From 53ef020b46d1c449b75df27f4b71df586699767c Mon Sep 17 00:00:00 2001 From: seeker Date: Sat, 11 Jul 2026 22:35:54 +0800 Subject: [PATCH] Initial pi0.6 minimal validation --- .gitignore | 9 + README.md | 184 ++++++ THIRD_PARTY_NOTICES.md | 32 + configs/overfit.yaml | 54 ++ outputs/overfit/evaluation.json | 32 + .../overfit/recap_trajectory_comparison.png | Bin 0 -> 89109 bytes outputs/overfit/summary.json | 12 + outputs/overfit/training_history.json | 625 ++++++++++++++++++ outputs/overfit/value_comparison.png | Bin 0 -> 47491 bytes pyproject.toml | 38 ++ src/pi06mini/__init__.py | 32 + src/pi06mini/data.py | 354 ++++++++++ src/pi06mini/experience.py | 252 +++++++ src/pi06mini/flow.py | 44 ++ src/pi06mini/infer.py | 98 +++ src/pi06mini/model.py | 444 +++++++++++++ src/pi06mini/recap.py | 78 +++ src/pi06mini/runtime.py | 29 + src/pi06mini/tokenizer.py | 330 +++++++++ src/pi06mini/train.py | 153 +++++ src/pi06mini/value.py | 188 ++++++ tests/test_experience.py | 81 +++ tests/test_experience_data.py | 80 +++ tests/test_model.py | 123 ++++ tests/test_recap.py | 49 ++ tests/test_tokenizer.py | 65 ++ tests/test_value.py | 73 ++ 27 files changed, 3459 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 configs/overfit.yaml create mode 100644 outputs/overfit/evaluation.json create mode 100644 outputs/overfit/recap_trajectory_comparison.png create mode 100644 outputs/overfit/summary.json create mode 100644 outputs/overfit/training_history.json create mode 100644 outputs/overfit/value_comparison.png create mode 100644 pyproject.toml create mode 100644 src/pi06mini/__init__.py create mode 100644 src/pi06mini/data.py create mode 100644 src/pi06mini/experience.py create mode 100644 src/pi06mini/flow.py create mode 100644 src/pi06mini/infer.py create mode 100644 src/pi06mini/model.py create mode 100644 src/pi06mini/recap.py create mode 100644 src/pi06mini/runtime.py create mode 100644 src/pi06mini/tokenizer.py create mode 100644 src/pi06mini/train.py create mode 100644 src/pi06mini/value.py create mode 100644 tests/test_experience.py create mode 100644 tests/test_experience_data.py create mode 100644 tests/test_model.py create mode 100644 tests/test_recap.py create mode 100644 tests/test_tokenizer.py create mode 100644 tests/test_value.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff92181 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +.venv/ +data/ +outputs/**/*.pt +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5ff69b --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# π0.6 / RECAP Minimal Validation + +这是一个用于**小样本过拟合验证**的 π0.6 / `π*0.6` 风格项目。它把 π0.5 +已有的“高层 subtask + FAST 离散动作 + flow action expert”扩展为一个最小 +RECAP 闭环:收集示范、失败与人工纠正经验,训练分布式 value function,估计 +advantage,再用正/负 advantage 条件训练策略,并在推理时比较普通策略与 +classifier-free guidance(CFG)策略。 + +它不是 Physical Intelligence 官方实现,也不能用于真实车辆或机器人控制。 + +## 公开资料边界 + +本项目以公开的 **“π*0.6: a VLA That Learns From Experience”** RECAP +论文和 π0.6 model card 为主要依据。π0.6 没有一篇单独公开、包含全部训练细节的 +基础模型论文;截至本项目实现时,公开 `openpi` 仓库中也没有对应的官方 +π0.6/`π*0.6` 训练源码、权重或完整 RECAP 数据流水线。因此: + +- 论文明确给出的 RECAP 顺序、201-bin value distribution、二值 advantage + 条件、30% 条件 dropout 和 CFG 被保留; +- 没有公开的部分采用最小且可测试的替代设计; +- 本项目只验证信息流和优化目标能否在几个样本上工作,不声称数值等价。 + +## 模型阶段与输入输出 + +| 阶段 | 输入 | 模块 | 输出 | 监督目标 | +|---|---|---|---|---| +| 1. Base policy | `64×64` 图像、离散历史 XY、任务提示 | 小型 VLM policy + stop-gradient flow expert | subtask、FAST-lite tokens、连续 `16×2` action chunk | token CE + flow matching | +| 2. Experience | 一条真实示范轨迹 | 确定性经验构造 | 成功示范、understeer、提前停止、人工纠正 4 类 episode | 无梯度,仅构造 buffer | +| 3. Value | 图像、离散状态、任务、episode return | 小型独立 value VLM | `201` 个 return bins,期望 value 位于 `[-1,0]` | distributional CE | +| 4. Advantage | reward、当前/未来 value、intervention mask | n-step estimator + quantile threshold | positive/negative indicator;人工纠正强制 positive | 无梯度,生成策略条件 | +| 5. Mixed warm-up | 示范与合成经验、advantage 条件 | advantage-conditioned policy | 条件化离散动作和连续 flow | token CE + flow matching | +| 6. RECAP extraction | 更新后的 value、重新标注的全部经验 | policy 从 base checkpoint 再训练 | 改进后的 `π*0.6`-style policy | 条件化 token CE + flow matching | +| 7. CFG inference | 同一 observation 分别使用 positive 和 omitted 条件 | 两次 flow 前向 + CFG,默认 `β=2` | guided 连续 action chunk | 与真值及 unguided 输出比较 | + +最关键的信息顺序是: + +```text +observation → predicted subtask → advantage indicator → actions +``` + +advantage token 位于 subtask 之后、动作之前,因此它只改变动作分布,不应改变 +高层 subtask。flow expert 可以读取 observation、subtask 和 advantage,但不能 +读取 teacher-forced FAST-lite 真值动作。flow loss 对 policy/VLM prefix 使用 +stop-gradient,以最小形式模拟论文的 Knowledge Insulation(KI)训练原则。 + +## 与 π0.5 最小项目的重点差异 + +| π0.5 minimal | π0.6 / `π*0.6` minimal | +|---|---| +| 只使用示范样本 | 混合示范、autonomous failures 和 intervention corrections | +| 没有 critic | 独立的 201-bin distributional value model | +| 动作只由 observation/subtask 条件化 | 动作额外由 positive/negative/omitted advantage 条件化 | +| 一次离散预训练 + flow post-training | base → value → mixed → RECAP policy extraction | +| 普通 flow sampling | 支持 conditional/unconditional 双前向 CFG | + +官方 π0.6 还把 VLM 更新为 Gemma 3 4B、action expert 扩展到约 860M,并增加 +更多机器人数据。本项目按“最小结构验证”的目标使用从零训练的小模型:policy +width 为 96,value width 为 64;没有复现这些规模和数据改进。 + +## 最小经验与 RECAP 近似 + +一个 PAI 轨迹被确定性地转换为四种经验: + +- `demo_success`:原始专家轨迹; +- `autonomous_understeer`:横向转向不足; +- `autonomous_stop`:中途停止; +- `intervention_correction`:先偏离,再由“人工”轨迹拉回。 + +默认用 4-step bootstrapped advantage,`gamma=0.99`。训练集的 advantage +第 70 百分位作为 positive 阈值,即约 30% 普通样本为 positive;intervention +动作无条件标为 positive。这个合成 buffer 只用于检验 RECAP 接口,不能替代 +真实机器人 rollout、任务成功标签或在线人工干预。 + +## 安装 + +建议使用 Python 3.10 或更新版本: + +```bash +cd pi06-minimal-validation +python -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +python -m pip install -e '.[dev]' +``` + +默认配置读取: + +```text +../../alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl +``` + +若本地数据路径不同,请修改 `configs/overfit.yaml` 中的 +`data.source_manifest`。仓库不包含或重新分发原始 PAI 数据。 + +## 测试 + +```bash +pytest -q +``` + +测试应覆盖 flow matching、FAST-lite 往返、三块/防泄漏 attention、return 与 +n-step advantage、intervention 强制 positive、condition dropout、CFG 公式以及 +value/policy 前向形状。 + +## 四阶段过拟合训练 + +```bash +pi06mini-train --config configs/overfit.yaml +``` + +或: + +```bash +python -m pi06mini.train --config configs/overfit.yaml +``` + +默认训练预算刻意很小: + +| 阶段 | 步数 | 目的 | +|---|---:|---| +| base | 300 | 先记住示范的 subtask、FAST-lite 和连续动作 | +| value | 400 | 记住成功、失败和纠正 episode 的 201-bin return | +| mixed | 300 | 让策略学会 positive/negative/omitted 三种条件 | +| recap | 700 | 使用合成经验中的 n-step advantage 标签与 30% 条件 dropout 提取最终策略 | + +训练应保存 checkpoint、每阶段 loss、value/advantage 诊断和最终评估。CPU 可以 +运行;Apple Silicon 自动使用 MPS,CUDA 环境自动使用 GPU。 + +## 推理与效果图 + +```bash +pi06mini-infer --checkpoint outputs/overfit/checkpoint.pt +``` + +或: + +```bash +python -m pi06mini.infer --checkpoint outputs/overfit/checkpoint.pt +``` + +效果图应至少同时显示: + +- ground truth; +- base/unguided flow; +- positive advantage flow; +- `β=2` CFG-guided flow; +- 必要时附 FAST-lite 离散轨迹作为独立校验。 + +在极小数据过拟合场景中,目标是看到 value 能区分合成成功/失败,positive 或 +CFG 策略比 negative/unguided 更接近专家轨迹。它不代表模型具备泛化能力。 + +## 关键配置 + +- 图像大小:`64`;action horizon:`16`; +- policy width:`96`;value width:`64`; +- value distribution:`201` bins,范围 `[-1,0]`; +- advantage condition dropout:`0.3`; +- CFG `beta`:`2.0`,位于论文建议的温和范围内; +- positive quantile:`0.7`;n-step:`4`。 + +## 安全限制 + +- 本模型只在几个训练样本及人为构造的失败轨迹上验证记忆能力。 +- 严禁连接车辆、机械臂、移动机器人或任何能造成物理影响的执行器。 +- 轨迹重合、value 排序正确或 loss 下降均不代表安全性、鲁棒性或任务成功率。 +- 高 CFG 会把动作推向数据支持边界并产生激进行为;本项目即便使用温和 + `β=2` 也只允许离线可视化。 +- 真实部署至少需要真实 rollout、独立验证集、分布外测试、动作约束、碰撞 + 检查、故障降级、急停和人工监督。 +- PyTorch checkpoint 使用 pickle 机制;只加载可信来源的 checkpoint。 + +## 预期结论 + +成功的实验最多能说明:这个独立实现中的 π0.5-style 双输出 policy、 +distributional value function、advantage relabeling、条件 dropout 与 CFG 可以 +组成一条可训练的最小 RECAP 链路,并在合成小经验集上过拟合。它不能证明与 +官方 π0.6/`π*0.6` 数值等价,也不能证明强化学习改进能够迁移到真实系统。 + +## 公开参考资料 + +- [π*0.6 / RECAP 研究页面](https://pi.website/blog/pistar06) +- [π*0.6 / RECAP 论文 PDF](https://pi.website/download/pistar06.pdf) +- [π0.6 model card](https://website.pi-asset.com/pi06star/PI06_model_card.pdf) +- [Physical Intelligence/openpi](https://github.com/Physical-Intelligence/openpi) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..fcd8290 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,32 @@ +# Third-party notices + +This repository is an independent educational implementation. It does not +contain code, weights, datasets, or other assets copied from Physical +Intelligence. + +The design is informed by the following public research artifacts: + +- Physical Intelligence, **“π*0.6: a VLA That Learns From Experience”** + (RECAP paper): . +- Physical Intelligence, **π0.6 model card**: + . +- Physical Intelligence, **openpi**, used only as a public reference for the + earlier π0/π0.5 family; the inspected repository did not provide an official + π0.6/RECAP training implementation or checkpoint. +- Pertsch et al., **“FAST: Efficient Action Tokenization for + Vision-Language-Action Models.”** This project implements a small + DCT-and-quantization `FAST-lite` teaching approximation, not the official + tokenizer implementation. + +Runtime dependencies retain their own licenses and copyright notices: + +- PyTorch +- NumPy +- Pillow +- Matplotlib +- PyYAML +- pytest (development only) + +Users are responsible for complying with the licenses of any external data or +checkpoints that they choose to supply. The default configuration points to a +local manifest and does not redistribute its source images. diff --git a/configs/overfit.yaml b/configs/overfit.yaml new file mode 100644 index 0000000..92a0a1f --- /dev/null +++ b/configs/overfit.yaml @@ -0,0 +1,54 @@ +seed: 7 +device: auto +output_dir: outputs/overfit + +data: + source_manifest: ../../alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl + num_variants: 4 + image_size: 64 + action_horizon: 16 + +experience: + augmentation_factor: 1 + gamma: 0.99 + n_step: 4 + positive_quantile: 0.7 + +model: + patch_size: 16 + n_heads: 4 + n_layers: 3 + mlp_ratio: 3 + policy_d_model: 96 + value_d_model: 64 + max_text_tokens: 64 + max_action_tokens: 32 + action_start_token_id: 269 + dropout: 0.0 + value_bins: 201 + value_min: -1.0 + value_max: 0.0 + +train: + base_steps: 300 + value_steps: 400 + mixed_steps: 300 + recap_steps: 700 + batch_size: 4 + learning_rate: 0.001 + value_learning_rate: 0.001 + weight_decay: 0.0 + grad_clip_norm: 1.0 + token_loss_weight: 1.0 + flow_loss_weight: 2.0 + value_loss_weight: 1.0 + log_every: 20 + +recap: + condition_dropout: 0.3 + cfg_beta: 2.0 + force_interventions_positive: true + +inference: + flow_steps: 10 + cfg_beta: 2.0 diff --git a/outputs/overfit/evaluation.json b/outputs/overfit/evaluation.json new file mode 100644 index 0000000..7d7880f --- /dev/null +++ b/outputs/overfit/evaluation.json @@ -0,0 +1,32 @@ +{ + "trajectory_metrics": { + "mixed": 100.86174011230469, + "unconditional": 21.51112937927246, + "positive": 0.061239250004291534, + "negative": 55.823570251464844, + "cfg": 0.24478793144226074, + "negative_to_failure_mse": 48.71984100341797 + }, + "values": [ + { + "source_type": "demo_success", + "target": 0.0, + "prediction": -0.007052943576127291 + }, + { + "source_type": "autonomous_understeer", + "target": -0.03799404203891754, + "prediction": -0.04714635759592056 + }, + { + "source_type": "autonomous_stop", + "target": -0.9509902000427246, + "prediction": -0.9405871629714966 + }, + { + "source_type": "intervention_correction", + "target": -0.0051981499418616295, + "prediction": -0.0118478462100029 + } + ] +} \ No newline at end of file diff --git a/outputs/overfit/recap_trajectory_comparison.png b/outputs/overfit/recap_trajectory_comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..3b81dd6a5c3f9ec5ba2da97e2d691b445b9a2cf0 GIT binary patch literal 89109 zcmdqJS6EYN)IW-aK|#TWs8mHoMNyF=9Z>-bC`y&4(mT>2KmMBeRgwdI%63OpJ!dASL&uPRHU&-;4KQtD zV!C$alBP%W43Xu{U3~YiIroWjRj$MrE}3GRzW#H4fr&{pG@k$Of7zzI7NSb+_(B&8zxVy|Xy*`ore53i zcOrJK(;WqTa$cQpPTtKmlc}Ju+hE6{Y%((=^V9+DjVv`h>^pXv{0zmYj`{ zS)Uty!>@Nx-^X0^WqRy!81AS*=tY-_FzT%mvS5x$6}!yvs|z!^bs~9l!&TVb-aSPQ zML%D1^Pa?m?@ejBcb2X;*E7G&RSxBTkQ+>v9VLZz+rx7nqPf1 zCSD_tPh%?j#+u?B^b2isCfic5;d7!c6Suj~-B)~Dv|VX2y- zJ=T@+Uj0&$cdC_L_Gr-|aqCqy#n5L7ueW~FEMH`VNO7}QSV6wB5^_oeE~#uuuW!hy z;uq$fK%YQxZ$q=2^gz=p<+KJg&!~f@p-Mx$0a`x%ojf`&A>CvG)T~Pmz0* z0&TIa(6Z(8Xun=u|jV zdgb{ZvYaNL~<@YMyl{oPh6G3J=E`4{@xC_I5N9I9ZTi*fVQd}&-Tm1IN6w$DeM z5Q=v67q@J#l;e}MH%OlDvRTAg4iUNSi@!Q;3848^3G&FfQ_5FLGj@o5z+}s(>R@7p zUByt#GeLbfri&SLY8IR*`=JL8--g?6DG*~SB87F5J%h*2G|T=IrEqEQwrSU?H&Ron z2?lz{T-#GeG%l8Qf2Y=a)7Qr7*TS$alS9`<-<;9!FeH5<-6=`Vtesl+YEN67%D|L1 z#$S&-Rq`?9WA?=wOC?o@Bj#$SVl+X*(6*4w|fE1)`keBG5t?sMzx|@+p%s zPokxLQ7hzv!5;m|Jo3xTR@Apf{r>L4`_KsTIZ~VzUUw=<*6u zKbpb2gv&N6?`EtEW6E0J5xWa)#?cj9SlFN1N#3tyLSu{iePv8P3dMSSb9&vaahkilF=jlQNnJ%z_R7Y%yiSm;T`?(n`sIhe6knu)BWBHY7FP$ zs?b~RC^(L<$?gIjRg3^GquJ4>IE5K6I#hN|48cP3bw7&_bvby=vzZbRp)P%LX8IN! zSC^*CrzN78+&F5(%7V?zOML2{ErL2^n=)Tts!Fd^^89fO-+2uVXLg zo3mQ~bBHHV=)(Bn)dN=4yLax~DNrEgw>^a0^V*sJ+# zjC~gltwe2_P`eG( z0=nds53h`yZMvcsqpL&uqqxmjp+?bgh)kn<6|ej1{3Es_~?9&dCVoQ6(epc1MS&dPae^|F`UU9V41=@A$ztiYNtl_a@B zM{mppQogeAFS@fwo;B_Z(D@LFC&@wKPj^nSqfe&98T1^x@wT|>X3oQ(-cZEvke=-%O$F>g*t{Hd2@x$5?E;l{acoH6;_KQF(x5aVp`9s3dQNuJ_nO zS9Z}oB+qTK%3co4^^(1gPxPx`i41fZ&6hcTF1lYjddannuC9#Qcu1299zpho%yed6 zuk+N=7F5%R3so@|#Kj`t`uFE?bt2e$_u*j4XWqzexq|GL=w9b0IXdIVq(bsnG3$Di z@BBzjS}a^ZQ?I2g&EjqXS<=3Df;wM!)=-}~ZE=RMu+EX$jk3qBnKQgNnG(mdiu&e@ z@IjN7a?t+igbFATxO)ZGNeAu5h+^q>)nSKmGwkbJXYP$~6o<;rtM&s5xg)3GJa&_z zD(9*?)0rdKAia;rz-uyz{zZ4w_4sQM$CFr-3my8&FOM+`co}CIS6wW>tyJVFxLIL+ zB0=+$y<3eXs^4>*m*I9|b^FyS&BOP-HuV$`jHYAT*6{8E`(j=W{d|dagFELL!h`l% zzO1q9T_0W=;K+i4g40O#O*cQHE8nLy)GLM=RUl|O9fpKLk_Ln+DHU$ zhHedMQYD)MGn9P8K>yCc^OJmX=zE0CY(pK-C9kAP)d)sYql2p;v2TJWzEuV zGZTCjZ~zK@sBU%a2kj^}nDQ~Qux9H0C;#kZEn#nvd9*hAE`=(ejz2A3(H(btaqUj< zTIF(==Z4me^$vZ9ool^nlEe{ordLzfnFLY?WrqfF3aR8H()S8&t#k}swMr>_Us7%- z=FS_o$!_hVlpvVU?(P>T%srPmDBohDlr zthp{eG8{-O{Y3xHePVX4{^l?4izn0R3F$SX%AtIu!W(-VwKB9D)S6|?YWJa2o*Xyq zEpmu6i=+?Fs69(LXS}!BjnYSi#xr`ntPLH7DIR*EiD|j=a<7p3B1=@%R#BdSOLpD) zk3xmiqDG9f?Nvv|lyGb@0cK%)YctZ$f^>)HY66x5<^u7uU-Nubti3 zFoQHBfs~Qt-yq?0^b=@SO#$D-gYc9u_e&-zrn2%WEzwVAqJlPOPckQ_2NGF_@HaqTB@6_EE2eZe5u|gfA=>0 zWpg#s zIvB`r+YlvAe(|ExXI^>1%!R!mc2Uf_y_(@M*_tGUE0-SYTYGM<|Gi{=DuYOKsGyD6 z`_Wg2InWl*17ikDJy-vp6Z)%lPWi>W|e*RZn0)@K$_x zn(nXh@KG0(V}X6ny8pDLd+(z_RGG)zLz|Aw54_Ix=j{i60JM)GFK=O*mp};cd=NXY zba9JKY14#49-rao*jRw20p5m*Vuu<;*Mfp3I)={7*#CK{QHD~yXLGj% z?}f;_8uzn&w)Lh!otON)WTwgfm96RMg(OsNX}RAgL$RJijgdM=lh-e)I;L%N5UWu7 z?l@}k&}XBK`{3!m>f2{s>N2BcIVR=!UsA%Pc{XpUsr<;rZoY)w-$4Jzce#}sV!CWl zwJsC3*Qssp}+qj>FU20oTKoh;Gc7QBXv;ZlJN~j_Bx=D-ja^~ zLIi`ls!kSsC^@v3DXf@IGv39-r0T(XNtE@vze<8tyUzVezSE9R-OZJAm_xjG#HtAB z-WO6FKtH{3!;G)2Qm5)_3_E}uwZ96r31PzWoOfw@t!NzYZgbp7~bFZ z=7UCelRP!ELNlONBtRx5{Mo#b+`8lDaii*+!6TXW4g=>yVfSeGtRcNwo;q_uXGPC7 z-c|g$5++`|;ND)wDhS=8lJsiCd9C@dfXh&vEK<%xzE8OhJCQwr3{HNYaY(1e)wv-^88hDRF??`kMauKH-|-j=Mkyfz+(tuppq zOAmc~nEzJWif6w}YX>L#8Wo6}x}N@JEBxbc7)l5ikzFucWqo8YE_ zE*@n;j>_wsG19IZ1=awm@2>Dy-v;1%_FVPMh;!)0n+q2-wfY9|W{XlwNxf`iFs7TH1T3Ovp8@88T z!JuK-ashbLulTl8o*kG1|jH9}37PB&ea{j)-1Rc9rA)Sai?6Qw0IziF6x z%OdhmA&G-Y!2P#G^Nw{RG}HQSRjXYQag5)L-`d}RWQes{;*Yu*&NvBV>ZKl`WL1_k9cj;@DPxWyM{l*uh zqX8c0QnAM+`#ch`I;-vkL-zpN1g1MnjiW(=AzyBYl>ErOoLTXEp}K!BR7&He%s&gY zKrzk#vrzNyrUOUox;!I~G6}q!+r;#^xOhAkCFW93T%PHQ7+XWGld-W=vUfnnao{nf z`P1d|o(@?lh#o)$K?Db4R!@;N{#4NhV80bZkHG-?3UoC0&ZQkZ7n^m89Ur)(#$(*S z6=Lb@)3oVne8xLUlLBLr7bs{EXk`6?{Js;2B8S$8p7(B&W=+VBVfeU|wO+7j?0`dq!sT&0T|!UQ1I+o|JX4sCzv&F09Myq-)j`V3)gXF3V$97W!0rKAXHY>?ubxd{^|Aa4&%{FP$+g zZ7&}O665WFTT8|$1+eap3H*;K^~ZQkrO^pSZe3|KUry!z;JFj>vJe@FeKcM1n?~_cc{SwD43!)1$|J z%pq{X8$j8d9B>I7B8HU5(RtH+5MJjnQXNj_L81GB^1L6EfR_CaQiq(ZnlWZT9T$r< zbWzh;MZacfOZ*j;x4*NkY(|s&%CQvCR38xLfR3&8oZ7lWT$2L-noU4|3$KdfAcY+=m}LR{R`_##~pGn~w*X5nueplJ)K#Dn^? z79zLY`2}-QCp(&Y3r9G(_gb@eo0?>R593bK^|fU6n5an;zL%s(e1;Ac^v8W}a%b-4 z&t@5vh^9Zzf@7N2fuImV!B7U(lU&$u(~iKW@Uvj4f4>RZ;aR=I_v6Eu%I z?A_OvCDVBRJ^?JFqjXaiCPcO(=;M^Hw!pnft-sKab8FG#4Sa+tV`1!G(e5;5_ zlLHx~tUF0BgkPTbn7wb)n)qDi?0T;l2$qp8%{=T$H{OL<_xKADcS>$|fJU0x>%Gvh z+=rs*QI|y0?q&8?$cA|Ge)+0jyda?5!gdmAh z4fjT7tb@uvgJ^LTD~r@ym^NPwV{_#c8K<;wohhi_<3=5#Dt zC_&D*WYYQ2%i)xYF3?_F$91U~H1|~ht3VEZw`t41vJw;-Q$f$Y(0Ea>YI!@;F7(Ok z)Zy~o(31rNYK^||WSbYOFy>xf9f;jc6FMJt&3((tr3d%LdJd3KSCt~Bt;@rC+HPYs zD&ra67UP~lZWZO|bY_Ql{yXeKJ4Ri#J5PVB5~b^g~nZ z!Bury)tPRFt_pXt>1g?p-BypIy9hsqWUpZR2mP%ASp4=%Sd2^jut8nU5^iPZp)9rJ zhq`c!cE?%v>BMlf%qowMwZk{(bJ%QQS9MJ&X5UDZJr=eS{jRWm#E2^U2 zd+fVUvM7~SqgHixe_Tl>o$m#~bDOtMeg)fRAt$`n{g$Ar{%aJU1W(G$dbD}6Pi6@J zVFjkgt#XLWe&q8WZbU~%W$rm}>iH%C2Mwu%w@%;qXEW2|nXA98yGrN;uMAP70tw#@ z4;|chVY>pC$vcQtkZFi@%eH-<{nT?Nr|P1$BHw4qI&{v#F~ME|tYWi}s|5$eC%m%av3$C;F!Q7IC>M_!5XIoU8K3xv91(Qqy^tYZuyN_B~ z_wds0WZNnp=bM=laLj+d!dP}MTC^Yzna}!S4yg2UBMZl_zxe`s%?~o5moL++fcc>H z+e=t9Mr+nBMR7ZnwHSHNT@}{-a$j4L^M8HL=c_Lbs*MwrciIq33lKr&6v#%x+L>R@ zoBZkon=;d(A6IBY5J3(2v9y-?4*v@7056jYq$U#}iQ8zTPO0k#AZrQeov0(Ix>rT{FoGao(`d4tj^-bm-Q-J+s$3dA#@8hrUg?CyKd44g!_#yHw3L ze&4~l*AaTB1=sUZL3w@<8gf6H|N7&vpZ{aYC+pEn6tp_ zb={rZYsD>LtGd(+`nV0E+=ZS66c~nq--BgauL|X-&;>?+as0V^iuMVPdO9wa@yCX^ z7c-`q!`|%^)~-LLm2P_V#lD}>&vafnHNMxNwW{s&RW^o~ z=K8<3{v^1w8Fd=l)`o8g#<^~pj{-=H?LWV67@;~UssN8BRt!ol#L&%1c11MgEjTQ`(-2UYF&`zP*5>+9)+2fwu zuD-jT_9jg7poS>a@*8E6n^dj6SvKL&fA5$SzlcqK*0)ix(Mox?&P}_714wpe4HFt^ zit7ipX2PE)ZqrfRFP@=;Vek2nL{U_C-F&-L=woVoBRJO6)(etWtv~osGdW^*2uLT3 zcPsGtsFrbT+57I#2EVdjE*q+(>;3*pcZ({(WR3>pP} zS-l>xNQUZVsrbKpt^J{(=ZZ7JE*f9WxWKP+>Vd)qAUmBwbp zYYy`|JL8Yegeo*{nvVjURPwcJ-S*vCWBHlHGAX^*z}>X2*{VdaMn zU#y+C?an_rv%Wd*2Ij}#w1$Ax?!~Gw<$hzwuguG&qW;oYo`4M?Ks$Tr?@dCkiJx>h zapRqcTHD`(#>A0?dU&zrMv*;nrtWNoiy%el=KK3l;IFTUgBJA}OE}#C?AM}NfC1!Z z)_6=GoK0Ojkw5E_dw=vTae45Tu2W(YTC)b}}F;`&v zU7dU5Cq=@l#Y=`qj3wpm&JSgs>KVp0WH~GjpLY9*(Z}aE4?(xf(#`pXRIR7-&!YA& zA|5_S1U*vY(R(Qkm)PT7ZtX{iTsCgqdgX><#HjTU5txbc;ytyF=9F9fE2|2OwNSMW zqQ~$HAb?{&HcO)E8*bovnf|RmVayd9d0iq0q=~pSIlF2~%e;fH@pyNd+@3m+$<1l& zOLAf>Q#GI;jO{4?^YFPb94UDW^j> zN4<{hT$8g_Sbcd_Vxu)nH?X^mr%p`A_f*jlS%HXU)@fSl^yq1*_X8gte zGuAo=kFG|IOW5~Xf&&YpRogh(K)V(_^8P{adB)jkmy#_k>jJ;7B3CeOOeBlR(D-9$NYu7QyilFDN zv4)Q%a2E<7D$%23>|>Upo#k|_bV<6a#5tvIi)(a#FnE?On3l?+%9WjB4t=szFam9n zunzPiNe|tF2^PQ^mcceGuIg^A^wBVT4R)xR$LTPSZR>fe#`UT~HNY0Fsz-qt7r`o3 z!`>KEo~Lx?d=CLP%bi(q;N8AI_d&|x=NNIB^qH}9`PHw)tJA$d%B93FVQQ~%@JhEc zx5gqyOf$VfvVZX`_eIBewvNlpG%s~et^3WGCArzkEh0N~Xe*Vi?OXoY;mCRNvsH>y zClK5UIWXf2fjy&TBEOJ9+L4?4vG?uk*L#+HuXc;<)9RZwO39Vvv&-AO!lAIyOfH=k z(TBT~PMFj!hy4bvG*Gk77+SM_zGXPF)H~@!X#D!VplD&^(V!o=eP!nA1bcM@W7Qn* zCkIE6OepN;nOCv!@^0469HreAW7Vx!Ni?JIhD`@m z1xezYGL)yF$8`lScci*$#owNs$VBuTgA-?=QMvjp0zEX;m4ohze$3yQ4ag>&r$9N6 zrRZ+80#Gfion5@3}`ch8N&v8sM-Rk+NAOVg15PwzUFGw*%3}FeTdQ zMMeh3BvF{j-%4&u^>z@(Bq?JXgg!>4_bk%ytwc*X_m)m|WKDO>fl-Q8e^y!Jfj{}U z3<=i!eqFnKn4$2|ZF~Y{-{ag&%>AhBT?bxqlyxFbxd{zY&<7(-?P3`un2->ej<2%T z30W3)9>-0nR5v5V?3N8bl9<0?%qG@EtwNQ^GqPUUAdHC2toYMaSA;XDjFE_2-GB*( zYNxlp#m>2aE5+wMdr60V9E2GcGk3@V4NLDwq;0}(M-YKhyn<_~mST*c-GZ5levGR* z-3kF4pm4=v@5JB!gqHEo&xGD!)klqS?|XEZAxwnG97gpo3;cVr)my|)l>u@SanUwh2SE%wFL}x^VK@0h(m6UPB^_9Iiss=8 zt|6PJaCNLJ-wW=R@CD2B%X#HzJU?4XQ2Sb8J(Dr47%E7_%ycs<*`Js+z6%UwBKzJpd2>j3+={67CGAZ3hHk$DtKS$RT<3ja^pVS+-Qri}64ycf!`HgAgj9U}AAmI%gKPUDZ4{Cuyfg75^#RUOf%pV!wvKljnfQwST|w#~gB@#@{lN;TJ2{@L>%6?&~~e!Rr6 z#H^^m$J#0R_(;RMhZ4A3`Wf1V@6Cz=*tqTmuyXb`a1Ul5ngyQVEIzH(nyiB{B9~0z zGq8K4(H=W<#z#+7SmmMDQs<98bH4YfmSX?D%)=Jq$ll1NbRKM zd=~(PPUtn^=`nqblsMn{!1-{pju@3cWQt^CikqUa*uRs!r{E~Rsb!=r{Uv`Ek{ATP zdQkc>r1Qo?K2E%sg+^x8Z8J|~&{rzR?`5HATE*&&zbZWDsBncEGNSl5otk+0Fl};4 zxLdLU6S{seM!Vup$kUC_0@P1|d`^O+oWE?YY$z}`Oc1{Tg|qF-r5A$LkPFF!l4jZq z;cUla=9-eeP#VdP;(?%)#&1B?b`hM_`j+fVTQ78-)+Xr1D;fy)J?S}Vsw4C*gjeQH zL55bwTee>DTBDdv>2a74mVik>R0T-mvu;lvoLW@LGj5qdU+kM$2hph{ge|z5koavX z{&tLy>&;Z>5rAQj66VCiv!%~`PCB#!L8AQfxnJDbPloc_)SZ)_xtKlH@vmZIT&=Q! zOohGZK&S$}Q(bOJ(;XjhoPz&R39fT~6+gPSe|v%kV`^V>P8q4N$6!q$ivk{qx@*$b zdG@~vT!AesvBXN>dxbv>N!R4M<>ZoH?$4(-X2+RSX-71js_#&JM3iOqK!IYp*(>yV z^D7|sV~sI3znk*D;@Uo7M6?N?$5WRP9S|iuA_SH z$ki7=Y>5bv%l4ce5BlaCupyXjZ7$SN?Xl1E^hr~~2{Ob*i*wIFi+Cj13oQL(G6}LQ zGazp`3D#;EBOuFNu$xni>#UKi=>TTwl%saqfykT|&P`FX5}H`3pxp&B5HS2!*cOB; zK=NB`g-i7AzDlXhpSv)Y%L>sT+jhapum)C|^Mr-t9tbGC4Z;eE!iV2vogATb7i3^r zFU4$;_66#_j2QqJ#>9H{(4Nm@A|%B}ui3jOZIW zI+pVf3?J>fsdH!I`cB@L-vc>%4Bd&5lb)=*Xp|4p^$)kNN9?$ya7kg;*$o!9AbucG z-iTnqeuT1>miq^3ANiPX8N<6AkX?guk~>smj8&TIUa0w%vBYn$)NPuwNAZ+lqA<7Y zsh`eZc9n#1_=ISE+g8%lXK-NgZt1_2GU#>;>oMEaNTHJLUJ4sl0cQ-#iEd;W%VVru zU!JMF&Ak!D8)+iG7TziL?(0NL0+ExYbm{@rb22L%enJ7Vv)k8pbN;uBud^;t-BPO_ zT~4+dG6Pxkxum4y4!|0=QR_JG9d79LoU5slI5p-rZ6cD|e$8 z`%ZrQ^z^@05vGFl+kg!uFu3!;Ie{C~UXUSsSZEj&^VS2}LQrJlI7s*`OnnSC^9u>|;c=lp0LO#z}<{o0wjgVHDb zt$h6oYzWVK11%GaViC!K)=q!c6IeN%>N;s*SHuH~NmdV4O731@2o>Oe8?mEuX_{y9 zy{GdTQ_Td03zSa;Oa3EiR1B#L(x{}~k_&az7oGp@hdt5XjPZ8pk#LpXCP*DcQuQPQ za_7O_(gQgK>A{c#Ip~fNJ*Q*jnN*=>J~dYa{(-d1#IE@g$X9fCS;WOycY!nR07%r8 z;t77KK2V4(*gVV7C04f#3B`H0My6Y-?1IvM-{`ftHu}f5C<98_)+pK zURr*ALQt~*E23;dAltnzU%dd7gKVPD3X!a9O_Y8MZfFcJLg9wO%zFj3*~LSwD|Bh{ zXl-O3aG=04>m!b|6{33_sv%s3Z!)`86{Czc?=zuqtSuwC*wE1BL8$!fU6Vipr3!!r ze~vt@*9!gH0?vQaqMLvlV$2|~$0G~Misy(U5A((jU;lK|r(>oG!|*XEPSgv{vFgc; zagd-lGHBlGm(9iXQ|wk+#LauO>D1NK{pP&?+&c}-sEN6obwm7~{w3R%&sT}gMv62D z5Q0QkKE?-v1sKKoOHk#9H}}W+uGeG<30Z2c0{=DvjF7+2^#ATpZ*pN*z7zoP> zH>?Akbb!v1#R)}K^%rPf3 zx@|Yw9;#i+yPhMvI`D)%4su+Naj8ng{BIz48#YKx2_{1m^xyo!i;M2is4cGx@c{ zy`T~a;hnA9YfUq<`ozNLAuvNXUzS_ku!!>+>oY;(BH%_9hp_pmZ?5B6nry4eS^-_h zJw~ZdUU;Gc1Y26np$|lMQ{KO7eArga&lVsGWWRc$e2)NQFW`9UavguE)DxZf`YhaO z?{qT+p+fuRm_9Z_9W1DMdfVxR zJ*o*`%h#ATL*n$bX^c;>5#daEylw>6{Ak{EOVARc`BtS5Vs77yXn&ULPU#b?D|77a zk#rEljcrqPz?QG=^RGL9^@uP8rg7T6 z-^T)Ax~oFqJ$LmP;cp3leU-2(UZ2GlG9Ajjt7|)_HJVB)%I3rB+>+1JY%&@}i&Shl zNwPe?UQ`CD!wwKJ(WDjCv=FRO!ZqW9tkOM~oLN${sn8$EwO!t;gMsIbZw-uKS!baZ zd80sjF+|~qIIc#lSiioPrN5WX&L`W61eapla%Pq55hR=~ID!EGh^-6|KJ7(K8+Omm ze&^5!30BF7cURhsFGui%4olGS0Xqnrd@QQ`SnUb(1li^lA^OOLm>@ASM)hjmV4|)^&CWdL6j>F*_?H25FOuFj>4{ZOeVd%bEX2Mo0-H2^?61n zJ5vBLs#&7_O8%iiKD$8GFYu+ajKAVKd=?rc0}YV{|2-b{`+LrB!@CkvCvUqpLsu|C z4Luh!Sj~Pd4c?X8;o_+Yn|&xpwN+%!#?>tUX3{;)WQ*qbONEYuN;1HZe^00(9+aAT zM%?Lr%Nmcf!-p~D%TXV7?gNE&gN|PsK72~9J=d%*DCBD5bjzbP2!r%aY#_;h zh8J_Iph}1f?@7><;s`7 zP}I3==`@1yME0#&Dt5Af!!YK=%x|V{WpRkxbo-q|1fNM zdtWdd_u96T(nee^gzwTZ+2k z1`tROw3y-YgNWQ)LRtlUE9lz*D3b(qtLV~`f0Fd%eb!x&;fySh3+>AxSzSA8&<7B} z4WcdgpiPqeSopqx4qlb%a}h~T5n&?J4aU!r8AMbhfqb416gAmXt-_aP@P&Uh3S@!HmwX!W(Y)gdDN+FuE1YwuGdr#tw+t_jU$PFyQVPeqqEx%q?9r?cqzVvu-+D zx3)?b@U9zJxLn;d1U}lIyQU~D=5H*xR?cL+@=1A_C2%AB6}_ne!OPDl;3hxP*<^sY z9kKca){)4BkzDKr3-!`zjY>LL)%PHlOO#$1rMlPW*A9u~RB?Cy2G$k{GXp@aG4x+D41H4>ibXNNGm+NPQ)>YSgT$pZZ^WutCM+xrP*skFCg z7e0gY*O96rNVpcCu{mG<79~X0>mUoL-JN!tD3(|{+44k1#udL?e<*< zZcjo=Ecjr+c&VbjEF-eYe`m?{4s@L@IXuD@Fp-|cZxCqcyd(9>OMi1_a@2(Qxp*U- zw`Y3G@F(j3RoxhoQ-p%*0>a`Bse$>@GG6&W#6n^geqpKDC`9B$WBOjT5bmWW7Sli zW@rn&RF;sjm3(_*w~zMXhvu!gYQw)(H_Qcc0=t)N_FZHNr1?#2T)YS5lTl4}6gz~4 zlJnLOO3+|!M(RJ<%?@}|^u(t?1A)vhMol*U9Y}f}4%YZ_JuQ^lSbBKRAVhi% zZdDk3y4PR;4TH?38hwZKvT5%g4gV?$tAONS2UwZ}?SdgKoH8#f!3dQ#?FiV+A6?G6 zhS!T%e}N421&`_UfNi8)MwzPY1ic`}W9Ye_>reKRt!Vw zy(8tNh=r<_s*iQETj^@!WaFcxvjkA1#0Vd^-fP#cbHTMGWSne&ln`=CZ8Ogcg5D=# znQiON{Qm*dn8oAmJFA2ph689{W0=!zaG-?jARux?ojPj(GLXqti9!rFu11(3Y!BXn z(aws}&;DZAlUZX0b$t5zOkxTQPAx+%E76$dGIR;gP{9za7T1vctiuXaPV9YS!Bzah z0S&8zkU3X%b={F#A8d)jYkaWft6XVDb)xoZ<$XEfeZDvRHd_g5I(#@z zZ?L5pd7*Ks#m~V>7gNjwFOAoHPq5T z=FW^h;Vi%P1E5KX+NvwQ;!rwyobX`RsR_aa1L#1hI#*2FV(n4SDi`482gzHU`2KRZ z*Xrih6E;u127X|_?z6PXFF0K@*Gx%gso`{1nLnvlM`BJ=`u4IIJc+oh2g-MC1X`NoU?YU-u4s#q9H|OR(b) zQ68l9mb67uw$FTNN%f8-w|H+WeW+Aa={mJmdF}Bi=ncz+evahr-mds6BnctkR9bsP zuJ8QL*j}!Z-_H#|7XEd}RGh^#u6Y*qGU2{lAbP)CMf;-T@68?hi3z8jI)A}&Kv9xQ z5lOD`9=Vr2JO|q+@U+=p-8v(Z)_&z<*83b;+@B(8q}>i5C94#V7R+AwoO|VzS;Fqc zKAh*iZzwy_9(f+aV~3+IAAavouax-Q!iak84(gj#H>$2ogTcoe2U#&}4GKz*kEE4@ za+yg~i!6IkXuF~8pS{6!gnf;30wqo@xY?AZW|OIT)gwte9kFQq$$p#TX^DXw54xjdCu(%-(jphEJ`P`=r{CFa);i z=W*5wuIgIDzA!;fAB{@5zc|rCX5AVv`T^FMYs}{LNG1DAF#k%fMQ-WwNLbd=R!MTl zZOqqc9%evSCiBH1;p|ax?+c*zl{|l0U>N>%!>>wnudv|Ce5)25@?Hg z$ROWuZaGZ>J_sK^=v zRJ*9*K;L?-ye0{eVZ6W2u5x#ZQIV0Wz?I9$^szO-+UpC^wOdYp>1g;^tV0<+!#3j` zro!*j&gVI@o9hJwHfkXZo)tNqirjl3)*`ul?07TE+SEtDHE80+Nb}X!@8ckk0AnY>y2OuzOkB7CKORp!0-zE@sL zb>go}RKt(x8KrEt2~Jc$JHkOY?+A~2C=rlNIWd4&okK?E@lL@fbh4EThY)x|y;rT8ek>@GUvigXOxo9l_r?PFxG!>{*VHz9DwaA$3`raIoP)JZXEcxvT8NsQ|zz>*GnP z<|x4pxd;dWUP8YhI;Y($}v1O_Z>ubte^Z>mSr+k^z%tP zT#;%g8n`hhVc9GIJoaPxe68?Y-gb?+IbUSz?&f)w|D?8o&nv5eJy$(3r>sqq!0Qmi4B&5m z3o>7L5~&r>4Ahw8U>M7bWgqnO+;Vvq)^$icJm3bPd+g9(>?pv?y2MFFZSpPtqHu8} z)O1n7=@$q!5m}_Yeo8YCKJJEYNj8RQUU_7S%b@C6?daCG^HLz1+kxht-QEP!`2-qC z)GUL#ppOh>s4l%yuCPdiz=KFolWIPtN49nuMmXGJ*pY!HBqD{lL^qE9jd&>T|Bg^E zn>SvA-vaL9Q#icUFHtyk3{sn@RI-eIxmN-5 z$R>!-j$SX{is=0rKeKbsc^R4A-$%mv+$00XKmPe7J4vdiOH` zcF9(l-E@d}Kf1&K@$s&l`?u{vy0TZOI3d%AGT{C{K;oC!+D{y{wNU=P*cz6a;P(ZU?i$fyc!akpyCC>%DASTd zoh_z7d+*`ep1Bu8(rM=J?dwSakEw%stSjf%Jxt%NW6ql=qpdW30KT2h(|iGzi5!xi zRN06^nh6y#0o+>8-${8PZ78j}1$J2FKun6Eiz z^^IP+lsIxDM^wD7^9NQa85_GN@`i3v4)Ju+?fZx^fo(m`nkfD}+rQDOExDwH>}RGA zbIyFIG+1@PLF~%SXW*+V5wWX~>`>+nTHES3QfE(c}LYd+!w$)w+d=B8VtP z1VqV-0s@M%5fmgVAc|y4vVo)|L2?cv7zm=Ek_DAqMb1G9f+$%iauOuxOi@(NU&V7y z-|mO=(07bGZr95mW2;cLYOVRt`K5``sIv{2#!z_*9~95(vhK0N7X%x;n7&6G?`{<0 zHis#^OeZ=T=HgE1m4b~0^nEu5Ok?Oio8ZM2qc3*Y?J&(o;LTLn-Ez#72|!eU85q4e z%@v=*Uu+}vydC`YDB-~}MzB^)lrkt1?-F?UYcW!|@ zp6}vSMVmyjNqsc^`t5ApTK%GvSxXCQuP*F*ZyS(pq(6A&#BEzjPfqrw+An;%`HiTr zBS0-B-pc+hV6&`TKtfF5Ef;Vy+j1E=c_Z6)+J?5QZ4zdBoG&60x#*bW%BQ!E@47O; zdcp*{h20Gv>tkF%LO;@R2SGT)=lx7Vd=+l)ps>=DFM~cz{k=>1u5K>hj{qn=8xy|* zAV^OGPZ!FLDr}Yv-;QC~9pkqAW@4vuf|Rjb-POAABTDtHD#2^o0&fdKr zn#JfDHPg%AQ=8l<|D?1wsoKM$Ej~>eE12hu+qZDWJgwy8vxWjCHIESUb?V{ zwl*y%T?&v*F?p2dZs8}~+zS)sM}Lajt)+Nwr-1UHMKJ@2iGzE;6^D`^8zD3Wqb>X1 zcpQ(LQ7BFP;PUw3iS8QPQ);_jBVir(;#Z?rdOu~`Nf6awsj4*wad02<;sI;lVv$lD z`qFfSF{~yaEVVS?V9_uD+{XcjPob4r*ptZVmRPvxMR?bIIioiLvfO$D3^6rn*BL{1 zHowb8=XZEG>CIarc+l{ZfOFec^QRjsLox0yCJP^3fk)atum8F?0Qh)PR(+oEesAo7 z19t9?FBm71Y22<~bXHQVORmKb1j~TT68yHe&6n#UgdH%|$LZKY6uyL<=s9%+SMC)`!-0%CsST&2pzs8xp>5hj9@%1!MVF9b5iq~&(eCSG!87!A z_J9;~*17)#HN^@@C)SW^&jZaNG2Qs2Q1IUiz6bqBUcHr#GsF{ z1J`o9uzCBLl~oXi)!vUO*8&=;s~%zMt@A+PzUzqT?*d~>#P~Vj7cqrXK0!pNX+85^ z6jbZ4SkB-7i2tA%5sbs1Wx8Nv81Cl3>_&Q$SpuAFg;;_`VAU_7*pVi-JK;y1>mx7Z zf}_Dro1R#TSvuHz~h)^AiVQsWXzf6{kXAlp-Rk z0iq%G^xOizw~g8b(CufPg68j4LO7k!Y5|aaAGANH)B_Qh4WnBZ!g2IdCKWITwufmW zC$t-|u*(427EGx)F$Z+EB|3&OP%r5>nahDHrl;mXD>eocG)3W(6K$q0IU1)KYKqp` zgRw%5XPYat7{}aZ`=v;xo61u;N}X(aC9%B6Z*>h0`ztWFj%6VTl@*Vovy!nJjZ>%J(4Y&D@oo9K+$dU9tUia|xNU zJTVaVGjjuaq#q?03hKdSrKh))k>dXd5ph$_gsbg7YzqHyCKJUEuB zyA{9BHecm0xM~sCl!D=R_(J=q_!?3vUJ?ZrF~oMhX-!h|^I?|swGwo)%Ky|}z#`?b z2Z>HIZfuQ^hD>#>vFU7gf!O1(Y_5F%pOLjR!~%cgM*SlDw1}xffSb-n@S&hy^a^%3 z8z=F~arP6Ho*tj#lef+!7y~n&o-V9$ClOa1tr&m*H8OSv@Xwq;sXowjWP|MkNunWP z!43HkQNE92z;q>B^Nke$wDm{#2MEav@y;O{!BM z0<~cwd(I6a()JMm_irIvB@}(F%>RvtZ34)&X%ym#QvF<>tv1e4&5xUOjVu%p;Att8 z>VBK3c=KmO)Zr#J6L?&*a_+Wp*6o6pQg)sJG8mx&HHO9{pi7AS&&RUimt7pWy_Zoi z9fbUy03aE@;8M!x zNQ$9z&}!RI4q>PN2`6m{z$T!QP3_}(crAu;PSl`{P7g{w!5I4 z$xL&zZ#kaQ84aFgUswGX7gNZqp-s+Q6n~@D0F}c6d>xi?_-AlDS5H*~EL5J0T(Kau zJTbT;Hx0L=9P0j;BBh%cay&?z%Ww|m$MBadHZ%8%{QAvg{^W#hFLvChR$<|wCbY@| z8*R9kfGhaPDEjmD9o186=aH=Jp@l(;=Bclz^qt2}2z&g5tF3wpb#qIjO%)1Ze0Sk% z&Nz5Np`DUJ_a+cS&+ZZ7{XIMK&*Zaz>eFvmJ<&pC3r3h_ZHDhuCV_~~ec%zsi>YV9 zL*D~YlQ4{$f&XY%gxayQXO39yF^B09IlxFxP===U#jMpnT$|(P*DK6s0Wr*Gj9eh| zT~H3&8w>(!qJv5agB=P*C_u=FJxq*PyRqgIha!Godr$5hTt}}09nUS%l^NS@n|QI( z+!sM27kYvXES4BcgtWNc;-F+D8v%k_@U>P@TRdcWF0N?`vKO+E+5!#=Q7oVEtwL7+Pt?#3E0lHUWJVjd*C9<7b{?e>n9*x?TFad64)b?;~dLdO6@ zHr45j+{BQLk_%JExyjUTJJfDC+8jSxXtLIY` zoOMhgCi&-ImsGd^P>AI$&A{2;AIH(W#Gnz2Vy=El>a9uzEYnx7H z6aO`w-(ln&e={q5pkH+7@qae=amIjYxD&}qSk2x&cz*2i?cC`_?+pMVQVtc7+w*s8 z1lWhhBmr;vXdBih3EGIl*n9vX$8uYNLGf3)`x?6Hhp7d)l{Bra4?3?DmQ62}@!UuZ z_m$(o+o`z(*kW9?Aem-&I!JjB%@fak)a*(g@#!*_PlpDoL-x^T*6-Jm4Ovm$aW&oJA5O51?~IkAQQxZ$6b`Fue$ zekrJN3L{vnJF|ydTuf-Ii-yTLdd{HGKo z9-KpFDCy4(XgIS39k_z74Em05t)Ld)?Ys^cl>1aoX!igl^xXgelp|;GYvhU5*mq8aYB>UA&y-e1=&knC=`F(?8|HXbQ0yLrp z+KX||=NKt*O|$U%?KJoV$8{GJoKF~{+yJCziC@$>CuOlD4rwVi3(Ak;Y37j&ljALg z8L0E_dLUGzY4{gzwlQ0g6s;EJQTMz9XCMKc7mDSf98s^(j~~>yA`{=Pp{;|v zur~Azmp=Qm%N&NdBg9;C zdH1JvP9xt*%EBgcn<(oaEvbFX&BxH{i{OKvp){VX2Vf288v61=59kJ0ddhFqYv-CK zAkXm*;Ij`VKnoIB0`X8|mM?0T689B*8-^x+@fV>Jq``V`0(x^sqvI$p8d>Rn@VCYX zZlm0QP@W7#uB!5M=0XpOZ;irmRWFVmiFpu;UmkCxVpCI*ljqj=yDWP`J5Ddi+g&Be z%I*gDBBtPYnq0L?lpv1_UAxl@EuNF`oUs*TN`WsxJq+&`S@rGD$tubE58w_A^H0g| z`}RSpu$LkN5Z-O$J5BwPzhLMf!z%9(Ljdb9=baS^DB(4{R++n-1Mb2J`gxZL&8Rg~ zvucw|6B>1^fbexyKBgspJ$D6*6*2`@3t34|shuLif4czbyk!z)7Y4ZfC*M};s;(Tf zU!4$B2krvJu{U^`DC$obMn{&tj2diDMCgxyccs+Q3c~A035A>70>$|B_#VACZ?Erd zor6QHx+gPLmC769NQAylNZ0=rG6L1iS$pbbnR4=D2cINcr3&i3Y3c(WT>aN@wo3M@ z@~K}iK4>CZMuR#HVX(*So#PqBniQ2Ocyw)E^oD^H1x3uGeE05AYf1v(KACrd#M)5m z2*Q=t+()S&{t8;`$GiB;j;3q`MK3tMAdDw_FJk$WJy|S|m&g&cp?49a9fy9fttW-{ zC>_7)e#{f@tckhKuSk?L)2%F513m!r7o3VVENq3^Z-FWeqwlS(>eZl<=i35|MK-o$A+MW5$_ITwJS>F^*x7ksf0CEPag7HbJ)BAs(TIswJ zBkrbKa5x|sQL(yWYlta@etuq(I`+_|zv z$jkm&ak|hNl=+AD03-z(089r!n%cXjRj>CChu{peThTwjb{(ECZkMT7%OnuhU(XV^ zdV{sC;jv=rt#LnoNMYr7fgyXd=Rav`e|qq31DSR;OLqxR!R5WAFbA#9=Mv6F$X(Fu z*Mm-p7cApCA7*vb2jwtS+>31I?!APb>ndRI(11lw+b7XV4&-8@IS(e6;A~`^*+Owm zXr9TQ=Qs7S2CPZ|H==uL6S<>kCSW$Oes3h$c`-fL1)kJYcq>W06R>LI!3i@#5jS82 zEr$w(!zI*Rf@DiQfKHg6gRE`F+~ty=dm7zQgnF*)8YC;q{fbQ!u9Y~5T+JA&(fqxT zVqX_}FnrG{Qjk~Qgno3I#}`ewpot3;T%a|jD_cIf`oz~1&jALq{D_-KI=&1~zZSGW z-36#2Yk7IRIrQK_pizGF#lk0209lOt2h!Ta(7h+l_3j*ER@6KOEsIiN#dND?3){Y5 z0)X;=696nv_n4v3YM0bZLF!9)ZZ&?FYAZD+_{+G2uB+5|7<3W%xK3`sAgFSU)1;0^ zaVPV|H&(}#->}#1Jp*wj7*4Jurut-V7{LO8+bb@dTv<0bfBfDgK_(FUQ9S(l+P#RD zqY}4e77iwA5<#B%hR#nNBl#^txB-xRAZMix{S{k%B=IW8dZK)CwBLgOQ^~@Hb(3K& zPs+Q4*W`7-Z++1qLH`Elq(iYV$3PU2u+w3CAa9y>Mp1Il-=F1F3B%1`$u6?NKapFg z82;BuQvc=ZoOJTJ-akc`9nU0Klj`P@^+G8ymAE)&toGq`kAs1|l~!~91h!}rOxEcz z>uFogqB1D9(tQUU$hj;iins&Ph)noPS8vxWBM%uum$VOA5EhvM`9pC$p?!{DD=$6z-@j0E~2?E(*Bffa&Xi?Fgn&sdE5> z?}mpqYJ3ebbMV8AkmI4UD=76};vniZsy%0TRmvwPaR2Nh(9b(en&v(jDU+aGmP-YI zbQwgah3O*gK;4=HI94#2HNLPm87MlPj%1x|^Iw77uduuW&rCDlQ&5{CEfVDObOG7< z{2n?JXUdmR{1`ghGNF8q-6>b7XIns@E8|`wi@RJ2_3*HLpYMfRqD~i26`b}t{!||!87l#`#AJERU=bB@Zj<_LO_;h zba7f-&A;=o{n*y6+#IiLFi^_S@3))aBeN+lOX9fAbLxc-cRvu(NMwjdC9#uLy@wEtlC_@^mM>Sq z^*1w~Lj|{Bcn?I9Sia2Hk6)jn4)ICa{tb;Y3SYSwmn}Pmz!iD(Fm}n*rNUwa`2Cq^ z{OHoRX)|u51pidf_Eu6YH`6=6nXV!;)w4Z-`V55-?>^OkWBTItx8XMoTSL`kRc z3dCiK&4UQ~Qa(cs^}l4wM?j{`KEx1uS0xrFiLzuC`AHzKm_(L5Dd+y0RTML&(&wxf z`%CFQ=o~fP=(6Y>Fw_e|O^-Jsbm}JV5cG4G+$hv?*FIlZIWnkFnHUMth8KSrU_->H z*G{Bcy1}t0-8PCOaXJkMwnK3{$tyPiEZzNDBl>fMzfh;!UOC$K+Am{~KWVTp+ zCL(ED%wIH_Y0Fg*0Joss=;$h1qJXqS5Y-l&5`(h?pr*yn`BFJ=#JeAK*Q%#rO3ek( z=Q51w4pIp61f?L&4AsBZfPI1F+);ytFI;ykfE*wCYNDC-p`*tkp)9&KwC;0Y&SWNJ z;xf$g7~&I`;M(P@Odv<44)%U`{5%i!efr)M=C3MpEJi?s*v2oS_%hihmnnMYmC244 z-;LxcY|8f9uu~J9^%`WUE4xnP;w@B^JwO)QfvE1XWBthXPFtG1=Nf=~L=F;EohW00 zUKo{qyOk`ik~WEYv24tXW)bw^6V!g+Ce56SEf7gmk$VIpMo1Zf1aJ}blqMbW;OnHC z06{@SJ7z?SpbR=#r0looE$(39@+BQ`(fMuul3Yf@-4T6?Q&@|Xc zl?n@pb-F1wfZ4jo&B(5Ody_M2l=Ae#hcu;%YUu))FEra(Mxu<9Rt=0%q1*Z7A~cKr zDxw#foiBNnwLNbVAG`M$iB~Ne{fRdb9U>YO&Ub-PL{_8$(9Pqc4aQsZ4WvglVwUD9 zkQMbi)VgCDM_5%HLB6KLQ1fKkPHl~;H4h`7Le|0S6fd&z>0CEDmxL9>U(2pbz;|^k z?_BzJWnSJ-PAnK(hgh#`XQxLw&6GS`tA;Qw;#AENT<49m% zcQUqV+3S;A_Ebev>rS5M@7!A4Q`Ah5tNxxZQjDVPBK#W76NOFF?Pax-iT@nW=L^Ep zHMSltWIW#8Ng+)m0^NEF$6&Y__RC_av8^ikDL4ecb!7d>+O#P2(Qf-@)AtCk32QUOP$j=XZC)uOOcUf zq0|nN8+IH*O7!7pheE9&9&h;SG9Jbeuy|o8Z@EYvxtFP|T6_npsJkXV(6r(<+A<2A?` zzu-ET!|Bf7KiG@v3$!NZ9oTen`h>)TB0LmyNP1uoX2`XB^^@BG;V#5Sw)PQ{4aG2D248Xjb|t{v~v7rJ5JecwDB$W=LQlImG^?QDSsAo!bs2y$8hjzbQyH;db`*Z@1;0T zAqQpru*PlKbG`KWE@+JxhSwcb-5@p zNorgS_sqz3tU`KI;X?{q<4j+=vfC*qUN2SHb(h#l#S#H@aiPen$PP#>tFd)ak$V() zKiD+#d&+>$9q>R@5B%t}232MC*|{+UL%^?*aY)eqeD=9;@$U~r#2HVrJt_Z* z_65;Q^W?;RybuX|SqC~8GC2e2FhqOGQJ(mJZddN~h~?wqjTf1(jsLF6LPp(*)-~0u zB&N!G<&Kx~f@{ROhfX}DLNdPECn@UZz5@Y*D3_{P?0VCr88_qHCbTN~MCyG%E)x>2 zQ`wzQJnb$!5tLn0?oyHG8g(+Y4K{!lGydTi1Y2g$)Rsi3K8YNM`sQ6OUfkIWO z_+d7T$v#yT6CcjC)(j+Vi53-cd2p*yX^i!Ezsk-*J;||m?XtzfjT(M9v9eOIg|z{P zFmCPvkF35YJ_q?8z1O`@TokSE%fLU~6G>JmLY4$e5gYY<-v%BS+Os80C8JTf@X8EZ z@Y#S`-pEjJ+stJKy`g)VRM$U8;FDZ~?rr|lWIkGO2(ldqvz|=V@kBCf5%#v!wJDu7vyB-x2Bb9;@Tm@ndI3tw802k5ugehs zIthnf4#FZq)$)A20H~-=fTB?-jV8E#CmP@Sgu=0qyaWVu7es{LMS5?PfCQXED)inU z2LH6jzy-8{Od__4Q$0n(L!0O<`laB;or3lq(T7Q40ONiOge<^`?@4Ov1TsVVtz$e` zrAETUQ_Jh1ae{l4bvrKD!$vO4N0dao@aho}ulpbX4HcU)K(|Jml7cR-ccOeAe8umy zH=*W=_wH`f4aiN)Z(0Oy z%Hb1C;yqJfDb0jR{h??YhPwhgvn~(}si&wV1Wp0ohE~v$ub@6uFD-WX?I@2oyxRuk zCc^HN55mf#nB!z4n6(vJ(DsUKBUkB&=6?m@z$E0WW}rYd z;szk=g)w$&lE1d6TB;Yy)}a_}1D{R!oli`e)K?5<(s*}}`#3@>tr8zZ!NM#RtNs<- z1@4$qla19mOIvqYDG#6}TT*p6HcAAfdhMwYDF4gX4aRjHFY*kQ-97Pg`%xB?sHNof zUn_smqaPgloEUOd+C4PO3B=Of@@~HCtcuq}VG$-IFm$Q5Mq(OS4lDB>?*khMLKcO< z)=GN72q^g+<9q$MOS_O^LZCYgWu#H|!uReR9&!PqqtlTJ4@l~m@e3s+l&Lztg8V{l z@2NY(GpX9FVm4y?AL=gC~BT-;r@BzB&$e& zJrCt5fAOq0lj3G8#y|@c>l+P7EQgf)E0MK}rsX$$*Ql37?Z$_!5>zJG+!mjsfGtJ; z3Ew%`s*sD4X7pid^v{#yn5XaoG0{21cHVqDgYH1@FVb)Q;ui~Cm zNZMDD1<+ow>0K$>`a-*7g2xkVulf7veAhpz6AT*QEBgi}g4c;F6#j?K{SnrBUX1GZcu^5Vn)sK`hdy!}o)vjdWFRQJ|Vht=lWDhVm4pN8+Nc2Pv|wq774 zUkNrKour~7aWOI4EiupwC3_H{Q+epx12d{x&eMmrv_!@#$uQX6Q`_3{3V4~*ba-trz3H1WbZ@4U>y(R-H6hJ5P~2vPF8 z`^F_|%l)bMbx`OCPWXxLu!BGfIDBr&IEHp3Z$9jaJqw#hHX6`U9eKh>feXJP*G{T^ zhFrBfFf*)_C19N8a;EG~cyg8mR03ZD2?GG1cfe4?YM)&i{R+MYKUe+t0k$pgs`MM# zu6vPF>T*3&VDydqF~mZ_&ZWE+9#msqgOlt_>A)E|U$+1cY*Juxxk>12MA5Zelz_1J zJ$N8UcRS$hQ1>00w2Q&qZ%~gO<;c9###($Ib%9&rEde0QAuNyb4Yn&|(coY2@|_>= zhR%n-`P7&A5kVU3*(qQ}?HXcin_)jTxtS7pb#Tw9zY?(c7`Cq*4G`vN4 z%N5V>sbt)eYkPisw86?hxzaf*?kBfw*4%~Kpg1xQDYFCDNCp6t+_>tz_$3v@_9A`>>0FmR=}xcmI5Y z=*&fj-OEte@@*u-@$BjEf;i7;9*IGwM>}$L*faKo5ArXZ(JlIBd;jf%QzG403 z5xfTa&Ls+TDq|QDAL~itO%QDj|2t!4n>Ka)#11FYmA? z17c8M$bVfDX36^dqURtI>n;c(!(%-_0FK=gQtlqgu6lu0Z>$l9MhD+Z+~bBSepOH{ zqXt|kGC~ILjE}q;aS_Srs8vYlv~@UPc9+Icufy4r9CF-dpSp}R+F zYA{(w-r2+6^|$Wrl;K$WhiU$)endygHZpeAze7H2HaNtPBd&<&D2I4V1B?x*B+~j33LSwQw0nTtWCOwmCA~#Dmks2| z5OrCFHlyjcWZaQsn9sZ0@)?31gFg_V>r938UxcBG&ew4C5Mfj^gaJco-z1Bt@>ZAQ zd2tX%4qMBTapB<~=SqYk#;!IF=gh=xNFJ}_#f0UiZ_Z!&YY4jJp#x5*y#a=ZXCxKNw9 z3}ZkSfUkOYSwg3spi4Uiafa|`r#%QTAax)QV<+!NOc5gEkivcxhu?uhL_smzAoj~i zg#^Q$@a*eyd5i33b?otD-q&%v~Nz>fR;j|NfOR3ToU3E*~Qu0>=& zbFUfM{7l}TW?WKQr`R#02yg1%Qw|MP8ngnP$jRt#3B6&s5Ht#z@O$2A|7`8Yj}mW! z_$UwLtX*J`0I@>y(th@lzf*^kB+LpMZ;NE{e(?vz2Lt;1AmlV$D0v2_D(Qumd`v@o z!(vm?YlCC)jFj1m#bV!Y+L@hHgf}cmj$j-AphAKLIM2*5Nd1I5;{+`{$Vo##P<%9# z01yyQb3p#Z!X{`{HGhj(?&nT%csBkOkqm7^S|W7ZKs40&y_u9ScbIHZ@@X!NWemOi zK%1gLi@!q+6b~qeZ3(yz%lv^I7_13w7Z8FDpH$Iq5uGsSr^JH0|8+x3ek$gyf?woQ0>{+!A-;6hGKCcc9iY88Odzi*azb(T45L(m z^%xh$=9I_o5~Pg>4!iXofGEkbAdx+k18x3zntH=9%*;KCF9JuKKcL(xXs?cGSHp31 z8=(0(kDm`pmXXs-EdCR1%%1}G78y$sqRs?cCQ+=AWqA*XnhS=J3Z7}0O+Jd8@zbwt zdPwC>g$~Oe{E|9=(sbWZtYu0B)sB$MvbhWVPh`Zk9MsdJkHrHV2qo3}w&!{F(V%U5 zA&Zdv^*Yb`?wt=MjnX#Z1i1~Y*MxtPK#d#DY#^IjkEDkf2 zF~j^sW&+9u)5Pgpz?txv#qPUG?(HgSgjK-|z`iAkidkw-L9?P6Imv&Ms?? zbq(rKkaPSn-L}h{?KqjBwMIw^g!U)r|l{awmY<0!z-m8`^S~@0%~ua$ZYE zDS&_+4E>Bs1&CD7x{BUXTA%E(SFA)JDs~PA0ILblUExM%B&jlmjmh2;Q4HzU?^~hf z=@Zy91*IpacpR`mTVb-tPZ^2U*$~aO$Hl%x;#l~`rIO@7w+-3$W1~Uw5J*!%fE(?R z1$rh(cNCld@Wo`3>1vSXkGvbA|%R;8F1QBZoV)xjjDp0h5qEA{J4 z&Tl7)q$zc`;0@JVWeWEkeNI(STGwngi-F5x@C-+T?eS^Yx<)E`4@UWdpANgFJebcx zTu2YUesTNqOy7*Nr}ruF{RDo8E+c!hY3%e*Fl~ou+4upeXBxN!Kt-jGNw) z)*m_td(EaDp1M~*WXxv)zef;9K%*6uRM;$xN4f^lAJ6`2emQjnN|1{y06?Gb^8R*# z91J4Qhp)X*m5Dmy&1>NiCyeS#u;%ZZ*;g42!}M_v%aeFo|5%B7SiE!=J!fFEX+ouP zCF}GWg5A-|8Oz&xilg@-EU((-&knGIf#e0FeFu3)!2Dk8+q3kPE&V-%5fyk%(-Pyl4EC)Pc zaAW-n-V<)^Ug2ckrHk$8l{q448oYY}wu=04e!Vi)Wde$d9N03M|C|U;K>sS^Ez`|B zx}_x{r^*8E+9Pa}zE)VMyw_$d?f&QYOTPPm zF{R_blqQ|o>Qx~8`|#h^mh3;5(rvYd|9$8Ae}6onf*-fe`~O40{{Q{)|G&5~DF;io zeVGRJOeW;B0F41hSzEkZ5OWvjR-GAymMaVGsetsE7|RF{fc@>W$)v%gcLL?Th)$$t zNsHrSrF@wC{!nIQlDFC6Pl4w#hQL<6V&OkL5n1rfzW_Yhe@>wBwo0cnZhj(1S;!V3 zebLM`yn?porgRu!bqoGy7ba+9Lyf_TRAv6rbDKRFHMHRn*UjDP7=nvv7DvcoDnxp; z3OLRzkn|l=D7#=Tw}3|I^r!)Np-Ujsmor|T zo`zMwHLS3P&);xlVa>*zOjybz5~XNpg3MQt%N0_Gkwm88BMJ7QXaAB4=G>#to7(>8 zA`V@LbG;3&PViR%obDm@rDBYD52W4P<&b^me|{8z6PHKvM2HYF{KE5Qg{5Zi|KeAN zhWHG(FATSf9=(UwHX`u1I?JTt=gzGYr5P^xFhKe@Ck;BGVl&<>RBi8WOEWc z396^za*LA0uT`w*po5JETHr852mneRi#TT%20}`-&%p!%WMJXt>W$d?ifVj!uK!4c zN!1=EU9|*!rmdUp$upaB*Ego|~5^)qY8Op8U_K3x?2Y6BK1ovec%7@>24)iVInYR^YYIqA(Vtov@SGqG_9)RS8&T zRs_%UKYP~RgG?D^Jr@dy1Gw@lx!F}bCV)*soJ)x1&l~a7JtZf*hyb6^zNYO(S}L&d z9_8_+;3Xg)?X|_$ok#YVN6vc_z=mpDH)PEQ_^8S@YU|S+k7NFu%|=F+N%e324xgOD z$~zMXjzFA!Cx}8UK-gms=7V$#0P&G0w;p`6e|`|V@Kdu!IVWds+Rqb=eZQU!k-+UBBI_pv`8Z5lql{y?=)hJt zrB6ZijPlXhvia{cYxStJ-?YfJ^64lIFm|yck@8$ANfr<8zv@`gBdsvjE)Au{A~I;n zZv)-VJ&;-e{(W7-)~$Hlhtg*=icF}vbC%~qmirT}cmiKTV`LJeH5+qd!ulGC4O7;h z`YGxz{_G4*R-J_0O6m8ZhC|j@+Ze!oGY#UryC5KRC>Hv2H%VstZamm=Nj&pnI<|PI zExpg7-JBFg@?9vEF%Io~Z;?0Zly$j**!6R^w5Cn(gZ1dJFR!6R#Pd}t4}i|P-QceJ zkpoN4f4xEiWaoK@_Mg)-Pw)S7XQ|V%y{O52O8Ay+4~f)35F#>7yC}4so0vE0v}5VD z%5^caox!c_e1Ct9Bvy}UPnGxesPH(o(ou$yTHW`~T~W50`7!+>gpxG&kid2^*SU2{ zhE2YxdbBGVwCSl2Td>#au^to)CHiN|j!rc=yVi0UU*>)9POPsoZsC7AEtZ2#eOAtY zW1J*Ipf)}+;cNL|@{X}0+h$%spo)UR_??8{K2iISrn7Q`ltkEe5Q*}Ng&-6@0qSz0GI3I)Z!&Q=NzbHzrz`1BPwksw{>V8OI$X59mUH##kbQ>?6?9F?Sb&L% zf+!axUPl3SQBFVKAnm`S<-y;VHrV0JnEB!@(y~OId*PSKS^X?m-)}rob5@Fk3oAsc z??m^|FBdk-?4nk^wMxYA_Q@Re;Hw?tez^Z-^0h#Q_E$iVmn2NY&F2Drgk`I%?K@yx-~eME24o#w{dA;KK9m#7%(iAkEF$Vz)*{DYxS?l7lJk^6HwUN(8sVp#lT7O*mV&md5)j^ z&9AaFPmF~3wB+iYt)CpxAz8%V7eDB?{B9q*4{lu3)LFC5I+)YKW{1;}HL{@_e`RG^ z5YE{fU@Pv^pKgFNQunyMg9u9749LC~NyY=Qcd9G*&x2W`Hh6Z-TyWo{Ai>PE^Z9b` z&JAKjovUASDR#Z;?nHgUkZ>2RJDIh-bCCS@S6{AZG;EUCQl{Owt0gS1R1}e4in{dN z)xEeOL3;C2vGeg)k98Uia_(t3{CUWNuX0809d6w8R2Dz;PD$@r!eo6;*v!X6Su#4` zI-jjM*t+oe)wvgOt-E#RU8pq789wHcCbE+{l#u84r7$T-Dt)7>?)utL-&DN?T)h-i z3c_S0Kl_(TyT5IUH8r71C#>vsM@G<_DQ(MKqvf(&L>jKw8e&{i>zvsFUGrv-taP|% zsqDxfCX3?wiL+j#%W`0>mGa1l|CVepZ{;~-IZR?0Et)@1BHfrda=6oPeDr`-$M=O7 z_L=QcYE)W;@TsYx_+wowW4s@_UK*)QmgYXI52D(d;E(qn&w3t4HCNtk$h|)?n%wHNv(UMjYcFkn zuRVcjk++!thxZ!s~-ft>^S(8YUI%;OF4aR`h68K80at>h0$uYuug`d z&bEumx!*q6de$3y^{9E7NwE5UjVKGmV_AHblODG|+Ialok`p~y)CVb#%anyCuwH^O{GTCBic=!wL4jAM_0EqO1*n^RGQ~Wf4Uae|zAqLyCa&{TWROrsN z7se%freu$Q)Q|K$u+1r!BgONwPn-*DMw)!3XGYUOp8?rsXTHWn%>DNzUoc*MJCzr0 za>_ScFIaU7zN@?5=zi|gNlcgZu6oaOkEzl-`9__LbUHp=Tvbkm$Li+xP+wD$Zq+(V zY9zL=Ywr4aE!@91O)YuP8iV2KM7tEWC!bXmb7FOaAJF(=Q1Q z52wDYGPItY9G9Mc0CY?r0XR$tMuks1d#7a{leM+=>x)hTtgxs@Eo0)+pO<>fcOh4? zXes0P0c(n6QJWL%g??lcZ)+7?3|dR7RhE`JukYEv9>f*Nps{+pulSbBaBevH=SQAd zmzcH-2jAXNNTfII=Fh(znDL~Z`3UCdhsQ$KbKDFbmiATNQF;+;pgu20y_P~vSV??w z)|fH9Kf24vNJ3`wpEINQiKWaC56}ZT!p6ZfK$IQnfBHo(q2(>OacuNx=ASA{DBr$) zOyHk?j6U|kM^Ve@+IrJ;Pj8Xt9&T9b+~;OXI(NMvzkKJT6OJNkdT*cRGGE%v23uOC zFot&V>3#jkoo8IQoOB*8CV7q@e5|{gJ7l9YBxRCUl6=annJR(UpV7Ol$kcRrA@@|( zopU9^t}}Tp=8;rQ*o5R@@YRt%HN2P`t-+=aNxY!?K`&KmI@UjIh1&R7Z62xgUmzJ=l1GX~q&O7Hgt$L=-OVKVs`n{{j`rr6LX zt*bg!o|U<$KV$H9WxsV&t=Ap;8^vktrf;bCtKS%C3a#+5nJJd8r`3BE_iZ*{5238S zXs>}%D{FA1tx!Tb`2$V1k3W4*aAy@MV}*RR9>4ljvU4_NH$kcT(;cy7xzgy{ zI%l6uRoS@bZya7MbhK3*^SPa}`-BjeLhALG?YgV@Cmw^r)_0X(^Gmyr7RS%Y29Fl* zF?wX>6PCe6I>Ne0b?}r6glq6|9sB)?`=|FOL-v`%T&a|uHg|H~`;VUB?;0HPQ_}34 zx|r5UOePM{PreuRa?84Yjo#hP!e(*q(?BtQwo=iV`E>>l4B;N(#h4(ot>$7V!zq(6 zb6!s_?iZGi9@VyFRz2HU?ALJE%a^viVspJ6qfaOOO7tu7s9Qli^GHI>{)Da{`+rus zc5E80niB*I^poPZ57!@5KY#VsgIDF#G#Ji@ev8)A(Wc{$Lgs5KKP=n#>cn*_1)Zmn zT+)o9BL2Pb2j)rQ1e$qta9EG=(WgDJnFpRCc~r z^L|xz?x{N|nR-1Ui0s*w=P9#Qp%hK$DUW)jtv6Ba5hY7{S^GU#Bkj=DNINu_$z*)O!d=-D@uM+XjX~!0E_cbM@ zy4m)PT-J$}-*fN5>x)rCLiCki3$6$Fd2i>oi5`vO`uNzDF1*((IZGx%duUD!r@^st zWA$d5_KBvXPq8unzh8-^i59yl<^lvdR_ewfw_ zA0&RWu|s<)9+%()n^we1gAMjDrpP#+x57_(t95WyOl@De%-zBt5=x{<&tGLnorz}(Hj>U{3P;PQ*^S6`Aig^YNq71g|@-K_-u)ki1uG;;MwR$~pe^dagGw%BQ>3@~L*) zpOQ?9r+dHL99z&nb4+JfU>L)lh9-{LO08kf3{E8~!3Pg9UtTdCivNBF*Xc&XwJY#R zicGQQ%-5>vo(luK;e*@6Ht$C;vt0AM_p)JfcYl@c$o?VPidSFz#f$Y`YE3;0%T48p zk?4IVXA?>D>Az7ONBbz8)hUcI=xZsWoiw>eIF*6$RYPFY%Hu=KW5yb_DnU-bUcLVq$h!Mc6J zuri$#1UtNEicMKK%9kHm@1-%>iREKhDJfaE@Zjc;mg(KMYvO5I759U%x8?71g4V`9 zUsOM@o|YD1YIWzq;47KFTg%Z@_j;2&$=!qM4_VXG$jV)9y=-X_zdN-OL+?RZas%r- zA4dB8OA}^|1}EN~r+BtLo5Gy@ zk5z>fU#Io~jT-(vW6g9&0n5SR9Y$q=u%Z^PMT);XEB#vrw)?MQa?%G;99m-|^yHSOaH zX%oKp$?aw^KhF$(MbeZQ+rjyu8p=af5#G8FCi;69&N43Ja~Vq5z47XVe>T%SR-X;L zwqFvwvFFe;qPaPETFTc-F1zI1@6|e_DaCf<3V-sfZxD{aSQ4&3G$RUsy3u^c(XpvQ zr7U-DhCj5Vk3uU{HP&XMQ=3ME_?$cOawCrCjFU)3qRX|Nsm?!r6C;k#`8nVbLUst+qm2YpJ`ZjMs?WbJ4Fqh;-V)w1@x zw1ovH4Z=%ag3W=<$NkTVC$brAp#B1%E9ZLi>ye{}D%@$>Q%*~=9evZiZWa4KSo-RR zCfoOIV03qfG?Jo{(yg?l)M$_z-7vaSB&5Lr>F(}MX&BwzG1%TcpZEI@?1y{Tb>GK% z9_3$2zA^er^3^{tQXPB^8ry?JsnziA(VI)DrHX?)<&%`+I|4e}dA)ExHKZsF2+(7_ z>}?rAj$C*k%iov!Ll)xHXQqsbhW$oiO;XCMbc50}aR%zU*unYU{Pjc6p1qpkdBNcJ z1GLf_dQU4ZZSA4?H}va9ddLJLR`M;1{%{Ye=33gHl2oVQGGX2?dI8}B184d<0l1YF z{um!{gyiZ$_Y(%e-s%sg)%C;wWPRLqmrB@@hz-IkIj6dr(qlIuUahY51fkS#IcoI^ z?K!y5t~dVeZWSL26pLm_7L)sv9jUR>#M3rD^x8Az;48hQY16qqEpZ6lCb$HI_+Qrd z7e}f&uqf22tM>i-xGyhO{jbX-G&@UtEmQ{fH)TSx_20(1ndu^*42%=Q?@vmMwB-Mx zHaZ5);d2_y`agDuQyo`Z)wk09()s3{LfMiAR5KXpx54)!u|a2Gj220_zQwUZ7k2PBpk%MqL z3VTX`@t=Fs2kRNIeyeW=9~KJ43r4x-4!h9??Dv-LdrZj|W$q%CU)c+&;p_%%wt;yK zRV{GC;`x%K&edSoe zzmnD!{n3xjaWQA!9Q+?FE8Nyvndg|srKjOZZ2Y#yFoY3#^HE-8D%u%l>X*Mix|z6s z#ZXBSI80}t!d??VWMG2 z3u}o*&EpiK(aWA|G2@`@O{k=%f4QUF2ED;+2GS{vRXv^cNAd=^v^1@|YfOcZ|AA?} zLcP3(e0$|*lbalhMgrQ>u7P8(M;N$eZ)keW)PX8CCxDQh!>*jj}3{FYsc~0>Y zC(Z`icb!6)V({oj9vBG--Q7OC+fk8h{T9$J@24FRB&k;pQ8WY z$;%n5>TF;HYnU=EEv}uat-K{*2_2 zA7BHB`N*YQF`i#5*%{skEK;mcA`p>gl7S%u#km1axNSe*o+KtCTQlRmFYRH*(D69_ zv?)y`)>1RleBZF-fnOFOaOFXeEjrbi)Ab1^0Y46%!_ZT#XP5;)(s4DupGD6|MQ+FG z*Cncn9l1%0xDj}88g-(ayUDlW&0!Vx(DHz2nAFuw_xSKHpoQJ})|>U6vbQL{n)Z!u zERXLP2{H_OPDWteh?hc?PEjnjcJiUvPre{^-*rczN5F+6ND0_)TJ!k$=nAPjbiu`@ zBbEZru;Rrw=<;j03xp2Fq`fPcJD?RyQ&aRHzae0dplw`r#j&2ujYljWH`KvTB>!<( ztB7X8nvqU~Kg^@v{{H~`j>QMu#jAQ#NMPSeDa6lk0+0rMvWrD}f{tkDMm&dhqLBkp zi*pYEPgD7lG2|j86PStBL^o;H$$GYdRL;`Z7aG92foM;S@g!v5Ug#u^b6O*wLVf6j zF3DBwp!DyinhMX&_>RoW9r2YjhF%wz|n4`@IR(+ntfeT)Tf|et=>mwo0OV z1uVg6zV7ogGkC8-Xy?UX_vNy&@h@*SLAg0-(;wZD=I&cTtE>;6Bt7(9cCl2!?(5a1 z(}3)iI*?S!&!;?*A4+t^dyM!>6MFUayVh~6|(*P z{X-)oJ&3Cui_i@}rQaXUDx$*>@ixKzuC%@0T5`j~!>%TuKg<8_`+6dmBhSVZh#qZ@ z2Sz8peCNHq{2U^fZ|~TvN|c-1(=B{VH+?MrSr~MUNTNeoUL%Dy$%h7qFu)hx2n;I% z@+9nueJ+-caDiy=vA#N4a3KY@|6Fbm?toq;ejn+AX~TH*1K^J%!90pL+&w(kl~{JN z@1!hS=E9!matMVXRX{n}-HEIGAQQ1jO%U$!2Y91A7{{P;NEfy7sKRySl-jc|eW5aq z$MKH#buS6fAwrmiX@~Ev+Tf*~9e9ahk6y4n2>T_5(mWcjQG)%ls z5TO;78Gvi#+?NLDBm7N6U2w#vC3vnR{$LFeA*Pb~{$s-(q8D7t z_}qNVVrnu!uejokElbIV=Vzihoa?o>;4;JU8XDi)aiej4| zo+Et+MnCZtdbKE7DE>#Zd=MF66V3tjDJ|mXI<-bW7rLO^u1huHhqwqw ze(99Vj9oAcwJra+oyN66`ML!o}PNl$@GLurjt2f@U!4+prw%NtTJ(Kd)ph-xOAI?j-89ZWVQ! z(9Zw)!`ll>xqO^e`{mgC&GB(ASBWI``Sdf1`r?^DOvi;o_Ek%%7= z@DY5CCHbo}LDXfjKyUFC-^lHazv0R{(maG=?OKQi9rp`m$MeF6U}G&N$$@`EG>byh zGCrD1Ih~XjsFAClIaqrIl1Q)H?bmuG5Wme7)OJ@+R*(G%=!FedYKCw^c-maQ0h& zSO0JIj!nhxINwTb4VgBs)S*|4tAxJX0T>p3>bd6mohC4>kWE^Xq~O)Gr{Dl18UvC(Vk;GnQu*KaYgo}bmt^OdFy;!-nfO>3*w7PA#9g} zi0~P1=+nE4%1%(lQv}$Q=<=PC`=6~zslpaVR;^Q;3l$LSwc;FGmt-nq=YjY+2QDa{ zgmMbH_acc(yn-&-VM}tMdXIoaVS5%1o83Nl4yOH0%OJ%f+w(PL^2l|)3lfbMl9f;R z+v<{Hp#VWHhbPoA+Iu|ox|TOv)t<*Zl*u^S!sYr&_$<-SwFzo9*&}(O+mO6O)>d#j zV9eKje#lMVO6l~Hx3dGD_UUc#B>t#`kww2VcFO?!kVaY7*J`w5=T& z%O$rqB&}I9&j0!+L`s5DI6u8nR>n_&$d^S1L+f89>*xL!zNyNR%rzm6GR*Ciu{ph& zNL~`gYLo9F3xIlYGnSq}pro%$kL{T_prqWC!VErh^1Fz8hvLDHBj@nj(m4x0^;|+FctAWN4Sr7Y7p6cmzaN)o8V3{Kt^p~;&_7bjV zw8Y}Gi)%&g#&XM&XWU%1a?7L79?~!dtVekolELbYAc$?lDxc^;u9f>ewS#^fvA+dci2RArKtC2m|0Nv@G&rxX61lgYM~ z+f8Q~)Y-Tr^!#<&R$I8f^i>B9oDRJKn-WQMAcwd8;PHRYS>0?32zpvSS!>m6IQoIW zq1f1UqbYr_+7N-Hy$C;lEJ8{#{9hKG7{Ur4fv_SnAZq5BB@k8fadaOV67F;V2ec-< z3#kplGML6lH(lSCTlQHTK15k?C)A&NKP9wl7!Tz&VhTUm?@HAbg#Hta?a5QlPWno? z85UPrulAm)oG;{FBW8(aL=p2KpwWW}R;NybYScl*-ZJ8SM9lncG*>44@MDuk@NY$i zzM(=x=kP#I(snYk5-PnNB;}DZn90VPJl{p#+2;CBgRmF1Msn8(d~B|=tDcD9YCSR?jTi3nL`Pvjll6pvCS9y>08?&WC)#hp^lUl5Y`Vwa zZ@(v`6<<*VPQH#%{fjgvATGu5Z;!3Y6>@?V=qRCj%IE7hm)90}tI)27K2azm@jBlQ zqG!gp*Lfx$!Qe$JarOyDWFyc!CqCP5ZxT~6PVrC~(&WDPyU0I(Ddn%%F{yvxB+^|W zd*LGHlQAzv2kgoIOM9pDrVRKt5FxU&;)7*@I=G-=ur3a28Ef_W$$O_YU_NRx^du`oK$GBnv&vwcaO!ewF9B z(mQ+q@HFZqC_W-%eCWri>Oto=zLT*U#TL;&{V;v%r(@QExmw-6H%LRw$#pD0nxhdz zL|LPB?01fHd3#w@mlxnz603T^*{er1a968;sri(pq#g9Ok&4+>r>x4*jGqNHvH~(0 zX@99fX=GK!*Fp(mHHpT{?YqE!3d)C^HEidMu6O6`uTeFq--G=+PxKh#%YY4x{ixrU zvkjC$UR$O=<^^6+3+kc$;a(vj&*XZt3klRIa|{j7E>Ro@j4!9g6Uhcn>nPrs5U@ai zTLF8B;WU-1NO=me=2!hLe!mo(8+GI{#kYKAX>GlQ5cI*2z&k_9HNO9al?uR|3E2T* zGcz+hqllMe z6E{`4-(Z$;1thG~5U#V(d{SU5wR9*O0jYN#Tv!Cb-r69X8h&qnGcZCt)rknF8in;4iBW<4}O3QT3};9BS(SeO$ytOpRLeEMac#uMW9}xoWD#w>#^R4x-ISqf`D{Kckr?pHWQMhT zIoO(}$tlSVc4)YdS}9mv;oYF8b!ZMu2y2w9qOocWab`_JXj7r!#`>>`Ka} zD9E164KWh;OmYx8-&=@~co~G}O8qF1_1^nb98E zKuuPY7LqLd^5oe?rv+`})3er`Df9z>@PixZk-Z9@Qo=y<+EVh^Wnjh@npJS_F;?QQJ(8Zw<9`+_9jlt2KG}|v*fRPUghE~ z(EQo*aO9I*UEPmPH;EX>evSr;QJ?5`xmLQT`g>0U8R{$g37nPr*w{`fdI+GWb$^2a zk{|e&O#f@C(U~XBMY?+6ARR@6Y_zQ-GkRow^M$Ik?6U?kZiBW&16y9jO1ONd6Q-e) z5KVB`n2AoC&ebJjx4+e5g&s|WqayArF7#YHf<0ADy$PBrN_2I1_GA>%Tuj0ru7bgRa!7$jCv1&^p8*d0p zGC=?Oy2ykOvF0B_PAHGQsz?1*8~oisJ1>%$owTQcBtRIr+aSZaSb`iSU5NI_u51^TU`3D$pP%dA)kBB-@(Xo++v!?$W-;Mg;n?_sye*;Wo3YLR3bD!NJ zAGLhzNPUn=*ez3CVU`9%a@?_fg% zuj=*6bHEnGuk>Mhh?NtL(=rtIX742<6V~FuRr`u==8dqaLUg3P!qKL~9@Ie%V)w6u@Ao|J#9m5?ACOQ8|^jT6SJA3vRk<;#LIBamDCWDLEzO) z;)QD$DV>NXW1fv18B;EBmseR~Jb7}wCy#p}3!RGfDI*Xa4YrPsQmgIu{@)QV&Lu=F zx_1a@md=}y02Fme&fvP$*mrW3=e0d!Jth^$iu`Bf~m1r$iB zbbJN|>Irit0n-?C=@_Vo(uFxY&$1vCmAGJ5hjscd&zRPrOuD(_N2g=mrqMg^(*1g8 z6VzKlzZ-UY6N3vHZhB)4=bV)hBzH^`^`gxBAY;{wh+A_fY5yNs!~N*Aj=AKQ?PU9h z!MMk`OIeVGi2+IHvI&yvBL8SJ-YqflX&WC|s=^Pd*yIUk+7|t7n2(X*8h}Ei=T-in zDxlJPdt`rSVbBf6jc7Vvw6*w*mR1jnze}t0DWkvpl1lzq@U_HZ+!)#qTaj1ZQ+jy- zF8o)=6(GwY(5LsCtI2fJuORxzuxlMWQ9qyS1s9J-Mx6>jQT+e=o49jI=I@N&<=}ry zN!e}t^$XP|^ZEbFYDzi_qoVSp3o{1ug0=1IsW=*V(NlO<0N+n=zb0z+I|6ONcPYzV zRRtY~$fPgyYuCVwYVx~Ce7g@TsKw@QCJ32ODa6-q$&0Q03S^b)Yww!^T#He*JDady ze?sGpddE1CjFnhKM@!FqQi7#67iHp@wVlAYHpN!&A~oa`$7@+R*q8THEs+Gjk=fys z`lrZ#eV_0yM}~@lo^lLRt$EpSlBs6rTq{Y5ui_NZzD0tR4o=Q_-o?IgpU#4OsG7~r zmtNZ!ZA?_2$Jv2f-_u4Jwzeg@Mv{+|<~hiIjEs2b=w4a0e|cNt$oioY(KPd`YBzF> zpr_yjTSP+?WSBJ)Byn-fvatK+uVZM*EFBwN2Q5g!Y?iI&$)q%2NSmgtvi(iFZny8R zV2gmzKM{}xk$oZ~AzAiDhSw+RSCKag@5gy$SA^!Jt6K{Os7!s&nBE`Eso>MoP;Wq; zVv~S;yt@yfk{DJ&l1w-WZzFLpn8RP{zbIxWwfr~4+kIpvrIApuupr=W+rKlY|3k!% zp}sF|b9mFcE_dPS5tlOuU&$91=PPosZjb_rX#MlQ<$RF>}2PPXE?_!=x8JXLYTY{=tLXsBJxdS5Q%3gSuJr)~9Ap z-qBlIy>CNoX2<=*^w11ud**$~Y8MV4QYeOnw83jZ28hwN;Ie~A)C3K|=1;B_^Q~qQ z{-?5}6ec9z=M9+?o@JUl{q(_bMKDTQ1F~)dcHr2RVH%Vx?LSf=oDYsv#BMqNrZsBoV00ywDSL_oiY2VpVhwNK%I&8Np`#Rjv!NPPn=`g`Me!i=H*&Eh4rV{=FDy+_W>tsEF4tRiNK z6pU~j<)F_bZ6(k%9xke{A>9MfG>en^NgLZ(xRb#ElxRP zaL#9vKj61?D+3NG(vKm>lF~;ZDl80&QGsux_em#DS19-?fH>194A4muB2DA}6E74s z&KBH1N7H-He?g&4LAEe|c-iWUYU(`H*w|RO(pTZm2#Mz5(j!6^Of%jGFLpEfT~4Ea z5Mps}r2pCTHaucs}l%&h)fE_~vfqvOa!R?p_G`Vy>hRG$*%!H|Xg?xUR zUnM?szpgnF|M&b6P(8HK^Cx$>YH;O0J(8c7#2+E<;2J0;8jPp>`u}m$MTbRo_(-T5Jd?NlwhCC%+KU6?j9bUZ@#@h z3a*uv_rqcTgOk7a?{@VKx2Sv*?cMV)HDt5o{y33bO~#tJOTTkiuo>x@>A6CoWgAss z*)vUjv2d7&->yUdjk<|U;r(MSFM6OD3U@t%+$a@2n0sGb#cZRfY&kwG7m4q8(d!jz zMw1?*knjQEI~NPN?MRa7Snf8L1>DAEzmI#7LVcj?<=jdvvNIuVX3xMi;5f&F)K_wa z`V#fnUvGH$H;xY8b`e-erU&d-KDw%VdpvD_ab8>#Kwq#a66$Kh!`QWvtSa*N&?~^) z>ab3*(fIaIck;SZ;42p!cg97e?2XcCo^~U4`WjkeFP+#@y-#-migz}>HjA$9XpWP6 zN1@wd1GXT=E5EOTEo)PFG_`^w0hyxCC2*MSUE<4rbku>i1){kL*YLi|gFw@GlQm1O zCI-iwF&svX*h<91a{8dPCus2??KfxU z5vK8Sl@-@?KCxC~V4^)9TjEvP8xFkg?AqK-37y9)|4uOH3#9oaVsaBXX^;anB_8{5hqH-6t(BsB0BvnK`#a=|2 z2fktcM$7v>GmcW+lSS92F06l+23Ym`Z+I?7k<0t~Z4#)d5;YzOLle!SsiI7Jp6Rz;EcMZk zVgOyC-$JS+CW__!;R;-zfBSoMw>+#yNg3T1zv-h28BinyjIn(e*06n|9Nslfb~r`t zFPFJ2)|qJi&1OC%M7zU|7!Hb6){Mw2YCJUl?&FC z&i(OfV_EeKAMpwH)V(YUJ@DtPQ1!I)Qv4`!Bm})>-2fE~LbNNH^-?jtt;XwuIsj^V zkIlBct7L8B$U5c^&zp^ld#XS4x^L8TrM~q>uxhDhSW|{NSQv75G~gI8Ezq=l(6p{G z-8!(Tk}T(%DHW+q*=lPafqDu8*b+Llo!$+b^%vDrjO+u`iC*tC4nYO~c!htQGZ;Q` zypvqo8?qE)e8)vHe!X4g>`s%PIxnga_To2_J6P~%t3&Y0Bdy*(NSms}p6m(>dG2cY zw5t+OdH_iCJ0}^vJ|9lqtS`bzJ|kx3EUf|(-=u(1i((oJu5;HC18 zZ7$YNyr~EMfczTlpERPaqr3E{5c5S2^xlfBI&!~k+v1GLsf1_^m#z)P)~b)+RG>1&Iacqf4!9T4d2a&@8HC;G>-%Nr2M}2(H;FC6XJK}Y zR(yEil;{OI0iC#?E9xT^$q2tqf_sp~Y2Y|N2 z>q_`@p+wnulj)JH#UbtF<#tD9A?=(HX&L_f2nnbJmoAcWx*f8k(qf*EJGwQ?7mZDB z*{t=mZ^7w`c~Obm>jPJhWE@rSIMiAyn|dV$Xq=JC!xbk>%jiWB8=Fd(nq$yn*T`f3 z(c|ZG6yR;woTt48AW%)=| z)H+Atr{Fhq)yk4SA2=k8lTq15$=!%+#HdSVM#>doLV`yJsWpoIP*hJZ^?yr56E*7^ zIq;j>K_sV2G6gwgJhh0TK(`oM<`rmL#sHK{ujB*gN5I4Bcni}KosZvyLf09@%B+1g zzzhxuW#~7=$y21IZCVn*xX1lLOL(OhaaO=ilQ7tF!`t-uRP^RFrE}DA^5FqXrD^j^ z2Cirud^klZ>cYR^Wov~@EM)JoC4XUtcbemrRq>mokZ=$7}>%TM$m1$r2l5~ z>`~b05q5|1S;)0ga~n_kl98>unf)(MRXm)^BIEZw+!_n7MOl8aZp20-xW{WIqfW1t zcH0?|Ch@syM$IBO3~c05Y8-UKE9_ z6@gby`IPq6kFXPnRhp_D1dl zI9PQw6%4V3^tG2g33wT8mNjLjz@%m*z?Oz%X(lz-B3$VpP^_)|vsfA`M=ZJB`b%WPDfT#zLizDnt>lb=I>axddFv!p?CNZ_S-24i5k7cvvz z9UF2QsUtLk1;iJ4?%sr(@kpg{f_i5dq=+7gmdXdoj92&yz1;s8Ysz2h%<1yLwvD{F z=4s?{!IPnbQBlj0@wvFym8b>p89yka?mziByXZ*pj_bUPR47{%Dt&|XSd1UpV z=hND!4J1OFDB0-oL%dnzw;JhyYI2439?Qqv7vb~f^ZGPFwZ^tCnS(cfB=-XA#@0z- zHDhwm-=@1Hy-PBl7@SSd6pt-+{C^Bbx%@ouQ3w!CqVa!Lo`s}!L*qo$Urm4Q9XrRj zST4-ccx%l;ZN)OO5il^AlDvWUs2I<`QFV!>pLL^7{R%5U{elkuCF=0UX3XM8(|HXFpu^xNz97(ni#w_6<^*@gFAZdXkcu z1Y#;g0?g)7lZw+eLqSR-B)|eosYw>Lg=06P48NtiG!cD3IG=IP&HkWp-42lYz(1l1%{^ zGnr|p@_Y$=AV3J$=Ecq2r9NM?WTcE=-NsIdI}=y-&I}RXy|D<Wey*3{KR z@BfwWVz8YLm8qv$#^3IwOH@|?f3|96jzG92w1NHqOxxHC6&Vo}K~84JMZIgweYhl< zw71SA5#b#dqzWEW3lC?Fv0ag12f^r)0r54lX; zF!XQ-Md(R;GnuIOyg$%d-CZ~j5fa`U^Ej7Ab4ct2*RP^R&c=OrGnCEyhEgPf@0mj- zn1j{wOU;(<2^E08QPiGTcrFePYF&TIprUm|De_)EeT#3!fKnQ?Fe8$sA!jxAnqt(!Xs0Ep*4if2k*I(UP zdojA*n2tT~SS_XPwQ>tNQNNff={%>vO1yzt86{DJs+8AdxKI)!K^0fk$iN1gPT(Rg zACDi}B`^C(d>5ei#@5J{=FZ~~ArQvAm2b+^eGukdwF&RP<@kIeo4nzGRJ($evVv7R z%2*^^l<}e64THQ5$i^rZNUJB1~HJM+fF_!{VMzwb)7>#&)6`s6O;}dR%41xefl275NBt> z>E5>q*Avv5dui+O>f=ps%)_EChQV-}L@nF+H~63E((fptur8CEdR9&-b24&!BaVS% z^bt&BuvWeBR`pl)>#}fNA0261oz7xwkWd-jx0#f>jg{g^bM2ke@AyOmD9Fy!?EzCS zGx!`etyQjI#yg4?(_3#s8~#GoSvlNe2<>|CUnQMJlcVwF%Lr^Xu9U@kQ<9U_I{ZI}OCU z0Kc|h{WN{+Admd#Q8tihh+K5``My+RLy)EGul~kA!;K`fwv4!{o*-BLzt}O#+8HTv zRq5z@Arm~;8>w4P!drC(#cjB(tgOz|+0HbpHSvUk){&>PyL5bm3;SZE4gPjLfnu&X z^9gLjdwsI%UAE?Z+a7(UPNiEFqh8D zd$d4KBOu$s-P^&cpa@lvto%1k{G_AVU1uVrr-28r^Q?<>V9{{C|NP0Y_x7sGTcb{k zZT2^Q+0Gp3+(VR6qYLF>_8JaeJjI!(`j~9z1zL5#rSI z#%4*Rz~WUnD*GFc4jTE0A5AvrOT|j>&0q4}!HeL>+|i4~aG)*1ZC063JS^xw;IJBH zNVK}Y>NDLR1veuqG#er@3&i5}Jz{!(xJiWVI>`ZbD$*;ZadKW%y4NSScaITrL65?K z2@cDX*9uuDf^UZ^qxJWv*Ajc2(-}{Wer;pT$d zvcqufZ=*H>{@M|4JP{V^HG+fn#OH4Rqm26?tf?*V#U>_Wohnrtw*6Vv@`WShm{#W<+7S_qCgCQ7rH$=7$F5NkTWf_!d9(m%~K z)XqlWU6(>f@VqaOG3H6>B_5o%C;SeM>vWF7f z#m#>S~OB{FTls|#S7BZ(ck!6~1QAt0|cJ*m%Vn|N`|20T17g55E zX2_{m#_d}^GZ2c_1ezCJ%r;P_p_=)yx~LI|>r-BLu}bkrkD@A;1ZiCFT>9)8n7m-{ z!Ec5ukP9|{_mSmD=X*;qjTeGl3+Cm0#cSzn#&=-})?6asc0xD$RuulSb%fzkCbtM* zhgn>tTg~p8)^nlM*b=yNxCjlLd}ZS(@wWHQFUIGnvWTp}>R52>ALvl6K9k{5A%#5H zVe>-TsjZ7}e0BUO`oEG_|1KFH+|>`gzI?f*OPcK{06((V!XhFXHtvnGSAruK0x0Xo zc3d`F#0^Xly=JtE$uX4-A`AnTwGN82s@3`X1D@-^*{G z_x#lR`zEc!cjeGH%HTU3@*#=KMioOqVqtIiC~ue|yPE#d&Vo6yI>JOZrCMf*f$huuI&En0 z$I69mL`IdQydPpNuxaBomErUV zk)RmLut=B-FFI*CNugd_v4Pi@dY{P#gNsv~ivVfwW2Sruij9(~`ISzbha;^G1$IIZ z60aCVm3a7;`n4&4$(uuCsr#bfd5fOBcOaAthwytGt|T~85%q2_-D3JpBs@&q@Yi6R79TmCHhe33qK;CI zYj2H{yROgJxN9gGoJg%C-sjwof$KJI`TE9)ezZ}Ozm#=T;j*kcpya#I+>RE}+$*Vrp-x_%=V&Kfm2P0!5M?nN(rDBcYOfj$Rg4H&;! zU!>YsU%`zE7L8O@+>|f9U=MhBN^Uqdv2>P`lBVlnB?`ME#* z{f*yy?`v7bKv~u5-WBZq4)UWka;bdlx3$z$u5ret2A>bOGb~=|pW&|I>qAyk$OLtV zi!#!)s*wkdSY5Nw00JNu4IdFe@k+2Q!jPrp*Zf*OxW=aX*S{v6_GS4c-qdfUSkrrN zQc0I1#v}!0!?K+%h{oODmd^vFXhIv=vDrj8_%F75Ly6WGX)ex|NE|8H!NPev?VMeM zX2s7Y=&n8h7a+%3D-Ron%9Wg92CH&VX+_W7tuF0~9!eta6d9a$t2wTFwi?|r*-ojy z1YC)|{LdA)PstyLIop|PY*F|jusQHvzOrYduG^~4bT)qS=PzS*4z}{bwpvYTz0i;c zHe~6h2QNfGRRQ_RhDj#RLhL)+a^pV7bxTMa`Rn^N%-=nD0uso-83M7}r4z%j7M|eo zhwY}w5}h_kuxUFkiIY@n_ZD^@MH+p5?#sSIm!+bg$q)_*{GIt=8{L(Sf-Q&NT}Ev% zCcJOJJl`ZMKy)tHgFEbC>%KDcuCnBXG|}omAEu4=6sU1U)5wUn!3Y)BG1bY5NQLP| z+%0wYlf_GxGe)9mD&kMEx(qMW0M$hErSwosjw9%{9BNr1$WeoXj!OCUY`@prBfpCoanB zQ?u`_qsjT|QhPQ(gu#&{c<0nyf%cg8npzg#uQ)5#1k0^r`SkB0kR}+#du7L;Mbkx z;U5^Fw=49n0|(O#kxFS0O@F3UG3)n1lPky{h=bmead-hdDK@GB^cYF=jCgR|fRJy* zI{_zMBLLndC$`}VonNslhme&)&Y(ZL%X3)wfqQ=Wt(*|$<$x!#&&M_8#d zp!HRL1E;jh@9?YnV?7K}So@tTS0QP&qI`e=M1j-@&oQin_#VxsK*0A{%=C67|Sc5 zNhIA!_-|WGbuV~#8X}7-+64q z&OhArceVau5@zhxUx9DD#2WI8T-BcLs?5#a{R=qRqz28$2$suu%zqvERvgM>*Bx zVnHRGjRm+|b`W$}YthLi{HU(JAB`t!(R)PEz%$o#NTqBwcUJTJY^c-S5987(Sxg_N z=r+{qReEt78V)`rK6>f(H!9P2>!6u={j}x#hT> z{=W3hYpl9i^xmK9xY#9$Eh|IZ+}#MSD%N5bBt2^$kyaXAcJrG`syDjk+d9Qn7A>1Q ze`Y}(W3WZr~fME0NQ+aF0ketfa`&uLm##tp~vBf2}6Fp2>CTgXkriryfs*T|oP z6}Ra7RQ>Xj_oX`=j-?A5@9#Z)IAId`AJ9=zg&hYV;Vbf*zP_|Di3|8tUB6i$_=*vi zO~{B14Ut@^5Ww)sAy_fPh)Y`XaT8nX}X&=41wnew-G;d9Buq4iI!f^Kmf^wC$usLOWmW)=CuwJNcqDQb=0=VI=D9lxKKlk~BV7>X>_56Uj;is! z*N0HEuX9KdXYHGhJIcRIz+`duBU6s5&SZ}j16|EX=)M3yqWRdVC7TlujEjqZe}@>B z@PA_|ETs*6b|-bS0f|*Cq=u_-Q+wg-?$H8V6U;u}&5tClZ$Zm^LywZCmK)WeE5WeH z(3R<^l0Y4b0K@Os3pWF1`YvV7cz&&aOz)dW-5)P7RS>7>2y1#r9~&R};7BA6I~0q? zXnfao(&f3r;YK%|CYv5i0Q~~xliiyD*>@)A1lcztV;Hh`7ylaG-l1s|o{#`4Knliz za%i4kdU1uvqcuEyi(^o9rU5Z1vQJ9~D@A(`Gpy2-=b0FMQfrbj`s*(o8eScRp%-#U(u>t@aAxjWJZcp`-r1O6 z?F>^&28todhue`?))Xs`wc8xH2k-%Qg!Ff|@ z^23E!O60@xUj|5-M@C1h5%twk_d43P>3)N;^aVuj2UFc(pek9pU+ytx{iuL&(>69w zl6~|>%sgSMlVcbd>BHloeTc`XY*m2A4xWs!F8GbINyTGnOklV=nP5EsOF+m zzI%=fM+1i_mj`{U-e`8>+vJN^k-9UkA*izhbokwJKDYd3$M+Crk^QPEe=5oEoL}>H z%Hm@Zc;~!cuyT$eK6z(C-m^=En$d9HQrfG zj(#ol)lCVRizr4WWz5uTCx)Ar4WK|Cs^P`idc@DJp8*669J!&Ac|t#rk|Z^{u#mIXf;KUNt$5Qr)!{!SD|!K3 z5;1n*`9h(N0kQ{2DK~Fk-6^OBZ{NdTk_*OVGm!R5W^8FSvUq+1nr?%AIBkHNz6v_G z@1-y08t=gglfwNgwfe|g4vKqP@2nrt1FF`lt~IOXtSQe^ zgM*zD$jdK=++a^y-<(VL{JBCk(Yf49v9R?^h|GX0!?b@hu?NaBXZqNF51-e0_dMVw zbt*&FuRi0lgL1-!>+*b_+7iTIez0$1(i;~8adi&6d>?g78~T{>TATrz0UJtmIhxnS zdgsq2gPIb$i?GGqh%0neXGcs2W34(Adl=2M*RExy#AzjysL!OF_{u~ z!9+Fo_3T63A)Q$xprYSJ5UI!!p1Zs8G`4HJ_ccXd@{P{sAOJUQEQRQ<5k2!6K*5Pt z#7HO{(z58_-OvYc%5BU5#M3ELk35w9K#3g(KsWX?l~W2?Ro(UbBiRlf9-ef2mlk`) z!+xv#9`#aAa&j0zYxVuMtjr3Z3e@J&mP^p}F{kT}I6Aup{ri*8$rL&Xyf#2mNxewP0anG);W`!ZJv&mCf-0@*_3x{PV0Ahcx?dCS zXiC@MLDj6K!LFXd>n}pyhikew&L^lg`L`-x*PuHjJ@4|vONRO*XRx&9*PQTRW@HEU znGMXe!LTmBvQ$TyN|WaL;t(a&Jtfscpwz!)C36V3OLF0ogx$Q(q{SIj{_vZ+{Gx+l z23cJzD(p5N)&KQ8Lws(N$-8fE3d1eWJZ@FKH0QhyM@|gU8$;K5QjfgQ7cOb!Ft7k5 zO*k|zFR@sf&V!n$D(nbJmd-D8FSw_|aPuK;N>y~LL1dxbH4RBUzT33(lTQNdkyhUY zol=xeqd#XP_#5#mn2 zMG(0<{024loeYaUZ21n7Tswqbz|nN9crViiyA^p-)Y`pKqhYQM_+TqX%2+}`=B%A- z=#TO3XZABclIl*iGKZ?elv>JLK8dc1Hs^bmQz!H{)omipXHAaB{e--~Z6n5R3|ZF+ z;V-AL0!8OyR^%L5Adty+MWVt$B8<65yICtF$*;Xu!Dl#eZR|BWdSy8-#k;di;7GuoD-VavuujG8T71EMew-iw z;y0$UBtk70_l6}eH;X(hQ=yh{HG{P3+dv}2TIo}Ur4;*HjAXHy<=c$T@V;?bo}!el zt#yyl0oi9B6o?bh`I4x4==o z1axfgu7;kVGhfbPL>i7^4=7rljwR1?^j{XI14b@(MSb2udV^5i+!w33)Crk z6`Bivr@sIO6wJf+QzdqF`xM)qrEnS#u)pbU*Lwm^P^O zx-1mdG`+!l;rs1rGnc#9bO4bNnQjPB_@4l31|kh;xw=7$8NxZ!@NVve@JD&-c%QHCuFZi&lHfc9>W3a~BXr+<(UCxq`x;TMj}T&y9ZwgMHs+22MbJ<6T!ZkDE`e zjb(^9W#9>n?xC)WBG`fBBI5*%x!T66zY^a_Jp|p<@Wq!Yx#w-SA8_znKa+pEF;@6h zZcee{pdg5#%ol<+Qy&WGDHm1Zt(yUG+S(g4~pI&b2>#A{n6h5D^bY&Ej zE08wPb8(k>QOXTGN#L6svZ{GMb(gOz{P}4&T71|$=U{~Q0v0Ol2)?Z4giWrW-2~2~ z{9*!x$bIgu3DnzyS8|}q47Ack1aSytlLIu1&Tv5GR*>P8?$-!V*^}c_NjvXvnG75z zKkKwp#_=B*TaX6ApVx?2;lBfu9j+Q~c})hgBS} z-3*WA9Xqt|k;U)~8UkC3QN}&t9Mh<=#*GU4!KWzO5!=uYUNoz$$h4_3F4Z#v1$^6O z#qnCwZ#=B3nU@^tbE&y;kP9TvNP^22kjxRA;f~w{K6du`ECWTp;oLGoJ-PR62OcfJ zVU^Qvmc!d<7Rn4SQO&*%nQw2oOH<|X<$K4V5ZKaAW+rw8=L$!Bfs*Z&!tkkxHQ|;< z9Cs-m^1J5)1F|?32o^Ez!x~=Sf40IM=)!O_I){}daQ2I8{^|7+N+f1m`TC3`?Dp=! z^4kJ8GSS?5e}kW3sJ$t*#)uZ_35s3S)taD{Q(tO?N!N>BzwzKrbL7>$J*~N=0e-o5 zqO*nDR2xJWh-)Z1hmkE#c9kv_fQ>%9$82Yr3+xSmUvDov6phfdKLV$g_Xhym%r;*T z5eR_3LX)W)oBipZha28GCuL+r&pWhXJlsMaf)5Ezx~w3HiHRZMlmXg}LN^6UThpSC zAgV1}udQr!hob@{+Qml&w=%1f@pR+Um#fY?I!&1k#V%bzf#UveLHoKOVuFu{AA&zh zUx)AORcx9BZpGxfV>+~~)<1h2C%f$|>bRZcb(OJ@YkdCcLxWe@!6}zI2F3|FIR;k* zmyW)r-FM0zZIX|#U%GgFGqff18E{OZ0vWdvhm3@fPFi;73v4*I@eF?OyNynr(CoQx z5@(FdDQ@;_+tIM{%dHWoEknP~@RkbvA#p&9qpi7agBTyR>&l7MPF}k|JC0Dy#_&lv zHpr9va?{W4{2~&tz+ckJlO(M*@#9HqEH-~k{KGAq8~Q#J=gkj z09dTViPf8gF%!$yB+btu%POA@<&C>k5iZ8mqDz_*19RZw5s;7KemQq_Ji~JQD{us+ zNokn`9sJ!Gmup?O4eKCteabj%+M6%vqcaEPr;#k!C27AK>)3X^w3^$RD*~*4D>nl- zR`a*aZeHgXMe!{hxCh}4m+YQ~CV|fSyo#Fu(!DSaL@9vi$C{5DsT`{yZ z#yc48=ZL#vtX7O*>-_91HyC9FKK@nij=`%jm+&)iyMo{;6gG`;-=!=5bV}A(8@&g` zPSj93(VF#252D{Zr1n_mG_J1+ey-jdpyfv`veIV-TdGk6F+EeE*I0tMK*Z4RVZNW| ze~u+Z+PH`+PzZzPE~Oyj0Kit8;kzSS3FZB_Rw1D!geIQhAy*swGr7BVM8uM7KQ1Ij6{$~ z@qQd@vH1I#{*z4%Q93#CR^+JN*RMw;%$0I5>-q}sZymo&kNmP9|Kdv7uM9)y2Nw;2 z2KAkfXL(P#n{Urk=5f?id#L=b=g0)eG0A>Y_WM@u9Btcil+c^+a*gZT(=&+=`u~Ne7{H%JX@93V7wVa;}_33TLijb~GtPULni)+2Aotf>- za+lShHFrmmUk3deQTd5i7*_Z@L(I0J4}$yVYgc8qsxurCe3!My-q$;x(1ZbDzzbf}m?<4F=w|;vHuXGS2ZkTDy zsV&b(Uu8g_r}M&VeZk%i-n9=|P7aNcypj<M}v%o1?r{Uay+U^2*#sSB##eA&X zejcew5R)!)Cf|E>Q*LAo22Z$PyV>LF#)RqkD{-_UbjkGav#W-?6hqg=QW%QNv4QG( zJ8=he-+nZ(xaNh|lc8?K;Yf_Z*8%g^W z$QA@(-K{msIvtBwN_L^#T&E;0jbXmglAS3Mx-5o=-tRasbl!N>3yhK;My-i_gVQHn z6!UMQ!|%FVGZe^Cxtp43qSTF;&rn7hiFMFRJPG{b_z=vwcC#9+S5?(+R%#_gvDj$n zeW&@^?eN)U@oVtyD1d&EBOY0I__O4?2foKERyO>S(hGHj6n|Sd@-nOON1u^cc>n1_ zPf%;Y&CXIC_=Wuv?Q&H)oJ0|gwYCal-^4=g*&XE6^o&5Zq`1ANJIng{2Ha|9l|>&fJkd-c z#MmfgQ6VI@aYLE$zOUN}Uz+;zJBF=DPg=~bjOZbO>}y*%2xktZp$(2oe9TU+VEk#8KDU5 z;KA4#Y=IpxGZkm5_)W)Y7sDlogh>;f9~!75y-&49CjF@f)b${^KSN~Pxr9_UjDAjs zf2>EwHH*Ijjnbr68wr-0p3I1kR1hq#he8%*+QOC#tnK4@`Oq-y5Th6{MKQ%=6QE@r z0t-i}Zca<)!9+;fMcZRG4`K>8#;89rh4VE?eV7Wdo-`& zR`1*kI-TL?wNdxo+V6ovEAr7_cOVq>z=aql#NlcQMYTAd__GEGvR$pBeasTVc%R}K zuOdZ%;$R!^kt13`W51+lcHXFTjlC_QwaX_!K zb}$`m?S&+acf{?ChvKYh?YKCp!rP?=%V>KkTy3jc=4RO0b$HyRi-gZEf~B&Vdf%5C z%n=3g9BxW7yTzSfsqx8e+IAip-pU;pj9K5i20WbREV6W3L2U9UO;h9q7ibB(kn4cL z!UOQe!;%*9$*nr>#qCWDidYvB%WnJWNc3HuUrL|Reb-^H8+t$2-FeXt5UbXXyDklO zYApMrN|&IK(&V*M$`Muy?+R2TsC{Ruwn3(JK7I?RyaJ9GqF<+zh+`swNd@?kdM}YU|U!JHkGY~xH;$S zjuvGmhMosM`n@Z)T?V1)EwXCD{^z7$`CM}qYAbIjBf@^^7MzDx+>b(smZJM?3-h<2B4n`LeE*4c8vV`WkJ)nQyBo8y=ds7Z4Z2Z)wZ!MT#>Fe80ta>bQ4gmhvj489w%%QTI!F)%cPh@j4>| zI?`WVuG90CW?PO77aDi@9-Qyjryh*k?-fO+dvK~Tjs+Z>_46bZ;XdIRM|~&sBM!lh z08yl_+OtwgX5(+iMLwhYTB%Hi z3`FBeG#d(|aBRFqsZ8}i8lmuPY}b`N+?`xY5U9js&A*yWrqB(M0og{bqh(O%S$q;; zfZy0Qsn_E}BtqsXmU%cW6^XwmA~{?Ns}vPSAep&4K@jfSN+Iv>%<(YZB79v;fe&De zof5!41eoPRuw;&nVYTO}mJ3B4AW@11=W?R)PM!|Ky`g*P<@>&~EUCi4f@j#!|wG04rFf8pgu3V8|gu7)@$Dd*UB^ zE|lzz`ANn-+}U74#w0%p_&1w8%+xF4$?f>_?qPllIlM+;cdMw0{ElRi>R;2u+to#o z{a#jba5f|`sE6X@*o6ZnY_jlbx63N(+Pp^yf=EKUHOnlwIwp`N!>tY)fY?pX{f)GQ zdKg)z?a#84+Z#Pc=N8Fo)=HkcRrG*&&b=pGlOBvh`1dawQwYhrGZd~ET}572)MKrb z)30s#Kf@$lf}ITHqFNE+_3ufJ_^4AI37UT=c|gA*2S;dZECyx2nyzHPhE|*SO}irxFOdn2ik`tl`+yFcs=>6jlW$Tr(TWAa;SeU7(6fw>75$z^}Ojz0e6l>a*GF3 zy;vV@DZzK}+eZGG&Rga(@Dtr!aa6icem{d;%J7b%*5QY=Vc9ZtCbU^)WIi`W1oM5Gp>%_;z3WsLI8NEYlu+@8KZ2qr7KKpkJ{#_+-QDu7{5^p^t?%s5gvSvucoD6Qz)!M2gDr^HKT2u zG+>~+riWK4^H?$sS@ifw+E;h)%bU!lW?E`_{7$}ojR5doKczQ&Qljz^ET<6~g^rXr zRM;51=9VlGOo_db55AR+p5`hMKzH0E#h=>lt?H`m*)5v6N{In?plo48I|z6Fxb`;s z+1I}B4coW`lRqsnDEHP|0-=gMSkcGlFE*;HFINp7-$-I`97wesA32B4L@gouu9)E6 zb^h-9sq1xh<K~W)(R3mAstR(t6DHI|4LK1HY2?ESx6FzR5acm4)gS#VfidLc?JqEWwE?f%PBsFNMAU z4mNP42cO!Yrs=JExW2)+e=E9+}TkD{Au>BHjEP ziXn>1MlHd3&QYw2n~D)-$8E;N$Pp=aDvSje>vG`r0l-?H5Mp>?N6<7-8(C*bQs|{Z!EEWco`*YPB&!Ax0KG%OrOpbVk>|g}SS; zd!`xoHN`3W=^Lq=ELmR@>kLSpNwr>xWWe!r=?L;K`0M_#YRE=>TueO(D5aM@zJE~} zEbu;PP*IHj>^U>LY6<%T#xtu>wFBS!Bu{dq*&KtG&StI`@7z@!ouBV^TvmHc(3&?{ zuYqDr;g&|lZeLnauC1;b^JQ#^to5LbyX|R~Z;vBVgd+lUnCGzSu}X>v9?U>&FjyrU z4m(5;Q8(^-#c@SQTH?u0kq^g`l``}10kJ=*PE#|vMsfgcO0jCey8Ur@*3+9&p}m(2 zo;iH4X3{oiYs_2>4ZJA5AmatH%0mIF)s(KHwMnwZKr%QV>H$V51r+!b(+HA0h$ZeE6l=?ETH^bk@BD#in?)1Q|kh$TQszUjrJ*F=*242z>1(L^2x$8 z4z1V~W_$*E8cViiRSQ)yebY(=hhs0OJ`b%+D}F?c(7@(QA+gDUb=U~$&COl<7>;eB zL+Ef`!w`1T3g)&6j;N8khFGJ1OrME?RO-3IHaK1 zwNOES;MWw()mM|6DzytL($~=p2-MpV^-ea?E~duqh)8ZhEH_Ubn0{irLI2 z%P?;gBh3e929G9*9zL;i_dq_oW}G3ku5egD)!jvPs&r`Dzhg^Z@>`*Zc&ELrihtel zd=KA;x!?}bDi&4hYLs!jPVARI_7;<#`Cg_Z?F#-3hQNF+F36fwz7bU7G`NzsFFd|r zNH(!{XT3hFPn2fX>$&qPcqvHL@>h5zuSyL&Ce}yN5F@>jAW}Ys%by=Cqn{GWtXGkD z@dx?%NLn{E*hYM#dM6#iKx|qwzvF$##=f<(? zzD;D=<9;AC^lHiOcd+;P>-VG6F;^CICSKAUdh%ws4Sh(2XBc`gD2QFMq+l~1@*rwI zg^E(BATi%|82nB%?Dt;PXNhKTmw2lulnqf2=Ok)OTiqFpm`ggW5#YgFiwTqOAgY0%O3}j zt#2_jM)Y)@%3)N<&F&7x6;t4>h?CHJ0RBY`1?SsLmk~t4_l>Kw5d=H&)1D=- z93Fp|@$tNBjHPK$?$=}bA~&%yA;CHwtZ&r((lI&(`=|jLz$tFU4Sx_4Vp-G^*yrt& z+7t!52K!_O4(_f6As;lrjwGaGYual|jXr>)ot`z8^GUwJtI(64ec5Yz@FD-HO}#}R z2E8C5t0ATyI->*R$xVFrj-IYZPXcrxw??Q%+*B{$KTM~guh{j+(Gn&iruRGJKv~0` zghfVpY(#UuXG_yXzfgK>I7lhl8i6oka;fun<^0Y;OaZl?eHcczhgB(ih|X5eIQ@>? zgS=BOi;toB)DbgY6;l!v?RrTCk4$4N-dg*GG7sJ^iE-Kl*1>Z$XQS|>2sQm`?~kcz z!abBH(H3!+h++FPG(&s{S+3DX($lEGnTfrj6OQRx(OG#%D?%;HiS%cxO$+j^C~<`A zAUr>ESLeAu%F1OtUa2InY5cA1#|c}lCNgz3j&2xo>(1%;4DFA7c!LjdtI1-kwACKe zvkdc#Pe=C*Wzez+3`bor2ck#BO{$&w!6c~Tn(YJXj&MacU?q5OORQ7A+)_X2mLREd zW)0U6WS=ZT@lbyJTRReRbNo{*^(+=A1(jwDWH&pTo~w`e;A6LpW8D#Qfo;1Fh`UwI z3^TDU#GR#XG{{Nvo)lonTM)5-YtY``2_?6&&}EdAV;!RiL8d5c zr_TL8hFwBW6LrR_T53xNond*!6r~z5t>TV+y~4~}D5asmg~#t3;_OT{bl<9M?4tQ% z=ZIb~_Cx7pAGkK|ZmawADGNFUe{338b@QSX(WiISo3EH^u|AuAv7lT%=lk5k+H4Kw z*47$t_z|SwnJf~rw-A7Lq%ld%k zm=k=V3_+9k7KFhzAM&g&>;y)|DVy7IPms5UBqXvnl(rx0P*Q$%lKmQ~YDcBYg#JvW zhpJFazcgY7@C$r#$9MY&wOHAL4V?8pme7EoMl0=TPeB4`!79AHvKAe^hs!?NzCccZves%W>DITf8#h%BB!*uJp-`k?Ycn z3?|@Q;X80g%7%O9XAn)uj_ETxRNa+yA)N0eu3G_*=!=348lv z&$MHcsAall=X>?b=2X)n*Bt-%r7qahbpl{rKe}N>tV*2Aqd?%%%%87fRy zn!8e>XN<2(yEK4PngcX4BdU=2cH^6kFj3W0)yoXsmE~shS>32)`tIvp5)tFnJ&1?p zJdd`UgBvfN6@+G7^QoqO*~1*=v#Jxf0Zqq<-LmghfjEioP?Nxb=sTZ+B8*C>_CSKS zoasy!uv6wNcNC|RfCf)J=J(BEd2kpVvP!PMapNdIUiR%Dw2A)WB?EaXO4@D^JzuYx zxY%>KXFgMH90Jhfd_>k-nXH6OmtL{<78Mp%E87R@wAv=TA3DVc3bmLI=is_h&vmI88 zZm-2e-h8v7oRtn01YX>`)vTn*vRlj=e?^N?e2r|8LWj!mqjdW^34^a;MthYG*$hD| z4bfMl5{>jS)Xvkh9(av1vYE!M`DxVty?rjL5g8KAy^-l)d+$oAgr@n`7rHdcwhaZi zIYozoOSu{=yY)xlCk$XOmwJg8Zm3ZhF)DeEFQCe9_%t0y{NAhIN|?2GlVnvpZVq_^|%`wi+0<6q5|bFVHOpC0KExyP9$$^7SK;GSsg?dR`9^(s^c2J zkrO`aWI*bJ_z=CeMbbY$zB=zxODMAqwZCK!D(Xe@Z-*CMB9Hpkk#2*(Bb3!dVt{sv z$PgleEObqx#AAfbEIB&nBsk%3<42=^3VVIs(Z+CYHb*GNu}&C;L0Rx!z##0-I@S_< zYbVNAGvH$vMLL@lEI%^Gwx>gD8dP_bdXq|qe$h#AxpK@x8SZ>;!}^q+U3RA+E`K!$ zh56Fk-C`E9H7>Tjf#F`@T@ggpL?~`dEwyK%lPs1jH{`PsO*fDiQZSIwBF>^7 zt?43YI&UD5fH#x&nb+ZzZlK}3nLjfmQGxV0bZd$JoISoW4WD*}6%t!m2tWX3x5=fE|M?Wd z{cL6>Rk(nJob(;~^Q<>l-0>rB;G9xeGn-NIAQ&(Sq2wBc>>`Va74*ZZCEdqyOkU9Yrs+!(iRbUXnmaS1q zvlOy=zeU5g2ZDxRU75$_qh?JfrdNKBe%+Rlxx3l<<#9@>XH6pbS?KcL%%^9+vB)AZ0b7L-rtZ=|Ry z6IL2FeU%;eRrNkp@auNrVOU@hevjAqP?0w>eIQ|EQTDJGw~&%c{YAd%sVet*^qo=F zsawAfZ@b;C=>~uO^af&n`s~;%n3R^ceuG5pyl(BiNdx=(CDKi7Eb_8r;Ls{++bH2h zbdZpi(so$>^pI6JqSaT5iIL1*H|>(MF#nY!8JWN!4VcS1>7?ZbolK2n_~>^^ zPSiZBC`vqBhW=<}Kh@0G+a>my(@Hbd;qh5lb>yK{!C2z--mj_kTc?_a(eq4)G+cNy zJ9q-;?`M;w=eCT>zaFG08A{|5T6(XOo93!VUmh=Qoy8WlGWd!MeZC`bTaadCMV|Uu zXK7rO9zy&*Sk@8^_3MDI+U0ZOdCQ)%lD?zMjLyWMQXRrL*_)r;NG;tmpw?&KqFe;l zRujIyH|^>``s_v1s@Osn@Z_oF3QO0t%R^Wb{8N1W0E-wj*oT%MIp7JCd~0hVv%n$|#WDvBoAt3S2wp&M`-Ej*TWmjegz;yq~Y^FaVOexS0K5kCO3N`sbw3p~QxfAuoeGXhk>d zs`DNj;gWSiXuN$(a)wwup#AQW&nZn{uEFp6BsUQYLGMy})bl)~nItSiNfh9F*~JtG zxfRNbh3eP=Y@aMzmHj}JC>9`!xJ_9E;9R`6vOL!hEQXVZob*1d7=iA2l^B#oj-NcT z7VAiKTE zSI5_tCQ2$AH)EBG$4dzh@EAo_tSKZ*@E`pMhYhvEtf2~tgBl<1N3pz@AZsFw`#_-q zv2JucnD7G2J3y|YZ#U6fA^%wld*_|*f}kg5R0Wu`JO-J7W^;)5yuhF*a6EX3=fc`Up?}HmdJVg4Dq$Wu%ZQwQ0^vUn* zjT9rN;{n29fV;0ETC6?~8J*|_3W8pq(DxN28_RNkQ})jWF4tjNk-%5_X3~9C^h9Be>hRv~4K0Msu1ZXGvJHr|4|=!Tb?0 zDhu!r&HjF|L?osYT(sVN#KhG5*^2Lj;xn8=FSRRk&Yh4#0kZ@8Ix0Ud;?KEE4=QZ$ zK|Cy`-`SaqA0{^kYygb)3h{;+6+Db95)RrQd^~^h9IK|W49>&|MaL$8cno!`KPf7@ z^;NI8wIc5lXIv*_RqJ;)SZ4!hO`g#!yrS35qa(3@nUr+bd0E(EkcNK90OBALQM_GP?z5LSl4e(CXbQVW^lY%11cs{x+o}4Ehrqi-q1OCq*PdlI*{)JLz1TLdH z{>g0s#S~vR?q#HVf9uHyD`)sZC81Fex-Io|hS1KPP#HazU&X3Tb*Ut5Y=(InckkA) zNkBqBW10KRD{?U6(nLcVNcp=3hyCn4Knc4GetJqtvPAsQ+DfxTv4{j!7BUJTN27Y6 z^jD4=%-%l8{0IV-`aH<^j6Ti(1d*-ZjJy!lnv6tu7CsX68)ZtCy%nQ>#8}PeHs&xi z+UQ(#7z7-iobJ2oepf5uj|}A@(>S2+2tKho%h7KDx4YA#`^<_Pm0?Qp>T}S7|HYhT z^IEKM@ANg`7P$u*Uo&@ZuSWsYtl5Fdcr#iG7-iF=hT;g{)!|jYY}jwG_rmy4R-GM( zUxH@E{ZZrTGywvZU$#P(*GwI%%N5xKH*|K=`Abpnn&4?LTb+iWKD2@h)q9HHU2Lhk z1QJ|E#xCS+Jt^Yr;jT=ceA2g&b9C&DQ24u0=Aw{n{d2=7ld~PKOsP1FRT;jKq z7JsFj9WIvd)rEh+z*?D}I7f9Ah7q=}kRT3p2)89G@f@nG0qv55rgcLS7?d)JrhJt$ zQ^7#VVT@KzMDnYk{+ayeq8$5|_G&+s-$&CtzD(-UEFt;W0ZQW4xEOlHbmJeNnQCpi zO2vM5ijzriJnZ0|MVArv@8N{(i{0+mHD7>jH;#{RLdvd@Uq!n;4^)bk1F{K)YHAId?V^pZ~xF+AQ{FZqtaFX3rvo*MBrFB>9_jAtDUiPAjMrj^SD1D9j zMwT698`||NoLwD1#LL0mPTHjFq8VG<1e7s-WidLZowI~+5gSv#Yfa@2=X8vaYs?fz zhZT05`IQ4Aq>VgrfTA1-<9S+8;2lRodWQy8#?^vDYEr~8c_8piL%)`hTU1~g_(`>9 z@hWSg6$d#KeV8M615Qu_Uv9=7+dp3&|K{8w>p5pJL5?lXMf6(J(4v0hUddkWjrn!K zip%Y9>2`n%5j332iXsm{^AE8^@9GVw%1Joc8`*B}Rbs)P?CeTs9Tl@PC{f?vsx5YE zoM#JWUuI$}MwfIu!yzauaPI#hlPm=RIB1Z1>%`1xiOx@-K9w3Y$)MR|j{{-AWw%oR z!*7^FoPhzw|Mp@a=i$!M+}!+o<7>sL3->iNhg|D1N}nF(5VCB;HiK1q)&a|zy=Tgu=OeUkV(p2?EOLY+KXAq<&&EW* zCCx1(n{;!5jBsf$#*^gv0J^3KGcY3ee&s9>9$QbGym#9w)pGih{kizSL z2cx(^L!(;LT_uY?WVdNoRmC9ZTK8M#vt9W>1~$VR8(G`5ls> zf7o`ECUYS+_?#5+pFXV`X==!HZA-95hzlw4o9YJx-wqWZn@d1ESpq%WOI#WO`R=v$ zI8)_P;h1vr@=_8()20l%t2L&i8u#$zL|>K~4?w!{V2E##ESg%G&6{!94F~~ya)j}?SGA~ z7*<@DE6&TSF=ERdI0Kvk)lLJpr$W$$0uD^`y*?kMAaP}*eU^Tw9hFi1IlmL!H6Hd^<|nB>$DNfAkM4()q!Y}x;b;#kF$FT;3~@iWE+Ne!2Z+=lsExZb;yW%wdT)&aDZV-+uJ|i z^v@4{fI(PVsCgsmKr{vu! z&Ti2uM6@51q5r<{XexaF8BWA@oXNkbsDUPUeM)ckvhC*^R{;7lSl|54zi==mFa9Rg z1p02fIlT=I;2b+#^n~1pH@N}^>%WMJ{%n5KLQ@i;)6HMXa<3qHQsKC@;(tFI_+1nY zBvl%@PieUP)%MTNeB9`e00%OF>-O)9^W(qCc!4jbif;BlM@)2v`_Fs%Z`Xg# z#eb*iZ=v|FNc>kM@cRD0+a*BcZGZ)bLrAD~;P!BTIrE>Ju&Ss<@gBLrq#zmQ?7@Z2 zjI&Tyksaut{g<(kW4nqW+y6pGPeOa2f4W5E_fY)b z?~X>w<`x!tKpKXelG1A*A0f7mvv=))CUU7Z{EvH=R?C7!YrIe2KO9U%bu4djZy&9?*&QsR{%2Oez&x}g2p8lY&EzuNEG#Sa)_1LlqV z+qX957mnb1rV(|^6I+H-tSe&L)vdT~g@K%)KYq}XM?a`~tD5>Rc8=}1iQZ7Om1D2i zJ>v|8!eUC-9wfr=xuDT((ZTb~f2%2JHy^Vx5Nhv@xTGo`Nc>-n=Z`aWPb|K7U*Oh+m7o4qlhEGzDK zZE!fq1DFJivMFZ0vxtB@5Ky$_e9$oSxPN&dSwu|3z$_!S1|bCao34MsM$ zM{D493`yuI!0G!1%Kwj+8DK-cXXpl0ZG@JQKRyx?lEko#woyjz!zBbXcs^qk2Pk?Z zR?udeC5Okpw%<_?3ZOuQasKm+T15PK3&0Gp_zo#iV?Zb2c`6%6F#u#MsFn><0Om!C zp$`*~FEUndt@a38|NZ;-?y4>rGa_uR&QeMh?`Njqsq{YcKV!;zKU?td#OrKaHX$d6 zUGMVIgdq}eGOnP!T~Ad05fAto@XNHSzD+8N$ILr+kv~@Oa+rNXZy5pXlq0|@$&4^~ zWN-NE%71W#isjaqj@%zRs^cL^)X2P|AtBArpRHk-Mf=9cDN*p|_nw9so%n4B=j>re za(}d_&C43&WCPrBMh~E+sd_VsYI&eBVdjI+1t?3W@2BvT$@`I?TXoarO_`^i&@}G4 z8A0gf{{6nv>oExGG!VI|zdIS`zjb%r{P||az|)`;^n_{sg_pW%Kt z%4JzvajO=Hp8?*MC3hRP3{hW?#HFUZ%jnVlsWPo>k>;{ALD%=bjtFkn;1^b)F%3cF zV7{jYNoFq$3^X(}cBd=!GW;}C&~HWs);{uhpR5yfT=e5AE9{lmDV5pDs^)Y708QtW zb>FF@*Fj-dMU2xZk53L{BR`9y6c1i19h~oXdRxN^;eF41ta?dO*v8&vXo5u&kjeQM)t@MQ##Tv)(PJeF##LlNkSOZsF|amM|y1a%KPQ-CDO4Zdtg4VJ-&S;wx0O%27ksIaKxY511z*^T>a$A(`v+GwC1NTtbcK9|<^grc(q@iWd@GIc2(fp7CgmcMfufh$m0%yEoK#^(&^qO%>O?cbLu4Z6D^l~9F zR`g=_Y)~A=DSG}}?V90b(}K$m;6ZzXLhhzwYe)a*TV=)PcH!IAkH6jl+g91TbpQg} zxq8~+d#R+Q?<^w(__a^B0|i|-*CRd6YL{dPju{Xh`{f3_Ti3+?>S2?-ZC#R@>N!a6 zX&0mT*T14Uh}r+#`*UjUUxR(CF!o25;pphM{}Q3+xMzruZC*t_yMcfFklX(+xmGa^ z4tV?n9I%$f|9trGZT>qq|9i`g-j@#k+`2SQv)4X1J4KU#&o-OD$nUrwhzRmE!xELn z(w6)$7m;U(0kx?Mxn(e;FQ&rTY{aJ}_lEE;|YT$5LRME5~WZy8QX??WZv$Nnjs#qW!=P>QN-}M07 z`GBir74z$_RgH#o))Xv-DfoqV3Hi@0((Us$yPo{{xXpOB4SlgWl9d5CPUg-4JdZ-U z6n(CWx)GrgM@Z*Eht6LQb@Dm$#4gJ6&?OOavjV%%7`S#kSfm*6-7lkv%i%I8*+CWm z>?OH~xIjae@@h%2X*BDElJLRj&dUzyS71KQx)HIynDm8x1uzL4I{5!mteBAuu&1 zLAt**D7komA6vb03LtKVgC*R4l zPUX+Ab={qmRG^ayaKsRCQa|R7?yQ7UI-KmiAI)C0a(rOK|E=M^0U;Mcu*a?!-!3IX zlpy_3`GYgQvQ&@sd=pwL0ZQ}vCfflU!jHURL;o;6kf^hAb`T~qX`gknbw2G`O+Mt_h}XrntJvc_g9-4j!J-5 zv;o^UElw==gs1@;d-A#b%LGF}@Xw0G?@Mm{sn;pR{Jb(?N`Kuv1W;kp-@9fEeW$!( z1#x2O(Cf!{ElcTNe{&oNkN6HSc3OdwX_ih$0FZ7{RhB$yMCA16^sK_4{s1`>h(+6Z z0Dlt5&6(Tw2|>n4haSfToE0o&fbOo2t$|RIHMri|8qnL1f@uw`h-=Zcj=yF{>~Tr# z6lRQQRQ7{s<)8FAFZiKacpYF8lwDp09BLq($SP1E)SKlt7npAjfZr zE@j`Pt>a1y3K-13j;L;L{#R>yF;md>#2zp!Cx8P?$18F9I2aqf<&WAxC1}#v`fCwuaDbT(%CIB7;bGMs3V^sd z_#WSc5=>w{NjE!OXlcRt#(5+RxmFUxpJLVU8GLreYdck*-BN35~oi44jk zmxP91EA@bzp8qD@vGba~14yj7)+XA@i)L+1y~m1qEGn9OxQ9IOw&oyXko?~E-gs1| zqf%=CwtpUwzgqXOqIaYit7Lz%KezLpCQf1aWu1bq_1B~c=Cbln$%Z~VXQw$2BL2Wx z;h@7?#RgC>3YJ;!nqc6hs|M-xp9IbsY198#c~|}pb@%tx-J)BzTOn(uP+1a@UD;YN zO^J#qZrNh8mcb0A>?S27OiY%Nt&}B8$r7?}W$fEnVq%PC#+c{*@qM1_`46679@o_` zx$v3uIp_U;ZReaD_XdjC8%(unIC>GuZvTV3m$U~#+Ae}|8}}`qd7CpCB-J+x zXXqyVb>S5brA;^2*jOFQArAvlv>w=@M!xew#l648=-v=S4pUY8?|ut#FRK%A9VdfP zo2fZp@+~h^@D!9}TB+0eoyxUI?mQ+i_}9+INM|!oN;xS^1gG3Gdq4+o6=?if0 z8v{KejPMw1`Wi)-{bKp2Jt06sq`vjkPVwV6^)zTdlxf?&UpbUrF|<5C6_S3htKinR z4p1qQCF{FK425nKF>2&1pJ=a*KU)jhs$#osAHR3V)EoYaG)Hmd70@y-o20&{P)4cu-M~<`L*}Ovb@`+-ohcF z*$9s*v_ri;rD|jqZSSy@9AR&_)N)ke0p>%qYH47dhd{P#lYFrW!l5c9=MFunp1QdR zLw+S+AjK31nu2A2AQ;>>8o=2WS;D3Q`GjRrZBf|TXZ<8bV@CF~MA8&&ve@LlI`NNA z9264gjd_gp6fYZ zxweAza=(@`MsK$n(>mFrKPIdJL5}i(;sM_a@Xrc|f91C{mDQ;+8YboWgx@lZS~i=f zbx7H4F!lauc)zRCyrg&A>A8v*7q5$A$~`m|Y6LnFCWmU|IW-(4U9}9Tq~(BJa`_GH zbcVWh)CjTtgM9_Pr%7mcdX~o$W9LlYtx)j}DsJU-|LyetFGbFsNJ&b;tN^&>oXJWjw_5)b{rX)3(yw7Y4CICGc(4JVS`NgBlcA42 zMC;$n+84uuqxdKQ3l8A9PDV(l5kVq_T3@K#g0??oWnIpkP{h9=>caf#&GiqpRm5j^&+=lzV?x$G47C{MGh!V_k1mNXxifffEfzf4rN4 z?`MBl-(XT(n?@<&GO11pZXqp`AR@K8xd6%M&RdzzB3jU)?&kZiFVy#5({X5wdkfQ7 zMX?H&o#3#bK4q!f()Nm5!Pgz7+-@Z!kA32YU}^eY6O&;t5b#~sa1hm_OaMN5mvqwA zLe%Y~w**8i{JLFoa^uPE-(#g_-6yDd$hwg@L$e&#L~ zX`BUy-{^K5PQ!m&wmKR~W5KpZbL$mE@R)(^(Ke2N`+VZ7{#0{y4@qI*1*`tV$`YX_ z9xsk{h`*NkE5Bm=>4int&yX;o!>jGShO6EsGd=++sayu!7-QuenKs)-BAQLN;y1kL z1x@EGqU^IL0Z!3aU=5avf8s04n`)5MSJv+m&X;$2Em%=lgmS7(ysI-=###G18K@mv`QdO5~Kqt@CayVq86 z>${Zf@?`b5nlCTZOHybJvK0bS10qPeWDof|gbnS?wkZjn>ty=cI z`hS_78VOMeWGs32IcH6TPm5j_>$Mx5mDTLHDC)gCq^`E#)dQ{BNL5i;a}R4tM4vCc zt(hY~c~MNo>4_?*{Gq9=%Mluww6ySB5e~IwdiAcvc3Rcsc)DJv0cyAaB)lIh0bLV{ z(hELOA=}4*D4idxC$z4t085If|5C!Kc6ay-TxbMUbw0(~EAUkw=WhGFEY9r&^~ppF zUEb(Vcg(rF6kColm*-v*9F>l0YDDf9Rh6^6nvLoBJ!%(3yLk2ZHkS8!rPvD@Hs&Yy zHraKwraSI!E9`S@rUz$+@A@GD!tTsS>+?R_%j8$fJGmqI3KG4KOBYtcF&duVm4L2T zpULsv)sSu~55>Sa?a>VTXeRfWZdV+W@C^)Dje+#R&EwF`oq2b`CU6iUGRPTRp#*<6nqC# z9F~Xh?4o8>vWj1VI`1P)K4iG2p~s_;ML(a$hN(ZnPXaP={_}j9s}1FSlfngn2)AmN zg_1+79~^*pb+ppFcJBnE%i6;Kpvuj!?K-!lslQ}h2#sH*r*|U>8|57k z@|Zr;eGK8BYcg2nx@`}X)SbGxB{hBTRJawSEoZ?HWtycV;h5y1Nf9Rhs#+-R;_9;M z&zlduXWnFGLfz1oT=^SqG#jHifA(7_Z!4ibYTq+xzb`fB;KIB|+x6Q}Sw*mLRr>@A zC*Q9=-BH9nCve?%TO=$oeJl(FEg>(P*T(tj*Yy7$EN*D^&2KNP7+~sTmH)gPT9;m# z(VfXAb>LaO_$zlFMn}iD??}Uiiu+zoeaDDWZuu_sGwGY3tlU*}yn@2k5YYW~E1-<{ z^x58(_?zqTE20^I9gDZAdT(@U-{V+}?tK>A-ciffU?RR>O)sLgXl6MQ162c8J`b6~ z592F*P_7cRq>deyHhvDD#glHmaEL#Lkn#Ci;q;=@U5a^uCXrlQ5h_=^5@r+AnT6`z zk@g@|0xJt0CeRsF>vQmS9q5lfjY0_+Y?w7v933qcUavKEa>RMWvKf|Gf6)eKHS0q+HGl z#v0t{%6=r`Q!Q149}eCrvVLh#nxRRepqSLHufL#T<;?OM&yDuge@WC^TB8v2SNo<| zr|!-7ul4NQz;AM0O!g7b!Lw##JO}LzDaw{(#q|lbn#awM}F0wWu)6RbQhg=Dos2_ zYJ*QX!{j#+lyY~kVlD3=1bZyx-OfL|e}ZR!ZMpDsT>8MC3H=C4{0-k<8uecQacZN) zu3L!-0mg0#81j=XGKnfX@~*H>geHHiZx~3m#lpvv%3^MONhoAC!e~zR)hL;lW%F8I zHtE}=f65;?tWk{EF9kB^jqW;8jP-!#7zp&KmBCZAFo(goF@>>!#(Vgdu` z;Ki?g>wj1U+*p0w7BushOMOJvZ^!Ou`z(?QRseQ37Pr&z(13HxCoba9qU9Zi?~~?~ zT|z_mg34UUbyvxG1e!LHY73f&&v_w(hExC#1!cKC$>V%yoxxW9=;p)to-YDL-mQM(WD! zJ0n!IJwaPF^f~`^Bki#Q0QRq8>ZF_EWn?2u&IW=~G>K)x*t_!_a{2T*spqzIOW`0K0V7*s2J1ALWhSYy|ZoCE3f2 z0bnNCxHSr-BXPg+*tO~}@G~hb8nf@pZ36B=jHqCMcW;f-(aLY!k~&)W)<{n+N&LD8 z7A!+r?F?K2GU*vBWPSH^ZKMx+P{F9Brnac0K#(esua3|TCDoXh9yl%)+v2y6%Sd_+ z;$A(CARy>DY2xoKNIJTy4 z>UPFr9tO1&i0i_m;bL<H+Y3udFFTOJr*>L z5_{gs^SoZu@H8y2CCVQ0gR6&wb zaZqftqgqowzmYacd?KTpBhSEs_9*Z7+RDU5IM`@+Y>c7>kfB0m+7<*4&2z5dnIElNCbIYr>^gU;zo`zrFpf_*7 zLVG}#-1rl!wZ%8CUj!87KB&u`K-1j-3#Hpt12?6WhkEer6UrUaI?;DAuD%ab#adJ< zJP_NwK8?bkFCN6YZW6UkO?u?9XeJ1{hqy&lzHfvI{_{UR&YNqS?$7_Z#p5Ki$-$vQ zbz~87Gnlr&nG1j%rk*etz#eZU_gWwdv##PY7YE0UhlnB7cX3-my@darnV~BDbtpKU zPcJCE0C{5!^G7~PTvL@=58U0mgtZFA?{#G$GZPWJK~?$K;Ar;Z#9{f5U6-c8qsF}q zkl^5$Lm}3dn-lOS6i_J~7Mff~HJ4&CcKP=UEPZAI({>Ii9t0I(<=ZCzv0Nt+5rN$* z0qYK-@v3W}L-r+XKH7JLu|Z{vry{Il8oJ33?RCfIB=|MEpl8@mgC=tBrF)rkAH4Ry zr4Q^e-3=+*vkZDZJ~xDyLr-Ww7;HhRi@*UWSG4@8TppV(RGZG+UBO6}F*hvfAS zme(AfFFll8-=Dx!2EjY>I&BMwlNDmSIguLBz&&CmjZ0JSTd&tFG6G;yn=+zgc`rL} z?YVDlvdsMDNSyEirPq?GTGy&J6wo=k3o}-6tO9?bxb$gH;=ii6`pn;KJ5l>H1Cud> z$gZZ2(MZKqpiM-LCXx+)VeR9|BJJii7Jm5yae|Ea<_%IO~aHQ+ta(Z&J!46jYsq7EEpo!`;osp0UeOtcQ zi4gnO0t4X9gpY5(M}2wAb4#0R6gq-i=*#^$=A2JFoGdru_v@TYipmg8*|Eh0d~2UR zsM(&1i$5n3A9u@NJ6=EJ(U4_I9-{ZQecxwEHy%Fm<=5|c-{xCfTM!i!H#I5|$mv;9R*{Ck!sNf%~- z7-u~y?53s`v~V9AOIqpUcp)nKgO=$w#=Sv5M=Y&bjpoH~24I7{w)G3pRE1B*a z>~Qw;pOW7mb2qhK`G;*a3qE-O`7aJV${}Q;Z~u*S5QTQnZ`^9*^*MQEGXWX?xP?gD zaSB0qW7Qj^_=!FiNH7g)_u}TmIV!Sg5)~{(kT0;hvp^n9ht^u|dH#{Ob+;V9v?G6C zhw+rv(jH!=eRo5C9Lp`bwDG$n`{L!L9RxHde+;5D(c~i~sQl)}iIPMCvM6z_O`kUd zvXm!wPPSjJO7m3acJm7PS;!tuH?~#@2-QzTpxkSk?3{Y`uwo#nyH6r`P zCP{Nx&@_caK<=kL7g}_QQ-o{<4H27|9>M8Kkht-v3=$Z@t-GU_WhU=(E))+sXQg=V zdL`V>kIyMt<$I=~h}0t1$`A@^=@rz9U$kBC-MCJBiLjDluI{|C)D358Xc?I9*l%IoQJ;zPeaLU6gocB z*`{h7xW-u=nYyx|S>srPZST~n-oQvTk_}qdrVQE7i0g-JuZDFP^;&zGZbW&E)m@RR z=zJpFeDQ}8pOAovK*bs<0Bvo4)hX@KCBz6q8d+B{W~lrIVzzAz7)J@mLg{{1e5`Rf z3d(Gi&azkFC|;(hsD__|V`8%!^6Y=`XNoRy8oRCaxq&!~?<*#7!UZ(s{33u|SF8oK z7mGo(4XC=E&l<&bJtGhq@x2P<%jcr@$V#)KnY9#c)>0?EGBeFBhKs|Hm&wU=B}3#V zN0*d=;3aE2nKK)lZQ#CsUk~W|K6LXTcM=QdaUg^5R-k>14r6LZ)4YLtR`xGjs~=;0 z5l_!O-$RttW^dt`Dn}v@ZCmy{>&YvfO!C1t3#HfJGqDg&clSRGJCb}K5-;06kv#1G z2_nHW$E&VN+zLc5XWVylYbR@Ad+WEAFX-@J5)x=OYF+Icx##t%^rs>^vPy?r-Zi3x z!xOP|FKSJ4g>mf#(GK%rc2NkN!5e{9iFb+*xV|eis-;Boi$}{7P8iXT=&;8Ky6)bZ z5WC1X?DGfvDk#qXOQmCD5D0 zP<%C;I{1X3!;l>^%3Y4X3i_C5fy5T9$sMZ?cTjNa%AqoJ-EGMHbO+2QFwuTE?|M+kt zX~k6`-<+s>o?+lj%(lmakMtTcc)IeHtE04BWyo7~i7*eJpXs82($BCUdXU|UGq@B= z_9uulW^LI?vvg29w1Mm#MLi~4gW=pnlB$k7a`}>fl7`Pr0F$31sO21jKE%I(;E;%T zkF&Xieae|Fi=15~6SJ$p^eeC2U?lmhlIn4BGe@L-b4%BugEbt+&E&L79`-x4ZTz`O z;_(B$RJJO7;JeU2C(%1T!q(&)+-6JUM76>DQsN*;uWf*AnyUDqA23O2m+2k7fJuAunV0eNLE?;)v1oQwS5IbBop7VKB=N<1{pEK-cd(F$nZ&fo>8e+T4Q;nhkz z_#{u!8WVA_h`^^&O+z47!tWynysV$8Fk2N9M#CAaQ0(1PR(^p3vN%^0F5nfOYFBeQ z?u$B@44sI(uXr&)6#b8^^!}I=dT5;~I4z~sYlq28RrzQrORRAjUFQY=`t>bJ)AQgc zEk24DKxrxIR>5KaSbK%hdafOeDGuYaF;19P0DNJZ&D2d(O+AQn7(kIPCA;pY9%bDwf>)^F~h!q6Os zZ8$g)APW+0lt^#urqpr5qS}zr`Ud1Rnm#h@JB9&jj&t!Uy&VaYiuo?kT~`|v zFRYFAt_=Gp!#A&Dxo_QH2=QXPe<;#tliFjiest+nl_lhhws&%O*H0 ztFAi2O71V^ssmu-d(l7bmeeV4S)3ZNks~na;Xzv19!%MErg|JIjF0C z>VOkCu5vuyz3*w0T&+_GB7B>!EI<#9H{PENjqwZ)vW$8Jz`a4+4@qkx0Rvj{R%AB@ z`H(l^Tr7ZFn${MKQm``g)0ap;g|G7_(*XP7>;H+Tf60%9cMg?c;%kkfkdJ-N(99t3 I%*_Y?1(;RO}RIY^W!8HtjEiU|Y-Bs57SHaW)zF@Qi5 zBtruYB00235}GjkfPVj1cS6mbnmc!9{{K|H^>#^zbDpsG+H0--JX2A+MMKR%jY6Sl zZp+H3p-_}6C=_}6J}UT^SEo7);D5p{H*{Ro9V}ej?>d>I6z{q?+B&${TA8rAnL9aK zIoR`FxpwW!)yu5+TwENTg}J%y{`HP44o;Tbk@YDK@GbitWp$lVD25Bje`HT4ONUXY zGU9C+DUAnlvx9phj*qQwFFYAK8D^L)e^Tm*l$2D3|GRycjHPZ!-PlyKy>#|cugYa( zsmrW^?dY>lT_VqF-iTc?d_MpjX!>zC%i#{twm{mU}ESX zb7;LO{Qk~$%`AA0_Rnj76vv-ep92qS{CVjpg`)fOQvZk~<)4=)_o$HVzWhK*_W!fD zO1>^%sAE)IneA)NE**+Z9~-ZXZDilXwVR5b3>VF_CfO$_ZX zo{46(rTF%SbD`6iTxAw#PT9xKbk$D-?gXEedJBEG`2j1VO6x8TuNt?xJH*57s_$>c zcoDHZkDAJzCuZk1))t@Ld1*+z%&Ded=2)9U>?^eFzzIAq7#iuHU_Ya_di$T0lsQFt zJh^GALX3&9+w8q71{JiUqdy;r9`(y2L&-3cr=w8kUn!ymVj3ek_r|;Z`f=O&=de_v z$rtLy`ATB!M4}(8FVXAWV^dF(1G9~M*HQ?J-@bhlP!Tb$JJ?a-YQuZ~yG;1GTQ8HE zq9y#Qt@?|svWNj+d#XH~8OyWASC-f;5`55~s>rz5F3%nFoQ^XVW)pq)Q&3E9^?D;NK@9IQ)&9kTSf)B| z6EEyqRa&#Xg<(sQPgZ-<-gQTCx@SNklwh~r&Z`10bU9hKG9Pfre)(q~i0Rxh@Q3=L=G3JyK^ zBvPbGpMgTjFc56-U%7`usU1LOKRGWmx4NIC^J(oN|2eg~jLsYbeDO#`>}~e>* zG^uLnP0}jf|NioHm*UpOnw^_!no_(iw#CuSSHgFrjO&R?p@kwz9DZ2P4qw7AvGv=u zx5OOYJ-$UE3Ys^)Dc1df>013g8jAMYTDxd#@Tu$ZdhK48qUG%(zhW*;j7EOU=UnVc zY{C#5>nl6|a{FcA5q+ZYRz&33pR&`7u72 z87Oytw@rX~QesA-!rrkdL?@YKtgK9D=GH}5`)+y?m(mhEIl9eh7zJ=5#KRIBN7_lD zOd{Q+SnlfIACD^)+w_z)*|jG>vxKosfPLgi;QYBV;M`h%CCzoF+bzK+r<|>2wXeYZ zMcO})sdI?DE|YCC4pHJ0!SuXT2EId89%73Vt*Zlm+nXzd3vv;%Z34?PJ*}>c-VXJl zEUVSy-)^ylIB(hZ6_oqB&2(okMqJkAsk7NypUDmD5z!~<&t!P4FHIevsh;f22#?S8 zUi;b5`c?f-Q4RVP_t0GMDc+_Zo~7 z^Xhi~LZ{u^QwmY6Wi3-X*M1^fEBnrKTX~Zj9Bx^ z0{t6L;-hs~*v)zlSaTLZ`+?pqy}pKvN^w{9N^GwkIC+JSg6d$NZmv=x&l-HBIfmC# z;ZqRJTn!Ac&GwvE2hYIDE9(`GLIqu$el~2{iu^yZ92a`T-NU0>KZeNJUcxI9cq0kF ze}pUBZhnWU>$z-D(P{DWIZs^|UyD285vekkOHTlayy(rBQ^l#B!{@iL@Wdx8c)GKMYa!ob(V`E+qUhfYQ-awF{giTXFJ0e>KAO* z97dt&kqDBM=)2y4KKlN05~RMm=m`jTt1XLz?u0KDRRmvBvk0-URUB>2qvD#WlmBj_ zH{bNyxGI(TSYzazsF2f-pQpvWuh)sbwGf+5t%un1{90?G-_}$^1bcY=#M>v#a&0S@ z3Nsx?KDyC2z7uvFmJIfQ36M}8tZ>E7gohpM+{^sngA%7kv_o}YHL{FD zoec@CO7!<&dY-y&{hodCd3Mp!{9xTg;Zr+&5lfK-pO{g#cS5#aDcm)zqy*A@WozZ8 zXTqBvh6Kx-y27t#JKI!Vn5%F zh5Dq$%lc()=(h=B!K;oic*V#fUJ$Fp#R+wYY`?xBuRqZtPW>hzHf(#94E6ND&e$C> zKQ5&E5BvxZ^hpl|u%i?W(Y;^M5`xV3GtKo$mMJv)88XBMH z7Jpo|>*sS|BfQ|QKE=nV-#i$hiWYIVC%Rutsm{C;g^Z)PR%(*{h+8v%PcK8tuP-N! z_QX%j_7w`q>p^BWrKaPE;3&O7d*H<7yk)+WTcOMXqdcePL?(K3Mm^==ne!c9n|OWl z2_{3amLuqrl`{Q)uy3C5r>ZB1n5LQRPknX2m+H$`U+3;=`{_wII#5Q5;+1jd28VKj z=n%`?e$6bMYwEd%Vm(vVo##Z{=M~z1@w*t(PIjwk2r}?Y3smvZa!{~B9Cnt@#lH8qk}_P=rw>$7k7XY@8(y#zJe6p8ei0oPDiTWO zb7d&sV>Z9u)=^c;(MO^)WTOOcUsy~Y`6{}t;(30CMuu9Tb$rF`9G?XJI9Cm0<(S(1 z4Au8R*cz9q*ES_)CN-JDzVN0mq7bnJOz%V3DG9;^&aL}SrnXGIWfnOm7xM&DKJ{j5 zQZ&s!KXAM@`HkJP7wXz~@R`#Il1qt#F7_XXoXN44H?qcN+jQPn;akc*uGY{i~WZf8fr$ z^Za)5`f;|3ltsm%uCL?-4HUj9UeIvqa6=FdNtoYP?iulp$*N*mso+5UDS&g~UU#4b zl6^fq#a**uvb_n2{U9ii=-*^a9 zw1aOPy%Xbz{%?*puHugpY*!t9dyW-4e8gf$#OnZN#C=6J9sOi|+O(9g>m;pcNM>!Y zw6tFQ$am_bFLk=rzUk{0dme`eIakn(9DeiV$HA#!u2FS~HbJ}oA56@NcOtp;x`!MH z)q@sazF3Ubh1~UMg-suQi^Zoi7{CdY`tlINcOoB};kske?T}-Yt?|SvJsQ;~;-No= zvqoQ?yY(Hn$2~}#^Fejsbd?Ch-w-eH@v>ELQoc6 zkoj7xe$jYp&xy{XeRF%Od8Ff0S=Lqci|(n0-??;e0qTWvr5+VE%d+$iA5s1asJdpc za&-4(v1ou>y>(o-rD`LOp1b+L=2(s)wx9pFj_c8Lu8AVGtUhn-m$v`0?fA5&%V*vB zm828%+CG9^$fo-;NrxoM|L{}u{-OF4N!{))17-Kwgf2aB-`-rs*S|QyRS@&jx@`P( z=67r6$kX^^FNxvm7ngpHd@8Ze#6M0};l6yK-ks|jiLJauFe5o=loiD~$GTJWv>zT2 zuR$r`8YCeMuq7%%bTD6_5B4mn~n1o zb0&_(!3KLIxME`xp^TDSu-jk1db|GDbXO+6Ud(gp=lq1@fmL^|w|}1P4f1SuyU(T? zCL7-oId_g4gIyi++JEN_j~okBNhBTD*-itGrZ-$Gdl%*hE1O}$?1B!n*ry5FbmIWW z)+V#LPIu;iYL3)RF)k3co=Ig$3}F-$fTA&f5xeoLDTBjVAdDfk3P8(Beo#kdLkHx3 zEKO18>8u+sP6%)=z0tG3{gZTQv<#k5fHa$1vsJ*B;g?@wQJ)~EM? zYU=%YeE;b$Q+kr58>UE@BO1@-X}uVs3%>a(r99 zbQ8W4iXY9RfAkL}zh-#(5arMJnF3HoJxt>=pJ-8`tkME zCtja*msY@h4%-IrWS%{6P=|`jGJZ+H8An#UP~B@@P*<14JIPZHZ@g1Y|op zFHL^CE|K`zTN|R|oKDR-2nCNUAqx%pwW0;N>v;a@8~Xhlke_K*$M-KUM!gN3?BuUk zD;Ib!TeoUz8_=1?^O`r(j+TG+E_t?@^VpQ|$kc1pD>z2TIn~_*NSpid$|TuEAbiBX zZ4)U_M^5qpFD6^@BEolD>y3$iX>SF;P5NSIhI%t39LvI#oU-rKBxWcji|J*j8*^-X z($mv*dN_ZIMFxzi+&L6tJ-4S?}zM~X*Vl9UD9 ze=3yDZu_v^mBzJ!<3PNWgj7e2yg7w2C-qq-_Kj&~-|2coqcW~Lj*GE7$7#-*8I)eo z5OLvz>?gls=0-Vn3i{@7S`uHz>e^zfgacs2yM+^RY?5D|(-p?pH=fAS&NYmiIWph+ zk915It9-~XtcZIyv@_e%G$*y*mo1Fe<9*(5ON6UI;e!j6*t#bnfANJ1;W!gxsv`PL zlG>S(OZP|g)c&Dqi%5yTQ-Unr49(0pTogaMw2k_a39K)inojhi zEM0mp_O@vAPQ5XnF40vxOk4UUe+_xVf(MJyouK1awF<)>_m^DaVJI&??l~dab1O?3pGfJ#N9<%LX(hhpOvY?Ub*JSE`h9pQ$hW zF@!*O6FtMS3*%ydT8|Z60i^zW-ez-q%I(#K>ZXvnZ66fs`#=9dmR^0t@;`w$yM;NRV7m8)MR<^Y)q`)RuRDG)1LRA4MZeb!7k%yQ8=Boa zVRe-|uKGdx$)OmiR@#0vMYSRfYpPDZNviCtv*R(A3D+R88et3s{yb49*TeeJh3I`H zUaR)%HNHND?nFb91LbvY{W9a1r-e+m4bqgD3h#Y88xCcYM)9oiyLazqeE@#yyxF_^ z70H^a4Ycwvejfw?SlN$C3q;FDUEv3If%nN_7J-gbMP6W41v0g=c{bOVP-B0a!e;vCk~5`C?ieU?=9N?qz-GNsBqJCB&ck}~f@{u+XU-KqQ%O@!ToG?c5UZT=hRQo{70PWnx+b^hcq-s}%MW2Es)XigZ|pzZji}<48asS8gz;IC^)$fE`P5GG0o+Z1#U>mUIOdooE5I0f%k~S}B}LZlrNPmMquK={zv<|& z``+01(x4&8RNHL0m+nma?|e>a9<#)oE+|du!NsvA%a<$?_K@7hgb4{^UbCA3bYeLE zjOD=G11vv_^7&KI?VPCdiBD;gVr;G<))S*E5H?LC`l{V# zcb>vHWR{{o16kWg5^^4o25PN7I>$nRQ)|O$umUn#TC6!_X5@N$@MwHMe~SFcMIifP zfr>>#vgi>d%@+E00E2GsJo1rJz0C2h^GtT>x?;y7QUHd^M_fAk{-u@4pW6&ez2;`)Nh@8Yc4@Y+L)>@d@kxam-%VSsNtgh|Qu{&N#YAtDH1;F0yp~EeJ@W(Q9Z2Oj zWYLKLUVVvo+6+yMF<)S zkSi2GRySFczc>fvg!IOOcCp`Sx+LP9PZC z5g{s0*tLmy4RV-brP~~CZE<1;l$JOU?E6dXY))_++D><79M2tViQ%mWjGCw_&!=6S%UB=%3ds7E^$H{B!>GMYhY zvJF#@2*Kbw=s2}{Stmg-94($@@!R-zsDoQ0T_xBGLSpF{*%e602nYYLc;pC(V#N3F z-+w9)g?mqSFi$|oLt;2G`*kR$mHpVCy(#-v=Z=ivD+USQDw+g{uU!Z>2GI)84&Hm| z+pU!ajAkYhpuZQ-p5VH(hbG}Uc_Bi?hFXGhW|OiL1%SBmIxFmtNmXqxvQoznu!r%; z#xtbwTHyB0cp145x~^=&>|L|Y{rheRi~bcLt9ltBbfZU{RZG=~)9?o}uS5u@=sCnM z02k}rGsvo-t2QHvWYlS_vAjjQ#8%g<1|*m~>?&X{90-$EESV>)0gHVs`5W*u>bJQW z7y@XLPlMd&9N!w__Fd*TwQmj3(&McX+eQ4^dk>xpOZr}7k59Jkez^B=&|NNwVqrs? z4emlI+GJ<0M;B8is-GWrXFw$jPs>M~eiqWeAgTE&4T{fmQn_3@c}X7g-6P4@NH(J3s19qBXejQs>R_h z-|pUCgQga*E=4+`ra54jwE_*%5`J5!YH#|;%5{mk%|_46#98IjEFS)XOeARV);l^4 z$k8`L7;em||GCQ$dALhDZT-9JD|3u*%&mHTGt^RWE%74FC3blI2@+`0vzt)FMGmjt zDYcKc`u6T|Gc(jp+4O?;aiA8c2mXaEtUQpa6Cj;?J?XBzpDMIi7ZrPhA3&Q$!L%<( zB_$qzEE4`j-0vvTO9$Q1Sfet>w|)7hw}PjDzg)3M8iE3I!Eg%_yCOhCyTwejeMN%; zTA&#p@n>yF_MiMl)(M1t%&`k{(SRlUQk5{u(<*JSvl3SF0}}z|J^R)sH@3$`57dKT zbA(ny7%&7BcV=q0A`0~nSe5i8f4|ZC=r_}E@Ns+b%_Vj8GtA{qxk!$_o5~ZdiGl!Y zi$2XSjyKzguTQ=|l?(aR51G(eQ%Y^1MWzI?up}Y%idHgejG(mG_Q5%C1Md65~8n})aU*L86A&ZEt2n({ab@wf z8cP1jT2^F3e(M<-hbyb)+?qhDzA;Shm>OBw^V9 zlo0+)NHwkWQ^RQ-HzjP8qhD@LlD? zdnw1-m9!-e);)U*D?-zyIX)8kP_nL_jO;?kn8W$~D}Yek*RgV2h6rG^k-e7GPFMnyn2lY`&25xb2Ap*%L>}$2G&~K9(0(hs9ed6GnZxs(!RSkRH+ zInAz~u5zZPXA8(zx8GQM;5Js!Z}@Grh(*SwxR`D3`-1AJ3PTg*HbsB((^K!GVvmff zH-5=?4Is-1+$)3gzS_*pq6P z-keyF{!PJ7;-jU`!?;=fW7hPh`EN;*Yi@|O(tq>49!5+SJJ){)43PX6 zJ@Ng?e`4S4&anMErSkt#`T(kT#MEF{Vn1k&gmH{kwl%)&1PRrqKSXMnusN7+nUB|MW$D~y;I{@&T*wr5u(^H36r1zbhDYIp z1(O|KuxNz`@ZtbnDiw zC*~e7SF(iv1LWKyi3NX1IkB2#`?CmYY;dCpAw;`dKM05l;cSXHM1@5X9SA-rh-1pY z6f%nX0ON?0=f;{r!k&-!h3a?*!Q6kbyQNUcY)1!WeUwimD;IF)Oj*^)t?+g|DJu-I>tyGE&;0@n@awCldR8KaeofM0mp|K zK)nV}0aREyG?>U^E?4zN%;T3?q2IPI5c^8J#vlBzW`hFyQqQW<^Bv+gQxRVG^c+DYa|UT7`;CacTVzzacw#R=^+QKMpAj`)PR)xQp5$ zn{+?#EdBEKA%rVHs84}#&DvfOVpYe`Nl+$XG6|q(TvNsWYh^s|MPVvzGM+`ya%g;Y ztp9u&?rE3ZiaVQ0#RUs*qcWe9vmy?+I<$3 zS{kll&EleR0w2IVr8b+z9}T89vBI<;T=odK|B5_!>yVcpxG}>B^13OOEphpw6Sx>t zUVZTO-BJfaf)qzy+A6jh=Ruk)wR?!UKUx<9M&L0JQ{wHdTnDde|Rg>8lP z@|74V0cHR>iB$JucJOsVQd zaMwPI|84f+l~3#}Stsbd=>5mp9+~Sh#O6xA52_^g=6`an28(__33l2So3&WK=2-qz z@Py|q0$WfwlnD0JH?&{DfmAj0XK6@IpZFG)Y}5I>=8hD&99`n;8xFaS(}4b3Ji7_* z7X1mt%`eEvUm~3KXX4JP1IR^a53gJ*WiZx;Uh{z>-2&W+EBDa?cHfI_Ew_NJDzhlN zf@nP2nOe==3V-U@uo4-U&Rn1lN^Gz}t*dkR9*2G~Qa6Ji!K(5;Sfd;Gz+vjJ4#Q?E zL^cSL2Ayf2wN6T%s`dFU*iR19?DbV1i@2q!j=9lC0(F1TMOeiJ8=6i>P5{*&%U#+# z?uwh>fTMT|i#rOLM|xh1V5a`uk!64l)!0>6*xC-m&amuLK|%2CD70v{z8QS#E-&Ks zwzjWYxY<$eY>A(=0y1kHK+dW~B8SR>-2niKY!ZbLUG7vL2a?nR1Z@FM4Z$Y`>Jsad z=Q`l-_Hb1~1RV!$UjW=9R{?sVv1``7d2iPs#91-_8H!}pqb!nb8=CiPADDUn=A)oakq)73ZM|>Xzc7F%r z7taHY^RyX(jTEvV1g&5n;8)|LA#$SL3WBC_(_$^T<_4be>S)z zNUHzEEBx08{J(JoUf|e5egps4{Ci>a-vtRAn5=9#VArRQB><#RND%EqEXhcT4X~ym z0%SQvx<+h0TYqKa{)k%0LkfF3rdQ_&@dsJNMeB+X1$sxaTmewl5E z7I0tUOMo+Ju>p0EIhGa%{vYdksFdBhud1c3*b!!sZ#FQ8LDq>*DF zkn$l-Ul8(N^c>RJ5s$%$A3U5Py3{7Ub{p>j?M;Qfd^Hzacz8^nPF_W{1 z7SvE0b_K@*!m(aUB@$`~vqMHz@)Zs!K3Y%= zc#(EDprUJ#lmdA8d4_&@LAm2d;2JP2%}6^^I0t{Yoa}wPanVb}V6pcwb8rt2L|K1h z%#TXk-s5D4EV5rAeu1ANG$=Ku3xXJ&MJ#oa^;a*b#X+t>*mb0#3)~(&OfkHb4ngXP zOTY{*__iV)a|3R8JH!@Y0dUKr3!^z?)s?9&2{#l3lrg9TV`YAjF?aco z|K$ihC}o}#@c^R`>H);R&(S$J71ttdu|1YUDnDR3=!Lof4V1Ge)FJoga*TiR-=e4g zh6}0~?ll#3o|Oe8IDwKg9$x~@KpmOd+z!p{;4S`*bpz{sNu0lZEsqWnJo;YSz*a`P z4|&wbN%#$k%z@i3)u_f7vB|9v7WTLNrBF`O1FcvulLrE3G%$L-XI3#U?qq6lG0y6P zA^%${Vr9N;R6T&;bD}E$$1{$LYS!rho2pP398pYpM5579dw2drzA8+w&< z>a929P4-1v)G>KiWjdKi*mkPSyIYGq{Z&)xU0Qk*)W-|J)%nKE?>s=VyV?ImNl7xA z2_*NOI^*5@_Z>ebL~MJnR{Cs{2-^yt8t-q?RBnQ5cY8{67jNFa?5tKpre+B28(Ow_ zbh(?Y9U*LIVdTWCf!MwMVhikWl_At8(r!U5$%Ay9LBNy$*XM)(cbKXh6&z9|I^hEqvhW-brChR?zChrnTfvk6{ zG=pNmEdj_%yc)TY&vK;IU`27ponnkZF4Ah0rB`~zFmVuw>{*`Wmigg09}NYvWy(GkSO}{8;5& zsMv~5mALZG%!jS3QqWDcK+R`+#0k{SRQtgS3rMrR9dvo&K=8&wc^wNm@3$fWg2ozT z2OKo{;=aB**9@x3Y&=x4XBf+zAs36>oKt~>6I`lLVr^6D!2#>7p<5eFj)9*ty$KL+4knu215&?y%F>mm>8TSwAQ&}kzDRjP1_j> z$tV^G3f?*%Xdf`6_zHIOg!l=lf1X_nEu635x9x579jb0glyD#dw6lp7u(RIUB01Dd zQTzI?Po)WjVa%_S_#E|U_qhkwR?|jkwXKv-BovYjf9TIWM3BrW+}>H5lKo(Uo$7`} zWl><(U{ZM6p>j?D;k(1nNK?T;FeC60sxln3uo@@&LF0lgYc7Pfl7KeYt~-{L9dyy9 zsoghwOSMg@N!?g0VuYTKZp^0@mdRsRMp{&-mlzOe0uK>`Kbyl)mG2^gwb)=Mj)T}! zq8kUMetx9K6H%XQbdPP#_g9 z*R+j%ipzgBa@q|qLNw+fRxQ4qGbVu4&-nF_zGb~2!P}I_0(Y-4h%UIR!iFUh_`CV{ zmBea1m2z>bq36^E#(mvxmn21?^AJ5XeY2@k;74O*(|kR2fOgSx8xBz2NQ3Tr@wFev z7r}Xs&oaCyo71Y>SKWVwvX|co0Bu#%&M6ZhOzjNi8*)eJZ!D+zNvUS6P^(U<00#wV ztLNwo5&3AO;n~Y+zOz3_9~npNG#z|34g&YU2l0`aZ#m_|4@e_+!+a%kr$yYdXat05 zZMz&FP?AT!tpE>^&XaNx+PF>?*Nej${%^OK6hQdf9O}4_U`s26TGOp`+wn@ww7F+j z@@Rt0w{WThIu~yhx=g8&9zf55KvTCniT0&ECb$a` zLc+I%I*a2-CJ#0Bz<6sSVTiWVmAug&zndA_sjQRE4yvK9>HO3{BqO5cfeyh3)AFvo z=77Kvl*)FY3Z^5UO8JIVyK#U**FV`RZv%Z!XC>H}uvhjd0#L1*Nfu7Ar}fvWv%Jc% z#f{0BW)yY3gz^N2YmZMhc_VcT0EupH_*SV7XhZJjA#04?$Rs|0GeAbL@tvk(JHXkPP)`rE7BmWed)*m8?E8R?A zOdzciL-~KtXx$kGFMQsrJ(-H?n@qV=WphSdk6i=5)Or;c{7#Dau<6viRN$o38d3&rF-pn)d9lU5hj(AFj>U8ZpRXES!)7bRXKo{=u z7)V_c=x^sLo;TVJJiqqA_&EhWsl7K@LhfRHKG`$vzABq#*;==n;#Yy>^X6mLPpbpd zIOvk7v^ZiMk^bt6_wU&Bts80eehQS#snest+xv7z)V`z0ZwxVd0{s;1?4^N+BP!rZ zR^Fo$z}k69?o$;2Z0nh%x?7i|nqgi@=KoL?g5a!C>F~Y34gq`MMe7qzw_ZxFlMgti zCm<tUs2(%z(T({LtUzi)yW6l@WwuuJ5v66fIHxc5_|qXrs!WU?QI(~BZhFZ}4Kj!QMUTh3K#^`uq zx7PG<+|x;tBqKfe_-MbB1Oa?aUg(ozAmdp;KwrscxSZjZF5JWd?Z;j7Xjtd{B#_y^ z03zh9b4eR{&0`BY$Od{yt+ss@zTe>W+wvyGV6Lf~xJ-98P`%TgT!^G-#UPER4S4j6l}_#8XG46UBvJ3`I^7Vd4-6 zKtU9RkXFG&o%8mGXCgvDj}5tWD2_vXxds5*5{8;fucRp8JJ`DVj7CVcP*F_Tt(r=PR08 z&_d2Q>kHJr-C$p$a!&;1lhl9q#`E5u%+LQINf+rKfrB_qs!Bi&SlNPlx+L*oi8emx zgUHSk%j$te=L!Ncv~msYkTjnI0wy}U4ETL?Mmov{48(R+)1b?mRIM}%68ONQTq>na z)1mz))jD?FLdg15c~BzB!bT3LRok{4APF+u2$xuep+Qd_q;e7wSnmG04n^G}B}di7 z(7@N8N{i5V@QunSvK)hJ{jA0|0}8GMa>B^NWDcJsRL)8>EK^0mQghz@hXZJ@8dN>V z=juq3w1zWNY$bJawcm5{*8n6kYU4p6?M73CnwmiY^K+bNEqqST4i@zoB5U>L-@vP6PJ$Bl)8f76m> z0-7I&$gL7HW&8_Nx*jxmD3M8F6CF-IprI!eT88?Z)}uI&)oN0q%2HQj4GWB=Mp;*k z%kT5KLcL{u342cm8~Cc4R;P2th7@Pjzjxi1HtRB|OjSK%ZAd48=eemTT;&Ny5R zeiIx(QWGC+w-GT@(l(tqm6cg?gUpSx`v4t>*;)Gf6DV1||Im>(x38?9jbuDkB}$88 zDd0+vb~fqc9Ht$<8~p#6e^b&Av~9k z%O}c%k-{XtUJy0=HD06?d%*&gM3IVz^m`ob@N#jPTL0pYMcJY*>8n0Fa{r$@uoHZaieM1N!FIvjLLc1PSX4$z`W@2|9F9W-9FZu9APygI zXsJO1w|0K5BKflyIm&+@&rcb-TzH_Tl=SJ6Jkfksuc!J7)96LqE+@M4=;j#*Y~r9f zW_}f^6f6^a<~~3Bysd+P*)`~no>L5HH~zSpup7(P!UAHM58`ajp3okB)yU3@`H(&U z6cZaDI(Dqw?t6k^_seP{hysCxr0XDLS*?ie!psNYFbvB{-%1a{r!BAUR%Ig~IZxFg zCR+qbf=O8s0ykpL(Vy5{sAp*g1#icQ4VI|Q)El>M;i+yQ#Sy4|{K$DUh;!rF^XGDH zo{G?NHw)Al(nP2MP-{{Y*4ASo_xZSTI3Y(7`VtLhY%#iBP%J>{Nv{ZnL9=U*fhFk0 zal;JnAlUGL;ky8zb|(>vMt(#hLu6T`5tJRyACS$}v}@bR9YDRsiTm^;-S^kJZ4e;{ zYKE1?9VRax|I3F-#0@9#qTL5*rrE9b^~dkub|7dL?PnET-F<_U2BnNIAspo1wYiIg zu`Xy;pMR)AScGc90;>F#$_W75Q|&2=P~GS^kxv$`!v>l)H7~RH8df_Cf)`TZ#DQ-c z;di3VAmg=u(_QF-c;Il!` zNlxx2$U{5X15)Wsd`@f6ey1yjRlTB{OC2Cr^ZPcbT+M?dW&sVS0!a+AWOYzB#v*EC zu1EOFtCrpFk!t`SJ0(M z)3iTBqnGcmhKp|4!z|*ns~d>COGI|5B$Ht#sOvwAx1_{`P z!Z-l2M0w?YNN~713tjOD{y!Pf0!w|KZ-*r>nDz!XtmnnA3vcZ9Y35AffN zip^#yAYi@RNqxolzI~*+Nx|z&C)pgy8Re6eRKz0}kHQq(hfcJUxvLsi@8B;svH=@Z zw(6B8Syrvw_)*h;3pv(_YfA8Em0*Ha zY!V)|IcbP=L-A$i&k=o@p^;eun7BEchh67=AQRY8(|HiLNG-h3Z1&*fbcOh>XnE zsaTtw{B5l%lwhOrGEIV9xzkvc^afP6J$c!@J2O7DA1X6<u$$%mC&+#`YF}$}#?1$IKPj_RJa^vT;bIH zZ%2OEe;Vm3lcjkd5t-3?=4x5iaJJsLOBYqL->y+Vpvoz|^nrsRDCf+QlBD`4(v9YH zzD}{QarL@6h!;$`cA7!?JK79{cDC}B_oBatA4;Kbu={5dv&g$!s%n9VVw+rKXyFbh z-X-TG?;#bLYLr_SZlJ<7lO7sX1f`Wbe_zsB$vA#pQ~kWThHwDZxO0|WD7`x4($mbl z6Zci~uAc#Mg=K<|GPHOQFUywhTus;cNB8A}}c2jlfUlbYV zHXz8)jRNTI$BEO}xED}$&B`vzYD(}5U5}sW^1j_1|NR|9&ikTLr>xdapECv=aHYiygp_7iW=o%ln9Q|@lqwI+zwbh_&*%J~yG(&O zSrCr5y2`1Qbrm_?u=%I6^*f{zM^0vQ*g_7Hf>K2T6OvIURco!n;m=^Cf5R4(sL;ho`ZK%9#Cu zj0}VLmtrpJUXcsCrcB9SNzZ88Q{XtISdc#Y>#5^79%PJc*ne8x26D^6^u2MdI$=F# zVQdfcU|;}sBNd}>c7L*xT@%t8b3PJyr1I|84bUKGeIQ#vE7w9LGjbx_Ub`c7)ci}w zO6znuN{7u3)IV8_dC4f)qW6UVN;Q72^HWIZj%1}g%jn5 zn8adxfQUzh59WQN7WJz7jScQg3uHE5V0Z;79=RF2a)B)})eYLD-|5@G{MC9xf=YxilAQ3ZP(vf1sXX9REu88 z{6*cTL7SXyVwNqZJAfdpG(-R%f)bGfB4`?|v2?Vdhs_^gRC^&i+(nvdqf%C{z=439 zqMGwkG1)+=#Cj7#R4e5r>=!Y2wogtupv!ZL6mj6CQl5PAho7 z?8!ukn>FcrZU+ijmBg-Q684fJo%)`D!_YvkRdAq@eT?lw3Q7r3B$ob(b@zMLZWwmE z5AwAkAmG*YU)q}y6-Yo1y~<|645wU|%9n)DTkt8J?t>cZ+zuC-<6jXg$8K}2n)d1j zG!xq1%;M3HY@XJ$maO%nZ0#Hq596_L9gz&lgZ*Uj(4m47q|Y}U9-wli`>83}`>n=r`#MGXFDPDmho_*$%N`*l zy__$FVLns`mhe5_i9f7spd@NGyCc*%gMjTlhel>~s>Fi1k5Vya)CnA;8d8_LY&)-7 zm_N7Lm(SCZ;4x0W2uDml8ruxpVD{U%PrPbJ+1#nns0f(V)?*(Hy*AssKwauB_Ty@~ z9bs<#(hz&^1ov=6r&+-&*;ippNxsaabws845p6`SZegn3y_Df>@*?Z=DMS-iu{2_G z^(y8Qrib&PdaKRm5>~Us_8%*e$f^GKw+^Fe%0Z++KgKL~Q3e}JVWABHk1AFt@Cs1h zu_3ui{S%Yj_#KV~OhfESG;%hZ5BZhag>kX`O%{nZm74D)PYOj~GgiC4(D;ns3F3CG zbdS}V@(G+8tSsHcMUG%_c!(o?%ZSR}{HyB=gOO(|gp+9KQ^0Q~`J|XWR?p@&&%O#Iubju-NgQx&l49IOJ=VM0=Hrt75({*LO#ivR`- zq{IkWI>~mY^>&ihtFs);`9+^))fJOJHcWYD<34^E&bU`rJbl|*wkGK6A76p2*S@5R zwmf6Yd-hiuoHEbgsuy>QX`mtYB~iVNjkab!7qEQXN2mj=q|q9r9W54^bn54mY-+b} z47R{|43;b>Ikj-E;%V$JDhep96ulXwc5C!xO=yeI#I}J4kZrPf+B5rQXTu1vJwx^R z{FY7?R+-Z7-Y@=}qYuR82N9Dn6d(6kC`LNL_U7eW?I6qIBC6^jHOK--K?*eP3XuHm z#GZrnJjvOpX^8wLOvhNzz%Zf_ zD4;v{G~cB8VV1Do!V>kbUdv+>PH;qn4X@S>-E^~OWc&6SoNDh9VN!?2x>GhM#CN`p zon5FvIb6*D4v3{@O=lGk(!7TgaB{OBSHSrgJ!{aFY)Z@pn%zu)mv}>=8ZMX?VAUUG zXl2J@jX(*Q5rxWp0gd#dz=cQ?T{pn@H@gXhI?_yRsvzMq8Kzt9UG6ggr}>7zW^d$( zK7bk-_7ZJ~ekE~q=VQruqxxx%94GP$p<)FeS2%80;E7t&5||f4uOY`S~{g%1<{n@$Idi`;rLa;KY~iQ$sop_&JHPZF|!nkYE?aaXTI}< zk~5+l)sNPP3L?E61SkG%P|GYnKRr4Q6(v8s=4TRi$?Hphe=Ae~0j%*3aD)ZoykRS6 z&x2emz$mK8%id+HnDen5)oxFb0bhl$7tT4ldmzDw=z|ISShfeD&RGP$vQCHq2UIEo zkBr^ca^x>TVJ_)llBS>l3cLc{l0z1~d3V|L{~j(Py9n%YFHkC<#kEkHg5=Do@A7_B z5gwS~jPP%U;N|vG*m*|Q;^z*Rxdttgrf>NIw=f#V4T05nyCs%CbJaDV!+QdHhALu! zexWJgn8dtYv}RZyet0+*d=oJd$-l@Nt2Kv5!CkpRg&MhDa^3II&fv=u&x%v@oKJ;TiV_A8)wUwa#mAM~?sd+j=IB`|ciK&*cBN z=jL!y$UTGq85zLPfbIWcLbw@X;VYlZ8XuZfAsUjio3|eQ~z_MV639&|KH_E9OnXgCblOJePFB!=MbQ(RtH2* zG)m#6Vb|+Jvx^LE^eiQW-hhuBljsrh_>&&PZ=VR#=_^4nW3z#)3~m#C7GZylML4h! zJCzAn1d>(|l+ObL2yT?|fx-bm(+9AE#6KC0n0;YR=>&ri!~?`Y`f$))3W;1d_$njw z0t}9n|4EMdx;NimREUZ&VrRwwdvt7dC$SHNiKfR>zuy`2K`dCYA0-of@@xC5B>sIf zps;YV>y;I?7NRIfWbr~s5#?FVMv>)4;!A$}yKHqYu;Y(8wvnsiTax%VVGAzRIFynM z$7t+bDv8{*@Gfvde}lwdyAQ9@U>0ft((e>F5#+nqiBZnA1bw<~FTgXgXb|Ezj%Fk< zp(!8A0=k70Y#TQrr&?(i55Fh~Ru6*bSj2rtQ6SM2s7SmhKNqH9Q>Tc-t&x2U(3wvt z&<1zz6x^UxE>sA1f?Y0he5;g}h}>c(CW6QLy3-^7r94|8_o1x|mebxP_7_4+)# zM~a<;zc_Aous98QP};$`vAFC5=#UzNxeXN^_eY~_t*F|-tjAx7`3VU(xYe?(*AmhK5GT<;384S4PNt|`!Z zYcMQ>H zY>(iV<&f5I&rGT;CCPEtnuxU=PV-9KG|am=|^t&0ahTc})GAw#Pf zbkgtCR```Ughsrg@J4OY7$Bv=2_&qyXh!?0)v{?0LsDaI^BN!F5 ztPpUBG>`*_0=m$wa0MRgsd9D+y+(LdBh^h(prb_eCAxQ~;NkEC>i?|#NJP*|B}5WZ z|JckJm_POlL|ar^*r~HUm?6V1oW_$wB0jeYueb5#^?K)&X(TPfD1)1U|6!TrJ#$DO z8tcG;_+IkegosrG48uHV0#{eH3**v{f(<3#g&{0Z;TcI0HILUQf{frnD&wNq z>+lCisKYg*KphX6)`RQPbXunu~d+)L+CrcxhII6_C|GJP;aO3uTstY{V1yW|1 zqzYD<(5=OG78rbCavQzpINf1)V;4<^7DUo*fDE8v*20HSzVxmw zJo`t2a#!S$%pdte9Bd4?Uu8JIo4d9J6v0>z;0t!H4rS{s+LWR2ATKcGzqzA-gtZ)^ zt>Yhn0+NXE-d_V~G}T-0<8>FTVY{KmHKzetT?L=bPWu5YKNz%vl#}%}!1D=JS>|Yj zg|3jhU})*u6!u)pz6^!D>ptlImp$W%(ryMa7{C!WTHTkj%@+l{2*z* zH!Ezugr{AwAI99>gjxr_dt=CO=TxNZiOO32!d+1h=TIk z=YhNXY%jv0--p0J$_tWyV9y2UV0oKWn_%4DQkw6{cTL7RSRY zkICA=Y52t!RQ^N=4)Qv9235P=_i5{$5$?c!!i*Z zN1iZs`b}&5h+_$jK3qIt>aou802Cu%h~A%{O2~8)N-(w3MKMIQP3zu3Hi?2juh?Tp zc3jZ;o=$5J?Dud7vVOtGmoQeh@v~$SQmIQ

i_hit5H0iwi3?TsB2~qc8*z)$Jxi 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9643377 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pi06-minimal-validation" +version = "0.1.0" +description = "A tiny educational pi0.6 and RECAP overfitting experiment" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "pi06 minimal validation contributors" }] +dependencies = [ + "matplotlib>=3.8", + "numpy>=1.26", + "Pillow>=10.0", + "PyYAML>=6.0", + "torch>=2.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + +[project.scripts] +pi06mini-train = "pi06mini.train:main" +pi06mini-infer = "pi06mini.infer:main" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/src/pi06mini/__init__.py b/src/pi06mini/__init__.py new file mode 100644 index 0000000..8db6dd2 --- /dev/null +++ b/src/pi06mini/__init__.py @@ -0,0 +1,32 @@ +"""Minimal pi0.6 / Recap architecture validation package.""" + +from .model import Pi06Config, Pi06Mini, Pi06Output, flow_matching_sample +from .tokenizer import ( + Advantage, + CoordinateTokenizer, + MiniTokenizer, + PolicySequence, + SubtaskVocabulary, +) +from .value import ( + DistributionalValueModel, + ValueConfig, + ValueModel, + ValueOutput, +) + +__all__ = [ + "Advantage", + "CoordinateTokenizer", + "DistributionalValueModel", + "MiniTokenizer", + "Pi06Config", + "Pi06Mini", + "Pi06Output", + "PolicySequence", + "SubtaskVocabulary", + "ValueConfig", + "ValueModel", + "ValueOutput", + "flow_matching_sample", +] diff --git a/src/pi06mini/data.py b/src/pi06mini/data.py new file mode 100644 index 0000000..ccb1bdc --- /dev/null +++ b/src/pi06mini/data.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + +import numpy as np +import torch +import torch.nn.functional as F +from PIL import Image, ImageEnhance +from torch.utils.data import Dataset + +from .experience import ( + ExperienceEpisode, + build_synthetic_experience, + discounted_returns, + n_step_advantages, + normalize_returns_to_unit_interval, + quantile_advantage_labels, + stack_episode_field, +) + + +DEFAULT_SOURCE_MANIFEST = ( + Path(__file__).resolve().parents[4] + / "alpamayo" + / "alpamayo-qwen05b-validation" + / "data" + / "processed" + / "overfit_manifest.jsonl" +) + + +@dataclass(frozen=True) +class SampleRecord: + clip_id: str + t0_us: int + image_path: Path + trajectory_path: Path + reasoning: str = "" + + @property + def sample_id(self) -> str: + return f"{self.clip_id}_{self.t0_us}" + + +@dataclass(frozen=True) +class ActionStatistics: + mean: torch.Tensor + scale: torch.Tensor + + def as_dict(self) -> dict[str, list[float]]: + return { + "mean": [float(value) for value in self.mean], + "scale": [float(value) for value in self.scale], + } + + +class ActionNormalizer: + """Invertibly normalize XY waypoint deltas for flow matching.""" + + def __init__(self, mean: torch.Tensor, scale: torch.Tensor) -> None: + self.mean = torch.as_tensor(mean, dtype=torch.float32).reshape(2) + self.scale = torch.as_tensor(scale, dtype=torch.float32).reshape(2).clamp_min(0.05) + + @staticmethod + def to_deltas(history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor: + history = torch.as_tensor(history_xy, dtype=torch.float32) + future = torch.as_tensor(future_xy, dtype=torch.float32) + if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 1: + raise ValueError("history_xy must have shape [history_steps, 2]") + if future.ndim != 2 or future.shape[-1] != 2 or len(future) < 1: + raise ValueError("future_xy must have shape [action_horizon, 2]") + previous = torch.cat((history[-1:], future[:-1]), dim=0) + return future - previous + + @classmethod + def fit( + cls, histories: Sequence[torch.Tensor], futures: Sequence[torch.Tensor] + ) -> ActionNormalizer: + if not histories or len(histories) != len(futures): + raise ValueError("histories and futures must be non-empty and equally sized") + flat = torch.cat( + [ + cls.to_deltas(history, future).reshape(-1, 2) + for history, future in zip(histories, futures, strict=True) + ], + dim=0, + ) + return cls(flat.mean(dim=0), flat.std(dim=0, unbiased=False)) + + @classmethod + def from_dict(cls, values: dict[str, Sequence[float]]) -> ActionNormalizer: + return cls(torch.tensor(values["mean"]), torch.tensor(values["scale"])) + + @property + def statistics(self) -> ActionStatistics: + return ActionStatistics(self.mean.clone(), self.scale.clone()) + + def encode(self, history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor: + return (self.to_deltas(history_xy, future_xy) - self.mean) / self.scale + + def decode(self, actions: torch.Tensor, history_xy: torch.Tensor) -> torch.Tensor: + actions = torch.as_tensor(actions, dtype=torch.float32) + history = torch.as_tensor(history_xy, dtype=torch.float32) + deltas = actions * self.scale.to(actions.device) + self.mean.to(actions.device) + return history[-1:].to(actions.device) + torch.cumsum(deltas, dim=0) + + def as_dict(self) -> dict[str, list[float]]: + return self.statistics.as_dict() + + def save(self, path: str | Path) -> Path: + destination = Path(path).expanduser().resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(self.as_dict(), indent=2) + "\n", encoding="utf-8") + return destination + + +def _resolve_data_path(manifest_path: Path, raw_path: str | Path) -> Path: + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path.resolve() + candidates = [manifest_path.parent / path] + if len(manifest_path.parents) >= 3: + candidates.insert(0, manifest_path.parents[2] / path) + for candidate in candidates: + if candidate.exists(): + return candidate.resolve() + return candidates[0].resolve() + + +def load_manifest(path: str | Path) -> list[SampleRecord]: + manifest_path = Path(path).expanduser().resolve() + records: list[SampleRecord] = [] + for line in manifest_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + row = json.loads(line) + records.append( + SampleRecord( + clip_id=str(row["clip_id"]), + t0_us=int(row["t0_us"]), + image_path=_resolve_data_path(manifest_path, row["image_path"]), + trajectory_path=_resolve_data_path(manifest_path, row["trajectory_path"]), + reasoning=str(row.get("reasoning", "")), + ) + ) + if not records: + raise ValueError(f"no samples found in {manifest_path}") + return records + + +def materialize_subset( + source_manifest: str | Path = DEFAULT_SOURCE_MANIFEST, + output_dir: str | Path = "data/overfit", + *, + max_samples: int = 1, +) -> Path: + """Copy a self-contained PAI seed demonstration for synthetic experience.""" + + if max_samples < 1: + raise ValueError("max_samples must be positive") + destination = Path(output_dir).expanduser().resolve() + image_dir = destination / "images" + trajectory_dir = destination / "trajectories" + image_dir.mkdir(parents=True, exist_ok=True) + trajectory_dir.mkdir(parents=True, exist_ok=True) + rows: list[dict[str, Any]] = [] + for index, record in enumerate(load_manifest(source_manifest)[:max_samples]): + stem = f"sample_{index:02d}_{record.sample_id}" + image_path = image_dir / f"{stem}{record.image_path.suffix.lower()}" + trajectory_path = trajectory_dir / f"{stem}.npz" + shutil.copy2(record.image_path, image_path) + shutil.copy2(record.trajectory_path, trajectory_path) + rows.append( + { + "clip_id": record.clip_id, + "t0_us": record.t0_us, + "reasoning": record.reasoning, + "image_path": str(image_path.relative_to(destination)), + "trajectory_path": str(trajectory_path.relative_to(destination)), + } + ) + manifest = destination / "overfit_manifest.jsonl" + manifest.write_text( + "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), + encoding="utf-8", + ) + return manifest + + +def _resample_xy(xy: torch.Tensor, steps: int) -> torch.Tensor: + xy = torch.as_tensor(xy, dtype=torch.float32) + if xy.ndim != 2 or xy.shape[-1] != 2 or len(xy) < 2: + raise ValueError("trajectory must contain at least two XY waypoints") + if len(xy) == steps: + return xy + return F.interpolate( + xy.T.unsqueeze(0), size=steps, mode="linear", align_corners=True + )[0].T + + +class Pi06ExperienceDataset(Dataset[dict[str, Any]]): + """Four-source ReCAP overfit buffer synthesized from each PAI demonstration.""" + + def __init__( + self, + manifest_path: str | Path, + *, + image_size: int = 64, + augmentation_factor: int = 1, + history_steps: int = 8, + action_horizon: int = 16, + n_step: int = 4, + gamma: float = 0.99, + positive_quantile: float = 0.7, + seed: int = 7, + action_statistics: ActionStatistics | dict[str, Sequence[float]] | None = None, + ) -> None: + if image_size < 1 or augmentation_factor < 1: + raise ValueError("image_size and augmentation_factor must be positive") + self.records = load_manifest(manifest_path) + self.image_size = int(image_size) + self.augmentation_factor = int(augmentation_factor) + self.history_steps = int(history_steps) + self.action_horizon = int(action_horizon) + self.seed = int(seed) + self.state_dim = self.history_steps * 2 + self.action_dim = 2 + + episodes: list[ExperienceEpisode] = [] + episode_record_indices: list[int] = [] + for record_index, record in enumerate(self.records): + with np.load(record.trajectory_path) as arrays: + history = _resample_xy( + torch.from_numpy(np.asarray(arrays["history_xyz"], dtype=np.float32))[:, :2], + self.history_steps, + ) + future = _resample_xy( + torch.from_numpy(np.asarray(arrays["future_xyz"], dtype=np.float32))[:, :2], + self.action_horizon, + ) + generated = build_synthetic_experience( + history, future, episode_prefix=record.sample_id + ) + episodes.extend(generated) + episode_record_indices.extend([record_index] * len(generated)) + self.episodes = episodes + self._episode_record_indices = episode_record_indices + + raw_rewards = stack_episode_field(episodes, "rewards") + raw_returns = discounted_returns(raw_rewards, gamma=gamma) + self.normalized_returns = normalize_returns_to_unit_interval(raw_returns) + baseline = raw_returns.mean(dim=0, keepdim=True).expand_as(raw_returns) + dones = torch.zeros_like(raw_rewards, dtype=torch.bool) + dones[:, -1] = True + self.advantages = n_step_advantages( + raw_rewards, baseline, n_step=n_step, gamma=gamma, dones=dones + ) + intervention_masks = stack_episode_field(episodes, "intervention_mask").bool() + self.positive_steps, self.advantage_threshold = quantile_advantage_labels( + self.advantages, + quantile=positive_quantile, + intervention_mask=intervention_masks, + ) + episode_advantages = self.advantages.mean(dim=-1) + episode_interventions = intervention_masks.any(dim=-1) + self.positive_episodes, self.episode_advantage_threshold = ( + quantile_advantage_labels( + episode_advantages, + quantile=positive_quantile, + intervention_mask=episode_interventions, + ) + ) + + if action_statistics is None: + self.action_normalizer = ActionNormalizer.fit( + [episode.history_xy for episode in episodes], + [episode.future_xy for episode in episodes], + ) + elif isinstance(action_statistics, ActionStatistics): + self.action_normalizer = ActionNormalizer( + action_statistics.mean, action_statistics.scale + ) + else: + self.action_normalizer = ActionNormalizer.from_dict(action_statistics) + + @property + def action_statistics(self) -> ActionStatistics: + return self.action_normalizer.statistics + + def save_action_statistics(self, path: str | Path) -> Path: + return self.action_normalizer.save(path) + + def __len__(self) -> int: + return len(self.episodes) * self.augmentation_factor + + def _indices(self, index: int) -> tuple[int, int]: + if index < 0: + index += len(self) + if index < 0 or index >= len(self): + raise IndexError(index) + return divmod(index, self.augmentation_factor) + + def _load_image(self, record_index: int, variant: int) -> torch.Tensor: + with Image.open(self.records[record_index].image_path) as source: + image = source.convert("RGB") + if variant: + rng = np.random.default_rng( + np.random.SeedSequence([self.seed, record_index, variant]) + ) + image = ImageEnhance.Brightness(image).enhance(float(rng.uniform(0.96, 1.04))) + image = ImageEnhance.Contrast(image).enhance(float(rng.uniform(0.97, 1.03))) + image = image.resize( + (self.image_size, self.image_size), resample=Image.Resampling.BILINEAR + ) + array = np.asarray(image, dtype=np.float32).copy() / 255.0 + return torch.from_numpy(array).permute(2, 0, 1).contiguous() + + def __getitem__(self, index: int) -> dict[str, Any]: + episode_index, variant = self._indices(index) + episode = self.episodes[episode_index] + record_index = self._episode_record_indices[episode_index] + actions = self.action_normalizer.encode( + episode.history_xy, episode.future_xy + ) + return { + "image": self._load_image(record_index, variant), + "state": episode.history_xy.reshape(-1), + "actions": actions, + "history_xy": episode.history_xy.clone(), + "future_xy": episode.future_xy.clone(), + "rewards": episode.rewards.clone(), + "returns": self.normalized_returns[episode_index].clone(), + # The critic predicts the beginning-of-episode return for this + # minimal trajectory-level buffer; the full vector remains + # available for diagnostics and advantage recomputation. + "return_target": self.normalized_returns[episode_index, 0].clone(), + "value_target": self.normalized_returns[episode_index, 0].clone(), + "advantages": self.advantages[episode_index].clone(), + "positive_steps": self.positive_steps[episode_index].clone(), + "advantage": self.advantages[episode_index].mean().clone(), + "positive_condition": self.positive_episodes[episode_index].clone(), + "advantage_label": ( + "positive" if bool(self.positive_episodes[episode_index]) else "negative" + ), + "intervention_mask": episode.intervention_mask.clone(), + "is_intervention": bool(episode.intervention_mask.any()), + "source_type": episode.source_type, + "reasoning": self.records[record_index].reasoning, + "sample_id": f"{episode.episode_id}:aug{variant}", + } diff --git a/src/pi06mini/experience.py b/src/pi06mini/experience.py new file mode 100644 index 0000000..10f66e9 --- /dev/null +++ b/src/pi06mini/experience.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal, Sequence + +import torch + + +ExperienceSource = Literal[ + "demo_success", + "autonomous_understeer", + "autonomous_stop", + "intervention_correction", +] + + +@dataclass(frozen=True) +class ExperienceEpisode: + """A tiny trajectory-level stand-in for the heterogeneous pi0.6 experience buffer.""" + + episode_id: str + source_type: ExperienceSource + history_xy: torch.Tensor + future_xy: torch.Tensor + rewards: torch.Tensor + intervention_mask: torch.Tensor + metadata: dict[str, float | str] = field(default_factory=dict) + + def __post_init__(self) -> None: + history = torch.as_tensor(self.history_xy, dtype=torch.float32) + future = torch.as_tensor(self.future_xy, dtype=torch.float32) + rewards = torch.as_tensor(self.rewards, dtype=torch.float32) + intervention = torch.as_tensor(self.intervention_mask, dtype=torch.bool) + if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 1: + raise ValueError("history_xy must have shape [history_steps, 2]") + if future.ndim != 2 or future.shape[-1] != 2 or len(future) < 2: + raise ValueError("future_xy must have shape [horizon, 2]") + if rewards.shape != (len(future),): + raise ValueError("rewards must have one value per future waypoint") + if intervention.shape != rewards.shape: + raise ValueError("intervention_mask must match rewards") + object.__setattr__(self, "history_xy", history) + object.__setattr__(self, "future_xy", future) + object.__setattr__(self, "rewards", rewards) + object.__setattr__(self, "intervention_mask", intervention) + + +def discounted_returns(rewards: torch.Tensor, gamma: float = 0.99) -> torch.Tensor: + """Compute reward-to-go while preserving arbitrary leading batch dimensions.""" + + if not 0.0 <= gamma <= 1.0: + raise ValueError("gamma must lie in [0, 1]") + rewards = torch.as_tensor(rewards, dtype=torch.float32) + if rewards.ndim < 1 or rewards.shape[-1] < 1: + raise ValueError("rewards must have a non-empty time dimension") + returns = torch.empty_like(rewards) + running = torch.zeros_like(rewards[..., 0]) + for index in range(rewards.shape[-1] - 1, -1, -1): + running = rewards[..., index] + gamma * running + returns[..., index] = running + return returns + + +def normalize_returns_to_unit_interval(returns: torch.Tensor) -> torch.Tensor: + """Map the worst observed return to -1 and the best to 0. + + pi0.6 uses non-positive reward conditioning. A constant collection is + treated as equally optimal and therefore maps to zero. + """ + + returns = torch.as_tensor(returns, dtype=torch.float32) + if returns.numel() == 0: + raise ValueError("returns must be non-empty") + minimum = returns.amin() + maximum = returns.amax() + span = maximum - minimum + if float(span) <= torch.finfo(returns.dtype).eps: + return torch.zeros_like(returns) + return ((returns - maximum) / span).clamp(-1.0, 0.0) + + +def n_step_advantages( + rewards: torch.Tensor, + values: torch.Tensor, + *, + n_step: int = 4, + gamma: float = 0.99, + dones: torch.Tensor | None = None, +) -> torch.Tensor: + """Estimate A(s_t,a_t) from an n-step bootstrapped target. + + ``values`` may contain T values (the final bootstrap is zero) or T+1 + values. ``dones`` stops both reward accumulation and bootstrapping. + """ + + rewards = torch.as_tensor(rewards, dtype=torch.float32) + values = torch.as_tensor(values, dtype=torch.float32, device=rewards.device) + if rewards.ndim < 1 or rewards.shape[-1] < 1: + raise ValueError("rewards must have a non-empty time dimension") + if n_step < 1: + raise ValueError("n_step must be positive") + if not 0.0 <= gamma <= 1.0: + raise ValueError("gamma must lie in [0, 1]") + horizon = rewards.shape[-1] + if values.shape[:-1] != rewards.shape[:-1] or values.shape[-1] not in { + horizon, + horizon + 1, + }: + raise ValueError("values must have shape [..., T] or [..., T+1]") + if values.shape[-1] == horizon: + values = torch.cat((values, torch.zeros_like(values[..., :1])), dim=-1) + if dones is None: + dones = torch.zeros_like(rewards, dtype=torch.bool) + dones[..., -1] = True + else: + dones = torch.as_tensor(dones, dtype=torch.bool, device=rewards.device) + if dones.shape != rewards.shape: + raise ValueError("dones must match rewards") + + advantages = torch.empty_like(rewards) + for start in range(horizon): + target = torch.zeros_like(rewards[..., 0]) + alive = torch.ones_like(target) + discount = 1.0 + stop = min(start + n_step, horizon) + for index in range(start, stop): + target = target + discount * alive * rewards[..., index] + alive = alive * (~dones[..., index]).to(rewards.dtype) + discount *= gamma + # These inputs are complete episodes rather than replay fragments, so + # reaching the final recorded step is terminal even if callers omit a + # final done flag. Bootstrap only when the n-step window ends earlier. + if stop < horizon: + target = target + discount * alive * values[..., stop] + advantages[..., start] = target - values[..., start] + return advantages + + +def quantile_advantage_labels( + advantages: torch.Tensor, + *, + quantile: float = 0.7, + intervention_mask: torch.Tensor | None = None, +) -> tuple[torch.Tensor, torch.Tensor]: + """Convert advantages into ReCAP positive/negative conditions. + + Intervention actions are always positive, even when their estimated + numerical advantage falls below the empirical threshold. + """ + + advantages = torch.as_tensor(advantages, dtype=torch.float32) + if advantages.numel() == 0: + raise ValueError("advantages must be non-empty") + if not 0.0 <= quantile <= 1.0: + raise ValueError("quantile must lie in [0, 1]") + threshold = torch.quantile(advantages.reshape(-1), quantile) + labels = advantages >= threshold + if intervention_mask is not None: + intervention_mask = torch.as_tensor( + intervention_mask, dtype=torch.bool, device=advantages.device + ) + if intervention_mask.shape != advantages.shape: + raise ValueError("intervention_mask must match advantages") + labels = labels | intervention_mask + return labels, threshold + + +def _trajectory_rewards(candidate: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + error = torch.linalg.vector_norm(candidate - target, dim=-1) + scale = torch.linalg.vector_norm(target - target[:1], dim=-1).amax().clamp_min(1.0) + return -(error / scale).clamp(0.0, 1.0) + + +def build_synthetic_experience( + history_xy: torch.Tensor, + expert_future_xy: torch.Tensor, + *, + episode_prefix: str = "sample", +) -> list[ExperienceEpisode]: + """Build four deterministic experience sources from one PAI demonstration. + + The small buffer deliberately includes successful demonstrations, two + autonomous failure modes, and a human correction. It is only intended to + validate the pi0.6/ReCAP data path and overfit behavior. + """ + + history = torch.as_tensor(history_xy, dtype=torch.float32) + expert = torch.as_tensor(expert_future_xy, dtype=torch.float32) + if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 1: + raise ValueError("history_xy must have shape [history_steps, 2]") + if expert.ndim != 2 or expert.shape[-1] != 2 or len(expert) < 4: + raise ValueError("expert_future_xy must have shape [horizon>=4, 2]") + horizon = len(expert) + anchor = history[-1] + displacement = expert - anchor + + understeer = anchor + displacement * expert.new_tensor([1.0, 0.2]) + stop_index = max(2, horizon // 3) + stopped = expert.clone() + stopped[stop_index:] = stopped[stop_index - 1] + + progress = torch.linspace(0.0, 1.0, horizon, dtype=expert.dtype).unsqueeze(-1) + correction_weight = ((progress - 0.2) / 0.55).clamp(0.0, 1.0) + correction = understeer * (1.0 - correction_weight) + expert * correction_weight + intervention = torch.arange(horizon) >= max(1, horizon // 4) + + zero_rewards = torch.zeros(horizon, dtype=expert.dtype) + return [ + ExperienceEpisode( + f"{episode_prefix}:demo", + "demo_success", + history, + expert, + zero_rewards, + torch.zeros(horizon, dtype=torch.bool), + ), + ExperienceEpisode( + f"{episode_prefix}:understeer", + "autonomous_understeer", + history, + understeer, + _trajectory_rewards(understeer, expert), + torch.zeros(horizon, dtype=torch.bool), + {"failure": "understeer"}, + ), + ExperienceEpisode( + f"{episode_prefix}:stop", + "autonomous_stop", + history, + stopped, + _trajectory_rewards(stopped, expert), + torch.zeros(horizon, dtype=torch.bool), + {"failure": "premature_stop"}, + ), + ExperienceEpisode( + f"{episode_prefix}:intervention", + "intervention_correction", + history, + correction, + _trajectory_rewards(correction, expert), + intervention, + {"failure": "understeer_then_corrected"}, + ), + ] + + +def stack_episode_field( + episodes: Sequence[ExperienceEpisode], field_name: str +) -> torch.Tensor: + if not episodes: + raise ValueError("episodes must be non-empty") + return torch.stack([torch.as_tensor(getattr(item, field_name)) for item in episodes]) diff --git a/src/pi06mini/flow.py b/src/pi06mini/flow.py new file mode 100644 index 0000000..f23ba89 --- /dev/null +++ b/src/pi06mini/flow.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Callable +import torch +from torch import Tensor + + +class FlowMatching: + """Low-timestep-biased rectified flow with pi*0.6 noise weighting.""" + + def __init__(self, inference_steps: int = 5, cutoff: float = 0.999) -> None: + self.inference_steps = inference_steps + self.cutoff = cutoff + + def training_batch(self, target: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]: + batch = target.shape[0] + uniform = torch.rand(batch, device=target.device, dtype=target.dtype) + timestep = self.cutoff * (1.0 - uniform.pow(1.0 / 1.5)) + noise = torch.randn_like(target) + noisy = timestep[:, None, None] * target + (1 - timestep[:, None, None]) * noise + velocity = target - noise + weight = torch.exp(-timestep / 2.0) + return noisy, timestep, velocity, weight + + @staticmethod + def weighted_loss(prediction: Tensor, target: Tensor, weight: Tensor) -> Tensor: + per_example = (prediction.float() - target.float()).square().mean(dim=(1, 2)) + return (per_example * weight.float()).mean() + + @torch.no_grad() + def sample( + self, + model_fn: Callable[[Tensor, Tensor], Tensor], + shape: tuple[int, ...], + device: torch.device | str, + steps: int | None = None, + ) -> Tensor: + count = steps or self.inference_steps + x = torch.randn(shape, device=device) + grid = torch.linspace(0.0, 1.0, count + 1, device=device) + for index in range(count): + t = grid[index].expand(shape[0]) + x = x + (grid[index + 1] - grid[index]) * model_fn(x, t) + return x diff --git a/src/pi06mini/infer.py b/src/pi06mini/infer.py new file mode 100644 index 0000000..a46a3fc --- /dev/null +++ b/src/pi06mini/infer.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np +import torch + +from .data import Pi06ExperienceDataset +from .flow import FlowMatching +from .model import Pi06Config, Pi06Mini +from .recap import classifier_free_guidance +from .runtime import choose_device, write_json +from .tokenizer import MiniTokenizer +from .value import ValueConfig, ValueModel + + +@torch.no_grad() +def evaluate(checkpoint_path: str) -> tuple[Path, Path]: + checkpoint=torch.load(checkpoint_path,map_location="cpu",weights_only=False) + cfg=checkpoint["config"]; root=Path(__file__).resolve().parents[2] + device=choose_device(cfg.get("device","auto")); tokenizer=MiniTokenizer() + dataset=Pi06ExperienceDataset( + root/"data/overfit/overfit_manifest.jsonl",image_size=cfg["data"]["image_size"], + augmentation_factor=cfg["experience"]["augmentation_factor"], + action_horizon=cfg["data"]["action_horizon"],n_step=cfg["experience"]["n_step"], + gamma=cfg["experience"]["gamma"],positive_quantile=cfg["experience"]["positive_quantile"], + action_statistics=checkpoint["action_statistics"], + ) + mixed=Pi06Mini(Pi06Config(**checkpoint["policy_config"]));mixed.load_state_dict(checkpoint["mixed"]);mixed.to(device).eval() + recap=Pi06Mini(Pi06Config(**checkpoint["policy_config"]));recap.load_state_dict(checkpoint["recap"]);recap.to(device).eval() + value=ValueModel(ValueConfig(**checkpoint["value_config"]));value.load_state_dict(checkpoint["value"]);value.to(device).eval() + flow=FlowMatching(cfg["inference"]["flow_steps"]) + demo=next(dataset[i] for i in range(len(dataset)) if dataset[i]["source_type"]=="demo_success") + failure=next(dataset[i] for i in range(len(dataset)) if "stop" in dataset[i]["source_type"]) + image=demo["image"][None].to(device) + + def condition(model:Pi06Mini, advantage:str|None, x:torch.Tensor,t:torch.Tensor)->torch.Tensor: + seq=tokenizer.build_policy_sequence( + demo["history_xy"],demo["reasoning"],demo["actions"],advantage, + metadata=None, + ) + ids=seq.input_ids[:seq.action_start_index][None].to(device) + return model(image,ids,x,t,generated_token_start=seq.generated_token_start).vector_field + + def sample_field(fn,seed:int=2): + torch.manual_seed(seed) + return flow.sample(fn,(1,dataset.action_horizon,2),device,cfg["inference"]["flow_steps"])[0].cpu() + + mixed_actions=sample_field(lambda x,t:condition(mixed,None,x,t)) + uncond_actions=sample_field(lambda x,t:condition(recap,None,x,t)) + positive_actions=sample_field(lambda x,t:condition(recap,"positive",x,t)) + negative_actions=sample_field(lambda x,t:condition(recap,"negative",x,t)) + def cfg_field(x,t): + return classifier_free_guidance( + condition(recap,"positive",x,t),condition(recap,None,x,t), + guidance_scale=cfg["inference"]["cfg_beta"], + ) + cfg_actions=sample_field(cfg_field) + decoded={ + "mixed":dataset.action_normalizer.decode(mixed_actions,demo["history_xy"]).numpy(), + "unconditional":dataset.action_normalizer.decode(uncond_actions,demo["history_xy"]).numpy(), + "positive":dataset.action_normalizer.decode(positive_actions,demo["history_xy"]).numpy(), + "negative":dataset.action_normalizer.decode(negative_actions,demo["history_xy"]).numpy(), + "cfg":dataset.action_normalizer.decode(cfg_actions,demo["history_xy"]).numpy(), + } + target=demo["future_xy"].numpy(); failure_xy=failure["future_xy"].numpy() + metrics={name:float(np.mean((xy-target)**2)) for name,xy in decoded.items()} + metrics["negative_to_failure_mse"]=float(np.mean((decoded["negative"]-failure_xy)**2)) + fig,ax=plt.subplots(figsize=(9,6));ax.plot(target[:,0],target[:,1],"o-",label="success target",linewidth=2) + ax.plot(failure_xy[:,0],failure_xy[:,1],":",label="failure experience") + for name,color in [("mixed","gray"),("unconditional","purple"),("positive","green"),("negative","red"),("cfg","orange")]: + xy=decoded[name];ax.plot(xy[:,0],xy[:,1],"x--",label=f"{name} ({metrics[name]:.3f})",color=color) + ax.scatter([0],[0],c="black",marker="s",label="ego");ax.axis("equal");ax.grid(True,alpha=.3);ax.legend(fontsize=8) + ax.set_xlabel("forward x (m)");ax.set_ylabel("left y (m)");ax.set_title("π*0.6 Recap advantage-conditioned trajectories") + outdir=Path(checkpoint_path).parent;trajectory_path=outdir/"recap_trajectory_comparison.png";fig.tight_layout();fig.savefig(trajectory_path,dpi=170);plt.close(fig) + + value_rows=[] + for i in range(len(dataset)): + s=dataset[i];ids=tokenizer.build_value_input(s["history_xy"],s["source_type"])[None].to(device) + prediction=float(value(s["image"][None].to(device),ids).expectation.cpu()) + value_rows.append({"source_type":s["source_type"],"target":float(s["value_target"]),"prediction":prediction}) + fig,ax=plt.subplots(figsize=(8,4));x=np.arange(len(value_rows));w=.35 + ax.bar(x-w/2,[r["target"] for r in value_rows],w,label="return target") + ax.bar(x+w/2,[r["prediction"] for r in value_rows],w,label="value prediction") + ax.set_xticks(x,[r["source_type"] for r in value_rows],rotation=20,ha="right");ax.set_ylim(-1.05,.05);ax.grid(True,axis="y",alpha=.3);ax.legend();ax.set_title("201-bin distributional value overfit") + value_path=outdir/"value_comparison.png";fig.tight_layout();fig.savefig(value_path,dpi=170);plt.close(fig) + write_json(outdir/"evaluation.json",{"trajectory_metrics":metrics,"values":value_rows}) + print({"trajectory_metrics":metrics,"values":value_rows}) + return trajectory_path,value_path + + +def main(): + parser=argparse.ArgumentParser();parser.add_argument("--checkpoint",default="outputs/overfit/checkpoint.pt") + evaluate(parser.parse_args().checkpoint) + + +if __name__=="__main__":main() diff --git a/src/pi06mini/model.py b/src/pi06mini/model.py new file mode 100644 index 0000000..712b7d3 --- /dev/null +++ b/src/pi06mini/model.py @@ -0,0 +1,444 @@ +r"""Minimal :math:`\pi^*_{0.6}` policy with Knowledge Insulation. + +This is intentionally a small, pure-PyTorch architecture test rather than a +scale reproduction. It preserves the paper's important information paths: + +* a VLM stream predicts subtask and FAST-lite action tokens; +* an optional positive/negative advantage token sits after the subtask and + before both action representations; +* a separately parameterized flow expert reads observation/subtask/advantage; +* flow queries never read teacher-forced FAST-lite tokens; and +* VLM keys and values are stop-gradient inputs to the flow expert (Knowledge + Insulation), so continuous-action loss cannot update the VLM. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Iterator, Optional + +import torch +from torch import Tensor, nn +import torch.nn.functional as F + + +@dataclass +class Pi06Config: + vocab_size: int = 512 + action_dim: int = 2 + image_channels: int = 3 + image_size: int = 64 + patch_size: int = 16 + d_model: int = 128 + n_heads: int = 4 + n_layers: int = 4 + mlp_ratio: int = 4 + max_text_tokens: int = 96 + max_action_tokens: int = 32 + action_start_token_id: int = 269 + dropout: float = 0.0 + rms_eps: float = 1e-6 + + def __post_init__(self) -> None: + if self.d_model % self.n_heads: + raise ValueError("d_model must be divisible by n_heads") + if self.image_size % self.patch_size: + raise ValueError("image_size must be divisible by patch_size") + if not 0 <= self.action_start_token_id < self.vocab_size: + raise ValueError("action_start_token_id must be inside the vocabulary") + + @property + def max_image_tokens(self) -> int: + return (self.image_size // self.patch_size) ** 2 + + @property + def max_prefix_tokens(self) -> int: + return self.max_image_tokens + self.max_text_tokens + + +@dataclass +class Pi06Output: + text_logits: Tensor + vector_field: Tensor + prefix_hidden: Tensor + action_hidden: Tensor + text_token_offset: int + loss: Optional[Tensor] = None + text_loss: Optional[Tensor] = None + flow_loss: Optional[Tensor] = None + + +class RMSNorm(nn.Module): + def __init__(self, dim: int, eps: float = 1e-6) -> None: + super().__init__() + self.weight = nn.Parameter(torch.ones(dim)) + self.eps = eps + + def forward(self, x: Tensor) -> Tensor: + rms = x.float().pow(2).mean(dim=-1, keepdim=True) + return x * torch.rsqrt(rms.to(x.dtype) + self.eps) * self.weight + + +class AdaRMSNorm(nn.Module): + """RMSNorm modulated at every flow-expert layer by the flow timestep.""" + + def __init__(self, dim: int, cond_dim: int, eps: float = 1e-6) -> None: + super().__init__() + self.norm = RMSNorm(dim, eps) + self.modulation = nn.Linear(cond_dim, 2 * dim) + nn.init.zeros_(self.modulation.weight) + nn.init.zeros_(self.modulation.bias) + + def forward(self, x: Tensor, condition: Tensor) -> Tensor: + shift, scale = self.modulation(condition).chunk(2, dim=-1) + return self.norm(x) * (1 + scale[:, None]) + shift[:, None] + + +class TimeEmbedding(nn.Module): + def __init__(self, dim: int) -> None: + super().__init__() + fourier_dim = max(8, dim // 2) + self.fourier_dim = fourier_dim + fourier_dim % 2 + self.mlp = nn.Sequential( + nn.Linear(self.fourier_dim, dim), nn.SiLU(), nn.Linear(dim, dim) + ) + + def forward(self, timestep: Tensor) -> Tensor: + timestep = timestep.reshape(-1).float() + half = self.fourier_dim // 2 + frequencies = torch.exp( + torch.linspace( + math.log(1.0), math.log(1000.0), half, + device=timestep.device, dtype=timestep.dtype, + ) + ) + angles = 2 * math.pi * timestep[:, None] * frequencies[None] + return self.mlp(torch.cat((angles.sin(), angles.cos()), dim=-1)) + + +class AttentionWeights(nn.Module): + """One expert's independent Q/K/V/output projections.""" + + def __init__(self, dim: int) -> None: + super().__init__() + self.q = nn.Linear(dim, dim, bias=False) + self.k = nn.Linear(dim, dim, bias=False) + self.v = nn.Linear(dim, dim, bias=False) + self.out = nn.Linear(dim, dim, bias=False) + + +class MLP(nn.Module): + def __init__(self, dim: int, ratio: int, dropout: float) -> None: + super().__init__() + hidden = dim * ratio + self.net = nn.Sequential( + nn.Linear(dim, hidden), + nn.GELU(approximate="tanh"), + nn.Dropout(dropout), + nn.Linear(hidden, dim), + nn.Dropout(dropout), + ) + + def forward(self, x: Tensor) -> Tensor: + return self.net(x) + + +def attention( + query: Tensor, + key: Tensor, + value: Tensor, + n_heads: int, + allowed: Optional[Tensor], + dropout: float, + training: bool, +) -> Tensor: + batch, q_len, width = query.shape + head_dim = width // n_heads + + def split(x: Tensor) -> Tensor: + return x.reshape(batch, -1, n_heads, head_dim).transpose(1, 2) + + q, k, v = split(query), split(key), split(value) + scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(head_dim) + if allowed is not None: + scores = scores.masked_fill( + ~allowed[:, None], torch.finfo(scores.dtype).min + ) + probabilities = scores.float().softmax(dim=-1).to(scores.dtype) + probabilities = F.dropout(probabilities, p=dropout, training=training) + output = probabilities @ v + return output.transpose(1, 2).contiguous().reshape(batch, q_len, width) + + +class KnowledgeInsulatedBlock(nn.Module): + """A dual-expert block whose VLM-to-flow connection is stop-gradient.""" + + def __init__(self, config: Pi06Config) -> None: + super().__init__() + d = config.d_model + self.n_heads = config.n_heads + self.dropout = config.dropout + + self.vlm_norm1 = RMSNorm(d, config.rms_eps) + self.vlm_attention = AttentionWeights(d) + self.vlm_norm2 = RMSNorm(d, config.rms_eps) + self.vlm_mlp = MLP(d, config.mlp_ratio, config.dropout) + + self.action_norm1 = AdaRMSNorm(d, d, config.rms_eps) + self.action_attention = AttentionWeights(d) + self.action_norm2 = AdaRMSNorm(d, d, config.rms_eps) + self.action_mlp = MLP(d, config.mlp_ratio, config.dropout) + + def forward( + self, + prefix: Tensor, + action: Tensor, + time_condition: Tensor, + prefix_edges: Tensor, + action_edges: Tensor, + ) -> tuple[Tensor, Tensor]: + prefix_input = self.vlm_norm1(prefix) + p = self.vlm_attention + prefix_update = attention( + p.q(prefix_input), p.k(prefix_input), p.v(prefix_input), + self.n_heads, prefix_edges, self.dropout, self.training, + ) + prefix = prefix + p.out(prefix_update) + prefix = prefix + self.vlm_mlp(self.vlm_norm2(prefix)) + + action_input = self.action_norm1(action, time_condition) + a = self.action_attention + # Knowledge Insulation: the flow expert may consume VLM activations, + # but the flow loss cannot alter either those activations or the VLM's + # K/V projection weights. Text loss still trains the VLM normally. + prefix_key = p.k(prefix_input).detach() + prefix_value = p.v(prefix_input).detach() + key = torch.cat((prefix_key, a.k(action_input)), dim=1) + value = torch.cat((prefix_value, a.v(action_input)), dim=1) + action_update = attention( + a.q(action_input), key, value, self.n_heads, + action_edges, self.dropout, self.training, + ) + action = action + a.out(action_update) + action = action + self.action_mlp(self.action_norm2(action, time_condition)) + return prefix, action + + +class Pi06Mini(nn.Module): + r"""Small policy retaining the structural contracts of :math:`\pi^*_{0.6}`.""" + + def __init__(self, config: Optional[Pi06Config] = None) -> None: + super().__init__() + self.config = config or Pi06Config() + c = self.config + self.patch_encoder = nn.Conv2d( + c.image_channels, c.d_model, c.patch_size, stride=c.patch_size + ) + self.token_embedding = nn.Embedding(c.vocab_size, c.d_model) + self.action_projection = nn.Linear(c.action_dim, c.d_model) + self.prefix_position = nn.Parameter( + torch.randn(1, c.max_prefix_tokens, c.d_model) * 0.01 + ) + self.action_position = nn.Parameter( + torch.randn(1, c.max_action_tokens, c.d_model) * 0.01 + ) + self.time_embedding = TimeEmbedding(c.d_model) + self.layers = nn.ModuleList( + KnowledgeInsulatedBlock(c) for _ in range(c.n_layers) + ) + self.prefix_norm = RMSNorm(c.d_model, c.rms_eps) + self.action_norm = AdaRMSNorm(c.d_model, c.d_model, c.rms_eps) + self.text_head = nn.Linear(c.d_model, c.vocab_size, bias=False) + self.vector_field_head = nn.Linear(c.d_model, c.action_dim) + self.text_head.weight = self.token_embedding.weight + + def encode_prefix(self, pixel_values: Tensor, input_ids: Tensor) -> tuple[Tensor, int]: + patches = self.patch_encoder(pixel_values).flatten(2).transpose(1, 2) + prefix = torch.cat((patches, self.token_embedding(input_ids)), dim=1) + if prefix.shape[1] > self.config.max_prefix_tokens: + raise ValueError("prefix exceeds configured maximum length") + return prefix + self.prefix_position[:, : prefix.shape[1]], patches.shape[1] + + def _derive_action_prefix_mask(self, input_ids: Tensor) -> Tensor: + """Allow flow to read every token strictly before the first ACTION_START.""" + + is_action = input_ids.eq(self.config.action_start_token_id) + # If there is no teacher-forced FAST stream (normal flow inference), + # every supplied observation/subtask/advantage token is readable. + return is_action.long().cumsum(dim=1).eq(0) + + def _masks( + self, + batch: int, + prefix_length: int, + action_length: int, + text_offset: int, + attention_mask: Optional[Tensor], + action_prefix_mask: Tensor, + generated_token_start: Optional[int], + device: torch.device, + ) -> tuple[Tensor, Tensor]: + valid = torch.ones(batch, prefix_length, dtype=torch.bool, device=device) + if attention_mask is not None: + valid[:, text_offset:] = attention_mask.bool() + + generated_start = ( + prefix_length + if generated_token_start is None + else text_offset + generated_token_start + ) + if not text_offset <= generated_start <= prefix_length: + raise ValueError("generated_token_start must index input_ids") + q = torch.arange(prefix_length, device=device)[:, None] + k = torch.arange(prefix_length, device=device)[None] + structural = torch.where(q < generated_start, k < generated_start, k <= q) + prefix_edges = structural[None] & valid[:, None] + identity = torch.eye(prefix_length, dtype=torch.bool, device=device)[None] + prefix_edges = torch.where(valid[:, :, None], prefix_edges, identity) + + supplied = action_prefix_mask.bool() + if supplied.shape[1] == prefix_length - text_offset: + supplied = torch.cat( + ( + torch.ones(batch, text_offset, dtype=torch.bool, device=device), + supplied, + ), + dim=1, + ) + if supplied.shape != valid.shape: + raise ValueError("action_prefix_mask must cover text tokens or the full prefix") + readable = valid & supplied + prefix_keys = readable[:, None].expand(batch, action_length, prefix_length) + action_keys = torch.ones( + batch, action_length, action_length, dtype=torch.bool, device=device + ) + return prefix_edges, torch.cat((prefix_keys, action_keys), dim=-1) + + def vlm_parameters(self) -> Iterator[nn.Parameter]: + """Iterate unique parameters protected from flow-loss gradients by KI.""" + + modules: tuple[nn.Module, ...] = ( + self.patch_encoder, + self.token_embedding, + self.prefix_norm, + self.text_head, + ) + seen: set[int] = set() + for parameter in (self.prefix_position,): + seen.add(id(parameter)) + yield parameter + for layer in self.layers: + modules += ( + layer.vlm_norm1, + layer.vlm_attention, + layer.vlm_norm2, + layer.vlm_mlp, + ) + for module in modules: + for parameter in module.parameters(): + if id(parameter) not in seen: + seen.add(id(parameter)) + yield parameter + + def action_parameters(self) -> Iterator[nn.Parameter]: + """Iterate parameters trained by the continuous flow loss.""" + + modules: list[nn.Module] = [ + self.action_projection, + self.time_embedding, + self.action_norm, + self.vector_field_head, + ] + for layer in self.layers: + modules.extend( + ( + layer.action_norm1, + layer.action_attention, + layer.action_norm2, + layer.action_mlp, + ) + ) + yield self.action_position + for module in modules: + yield from module.parameters() + + def forward( + self, + pixel_values: Tensor, + input_ids: Tensor, + noisy_actions: Tensor, + timesteps: Tensor, + attention_mask: Optional[Tensor] = None, + action_prefix_mask: Optional[Tensor] = None, + generated_token_start: Optional[int] = None, + text_labels: Optional[Tensor] = None, + target_velocity: Optional[Tensor] = None, + text_loss_weight: float = 1.0, + flow_loss_weight: float = 1.0, + ) -> Pi06Output: + if noisy_actions.ndim != 3: + raise ValueError("noisy_actions must have shape [batch, horizon, action_dim]") + if noisy_actions.shape[-1] != self.config.action_dim: + raise ValueError("noisy_actions has the wrong action dimension") + if noisy_actions.shape[1] > self.config.max_action_tokens: + raise ValueError("action horizon exceeds configured maximum") + + prefix, text_offset = self.encode_prefix(pixel_values, input_ids) + horizon = noisy_actions.shape[1] + action = self.action_projection(noisy_actions) + action = action + self.action_position[:, :horizon] + condition = self.time_embedding(timesteps).to(action.dtype) + if action_prefix_mask is None: + action_prefix_mask = self._derive_action_prefix_mask(input_ids) + prefix_edges, action_edges = self._masks( + prefix.shape[0], prefix.shape[1], horizon, text_offset, + attention_mask, action_prefix_mask, generated_token_start, prefix.device, + ) + for layer in self.layers: + prefix, action = layer( + prefix, action, condition, prefix_edges, action_edges + ) + + prefix_hidden = self.prefix_norm(prefix) + action_hidden = self.action_norm(action, condition) + text_logits = self.text_head(prefix_hidden[:, text_offset:]) + vector_field = self.vector_field_head(action_hidden) + + text_loss = None + if text_labels is not None: + text_loss = F.cross_entropy( + text_logits.reshape(-1, text_logits.shape[-1]), + text_labels.reshape(-1), ignore_index=-100, + ) + flow_loss = None + if target_velocity is not None: + flow_loss = F.mse_loss(vector_field, target_velocity) + terms = [ + term for term in ( + None if text_loss is None else text_loss_weight * text_loss, + None if flow_loss is None else flow_loss_weight * flow_loss, + ) if term is not None + ] + loss = sum(terms) if terms else None + return Pi06Output( + text_logits=text_logits, + vector_field=vector_field, + prefix_hidden=prefix_hidden, + action_hidden=action_hidden, + text_token_offset=text_offset, + loss=loss, + text_loss=text_loss, + flow_loss=flow_loss, + ) + + +def flow_matching_sample(actions: Tensor) -> tuple[Tensor, Tensor, Tensor]: + """Construct the linear conditional-flow target used by the policy.""" + + batch = actions.shape[0] + timestep = torch.rand(batch, device=actions.device, dtype=actions.dtype) + noise = torch.randn_like(actions) + broadcast_t = timestep.reshape(batch, *([1] * (actions.ndim - 1))) + noisy_actions = (1 - broadcast_t) * noise + broadcast_t * actions + return noisy_actions, timestep, actions - noise diff --git a/src/pi06mini/recap.py b/src/pi06mini/recap.py new file mode 100644 index 0000000..176d219 --- /dev/null +++ b/src/pi06mini/recap.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import torch + + +def condition_dropout_mask( + batch_size: int, + *, + drop_probability: float = 0.3, + device: torch.device | str | None = None, + generator: torch.Generator | None = None, +) -> torch.Tensor: + """Sample the 30% advantage-condition dropout used for CFG training.""" + + if batch_size < 1: + raise ValueError("batch_size must be positive") + if not 0.0 <= drop_probability <= 1.0: + raise ValueError("drop_probability must lie in [0, 1]") + return torch.rand(batch_size, device=device, generator=generator) < drop_probability + + +def apply_condition_dropout( + condition: torch.Tensor, + null_condition: torch.Tensor | float | int = 0, + *, + drop_probability: float = 0.3, + mask: torch.Tensor | None = None, + generator: torch.Generator | None = None, +) -> tuple[torch.Tensor, torch.Tensor]: + """Replace complete per-example ReCAP conditions with a null condition.""" + + condition = torch.as_tensor(condition) + if condition.ndim < 1 or condition.shape[0] < 1: + raise ValueError("condition must have a non-empty batch dimension") + if mask is None: + mask = condition_dropout_mask( + condition.shape[0], + drop_probability=drop_probability, + device=condition.device, + generator=generator, + ) + else: + mask = torch.as_tensor(mask, dtype=torch.bool, device=condition.device) + if mask.shape != (condition.shape[0],): + raise ValueError("mask must have shape [batch_size]") + null = torch.as_tensor(null_condition, dtype=condition.dtype, device=condition.device) + batch_mask = mask + while mask.ndim < condition.ndim: + mask = mask.unsqueeze(-1) + dropped = torch.where(mask, null, condition) + return dropped, batch_mask + + +def classifier_free_guidance( + conditional_prediction: torch.Tensor, + unconditional_prediction: torch.Tensor, + *, + guidance_scale: float = 1.5, +) -> torch.Tensor: + """Combine conditional and null-condition flow fields. + + A scale of zero returns the unconditional prediction and a scale of one + returns the ordinary positive-condition prediction. + """ + + conditional_prediction = torch.as_tensor(conditional_prediction) + unconditional_prediction = torch.as_tensor( + unconditional_prediction, + dtype=conditional_prediction.dtype, + device=conditional_prediction.device, + ) + if conditional_prediction.shape != unconditional_prediction.shape: + raise ValueError("conditional and unconditional predictions must have equal shape") + if guidance_scale < 0.0: + raise ValueError("guidance_scale must be non-negative") + return unconditional_prediction + guidance_scale * ( + conditional_prediction - unconditional_prediction + ) diff --git a/src/pi06mini/runtime.py b/src/pi06mini/runtime.py new file mode 100644 index 0000000..118fb34 --- /dev/null +++ b/src/pi06mini/runtime.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +import random +from pathlib import Path +import numpy as np +import torch +import yaml + + +def load_config(path: str | Path) -> dict: + with Path(path).open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def seed_everything(seed: int) -> None: + random.seed(seed); np.random.seed(seed); torch.manual_seed(seed) + + +def choose_device(name: str = "auto") -> torch.device: + if name != "auto": return torch.device(name) + if torch.cuda.is_available(): return torch.device("cuda") + if torch.backends.mps.is_available(): return torch.device("mps") + return torch.device("cpu") + + +def write_json(path: str | Path, value: object) -> None: + target = Path(path); target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(value, indent=2, ensure_ascii=False), encoding="utf-8") diff --git a/src/pi06mini/tokenizer.py b/src/pi06mini/tokenizer.py new file mode 100644 index 0000000..fec7dbc --- /dev/null +++ b/src/pi06mini/tokenizer.py @@ -0,0 +1,330 @@ +r"""Tiny discrete state, subtask, advantage, and FAST-lite tokenization. + +The layout mirrors the ordering used by :math:`\pi^*_{0.6}`: the model first +predicts a subtask, then receives an optional advantage indicator, and only +then predicts the discrete action representation. ``omitted`` is represented +by the physical absence of an indicator token, as required for unconditional +classifier-free guidance training. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Iterable, Literal + +import torch +from torch import Tensor + + +Advantage = Literal["positive", "negative", "omitted"] + + +@dataclass(frozen=True) +class CoordinateRange: + minimum: float + maximum: float + + def __post_init__(self) -> None: + if self.maximum <= self.minimum: + raise ValueError("coordinate maximum must be greater than minimum") + + +class CoordinateTokenizer: + """Uniformly discretize XY state values into literal byte tokens 0..255.""" + + bins = 256 + + def __init__( + self, + x_range: tuple[float, float] = (-10.0, 10.0), + y_range: tuple[float, float] = (-5.0, 5.0), + ) -> None: + self.ranges = (CoordinateRange(*x_range), CoordinateRange(*y_range)) + + def encode(self, coordinates: Tensor | Iterable[Iterable[float]]) -> Tensor: + values = torch.as_tensor(coordinates, dtype=torch.float32) + if values.shape[-1] != 2: + raise ValueError("coordinates must end in an XY dimension of size 2") + low = values.new_tensor([axis.minimum for axis in self.ranges]) + high = values.new_tensor([axis.maximum for axis in self.ranges]) + unit = ((values - low) / (high - low)).clamp(0.0, 1.0) + return torch.round(unit * (self.bins - 1)).long() + + def decode(self, tokens: Tensor | Iterable[Iterable[int]]) -> Tensor: + encoded = torch.as_tensor(tokens, dtype=torch.long) + if encoded.shape[-1] != 2: + raise ValueError("coordinate tokens must end in an XY dimension of size 2") + if encoded.numel() and (encoded.min() < 0 or encoded.max() >= self.bins): + raise ValueError("coordinate tokens must be in the inclusive range 0..255") + low = torch.tensor([axis.minimum for axis in self.ranges]) + high = torch.tensor([axis.maximum for axis in self.ranges]) + return low + encoded.float() / (self.bins - 1) * (high - low) + + +class SubtaskVocabulary: + labels = ( + "keep_lane", + "steer_left", + "steer_right", + "slow_down", + "stop", + "accelerate", + "unknown", + ) + + def __init__(self, first_token_id: int = 260) -> None: + self.first_token_id = int(first_token_id) + self._label_to_token = { + label: self.first_token_id + index for index, label in enumerate(self.labels) + } + self._token_to_label = {token: label for label, token in self._label_to_token.items()} + + @property + def token_ids(self) -> tuple[int, ...]: + return tuple(self._label_to_token[label] for label in self.labels) + + def classify(self, reasoning: str) -> str: + text = reasoning.casefold() + if any(term in text for term in ("stop", "halt", "stationary")): + return "stop" + if any(term in text for term in ("slow", "deceler", "reduce speed", "brake")): + return "slow_down" + if any(term in text for term in ("accelerat", "speed up")): + return "accelerate" + if any(term in text for term in ("left", "counterclockwise")): + return "steer_left" + if any(term in text for term in ("right", "clockwise")): + return "steer_right" + if any(term in text for term in ("lane", "straight", "follow", "continue", "proceed")): + return "keep_lane" + return "unknown" + + def encode(self, label_or_reasoning: str) -> int: + label = ( + label_or_reasoning + if label_or_reasoning in self._label_to_token + else self.classify(label_or_reasoning) + ) + return self._label_to_token[label] + + def decode(self, token_id: int | Tensor) -> str: + try: + return self._token_to_label[int(token_id)] + except KeyError as error: + raise ValueError(f"unknown subtask token: {int(token_id)}") from error + + +@dataclass(frozen=True) +class PolicySequence: + """One policy token sequence plus masks needed by ``Pi06Mini``. + + ``labels`` is already shifted for next-token prediction. The advantage + token itself is never a prediction target because it is externally + supplied conditioning. ``action_prefix_mask`` allows the continuous + expert to read observation, predicted subtask, and advantage (if present), + while insulating it from teacher-forced FAST-lite tokens. + """ + + input_ids: Tensor + labels: Tensor + generated_token_start: int + action_prefix_mask: Tensor + subtask_index: int + advantage_index: int | None + action_start_index: int + + +class MiniTokenizer: + r"""Compose the minimal :math:`\pi^*_{0.6}` policy vocabulary.""" + + BOS = 256 + IMAGE = 257 + STATE = 258 + SUBTASK_QUERY = 259 + ADVANTAGE_POSITIVE = SUBTASK_QUERY + 1 + len(SubtaskVocabulary.labels) + ADVANTAGE_NEGATIVE = ADVANTAGE_POSITIVE + 1 + ACTION_START = ADVANTAGE_NEGATIVE + 1 + ACTION_END = ACTION_START + 1 + ACTION_TOKEN_START = ACTION_END + 1 + ACTION_BINS = 64 + ACTION_TOKEN_END = ACTION_TOKEN_START + ACTION_BINS - 1 + METADATA_DEMO = ACTION_TOKEN_END + 1 + METADATA_UNDERSTEER = METADATA_DEMO + 1 + METADATA_STOP = METADATA_DEMO + 2 + METADATA_INTERVENTION = METADATA_DEMO + 3 + ACTION_MINIMUM = -4.0 + ACTION_MAXIMUM = 4.0 + + def __init__( + self, + coordinate_tokenizer: CoordinateTokenizer | None = None, + subtasks: SubtaskVocabulary | None = None, + ) -> None: + self.coordinates = coordinate_tokenizer or CoordinateTokenizer() + self.subtasks = subtasks or SubtaskVocabulary(first_token_id=self.SUBTASK_QUERY + 1) + + @property + def vocab_size(self) -> int: + return self.METADATA_INTERVENTION + 1 + + def encode_metadata(self, source_type: str | None) -> Tensor | None: + if source_type is None: + return None + if source_type == "demo_success": return torch.tensor(self.METADATA_DEMO) + if "understeer" in source_type: return torch.tensor(self.METADATA_UNDERSTEER) + if "stop" in source_type: return torch.tensor(self.METADATA_STOP) + if "intervention" in source_type: return torch.tensor(self.METADATA_INTERVENTION) + raise ValueError(f"unknown metadata source_type: {source_type}") + + @property + def action_token_ids(self) -> range: + return range(self.ACTION_TOKEN_START, self.ACTION_TOKEN_END + 1) + + def build_observation(self, history_xy: Tensor, metadata: str | None = None) -> Tensor: + coordinate_tokens = self.coordinates.encode(history_xy).reshape(-1).cpu() + specials = torch.tensor([self.BOS, self.IMAGE, self.STATE], dtype=torch.long) + query = torch.tensor([self.SUBTASK_QUERY], dtype=torch.long) + parts = [specials, coordinate_tokens] + metadata_token = self.encode_metadata(metadata) + if metadata_token is not None: parts.append(metadata_token.reshape(1)) + parts.append(query) + return torch.cat(parts) + + def build_value_input(self, history_xy: Tensor, metadata: str | None = None) -> Tensor: + """Return image marker plus discretized state, without policy query tokens.""" + + coordinate_tokens = self.coordinates.encode(history_xy).reshape(-1).cpu() + specials = torch.tensor([self.BOS, self.IMAGE, self.STATE], dtype=torch.long) + parts = [specials, coordinate_tokens] + metadata_token = self.encode_metadata(metadata) + if metadata_token is not None: parts.append(metadata_token.reshape(1)) + return torch.cat(parts) + + # Backward-compatible name used by the pi0.5 data utilities. + build_prefix = build_observation + + def encode_subtask(self, reasoning: str) -> Tensor: + return torch.tensor(self.subtasks.encode(reasoning), dtype=torch.long) + + def encode_advantage(self, advantage: Advantage | bool | None) -> Tensor | None: + if advantage is True or advantage == "positive": + return torch.tensor(self.ADVANTAGE_POSITIVE, dtype=torch.long) + if advantage is False or advantage == "negative": + return torch.tensor(self.ADVANTAGE_NEGATIVE, dtype=torch.long) + if advantage is None or advantage == "omitted": + return None + raise ValueError("advantage must be positive, negative, omitted, bool, or None") + + @staticmethod + def _orthonormal_dct_matrix( + horizon: int, *, device: torch.device, dtype: torch.dtype + ) -> Tensor: + if horizon <= 0: + raise ValueError("DCT horizon must be positive") + time = torch.arange(horizon, device=device, dtype=dtype) + frequency = torch.arange(horizon, device=device, dtype=dtype).unsqueeze(1) + matrix = torch.cos(math.pi / horizon * (time + 0.5) * frequency) + matrix[0] *= math.sqrt(1.0 / horizon) + if horizon > 1: + matrix[1:] *= math.sqrt(2.0 / horizon) + return matrix + + def dct_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor: + values = torch.as_tensor(actions, dtype=torch.float32) + if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0: + raise ValueError("actions must have non-empty shape [horizon, 2]") + transform = self._orthonormal_dct_matrix( + values.shape[0], device=values.device, dtype=values.dtype + ) + return transform @ values + + def idct_actions(self, coefficients: Tensor | Iterable[Iterable[float]]) -> Tensor: + values = torch.as_tensor(coefficients, dtype=torch.float32) + if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0: + raise ValueError("DCT coefficients must have non-empty shape [horizon, 2]") + transform = self._orthonormal_dct_matrix( + values.shape[0], device=values.device, dtype=values.dtype + ) + return transform.transpose(0, 1) @ values + + def encode_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor: + values = torch.as_tensor(actions, dtype=torch.float32) + if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0: + raise ValueError("actions must have non-empty shape [horizon, 2]") + values = values.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM) + coefficients = self.dct_actions(values) / math.sqrt(values.shape[0]) + coefficients = torch.where( + coefficients.abs() < 1e-6, torch.zeros_like(coefficients), coefficients + ) + unit = ( + coefficients.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM) + - self.ACTION_MINIMUM + ) / (self.ACTION_MAXIMUM - self.ACTION_MINIMUM) + payload = torch.round(unit * (self.ACTION_BINS - 1)).long().reshape(-1) + payload = payload + self.ACTION_TOKEN_START + boundaries = torch.tensor( + [self.ACTION_START, self.ACTION_END], dtype=torch.long, device=payload.device + ) + return torch.cat((boundaries[:1], payload, boundaries[1:])) + + def decode_actions(self, tokens: Tensor | Iterable[int]) -> Tensor: + encoded = torch.as_tensor(tokens, dtype=torch.long) + if encoded.ndim != 1: + raise ValueError("action tokens must be a one-dimensional sequence") + if encoded.numel() < 4 or encoded[0] != self.ACTION_START or encoded[-1] != self.ACTION_END: + raise ValueError("action tokens must be enclosed by ACTION_START and ACTION_END") + payload = encoded[1:-1] + if payload.numel() % 2: + raise ValueError("action token payload must contain complete XY pairs") + if payload.min() < self.ACTION_TOKEN_START or payload.max() > self.ACTION_TOKEN_END: + raise ValueError("action token payload is outside the reserved action token interval") + quantized = payload - self.ACTION_TOKEN_START + coefficients = ( + quantized.float() / (self.ACTION_BINS - 1) + * (self.ACTION_MAXIMUM - self.ACTION_MINIMUM) + + self.ACTION_MINIMUM + ) + horizon = payload.numel() // 2 + return self.idct_actions(coefficients.reshape(horizon, 2) * math.sqrt(horizon)) + + def build_policy_sequence( + self, + history_xy: Tensor, + subtask: str, + actions: Tensor, + advantage: Advantage | bool | None, + metadata: str | None = None, + ) -> PolicySequence: + observation = self.build_observation(history_xy, metadata) + subtask_token = self.encode_subtask(subtask).reshape(1) + advantage_token = self.encode_advantage(advantage) + action_tokens = self.encode_actions(actions).cpu() + pieces = [observation, subtask_token] + if advantage_token is not None: + pieces.append(advantage_token.reshape(1)) + pieces.append(action_tokens) + input_ids = torch.cat(pieces) + + subtask_index = observation.numel() + advantage_index = subtask_index + 1 if advantage_token is not None else None + action_start_index = subtask_index + 1 + int(advantage_token is not None) + + labels = torch.full_like(input_ids, -100) + # SUBTASK_QUERY predicts the subtask. The external advantage indicator + # is deliberately skipped as a target. Advantage/subtask then predicts + # ACTION_START and the remaining FAST-lite stream autoregressively. + labels[subtask_index - 1] = input_ids[subtask_index] + labels[action_start_index - 1 : -1] = input_ids[action_start_index:] + + action_prefix_mask = torch.zeros_like(input_ids, dtype=torch.bool) + action_prefix_mask[:action_start_index] = True + return PolicySequence( + input_ids=input_ids, + labels=labels, + generated_token_start=subtask_index, + action_prefix_mask=action_prefix_mask, + subtask_index=subtask_index, + advantage_index=advantage_index, + action_start_index=action_start_index, + ) diff --git a/src/pi06mini/train.py b/src/pi06mini/train.py new file mode 100644 index 0000000..f9d033d --- /dev/null +++ b/src/pi06mini/train.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import argparse +import copy +import json +from pathlib import Path +import torch + +from .data import Pi06ExperienceDataset, materialize_subset +from .flow import FlowMatching +from .model import Pi06Config, Pi06Mini +from .recap import apply_condition_dropout +from .runtime import choose_device, load_config, seed_everything, write_json +from .tokenizer import MiniTokenizer +from .value import ValueConfig, ValueModel + + +def build_models(cfg: dict, tokenizer: MiniTokenizer) -> tuple[Pi06Mini, ValueModel]: + m = cfg["model"] + policy = Pi06Mini(Pi06Config( + vocab_size=tokenizer.vocab_size, action_dim=2, + image_size=cfg["data"]["image_size"], patch_size=m["patch_size"], + d_model=m["policy_d_model"], n_heads=m["n_heads"], n_layers=m["n_layers"], + mlp_ratio=m["mlp_ratio"], max_text_tokens=m["max_text_tokens"], + max_action_tokens=cfg["data"]["action_horizon"], + action_start_token_id=tokenizer.ACTION_START, dropout=m["dropout"], + )) + value = ValueModel(ValueConfig( + vocab_size=tokenizer.vocab_size, image_size=cfg["data"]["image_size"], + patch_size=m["patch_size"], d_model=m["value_d_model"], n_heads=m["n_heads"], + n_layers=2, mlp_ratio=m["mlp_ratio"], max_text_tokens=m["max_text_tokens"], + num_bins=m["value_bins"], value_min=m["value_min"], value_max=m["value_max"], + )) + return policy, value + + +def policy_step( + model: Pi06Mini, sample: dict, tokenizer: MiniTokenizer, flow: FlowMatching, + device: torch.device, advantage: str | None, cfg: dict, +) -> tuple[torch.Tensor, dict[str, float]]: + sequence = tokenizer.build_policy_sequence( + sample["history_xy"], sample["reasoning"], sample["actions"], advantage, + # Outcome/source metadata is reserved for the critic in this synthetic + # proxy. The policy must distinguish behavior through advantage alone. + metadata=None, + ) + image = sample["image"][None].to(device) + ids = sequence.input_ids[None].to(device) + labels = sequence.labels[None].to(device) + actions = sample["actions"][None].to(device) + noisy, timestep, velocity, weight = flow.training_batch(actions) + output = model( + image, ids, noisy, timestep, + action_prefix_mask=sequence.action_prefix_mask[None].to(device), + generated_token_start=sequence.generated_token_start, + text_labels=labels, + ) + flow_loss = flow.weighted_loss(output.vector_field, velocity, weight) + loss = cfg["train"]["token_loss_weight"] * output.text_loss + cfg["train"]["flow_loss_weight"] * flow_loss + return loss, { + "text_loss": float(output.text_loss.detach().cpu()), + "flow_loss": float(flow_loss.detach().cpu()), + } + + +def train(config_path: str) -> Path: + cfg = load_config(config_path) + root = Path(__file__).resolve().parents[2] + seed_everything(cfg["seed"]) + device = choose_device(cfg.get("device", "auto")) + manifest = materialize_subset(cfg["data"]["source_manifest"], root / "data/overfit") + dataset = Pi06ExperienceDataset( + manifest, image_size=cfg["data"]["image_size"], + augmentation_factor=cfg["experience"]["augmentation_factor"], + action_horizon=cfg["data"]["action_horizon"], n_step=cfg["experience"]["n_step"], + gamma=cfg["experience"]["gamma"], positive_quantile=cfg["experience"]["positive_quantile"], + ) + tokenizer = MiniTokenizer() + base, value = build_models(cfg, tokenizer) + base, value = base.to(device), value.to(device) + flow = FlowMatching(cfg["inference"]["flow_steps"]) + outdir = root / cfg["output_dir"]; outdir.mkdir(parents=True, exist_ok=True) + history: list[dict] = [] + + demos = [dataset[i] for i in range(len(dataset)) if dataset[i]["source_type"] == "demo_success"] + optimizer = torch.optim.AdamW(base.parameters(), lr=cfg["train"]["learning_rate"]) + base.train() + for step in range(1, cfg["train"]["base_steps"] + 1): + sample = demos[(step - 1) % len(demos)] + loss, metrics = policy_step(base, sample, tokenizer, flow, device, None, cfg) + optimizer.zero_grad(set_to_none=True); loss.backward() + torch.nn.utils.clip_grad_norm_(base.parameters(), cfg["train"]["grad_clip_norm"]); optimizer.step() + if step == 1 or step % cfg["train"]["log_every"] == 0: + row={"stage":"base","step":step,"loss":float(loss.detach().cpu()),**metrics};history.append(row);print(json.dumps(row)) + base_state = copy.deepcopy(base.state_dict()) + torch.save(base_state, outdir / "pi06_base.pt") + + value_opt = torch.optim.AdamW(value.parameters(), lr=cfg["train"]["value_learning_rate"]) + value.train() + for step in range(1, cfg["train"]["value_steps"] + 1): + sample = dataset[(step - 1) % len(dataset)] + ids = tokenizer.build_value_input(sample["history_xy"], sample["source_type"])[None].to(device) + output = value(sample["image"][None].to(device), ids, target_values=sample["value_target"][None].to(device)) + value_opt.zero_grad(set_to_none=True); output.loss.backward(); value_opt.step() + if step == 1 or step % cfg["train"]["log_every"] == 0: + row={"stage":"value","step":step,"loss":float(output.loss.detach().cpu()),"prediction":float(output.expectation.detach().cpu()),"target":float(sample["value_target"])};history.append(row);print(json.dumps(row)) + + def fit_policy(stage: str, steps: int, conditioned: bool) -> Pi06Mini: + model, _ = build_models(cfg, tokenizer); model.load_state_dict(base_state); model.to(device).train() + opt = torch.optim.AdamW(model.parameters(), lr=cfg["train"]["learning_rate"]) + for step in range(1, steps + 1): + sample = dataset[(step - 1) % len(dataset)] + advantage = None + if conditioned: + dropped = bool(apply_condition_dropout( + torch.tensor([sample["positive_condition"]]), + drop_probability=cfg["recap"]["condition_dropout"], + )[1][0]) + advantage = None if dropped else sample["advantage_label"] + loss, metrics = policy_step(model, sample, tokenizer, flow, device, advantage, cfg) + opt.zero_grad(set_to_none=True); loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), cfg["train"]["grad_clip_norm"]); opt.step() + if step == 1 or step % cfg["train"]["log_every"] == 0: + row={"stage":stage,"step":step,"loss":float(loss.detach().cpu()),**metrics};history.append(row);print(json.dumps(row)) + return model + + mixed = fit_policy("mixed", cfg["train"]["mixed_steps"], conditioned=False) + recap = fit_policy("recap", cfg["train"]["recap_steps"], conditioned=True) + torch.save(mixed.state_dict(), outdir / "mixed_sft.pt") + torch.save(recap.state_dict(), outdir / "pi06star_recap.pt") + checkpoint = { + "base": base_state, "mixed": mixed.state_dict(), "recap": recap.state_dict(), + "value": value.state_dict(), "policy_config": base.config.__dict__, + "value_config": value.config.__dict__, "config": cfg, + "action_statistics": dataset.action_normalizer.as_dict(), + } + torch.save(checkpoint, outdir / "checkpoint.pt") + write_json(outdir / "training_history.json", history) + write_json(outdir / "summary.json", { + "device": str(device), "experience_samples": len(dataset), + "policy_parameters": sum(p.numel() for p in base.parameters()), + "value_parameters": sum(p.numel() for p in value.parameters()), + "sources": [dataset[i]["source_type"] for i in range(len(dataset))], + }) + return outdir / "checkpoint.pt" + + +def main() -> None: + parser=argparse.ArgumentParser();parser.add_argument("--config",default="configs/overfit.yaml") + train(parser.parse_args().config) + + +if __name__ == "__main__": main() diff --git a/src/pi06mini/value.py b/src/pi06mini/value.py new file mode 100644 index 0000000..fc0c0ce --- /dev/null +++ b/src/pi06mini/value.py @@ -0,0 +1,188 @@ +"""Independent 201-bin distributional value model for minimal Recap training.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import torch +from torch import Tensor, nn +import torch.nn.functional as F + +from .model import AttentionWeights, MLP, RMSNorm, attention + + +@dataclass +class ValueConfig: + vocab_size: int = 512 + image_channels: int = 3 + image_size: int = 64 + patch_size: int = 16 + d_model: int = 96 + n_heads: int = 4 + n_layers: int = 3 + mlp_ratio: int = 4 + max_text_tokens: int = 64 + num_bins: int = 201 + value_min: float = -1.0 + value_max: float = 0.0 + dropout: float = 0.0 + rms_eps: float = 1e-6 + + def __post_init__(self) -> None: + if self.d_model % self.n_heads: + raise ValueError("d_model must be divisible by n_heads") + if self.image_size % self.patch_size: + raise ValueError("image_size must be divisible by patch_size") + if self.num_bins < 2: + raise ValueError("num_bins must be at least two") + if self.value_max <= self.value_min: + raise ValueError("value_max must be greater than value_min") + + @property + def max_image_tokens(self) -> int: + return (self.image_size // self.patch_size) ** 2 + + @property + def max_sequence_tokens(self) -> int: + # One learned pooling token plus image and discrete-state tokens. + return 1 + self.max_image_tokens + self.max_text_tokens + + +@dataclass +class ValueOutput: + logits: Tensor + probabilities: Tensor + expectation: Tensor + hidden: Tensor + loss: Optional[Tensor] = None + target_bins: Optional[Tensor] = None + + +class ValueBlock(nn.Module): + def __init__(self, config: ValueConfig) -> None: + super().__init__() + self.n_heads = config.n_heads + self.dropout = config.dropout + self.norm1 = RMSNorm(config.d_model, config.rms_eps) + self.attention = AttentionWeights(config.d_model) + self.norm2 = RMSNorm(config.d_model, config.rms_eps) + self.mlp = MLP(config.d_model, config.mlp_ratio, config.dropout) + + def forward(self, x: Tensor, edges: Tensor) -> Tensor: + normalized = self.norm1(x) + weights = self.attention + update = attention( + weights.q(normalized), + weights.k(normalized), + weights.v(normalized), + self.n_heads, + edges, + self.dropout, + self.training, + ) + x = x + weights.out(update) + return x + self.mlp(self.norm2(x)) + + +class DistributionalValueModel(nn.Module): + """Predict ``p(V | image, discrete state)`` over 201 return bins. + + The value network is deliberately a separate model: it shares no + parameters with ``Pi06Mini``. By default its support is the paper's + task-normalized interval ``[-1, 0]``. + """ + + def __init__(self, config: Optional[ValueConfig] = None) -> None: + super().__init__() + self.config = config or ValueConfig() + c = self.config + self.patch_encoder = nn.Conv2d( + c.image_channels, c.d_model, c.patch_size, stride=c.patch_size + ) + self.token_embedding = nn.Embedding(c.vocab_size, c.d_model) + self.value_token = nn.Parameter(torch.randn(1, 1, c.d_model) * 0.01) + self.position = nn.Parameter( + torch.randn(1, c.max_sequence_tokens, c.d_model) * 0.01 + ) + self.layers = nn.ModuleList(ValueBlock(c) for _ in range(c.n_layers)) + self.norm = RMSNorm(c.d_model, c.rms_eps) + self.value_head = nn.Linear(c.d_model, c.num_bins) + self.register_buffer( + "support", + torch.linspace(c.value_min, c.value_max, c.num_bins), + persistent=True, + ) + + def values_to_bins(self, values: Tensor) -> Tensor: + """Nearest-bin discretization of normalized Monte-Carlo returns.""" + + values = torch.as_tensor(values, dtype=self.support.dtype, device=self.support.device) + unit = (values.clamp(self.config.value_min, self.config.value_max) - self.config.value_min) + unit = unit / (self.config.value_max - self.config.value_min) + return torch.round(unit * (self.config.num_bins - 1)).long() + + def bins_to_values(self, bins: Tensor) -> Tensor: + bins = torch.as_tensor(bins, dtype=torch.long, device=self.support.device) + if bins.numel() and (bins.min() < 0 or bins.max() >= self.config.num_bins): + raise ValueError("value bins are outside the configured support") + return self.support[bins] + + def expectation_from_logits(self, logits: Tensor) -> Tensor: + return (logits.softmax(dim=-1) * self.support.to(logits.dtype)).sum(dim=-1) + + def forward( + self, + pixel_values: Tensor, + input_ids: Tensor, + attention_mask: Optional[Tensor] = None, + target_values: Optional[Tensor] = None, + target_bins: Optional[Tensor] = None, + ) -> ValueOutput: + if target_values is not None and target_bins is not None: + raise ValueError("provide target_values or target_bins, not both") + patches = self.patch_encoder(pixel_values).flatten(2).transpose(1, 2) + text = self.token_embedding(input_ids) + batch = input_ids.shape[0] + pooling = self.value_token.expand(batch, -1, -1) + hidden = torch.cat((pooling, patches, text), dim=1) + if hidden.shape[1] > self.config.max_sequence_tokens: + raise ValueError("value-model sequence exceeds configured maximum length") + hidden = hidden + self.position[:, : hidden.shape[1]] + + valid = torch.ones(batch, hidden.shape[1], dtype=torch.bool, device=hidden.device) + if attention_mask is not None: + if attention_mask.shape != input_ids.shape: + raise ValueError("attention_mask must have the same shape as input_ids") + valid[:, 1 + patches.shape[1] :] = attention_mask.bool() + edges = valid[:, None, :].expand(batch, hidden.shape[1], hidden.shape[1]) + identity = torch.eye(hidden.shape[1], dtype=torch.bool, device=hidden.device)[None] + edges = torch.where(valid[:, :, None], edges, identity) + for layer in self.layers: + hidden = layer(hidden, edges) + hidden = self.norm(hidden) + + logits = self.value_head(hidden[:, 0]) + probabilities = logits.softmax(dim=-1) + expectation = (probabilities * self.support.to(logits.dtype)).sum(dim=-1) + + bins = None + if target_values is not None: + bins = self.values_to_bins(target_values).to(logits.device) + elif target_bins is not None: + bins = torch.as_tensor(target_bins, dtype=torch.long, device=logits.device) + if bins.numel() and (bins.min() < 0 or bins.max() >= self.config.num_bins): + raise ValueError("target_bins are outside the configured support") + loss = None if bins is None else F.cross_entropy(logits, bins.reshape(-1)) + return ValueOutput( + logits=logits, + probabilities=probabilities, + expectation=expectation, + hidden=hidden, + loss=loss, + target_bins=bins, + ) + + +# Short name convenient in configs and training scripts. +ValueModel = DistributionalValueModel diff --git a/tests/test_experience.py b/tests/test_experience.py new file mode 100644 index 0000000..39ac302 --- /dev/null +++ b/tests/test_experience.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import torch + +from pi06mini.experience import ( + build_synthetic_experience, + discounted_returns, + n_step_advantages, + normalize_returns_to_unit_interval, + quantile_advantage_labels, +) + + +def _geometry() -> tuple[torch.Tensor, torch.Tensor]: + history = torch.stack((torch.linspace(-7.0, 0.0, 8), torch.zeros(8)), dim=-1) + x = torch.linspace(1.0, 16.0, 16) + future = torch.stack((x, 0.025 * x.square()), dim=-1) + return history, future + + +def test_synthetic_buffer_has_all_experience_sources_and_failure_geometry() -> None: + history, future = _geometry() + episodes = build_synthetic_experience(history, future, episode_prefix="pai") + + assert [episode.source_type for episode in episodes] == [ + "demo_success", + "autonomous_understeer", + "autonomous_stop", + "intervention_correction", + ] + demo, understeer, stopped, correction = episodes + torch.testing.assert_close(demo.future_xy, future) + assert understeer.future_xy[-1, 1] < future[-1, 1] + assert torch.equal(stopped.future_xy[-1], stopped.future_xy[-2]) + assert correction.intervention_mask.any() + assert not correction.intervention_mask[0] + torch.testing.assert_close(correction.future_xy[-1], future[-1]) + assert torch.all(demo.rewards == 0) + assert float(understeer.rewards.min()) < 0 + assert float(stopped.rewards.min()) < 0 + + +def test_returns_are_normalized_to_closed_negative_unit_interval() -> None: + rewards = torch.tensor([[0.0, 0.0, 0.0], [-0.2, -0.4, -1.0]]) + returns = discounted_returns(rewards, gamma=1.0) + normalized = normalize_returns_to_unit_interval(returns) + + assert normalized.shape == rewards.shape + assert float(normalized.min()) == -1.0 + assert float(normalized.max()) == 0.0 + assert torch.all(normalized <= 0.0) + assert torch.all(normalized >= -1.0) + assert torch.equal( + normalize_returns_to_unit_interval(torch.ones(3)), torch.zeros(3) + ) + + +def test_n_step_advantage_bootstraps_and_stops_at_terminal() -> None: + rewards = torch.tensor([1.0, 2.0, 3.0]) + values = torch.tensor([0.5, 1.0, 1.5, 8.0]) + dones = torch.tensor([False, True, False]) + advantages = n_step_advantages( + rewards, values, n_step=3, gamma=0.5, dones=dones + ) + + # t=0 sees r0 + gamma*r1, then the terminal blocks the large bootstrap. + assert torch.isclose(advantages[0], torch.tensor(1.5)) + assert torch.isclose(advantages[1], torch.tensor(1.0)) + # t=2 reaches the explicit end and therefore has no bootstrap. + assert torch.isclose(advantages[2], torch.tensor(1.5)) + + +def test_quantile_labels_force_interventions_positive() -> None: + advantages = torch.tensor([-3.0, -2.0, -1.0, 2.0]) + interventions = torch.tensor([True, False, False, False]) + labels, threshold = quantile_advantage_labels( + advantages, quantile=0.75, intervention_mask=interventions + ) + + assert labels.tolist() == [True, False, False, True] + assert float(threshold) > -1.0 diff --git a/tests/test_experience_data.py b/tests/test_experience_data.py new file mode 100644 index 0000000..a3d0a20 --- /dev/null +++ b/tests/test_experience_data.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import torch +from PIL import Image + +from pi06mini.data import Pi06ExperienceDataset, load_manifest, materialize_subset + + +def _make_source_project(root: Path) -> Path: + processed = root / "data" / "processed" + (processed / "images").mkdir(parents=True) + (processed / "trajectories").mkdir(parents=True) + pixels = np.zeros((40, 64, 3), dtype=np.uint8) + pixels[..., 0] = 80 + pixels[..., 1] = np.arange(64, dtype=np.uint8)[None, :] + Image.fromarray(pixels).save(processed / "images" / "sample.jpg") + + hx = np.linspace(-7.0, 0.0, 8, dtype=np.float32) + history = np.stack((hx, 0.01 * hx, np.zeros(8)), axis=-1) + fx = np.linspace(1.0, 24.0, 16, dtype=np.float32) + future = np.stack((fx, 0.015 * fx**2, np.zeros(16)), axis=-1) + np.savez_compressed( + processed / "trajectories" / "sample.npz", + history_xyz=history, + future_xyz=future, + ) + manifest = processed / "overfit_manifest.jsonl" + manifest.write_text( + json.dumps( + { + "clip_id": "clip-a", + "t0_us": 123, + "reasoning": "Steer left.", + "image_path": "data/processed/images/sample.jpg", + "trajectory_path": "data/processed/trajectories/sample.npz", + } + ) + + "\n", + encoding="utf-8", + ) + return manifest + + +def test_experience_dataset_contract_and_intervention_label(tmp_path: Path) -> None: + source = _make_source_project(tmp_path / "source") + manifest = materialize_subset(source, tmp_path / "target") + assert load_manifest(manifest)[0].image_path.is_file() + + dataset = Pi06ExperienceDataset(manifest, image_size=32) + assert len(dataset) == 4 + assert {item.source_type for item in dataset.episodes} == { + "demo_success", + "autonomous_understeer", + "autonomous_stop", + "intervention_correction", + } + assert float(dataset.normalized_returns.min()) == -1.0 + assert float(dataset.normalized_returns.max()) == 0.0 + + items = [dataset[index] for index in range(len(dataset))] + intervention = next(item for item in items if item["is_intervention"]) + assert bool(intervention["positive_condition"]) + assert torch.all(intervention["positive_steps"][intervention["intervention_mask"]]) + assert intervention["image"].shape == (3, 32, 32) + assert intervention["state"].shape == (16,) + assert intervention["actions"].shape == (16, 2) + assert intervention["returns"].shape == (16,) + assert intervention["return_target"].shape == () + assert intervention["value_target"].shape == () + assert intervention["advantages"].shape == (16,) + assert intervention["advantage_label"] == "positive" + assert intervention["reasoning"] == "Steer left." + decoded = dataset.action_normalizer.decode( + intervention["actions"], intervention["history_xy"] + ) + torch.testing.assert_close(decoded, intervention["future_xy"], atol=1e-5, rtol=1e-5) diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..389e03f --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,123 @@ +import torch + +from pi06mini.model import Pi06Config, Pi06Mini +from pi06mini.tokenizer import MiniTokenizer + + +def tiny_model() -> Pi06Mini: + tokenizer = MiniTokenizer() + return Pi06Mini( + Pi06Config( + vocab_size=tokenizer.vocab_size, + image_size=16, + patch_size=16, + d_model=32, + n_heads=4, + n_layers=1, + max_text_tokens=32, + max_action_tokens=4, + action_start_token_id=tokenizer.ACTION_START, + ) + ) + + +def test_flow_reads_subtask_and_advantage_but_not_fast_targets() -> None: + tokenizer = MiniTokenizer() + sequence = tokenizer.build_policy_sequence( + torch.zeros(2, 2), "steer_left", torch.zeros(2, 2), "positive" + ) + model = tiny_model() + input_ids = sequence.input_ids[None] + derived = model._derive_action_prefix_mask(input_ids) + torch.testing.assert_close(derived[0], sequence.action_prefix_mask) + + text_offset = 1 + prefix, flow = model._masks( + batch=1, + prefix_length=1 + input_ids.shape[1], + action_length=2, + text_offset=text_offset, + attention_mask=torch.ones_like(input_ids, dtype=torch.bool), + action_prefix_mask=derived, + generated_token_start=sequence.generated_token_start, + device=torch.device("cpu"), + ) + generated_start = text_offset + sequence.generated_token_start + assert prefix[0, 0, :generated_start].all() + assert not prefix[0, 0, generated_start:].any() + # Flow sees image plus all text strictly before ACTION_START. + readable_prefix = text_offset + sequence.action_start_index + assert flow[0, :, :readable_prefix].all() + assert not flow[0, :, readable_prefix : 1 + input_ids.shape[1]].any() + assert flow[0, :, 1 + input_ids.shape[1] :].all() + + +def test_flow_loss_is_knowledge_insulated_from_every_vlm_parameter() -> None: + torch.manual_seed(4) + tokenizer = MiniTokenizer() + sequence = tokenizer.build_policy_sequence( + torch.zeros(2, 2), "steer_left", torch.zeros(2, 2), "positive" + ) + model = tiny_model() + output = model( + pixel_values=torch.randn(1, 3, 16, 16), + input_ids=sequence.input_ids[None], + noisy_actions=torch.randn(1, 2, 2), + timesteps=torch.rand(1), + action_prefix_mask=sequence.action_prefix_mask[None], + generated_token_start=sequence.generated_token_start, + target_velocity=torch.randn(1, 2, 2), + ) + assert output.flow_loss is not None + output.flow_loss.backward() + + vlm_gradients = [parameter.grad for parameter in model.vlm_parameters()] + assert vlm_gradients + assert all(gradient is None or torch.count_nonzero(gradient) == 0 for gradient in vlm_gradients) + action_gradients = [parameter.grad for parameter in model.action_parameters()] + assert any(gradient is not None and torch.count_nonzero(gradient) > 0 for gradient in action_gradients) + + +def test_knowledge_insulation_stops_gradients_not_conditioning() -> None: + torch.manual_seed(8) + tokenizer = MiniTokenizer() + model = tiny_model().eval() + positive = tokenizer.build_policy_sequence( + torch.zeros(2, 2), "keep_lane", torch.zeros(2, 2), "positive" + ) + negative = tokenizer.build_policy_sequence( + torch.zeros(2, 2), "keep_lane", torch.zeros(2, 2), "negative" + ) + image = torch.randn(1, 3, 16, 16) + noise = torch.randn(1, 2, 2) + timestep = torch.full((1,), 0.5) + with torch.no_grad(): + pos = model( + image, positive.input_ids[None], noise, timestep, + generated_token_start=positive.generated_token_start, + ).vector_field + neg = model( + image, negative.input_ids[None], noise, timestep, + generated_token_start=negative.generated_token_start, + ).vector_field + assert not torch.allclose(pos, neg) + + +def test_joint_loss_still_trains_vlm_through_token_objective() -> None: + tokenizer = MiniTokenizer() + sequence = tokenizer.build_policy_sequence( + torch.zeros(2, 2), "stop", torch.zeros(2, 2), "negative" + ) + model = tiny_model() + output = model( + torch.randn(1, 3, 16, 16), + sequence.input_ids[None], + torch.randn(1, 2, 2), + torch.rand(1), + generated_token_start=sequence.generated_token_start, + text_labels=sequence.labels[None], + target_velocity=torch.randn(1, 2, 2), + ) + output.loss.backward() + assert model.token_embedding.weight.grad is not None + assert torch.count_nonzero(model.token_embedding.weight.grad) > 0 diff --git a/tests/test_recap.py b/tests/test_recap.py new file mode 100644 index 0000000..8defff4 --- /dev/null +++ b/tests/test_recap.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import torch + +from pi06mini.recap import ( + apply_condition_dropout, + classifier_free_guidance, + condition_dropout_mask, +) + + +def test_default_condition_dropout_is_statistically_thirty_percent() -> None: + generator = torch.Generator().manual_seed(11) + mask = condition_dropout_mask(20_000, generator=generator) + + assert mask.dtype == torch.bool + assert abs(float(mask.float().mean()) - 0.3) < 0.015 + + +def test_condition_dropout_replaces_whole_examples() -> None: + conditions = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) + dropped, mask = apply_condition_dropout( + conditions, + null_condition=-1.0, + mask=torch.tensor([False, True, False]), + ) + + assert mask.tolist() == [False, True, False] + torch.testing.assert_close(dropped[0], conditions[0]) + torch.testing.assert_close(dropped[1], torch.tensor([-1.0, -1.0])) + torch.testing.assert_close(dropped[2], conditions[2]) + + +def test_cfg_interpolates_and_extrapolates_flow_predictions() -> None: + conditional = torch.tensor([[2.0, 4.0]]) + unconditional = torch.tensor([[0.0, 2.0]]) + + torch.testing.assert_close( + classifier_free_guidance(conditional, unconditional, guidance_scale=0.0), + unconditional, + ) + torch.testing.assert_close( + classifier_free_guidance(conditional, unconditional, guidance_scale=1.0), + conditional, + ) + torch.testing.assert_close( + classifier_free_guidance(conditional, unconditional, guidance_scale=1.5), + torch.tensor([[3.0, 5.0]]), + ) diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py new file mode 100644 index 0000000..8bd686c --- /dev/null +++ b/tests/test_tokenizer.py @@ -0,0 +1,65 @@ +import torch + +from pi06mini.tokenizer import MiniTokenizer + + +def test_advantage_condition_is_between_subtask_and_actions() -> None: + tokenizer = MiniTokenizer() + history = torch.tensor([[0.0, 0.0], [1.0, 0.2]]) + actions = torch.tensor([[0.1, 0.0], [0.2, -0.1]]) + + positive = tokenizer.build_policy_sequence( + history, "steer_left", actions, "positive" + ) + assert positive.advantage_index == positive.subtask_index + 1 + assert positive.action_start_index == positive.advantage_index + 1 + assert positive.input_ids[positive.advantage_index] == tokenizer.ADVANTAGE_POSITIVE + assert positive.input_ids[positive.action_start_index] == tokenizer.ACTION_START + assert positive.action_prefix_mask[: positive.action_start_index].all() + assert not positive.action_prefix_mask[positive.action_start_index :].any() + + negative = tokenizer.build_policy_sequence( + history, "steer_left", actions, "negative" + ) + assert negative.input_ids[negative.advantage_index] == tokenizer.ADVANTAGE_NEGATIVE + + +def test_omitted_advantage_is_absent_not_a_third_condition_token() -> None: + tokenizer = MiniTokenizer() + sequence = tokenizer.build_policy_sequence( + torch.zeros(2, 2), "keep_lane", torch.zeros(2, 2), "omitted" + ) + + assert sequence.advantage_index is None + assert sequence.action_start_index == sequence.subtask_index + 1 + assert sequence.input_ids[sequence.action_start_index] == tokenizer.ACTION_START + assert tokenizer.ADVANTAGE_POSITIVE not in sequence.input_ids + assert tokenizer.ADVANTAGE_NEGATIVE not in sequence.input_ids + + +def test_labels_predict_subtask_and_fast_but_not_external_advantage() -> None: + tokenizer = MiniTokenizer() + positive = tokenizer.build_policy_sequence( + torch.zeros(2, 2), "stop", torch.zeros(2, 2), True + ) + assert positive.labels[positive.subtask_index - 1] == positive.input_ids[positive.subtask_index] + assert positive.labels[positive.subtask_index] == -100 + assert positive.labels[positive.advantage_index] == tokenizer.ACTION_START + + omitted = tokenizer.build_policy_sequence( + torch.zeros(2, 2), "stop", torch.zeros(2, 2), None + ) + assert omitted.labels[omitted.subtask_index] == tokenizer.ACTION_START + + +def test_fast_lite_dct_round_trip_has_bounded_error() -> None: + tokenizer = MiniTokenizer() + actions = torch.tensor( + [[0.0, 0.0], [0.2, -0.1], [0.5, -0.2], [0.8, -0.1]], + dtype=torch.float32, + ) + exact = tokenizer.idct_actions(tokenizer.dct_actions(actions)) + torch.testing.assert_close(exact, actions, atol=1e-5, rtol=1e-5) + + decoded = tokenizer.decode_actions(tokenizer.encode_actions(actions)) + assert torch.sqrt(torch.mean((decoded - actions) ** 2)) < 0.3 diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 0000000..13a2657 --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,73 @@ +import torch + +from pi06mini.model import Pi06Config, Pi06Mini +from pi06mini.tokenizer import MiniTokenizer +from pi06mini.value import DistributionalValueModel, ValueConfig + + +def tiny_value_model() -> DistributionalValueModel: + tokenizer = MiniTokenizer() + return DistributionalValueModel( + ValueConfig( + vocab_size=tokenizer.vocab_size, + image_size=16, + patch_size=16, + d_model=32, + n_heads=4, + n_layers=1, + max_text_tokens=16, + ) + ) + + +def test_distributional_value_has_201_bins_and_expected_value() -> None: + tokenizer = MiniTokenizer() + model = tiny_value_model() + state = tokenizer.build_value_input(torch.zeros(2, 2))[None].repeat(2, 1) + output = model( + torch.randn(2, 3, 16, 16), + state, + target_values=torch.tensor([-1.0, 0.0]), + ) + + assert output.logits.shape == (2, 201) + assert output.probabilities.shape == (2, 201) + torch.testing.assert_close(output.probabilities.sum(-1), torch.ones(2)) + assert torch.all((-1.0 <= output.expectation) & (output.expectation <= 0.0)) + assert output.target_bins.tolist() == [0, 200] + assert output.loss is not None and output.loss.ndim == 0 + + +def test_value_discretization_and_expectation_use_configured_support() -> None: + model = tiny_value_model() + bins = model.values_to_bins(torch.tensor([-2.0, -0.5, 1.0])) + assert bins.tolist() == [0, 100, 200] + torch.testing.assert_close( + model.bins_to_values(torch.tensor([0, 100, 200])), + torch.tensor([-1.0, -0.5, 0.0]), + ) + logits = torch.full((3, 201), -100.0) + logits[0, 0] = logits[1, 100] = logits[2, 200] = 100.0 + torch.testing.assert_close( + model.expectation_from_logits(logits), + torch.tensor([-1.0, -0.5, 0.0]), + ) + + +def test_value_model_and_policy_share_no_parameters() -> None: + tokenizer = MiniTokenizer() + policy = Pi06Mini( + Pi06Config( + vocab_size=tokenizer.vocab_size, + image_size=16, + patch_size=16, + d_model=32, + n_heads=4, + n_layers=1, + action_start_token_id=tokenizer.ACTION_START, + ) + ) + value = tiny_value_model() + policy_ids = {id(parameter) for parameter in policy.parameters()} + value_ids = {id(parameter) for parameter in value.parameters()} + assert policy_ids.isdisjoint(value_ids)