From b4c8db846b1a9fc52e3a4d9593c5e67b10e1353d Mon Sep 17 00:00:00 2001 From: seeker Date: Sun, 12 Jul 2026 00:52:00 +0800 Subject: [PATCH] Initial pi0.7 minimal validation --- .gitignore | 9 + README.md | 246 ++++++++ THIRD_PARTY_NOTICES.md | 31 + configs/overfit.yaml | 98 ++++ outputs/overfit/context_ablation.png | Bin 0 -> 93967 bytes outputs/overfit/evaluation.json | 11 + outputs/overfit/goal_generation.png | Bin 0 -> 51295 bytes outputs/overfit/summary.json | 7 + outputs/overfit/training_history.json | 459 +++++++++++++++ pyproject.toml | 38 ++ src/pi07mini/__init__.py | 32 + src/pi07mini/context.py | 209 +++++++ src/pi07mini/data.py | 418 +++++++++++++ src/pi07mini/flow.py | 18 + src/pi07mini/infer.py | 40 ++ src/pi07mini/memory.py | 215 +++++++ src/pi07mini/model.py | 816 ++++++++++++++++++++++++++ src/pi07mini/rtc.py | 62 ++ src/pi07mini/runtime.py | 14 + src/pi07mini/tokenizer.py | 393 +++++++++++++ src/pi07mini/train.py | 40 ++ tests/test_context.py | 76 +++ tests/test_data.py | 134 +++++ tests/test_memory.py | 63 ++ tests/test_model.py | 180 ++++++ tests/test_rtc.py | 50 ++ tests/test_tokenizer.py | 65 ++ 27 files changed, 3724 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/context_ablation.png create mode 100644 outputs/overfit/evaluation.json create mode 100644 outputs/overfit/goal_generation.png create mode 100644 outputs/overfit/summary.json create mode 100644 outputs/overfit/training_history.json create mode 100644 pyproject.toml create mode 100644 src/pi07mini/__init__.py create mode 100644 src/pi07mini/context.py create mode 100644 src/pi07mini/data.py create mode 100644 src/pi07mini/flow.py create mode 100644 src/pi07mini/infer.py create mode 100644 src/pi07mini/memory.py create mode 100644 src/pi07mini/model.py create mode 100644 src/pi07mini/rtc.py create mode 100644 src/pi07mini/runtime.py create mode 100644 src/pi07mini/tokenizer.py create mode 100644 src/pi07mini/train.py create mode 100644 tests/test_context.py create mode 100644 tests/test_data.py create mode 100644 tests/test_memory.py create mode 100644 tests/test_model.py create mode 100644 tests/test_rtc.py create mode 100644 tests/test_tokenizer.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..046811e --- /dev/null +++ b/README.md @@ -0,0 +1,246 @@ +# π0.7 Minimal Validation + +这是一个面向**结构验证和小样本过拟合**的 π0.7-style 教学项目。它在前面的 +π0.6/RECAP 最小实验之上,重点验证一条可解释的 steerable policy 链路:利用 +短时历史形成 memory,在高层预测视觉 subgoal,通过可独立缺省的 policy +metadata 控制动作风格,并在存在控制延迟时用 RTC 对齐动作 chunk。 + +本项目不是 Physical Intelligence 官方实现,也不能用于真实车辆或机器人控制。 + +## 公开资料边界 + +截至本项目实现时,作者没有在 Physical Intelligence 官方网站、公开论文索引或 +`openpi` 仓库中找到可核验的 π0.7 原始论文、官方训练源码、checkpoint 或完整 +model card。因此这里不能声称“逐层复现论文”,也不会虚构官方模块尺寸、损失、 +数据配方或实验数字。 + +本仓库所说的 **paper-faithful toy** 有严格边界:若 π0.7 的公开描述强调 memory、 +goal generation、high-level control、steerable metadata、CFG 和 real-time +chunking,本项目保留这些模块的输入输出关系与必要消融;所有未公开细节都使用 +最小、明确标注且有测试的本地替代。`TinyMEMEncoder`、`TinyGoalGenerator`、 +`HighLevelPolicy` 和 `Pi07Mini` 都是本仓库命名,不代表官方内部类名。 + +若后续公开原始论文或源码,应重新审计本项目,而不是把当前实现当成事实来源。 + +## 模型阶段与输入输出 + +| 阶段 | 输入 | 模块 | 输出 | 训练或验证目标 | +|---|---|---|---|---| +| 1. 历史构造 | 当前图像、过去 XY;不接受 future | deterministic history proxy | `6` 帧 oldest-to-newest 图像、历史状态、共享当前状态的 ambiguous history pair | 检查无未来轨迹泄漏 | +| 2. Temporal memory | 历史图像与历史状态 | `TinyMEMEncoder` | memory tokens / pooled memory embedding | 区分当前状态相同、过去路径不同的样本 | +| 3. Goal generation | 当前 observation、memory、任务文本 | `TinyGoalGenerator` | endpoint heatmap / subgoal XY | 只监督 future endpoint,不把完整真值轨迹输入 policy | +| 4. High-level policy | observation、memory、任务与可见 metadata | `HighLevelPolicy` | high-level context、goal/metadata logits | goal loss 与 high-level classification loss | +| 5. Steerable flow policy | high-level context、预测 goal、metadata、noisy action、flow time | `Pi07Mini` low-level action expert | 连续 action vector field `[16,2]` | flow-matching loss | +| 6. RTC alignment | action chunk、随机 delay `d∈[0,12]`、已执行 action prefix | real-time chunking target alignment | 从 `action[d]` 开始的 target 与有效位置 mask | 仅在有效位置计算 masked flow loss | +| 7. CFG inference | 同一 observation 的 conditional 与 metadata-omitted 两次前向 | classifier-free guidance | guided action chunk | 与普通 conditional、unconditional 和真值同图比较 | +| 8. 轨迹恢复 | normalized action deltas、历史终点、训练统计 | denormalize + cumulative sum | 未来 XY 轨迹 | 轨迹 MSE 与可视化,不等同安全指标 | + +核心信息流: + +```text +history frames + history states + ↓ + TinyMEMEncoder ───────────────┐ + │ +current image + task ────────────────┼→ TinyGoalGenerator → predicted subgoal + │ +optional speed/quality/mistake/control metadata + ↓ + HighLevelPolicy + ↓ +noisy action + flow time + RTC prefix → Pi07Mini action expert + ↓ + continuous action chunk +``` + +## 重点结构 + +### 1. MEM:短时历史而非单帧 + +PAI 的极小 manifest 只有一张相机图像,本项目依据已观测的过去 XY 对该图像做 +确定性的微小平移和曝光变化,构造短时视觉 history proxy。它不是实际多帧视频, +只能验证 temporal memory 的接口。 + +`ambiguous_history_pair` 刻意构造两个“当前状态相同、历史路径不同”的序列。 +完整模型应比 `no_history` 消融更能区分它们。这里的 MEM 仅表示本仓库的 temporal +memory encoder;在官方资料缺失时,不推断该缩写的官方全称。 + +### 2. Goal generator:只用 endpoint 监督 + +训练目标是 future trajectory 的最后一个 XY endpoint,经高斯渲染得到 heatmap。 +API 只接受 endpoint,不接受整条 future trajectory,从而避免把低层动作答案作为 +视觉 subgoal 条件泄漏给 action expert。推理和最终评估必须使用**模型预测的 goal**; +真值 heatmap 只可用于训练监督和 oracle 诊断。 + +### 3. High-level 与 steerable metadata + +最小数据由一条 expert demonstration 确定性构造五种 policy mode: + +| Mode | speed | quality | mistake | control | +|---|---|---|---|---| +| expert | normal | expert | none | human | +| fast | fast | good | none | autonomous | +| slow | slow | good | none | autonomous | +| understeer | normal | poor | understeer | autonomous | +| stop | slow | poor | stop | autonomous | + +训练时 `speed`、`quality`、`mistake`、`control` 独立做 metadata dropout,任务文本 +始终保留。这既检验属性是否能单独控制行为,也提供 CFG 所需的 omitted condition。 +这些合成标签不是人类偏好数据,也不能证明真实世界可控性。 + +### 4. RTC:控制延迟下的 action chunk 对齐 + +若推理耗时等价于已经执行 `d` 个 action,模型不应再次预测旧 chunk 的开头: + +```text +original target: a0 a1 ... a15 +delay d=4: a4 a5 ... a15 PAD PAD PAD PAD +valid mask: 1 1 ... 1 0 0 0 0 +``` + +默认随机采样 `d=0..12`,返回已执行 prefix,并对 padding tail 屏蔽损失。RTC 在 +这里表示训练/评估时的离线对齐机制,不包含实际控制器、异步推理线程或硬实时保证。 + +### 5. CFG + +推理时在相同 observation、goal、memory 和初始噪声下计算 metadata conditional +与 metadata-omitted 预测: + +```text +v_cfg = v_uncond + beta * (v_cond - v_uncond) +``` + +默认 `beta=2.0`。必须同时报告 `beta=0,1,2`,因为高 guidance 可能放大过拟合、 +产生分布外动作,不能只展示最好看的曲线。 + +## 数据与防泄漏约束 + +- history proxy 和 ambiguous pair 只依赖当前图像与过去状态; +- subgoal condition 只使用 endpoint target,不能编码完整 action chunk; +- flow expert 训练时不能读取 teacher-forced 真值动作 token; +- 所有模型对比使用同一 initial noise、归一化统计和 RTC delay; +- 数据增强的随机数由 sample index 和 seed 决定,保证复现实验; +- 仓库不包含或重新分发原始 PAI 数据。 + +## 安装 + +```bash +cd pi07-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`。 + +## 测试 + +```bash +pytest -q +``` + +测试至少应覆盖:history/goal 无 future 泄漏、ambiguous history、metadata 独立 +dropout、五类策略构造、action normalization、flow matching、RTC shift/mask、 +MEM/goal/high-level/policy 前向形状、CFG 公式和 checkpoint reload。 + +## 三阶段过拟合训练 + +```bash +pi07mini-train --config configs/overfit.yaml +``` + +或: + +```bash +python -m pi07mini.train --config configs/overfit.yaml +``` + +| 阶段 | 默认步数 | 更新内容 | 目的 | +|---|---:|---|---| +| goal | 300 | MEM + goal generator | 先证明 history-conditioned endpoint heatmap 可被记忆 | +| high-level | 200 | MEM + high-level policy | 记住五类 metadata/goal 高层条件 | +| policy | 900 | 完整 `Pi07Mini` | 联合训练 metadata dropout、flow action 与 RTC masked target | + +默认预算面向极小样本过拟合,不代表官方训练 schedule。训练完成后应保存实际 +训练会生成 loss history、checkpoint、evaluation JSON 和消融效果图。 + +## 当前最小实验结果 + +固定相同 flow 初始噪声时,高层策略预测 `steer_left`。轨迹 MSE 如下: + +| 条件 | MSE | +|---|---:| +| Full context + oracle endpoint goal | 0.470 | +| Full context + generated goal | 0.491 | +| No metadata | 2.458 | +| No goal | 4.043 | +| No history | 2.159 | +| Metadata CFG beta=2 | 10.576 | + +完整上下文明显优于移除 metadata、goal 或 history 的消融。TinyGoalGenerator 的像素级结果仍较粗糙;生成 goal 在当前 policy 上可提供有效条件,但不能视为真实未来图像生成能力。较强 CFG 在此极小数据实验中造成退化。 + +## 推理、画图与必要消融 + +```bash +pi07mini-infer --checkpoint outputs/overfit/checkpoint.pt +``` + +或: + +```bash +python -m pi07mini.infer --checkpoint outputs/overfit/checkpoint.pt +``` + +最终展示不能只有 full model。相同样本、noise seed 和 RTC delay 下至少比较: + +| 实验 | 移除内容 | 要回答的问题 | +|---|---|---| +| `full` | 无 | 完整链路能否过拟合并重建对应 mode 的轨迹? | +| `no_metadata` | speed/quality/mistake/control | policy mode 差异是否真的来自 steerable condition? | +| `no_goal` | predicted goal | endpoint condition 是否改善几何方向和终点? | +| `no_history` | MEM history,只保留当前 observation | temporal history 是否解决相同当前状态的歧义? | + +还必须提供: + +- predicted goal heatmap 与 ground-truth endpoint heatmap; +- expert/fast/slow/understeer/stop 的真值与预测轨迹; +- CFG `beta=0,1,2`; +- RTC delay `0,4,8,12` 的 masked trajectory error; +- 每个 ablation 的轨迹 MSE,以及 goal endpoint error; +- full/no-history 在 ambiguous history pair 上的差异指标。 + +小样本过拟合中,某个消融偶然更好并不证明模块无效;应展示全部固定设置结果, +不调换 noise seed 或只挑选最佳样本。 + +## 安全限制 + +- 严禁连接车辆、机械臂、移动机器人或任何会造成物理影响的执行器。 +- 合成 fast/understeer/stop 轨迹只用于离线可控性探针,不是安全驾驶示范。 +- endpoint 命中、轨迹 MSE 下降、loss 收敛和 CFG 改善都不代表闭环稳定性。 +- history proxy 不是视频;RTC target shift 也不是硬实时系统验证。 +- metadata 可控性可能放大危险属性,尤其是 `fast`、`poor` 或高 CFG beta。 +- 真实部署至少需要真实时序传感器、闭环 rollout、OOD 与延迟压力测试、动力学 + 约束、碰撞检查、冗余控制器、故障降级、急停和人工监督。 +- PyTorch checkpoint 使用 pickle 机制,只加载可信来源的文件。 + +## 能得出的结论 + +成功实验最多能说明:本仓库定义的 memory、endpoint goal、high-level metadata、 +conditional flow、CFG 与 RTC 对齐可以在几个合成 PAI trajectory mode 上形成一条 +可训练、可消融的最小链路。它不能证明与未公开 π0.7 的结构或数值等价,不能 +证明 emergent capability,也不能支持任何真实世界安全或泛化主张。 + +## 可核验背景资料 + +- [Physical Intelligence](https://www.pi.website/) +- [Physical-Intelligence/openpi](https://github.com/Physical-Intelligence/openpi) + +在原始 π0.7 论文或官方源码可核验前,本 README 不提供推测性的论文链接、arXiv +编号或官方结果表。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..9689d12 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,31 @@ +# Third-party notices + +This repository is an independent educational implementation. It does not +contain code, weights, datasets, or other assets copied from Physical +Intelligence. + +At the time this project was prepared, no original pi0.7 paper, official +pi0.7 training implementation, checkpoint, or complete technical model card +was available in the public Physical Intelligence/openpi materials inspected +by the authors. Consequently, names such as `TinyMEMEncoder`, +`TinyGoalGenerator`, `HighLevelPolicy`, and `Pi07Mini` describe this +repository's own teaching implementation. They must not be interpreted as +official class names or proof of the unpublished system's internals. + +The project uses the earlier public pi-family literature and public openpi +repository only as background for vision-language-action policies, action +chunking, and flow matching. No source file from openpi is vendored here. + +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 and terms of any +external images, trajectories, datasets, or checkpoints they supply. The +default configuration points to a local manifest and this repository does not +redistribute the source PAI data. diff --git a/configs/overfit.yaml b/configs/overfit.yaml new file mode 100644 index 0000000..5203fac --- /dev/null +++ b/configs/overfit.yaml @@ -0,0 +1,98 @@ +seed: 7 +device: auto +output_dir: outputs/overfit + +data: + source_manifest: ../../alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl + max_samples: 1 + augmentation_factor: 1 + image_size: 64 + history_frames: 6 + action_horizon: 16 + policy_variants: + - expert + - fast + - slow + - understeer + - stop + +model: + patch_size: 16 + d_model: 96 + mem_d_model: 64 + goal_d_model: 64 + high_level_d_model: 64 + n_heads: 4 + n_layers: 3 + mlp_ratio: 3 + dropout: 0.0 + max_text_tokens: 96 + action_dim: 2 + action_horizon: 16 + goal_heatmap_size: 32 + +memory: + enabled: true + history_frames: 6 + ambiguous_pair_weight: 0.25 + +goal: + enabled: true + heatmap_size: 32 + heatmap_sigma: 1.5 + xy_extent: [-8.0, 64.0, -16.0, 16.0] + endpoint_only_target: true + +high_level: + enabled: true + predict_goal: true + predict_metadata: true + metadata_fields: + - speed + - quality + - mistake + - control + +steering: + metadata_dropout: 0.25 + preserve_task_prompt: true + cfg_training_dropout: 0.25 + cfg_beta: 2.0 + +rtc: + enabled: true + max_delay_steps: 12 + include_executed_prefix: true + masked_loss: true + +flow: + timestep_beta_alpha: 1.5 + timestep_beta_beta: 1.0 + integration_steps: 10 + +train: + goal_steps: 300 + high_level_steps: 200 + policy_steps: 900 + batch_size: 5 + learning_rate: 0.001 + weight_decay: 0.0 + grad_clip_norm: 1.0 + goal_loss_weight: 1.0 + high_level_loss_weight: 1.0 + flow_loss_weight: 2.0 + rtc_loss_weight: 1.0 + log_every: 20 + +evaluation: + fixed_noise_seed: 1 + cfg_betas: [0.0, 1.0, 2.0] + ablations: + - full + - no_metadata + - no_goal + - no_history + compare_rtc_delays: [0, 4, 8, 12] + save_trajectory_plot: true + save_goal_plot: true + save_ablation_plot: true diff --git a/outputs/overfit/context_ablation.png b/outputs/overfit/context_ablation.png new file mode 100644 index 0000000000000000000000000000000000000000..8d01cf110dd527d07d0bd2130d3c2a0f4ed86c99 GIT binary patch literal 93967 zcmdqJXH=8v*Efm<3y7$Qj8qk+3!{KY2T__70Ribsuc7y*SOAqGh=nR3(i7<=1QZLs z_YzPk0RjmgDWUdB(@a!_&bP zAu4?7lJMU`XY9Sa+&ra3L|p#!8^W$0b|MK4S+4LYhuu_-JZWfHFHry46FyV%gJus6 zjoNj^+kVOO*uym%Ym|nij;Udi^U#g{TlOmS7cQJkIA&bRa^rp6DYc}z*DuwLOJmgq zvu^14@YAUkyAF|pcVkE-lJrX2TWL>XD;764u(I@KdQ23TyqPNcjIg~eo8}tUfm=BZ zU;q0RanrxJ8ou?<>&)7rt$+U`!fHSH-=BZI6c+OD`(J-4%K!V`{rNktf8O7}s@%!= z&wB-S?wN!Cygw6te0ASH?~gV7|KZ~&|0*=8C=a8hH_kUGEUxCSG9EyhVexLuO}usv z(yKd;4f=*=37KPtLxr+5G-vR?&;R;I{}tQ7ji2EQy?WYZ9#hRmL&Y|Q4m}^P_$}TY zDRt0G>=;S$9h(hHyn|qr+c<+_iG8jVest*b!-H4`BVKgPQFFH5)MPiAp|bz_&__#H z-|-p#oi@Mt2l{N_nDc^8LuJwcBU1NE- zbK~+YP)+f?+tRYOt#9rvy)!DYMPgo?dZ-ylTrer4Rrp&j4G#R`@0)dPXQ_e9XUOJt z)m=*EM0Cj|tM7wP7}%UAzDKS1o46Xiy=>z=QtsljIrVy^{xQ8?@UD5#(v6Y2(9mKD zS?warZ=+j}=~>G2QMFQrb>=EDoNK-T?9x81zZ3(a_}U#@n)viSXt<}P5bG#%`AK`N z2XXNvNgA3%32&?q)6ggg4`gd|jg;8MOm(D<*OJ#woJK2)C`FvI0fYS6$(Gq^A=L|5 z^gsZK;3B`XsFat=d+AaaHg4tfL9Rgp7LD!5yJS}5w{%;UCDvrdlYY{xrehC{0-rK< zP43@Vs2DdL;+!2SvAfksbm+}_VHQeWBjC6~ce)bRoA^+5yVVpNn`_OB0cts*iQP|K z`i;ludUKgpzLr<#@_4+9k=tsdcvc{==j6u42TYlKl2%KDHa(KAxFl{HdG1XPULTq@ zNX)UQ3-K9t$ToUR$6R3D6lZ`kmo=;)PrbJAx$vxhX*1}`z54y=Urz<+A2DgU8M53i zxYK1^YJcy`6NVBFX`kysS}wR0Pt<7FfJLawYx98IB;R+pb9D*ok>Q*HjXn#*3D(=p zf(Df?(mwMu7X5kp`R7r%9G%R1ISO1@gfG_nHNLXzYjZ&D0S4B}d{ zo}Mhq)yr0vDPNjsFiF4mc(l4=)ZG=?a*_=+&Nw}rnBBv#{ zX+0ok)^$!GUEIF&3@L=)thzMN8C&hWyJk{>Sa>x{KHj!lj=;|G3>q9vZON?^JrCwW-#ii=`u_Hqfb6(vQHb3hn)|mGp*&8~z_+c;hPqf{ z#9fBP?B$^z__VA{x8bSF{wq$}YbI7Cbaw}VH7GQ;X}rY1ezkMF-7YQoO53)y-{L!B z`3Pd=!uj*(1GN$*TvM$_G?T7uCWPBJikU9R~+Gx3mSwil4c!agqpyk>QdjgnUp&(=_5^t3iWD( z{KvP}@bLvJxU|r%r_6%W{SKY)9=H<6FuM)a-uPFRfj3_ZFFZ>y|6FB85mf$|1Ee-THRI{8{IMAsa;oY(t zqUjJCy0fHGJkF6$%)Z4ayL#&@>6IL#JY}Z@rI)Qm@RfCS#Co(`l`|_%6d?HbZhUSNwO0r;%&P?*DhG>qJgRa%$h|xjPSRio| zet!zSEZcRqJF6CICHmuBaf|Rx4@6mQg-Z;lY>+3eeQjZ|=nJVso>Hk5@$1{OZ6#0H zSEilg`Gz7DWBV9oe$;czQsOS$43ZdidlC8-(L}T=>A&hxx0zubwIVd2z`&?BD%Ul?Fq67C6~FjC5jW+g-yy^No3OaHldCZ zLK?fxt71G{?Dq4T#fx({Mz>&ntunBRHCEVnogInk1dRmg-Fwt{C##J6!^jdmMt9bSqnc3u-GDBQ%JSP&1P5mQ z8H>nKbef^L|2K+pFUn$E!u{9v3dUfAx}D{l1Ccr8Qs<$|Z4XGW*A|?{YcZ~^Z!XnB z6-H0=;j}q4x13X;Z0?rNc&5B)C|n#26kgC9@oyk+;?lNR#T{Q>j4>f{1=r;VJ7y2( zlR6J_1^u814_q|Owh$xcmzIi)r**s>X4){*$<@6u4(0nNl$aD(=u)-R%08Z3Xacue zbY3=o_`fZoOh3g?`&;u4BjtsGHMVVu_qa&Pvw~h4k;=7!nUtOp*uT?^D#+TcIWeO4 z&-e;?+FiO=8RE`^e^bh5wz-w~utJ z&i2H+RP2>;HKTR=8EwQNd(pVm4WDCmp>#s!2_qL_yd#)|#aV+;jRP;$l3w#-o`y$u-0wl_4R!e*CX1b3Svq*$2$T zk871G)*-fzgrhxF4A1(xbTgk#hyE}{9PQpcIrBtUC3cwYucvk|GgV@$kLh=0dZy|$ zU;;@9i^vlfcE;1`0>7lNq4o)(IVx(CypE)MiAVbOMMi5k91}=9zUtuOBs9+4RA$$a zDoc2JLe?Bq;brqAwAZZVTSf8W8)ZdMx0CL5tibCbR|tyP>+=_-pwP>~sc9s&-E;)iXyYqHz(CA%GF0iA&+;*CnVAF9WCeznCPw;!!^ z->o{I8ZgIi_)PX?tx{^}u7ArBC~@v<9XD_?-7;*m?xWJagjKGJTJ3Alp@l{`NuC|D zxLU?*3H*BB+eFVv)sn_T51#(x!o{a(uVm-QUJ;b7~Mt8n(gE ze`t6nXlZB`d-|eNA3b_>vP0vC)Ps=^eIGQF=37MSyyU3`&$!(A?FVw9IcE5kkkPLY z!e~_q{jZFyNJmVQxcFgL503qbo!#-HlVuN>=ALx#&*yZ#+|!$?$@6^^f1hN=z%^6}k7eydwDiu|*Y#goGY-pP6g1N-2QDRy^}hXC_45f^oY_F2 z>}z4OIjNi7o?H*aB0b)>hxMkm08IR_VSa=px5~q%{|2R2z};m%p13F_J3my?pm_zG zu5?fu3bI)vL33}R2^7^GJkp|0?n{icu3gHE^i8HZ+w6&c-%qgZxt83$r*kJnvWTcw zdnaT6ajPu}_3_OYGos#~X<0US6QWmx<6ixHm41^I`NuNmu^89!+LIm6E25S8l|5+{ zO2u?dxrZ_L#S*o`CxxIeQcA*R?-blAI>&cRTkFR}D243%b?xLEl@nJl1Ej>hGKQ`e zPQk%CmHoDc`CDDl^Sw_npm@Y89&~__Sad}o3h-L1_m!6y zP5NnHR$ER$>(#0_)+QY)rA!>XeqzJ#$L7tL;QiDdo0UB|7XE=e+j@Z3xlkhDlGeQz+mREKVem=u+khP-}KKwW)ww@f4^`?buhg zO$>DV9ObB+(Byj7gz|)*^C{F2s`1@5@~GY?j|4BpC4c(ClO?#PSLfH`F>|AMxB7(8 zLqfbib^d;73`ld45=6w1sy#TWnfZ0UVsGz$L-RjMylnH-#PGA}c{&OGkaHg=TMzRl z-+ilgFH2cJ&42ps?#fORZ`$DfJ^Kpa%MmBb)cIUSD=%qwOr*$Td}};e2s^W*DS?El z-G#j!76J3zw+e{Zm*+`xM}kD21abwWJ+ntfM^4cty4?=Tav3QXe$=;#rtEHG0~HMC zW=8XR)FiyG+hk7&o7YsrAJJKDaII{VO?m{%PT_WpLGIWk`Ep6u;jSFb*IDFhCeQ|E047- zk@KgmF>Uz>nFe3r+y)zj>d`YY3j_tUL@xJJym0rvzqo;(G5>i{$PaeGVrTSe3jGK!%tc- z6pE53M*T*MN+tV=sQFb^+FGryngb8;*1aSj+aPLf_`_+Ic$ja;%KH*VBkWYx#xaNe zv}MzN+4BrN@~wG$E#*VWQd3E28Et}rMkz%QEnvnXE3twbgo)s{6rgloLRK= z;x@moN*2lJ=+0Qn)AA1q10mkZ*}YZxU{t;y8t{bsb_x`|-()mwe{?&i1j6)d$qE&u zg%Wym^{SI^&|c2l6iyJ(_sF`}Ex4*Pbo#V>NKM_tbHjJPqw^HFh2!bPV<5$KLSrsRn(c<;w-{CY<4)*U*XFjcwSEEtcm z{N&`R{@LExVOiVT(Qgl0;0Z+F)QEL2RjGs}9UhMWVqD%1-d>W5Ut{7XsLzwji( zfHHARtzf$}QN|ZVbP9G=$>>@&A1W4YzsmjXgsjmL^(th%CR0RMft+*rE2_W0YoJJg z;;;~6Kyw@+hEzW5_^5&<+OI6UM?LFq&0$vYW}Wz*53fVWTbo~m`;@eVW9hs>3_u4) z`2+)dFe(WmOHFd>bpC0VPV&*JESG!bl2|+rsD4LJJHIbr9 zDSPsyWj!WUE80Q*m`9=s>P2l1404(2_dDqYzi8H0Kerjh(ay` z+9GXbn}F^h7fj%RRK6;uUYb=Y{|h6)Ur)NXpT=m7)k(5Cq%rGm{RSMr1*ahyNp)mdOM$5q3O;t4CZ}n!7b9S^#S7;yg z7B+ROMvlPM1)u$j+SM-n zG_VBHQH8cVxfc_@Cd0a?@F;WeDJfrq^Jz`R0y)9uZDJj|qZRfYwua6zX6#`=jIc$m z%m{~XiEZ0xATf+y*o}L;jH}EZ0-F@bY&y*GlhI^R^t4ws9SlW`!d`P?#lEp(`Z}Zv#&~wZ2p_7SJL~z%EZV zO_+SVs~EQ?M_$#l=*qb9uwPSEb&+EFUiJ7m5W8e&69N6a$^Nz^3D>6!=B4&8LdSug zuiHEVkh@jwv%u=5F(!Fv2Ll*Kt=tR~P=?aURBl4N9+mW`kV$0xW9ji!@-}IEfbFui z>Yz83d#~0+Mn=}|rKPJJ0hGmFN&rzV*mum|uG0yJ;Yr6SR=^$nUbhf&oO`EbdkxQ0 zesOz!VFjo`XJIC(r910pd_jrNg0V%eZdO=ogA;0uyC?_h|5H>NqGCJ*O&lz;azL~I znEfJWSGmGM2#)96&VgbZSOE7~CnXaAqr}kqb#z@I5hF3wfoey!d(QYiBANGW-)nx! zUmMLKT|LewX8+@6p$ez}`KBtmAnp7@XgO6B-;|Z-Qiv|x*{lgDmhszW9Z6=<+n_M4 zg`&bkcxU=6T*q(Mp}I2*i-P3z9Al1f$;mX~gjTmRK$NIWk@kIpm6QDsthc&O)xY$` zEN^iai8tTLY|p=)`%A?ASWfWvI^t`dV@du_gfcHL4^MRyAF*=cDCvzd!WO6Ir{r+H zhDR}3F1UJart1r+Q`6BmafrC%!)eS zDel9M*VPF+yiCQNKT)QWX{CQz&E|@jqXD;tcTdNI+qNo(W_~Cekjb$Ax2g_d@oTrS zOTY&^0TNH6TndNh%!im0Ji0xfWRhOYt>N<+qHgsyVmmJjC%P(f4|``l;=$i$2tZ+q zT3?@Gl`R}l=*Y|$su@s3Wj2Se&JXp3Y|ZjY#3)qe3s|~5YhQwE>Hy5Cyxe8PcyoCQ ztIx(7h03?`l0$prr;$E~k~7a@NG~{Mcjjb5K@s)oy1}wpr{WbPRp&X~T0}2W(`iwh zb3O7zZU_<<(Kd>wJJ7tN@Unb99CitI9lTXa>h_yeXz)n7@wqKeHOwA667Qssb5R>vJ%t_srIh*c@;@QLUtv`7|7KcH0nRfk={f?CU^Y*HXk;kXfW zKAAsOL?aD7i?Ej`Q)B4v3YV`wXvH+K$N$(1XHp!_`{4Tv&D8j!o|wZH=K$1|YzMz# zeaP{*8KEFhYTs3F9=xW%JrN`CjB8Ip48bOv2b}NiY0fvv(neBf1dM$D`PYcw#)oPf zm)=?Ne*NvyB&RQ#ktp%w?qVzS@QF0JH~!cjxr-|u@*}W8?L=)`KYHW)hd>j-p3h{( zppKI<0c*vu;oT8%@ne7fbCewRVs*-ERBz;M)PJ*4zPm9|KbQLYe{8i}u=U;-TeUph z<|LQa@$}Ti=G=d5nEOXc?K(Jbs;Yj3)oVN1b8HoNg>KWkr4`47dAnzEh-Slg-w#SY z&F(bK<%6Ly`-0?RWvvZPwM6WierIc{a~eQu_}4;3sM=nhYO!lx*|QYZap05Pz8~X9 z?^95FHvhHX`x84S=UBjRA&sC1NB4TW$8UXcuCr0G6H?j(tTs` zo^R-nPkXf3E#r8#Ie|5Kk%k!lwWWSNsULzv9RisU+?j}#ccH-KjIbxa=7K+A0=u^G z?&q*qX4ZAce=g3M=Rny8L90}1j1lXKaK_&6)m6!?eYTqN(rhuBYWqP?D#xvCIs=d( z*d?^_!3JZ~Oyam{edLP&{nB^^RrR1X3?5T;sJ+5wJPr^OJ7Oa{m5S8tc#p@q`nd zdmGg^_ew<;w>GKtqC-~LXew`u#jM zAcrTx=<^PZ#I9x6*Ulbzhv4@y&}mJ8L@$8#)zs-yoKMvNt&A$&?ionXx<~)JE2;v{ zQ`TJDOH#HORmy7nd$xv;(iGGG=i>)-t5WTiLSp*4t~lgI5&ZLP%Ax&_Fv{=TWs^XZ zf&@G4k4v!w^MJwF4iuYm=*4)DMf5=)oGGIAZV;Iy&_BZH=U?h+70Cv!4+01)09?>t z?d`M;+NsO;=cixA#{O701Kwuc(%zaPJ+Mx|Isv~ffqiWRQfgTLVJxNCrez*1sbVv< zPgSwBWBzXn*ecqwQGo_pUEnp_t(NKuD^SO!#AW145=j1Js2U(`@9{0jPEA~0Q%43< zy_!%=f}r6r=t8dHM>&e2Dq$1F94vdXwSBplzm)0YVR^SLh-jzEASfV9`gBTBwJm?*mj!YLIdLaNzoo9fk7O%4E@=eFp|V-un_ZTzv1#0rYgDlj$Nyj>g-| zSA6F0H~~5hHTtp3d{=(0R}WD&EEclkhu&Fk;r2Nv24+&zvbNmj`EM==%$h+U&>L(Z zPv={^REM;Xtz005&E!-Oi(2cE>?)c`K;acs!z`Lz^4Iw5uno9h)oV&4d5gQiaVwx` z)5=ne>{4vFf>&SY=iK^CMtW`l+2xUpmhzq(1R(c?llvEOJI9Y_s_6N@+h!EMJSSvQ z=NYOoGC%0cr+>E2u%vE*zNgUoC*{dZSI(V`SWIZo+uQ)T$2)YR%c*m) z4OQ+dw});oR3P{L{K};5rC7igfO_kt*Qq4+oh4WDN+yKfI zJXjw?`cd}=j&-m`s@I?MC-2IfeafiM zV2RB4gEmu0Er>`fg%Q8_rB{N6!p*+4i0$(3}~Jpwy;(O72$}op&66EkEmb>B@La|3RGhzVgRF@hS1SAgIV{!x=9Y0cfff zQ>dzeT12Xj*Nx8!hUO08jNxg4ii&m3g!jF2kE>R29lqQ0;G(fbnkbW?(3QngVD z?QLKYn%af?nt#P7cW*9*g`C0Ms{W@*Mm)@~*Ed(LP(69~-~n>tUloBQg3ov`sVdh0 z$EOF!D;O@8F;$u>QULXsbrQ>NTnLVHQsGM&YC6KIh6>n9yl)Oo&ngxoLvPg@$*BX8fyRi?C)De|8k3)D}l zKd({_%}dR`ewf0y9Iq`W`70n%<4Py&CQE7#B7Yop;tC)xkF3CYBTRsIam&qDLZ^FA z^;I(#t`H9!TAAB;T>e6}a|qz1I!T;$KhrlGbKZ2DYLH4D>IZScC#iEv;BDcWcdfnw zUmTc0S$^ya1%GfGU>cIHXw$;*!gj$oJv}|~v#zQ8OGE-IJ~n6VN;^0yOMxFP1hu(5 zloCQsd=%PuWhlnvTR)#`Jg9XPuUVV_VnKd)BU6w&^RP3dG7wNcZ!WVZo-v4@b-Zlc zQR>j+3<(2+Di0eL^FXHVkfm=dgeuOU#n0!rhid#w*vMQL%R1O((>O=`GS>ue=Nv0F z{alCChNJ|o*-=49y9{a!6DIB(?!6P=2u zkb=;^Clo8l|K3%iwr>weor}EPX|@c{JXvGk=LiqNJF` z1kA;klu)-KY}q(QfV(aXXMHqFIY&1pu887UjLKrVRvKz{xC_y0Q94L6=Tt zTdIRDOB&_m2n05;P*BqDL1JLL;&tI=Y1AAU|6$<9hZcQ)Mu0LR z_@NvNEfsNcmujC$$;r$)cj+HR)o$gnZ4$efj|YU5DH+PmoXUZ`5%Oz{3ddgK4@d8- za0U0uThJ)VTmC~~XzrWl+YV)1nI6_#*pk5AU|tuTFXfGLmeb4Xz0*{4 zUssW?@JnC(fMzUg?ku}&r;Wbg=p0{#HL*Bgwb=jcP8eQ$EU=!q3Fc)-K z=Dex}irQhfX@eevu_}&~D8Y4)f-^7G^88w%lon1%4T7BWUSVnw} zD7LCy6yMZGs&H?LN>6sE0+ufW8LILHo+=iMDtNMfy{!1Lek)!}ir;?dy;}TePRL_Y zb@866mIr^;##|PRv7ss})R%{B)I1k<<+g+(5pcWn(w12{?#FRK<1<}zhC+xG?ff_g z@y!<)Q*b8{9u`rP1AR2rx(bFzR3X$+Qtf|V>E9IIPE7qyNFm-$?#BSgD24?4t<_n@ ztmxazxI`!9PmQxCXa3FTdF0xI5aW}VhVrKe3C0dUvzPd)NcGG)unNo#Bp4R|6X)~D zJ#j&OP_#oPzOSuuyt=t#RjQd)*}wC-!SYS7wDe`Ao1nv zWIloB`@NJ5Rl)_DhXG)C&^w%;l>b9{XpT`4hO$$CK@lK=(RN83JCw*+Y9NM+p}psN zi=g?KK(@yPs2Kl&9RQE-M+{Q-MX^bgLoRRVv|aK&K-$_)OhDG*b*!n>uGOF;dUwa( z%q9M@&-~y$K|^FIXw}({fegBR>--m696Y{U`hY9qlJxlMQsPS-`xwIo&NoAV9EAI- zOWmO)9UmWHWiMTPQ;;P}yJX0$V@Vk@WLk3B4nTwoniNg{7rP%($^ zsQd300<{xM1X1A-8q zD$oDA2RS3pK9#*x=qB7DWKi&i3WPyRa0HTE3bN9N>R`~M%0a|a4zdsw>9cx)fB=bV zv8K?%`~WIV+;wb}p)uNUKiT2E#1G(3#IW6c2ai^!W`q0G2MI67w4mk41Yxsbh-O)t zReQO|gtLm;K8MTSPL=^qVNw@@A+L|PBIKd#c&8kM@=|fpr2KtZ?w`{;bQ|;7Xg9+) zeEmK9df?f*{=O^2SD5%kb(P=8>Q1FXMr`^(;@;DWnIqvxPFqsLY;1$`Llv%RWdMc8 zf=Ol)x}d(t__pwBCC?lX!aK&_nQeZMgsA2pB9&aegTX1en$NC)z;~dK%SoCmVCJkz zv2|n=r|j@AX2np}GS~63wad8;yrrC>K+fjN2FzD1c9+^D#?KG!7Bz5slHhKaK&LYv ztMSJY=ic`9(F8CN#xNt04cIKb!u;nf9^|Fo^J#0~4=1Oett;;;e>(%s(5H|4?>ZZ8(;;uT%?nnY}OdmBsp-z-pZsa~b zK&8!tak79FS1pQSU-`^Ey-zP(`6uQtB}5HDrnUEHeyT(kF6O%Us5kOl-+OI_ zgaw0b@p_rzf%A!;Z(e!6Jh7m{Jz@euM@S7a22fqcU4aX%IwI57`qzL5 zySdq^tUlYougAyL6gK||D)%*xY?pji-K7$A*2yt@I|jP7xw!lTRmosY1KY=c9Kjde z6s|h|Wh7TI55Phx6btkx1CpVs`;9?0UBH+ghMr~%bzlt^s=hdf6l(zmAJ0jF=-oV6 zcU}p?>aT=m2{&Kz{Zw`hY+y%z-@d_OHaYhZzX^J!Gc~G6hSkSk9t=2Bn6JN_YFip% zfpkuF5Okd;8($!=)C{A7^Ml87mfcW>Lb$)haeT%CI>=8Z2^!a=&xom;{|p7XAI8~J z3?g^6$K->QtNc0+@1VW4B@iMLrp5b;=H9GKHpNf-%=slu_VidS!dajy-SR zvScWQ-sca<;NatbmgSkSfiDNgnyR-j6vl1L-B68BAt#pKBosKV zw>@kkX4^}av=iUsZ)Ucta>p@mqb0B0aZJ&}d%)jd1GT3T^1ctLIk8J=@l^DwxhRmg zp8hTr43Yj1nryzbO2!@c3UGXI*cm-AH3E({K2& zsg8NjU39C-%LSyDfvlU-Yn%mU=yJ-&1OxY$Xslc*h*ZAvc-_~Xoack@d`miVj@?Z6 z*S&ulwgO#$Yc$zsHS}{n%X;`Shv-Pn)hTiT&RSV%~ zr!IQDrt_C|eozx&VeV>kH9*5cAn!D@OYq<1ea9llX66)oS+3jOV&waum};7eshqx+ zAy^j*Zb|5pmxFSLNyo_z%?>7T+>qi;kMS>S(lb8A z@VJmpLq7}$^2|C>Da-Em-YjUXh_AtwIBG~sslsU&K!Fj`3P80$p#$EBPU05%^>0a+UuXao7 zL}wqi6dxXyrsU0~CoaL+9}Upd^*m}NsZvy1OIPhJeqUXH7D;T|b8J@juS%gTTooRP z9$lLC!+2u`Zgj+Vug|^d3Xlz0RV^kIiItX6!`tQ;Ud6E`7Y5^#=gf&QL7V47SL`vp zrbWjD!*-8chG3!ZW`x|yEt+uj?dz1kX+w0vGaG6cGb-f%?A3Z{1Ifx!)!WOv67O%` zk#1P74Yi324?gVQYWN}A=z%5dSJf(0Qn8SY?1dn-bKz?EM+!*7*Knkg&@# z>{iLLyS}!}T0U-8h*KD?g+7 zv==0wud#nq@5o%fmHxAIfS{_cQZtp_z%mDr!I$GNYH*ppS9WZ4@7!u<-;rVM%3yvG z{UI)n3gq2G`j;6NZsU}H=FGLg zbfM@`ic?17bBWA3>%g^*FC1u2y$v=(@=P+n(9xh)dN=A?Lg_vZn{pcw@H?env&cE% zIqU0+e4qfB%WY4&a%SC1yELNvUQ7r;Y_znwtHsy~p8P_01Y@;&peF#2-|^eydR3r; zsl}fk0(+sQeltF-o%x0?_yU0JKIe1j0@A%#XFvnb0kpYs>$K) zdlsQ}R0n!%oF~att6u{ecw&7CQvO)1SY5qUjO?q)B^W~(_C7YF{@jh@Z}09-EfGU~ z^Z3zAIVGP18P;~ykd`-XpHH(mu4ap_?XD59Bjg=OWmmD@dtraYT@ArA_bK33mxkpZ zG<*Lq{z($g&$VkIv|HwFAs2hBj8*WqqRaxf-NajzTI8VW#pv|i)TZxy`uZ>v&qltk zpBR6EdLv)NdOh)_-<0P`gM$YK$2=c7!nB0^kE0SzE*=SDH7=;?IRQ$|!So8~+zqBm zW753f#e3C;0|{;B8qHp3{~i!+Ii!C9@Ym=y+*=lFLGVqEp0!j4L=@f_4b zaw|&O&9o(XUfY+jhBj2@ed)XQSe^TEygb7~Z{M*8xHGALXUKER#=QWz))agO@RuWm zG1Pp5U9ZubS-y`Htv3_?r5Tbgku&H1=X}pFT?>g}Sr5d|$US{Qeyb)v4%t-~Dn#3c zcuCX_&WX#NpJMyaROHj``5oP%2&Q1f&%R@h?Bw>loUW| zMvTv$*U>-9(5jF7L>1cjP|I?O2hTtTqVyw-Sd6^n*E9Vql$y^O)ka0OtIar_!2j84 zxT%MXa&8@5tbXGG_(J49wTDUG?OO6=uYuD)8G>pT%17cfGzuh@P7ViV61n^*kNeVR z-P(m5hwpX>NCoxfnoOsub~HiUK$^Qs{JT%UbVnKyc}O+4WCA3aoF75S1TF(13fei>2A12;8reyx$tn zMByB-3$1fq`t^7 zgJX~*lCl6^5(KQrqjQUj`eU+7g^cCNtMM&K*U~I1;1X>dWxsl?bQj3kPeGlG>tD%b z2&`072pDHLJQVnCf9f;@Uc{*QD~DYrP97|Xc=m~OrSQ=Q2B#5 zS8&?JbpSFpfoaW9C9%YIDo%Jc;0po+0{dX!K^}hqqE@xknI&gwk*diP;}8Q51AEE& zO`vmQ>{)&};@3zRBmm*=S!FxK->_1^BCU(JrA}E%+e|el5SBnfzi3wXhuMcp-E!># zFMlFn8>TVrl%5=kbd+8@;_&OpntY5H%_MDUp_pDMrp~fE|;5^7_-dW+dnU%QL#K%8@ zCUo*;TBNdXJ|}v*t#>9c!6NrgQ3f@SsmLr*&ym@l;#i)~-Ue!_sY!SvZ1Jm8`Y>I1IU^PZ@DM)kKV~g zi77Umw!2Q6;*_E<$597nUh7Al7X1!#_mtemmaaV*IA|HZOD|qi!xiv)JMke2@l;?` z|4nc+fA$^3F}$QsDhU)zv(S@eZe4{(R;c!Q5B=Vr!$GE%v*;$xM&Qte+`>cZb1i`<<@NH@IkM zQVSzQrx?X5au8LYA01{A8vEB*r&$byXqzP4teX~fB+Yl)x zH2Tjztq5-ZgRWgG$Zxq)6 z0zExs8}q^X_V)q5Y6xOWu^-l-yv3cxFlH?wXO!v2(-d98 z%acmGNp)32>l{P4kh-F-sJFekkLDn?PMsHB2Qk5BrpX8>2J=N@F_jt`aM;SgkypXg zcHlfelfXQ*0|Dr005$qqOxeN7E4J`xytzv?pQ*hUEdoU*KaD;8c|H~L|=3k}BH!gJB~kA7eN z1aDy4PCc6=G?9Ljc~tO|Ho`jeZX?R2^kQ*45OWvc&^7x{tA55v>A`f6Q&M3^!{OL?qEDd@4l%jrTwqWTv znhz3bx2gw-ac0*-9%r*I)Hn?YQ!djkvre%iqj#uk8qF3!k%Jd0U;&%aATRUgVF+CT zbgRtOd1F;7c&aKX8=4~J-CaWgU{LKL_<-qSGln#VZ~nAj_nnMw>tBXwGNcWbL=2la zMLHP@B*#IO$r=6-C-T7MnHQqRn3b=8z!~B(NKxya51NWC`gAJbb7W;Ij4-uGK1;af zDG9Sh4_~3~6xDlYL)*KPcz73p-_SqTM_$jIznLH~#gn108UWY*-7#~q zVdJxTVYUg^A;S8U?IQB{6vz}=SO}&HA3ItK<6|uK`N~zwT26JWe=NeO2l#9s%5Yk9 z;f&)Rj)wyXj-;@y7GZR&mdw(13LoJ(w{m5ikibI5*6bMHx-_vyw8^>#LNC#IE_96+ zfeYdlNaf~0T)9olD}uk+3Dfv`0ltAl>z$rS$5C&G(~Xzu3PCpak{EJ>IE@PoeO&>* zOf~$aeIbjw=KA~E`kKQiBx`99b*jqTg&qgohS69=Qpz@kh2tTTU9}xX$bZ#`cz4eP zdxP_ao`RW{LwWc*2!e>wl09IcZWuhwDWrO*(R3JRKt!^>$KM{OU8UuVJ7}7@T zii1q#uJ}=XVa7ZYkLfhdY|@ufzNQkT(o+tP4*1mfy5qG-THBjTU*xw;&lXqIA`j)j z0A$><*y**z*^OSwE(mL|6z98Sw1cc~ThD_;ALNDS`KQBBXJfszP%>JV(^*Jg?aEnm^ZIt*u}9^^on0QuqEb!nIgDq%g8b@eo;g~=LnhNg)caNha_=IryJlQY zk0R4oPARX(asN1ToDh4|bHhr`;Zvy5TknJO`$K*NyvW%fgJkczy7&K1}?^RMmjLhABg@$;+yZY-zg7~w^VhwBN8wwvz+*{EE~Fif@gXhLxJ z&b|Q7z9~byC@BOr-zIqH?I>vrPhLneX*Z#2y@3ULYU$D|EOJP|)s8u64R_qH_y0Z9?J|hN_Te~)SY9e`NW6(Jwwl!TYuB?vLHFxmc3V|^ZGnXv| zfjXw>YvXQ;K4LC<{wydLft!a3muBZcXVRwpk4Y|f#Y2ndy z!9mKdsB^O71Tv&nqVE2*ai`>Z*fw>#s&Cq`DO1KjE+$3Xs6lGbH!7xi)vpvYu!qSnAN2S@Q$y()(x)x5 zlq-0zz89XZCQGSMO{>5K>PU;klKYvqFFzJ4ARCEzi+sNL%3=4>t;EhdiWHc>inm5Q zp8u~rsjm82fJob1f%BIn{B!4GQ6?4bw66p#GCGZaz7PAMz^Y$(yp#1CQfDBMy+5gS zt)5<_6yW*anhRth{c60`WU$ z;k&Ygv8~?6ysepLKhOeyIwlKt8oYfA1J;kQ%9$fD;xz&DQS6cG&5v{s72eu}$KEh( zziF>iZnyioEBlmR{Hb|WLbT?+@~{AKJDfO$Ep#Ia+|0d6;#<;g`~BXR=hi%b)bD9{ z3+b63>49P0&3lY`K>`+C+#1k!QVb%UqmR3#QYVoD5mU+3Cj%h$6dnYL-KN6<8gt(! zO&>dybwWjo;%N2WyB}piTu;wGeXejqXS-L+?CiUIf%-0gohF_H`=_PRGmdnakQXzu z$G58Ip6>D9J0r`4u$=1+;<7Q|yxE!7a%^?q&^|@^{~mYe#y<=ZnU`PK=*dX6HoQj} zjq((goiw~91J7$PM32(vh^w1%$FRV7lX|78|5U=;;SYYy{4?6FOS_5up9Yxsfoxd> z@|3Ywh2=Lo-wWZ<-kDe#aRvN(vnL$57DBu5T@mwW)XFe&+p zX>Mxra$>HhQrXgki{)@{XAL7Htlgf%PP#FhY6?z}!uqF(kozzfP{%<-v7lX)P;vIy zj@bRwH3NYOuQh@^*pN*6gqsj=F**vl&_Co=!I3}N z!00b)xu{NU26IvCCo*rBVQ~4+>x>fIk$>L*U(FZ%_a|1}Y@Pd~ve3{N*|g*s7A($Y zoBZU7$}|og7om3^PnI;8ew8Fz2A!mKBP%-w;+tPfZE9R}oi7alKzG=uvL7L+(; z84&1DflYx5!?7dZ3)!p|miYTI>q9O)JiK2jlK}d&6DImFMR8mR>7F~jrQj!W%0@91_ zQUocL?lM4X0n)i(kBRcWzyFTsoO7|yvoE$6UMM1Ke&=`2F+Mc}`D(Is7`Gy{%kiLA zowljk{LBUULMZ=cajK8!MvNi!Vk%&PfhzZPhe|R0^qNJ3r8bY2OF>~0E$Gk-@TM7L zJcv%JfNo|GN(>W#bW)=yA+V?biuE=^f2L05S<%YgmP(hctiCXiluI>d`tQ!)65d)3 zpcSxft`vAQSY{19XUPa4uFD)4iT4NzX$- z0aMEXod!dVQw6~8)0=l zGV}*pY7u~EeX88R=QdgMv{;U-Lh~$#xltkRM{TY{R>kxhe14ZSi<+Ui^DZ=4Sab8Rq}Z1taQ#~8pl;Ht^4(9O}qI2;a~`3H}sq&F7N z$!|d<*Mtkag$CUE4aZIl+aC)Y1aO^w-=Ty+o%IQ*Vj_Cw;e9%BIj!x({QKP$-9WY( zxbHm(4g}BQwaK?ZSD`t`1sqSnNXK4t@({gHe`whL^d_AAPwM(8FoT30JbC%O$Iqjx zyElPAviM%?ub2;Qz}Qv&hN6~9Fr6M!$9?qy?4l2L;*8F_KrN=mk3RZa;{%m&|YJ6~80_6Sh0_nYfbcDX4S|y*#WJX?cFz;W-U=Mb~W)D+M z2{Hpqquz3`>N{U$x>+~gS-|^Bbw{k zsa=e*uI0d|slcga6IunA%J@V920B~e59w|$*yGP+A5Z!P#_(ZNCpVfq4%%zORqmo0 zh!p%i&=|Nkl=VB#f0@1_Q<63xU(4C5MawW@)I2AWjTbKOqppXhYHV6Az@lOw$r1np zd4L?S3aO_yW(IDe6e;`2=9f$MUs3GK@~v3Fl8TnS`23VgB)HbFT+gKccnY5H3+RO! zm(u3eGd^$PA;sc<-!5MQ`SI#9zZo!`&3F(*87oio8+bwdRt;e|O||7}kaU@*!&JWc zQH4yUCIBR-kOv#w5B>BDY@D5Sk3)szPpVfsJ8cPZ{@8>_I;-WBgKHg!I__bdY#&vZ zi)m_!#@@3A!K`SaYC8+~80d@qJ#)YEHC}>9z+jqs9e}@iA~fP~5&T@w%iOBzlamqqJ*1Ii;VkB#I!mpDK0Jszd_b3A7|yaEWe+>98UiE4qaHU+V>}P zVKBS$z)gyMcOhXp*;0a*AGA;&X{9srDM@f=HBWSA&@AyT6fGsn9tq7P-Z_{K^U#4w zB-Va|Y?dmFO=-#KS)TN}exNU55qbF5;iR^}?7GnHuhb`e4ypEqhr>a4RXvcFciX5O z>-P%NTxs0F>e8I~8}mI^Ja6(!KE^6D(#@sA9dntsd;eh{{}H%9EhoL=*S6?Ls_hT? z@hyuvw-nzUts2`3HItq~pR|4zUcCXMr4TU(nX0rS^L(Qvb5*4|4Cf^LPd}mMOI@b_ zQeE??w)?j2^{V$%!jC5fl=o&#B>F zu0IV%KdU6wXYs&A++zs&-1``)>jZR|IvwB|E(avrp+cjG|Ct ztWJJEm7a-~-|;T0L&ovazmsLls?0JCDhlOh8-DF+eUhT$tiBWzZ6@Q-$41PraYm-w z0pX`?2RP$!%%}as0Qb0A!&-dIR=*0;uf9(!@0D((s!KDkFgoOOH#I3UO&CsJGO_?^ zxfEZe2M^v}Qq6jgRkd5#R7!d&eK8-1-{hYnWrtWZ-S_P%d+S6~E4Q>$3+BZ(-}~6q zXjtnF@TCQM1kZjF`;cw4$LYE{?lD*I+@(`J;?P@n(@KH#L9ss^gxZe*i74!Q!BKH? z;gvJSmacBH|L&!g$MHkT!j*7Oqzt;)7jW^i3MTL#ZvXXIA8V!W>P7&UMq{Y;b8Dpv zi9VlS^4^>huI3TXddSz9TGnNvjt=hmk^_m0U80F4gr|&^N+4Ga4Qm~)n=ri&GJ7|= zW#;%!-uEt7c52IF^`gKb@`c^!$2y$8>Gp&&*M$Q5Rva-;CL_ptJKj|zK8x$QK-wG2 zue|0;OPhpsR0POgUCj`8Fj-gz5(Cb1>Luza9NTWTFOV#h z@QE-@HvvmvGFVo;)x8j5HzOP%J}}AEiS7nMWBL5smJR9;^eaLgq5D`lsEV{1%Dt2e zF7#8KEvDLey+VhUJYYy7!Zj=s+RJKg*pa3|*)01}K4ZuI?XrBCahX4d5qiZWR9nswewzq;`YR*iI`4Qu84g= z^0Ai%fSRw+(_4f>d`z*1K~K?>_|je9a(Dfn+53JAvuoqaF1_eCE6XeJaGf9q$C`)m zhHmNWR!w2v6Uw0cMPr?KaX+`tuBFVfx#yX1idP`>z*wQ<=MW^P$3H9xNAQy%V*y~1odACRP!M;KSw0yyvS&KGR>}8D!D*u<;L89 z&59-XR zhV10dGVzaTrgz2?iXz6apc*mfEEab_3VOew6H_<(l0*E&^LUJ!-}e?in!z1vw)FBR z!$>jsS#*tAb+}^ePDasa?TWKZY2|s0fumVCqRFg1CSFW zh?)8;hL*Kim3IoVBg7>8Zu%RFGJbe8VAhO}So0g zJ(bKyA&j;FB5O5dlYG3CTgMS0UwfKjNTTK%%YWEIGE{dvVE5^bu5Dzude5UM(3(a2 zCHzAu0=|TBGulJuqa}6)DWgC}@TGNs*tL&9tqpXr1PCsvLcVVw9t0No zlv8e-cqz&s72xIu4}>&^?EHDfClOF1C#7sC$?n*Lh=vuywX9!9+5Yg6?{Jq`O5i_Gv0z}txJp$gT~3V?F4V<#H-IA@sEvs6Nhjx*{9@{zytG)+~(}hD=%^Th5xS zA@bFH9M}lFg{cRSyP*&HT*c-dbBbvP(1VfW%8X7tep_n;Km|<;N+By9LGKDk8=I{0 z-AA7E8AUIHeIa+-$k&dM5Cgp|QeZ5>h1ZAd)Zlz4Q{RFUE9czpux@C=b%l;spZK?j zr!o%9vM!gCx`LYGe7}NQ8+L#*YjrzArtMA5(eb1h>!+(sAZSV@K%YBf4FObrNCx+i z7W%#9HN+)FJI>W}3O<4+43DJCBTiPg-1yjmJ2o(k!iFJb1eW+2&IJA;Ia@htT@Ku#8xi@!=b5z}OdPEDq6L9#r!MPkgS z?gnOzN@lvMuea1$drTt>LaBe%3hEL>gj&6~h9MWCb07dDF`->AL1|Vx0yqtJna?K0 zX%O(6UQoB3%#+Pf&&O})8Uf)RBUK=Oim}`Vgf_?KXBnJ#a4H7Oi(^ZM;ek5r$UlJ5 zj&CWoo|>4*P1|$;z&^V@E)z{u|7#r!KEk>sEk+NVG@(f$OUF zhe~Em<@ag7`{zM9QS@8&C?%zqDz23sS?l|pf$9Y3R^qF^SXmOmm|@8FCLti=n%k_m z8{nLvbgPlJo^+AozLW1nRP%clb1^yR9_Mtx zG1~)v&;BM>l6VnYxDn9_$`6o;7VjIx87Rkpf-?p$-F(5f%bBW|14Mfso2-qL7r3T? zmc^lrY}{^LsF1lq-+w|6%7}VrRH?BF=jUv3~ztlyca2~N<$ ztARw(4C&#qoE94QEx<;n)r>enKD&^`Xl#MjK;dMstah2ijQ8Sj+^mQS~hO; z!ohPY zbHZBdk)6dlc`c0(u34~u-pliMc7ZOk1r4JIfHF~@Bt8^agW$61u;VBhyman6+~uP2 z6R5JAhQhERs}I$h(@TI~6^RHQ)$)iXq~-!5wEG#>CEa;JdC0zQg93TPN`?zkT`RDJ{|4{bp3w zY&SXX#)_maAqm%cJ3I(urCWC$HsMFa$~4s&sprO^P-{cv0*2|^Zk7#|Q*W9h8-7Yc zQRm(;eDiJVO;nTwld@S+a6(=>##&N8yiCed*GW6-M&^dx1;2L8t4vwn$7N}I6t3;M z_5-+UI)c#=37H}RD?)jCxI3TEg=_w(y4O(^5GuW7B;<+` zP%di+SF&bxRhO*^=AD&IpSq4BzX`v;f1HBjp%CIx+CmFkZo=PYY`a%8>z#I|6qHL3 zKO01GP|GVQpTW5+q4+5!rOE5*-2;GFY?@iU;oJL#qZtYAHXywH_Ts#;^E0FdL;kz3 zFP1XW_Hf!|f6bSs1hj><-pKj2uuDa0(ECX}k*_y)vV4Z6Sv)r4?pGG#rgKYtIZ*Q{0b&;w^M{c1AEjzYkB|M5Kba{a{G0WuYTpwixI~dw5@MjkmlU?#xBh^0l4@%15v8Z-deK>n_V>tH*S%{jMFJ%w)2^e2}pW|Um_Ff8U=zizzlXndvI+m^KP72wfeF>$_160JL{+E zGF?1sir)9ULh+w$cy!`vy?)((_b_r~^+ABcLxkCn=;t*7B3v4C<2<>4J}oQx0^so7 z$X&-A-|?&*6x{)AGVw2G?fskA7FZuq`hj{&9cfR@HsHn(=UUBP%IMvU)Es|PE zd|JQSenQ{iw{i6N?Gyi1NNkCO#1yBm--AmbU|sEl0eIlr^eg9?GxMbLZ{MQron$5Q z*htdW9I%LgOj+IlwUt)NYt$+M4y@l3ij!zDsLBL+eZb+<$C!mncy30$qA#7~j- zU3|#IszSPBJB0uQEKkQp;duovXmy`S9 zH&H90_;WBVF*8etX+`he# zdk_ih4vJG&PV8k^XBcZ@2ENK1n1*yn9&Uje^q0Xd%ok_Efj>Vyk;vISgdU?$Gk2lT z%|j%r{ql6wIkP`22_bSW@Yf=6sBRNbU^Y{QgKl7K(}(hWFH~q5axp@vk)Yu=Rc&8y z6FSp!XAH2^d?4D@41kVvcqc${_|gB+FQQDdHoa5?ObYK#iCd-!>|726+3hk^?NZhi zq#5CXf%i`N#R|VTc?C*3z1GxYnwAamN7u8H3K#NKj%6TQ4gi4 z%W<^e)$*%9YXcBpR#HM>ouV{rMyYPcB-STc-9Cr(G?7-5Lm1S5a?WE&qpHk$*l$A4 ze9xuB2M2FRW`OvoM1IYl0;P749lv<;$-w86UpVZpj;IB)qA@Sv04|Cd{SsNLwv$qJ zR55konONc02kUGSZT*q0kfTZRTPAVDZ>eao=?KOHASQiR~_?ljHHIOSAD zj?~32LUDB`ySAR8rgP|sI7;M05=->5bgZGn1)v-`l+4_C2?U%5K&NGvgGv}}&B8mU zzM;$pJLFoKf*V*RNtPB#w;T8BW;?B0M*>)jf^?Sv`B#PJ#dKk;<$jkF96NZA6WaUM zU$J^Sg8SzF@)nfB-SDm=J%=>_FRGAvjt3S~E@%26Pyap88yAj7JFr8ex5hLkM+&E6 z4!RDuwgn}bX)aK@t%f822W-fxEmT5T14pO7-E+|R)RM-ppojGaUU8?ih2naT(u3nO6o>9!fBp9dnPTcp+pJ*G{txH-svv2-@Ob z|Jfw_U_X%kQHrzUDGqG6p$*VSn8Q<%?nqHDvwhO9wf{1}g=&fg8P-8dQRfn8vRz{ z9A|MoY(%)cjjadmMpwfj6a(dCWWa18(a8d!Yhbp?aDhBav=N#|fmuB}r8w^(PP5Pj z(@AQ0MU?e##&1Eo$F!J(GTjlZtdU60UZ%+g_xG_nBXya@zo;F?;=D|uMVPijuua=j z`|rfJ7pkQ{I2Tc4{;YMKDvJl9=iD$wy5AOrQ1^gx$0FrjWOb6^rE&Ecjta|WS+IvN(mm#UFz(ZecF zdGhY0z}WH0S(4~V;P|@z$`D(Epwj7+R*SAxOMs$yX56R*^~|y@+^4|@8cOYLrAHy0wtM&ExW1ytH>6V4bt)%C!? zsiH_T{<6xs{t$rBW*Z0zP#>oYeFmB1Z@xtF^B66ZiCK8m0gNDhl{c;GG0DKw`)28H z%_NNcoMom{?{2{faG*D)fcDCRXTqi`wXPU1p~vgc)Fw=ZdU;r}m9PH(&16I3`S6~W0 z;GL_dGR3EomR8!$FLlkQZunL`^cZA~i9bXn<4Z%mPzeR-w9wmXB@!;e;aQmah-(h*yH6AYLVbJ zdoZm%k7e7#c`BVbDzv$G&fdTMbWCqX>{iJn&a%>>&!=H;O0dZKym-mv1lU^TlU(_j z6>*%nwX<{$3pj%3$~f#0r^2Ug)i(i(lvce(ww1w==jZHm|J5*9H*J=}_gsNmz@^63CN@JAP618}3+8db_C&8pv%6j8|IrCu^pR-@cL1PuOcAL?Ie6CzS zf@hbS`@fVbYo~v?r}JR7qbTK!r^0x2YiOG4rq~C{5Kp#&;FrhFj6zUqy#+}lb;dL= z%+x9k;LPdOz%5oyag_HS@h?ymMDJpM1q8e~fIu1GBWKI_FH{>QX9hwNmR4X?*mpik zCF2VrIPqr7G{-<`=!pZxwxA?I5vG@V=Pdxh=gARl9WIGTF9eJSr}t8NID{=c8}sy; zZ0h7V)Li6ST=kC`@*P0Ahbx26+Ni1J9Ll3$=3Fitkt9OP7Y9xLs4!_A^*<2hm68K? ze9C=xj4t1*OMhJITb=m@r~uD#vW)VhJW!HIDB8%h`n!H3ym4kSOfl|A2XfL^aQ z`I{yq5(ZyPOIOctz&6}0c`jC~nK`&s*#&X8*|}E@L=R=%0RJwv5PW@P zF3qY1$;3b~ct9OzA%G&}*40KFiDh12t-(s#T5V#Zl?!!fIiWs#)IHjI1rEbrtrXXE z(H%Yewi4Q>HcRJensJ7S)he<`L15Vrb(vRuaPRZ$OebX&3 zD2ouhz68MOTH}%}<*@M&ZIXI*xm_i0)4l=~AX6%RtnqXaK&Mk}1rS*|)iPZ8l=HkR z#?1(o0oTPIMF?DP21}!xe;2fQWoF1i9yY!KA5mgR5&0>g0v=G_6LCQ`;Gv89bg0x^ z1A`p`u5Cu1N;43^dOe*lFXODmd!(1;8`3vSDm541=#={H!Di)g`nF)iVB($EqJm*> zIVAr(Z~05`mSf(p!uwnl!zHE`?Dnn$??c}Wm8>lHa6tQ&=8XwD>rW#XHG`I9Gm;+G zOO(@BqCopYkw|0>Z5sOlm{MgCVUk?idx)ePsuK$8l$re7Ory#MN#JW>X9&0%w+vuH zzqc)|yLmR9&b`v|dfSg35nX)Li#Xk8y${)~bsI?X22&fSC|bCvwP~s9c|k9d4oSzG zL%=0~y0~DE=WJ?lX3GkAO@hSnUcHds%Jx0}s#L^^>U|drDgX(fY+3Cde+nYnQ53Gt;51ylmD%7OJ4C+D*7fM* z;@MKN^)7bpkQ7GYR*Z${76N;aLo_l36F;b%cxb6b~IYFvHSHw z$3CYtC4o7=03ODUPcvY&kCbCL50eqROM}E{Y3kH>PBszt#8$qz;95^eKhtikFSCr5 zG8uw+nyh$&?zn(_#(MwdD;dA!Z6OX*@gm7HU(h)J`8kFaMnnmKt+R>ZW z48P`~oZz4BPU^|Nx5wV>;Z-gwrv8^abk$@{B3)@|xb#850{6cqd2Xk8KX|$x2bK6cqKM0pP{!dw1vc1B4kV z!!%GBT@it=0O0*+C(IH>`Osh03(d^{qVO%b1kG?R80#7Fx@ev!cwM=SdO&1j1T2_$ zQDt@Y%~C*Ts)Uw6klc$#cQ}EL9$z;9H5XQzBx4?ean*o6NC${Qo_@E&zef%C@X+ES z>+QPA&W8SGhHX)CSeFaWP+a`8KAiB*|9*D==P4n79Ooy1n6%)9BS+?NT3)FqX#qBCDaQB z9b5xQ&ICami2tjESzQmNv3U3pN@gwdi!(#QcwJaRxuXkdesJaa770fMH^*+ljflKe zeUK+L$tNWMJ!cue%fy@C5bV?mXe6Bmm~Bz?W2&ym#LnYpz_dy_Lqzj5)wlB}mH^Y< zzdB!HmKzUF#~P21h`B}nMY0p@cyW!|TqIBS=ze%`jY=T^XhIiRWNbW9?E?87>>-Fq zA5cCFJ*BU)3rYs*8v-Gz>9!C$2B2Y@*O{@KFe)VrD7kZ`F)$ZS6&T@x(7h9fo2gC$ z6Yn1T%5w}c=`y90_1OOq{9O*`yzPRN3ODZpUCMMeJZ zCF&phO|i%=Ny&DSnk3sLd_)if>V4}MtAhR|IGMA=f$u(>NzhB z51|pqKy%~#OB)j3U^~@t)eV1B>A9WOtO`-DJu05kXjVAzOFY{l?<*w#4_uge!Cq1a z`awY~$q*Qa%p!V|)CoFHi=m~$K(lFfgQ)kg^#E!~Y(h%R6kJGFT%$}KzL@5rat9lJfa01hqG&<~<(o6PZ{4t@_oWve^sB}siN-<+ zuX+*9_~a4W1n5LgL0tm7fQn?B5z%kTyHDM!MLl$}Wm+*g@i^fe+0^bE&hm!hMTh*5 zazNXKfVCNM$1#X}`d0vOoCO~a!f&?E&Y&CB`kiriDE{##q@`sEIj!yKFr*i>x4PS% zdRb$!8->>K_@qkQFA~pYD}%ThqX;+W(6kn#$1-A*^7l5N2v>*laHA1y`@h8SFji#U zr29pc`IqytN2aw$p}gJQpMCq=tl&cP4f7$gK4sFu$@br4t5%v%6ZL{}0Rgjt=KN!F zC#IM_wEQ6;Juv;sl#?2?jwBKXsRj2XY{GaYH+4tJhz0v%%tQ8X6N3HP(=b z*MEY-3bu5~G^qF{uY?xJ57>GPku7qmrAjcBp%L2{?OVa6_8)S@_0ViiZt#9_=F6Y6 zRQe>&RK+#XXHv)4{S;mo7Q=8c_Bb`1KO4FUFLdnT-;aOr`A5`11LGo5lj=^U>0?r1B- zbDDyo#zsGWVI78h-iIGOWAf~Np4s0po$y<^K$7CAYa)afpY1m$Hv>kIwRIfDw6ZWaO7rR(CivpM_of` z_>qChcV;L~#clb$tgB?Z{1Z@QtV(?HYL#4&JQkg1DQ2ed@YD?JQ={a5fAJ)VzAJ}) z_>9hGomMm=mA0VW^>jh#Zhh2y`NdxCC?Qtegf59ufuouIibg%r@2!n1p20=mA?|;A zoFg-V?E;9}F=iJb07=X2Xq`#G#1`d(;pu;${;Hzl4dF*(U}Ba!`|4a6o2ebyA2$7h z(s3E{NOkIgOWJmkBIl$-Bo*j|RLIJe-Oh(BJF;f}^y@w0QT24hv8v;<9Lv~HzDYRw zL_SVDHH}HM_U+Z1C2peM%P~X3A>waM5-M@Wn@Rwx zB$H1k&yB)-jr}yuldKdZgpwepqfs}sX5-j)0k8GuY=B9X0d1zg#ys;Lx>9jGoXZ`^ zDOJqIBj)amW%lVxargbF%R3$4pK(6Pn4e>q0{XTxeThQ)JO!TihM;=e@*^!|nTlz) zvp zcTG#wB|&}@oE7;{SM-4yfv$t_NCj{cJOE4u3AEj@kbho+DH zDS#Qm$^L7XotyHj#7*7rmCD1AyO7`{`Gh_-l%v;6JeO2KDtuV%n~@+04@4{3ikzX4 z>2HE->0N1;(G$PlDx+0!)8i+mNe4NlGR4P%F`V5bBo9`I|4Q3*W_Xn6JXYrdfJqIV zf5YcD;OHp;@Dw8_#Kl;7VId4!Mm6;@ETV$7k~4KFI3HB+vpMJ4`$}P)E{Z%%O3;hXpaoU6x*{X;R7;sJwG<* zP?j~MA+a1csmCd^Vh`HDUNn(8ANeS3aR3mFEBHMkvDBXioIF?Oqp3KGf5)xF?_dCKF#AC z+IZ%(n{cZUYOfeI$Ho#Sc_C&&wd~L_nTGWGCvXH#OpIW_Mu)XEI-@6ZI=!m^3i$g0 zx7Ia^9>+zNsrB8m02ZfISHDA>h>k9_7$!-h_WJ2^sFn&1--n&JDn|PyyxRS>EXX7n*0AJKLJ) zO#)c8nCs^~2KlVU4tHF61e0oLO+eTdEU*L|*tzx7_6Nlf=p^MYzmBr(g46jS@nd-< zknzSw<9{feO|F5Q(Yv;FdkQ<~)D;D$K?bu1sj$Jryh!pf1Y(llbYY0-ST5>)qfr^~ zTwj5%@!zx|QVibv`cCTF>-aS;YI}fTxdFT9Vbx(uR?ZPmd`#S!Xmqns+}vc?@j^!C zk!5(*veP&+?SCU79e6!@Kt*B%RGxV%VuX3RqCw+X)G{2=ufkN09T9=BQO)rv5$czj z)N2qfTZjT4L#^cx+WPf(CI4>rdCFCvE?xpzDyLk~4HTi7M`1P(JFN7`I<-7bx3yyz z9|iT|3=4#FYy1jjA>)}4wz_G5Hlj&{X$z!5MyTLxHo`_VNyd*RLW?IAhMnr|h;sSD z7Rz=h&xKZ^s7oFrVYEg4VWuVYAd>ATMpkH0NwMIC-yFE-_ z%wnz4>{9a_9WCW%s7Y$&~dMQ07F4HDxJU%o$4YBf!n#UE+@&44ZyR*SQ|p} z3`{4~X&hJjC4NjK$)kZT#qld@NdUN(OX?gs&8m@b^$r@<%`d@Q|3BpDBcb%8ph4R_ zkmK>3(ZJ3sW1PWZH$6erVTdVr=1A|Xv-9he^`@Nl; z^ux6bz)Sbw`MM(QYQO9}*#*3W0-!kG^7PK^+~^G9%?a78Ji%{ZY$$hI?`{UCvr+0W z8TQYw>VrF9_rIDV*P|^a=C(Bo^oRaK4WeQ5TH*?Kh-Rca;;>}&mp2IXg$ZS=A2&gO zW`QHpdtJV@eyG((0cP44*q+OgVEgl5x#}6UqkJPXSX_wfIbMj=l|2TWmws$3uhGnR zAo#sMzT5yE+oB8U_7HSO(to;c_7H$FP>hkAI*FNf_ulHN(fM;Mq$JhSOsfD8nDpFQ z8Z-at#UFp%JdVPb#>ryve57drPO3B?`N3GabKht?0U zA2Yxt19Pkkdw@QB1P5?{eIL+%s+rp5nsr0qHHJGo0)+TgaKjs-WL#l1vP*p_wB(D>Q+!M)T3GsPYt=dnz*Ko4jzM#EwN@$Upz4|48r+o2JXHQ5$l z{C6|Eo_=R4}fc@iKNoX?Y&B$s3H7qY1&XIc%9_twVoh4WO~R;I?@&p?4j| zI%1E3XNnom7ZHfr9!~m!PQ&=tqferMkw{Ek`g^z$G1%O@I(QL{i?pGC$muesJGjQe0=D_;T977~ z{XZ(1T+=@c4cFaa9f%GL4E)~o(R=eEgj`**iRmZLs4c54NApLwfBl9iUvj(n#o$Q2 zWspANx+FM{3RA-gDE%bQ1z`J2*Yy7mr>#XXkomx41vdx>fCvX?{B~&02K0_V_+TtEDtS1I31=pk%1Q^ z^?JtZf4>t^4?QTT_y8d4mw_0z@v{M_G=ZmYwrmN)>1*Xqf4|Tb9{}PkLCABRc7}r# z^yTz@#g<9y1b~V1fG8*)QjXk$xYoKKVLM8)943&{{RXQ;50cYV+I=Sg4dE6ByN$8F z0FB!~k&c87NT2CO*^5Yy1_VqmsNhj-4`SHWpcZV6|2r9j$n~^mtS~K-ZH4yCnwQ$l zST_PTc|6Y%3bgx$E--Cx3aW@gP!)C%aPk{uD_}GrTL<}0I%vtpTkh;E-l34~7Z+wo zJ>anyJ_oR6z)Cm#m{aB;Pebz&i{y7Em!XkK!nDUO+nXsAca#9j-z!v z)W~KK58iU3ww?t~^Y%%qR!@n9t$Qe*$gJt3xa^Hns zRBJ^M(lD<_3Z_>5{n|jkBMXL>j)>{Y}5t>{(ij( zH^doTw5~rF-VN8%faE3^qqgQyJ`9i-dT^5FqN^Eha*nQDhy5V*LBi1#N?(Z6kw>8o z`kv4O!h~d^Sx+Ew|Hlc*!}F_d7DK*Oa4(_~L=EX!%ep6snkB&#HQxt3{1jv(G~{sq zb4hTBFBs~=RhA3fulOdG|w0qspxAS!ZsFL8c#;bxB`W2E{ zQga&JL-ifdF@5ku59mUAmBl1dxPav$JXB_9JLoCyA*dyr_T->Q8yGsGRDJ68qoVJ7 z9Uz?cYWd&jXd>4Y5%@V!$})tI`yd50f^RfRturDWmy3S-=PJtb)*vG#m@*m~nEzQ& zUZ|y1Qk7UHA?~zvXM-PR`(N$G|Fg>Xi&xlU9AP)05d+I15lUcLBZd* z_rS?}@SYU_wcEFwLICNTo6_IyRP%wQ?#?7jNZWr^2JTsF2v8_e-vCY1Ya}+U3Df}* ztR9-9!}c>5YGwzRh46WF9tE4dJ|(kL2r_6ih8!Wl3fhd=aDFgXKgOa^4JXCu#3F}! z-i3kQov#xqYY2(01XIm;NhOF+dtqc#C~kd6=FUK;FTG>*f#C5g|G8;iE0mn)1+h~B zu({j0V8{^+C8>la8lnpEVOe*~=yz7TM=fkG0VNNCr|&GyG-)q520RP@Ca@E^+6L*1 zZozwrhuA0!MiQ<)ggQ5er~Zg}U@G7P$HqSfnA{O5V;>Zvsky}d=wrk_~Lr%SIB8UOZ#Rk#f$#=3*#z^;#=JLU) z^eUVV@N>|%sJ$!PF^s$7S#P@|Aif6<#sQ;E6i}mKfi~&C*hbFn<{=nhwH+bd;nr@BI1U<(!LSkc3uIssr$+3fInhxE6)$CpsVzw@Gha z3X0-A4pV$VbkUId?(e_tse%ErrW^fSOSNnNjChnyRqf}CQK?}KXMNN0Wp|*A{y4r6 zAewy0l~V&Fw*hr1#-=kIM;_sinJ$<6;dx_#qSnBSX{e)Y~vkCwF*x{Sjv zMJRvS1K&@rc?;={;lxHJ1msgeBit`txzYk*ONRUR@4sI-4#y&z(9qWR=QcPTV4=@$8$^o|9xAMgfiSi(WG7#_7f+PYFMOEPvl#COIAJOG;WzzZW;Q8t%no0d-nZLf93Ncr9jS5`D`zU>Z1e8BsQ4eEIFZb;@q=ldkozR8P((DXbCG@By#6a>_8+3D zl(gs{y40gr=o@7S&D!kD5PgBypqIeAKq3A%$ZaXOQHY6HiJdSIaI_W$V}DUW~2 z9Bzi~7veAa#lin`F0FNBwb0kTh76SFROYSMQx}&BIq#Rgyw=q>FXq)+j z%icRtOMl&n_UN42raP(Wc;v^7joI+tPj0nIC+EEA3YRuI=p!#Csh2rpRCsoYn3Ju6 z-BwEjg6U9*I1GdL;hdTT42Ro(!Ke?^9WW-~`ati#?N5?3bHO?^L4GytYD~qW%23?H z+tQfj%*vH659=`((V5P2(y1VmlJhh$5qcM)vPt-n9odNjRtT>+**?kJxiFz$yEJ84 zIa%DQOaE54i}!9_X4clnI12S#{^`PMGA`4u&5s3>@9+2JZ+V-o8@`@e$DecQCOo|Q z!8W1gJ5KElb26|5didD4d$g&1qV$2AT_gh1GGB-n2|!vnR;^Fo8|Ebah0{xoA;CQ zR~OkPi}iA*ZeP6T{mr5)qIH2u?k{)v!tnD@`>FsH~NhF zB-bxIZeS@`clog4vU}|#&h+a`k4p8$B)Y6>_w}jAn{G?JxS*x3NK1lfMJJ2u(ovJ# zrE9tFr1(ffuRSEayj*x$7JO4mEiJiL3o%j9H$}$01J(;;@@TZiz&=O6>TsC%+6H2? zsp&|Vpo*$zV0VgY+Y_MD?SoPdjbT9L$OgT`@~$CqyUkr2Hn$rc0;+o)W^Q%UR2?YI zOK&A2X<+qe{SYG-=TYE$oLK+(1qad#hgJ6VFuLO(*3J&zp6uM3ub>XQ*b{cTHamEf z{CUFsxFes*Ca|0}P-U-Lm~zvYR}!rIK`$mLDH&V&;7w3)@T#4zZqw;rW%=*?0_^9{ zCrLQdGdht7^6ZNn-OIQzD7w!HH{tJ$zc@^xWZ!iC*V)sAMvVu(Sz0L;GY{jIWvHmi zVy;mAxiR$Eh>mVaSb#u$$11~bpS>cUY*2`A*C$#Y(%mbnsT2HUzk8<4f|=uL z8pZ4n9+aiJB)maXJ7exXW-VISo4w{K{6J?j>t0|?nFAZU%krbF6uKfkw&~fKH~Y9s z-d)BEkyzimNqMQ;;80tw|E-tE|v$KIQ z9&2$>dOZO`ZQj)dls?*Q6Zf(HG@GnCwdb7#=W4 zkxVC9^0dB&s~g+#=*dLQ`FNKIrgudAlVKY6PTgA*TLZhLmiK<_7wMkKCQ9=hzG3K| zUa;R@&33c7!SMcU$wx8g<4=o}1mC_DYTjhdbBorJ8!RUYuV|xg-*Wz%-~C1?ncynq z6#J-#gwdA1+tEdR^N~pVs-xVUh=8gP0q=MIj0Y2o3>wd<9-v~ccf&|T`L90^>$8fRxN!{Y7(C={HjM z<#h<_jG2kC7Zz7EX}ju==Q4$19Ty8vj$I`C+C|;&?zOlffVVl>eGRYIe&E#+I{(Hq zYff_~B%^=5qGQT_wt=DNI$6p0#P+hcp{UEF51-y#U(3{qXB$6VN^^T!pyJ~wzmI=K zaB#Gh>R!Swx&TrT&mTuhPf%*?;o{ zLWdx5eJpkF{^BS(t4LymY3%IBYaAmPI|qhC+IBn@%SYe46srF9YBySviVZ!@Gfmr+G?_BTkU~2Af zW4-!MF0q;6t$a7@{k|(t<#;PM5op(4R8Mu~_^7V46;^(VX9SZM#%`^H{;(ZF3n^NQ)gZ~8;U zT_aVm+L~?zdntFM8Ck5SjLD?TyZd&igPF{L3ohn{U* zTMGGcvPr|%^e6)jwQSl7S?OG>TcK3ifN!Tsi(eXhwTEBheJ&+ctAoe2JZhSMPkBF; zyh7dlpm5J{;{h~vHhZc+GQZ9`ulCY3_@ybv z_V(L5;rl~s@=sP!o>^;OVe7j_dF(S?#E~mb!TDI4nlcrG75V*fTJ$B2`{OO_v2{)T zPM?g@+0%bY1XVv5JnrluKKzwMtnTGxU_kc=?J@v>>*f9~o|vS~0&Zw7oHX0E%{W<` zqjFHKd#KO8Q-XZmaNzL?=k@7#-hna7*TVbWYt-(ka<=}8(;=MsJ$=wJ5L0&TkA%J9a|+@|(<*l#ezp6$eYYKBf)p zVuzAKvrh7*N1WguAdmdzQEPm!tn}4Zf7ir0?u)~6mi8+nrJWTGO*X?;$0sEkEIaQx z+S$IiZ{RcjsD_eM%{mf;p02AHG?Zc>)dM_K@XmG?NR!8 z;>Ub2!~07;@n2_xOhNFy;^@Oovim9}AXiPdOT@EWs?H~jY6+*3!jq6w54q1W*;r@qk2Iy@3Ji7#lP3yvT3b(omQIi ztf};j(R+iljCV#i*L(Oc>Q!zNK2H7}0XYLBhJ__C$*0EsI1hVI{$&$j#2vPQBID%;WJA^on7nE%41&a zFJziB9*L&1IY#X%rJWd}cPo5MZyaOCXS(@DXK4R=-I22Rrx)rE>ksjkMn`l}(6?Z@BEqkH=pt){k&f8Y+e%y{yab6@x8zHDjeeweiCcgpUcL$j92 zSp}LD&N&Bl%+g%%i$A(i?SJ zvr=~h+46ESY%Mqeg{=SYHPc@0eZxE1`4j!Y(M17$c0MQ_Mw(vd@ypOCHX7|;yhJNZ zP?b)GXe{-SbOofXeR7F_&0}k*l1Sb+|1x(;-)_oCnWNxD-bX|skasMBOxGtYCWb7u zW(Sm-DM`V6$`o=b3<2QgtM}a8%7Fpilw)_x#WJ)Y5jW&2;7m%z;R2FI6MC zUr6?o{n03eCa_(8g!ey+5&Y)Ul%W|sD}^x<;f}fKIxK;(J3kP3k#+N9A`E+sTKovgF@?{07;XT4C#5T$r$X4c>R zf(98w+86grEA`>QMpnSnM=q#d5NG8PG1Y$MmB7sZ?}G z;A8=d0oFz5Tf%?M?KRn(T{oJ&2D8fG&+&l*RPp-B6JY4+_bK$_ z)2a$#zt2W%#PuIZnk#AYXYip%l=B0Yk%uZl-|Qowv0>GA<3hhtS!ig4vWuosh^2Mo zXVh@rpB=M^u2r?I(A7u{fnz*=&Q!+%89i6@IY#6{|Ij`u2nt93Li-ZqB&Nrg?c3XF zf|Wy`?7p@ly3HMVXKpgS$r*D zVRULcfHf~K;#X`KjxKBnd`oSvB(;t*X+Oet~uE&3=&-TwY2sDxx!4zfZxxy z+KJ$Kp37clvmX78<&#UtGyJ6x$)}s5@3CG^+QA`+!bTd+_wkVz?ALSF3Tlc?!Tv91 z8|}Q_NuxwAKOCx(1AT1tm&9u9X(TK0Tn0ejc=^G4{p1A6-M9vim>|!ecV_%)H(tBx z^rnA%_$U0_6WtIWe%YEiPs5d8d~5%PrOjC$H6a`>Ieu+Gxv12^B*}Yh0R^!h{j?%l z1EduIQXZbjPG`fMC!5bduWOVmnNwdQ12?X{cQ!^erIw@8e`_5X6Hx~eJaHo+|Me{K zL1+R#{&1W#Y8f&bNiJ_n^h~(VH>Mj~oz6d1o>MySS_s1s5YH^ktM$ zF{xc8bMl>P11P9M)#Q6Hv{S}>7y71mqV@J183L^}Ki zxC02MkY4r>jKJ6G|nWxW;JR8RXe?M&nyJ2OE&Ec zRAlUS0y%s;y(GZHjL{m(@57X^GlfLfc`9=OC$w0M73Q$7PoDd*D@l~$h{@k#3w`Yw|NkV zv}C>A4sF8*I}v#be9)aydJsU;6gCV=lF_U|!!JnqCu&3vZ#A<%)t?~j9_Y_8Z=6ah zFQnB0Qa-bm8^iQ?`|7nv71Y($Gs)7dTUQRH{VzF_3&o)_PFS{P9)3I5Yd_AhfP$`_ zClqR~$L4khkAjC*j{FhV5gyWUnr4dReUf_8g{qwY)4C;@nXxgHE~!oL`vgTIBP*9i z7IKVCkn0RW_2B-IbUISMd2td^Lya$HTRdq7m&dA-90|`h>ohWOM-DE-&v|UF2`-RI z@!>DtS{`V&lv9>S`}fiHMuwlaLd>*V9v-AmfF6{2Z<@RMdI&Y@3N;D^wgz;0>VQpX z=R|&fXdvS}gpSIa$(>o5XVeVAwCNN{U5HO~Gj~0#ygD@r`MWxAJ1#$p`Hg)6ZM86y z?#&mqypZfU>Kv@5e>iphoLpAVh~zgl0T9%M?dwTwl7cHrQRXw#TDaPzb*^PKfcM*9 zYS&N~LBIw=J7p6N3HF_*OU)r~g84SDuM1ax3q-BhF2$oG8^#t1VyB|d%@DCND$KFV zF-ImOOK*keQo2|Ww+OEY%yon?*c~NtNz679cR^f|J@&qgTeVJ4@1we^+VKVhI-z4w z$>5b9m!VLUi-D4CGk{_K+*Dl+c)f#Oz5%`;OHX(UqwS*>DK9rkoY^Y*?7SZ~3;88Z zXzAvVEFozxiw@j6M+(G1;+j(%Ms)ABRN&&M-c zJ7$c8nT_U07V3Eh#EAEJM}E#WOBfJIBko`FALZQM?1zWDrX**59&sqlCo=tNX=^7h z?9M!{e4q$IwwZ^si-()rnll+{wmEL#^8|8K^7$#_$}3ovhSo)Xws#S`X&^u|3Sj+u z5$$LwemU~fNN2+3#8;2S++{#1_Zw*kwd?jWO-HR*up8}k)sUXrj?@uMVh2pH_9Gec zX^OjcU1KOyKGul@|13R&GcSv+DM){Sz<0FnD)Ty%zNyk@B9adqkqvlgeJT`2AX)wzXv>tp*PYw!25j)J=?ynfz#c|8&kCZ5H@I zSH40#82y>cOz%u0ub(~Tmm$1ndsP9}02=Rf;RbMWN<|x8CrKmQiL>a`-|RNX5w-v+ zC!3`8FE49_Nl7H<+rOpB}&sKiPhQ@ve%^g)d)sO>+eZ>=4Gcc=sR)1OT%$r<8=3be3wvT=3 zJ9)_vNzYM8&%wGJavO~Oc!2CPukx(>pUMV)tGfg1od+VEz`AuB-j1o^N?sKYfR8GZ znJ~*2mve`44gqi}SySd%9DtF%MJA#KSoD1gj>_>{Fy*V&&f@1H&`l5m$&PU!9C_P8 zE8zWlVmu%CgEVTQRBVo%9yvK1ExynS_4jnq?o5mj8*kl|0eupt1(L~N2ZtkfegqKG zODHAJu(r+8JPgVdk{}o}6FADMbKd7_LZ52*Tl2NQHV$kG9V)x6rbH^`u>tMhUBH{w zV9BpNsNZ=`TQ2OC!gyzExq@ANmG3Ug)hwDg1A|@FeCs!6L}M!UA=|9OW$Ahd2q|kN<4Z6&j~|E`2kg z=U-F$NGk^7GjoQ4*RI#}x%|`SBd{M3^M-2*gNxCZrsTJb+(TMbZI3fjZ0pl`;X;Tp zV`vdQvz_2#@X+DnUU%4cjlQ>YCM*?T(Gvu3j41GM@@fz)Bv9ke$AQ*w9qS_xdEQ4S z$x@U1n5zt~C+YYlB8Pv4mUh@r5ij&$NB~k%DV$6}v};HcpcD(Ry2ZSA^0CCr%XEs% zUC~5Kn}E;V?x{9%iXWcV(&Tkg;t}Gn7S^@L;`;NuzcOaj~*A!=HR*~n;Osq7yL*2NM=uQJLNlh;mqDc@V3}R6?JO7`WnIa z78KMaVO1^}-CZa`HY|r<;`A&tNG$Nd^srlmLIg5Btdz$!aJ^xU|L?9&;Wq;9vOy=& zf7Zi0&MjCmF7YS;#Ti10#}e(ER--JB^6o^D??hi+TluIOGqEfLHs{Mn->f>4 zr^Z62p>g|OE5UQ)*@nDH&))|`o1U4~Px@zNJT}h_Z!yfmaK+9@E7baMM!+eKQ)6s=uAkqmPrXv}mia zB;B8n#m0v9<-^m>JPw`Wr_+N0YjT0dlFQ}NI(|n^LM3GK=HAlvq1zVvwUK(seIa-& zOnS*)XD0RDh*aoLaiOQ1Ak^UAPYHfrEAi$*FIa?@zh2Y@BbJCsMTAC=AftS#|U~VQ)~OA^**Fp#=XHo*fv1 zLl8!&!lYjCUBgZ6Nj=8K=Z5^BBk7Zv8Hq!!aSfFdSr+bpYl%I>NcpDU$KEFv9m^4n z<$Z(q7vx>2S~m3Xq|y8P9qmYPi?W~Y@cYUI!q~NNt74QJS824;X4H(=@`+mik zSpeDasgG5IH<(;N>;?Gwi^rx-6vPL7<&5KsSRaet^3Su8WgqMSKT$DU3Dz+MSo4!+ ze5hce;6Zy$dwjqv5>D0!cenR{@Y>K>;p~n8 zOevQc`lIDMJ0i+6Y1Lr2f@Y|Lq%C=<@176pD%9t0R5q9sf_1C_rBz)iV{6Pl6 z=_?Q=OFi?WVIW^wZ|dwRG>u=(%5aVb)wINXc|;$utxIVUUQk58oNp^uqN=IgEOCrr zW58EOdjM4OK~*^rqTT=H(zFMgrTjqt(&$C_iRD*8OHn%eJM6kTi{ujpE1& zU9dTVQB^ule3*T0gQy^*3h>w9z+iHy@d zD_Dw@v|PffQ;Q}<@Kv#koA-pAoXC9$P(RoRL5`9rZf+F^^}g}q$v4OTHguF}0q7pqQihbF`j~6_Xd#=3- zWeGHW5NJ3T6rGW@e_;*6IT7$$T^K9BvN^y@r7wT7xqGKXGWGd^ZeOSJ`hdV)qHQ+W=Et-!8zh?F6$N$6Fc@elPJUr&o&7k)k z(j?pRp?5`oLld>J2!#C3>V~DD_hrCp_rm7=$35-G=2Q*VS-iBSVB|0PWXOMLo+?dec#C(l*93~}If!tm zS&KAZCb1q4s}H@q>O9(Om;#VF;qaO%kg#Snu#l)D4I?q8+ZEu+mdb6wJyID51IWpN zeG5>NhD+^fPQz7ZPRs66kbE0aUmZY(0#V+;5HS=FK8^2h-kXiO5~_$P_yS1r?H<;7Smm`AKZ{x_Gh@X+-bmNHcWG+@ z2s_Pr19BAWpUl|T)8McE{sgosW8)lokQg^ltByN?Hl?n@Dfn4R>Y8x#{gsEJ%cj2+ z6K!bx$F<%*>x}ghx9yaJTOm_->!I*CH@%@-_f=nP<**>Bd#6r>u5FFX+ zB|$lOsaImRm3IT%?s%oSQH4WkCtH(Wn9l_1;xl7QKi59=YtA9=1IU|Y?kwYvoqAa= zylf55rsy8B21UP}DkH%Fb8OIPEXdM}21mr4(91#&{dhG(1f;!Pf(U+Qy5gf!>PJp~ zr>^ch`H&v7ZJwD~!^A*RV7!6pF^t4;4JuqL&Off$6!-rUj{3i(p+0du|6k43#1wht ztZ+rdy>6m3!$e3f#_F>A+g$U$ok8b06kYl_Bo-EH>?I;W}C1Y5Jbf72H`Ll49X8kZn#Ee?kUndWuJ-Z(NN=tq0MDs;gBbRgTGXA-aH^nFZ4N0(Y$3&=!tdO03GSQ0o*7~Ot~NeFTE4H4 zV&CKL$E;I8;^&1E!6knpL%{elP{Gg|k8~s`J8j%}-?CPyonPVSq0Kx0o&5wkbnu*z zj{*Ao*%P7Uo5W|q=)S?hfbyu;a|dN2X~s(vxJY?&$~pJt54BW_ zsso{)@32V+Vw5!4jeuNl_;LI5pNa9ZQr_!x&{%CO2dv4o*b1NuW>)7^1DWPBUBAIX zQ-}Od9?D@jHY{x|bSJd8iRxnQS?F(pw@{~OzDOVNXgC#=nQCDDAXw&-yRZrr%~k%y zyJS1nPA2Lt`vU2v^KN^-S8!uXtDg98*xYyYCc^*3Efd`_S7ly{Qy{(SSksliqY-^L zapH2p8;K0R@v~9h$vJu5lv#xL_2p245L`&PO{$5v$>a_PzD*Of(CMYHHaQmnaI)uL z4vqNl23H_JVquaj+cDm9p?_6VBh$ee?d6w7t2CvOhoI{S@s2?h zi}dY-hRTBces`(i7##K3e{O2iJ=JX0=kg zQc33ch>-U&|MzgXZ|>UN^|XL>{gPSN>Hd}hm1xN-C>f&GcJ~|kh5}WtvN|Ec$0D$& z?K~K-fJL&Fym79*^5O`3RPD}i1`whEPMrVG95iK&(c4Km3@MuaKzTyY^mwK!guM^22Do}HbzDU&|$_+*y$RH28H1L~7uWUZQFn0H;(*=9!L))rc`s9di z=jZ&G1FI-a&eI9hgZCP3JFQZaQg(uruiJBdk_=k$&Om$$pQkjZtzKLV=y&buYE@s# z+e?%<)X5D!CRs|(a%3>aH*PJ$sku$l{82;ivP1&FmHaNv_;HK28G+=vN{w~2%tatFOd@zm_EIvDRIgn59Qwdr)G1L{vD9vHy$r3 zVor?j?3#6N^a|?iK!4$EbpcOuLJA_yBHpgt18LVk=>TQ^3Q)<CKx?C%)W_RU(gC*^Y^sxuCcyqFO>Ko0ud&U0FAVwHQ zNm_ex;ii=?C9RBKS}-!umQ}$FpOEM6DA)ST-)z|xE;f;eqm(pH5|D9| zGa~F_0z!#cQr15Y)(4RI$m0OZcRMhby+UsW>F|r9W1&roi#3^-bD?VoW+X5lx`|X2 zltJtRW8-3p6s3a~^;P7k;J7%kol8rGzPA??(DEI1J?_0l2nj+tr5ga(*(ELV!5ETrHsP<*YVg(l-_b~JTX%uYa3dnq5OPj`Bqs~56usy z2mp(HJsvCiv^<@SD0BGG+s5FJh^zA?%i?fJI;BsZ{-?= zncVJ1qHj#oVnuSboE6md$f@N{m!7-R=Y*)uq`4y34+6$aySk^gXoA+;Ll$|&JQ7cC zCD&}<-|dbvQ>5j#l<^<6&bqt3EM}gdo4e(gTN!w(A+a9vPb+_>CxwxGN^-vpXxW0Y zHe-@GxB_p7%&i8gSF%kT%Qi3`Q{5OTQ8(hI4U^qczxl1d%j&KB|Lcc*=iD|lDP|X# zNb$&j_ERS=eNydNFh4u2l}&et@)*_?gnNvKa-qu{-lU&{^rG_u0cBYC-*2_nL! z;w)k@)qJ1)v=CCr>@Q$Tc<5kr>8ose#J%Qh`^Ne0Nn&RR+~R9_IahmrQ8TDU`4iKo zFzdpDve<~>_Q=5 zKTTMblXA4)09>aV1WCQ`@3vvbGyCcNvLp1vvpfCYjCt3e3(lU$OcNRCTr0OzTE?jc z^VTt|oyZVrU&>ZP`JFp}A|;1BTP`rycD6?p9N zJf)U5J6JT$Zeo{H>#Jr&N`SugJ44|s*j+f83gfOSiwLyLw8KwyIJl-(%xcHR9-8Sf z<8QOi_e2vqtp#N{vJ+PeWycP9KAR*ndrfYaOSin_m>uAn#ut{VoyFwE2i2BETnX5N z&rIKki0pCO*P0Z%@DcYeB>gUsyR4O+Tk8Kx^K@j{PkwFx}1{cZ;WGOaI((f`|L)1I*}TS*mDK{C2wn!A#U~gn8ZFF zQZrCf9w~bZcdJ7^ma!>kUkz9oDbw>i1bsRb^JzV@(<<;*Mpq;IEfCbcX%Dsb2km_zqW&H^&si*})wFI>Um9Z1TlZz%RZyyN9qM1%q~(uhFj|hOg8F3!s^L3VzG=eu;;=4DP;)sa zI6%vb{M8bBqmQi9q~HpGp>3JqAW6r7pq73vn<*nl$@^-W{r2V+DfN=_gop&j{7G#J zVn6ZJtmU7KouK48l7eN_@6;R#19i`;LppjUbQnFDVg;0ud)YW_ED!%XRy zt{0=~8l~JGu9 z*fkoDhg0V15;UKFm$S6!TR4BBj>1nW-iI$#db=hs7~rNA446v+b3o4QkD6OiM_8~g z&vWf>BvnFaw5en|Kct0m#-F?HUG<1Lu84LNU&=zps?%-He@a@Q8!d?co6K1E!mJB{peDTLwVD)HVa~A zY71$lh0&hTnCDL1Watmc<+bRd$le(7hNBUn5&T}HWQ1)yfZKG!{OlW>)|FNax^Mr6D zqhKVyJZh)>PeKt+E~p^JTkSAn@EY#5B1xYaE5!Z&LE!hTvt&bcf2fmt%cFp*QGbUGz(_SStw1RL44z;{Tz(v` zf4yo8qs)8EKwFv-owI+Fak4@dNRs9jz-!|sOlD4v$Y2@MZ6>UnKTU|NU1mn}7gJrc z7knDW!b5`Z&?{9g6lQHz`Q$RrPMw}}6$Xox<|V0MhcfKG6(AU##OX^qvp^db zPGjLIt0^5axDj0~a{$n>2_P%^)amluJHPo6&4M?EK}zs5iT$|ic$kI19>hGEb|s&} z(I11Wn8Ipoleg3KH7Q57f`fHFB_U)pn4g2u!NDdw*_W7b#o5o@)e zt9v!O!p~J95?8&2fPSi3Cip{^$Q)B`eHncZ>NA34YlW<3dCYlJCOq}2Oz6q`sNvOs zXQ)rGBF*aLlw6K-SDCw2A-8EdYYBBO?;VR_HQ=x@6D*rL>aEqkbW5}F_29^qfJ2}z z>DkWiVfaWe?-<4>AYt;u3c>TY-&}j??cn=JjJ`7bMCzm;V<-^L=u(e`u`62z5UV)7 z*yYUg%F4=o@~)*pQLT9_zD%#~e1FE+B^q#OUh{>w)#ngPg-5|*>UA!s@pSj2t2$AQ zYQ%uM=L(rTkZcv%ahEA$5E&n?+e#}Ou?U!#g?g^)V!Lwbn5Q{D7`GC1NoIL zlxBQ~?TxVH;H6`Eo$s2ZZfv}5NCGR*slY7BJzarhkCul3fWyzD_)%(*eig`|>kW=? zq^?qqF0r}>iEuZoVEI!D>uHr>f9sQPSn~b_MC|O zVCP9QkgwjKVb5Gh#!4UgP^VfSnPQubAB5@shX}GO1=Vf|r`y(Px(rAB>f)!~b1g*W zSl$YNX*td}Go*!x**Ag;_8~OB`>uHzMRK|fYkFw{!(SuCi`X@5%JEoQj>A&xGA7u5 zSuXYEI#f(=#IEtJE{?2W-PGstL5jFq2hW!F4&XigbfI%HT(9JU^EW^qZM`lUHpbr* zco=V_MYcgBd<6#*txNiyd~G_nq`%|A^(mJqsIn#T^U=jGQY@)iQareJoU<;yUdK&k zRqs4R)O1c!&_3^#FWcJ-z9z8oDB=@Wr_bqxT%{~|0tFjgofH#bQMqrp=9I^!;b4(B zv_kzMw|-Oe1=*Ahm)qQiop*D`KuQp_C$z`3`1UZ)Mahr;am08j(1nUXck4vZt9+wY0@` zmzQiOzuVj!oZkVT1U0mb=~|Z8g~S!8UMYv&Ywb8QUa^D)Nv0&pP^trKPNGQ>$|H&O z6h{$LY@snMuL<}m?}8;JapueXIVqO#)1B+Qx267!|93Caj{Y-X?jIan$2qPfMqwgJ zd;Jj@d!vk?(*&Evgf@4cTH??g&f^ik_)^S>doI z-#q7AorDfDo_2>T0!IqMcSWD_f&;?Or}Q=b=gcj=r=t*68^{yL<$PU|_8IT{CMJ5X zMXb-VSTe+N=e}B|CSjMEyz4ZV**Q|LqhMF27az0rJbvIaIjfTaC$wRnKdrWEME3H&ghyOSAK&Hos9fX@{%3Tj7iCk>pW7q z?>ni;2St=unpdob!B9bR*1etCDoKD{sTJoRznr&kZ&gbwyi3Yk?7)^Xi1}ygFF9}? z(lot1cacYn+rj&MFU z?EsqnCX!gK+EJnb(zbXkCdTvDo?gq$jc7x3X27ZoTg8@^qL5 z1WOq3Dy5hKNmT46((dfg|B%PKUEo`4m(1nuE&oC_-QKX{_haSImKgKatO_C!xJdqo z#AD#>y;Rv&{if|baJr41dx=wGxetG*;WKZ^3lBkQCggkg9RWgBKw$<_kFs_0Vp-X0 zJ&%qE-coFBJvX056T7g}pFzZ52s1#+Jb8t*$THj~7(LU|S-n4Zs;{he@ob{JhqWo+ z2PbM}J#OU}J6)7;(wc354zs{8bHp7jOXhLXY{JzlPxnOTj}gDVJxE)ZO^_m?zao{b zy~8*EXsw$^k(PGzoLGRx+*-drDVqfFomsx^pIbHF_3}8#k>tM8Ak3}L| zl~yEgZB5c7*0S(6A0E(Rf{w@vp<1ygb^4O_j7-6D zXpFQxy0Y@MiTi{i#NcVkyZ#Qj|JWjU$W4pt%xFc-`J4K&(|fj)BIm#NkVD@kzNoQ9 zz)vXd<#GF=<92s*{JZ`1oBaty0LGMaJr_$*i`(5KyuTCWRhAZxeODC`-ID+egwLb=D$W_^K?D)xLk%F7(CT`0s=4 z8CrP}o#6!?M%pX=WJiOGJ1cLg6?U^CA8ppE80i8k*Cy8bCFrj~qi*Wnnm=Fv3O_d5 zas*KA*A#tE%ITT&${L_rUsi7D!>QsN~3m(?kfyLC6{z5kqSV=$H4f5lyBl47RprYYD zw0q@L(QZYYRQgE0@r%%F2y7+SGbhT9pw%`;oM*&;X4lu=&p@i{)@6&$ zl@qf6h0IZpa9j=V_qPZoJF4kk+Mm4KLdw`6)Gr2${YR@5L*Zc#77HYrWMsufYdjp5 zd2Ci6C)~kYznJD~VZUphtdIxrKAwKX%dHe+xt_bQVx1}aiAOttU1GDfcBd#_b&r$3;2)ZQm6_ns6^c|t-1^f_ zV8^m{=J;4vaCGKybXtA3xTRo(94nd^0;XM9kES^c3Jk1?!~MXDN}^H^gb z`e(GDE9t>b?{Op$u>9m--%qucSp0t=nc_(%TMO9UFz~EdrKK=oSl`|QdY~y zg|){RF1+y$X1`}GT^!P)SN_VJ>0urVDO0I&^@FeB$cfn_6$)eP&m07m1ZU~Zxz1ne zUCO;N*s?Pu@QeNbMT^k|$oDXZYmI0e}f zRpiURH@b|b@1G&uY~rLUVC13+*9{%DBZCxIvU)*p8Bz4Yhd{=&@=R}bo$`dDg6pmu z>n~ZpQj$6t`ItnBNjbEM@#TsiyA`V*?HNd*iu%WZE&s4o$-Om{RMbX0(#bw)WL*1V zR!GKgl|@B$Y!#=zSe;JNPe1qL1n^X8??tz~fJdA&^;c~j!U3W2B*~uy>&yWJLOWL7 zju={yWoY^9F^ylbCQ_J!+{Nu6kRw@na=)_u2aBEo^uD521k9_JU-HoI*l-jEg~#}S zjMHTCT6AYve-SQ=GG86I{Q2&|_bvJ9{jeV$P_fIzs2&UF+w-m@Fr_=imqj|3a%x*_t@huNHMG_kU_KA3BG> z@RG!mhqq7jVmAEtd?C53;)LHs*X?A&@eZ=Asr(avq^UoSfN9XrAD?%<@L(r-7HXp@W67xgb^Ce33 z=2mrey&bRRFU2YfvT=d5Ae`BXBJphEIrX2oYUk_aJ(+M~x5&cPMBX{}i66vL9|?bx zQvDeD!SDW^Z)!^~TV(n{K4EY=>zPH1(6#*THv+rl~)*L9!lyVGkfp(pf_6#J+!Zy|${(}m<&nB)hoaP9P0U}tHTm%l*_bCsSp zWgYrm=HzDP&E>e_jR}bd$4c3DN~ws^9eV~^uSu`KpSrStvvUS5EF0V6en2cWxDQ~VkORm=pJRuC6gLR{Zc6u!wdNo z%;n3P;Dbl?*T!kt{gsYsFwZBkjSHm05U=uD z4x$O0)_>KNU}sWDFpbZHgiL=)aMxQ~Y>Ue;AW3H?c7BHQHRA<*R2mLdsp{)XB??U$568+6$jbLnlz1LNGw13V)q)1Omd)) zILbh`=NxG2JhYv170Qc-FjmynSZ=%UDOJMsJ)S_MI4PRkHcAR<$fydC^^xY}WSwer z_}+`=PtAfm^f4q0keWEz2svJH4_Tf|kgb-XLL?(2XvwC5=&ljzYACAlDSNeF@Y`zr{(3i|MD4!s|=Me))4JI*62fPAA@ooy6m+U zP=}5bC9hsw!~R@9y>twkhjO>db>+D#WiIJme=!s2^cK6`-Hw0UZh`Dpjf9p^L`;pU zO&}f0w(E_)%T4-jAI?6HJ-<%DN99KqZw zjjfaSye#DBkLckdL>9eSVFW=JU8>bi@oQ)vY4!UiYL)llUz1{E^4~2hMR+}ajoUtS z*YP0dm7J^Yo4FxY*g3?ZH&;`Qiu?V2c1QGQ7tz-8md6LiqX`EUwF1|SJdF2d@Ww8F zxVz6Y&(!7EFT$<4_y(ZsPyHP~ba!M0)p#6d0T@+vzYM2V2oE82eKOP%J*16_qAfYG zPgZ3}bJb8|8f;;Qs~?%K8x_$0#p2UI$iweKf6V-xTknoY@iAD~^fT%13=3YOKdA8T zR_*x2f_eU+d`r`z`N9v8Zy3aI;s^F_z&BG-{27_CPI-7VIaxV9TDVIeh|)eio#H>$ zNoP!=7TUd~y-0|}V@8fwPmd4f2<4uCBw=m04LSytN$g|5x-OunV z0g-19iDqqHsLdCmo>S#NU!6HjJbl;O*TF9IvsT%8Q;6)i-pki+272mWwx1t!>0RwS z_8I|pzc9~N)?vd)b9YQ7pKPOvDFQXA#{y1lmfLrJBotr3NDxD3scSpS4N1a9#wWn) zS}&$Ou;#kv{U{DuHO0=!6J-an0LYW$VDJEbwWY@>KEQhCId@43>kF&yb9vxdrv*+o z0=R*2qOKnv4Vat%SksIbpGn&}-Dxa@Uz@r&VOfkCYjKwTU7cc@W@$H@U6k~wv#(yD zSz^)H;f>3p?bqP&np-9!ZT3urQ9Mb86q!~E%}09F(1&l>2f*wRt!wzq|!vKSNHY36#@Af z39;oZ`&re+dpsDPFhHW%D3S9qQT2dI;Uq@P2%!~5CAEFJY0}Jk^PyDp+MIOaM_0*A z-%r2K1s4+3+M)XH7Tq2s*`#TVS55nasugETqFJ*)-n=xyQnSX&#^&|m*OA}Uy=dHD zb5_7cu2Vhw2LC=(7QA-Veo=2(zr?@9?4)lDxjz_NUnukOQ2W_j{*B^YBU|3(&}!J5 zxSGVBj-9$Kpa(`wn1qHdl4M8edDk`*oFr(3%J{kMKjv`Az$yE)@@;|zlYm1RDUJQU zFk~3Vo!}smuOAMrylpQA*SR@Z3xJnx-!JG#_l`Swjl_R7Mn&sKXvlv4K+Zxc;@TM4 zh3I$Fx6no(TIeFGrT&6nNq{HQ!&kY~`GET8&^(fgJak^v;Kc#$FV_a8u5i-&XC#uI zoEKnSxPeFgM|!f$g03PuI6Q+4E;05k_+>J+nB}^NScT&FKdJecml-|q>#C+rptRmf z%)zxxq|%ra|C+32+jAXq*9~xdn?g;=0}PA%>$Ns5%|q)~OoO4<9_5EY$HLxeDN}(0 zcU|}xK75B{vts?mkjX063aR))?>Z00TNrpMgFh!Cv1*B|vwe&;8{7cP zNErKl1=j#=oXnaLv??MyzX6}Y@iKRBT!_h6{%?6mx|;P0o`>wfO6(-~(!1(2z+Hs9 zc%_+Fly&9@o|fw7kKJz;vag@n#5~&O7?gB!VbtVfycHY#?j=hTROCXz%sr-1bFIN| znJ7L?esetx^MMYTn9l{pw$5p#3zM5Ov_&6$Ha>zT)^SQdX&w!^6@Mj~B5InzVzkaE z=%_vFWgxM6w0dbb$juKs>Oj<7XLkdn%RUB(AAam7s`0j18G)pwsMl#=Sj(QvbI+xM z@SA`z8Q!VXxpGf3u2QkD78iZR0|-_lp$fcN(1p0%Y?l~gj@0gU(Qpf3Yr zbM9G&8p_{OhTdIhCJI5*1?o679064cDN4mRbkP`IO~GeWEe;X8CHle4PjZxHp;k*FRWXUqmr6o4(~B+$(IixUq&{r$Hr|*no_R6YBVIR z`OWJz9coC(SdLs;fIO1de1b-WqxsR~c=K~=arGF-{GoApTj#1o_40CC^NTRc=%lg1 zMDM`q=0=(_mXgn`m1UW`e(8R$G#B_9sVl~th*oVx;N(YJM({$ZE8j$dm*Lcle)U)=6;olSp5?hne%&-H}8 zG6jg`t2zn#<7#--l%bv$Fm_k-d5nZ!^y6y^QE7j7-jli(m+mnE`Y|ggUmy;bwLSdG z5`fFoJ}^fbE;SK8*va6XuI?S1W|`tUzStIS-Ztfi)%pTW{Ze_(Vs@5vIT3j&bN`z! zb|&#gdYMtI+@8L>vpGo^`P`{RCcOM3G5d-;?-TLw7BQaYDkq*@;$L2foXoFhvTp(p z;-2UT%9fUQp%{TNKU-b4D?S7DO{snU`q}r7;k%iN*gjPE&;N37`e2>^(R z)pSH`GUckJ%GRpVLV{SoU&w!#>gV^e95jp9U#ueb!q`5(ejkMz^okVqr!lJvB3+-U)xdC9qca|F1RB*WmW@oTZg$CaPhxIbuD1T2O?5UG>(bfRG1 zo)cU1SSI??v9DXV)&d6KS&5yZG#6rURy<$&`bi2=P~gj$ZspTSF4`y~-sZmrvaj(tHvI|TK7OE8)ln#k6HUaW!mpOs z8H7v4?7bR6PQW2a%s86}?!@DyZlBMyI(kjhh_!q5ej0F)Ekq_8zZ2MRAqP&Fl=@M) z^mpCxo<6^*y}Hq|_z~$$h#JNtjZx{52PEKCYzA~SjelNWtF%Gyja!uf33b%0Tg4Ye zZvHz7R8{ptX;S$;5p%skX95a)nTM^Q8b)|tMb!;Z~xUS6MyN`lHVy@2Nk`w zBXjmLVO?o)3c&vMVbsZ(hlF?k6T=25^1|Pw%!g!Lk1wj2 z2!2_otzFay_aDDw;wu=jC=Kq_d-{}REZq_y@#YiiN(FrO=hBvrm-zi#+0fZ`tGboLXD*h^^Hr^Lyn|o%dH}4IxjM4Rm ze>{6xsIk(;0(ggdVUi(@Py6vH^+3p4G&1<2qCbhJJ|uht%=G@s-l^!VLhJ&zqR!1* z5w#Z~xQZ3>U!9W_#cu=^tq$1b;VUv%#}DuRKepa7uF3cR-}<}yG2n1lt#K? zBSgBpOC_X3k!IBBZfR)-jFcJ;BL?HQ_xJPo-}~?W9;<8DbzbM|`8*Edi_G$nn@;Z| z8Injvrbo)`zr21*>n+;5RGZXm(c@lwmLG61yzAGdDH>J+(X{tfKs4k#CO|}*pFMen zqJ*ENK@uX+s`~~9Du=X`%1AA+%3N-Qc3wVv|8UMr3plfsxOI|6Q^*+#ruKES*{f?* zbvi|Up|x*tf;oiwsh(IEg3LE7FBCU3Yo+eOH=s#abo~4T03mOM`)rTEb`U58l6vFe zdTl39aUmKu#m7k4N9QHmbZHzD#FZ00GG#Y&BH5lVNI=_4Bm83LR3`v(s)TXuNHqz2 ze3SSxwPkarP}1L2buo2X+Jts2V>H9&#p`%FDHr{kSm-f9Bs+$Q_p*!>7TBshZEVGi z9K$ei|3JIro&b-TLt}m=NopPgyl-=LYS=$ydj7SDjO+ z31TYyK$w9}Gff{@Qs;wkq?Ru|pAW&l;vOMWg||%XY*#vuaThK^V7ml+uFAhhhC}=x zN!^-Atg##<8Tgy90sjpmIJW#g7x9oXf=jg5jCNEE_E^y_o)RJBb?hJoQZc@mNUD0yb1B zv46AYAxP`>*>~$ZcMHU~XGugnBZ#qKq;hkSURa$^&{AU_k(QFZ@AZ5B%|NlHBtfYa zV@iTKW3(n#?$FG9lo_M}`z3=B>JTMky_E=(L(bRZEpon()M~VLUH^3(f`j-m8zy!ooV8W>4q78E!sH(y{4-zlvGbEXkWfusv{%+BFB>-FMEdf5=&B$ zzLoqs;?fGAjm2>(zLR!~VHBb5mOZy3WG1ZV3!|@dp0rK0$c$W#Pr4n857|VZ&V&-8 z^o|msg{zAm%3}24!UWXB$vTo%Nenaid#GcRa_wFAO?fwH%jWgT?2NFx0Nu+>WlC9z(=nEzLt`EYbiqdQh;`t!&Ge{-Sv{u1aH=IGvcK= zP+q)hWkoHI2`STu4kZ~@2)mS6qmu}#e*{)JwMuQ>{PDI3<>bX8-F-N-dqWO0-^iEz z!UV4@4h>G`N*v+osriTOg$&V^X;9!Umso`bHG(WIgj^auaf1y!*aK|qtitN5jN|xr z9I7oiW@(&7D-_+qxbJ_CA$$|qL;|F|B~FySE#Q8%O(DT3?O8C!I;G$^grQ+i{Ea;i zU!!p*VEY=ag;`}xyP6d@Pznr`RzVsCw%lO6uE%^s&H9#nC*y;HL`2o=GLh`MGH-$G zUuVicPWL6BX-~D$IMji%qYlB83u}z?lg<`K>&m|7V8mx+{&d-)(XMz9=PHA+VSsLh~1A` zj!Y?1*fzA9K)OMm-kqJGr%-jg!DGRh|8~1IoY|?1hq-%IOWJwjpU*?NON2f8Em0{C`O;5=Cns$o^vHgiK+(GAxZ|+ zX=ACM+*$Z9Fmdc5`i!(zuDyx78jp+@0I=1x=~rDv=RVtn>7G_f<`!Q!7%OQF|5r z$pB9Aj9G`2v^3s;nd}k^Cl?PkVisYKl1u+MUp>?xa>}wO9L|( zf~go-!d_m{&8;&Hee0MVjjE@iqm(R4s;U?$LUIfW3rS%!^ks?$?{5G@MPXY>m;%Jv zRM|}GY_5zIpq6_0#71#H*fe64)E7<7V-b>qC;2H33JVTpuAfkgd_*-K?tl9_Op%ri z$%wUd+=_Vm3y&+5;7OI6VDJx)wAly#h6p$IB_8~dNOXW;`G2cdX5$9lhH4-2z-rg` z1|*;E$aF-{)b3nb4(OmX8;&ZKGlqd}I0aK>kwxjnp!>cJITZP6pu1~5O4L)17TzC% zq6p|zQC~r?#dLOL0nL?GiUF$4M~K*-*%MOgKicM2p^k zD2z?wX!cZ^KLI1*VRWp|1$(hoQ&SHB zDisi-w5Ty81;L>@yx>KVjLj$a=MM~Q7q&z$%b9X%oOsyvW-s{Zd$I&z&g8jY8O?w8 z{ZJt0s;tsRbACMH0x5rl}`AKb{MCXLS$Pmh@B#ENL6z@wE2KBlyT<6(+kb| zmvy2o*xe|~cyIJG8ax*W)_n4sT;fVO>~ONH z^>seAmeEv^lEal^&{T!}WV1Mm@Y3yhC%q>_)l)_IM%7xs5$96VGeG{WG8DAZ@App*J0xS)Dke=1^ivxYYeg9JBE7kU8_Q+|HDo5Z=O% zh9H82A=_cV+1CzOzg&>-VJgkQ!M=}%2}k;6pu#ZK5AuEJu4 zuCu)r6#j9KL<5!CyFD$JI9089z}&bhM0VPDQD}biTAdY=+i_6-H97PQ_d)aNRKae@ zC=l9#$2`}aqE@!lm!BXHwJ_GrD%~mX z`W$!tH@&F_quxq!AL=GunH{5MOswc}ZE?^)u7?|UQh@T&LHT`L1i7;;zgPO2TkQ+| z)Qxi>&4Q=amtsh+xt1TDr(k5wPa6cPe8S&&t)4|rVzPYd2`mfu46<|KBZQmKez@cR zwUX*Tv=KXOFjn!o@j-{o+-t4$Z}yYG!{R}Ge3ar#`ty|e|- z#rGZ3|DJ>IErVK0&S5U$y%l>lcIcW0vd5fjT}e!y9FP?;xy0!xQt{jDImR$Rz!-U( zgKI3tGyjPAxp!`GVQ&p7VYojAN)S=}V}2jVP7YCkPx z;+rj;10uRk>-{VFtBC6IN@wNMKM~d`*KqD{IcMQL0jRDgYs8{Zzx0JI0N*v_px*?5mBqC!mDclW+4-SJZgeUdDGxRsNBeWE0_fbw9n01r%2 zwwSkFHvACITLytB%<8#FxYuh!>rs#m&x#>Zq48~I!&b$6o~ zsJN49rTOy2>{z+n%kZd8xsO3=FEavSAn-Z4bHYpIJ^+cOxIBGp~dRo&zdLuMI zQy_h36Vp__*|{%^cPA?osW{EXLdz0V_e`7V5_1SnA0g`2g~6-{H)Mi`TII?mb(C!CQo%H*hD@8~N$B~Py5$SCT1~8`O{_)6Vf5RqxcKi3#D$D}yM4VU-@7>cJTHiP zdHq4oXHFFfX4t)T@Z(JOYCSi*+g*x<2lyMR{{YKh2JkZ??a%K27F++MJf5_jn z-Xu=oOiU20J`#7yUa_KDS5SvQ1w6}MyE+rmH>SE_u+`q4Y#`Ns*v|+orGs#8;34=S zyj{cw>kSXYwy$Nn3q;8oP-j3{gD&_&;h(MOO_JH=G~Cj%OC7TXs&S|{27kDar`zHGIX{lb;N1f)`j1&LHq+9->tZ}pk)HC>_=CnS!{=sG~gmDchuD+r1@=b%+bN3~b?aud+Hm^Cfw z+{@R9{!^u<9G}dIx~e^vOSMcNUj%fq1`Y$@9CIBVoPX{cqZjh2sq_)q!dmtMmbSKV zZ8h)Yyqjmlk!AcaMXncT40e0i1A4NDhRdP)M-NBk&;HOWd9uDs!Q)>gdZDeppp~Xy zEl@c&bK4XWVB6tJ$wLTm*Vxw zxlkE_#?I`x4wv5>JJEht1?g)LXWbivD|}1%=q8C`vM4tR! z`>tkuw^r{v zxfX%|U6c3V=v9Zf(LjsFz{V?4?6))0;uh`U_i+!E%>Df51&^TMD-#pC8kBqb3guTo$t3$w zqUtb*f}o5LH0!;c4a1t4=*O@_05w;(1LbDHE^GP`gj$2}(;)gt^HT>YsyL|AcrMDH zuOGH$3yp!Hn&oG_FDly=F%N`K1Ap3T)O6hzP5{Pj!_4z)eI%^1*m&J~nLrJB}`5HQlJr(_OeQ-|NzA)5<$1 z)q;kzBPWRZPxU4%l50Qk*>HCQ2uivU7=$C?FZM3$`TFOd4a()j3t$_oYYyp{mp^I$I7V;p$L7=L;oa~j?vw9s zJ60hz?YTGU4g!)!;`gn0^fTJ!CmG7SD@Q~t-tE!Hx%IK20w~G*w-nj%`HL_E6d}8& zqHC-a|FqUkeZKE^VSqlzYKZ82@^bIe2^lHy`ypqb z`*X^|^1`a{ojQGXcX0+?EZcpslBE`%hg*LX;{7mkd%(x`D=S z4qeY?nZ*ZCDqmN(H)_gyzPP+6o3iTf|8#eFz0Kj0!xmod%@`nj_SZGs>9#^+nSAdA zK_&J^qv=D6EDo~2Td*4X8pl|Tu(?U$q?mg0?ANc2?~EiDM{mC^DrXkN8!b@A44m4u zkDY6aCB{vgB8krld)m(h2q6t~K3Ufz#${=YsSGmq2?pz?a@L)@bc5AdE-MaCxfN&Zi~{JON%5%>+Lrcw!+T^IXfSf4CeH#(n3^_7%-y#boK(5E5$RTy%v$p56)ekcQM6giE1ST+ zj@z4MoY!UZXt0sKxZW_)UulnO19*po%UK9j!}VL6{Z7SJ>8?y9w|ZS?sa>R-{g2=C zOi1)x-EeFR*)c*l9}ghDr#JJv{e6mj4EwB)VZw4i!87MTS;D0RUda05*2?<4eIY$T zXo=tvDYP3NK2_6HyFY2yWI;BWqA9g;yq-?kk#~?MkPuT?t<3ZEJMSzP$AL8rAWTU7 zkgP;XciRaQjbp#(R^k*Qv;HcHnvmX%?#Y&~tTU%I9eAh*3OWE&B1J|Y_K0Ijw4_!` z|H}Ma`4I$nqF*w(=VrU^uwrc{GD}w6Aga!`mz^!Sx_4O86_CRvA2FQZTBW~Y04_rYXlpDM2L%%Jg2ENU3WZk zi=dl_P}Bh8?nUaco`aui6*P~;p)o1Br?5c3PROi{vP2_gNLPb#9(V~CzgZ7(6W+1t z^cRmN1~M9*ql~y)RF5cp2m=(&y=CntVpEB4QMFDJsYKO&>k&f8Adjz}4D1prpl}`H zpw7Q1ITw^Y5eU4(KC*$N+(b)=3nd%phjTWB9fY$3bQ&^7>CUQ0A`TV*wB9a!O?v6Z z35Pty$kqw%l}5fzkRvtfD2W|8h?b0+L_dP7ASbca2qGVkiK@U|f8(Kq+Da-WcEW}0 zw>t^6`4X#=q|S}0ihF?XkYd9_R0I-6u@Mje8s^2}L$r(Toc;m7_0-##yQ~jSKLFw+ z6X^YHNVNdR_7=#?v%K7}j5z!e9d^BYbcXmPsY)tW_71$!A70|_I>U?n1^f2c@8Pzt zB3G0jQ=|_m%&LltjJ2u1QFy*$InUJei__?F zQ&w0ky_sTruL6I5JM7=N%E!*vb038#KC{^h>D<@O8y(-_WBtxWpRZP_nVoh^!T8Fm z6p`$PZ=pO9F@qyX^zF!{bG8f4H~mWvsl1r&DL1Eb znF3Kft4(D@6W?71)EswN>t(MX+Z%lT-xC)qf&?p{EMsc>GV~*l!cT=mRIhI~u!Q+U zz$p@{^6VRK#>v|C>nd?P)-DI8R;-E^r7SG0#b~*qwX#a$&4Ju-0(73(j}*E(qUO zq^sluC5nzS*@c%Mm!uoMbZf-;vc9-OBTxE8{i%mGUABza7H$ayxp*gqt@~qBBQoge z!u}^c>DPTJ{tc7_%%NiZ97#s6j>NppUc{PnIx`pGaVoOD93ZMOy}j_${~}w^{_QG;(1UMnds3~J)Xi9Zy8+W5y+o+K~Ne@^No^U&c@W@hz?w zEmE)usL#gM#N8~mzR0AdrJ*&WAbZTgv_7k3%d!C{tF5<4#Hk0$5xUW|XfC?}{gDd` z&~*a9`jkY?bT$yxbxqOj3{%1M_WOHLC;QUz~Cc4Sp>hHQA65s`HucPdL_>$RXgmZOW9Vtf(mGR z5zGoAAny<^oRkoDe?Z+`wTQ#C7VSK(7Y}#kgeAYMq2MA4;8#a>!G9w%DP~_56JvHD zcWLVGt~0F;vERK3_j>T{Vpz-&c<@f;;8BfHabMc>swk@kMDwIX%G*&-wtNz?oHbz7sEaNE|b&Ul{#586L7)lSB z)6tqY1}#R+PRfzZcdE3`N1K;*smH_0V6C_;^P>koa=9*{p&9Jdub2xfk%O~cZhkPu zw|&c`=?`c4-d;l9oB~?{F#28U-2IvX(*6i}tp;7*@g1u`^2%)c&K9ge=w!5jYzKKx z-l-NebixmM4sC+8F&ET8qAuKCg8WyH9bYpJ?Tx0XR`jf_=I4>CS+;0xvHJ1AdA}At zkkuMMv6QEa;>u}V?NB;O)=K?U78P=;p0C8Zpfkv2YQFrK8VRf|++Ulst0oXDlkx#K z;cNW708KHO_sHKb;~%yvX_nG%*zdJndvG-boezO!guV^!DA1!$#% zFdGE|KsO7DN$V1TaCnnd-gaLdhQ+k}|l%l+d44^n+2Gy;i4uXoL*KW7q`)*X8GG(%FS zDdX|%!+W@)R}9Y+g3d9$%lY2O&Rm`tEMXH1SZXGIu07<(L*S;x!giT`Ue??S@_Iuh z5n>z{s6KcV>FxirSykLcjxNUKI@;9w$dUEX)-Uy%Idd0|Bb!fk~)8{9dNd0%MZysV`i z!}tk4Cc#+Z>;AeI>S$9tfs6|y6?R{nFp<+|ef)RHc2eN-mdIqmpb=aIY&S<96djGP zY;#P;0Wwi@K0;?F&!yu)NrF};@8_VD$xttM+c6n$wdl?6+Fk@z6$i0dd5W6+8fu)%>@vdqcInA| z;dlvd9xk*)^Xb<2;gUylDJ*=*Hn*Oapv^FX%{fcamgHW*_qpPe$v~+Wy)OD_=`zx_7nss@Ez$?L`5r!unM49G9Mp z3&&;t7jJ0Ii3F(0n4*XZU-UFCr`}%{sE$vmU;9)RImvu6i!r_I8_O8%b2p2b&rYGc zc}H7Xo36g`Q%3Ps!v{=8Ua>Wz8PZ!~l7xRbQ@or#f}FQoAH&q+svJHawW^MXany&& zi2GhSGf7C4HkVfIe%!KNGo?AZT-LkPkHWlm_C`uVuMbG= zEL@5f%DMD{2-Mahom=X<;774uf0{`C-H0@1M)T~drUqJ^_{kE@VyOz?BfFfn@w_Eo zMFZKtIk5CE+~4Ca(&tiXX3p{34nHZO$pq`mjMH0FwJhex; z1yB&R;nnK);waiBnCV}?$QFlw#(;ewPoDg7YU+dJ%$DckCmr}Xk|E56KsY^|-s|3} z{OlPMP2gfr0a{W@MO!)FSDkeiol5PTDamnQLSMl8LK10&{Vm89(OE4`hRSKP8K73} zPZJNA4cB`fjblkDq@1-@f*beD`J0ztw_!>5-yc05uBwxS#mU5tZBoUUjHEX{%Uqz&WD-W0Hz#0v5MV`3G!x?>RyyE4N}T3oooBt$O)W4X3^)XQV&dv4|xA zRF|53oo|aFQY%ywvnb3GD;NuBllUX_?qlc)U93!ha?IwxSqf}9l2|6B`FY<)2%AG~ zhy!rHBC3YFedz;6(V3~zqGcUSd24>LIr;&4Q;a^VmR(iSQ_tl63yapDEp^|;W83+n z*wIsfaBWAH3@3Rk6jiQ33{;iLzdcf1BbY7Gp!^lBFB1K!>a+f){oZMv1rG^*q?sGM z*eULmlpelQj#=H@M94Guuo-(?e$NR?e?TwX;l2o)RjC70A<H9qBb>+YDaeQIA&J41+IBXnPr4 z(bTmMk0FghQ9m?H_O-%bMQg!92d!k4?DDxM?=}2cAoF`T5Z9`v&s8a$;5E5`L&O6- z9tT*WnmG)QV|u*4zJ6GBJpf@U;{%v2L$QBQ^oQ4%DI> zOA063ETS%2CaDJZOnr)r-Lnm)XiKZ0T1eoG zwtQy6y#JrudXqcn-x?g+{rhvb_X8-gK*Y+*NMVT4g)P&SMt|SlYNXOp!Gh$hznl78 zVL`Paq(`Q00WJVJH0(<_D}?e&-}(>Q%RMcYi@zee|CauaP!s?8s(1xvj|>;!&wDj& zR02fkHSO{p?~AJJzlOTTRmWjwKK;ZP0_%jn%f#5qD#<}Sun&mZL6}I|5TD~^_$-*j zXY26(I2Drus&7Wr%t=o@8ZPEi#atFhRoUib4W%S{0+?XwH*fr?XutcCz}7>)@{J3- z!rFSLMvdtrp*XDSdx^2!n2+z^m~&R|0{9!n`fy~V`jX_1O6+bX2*>QxSetd+GcyWS z6T5eFYQIIaCiq5q|v4mhc!TO87US$HkL$#{ZchZV4!p2nGg?SB%%-5{@@B7dd!2P_n zaV3Hb(c@vGR+om9lH1=LBzm`JSowPDA~Zz~;e6KkAk`A!b4f;ud$dm%qWmo2%zetl;L`6pr>@ zC^4{r9FP>#R}v0kX`KI*^iiXfqZpdFu=ukEL_|_MPU3a4Kc>@E7lpHIBa7OkCryPUkR3gE9GU3 zsk#Wx^`3lrbL+Ji)6`9$_k$a?0_;thlB=1@f-r#H5f?2hADKPfp>XLzSBPPOHjXNb zZ?bqJF<^>apwX3Y9We=pq`_NCfM52n?hCuy+UQQZ#{6+HFt8cSg*e)M5EXVfB(6^h z-uqKa50qCv@-U$`SUsqq;m=i&;ipZ4D_LRZv4641D~)5kr_aEic>Zhd-lG7cj0|^t zTbCEibAN^PVUX_U6soTMeZhK9p>8?6Z2l^5izCSnx>%&z7J`xD*9e^h;;UddX8%HO zUi3gPGJmxT5;qM=gbfx4<;)>_f&i4X{)KI*P%xwK3Q1Mq#7&rH`Y+rM_}KLp7e~-TLLfpB#IRy-JeJ*N1komeXN5n5wrP@%Wnw~wATAxfZl4E+@LjrI}?G70AnbyIlxi6xWeBsucOpP0l znH#Ja8N40D+P*`wHx?3l_K!2PnaP(^F3bM!Bt4U^@i#IXJ0gPKOc*_;)^${mryGEg zf?W@3#5YOk$LP@fW_d02i*amax68L668v2a)*b?5kX4H>edMb0>&8E{-wF~Fe$y0& zWV`L@bGiCv*^%z>|h4-r;wm%(BffjSUE(lOCW?|x8}_2D;B{& z66c_qJdv6G*hN#)l4L3N!H?%Uls|6lGpMkNv+$0%Ifa*9 z$SOCe)nAvqQhKKux(F<`@6I8C@n^|=$cC%XNl?zuqc4aZ1(I&JDl*27qjd?-^LX|y z&59mP5^a62~ftETy7zN!h;Qru^1}7?>H$Z#?yD4{~LCX2KnF&)?)TUwV zQmz=?n|S(6O^ZHW-_5AVCr9q{U2~f|1_Mt%N*BDtJ$W4QX0K1rY_vsPP;J;42?5oW zPdCAj;&M+2WZS2Ggq1W-1%o|lX-90jj6sST^0Tw84D!UeDQ=AL5N{+`Gw&I0`?0otI%+G=f|+tZUL zYwP=g_otrwK~SA`R+vKbT7Dhi((#f(aI3WzV1AtUNZQyR(tb4nmixeDqwz=Lm7&cp()>oyte+ts_5E{fN;v1Z z?JSWLPyoPV${|^n)#{?CcwgwQsbKOwO_!f@VE5cf*v_vShNn`Ks3 zaZikZY!0LZ9zRq}4x36reWZU{B86}n$Y$zLw!)T>yY9_tIrmWmIrpuJhyA$rZe!f` ze_^KJt##BtQTIg0)(>-B0Ly5-IHpRhCmLW7Qzy!wnP#L}aSxa#Lh>psEfV)GHz&pA=e(BwKJ6%aM?CY$dkA ziet167y9$rBSYR?jx%a z=-USeK)zBL-@oO%@`gfjT@m*`Z2++&Hrq-nZm2sBSE---QM|0xYbGZ9THY>fK4H>U zIDN!4Ja z^KI?uC?W{hpUSVSsSx3AYf=aeF+sxmcc3cuqN^KJc zKAaEh?petyR~bMpV*uvfls#cmAF#KHa2b6i6ZzTCmu!l5{;B9t;>M3pEq@6%GkDm0 z`C(F#q3@cIflqU6W$)I zg@&1mXm9&D=Cpg^V%F7mXkKN-H-t7Kxq6w4Jm{D^s~ ztUpn*ybVA7M3|efx%g{;wke5T=ksxs2+rG$%mNQ@1__vR7io< z4Hq{PvMjELE)_wzKx-f9-~%1N61=sVk2ki~4`56l3E6*7aD^*+>FlY?^JKf0BSg+! zPk9m5Q4!>`s{!kfy&hJxXZ-*1gL_k})I!{@FCoD8zl;lkCbaAIKffJMfH_@^Uk1PP zZ@(Onk@|;};a*2k2-1Hk7Mei9doPwZ1)MYF&O=mnKNd1FT*Nc8%EHUBTTLfL} zuX;|&-;QbrhvP8$hH3{Kmj6CG>nGVB7o8fQ(3T5hpFs_%68?)Yk+ig155|{Weos%w z!a01r7!&g7!{cU{K9&0#j^Zh?Q3zPV-Z?&dVG{b^tcvg7|2%3@JT144#oyVaIQa7O zefjFmpAh)JMY2Qo|$w z4FCa>(t)C}%Q5mwZY>9+X{s-IAZHVvhSr0Vy}kMS4X$0p!xJ*?UKppQagPK-!|eev z-gIych=M65BgzD+utX!f&~vB6^cKi08RmDm!%&5SF?|_ba3!4p@5ZE5Z>Jvs@^uxx zHWndnFwoPrc#1;uW+jqlHToOQXE+&|NJ;8N6CuQ z2=kI_5k&DQ!?Z9z@J;(=bUgTS;PR39idj^F_5!D)%L9+eD8STcNpbYJ*P2{U=?8sdO^3bDWsrvufo!fANYS`qqt$jFp}F_iD)PBlCS+LHyGdME4(zWQvHgC!^a3nH%_Wi^ z6DRurB}wiGeFmtakh`V1vzY4cy~4+G8%#?t(ciB{K!Yzi)>kBlXf7HGn=C1UWd@V+ z0|ddlW~797`uzzwa)Z79^;(TlR|WvJeS=N_I>)NVpV#9IU&EXt{)*Ts(Q#4#wEomh zX>Kh0QWCGf{+qAYdEq!FDk3^9G@ramNnigB06JX?mqB#`PY|IF1?m@9<*Ca`KUg)! z8k>VqGG^mc|EGrl_mkSv3%{ifW)v!uzMzGk-iMG7dk&y8^S8!b zBsCFRQ9S}=AJ0_8zaG}hGVFW~wl`Ulc<2|#8sm>6V^!?;P6aV?w(*<~Zy>P~gd**& zzK{^HEY5(ZI$Y66V5-e_bG@%r!2&s%Ytd^G!Xx$@9z(A5M;k?=Q|&x#I}U_&o>|t=UK~YKL)i#bja(FJht|iXy^MHBwREE zoSaM*B!(&^msACp6$Jpecb}fZUbQP54s3CnEj_OoE_*IhK(*pqKk6VbhZ>I@5sbOEGLDHrs$O6B^IE@$Uo=*!HXq60qD zd!#h6&|WlzebGqoWF&J$73H2-xmYUGyqZqw_A2y6mPN;WusI3YI(38@0Fb1n+eR+yuB$>{GOwM)Y^> zC(T&_E9Y51EPQ%4J$-P-_(3FV8ME)1BY*4>IyK7_OpLiwkoxA+^S5o}DSMAt8jK;>iq1RBe5H3Q*J>2F2<_XT zcA!(6D8JpYjdk@T$%9teF5!P)`hVa2XWzrL1^I#yr1X3FYdwX&vuGmv4RiGr6sVI=Cc`kUO9omGl_1DEZ*7<^Ox;tAqpqa8y!SL+S5Co-*M)vz7|A106^Nc zg&Ps8eq`0oDtt>MVud!x9~eaK2j(hxKtV<$jJSp+*SM!;Q$B?nw~dj_b)TC2v@0bpFUBdq{=QS$FeO!mp%=Ixa8gfvFIDf(2!g=m#&{i_BdX8t>dhDipdkF=fb70;PFJt!31bCygQrBg-eS<)Fdd%S}&F%I7Q7Z1mq zxTfYS#Z04Q-6Sgeo&U+Y)p3JPSz^BJ^p|UN&mQc|@AFSjj*kzFq_N*^bjbRgsF|Ca zj|(_WDj)!>-VmTv?gRAck@=+a#+)ZU997rAI-MX(7(5{CnN zw~VQH-n;Hl4K9|x$AzD+7d%Rx@SMg_nUmD+ILIT6m%8PEuhc*j;jm0?`UK_okFm}) zy?rno#pUQ)uzpYM1wNJd_=yeiRVx`Si`QEx_H21N^6JmG&&ngObWvu8|DR9(V&B0i zHfY%{vk3JV853lwUNqGe^QGJ8iZil*- z<4A}U=JML6J8{E{agNA*`pD@39M-GWc+&exl$-Q_`UzM#vZt^E8jKLo)YLpfrC7F; zB-eXZTRlC_2Wf`CAUw8*He0rn`!`F$U~0)#cZ~JGOD*$J=6jZwCnUmSR!h*?Wc0`mE1Bd*h9voZbE$A?k`IeF+cK-|h!KIQpd{@dEDc z5fQU*>NpZpidq2sed1-U>(@W2|HnsvhRDl!22$$P$iII5?XIaO7jUNt)pzH5^1I&M z*~XRW{FnD`$*H7Z`sI6!Kd;=1u)DZRV;{cNzP-LUb{OL+o1z{U7)RZl_&{Fi{jt(( zLyM#II}dUT3k!Gk#{DNK|9xFj0&c7}6s_sdkB2Y9!m4SL`%B%NyJYRh>xZ_rdQ#RW zN_sFnJYz4i1po11P(<{yxB5|#n&<`fm8!snD_3^U!x}tuIdNs!%KfHR)o_(R!&lb> zzYep5doKyM`9pai7$YLW)FeyXhwnMGoqM`u$BrGF?!@s1xstG7XJ*0a%VWy5Xz?FJ z-CQ&7)2GuaT!=*Cx{ITuV^AjBuS3#ie;+?MX7|IM5RQp2_46bY_~huEmCJMb^_GJV z;Fg)0XFUz|_4}qu*d(k+C&;8_naLTY|4hrz&oA-#oSlr5a}&pZ12bCCz+!u>m2fUD zQa~`ud9Fv81Wx@}KJm-FU2_#!mU1sVoM?(;z<2udWqQizm8PJCo=)lY#;Y02@Wq(^niqVh?;UO`umR>F5(MZbNq$1=?uJ(=jNJ1QTi|6 zdKvF^dKRjx(0_PGf zyK|rIrs6Myc_H6g?DMOc(LR0b`HLYWG0Z?}f?aPFt;wnk52gN=$nwwpdM|c**AtU> zx6K(szM?I84dw=KHT~t=mK>Vn!oxfHeu423Y9zHVdr5lTH3?Cggu(uasWo4CqT{Gpd(nb0u z-c9M9nq1(atkqzK!{BTXl1N5J&&Z6z-{ycmn`9FoUmpIh`+mtahJW6D{{e>Smj9Ee zQ(B3(aP4L~c~nM*O~Hr0s%RqCU)#`SM^p;Ov=*>@f4d>1YJZI&ilH&2xX*Q3T3TJX zcNyf@zHO8JQOWd>3Wqs|hdW>H4Eo0f7cVa;UZ8S^y?pto*QT6mW zrgqC|G&Eg@TPpdpIJ$W06VCA(*09^}V<3StrayV`%0HKJ^!ud?I0o-!+GEY(MxD!? z07)`Ea`K0{YA8T@SPO{H+ z4-dIm9L2@c_{5dRRmP9%#`ZLd{PAVKJk7g`AxUq`UrEHeHC84 zb1BmvzkEhG5$5OFDZhh@D2Y-B2RuIMYV?ywFkZ(JZXaTH`F9t_xkjZ2fTXYBR&u4?CFt z{m8%b@?Xdp?w1h^y$Z}W*M3X^9#?1LRl6D^?Wodg{n09TB1Y8W2CKaLLlSV^nyd5u zZLF4=!ph;y$dtVncax$&14I6jvWlZh&~eI2maYx^TlC~vtgr8yQGR|{xMH1GJ@$2@ z^h~D1Sg3YNj!Dfe%pf4}_ClM%yqWBe;x)#a z6sno+}jRt#Cn@b=ms3!}rg$5;X}wZ&&@c zWpG~se9{5R_Ed;n;sHlQPR2YO_PrLn3iU}NH}0d;T&rWJK{yof4Wj1F@uGI4U-F>v zAF~LQ8jldY*Zy`~=O!8(P0wL|7#!3$7*!%9wPc7*M;W+WMqf@nJ0AF!v+&#Fy(yk# zQZhngobVU;P*MaEx|HQY$S&p7Js(57q|hT9Q#KRx|ZftjcoUkh1@22 zJ&yNHnQ(xnD1T1nfrj_&Oee2?S%p=<_aJHqi_X!)m7qX3bo-N~)}7e~Z0r)&eIN4B zq6A?mlKI}kCe?XgHqKjQvfb{d>7{>Xv?EX|-cj;oDvwfj?{%^8aZ&Z1WDnBPl_xvt zqLN*>Ae-h@(94L{+0|pHo9mv_?P+vsB33i=T_&eL`sv@6Bwv zwG^Oh_mwBwN-DwBBRSMV;a=bj)NjFt&a+)PBKPM8L5b7;3QHo#A0*Jdv-Yy0?ZW?F z!`>{-9^7^Sn;=G&Wm__W6J#u`-!0biO>97p!K2y8_6QsW4$nA@McL?3+nB_T0faBqK_{%pJExx?qo&xgYYujvIo0>&ozmsL4 z($7f?U|f=Tk{auJX0CK~z%e$6g#<&eUmoEv>^mn1z$-sR*DAlG75+0C7@G1W_$w8h zHixhVXI#mX$qP7}z^d(A^G|mGZe`tHoD2tX+dgf}DjaZKmM>o>=%wDNc$%zdQz|A$ ztQWE9`(SHPG!~}%alY8H$28pF2KZ8xiSSQ*f3lt25U-6l%vV{e_d7LX56#>OHgtCy z(a^?+Kd0>WW6!PMoQjC&+WKM7$;mD2_IkXV^zTP#{+$G_A~&~3bmBI0oZwli6U1Q+YiI}TkfU#k*q#lw!I_Oeoc!L4t4 ztJ;h?q~o#N(MB&b`{I^?V@G?wW#jRa|`9u_gX>R-YTQeFHTg!ws|4Xtkx+ps<+cGw$`iSKo0_p{^TT+U5W z`xiXJQBu^6DpZe?w}^2hSQMB}^%mMpw##4cT^;4H^}S|{ z(`?s~f|UhAmfpQ9{AADTduDd(NPH^THmm)}gx;JZ@TAaT?E*<> zkqq&_yT^ZPsXpmnhS}W!nL?Y~YG1GqdalBi;jL?g4jou3O!)kSHWhj$g4beYiyA*b z{2n`?Fj5nw4*B*fX8o%=*7V5MgGYR92o@tn_rj#V5GY-0ovwirzhTv+`gvN%luZz) zTr1vlIb*s8&f|8M9t+XD0f2z`VZQ%kYGaH{s>GnX2n@hJEj|5Az73?4^(LHJMt1SN zy|^=5gHbsOcDYba^(=Gm-0F79{v`cjb>0J{h?>MCY z0^zoCovT}y_Z+=|6X`Nf47gG5Nj8%-D75asCr2R^mW9rj5F14;T9ZWG*S?DyS3VO( z5C!08YOI`FifNc`iWJKhB=Qo!+B~@LmcDHjmdDlXR;pTz-6WU9&&fR^J+hTU|7%5> ze^Vf=&c7)TndtxCFypQbLif>irTcChpx0=pKHHkSl^)Afo-mzbSs#Y|EW!<(u5=pV zJfhB-*6uUnH5`e5La&mMRe;3;wr+dMW*wo1AupkNp=tiuR$OU*it^m`VsPOzF%SOQ z4S3Rv@`OSD^0uA(E~A(=Jy?#zLg1S52slh_i=&7_!z_zb)Fi)gwiJ-|R5hE;)gXD2 zImF!>b~5{xpM_lUr*xBl?=4i1wkpttVlcN=fk7?=Gy-Ar>!ZO-f9(|_l z%TiEL&k1glD$*!et}Hst^@t4yama}0d!r&}&>33JN&^fkclv2&c6K>=kF2s$PKc`)FKAxOriYJxw5;f@1KO?M26 z9j@%<&%4eU3-wl(ZIJBZig}?%k(dIZT6CNLUUb#@JKM?jIu5ODw+vmz z@&J(#SXYO9xW+f2$}ao7{?U2CP>!90xV7r%uMs<36?2Q{i)UKl?x8+ZAe2)AIb;Z| zMix_pMfMXrjS_FHq>e9LoBQ@g{_h;^n0nm4Cqmc+JL(ZH`ahLnuhQYq#)Ft(lvp8u zsXd`(h)s5ZTtJcLC(aOI1(V?=l=2L5?u%b7;Jq~WAM_j{zcF#&JUYB{>$xoq8A!Zp zuOrF;kmyD^ljt31fKq&sSHtO+gfFK^bHHZ$G@HS)fC7R|W^lg_LFE${|Bq2^C6?lg)2FPj%|vjd7s z3sw;;S3Q9kpEbCC(d9Z?;kQ$xKwy+|=meeDSh2v3{DUcG! zhakbfYg~JITsZ|yl7|7ULqpEnt$XANKc;jqL9co21rG43MQ($*DtTiSKmSMJ=|-s7 z-Kk%W2PGX3l0D}EwQLcweGW2XZge?B?{A_w^r+lg2=>mG-;Ynp>)9&InU6145>?A)>I$r|D(+B_Dc@MVy)KpBGfgFBHNB46vjB zE9T){-mdGDGr(Yw)IUJM?=8bf)h%z35Mbvb@tV5GztC;n4M#^##!P zY^Bdn_K?t<-dtmEt|l?5NsWad=2&X;bo_di4HYhGtgtoJ&KnIHe3)08Is14PiUO&3 zY51{jS}yk&aH``eI$I%W$LV$mTU=u`jHG=)QaetyR>cJ+R4zyo{`C9vkf9_ke`to` z#aY3s71MI^N`?sTz92;5xaoHyYTi}5^L9NN-hTeI#$P|7mwtQe z@*v)*+@b4^Plt?^Xqn{jv*U|SCkyy(A4?XnF;ct{4w1O{xK*+1@EcQ|yD^T7!p^2%V<{S2E6M!#iP=^)*;(IPO6DRu7U}iQua^MSzb)JHc%?^%xl2l}(;~V9i{V zp{|xwV^E-!-Y{*sKK#aqvo^>i?o8m6N%@(TqG6p8w2Nt95S&r1)XdOur#9rY7INJr zaV^WN{97HMSy?0^ckKY)_htuB@v^9dBL3MnzVEP5{B*MdLsl`M#|$KZNr!YaG3eF@ z{lKbA6C6@@e`rlK#t`!cHwISIIQw2p9fYFGM3iN)f4o#$`~cad zHSYN^^d9o3y|&T6Ee3i#9f)rO%f52TGCmzlOom!^2y6Z(T_f>QMz8$N`xIzznoey*%FBprN|q^+^P$H@pyP9S17qap4%cl^Hh>frRU&BZ$DkyU8P%6dX4B=(8wK7K)ZNoQm@8-~_J zW7E$;09TXInfPcMt@ww2Uyj9H5^wl@pS0}yF(TpFp{3oydWbesl|TO`r!#w;S%lg}M@0f>pqi7(#`QrbQ;0r|y;7@aesEQZ5D@#_;4p1M(IYL{;YDxB$k z7L_3AFSpWloS_$QY|T5Fsw<k7NgqOolk^=_o`a^{;_rf&`;Hu*_%UyrMGMJQO`nMFm^ zVX}G22eZlTbGDq+fj5ℜ=yI*w|@l-pkk1;*8*b7$Kc+jO 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 B`HBDl literal 0 HcmV?d00001 diff --git a/outputs/overfit/evaluation.json b/outputs/overfit/evaluation.json new file mode 100644 index 0000000..4b36128 --- /dev/null +++ b/outputs/overfit/evaluation.json @@ -0,0 +1,11 @@ +{ + "predicted_subtask": "steer_left", + "trajectory_metrics": { + "full_oracle_goal": 0.47031369805336, + "generated_goal": 0.4905388057231903, + "no_metadata": 2.457864284515381, + "no_goal": 4.042560577392578, + "no_history": 2.1593737602233887, + "cfg_beta2": 10.576497077941895 + } +} \ No newline at end of file diff --git a/outputs/overfit/goal_generation.png b/outputs/overfit/goal_generation.png new file mode 100644 index 0000000000000000000000000000000000000000..a38255fdb40d021e68bf66418ac03c2ea81afa25 GIT binary patch literal 51295 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 R{qkG7xp*ES9QyO>{{j)Mwe$c0 literal 0 HcmV?d00001 diff --git a/outputs/overfit/summary.json b/outputs/overfit/summary.json new file mode 100644 index 0000000..1f6e681 --- /dev/null +++ b/outputs/overfit/summary.json @@ -0,0 +1,7 @@ +{ + "device": "mps", + "samples": 5, + "policy_parameters": 1088930, + "goal_parameters": 75731, + "high_level_parameters": 652800 +} \ No newline at end of file diff --git a/outputs/overfit/training_history.json b/outputs/overfit/training_history.json new file mode 100644 index 0000000..01f5f9d --- /dev/null +++ b/outputs/overfit/training_history.json @@ -0,0 +1,459 @@ +[ + { + "stage": "goal", + "step": 1, + "loss": 1.0040572881698608 + }, + { + "stage": "goal", + "step": 20, + "loss": 0.34230467677116394 + }, + { + "stage": "goal", + "step": 40, + "loss": 0.37451064586639404 + }, + { + "stage": "goal", + "step": 60, + "loss": 0.26792070269584656 + }, + { + "stage": "goal", + "step": 80, + "loss": 0.10219617933034897 + }, + { + "stage": "goal", + "step": 100, + "loss": 0.2711445987224579 + }, + { + "stage": "goal", + "step": 120, + "loss": 0.2289590686559677 + }, + { + "stage": "goal", + "step": 140, + "loss": 0.36456483602523804 + }, + { + "stage": "goal", + "step": 160, + "loss": 0.0842418223619461 + }, + { + "stage": "goal", + "step": 180, + "loss": 0.9889341592788696 + }, + { + "stage": "goal", + "step": 200, + "loss": 0.5776903629302979 + }, + { + "stage": "goal", + "step": 220, + "loss": 0.44620272517204285 + }, + { + "stage": "goal", + "step": 240, + "loss": 0.05634145438671112 + }, + { + "stage": "goal", + "step": 260, + "loss": 0.05701519176363945 + }, + { + "stage": "goal", + "step": 280, + "loss": 0.06764699518680573 + }, + { + "stage": "goal", + "step": 300, + "loss": 0.10973378270864487 + }, + { + "stage": "high_level", + "step": 1, + "loss": 76.15934753417969 + }, + { + "stage": "high_level", + "step": 20, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 40, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 60, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 80, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 100, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 120, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 140, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 160, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 180, + "loss": -0.0 + }, + { + "stage": "high_level", + "step": 200, + "loss": -0.0 + }, + { + "stage": "policy", + "step": 1, + "loss": 51.04338836669922, + "text_loss": 44.21609115600586, + "flow_loss": 3.4136481285095215 + }, + { + "stage": "policy", + "step": 20, + "loss": 26.20608901977539, + "text_loss": 5.128565311431885, + "flow_loss": 10.538762092590332 + }, + { + "stage": "policy", + "step": 40, + "loss": 13.018914222717285, + "text_loss": 3.846059799194336, + "flow_loss": 4.586427211761475 + }, + { + "stage": "policy", + "step": 60, + "loss": 11.344329833984375, + "text_loss": 3.335771083831787, + "flow_loss": 4.004279136657715 + }, + { + "stage": "policy", + "step": 80, + "loss": 6.586862087249756, + "text_loss": 2.5455708503723145, + "flow_loss": 2.0206456184387207 + }, + { + "stage": "policy", + "step": 100, + "loss": 11.200895309448242, + "text_loss": 2.1739449501037598, + "flow_loss": 4.513474941253662 + }, + { + "stage": "policy", + "step": 120, + "loss": 17.273035049438477, + "text_loss": 2.1746408939361572, + "flow_loss": 7.549196720123291 + }, + { + "stage": "policy", + "step": 140, + "loss": 11.353687286376953, + "text_loss": 2.2218563556671143, + "flow_loss": 4.565915584564209 + }, + { + "stage": "policy", + "step": 160, + "loss": 14.110220909118652, + "text_loss": 2.3123977184295654, + "flow_loss": 5.898911476135254 + }, + { + "stage": "policy", + "step": 180, + "loss": 19.999370574951172, + "text_loss": 2.749119997024536, + "flow_loss": 8.62512493133545 + }, + { + "stage": "policy", + "step": 200, + "loss": 8.102465629577637, + "text_loss": 1.631801962852478, + "flow_loss": 3.2353317737579346 + }, + { + "stage": "policy", + "step": 220, + "loss": 21.903854370117188, + "text_loss": 1.6063504219055176, + "flow_loss": 10.148752212524414 + }, + { + "stage": "policy", + "step": 240, + "loss": 10.197904586791992, + "text_loss": 1.5640149116516113, + "flow_loss": 4.3169450759887695 + }, + { + "stage": "policy", + "step": 260, + "loss": 6.899936676025391, + "text_loss": 1.3081820011138916, + "flow_loss": 2.795877456665039 + }, + { + "stage": "policy", + "step": 280, + "loss": 10.051774978637695, + "text_loss": 1.2130903005599976, + "flow_loss": 4.419342517852783 + }, + { + "stage": "policy", + "step": 300, + "loss": 7.963553428649902, + "text_loss": 1.1586928367614746, + "flow_loss": 3.402430295944214 + }, + { + "stage": "policy", + "step": 320, + "loss": 5.177718639373779, + "text_loss": 1.2673476934432983, + "flow_loss": 1.9551855325698853 + }, + { + "stage": "policy", + "step": 340, + "loss": 4.487771034240723, + "text_loss": 1.0361082553863525, + "flow_loss": 1.7258312702178955 + }, + { + "stage": "policy", + "step": 360, + "loss": 4.356203556060791, + "text_loss": 1.3223515748977661, + "flow_loss": 1.5169259309768677 + }, + { + "stage": "policy", + "step": 380, + "loss": 5.289548397064209, + "text_loss": 1.4994667768478394, + "flow_loss": 1.89504075050354 + }, + { + "stage": "policy", + "step": 400, + "loss": 8.761286735534668, + "text_loss": 1.4391229152679443, + "flow_loss": 3.6610820293426514 + }, + { + "stage": "policy", + "step": 420, + "loss": 2.400188446044922, + "text_loss": 1.0106937885284424, + "flow_loss": 0.694747269153595 + }, + { + "stage": "policy", + "step": 440, + "loss": 9.683706283569336, + "text_loss": 1.044368863105774, + "flow_loss": 4.319668769836426 + }, + { + "stage": "policy", + "step": 460, + "loss": 10.443679809570312, + "text_loss": 0.7045990228652954, + "flow_loss": 4.869540214538574 + }, + { + "stage": "policy", + "step": 480, + "loss": 6.514384746551514, + "text_loss": 1.185524582862854, + "flow_loss": 2.6644301414489746 + }, + { + "stage": "policy", + "step": 500, + "loss": 2.255455255508423, + "text_loss": 1.0136079788208008, + "flow_loss": 0.620923638343811 + }, + { + "stage": "policy", + "step": 520, + "loss": 4.1700439453125, + "text_loss": 1.1829472780227661, + "flow_loss": 1.4935483932495117 + }, + { + "stage": "policy", + "step": 540, + "loss": 6.956455230712891, + "text_loss": 1.1096038818359375, + "flow_loss": 2.9234256744384766 + }, + { + "stage": "policy", + "step": 560, + "loss": 4.5965800285339355, + "text_loss": 1.5257400274276733, + "flow_loss": 1.5354200601577759 + }, + { + "stage": "policy", + "step": 580, + "loss": 1.7948781251907349, + "text_loss": 0.835124135017395, + "flow_loss": 0.4798769950866699 + }, + { + "stage": "policy", + "step": 600, + "loss": 11.411609649658203, + "text_loss": 0.7508105635643005, + "flow_loss": 5.330399513244629 + }, + { + "stage": "policy", + "step": 620, + "loss": 14.173189163208008, + "text_loss": 0.553065836429596, + "flow_loss": 6.810061454772949 + }, + { + "stage": "policy", + "step": 640, + "loss": 2.423476457595825, + "text_loss": 0.6566628813743591, + "flow_loss": 0.8834067583084106 + }, + { + "stage": "policy", + "step": 660, + "loss": 7.33753776550293, + "text_loss": 0.5569484829902649, + "flow_loss": 3.3902945518493652 + }, + { + "stage": "policy", + "step": 680, + "loss": 7.2152018547058105, + "text_loss": 0.6606324911117554, + "flow_loss": 3.277284622192383 + }, + { + "stage": "policy", + "step": 700, + "loss": 2.5858616828918457, + "text_loss": 0.471678227186203, + "flow_loss": 1.0570917129516602 + }, + { + "stage": "policy", + "step": 720, + "loss": 5.87769889831543, + "text_loss": 0.5711548924446106, + "flow_loss": 2.6532719135284424 + }, + { + "stage": "policy", + "step": 740, + "loss": 6.787323951721191, + "text_loss": 0.5064371228218079, + "flow_loss": 3.1404433250427246 + }, + { + "stage": "policy", + "step": 760, + "loss": 11.166234970092773, + "text_loss": 0.5802748203277588, + "flow_loss": 5.292980194091797 + }, + { + "stage": "policy", + "step": 780, + "loss": 3.563460350036621, + "text_loss": 0.6946841478347778, + "flow_loss": 1.4343880414962769 + }, + { + "stage": "policy", + "step": 800, + "loss": 3.2471487522125244, + "text_loss": 0.44556593894958496, + "flow_loss": 1.4007914066314697 + }, + { + "stage": "policy", + "step": 820, + "loss": 3.1103086471557617, + "text_loss": 0.7132699489593506, + "flow_loss": 1.1985193490982056 + }, + { + "stage": "policy", + "step": 840, + "loss": 4.753307342529297, + "text_loss": 0.48397523164749146, + "flow_loss": 2.1346659660339355 + }, + { + "stage": "policy", + "step": 860, + "loss": 3.5492496490478516, + "text_loss": 0.366057813167572, + "flow_loss": 1.5915958881378174 + }, + { + "stage": "policy", + "step": 880, + "loss": 4.0379414558410645, + "text_loss": 0.6511648893356323, + "flow_loss": 1.6933883428573608 + }, + { + "stage": "policy", + "step": 900, + "loss": 1.7170453071594238, + "text_loss": 0.2673894464969635, + "flow_loss": 0.7248279452323914 + } +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ccc1f10 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pi07-minimal-validation" +version = "0.1.0" +description = "A tiny, safety-scoped pi0.7-style steerable policy validation experiment" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "pi07 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] +pi07mini-train = "pi07mini.train:main" +pi07mini-infer = "pi07mini.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/pi07mini/__init__.py b/src/pi07mini/__init__.py new file mode 100644 index 0000000..7936b42 --- /dev/null +++ b/src/pi07mini/__init__.py @@ -0,0 +1,32 @@ +"""Minimal pi0.7 architecture validation package.""" + +from .memory import MEMConfig, MEMVideoEncoder +from .model import ( + GoalGeneratorOutput, + HighLevelOutput, + HighLevelPolicy, + Pi07Config, + Pi07Mini, + Pi07Output, + PrefixLayout, + TinyGoalGenerator, + flow_matching_sample, +) +from .tokenizer import EpisodeMetadata, MiniTokenizer, PolicySequence + +__all__ = [ + "EpisodeMetadata", + "GoalGeneratorOutput", + "HighLevelOutput", + "HighLevelPolicy", + "MEMConfig", + "MEMVideoEncoder", + "MiniTokenizer", + "Pi07Config", + "Pi07Mini", + "Pi07Output", + "PolicySequence", + "PrefixLayout", + "TinyGoalGenerator", + "flow_matching_sample", +] diff --git a/src/pi07mini/context.py b/src/pi07mini/context.py new file mode 100644 index 0000000..2e05286 --- /dev/null +++ b/src/pi07mini/context.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Sequence + +import torch +import torch.nn.functional as F + + +PROMPT_COMPONENT_ORDER = ("task", "speed", "quality", "mistake", "control") + + +@dataclass(frozen=True) +class PolicyMetadata: + """Steerable policy attributes used by the tiny pi0.7 experiment.""" + + speed: str + quality: str + mistake: str + control: str + speed_steps: int = 1_000 + quality_score: int = 3 + control_mode: str = "ee" + source_type: str = "autonomous" + + def __post_init__(self) -> None: + if self.speed_steps <= 0: + raise ValueError("speed_steps must be positive") + if not 1 <= self.quality_score <= 5: + raise ValueError("quality_score must lie in [1,5]") + if self.control_mode not in {"joint", "ee"}: + raise ValueError("control_mode must be joint or ee") + + def as_dict(self) -> dict[str, str]: + return { + "speed": self.speed, + "quality": self.quality, + "mistake": self.mistake, + "control": self.control, + } + + def as_token_metadata(self) -> dict[str, int | bool | str]: + """Return the scalar metadata contract consumed by MiniTokenizer.""" + + return { + "speed_steps": self.speed_steps, + "quality": self.quality_score, + "mistake": self.mistake != "none", + "control_mode": self.control_mode, + "source_type": self.source_type, + } + + +def prompt_components( + metadata: PolicyMetadata, + *, + task: str = "Follow the visible road and predict the next waypoint chunk.", +) -> dict[str, str]: + return {"task": task, **metadata.as_dict()} + + +def drop_prompt_components( + components: Mapping[str, str], + *, + drop_probability: float, + generator: torch.Generator | None = None, + preserve: Sequence[str] = ("task",), +) -> tuple[dict[str, str], torch.Tensor]: + """Apply classifier-free-style dropout to independent prompt attributes. + + The returned mask follows ``PROMPT_COMPONENT_ORDER`` and marks components + that remain visible. Task text is preserved by default, while metadata can + be independently omitted so the policy cannot require every attribute. + """ + + if not 0.0 <= drop_probability <= 1.0: + raise ValueError("drop_probability must lie in [0, 1]") + preserved = set(preserve) + visible: dict[str, str] = {} + mask = torch.zeros(len(PROMPT_COMPONENT_ORDER), dtype=torch.bool) + for index, name in enumerate(PROMPT_COMPONENT_ORDER): + if name not in components: + continue + keep = name in preserved or bool( + torch.rand((), generator=generator) >= drop_probability + ) + if keep: + visible[name] = str(components[name]) + mask[index] = True + return visible, mask + + +def format_policy_prompt(components: Mapping[str, str]) -> str: + """Format components without inventing values for dropped attributes.""" + + pieces: list[str] = [] + if task := components.get("task"): + pieces.append(str(task).strip()) + attributes = [ + f"{name}={components[name]}" + for name in PROMPT_COMPONENT_ORDER[1:] + if name in components + ] + if attributes: + pieces.append("Policy metadata: " + ", ".join(attributes) + ".") + return "\n".join(pieces) + + +def endpoint_subgoal_heatmap( + endpoint_xy: torch.Tensor, + *, + size: int = 32, + sigma: float = 1.5, + xy_extent: tuple[float, float, float, float] = (-8.0, 64.0, -16.0, 16.0), +) -> torch.Tensor: + """Render a normalized Gaussian subgoal heatmap from one endpoint only. + + It deliberately accepts no trajectory tensor. This API makes it difficult + to accidentally leak the entire ground-truth action chunk into the visual + subgoal condition. + """ + + endpoint = torch.as_tensor(endpoint_xy, dtype=torch.float32).reshape(2) + if size < 2 or sigma <= 0.0: + raise ValueError("size must be >=2 and sigma must be positive") + x_min, x_max, y_min, y_max = xy_extent + if not x_min < x_max or not y_min < y_max: + raise ValueError("xy_extent must contain increasing bounds") + px = (endpoint[0] - x_min) / (x_max - x_min) * (size - 1) + py = (y_max - endpoint[1]) / (y_max - y_min) * (size - 1) + yy, xx = torch.meshgrid( + torch.arange(size, dtype=torch.float32), + torch.arange(size, dtype=torch.float32), + indexing="ij", + ) + heatmap = torch.exp(-((xx - px) ** 2 + (yy - py) ** 2) / (2.0 * sigma**2)) + heatmap = heatmap / heatmap.amax().clamp_min(torch.finfo(heatmap.dtype).eps) + return heatmap.unsqueeze(0) + + +def make_history_proxy_frames( + image: torch.Tensor, + history_xy: torch.Tensor, + *, + frames: int = 6, +) -> torch.Tensor: + """Create oldest-to-newest history frames from one PAI image and past XY. + + PAI's tiny overfit manifest only exposes one camera frame. We therefore + synthesize a deterministic visual-history proxy by rolling that frame from + past ego displacement and applying a very small temporal exposure change. + No future waypoint is accepted by this function. + """ + + image = torch.as_tensor(image, dtype=torch.float32) + history = torch.as_tensor(history_xy, dtype=torch.float32) + if image.ndim != 3 or image.shape[0] not in {1, 3}: + raise ValueError("image must have shape [C,H,W]") + if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 2: + raise ValueError("history_xy must have shape [T>=2,2]") + if frames < 2: + raise ValueError("frames must be >=2") + sampled = F.interpolate( + history.T.unsqueeze(0), size=frames, mode="linear", align_corners=True + )[0].T + relative = sampled - sampled[-1] + spatial_scale = max(image.shape[-2:]) / 24.0 + output: list[torch.Tensor] = [] + for index, point in enumerate(relative): + dx = int(torch.round(point[1] * spatial_scale).item()) + dy = int(torch.round(-point[0] * spatial_scale * 0.25).item()) + dx = max(-image.shape[-1] // 6, min(image.shape[-1] // 6, dx)) + dy = max(-image.shape[-2] // 6, min(image.shape[-2] // 6, dy)) + # The separate current image is appended by MEM, so even the newest + # proxy frame remains a slightly older exposure rather than an exact + # duplicate of current_pixel_values. + exposure = 0.90 + 0.08 * index / (frames - 1) + output.append(torch.roll(image, shifts=(dy, dx), dims=(-2, -1)) * exposure) + return torch.stack(output).clamp(0.0, 1.0) + + +def build_ambiguous_history_pair( + history_xy: torch.Tensor, *, frames: int = 6 +) -> torch.Tensor: + """Return two distinct histories that share exactly the same current state. + + This supplies a minimal history-disambiguation probe: a current-frame-only + policy sees the same endpoint, whereas a memory encoder can distinguish the + two past paths. Construction depends only on observed history. + """ + + history = torch.as_tensor(history_xy, dtype=torch.float32) + if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 2: + raise ValueError("history_xy must have shape [T>=2,2]") + sampled = F.interpolate( + history.T.unsqueeze(0), size=frames, mode="linear", align_corners=True + )[0].T + alpha = torch.linspace(0.0, 1.0, frames, dtype=sampled.dtype).unsqueeze(-1) + chord = sampled[:1] + alpha * (sampled[-1:] - sampled[:1]) + mirrored = 2.0 * chord - sampled + if torch.allclose(mirrored, sampled, atol=1e-4, rtol=0.0): + direction = sampled[-1] - sampled[0] + perpendicular = torch.stack((-direction[1], direction[0])) + perpendicular = perpendicular / torch.linalg.vector_norm(perpendicular).clamp_min(1e-6) + amplitude = torch.linalg.vector_norm(direction).clamp_min(1.0) * 0.08 + hump = torch.sin(torch.linspace(0.0, torch.pi, frames)).unsqueeze(-1) + mirrored = sampled + hump * perpendicular * amplitude + mirrored[-1] = sampled[-1] + return torch.stack((sampled, mirrored)) diff --git a/src/pi07mini/data.py b/src/pi07mini/data.py new file mode 100644 index 0000000..877ac25 --- /dev/null +++ b/src/pi07mini/data.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, 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 .context import ( + PolicyMetadata, + build_ambiguous_history_pair, + drop_prompt_components, + endpoint_subgoal_heatmap, + format_policy_prompt, + make_history_proxy_frames, + prompt_components, +) +from .rtc import MAX_RTC_DELAY_STEPS, align_actions_for_rtc, sample_rtc_delay + + +DEFAULT_SOURCE_MANIFEST = ( + Path(__file__).resolve().parents[4] + / "alpamayo" + / "alpamayo-qwen05b-validation" + / "data" + / "processed" + / "overfit_manifest.jsonl" +) + +PolicyKind = Literal["expert", "fast", "slow", "understeer", "stop"] + + +@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: + 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) + 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) + ] + ) + return cls(flat.mean(0), flat.std(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) + deltas = actions * self.scale.to(actions.device) + self.mean.to(actions.device) + start = torch.as_tensor(history_xy, dtype=torch.float32, device=actions.device)[-1:] + return start + torch.cumsum(deltas, dim=-2) + + def as_dict(self) -> dict[str, list[float]]: + return self.statistics.as_dict() + + +@dataclass(frozen=True) +class PolicyTrajectory: + kind: PolicyKind + history_xy: torch.Tensor + future_xy: torch.Tensor + metadata: PolicyMetadata + + +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).expanduser().resolve() + records: list[SampleRecord] = [] + for line in manifest.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, row["image_path"]), + trajectory_path=_resolve_data_path(manifest, row["trajectory_path"]), + reasoning=str(row.get("reasoning", "")), + ) + ) + if not records: + raise ValueError(f"no samples found in {manifest}") + return records + + +def materialize_subset( + source_manifest: str | Path = DEFAULT_SOURCE_MANIFEST, + output_dir: str | Path = "data/overfit", + *, + max_samples: int = 1, +) -> Path: + if max_samples < 1: + raise ValueError("max_samples must be positive") + destination = Path(output_dir).expanduser().resolve() + (destination / "images").mkdir(parents=True, exist_ok=True) + (destination / "trajectories").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 = destination / "images" / f"{stem}{record.image_path.suffix.lower()}" + trajectory_path = destination / "trajectories" / 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") + return F.interpolate(xy.T.unsqueeze(0), size=steps, mode="linear", align_corners=True)[0].T + + +def build_policy_trajectories( + history_xy: torch.Tensor, + expert_future_xy: torch.Tensor, +) -> list[PolicyTrajectory]: + """Create steerable expert/speed/failure modes from one PAI demonstration.""" + + 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) < 2: + raise ValueError("history_xy must have shape [T>=2,2]") + if expert.ndim != 2 or expert.shape[-1] != 2 or len(expert) < 4: + raise ValueError("expert_future_xy must have shape [H>=4,2]") + anchor = history[-1] + displacement = expert - anchor + progress = torch.linspace(0.0, 1.0, len(expert)).unsqueeze(-1) + + # Speed steering changes progress along the same demonstrated geometry. + fast_index = (progress.squeeze(-1) * 1.22).clamp(max=1.0) * (len(expert) - 1) + slow_index = progress.squeeze(-1) * 0.68 * (len(expert) - 1) + coordinates = torch.arange(len(expert), dtype=expert.dtype) + + def interpolate_at(indices: torch.Tensor) -> torch.Tensor: + left = indices.floor().long().clamp(max=len(expert) - 1) + right = (left + 1).clamp(max=len(expert) - 1) + weight = (indices - left).unsqueeze(-1) + return expert[left] * (1.0 - weight) + expert[right] * weight + + del coordinates # documents that interpolation is index based, not metric based + fast = interpolate_at(fast_index) + slow = interpolate_at(slow_index) + understeer = anchor + displacement * expert.new_tensor([1.0, 0.22]) + stop_index = max(2, len(expert) // 3) + stopped = expert.clone() + stopped[stop_index:] = stopped[stop_index - 1] + return [ + PolicyTrajectory( + "expert", + history, + expert, + PolicyMetadata( + "normal", "expert", "none", "human", 1_000, 5, "ee", "demonstration" + ), + ), + PolicyTrajectory( + "fast", + history, + fast, + PolicyMetadata( + "fast", "good", "none", "autonomous", 500, 4, "ee", "autonomous" + ), + ), + PolicyTrajectory( + "slow", + history, + slow, + PolicyMetadata( + "slow", "good", "none", "autonomous", 2_000, 4, "ee", "autonomous" + ), + ), + PolicyTrajectory( + "understeer", + history, + understeer, + PolicyMetadata( + "normal", "poor", "understeer", "autonomous", 1_000, 1, "ee", "autonomous" + ), + ), + PolicyTrajectory( + "stop", + history, + stopped, + PolicyMetadata( + "slow", "poor", "stop", "autonomous", 2_000, 1, "ee", "autonomous" + ), + ), + ] + + +class Pi07SteerableDataset(Dataset[dict[str, Any]]): + """Five-policy PAI overfit set with history, subgoal, prompt and RTC inputs.""" + + def __init__( + self, + manifest_path: str | Path, + *, + image_size: int = 64, + augmentation_factor: int = 1, + history_frames: int = 6, + action_horizon: int = 16, + prompt_dropout: float = 0.25, + max_rtc_delay_steps: int = MAX_RTC_DELAY_STEPS, + seed: int = 7, + action_statistics: ActionStatistics | dict[str, Sequence[float]] | None = None, + ) -> None: + if image_size < 2 or augmentation_factor < 1 or history_frames < 2: + raise ValueError("image_size, augmentation_factor and history_frames are invalid") + self.records = load_manifest(manifest_path) + self.image_size = int(image_size) + self.augmentation_factor = int(augmentation_factor) + self.history_frames = int(history_frames) + self.action_horizon = int(action_horizon) + self.prompt_dropout = float(prompt_dropout) + self.max_rtc_delay_steps = int(max_rtc_delay_steps) + self.seed = int(seed) + + policies: list[PolicyTrajectory] = [] + 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_frames, + ) + future = _resample_xy( + torch.from_numpy(np.asarray(arrays["future_xyz"], dtype=np.float32))[:, :2], + self.action_horizon, + ) + generated = build_policy_trajectories(history, future) + policies.extend(generated) + record_indices.extend([record_index] * len(generated)) + self.policies = policies + self._record_indices = record_indices + if action_statistics is None: + self.action_normalizer = ActionNormalizer.fit( + [policy.history_xy for policy in policies], + [policy.future_xy for policy in policies], + ) + 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 __len__(self) -> int: + return len(self.policies) * self.augmentation_factor + + def _indices(self, index: int) -> tuple[int, int]: + if index < 0: + index += len(self) + if not 0 <= 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), 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]: + policy_index, variant = self._indices(index) + policy = self.policies[policy_index] + record_index = self._record_indices[policy_index] + image = self._load_image(record_index, variant) + actions = self.action_normalizer.encode(policy.history_xy, policy.future_xy) + generator = torch.Generator().manual_seed(self.seed * 100_003 + index) + components = prompt_components(policy.metadata) + visible_components, component_mask = drop_prompt_components( + components, + drop_probability=self.prompt_dropout, + generator=generator, + ) + delay = sample_rtc_delay( + generator=generator, + max_delay_steps=self.max_rtc_delay_steps, + action_horizon=self.action_horizon, + ) + rtc = align_actions_for_rtc( + actions, delay, max_delay_steps=self.max_rtc_delay_steps + ) + subgoal = endpoint_subgoal_heatmap( + policy.future_xy[-1], size=self.image_size, sigma=max(1.0, self.image_size / 24.0) + ) + history_pair = build_ambiguous_history_pair( + policy.history_xy, frames=self.history_frames + ) + history_images = make_history_proxy_frames( + image, policy.history_xy, frames=self.history_frames + ) + return { + "image": image, + "pixel_values": image, + "history_images": history_images, + "history_pixel_values": history_images, + "history_xy": policy.history_xy.clone(), + "history_states": policy.history_xy.clone(), + "current_state": policy.history_xy[-1].clone(), + "ambiguous_history_pair": history_pair, + "ambiguous_pair_id": f"{self.records[record_index].sample_id}:current-state", + "future_xy": policy.future_xy.clone(), + "actions": actions, + "policy_kind": policy.kind, + "metadata": policy.metadata.as_dict(), + "token_metadata": policy.metadata.as_token_metadata(), + "speed": policy.metadata.speed, + "quality": policy.metadata.quality, + "mistake": policy.metadata.mistake, + "control": policy.metadata.control, + "speed_steps": policy.metadata.speed_steps, + "quality_score": policy.metadata.quality_score, + "has_mistake": policy.metadata.mistake != "none", + "control_mode": policy.metadata.control_mode, + "source_type": policy.metadata.source_type, + "prompt_components": visible_components, + "prompt_component_mask": component_mask, + "prompt_text": format_policy_prompt(visible_components), + "subgoal_xy": policy.future_xy[-1].clone(), + "subgoal_heatmap": subgoal, + "subgoal_pixel_values": subgoal.expand(3, -1, -1).clone(), + "rtc_delay_steps": delay, + "rtc_target_actions": rtc.delayed_target_actions, + "rtc_valid_mask": rtc.valid_mask, + "rtc_executed_prefix_actions": rtc.executed_prefix_actions, + "reasoning": self.records[record_index].reasoning, + "sample_id": f"{self.records[record_index].sample_id}:{policy.kind}:aug{variant}", + } diff --git a/src/pi07mini/flow.py b/src/pi07mini/flow.py new file mode 100644 index 0000000..eb93db6 --- /dev/null +++ b/src/pi07mini/flow.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from collections.abc import Callable +import torch +from torch import Tensor + + +class FlowMatching: + 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]: + u=torch.rand(target.shape[0],device=target.device,dtype=target.dtype) + t=self.cutoff*(1-u.pow(1/1.5));noise=torch.randn_like(target) + return t[:,None,None]*target+(1-t[:,None,None])*noise,t,target-noise + @torch.no_grad() + def sample(self,fn:Callable[[Tensor,Tensor],Tensor],shape:tuple[int,...],device,steps:int|None=None)->Tensor: + count=steps or self.inference_steps;x=torch.randn(shape,device=device);grid=torch.linspace(0,1,count+1,device=device) + for i in range(count): x=x+(grid[i+1]-grid[i])*fn(x,grid[i].expand(shape[0])) + return x diff --git a/src/pi07mini/infer.py b/src/pi07mini/infer.py new file mode 100644 index 0000000..eaf9079 --- /dev/null +++ b/src/pi07mini/infer.py @@ -0,0 +1,40 @@ +from __future__ import annotations +import argparse +from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np,torch +from .data import Pi07SteerableDataset +from .flow import FlowMatching +from .model import Pi07Config,Pi07Mini,HighLevelPolicy,TinyGoalGenerator +from .runtime import load_config,choose_device,write_json +from .tokenizer import MiniTokenizer,EpisodeMetadata + +@torch.no_grad() +def evaluate(checkpoint_path): + ck=torch.load(checkpoint_path,map_location='cpu',weights_only=False);cfg=ck['config'];root=Path(__file__).resolve().parents[2];dev=choose_device(cfg['device']);tok=MiniTokenizer();ds=Pi07SteerableDataset(root/'data/overfit/overfit_manifest.jsonl',image_size=cfg['data']['image_size'],history_frames=cfg['data']['history_frames'],action_horizon=cfg['data']['action_horizon'],action_statistics=ck['action_statistics']) + pcfg=Pi07Config(**ck['policy_config']);policy=Pi07Mini(pcfg);policy.load_state_dict(ck['policy']);policy.to(dev).eval();high=HighLevelPolicy(pcfg);high.load_state_dict(ck['high_level']);high.to(dev).eval();goal=TinyGoalGenerator(tok.vocab_size);goal.load_state_dict(ck['goal']);goal.to(dev).eval();flow=FlowMatching(cfg['flow']['integration_steps']);s=next(ds[i] for i in range(len(ds)) if ds[i]['policy_kind']=='expert');meta=EpisodeMetadata(**s['token_metadata']);image=s['image'][None].to(dev) + prompt,_=tok.encode_prompt('drive safely',None,meta,include_subtask=False);hl=high(image,prompt[None].to(dev),s['history_images'][None].to(dev),s['history_states'][None].to(dev),s['current_state'][None].to(dev));pred_subtask=tok.subtasks.decode(int(hl.logits.argmax(-1).cpu())) + full=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta);ids=full.input_ids[:full.action_start_index][None].to(dev) + torch.manual_seed(cfg['seed']);g=torch.randn_like(s['subgoal_pixel_values'][None]).to(dev);grid=torch.linspace(0,1,6,device=dev) + for i in range(5): g=g+(grid[i+1]-grid[i])*goal(image,ids,g,grid[i].expand(1)).vector_field + generated_goal=g.cpu()[0] + def sample(name,include_meta=True,use_goal=True,use_history=True,cfg_beta=None,generated=False): + seq=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta,include_episode_metadata=include_meta);xids=seq.input_ids[:seq.action_start_index][None].to(dev) + goal_pixels=(generated_goal if generated else s['subgoal_pixel_values'])[None].to(dev) if use_goal else None + common=dict(pixel_values=image,history_pixel_values=s['history_images'][None].to(dev) if use_history else None,history_states=s['history_states'][None].to(dev) if use_history else None,current_state=s['current_state'][None].to(dev),subgoal_pixel_values=goal_pixels) + def field(x,t): + if cfg_beta is None:return policy(input_ids=xids,noisy_actions=x,timesteps=t,**common).vector_field + un=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta,include_episode_metadata=False);uids=un.input_ids[:un.action_start_index][None].to(dev) + return policy.metadata_cfg(xids,uids,guidance_scale=cfg_beta,noisy_actions=x,timesteps=t,**common) + torch.manual_seed(cfg['evaluation']['fixed_noise_seed']);a=flow.sample(field,(1,ds.action_horizon,2),dev,cfg['flow']['integration_steps'])[0].cpu();return ds.action_normalizer.decode(a,s['history_xy']).numpy() + curves={'full_oracle_goal':sample('full'),'generated_goal':sample('generated',generated=True),'no_metadata':sample('no_metadata',False),'no_goal':sample('no_goal',use_goal=False),'no_history':sample('no_history',use_history=False),'cfg_beta2':sample('cfg',cfg_beta=2.0)} + target=ds.action_normalizer.decode(s['rtc_target_actions'],s['history_xy']).numpy();valid=s['rtc_valid_mask'].numpy().astype(bool);metrics={k:float(np.mean((v[valid]-target[valid])**2)) for k,v in curves.items()} + out=Path(checkpoint_path).parent;fig,ax=plt.subplots(figsize=(9,6));ax.plot(target[:,0],target[:,1],'o-',label='expert target',linewidth=2) + for k,v in curves.items():ax.plot(v[:,0],v[:,1],'x--',label=f'{k} ({metrics[k]:.3f})') + 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.7 context steering and ablations');fig.tight_layout();traj=out/'context_ablation.png';fig.savefig(traj,dpi=170);plt.close(fig) + fig,axes=plt.subplots(1,3,figsize=(10,3));axes[0].imshow(image[0].cpu().permute(1,2,0));axes[0].set_title('current observation');axes[1].imshow(s['subgoal_pixel_values'].permute(1,2,0));axes[1].set_title('endpoint goal target');axes[2].imshow(generated_goal.permute(1,2,0).clamp(0,1));axes[2].set_title('generated goal'); + for a in axes:a.axis('off') + fig.tight_layout();goalpath=out/'goal_generation.png';fig.savefig(goalpath,dpi=170);plt.close(fig);write_json(out/'evaluation.json',{'predicted_subtask':pred_subtask,'trajectory_metrics':metrics});print({'predicted_subtask':pred_subtask,'trajectory_metrics':metrics});return traj,goalpath +def main(): + p=argparse.ArgumentParser();p.add_argument('--checkpoint',default='outputs/overfit/checkpoint.pt');evaluate(p.parse_args().checkpoint) +if __name__=='__main__':main() diff --git a/src/pi07mini/memory.py b/src/pi07mini/memory.py new file mode 100644 index 0000000..8bd73b3 --- /dev/null +++ b/src/pi07mini/memory.py @@ -0,0 +1,215 @@ +"""A tiny MEM-style video encoder used by the pi0.7 architecture test. + +The real MEM encoder modifies a pretrained ViT by alternating spatial +attention within each frame and causal temporal attention across the same +patch in different frames. It then discards all past-frame representations +and forwards only the temporally enriched current-frame patches to the VLM. + +This module preserves those information-flow contracts in pure PyTorch. It +is intentionally small and does not attempt to reproduce the pretrained ViT. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import torch +from torch import Tensor, nn + + +@dataclass(frozen=True) +class MEMConfig: + image_channels: int = 3 + image_size: int = 64 + patch_size: int = 16 + d_model: int = 128 + n_heads: int = 4 + n_layers: int = 2 + mlp_ratio: int = 4 + max_history_frames: int = 6 + dropout: float = 0.0 + + def __post_init__(self) -> None: + if self.image_size % self.patch_size: + raise ValueError("image_size must be divisible by patch_size") + if self.d_model % self.n_heads: + raise ValueError("d_model must be divisible by n_heads") + if self.max_history_frames < 0: + raise ValueError("max_history_frames must be non-negative") + + @property + def output_tokens(self) -> int: + return (self.image_size // self.patch_size) ** 2 + + +class _MEMLayer(nn.Module): + """Factorized spatial-bidirectional and temporal-causal attention.""" + + def __init__(self, config: MEMConfig) -> None: + super().__init__() + d = config.d_model + self.n_heads = config.n_heads + self.spatial_norm = nn.LayerNorm(d) + self.spatial_attention = nn.MultiheadAttention( + d, config.n_heads, dropout=config.dropout, batch_first=True + ) + self.temporal_norm = nn.LayerNorm(d) + self.temporal_attention = nn.MultiheadAttention( + d, config.n_heads, dropout=config.dropout, batch_first=True + ) + hidden = d * config.mlp_ratio + self.mlp_norm = nn.LayerNorm(d) + self.mlp = nn.Sequential( + nn.Linear(d, hidden), + nn.GELU(approximate="tanh"), + nn.Dropout(config.dropout), + nn.Linear(hidden, d), + nn.Dropout(config.dropout), + ) + + def forward(self, tokens: Tensor, frame_valid: Tensor) -> Tensor: + batch, frames, patches, width = tokens.shape + + # Standard ViT spatial attention is bidirectional inside each frame. + spatial = self.spatial_norm(tokens).reshape(batch * frames, patches, width) + spatial_update, _ = self.spatial_attention(spatial, spatial, spatial, need_weights=False) + tokens = tokens + spatial_update.reshape(batch, frames, patches, width) + + # MEM factorizes time: each spatial patch attends causally over frames. + temporal = self.temporal_norm(tokens).permute(0, 2, 1, 3) + temporal = temporal.reshape(batch * patches, frames, width) + causal_allowed = torch.tril( + torch.ones(frames, frames, dtype=torch.bool, device=tokens.device) + ) + allowed = causal_allowed[None] & frame_valid[:, None] + # An invalid query receives a harmless self edge, preventing all-masked + # rows from producing NaNs. Its output is zeroed after the layer. + identity = torch.eye(frames, dtype=torch.bool, device=tokens.device)[None] + allowed = torch.where(frame_valid[:, :, None], allowed, identity) + blocked = (~allowed)[:, None].expand(batch, patches, frames, frames) + blocked = blocked.reshape(batch * patches, frames, frames) + blocked = blocked[:, None].expand(-1, self.n_heads, -1, -1) + blocked = blocked.reshape(batch * patches * self.n_heads, frames, frames) + temporal_update, _ = self.temporal_attention( + temporal, + temporal, + temporal, + attn_mask=blocked, + need_weights=False, + ) + temporal_update = temporal_update.reshape(batch, patches, frames, width) + tokens = tokens + temporal_update.permute(0, 2, 1, 3) + tokens = tokens + self.mlp(self.mlp_norm(tokens)) + + # Invalid history frames must not leak through residual paths. + return tokens * frame_valid[:, :, None, None].to(tokens.dtype) + + +class MEMVideoEncoder(nn.Module): + """Compress arbitrary valid history lengths to one frame's patch count. + + ``history_pixel_values`` is ordered oldest-to-newest. The current image is + appended internally and is always valid. A fixed sinusoidal temporal + encoding and causal temporal attention make the representation sensitive + to frame order, while returning only current-frame tokens keeps the output + size independent of history length. + """ + + def __init__(self, config: MEMConfig) -> None: + super().__init__() + self.config = config + self.patch_encoder = nn.Conv2d( + config.image_channels, + config.d_model, + config.patch_size, + stride=config.patch_size, + ) + self.spatial_position = nn.Parameter( + torch.randn(1, 1, config.output_tokens, config.d_model) * 0.01 + ) + self.layers = nn.ModuleList(_MEMLayer(config) for _ in range(config.n_layers)) + self.output_norm = nn.LayerNorm(config.d_model) + + @staticmethod + def temporal_position(ages: Tensor, width: int) -> Tensor: + """Fixed sinusoidal encoding whose current-frame value (age 0) is zero.""" + + if width <= 0: + raise ValueError("temporal embedding width must be positive") + half = (width + 1) // 2 + exponent = torch.arange(half, device=ages.device, dtype=torch.float32) + denominator = torch.exp(exponent * (-math.log(10_000.0) / max(half - 1, 1))) + angles = ages.float()[:, None] * denominator[None] + # Subtracting one from cosine ensures a zero embedding at age zero, + # matching MEM's invariant single-frame initialization contract. + embedding = torch.cat((angles.sin(), angles.cos() - 1.0), dim=-1) + return embedding[:, :width] + + def _validate_images(self, images: Tensor, name: str) -> None: + if images.ndim != 4: + raise ValueError(f"{name} must have shape [batch, channels, height, width]") + expected = (self.config.image_channels, self.config.image_size, self.config.image_size) + if tuple(images.shape[1:]) != expected: + raise ValueError(f"{name} must end in shape {expected}") + + def forward( + self, + current_pixel_values: Tensor, + history_pixel_values: Tensor | None = None, + history_mask: Tensor | None = None, + ) -> Tensor: + self._validate_images(current_pixel_values, "current_pixel_values") + batch = current_pixel_values.shape[0] + + if history_pixel_values is None: + history_pixel_values = current_pixel_values.new_empty( + batch, + 0, + self.config.image_channels, + self.config.image_size, + self.config.image_size, + ) + if history_pixel_values.ndim != 5: + raise ValueError( + "history_pixel_values must have shape [batch, time, channels, height, width]" + ) + if history_pixel_values.shape[0] != batch: + raise ValueError("history and current images must have the same batch size") + if tuple(history_pixel_values.shape[2:]) != tuple(current_pixel_values.shape[1:]): + raise ValueError("history and current images must have the same image shape") + history = history_pixel_values.shape[1] + if history > self.config.max_history_frames: + raise ValueError("history length exceeds configured maximum") + + if history_mask is None: + history_mask = torch.ones( + batch, history, dtype=torch.bool, device=current_pixel_values.device + ) + if history_mask.shape != (batch, history): + raise ValueError("history_mask must have shape [batch, history]") + history_mask = history_mask.bool() + current_valid = torch.ones(batch, 1, dtype=torch.bool, device=current_pixel_values.device) + frame_valid = torch.cat((history_mask, current_valid), dim=1) + + frames = torch.cat((history_pixel_values, current_pixel_values[:, None]), dim=1) + flat = frames.reshape( + batch * (history + 1), + self.config.image_channels, + self.config.image_size, + self.config.image_size, + ) + tokens = self.patch_encoder(flat).flatten(2).transpose(1, 2) + tokens = tokens.reshape(batch, history + 1, self.config.output_tokens, -1) + tokens = tokens + self.spatial_position + + # Oldest frame has the largest age; current frame has age zero. + ages = torch.arange(history, -1, -1, device=tokens.device) + temporal_position = self.temporal_position(ages, self.config.d_model) + tokens = tokens + temporal_position[None, :, None].to(tokens.dtype) + tokens = tokens * frame_valid[:, :, None, None].to(tokens.dtype) + for layer in self.layers: + tokens = layer(tokens, frame_valid) + + # Fixed number of tokens regardless of how many history frames entered. + return self.output_norm(tokens[:, -1]) diff --git a/src/pi07mini/model.py b/src/pi07mini/model.py new file mode 100644 index 0000000..4c66a2a --- /dev/null +++ b/src/pi07mini/model.py @@ -0,0 +1,816 @@ +r"""Minimal :math:`\pi_{0.7}` policy with MEM and diverse context. + +The implementation is a small architecture validation model, not a scaled +reproduction. It preserves the paper's essential paths: + +* a MEM-style encoder compresses ordered visual history to a fixed token count; +* current and historical continuous proprioceptive states are individual tokens; +* observations, visual subgoals, and causal text form distinct attention blocks; +* the VLM predicts FAST-lite tokens with cross entropy; +* an AdaRMSNorm flow expert predicts continuous actions; +* FAST tokens and flow actions cannot read one another; and +* flow-to-VLM gradients are stopped by Knowledge Insulation. +""" + +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 + +from .memory import MEMConfig, MEMVideoEncoder + + +@dataclass +class Pi07Config: + vocab_size: int = 368 + action_dim: int = 2 + state_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 + memory_layers: int = 2 + mlp_ratio: int = 4 + max_history_frames: int = 6 + max_text_tokens: int = 96 + max_action_tokens: int = 50 + action_start_token_id: int = 302 + 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 image_tokens(self) -> int: + return (self.image_size // self.patch_size) ** 2 + + @property + def max_prefix_tokens(self) -> int: + # MEM observation + all states + one subgoal view + textual prompt. + return ( + self.image_tokens + + self.max_history_frames + + 1 + + self.image_tokens + + self.max_text_tokens + ) + + +@dataclass(frozen=True) +class PrefixLayout: + observation_tokens: int + goal_tokens: int + text_tokens: int + + @property + def goal_start(self) -> int: + return self.observation_tokens + + @property + def text_start(self) -> int: + return self.observation_tokens + self.goal_tokens + + @property + def total_tokens(self) -> int: + return self.text_start + self.text_tokens + + +@dataclass +class Pi07Output: + text_logits: Tensor + vector_field: Tensor + prefix_hidden: Tensor + action_hidden: Tensor + layout: PrefixLayout + loss: Optional[Tensor] = None + text_loss: Optional[Tensor] = None + flow_loss: Optional[Tensor] = None + + +@dataclass +class HighLevelOutput: + logits: Tensor + loss: Optional[Tensor] = None + + +@dataclass +class GoalGeneratorOutput: + vector_field: Tensor + 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): + def __init__(self, dim: int, condition_dim: int, eps: float = 1e-6) -> None: + super().__init__() + self.norm = RMSNorm(dim, eps) + self.modulation = nn.Linear(condition_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): + 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: Tensor, + dropout: float, + training: bool, +) -> Tensor: + batch, query_length, 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 = q @ k.transpose(-2, -1) / math.sqrt(head_dim) + 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, query_length, width) + + +class KnowledgeInsulatedBlock(nn.Module): + """Independent VLM/flow weights with stop-gradient VLM keys and values.""" + + def __init__(self, config: Pi07Config) -> 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: continuous flow loss can condition on VLM + # features but cannot update the VLM or its K/V projections. + key = torch.cat((p.k(prefix_input).detach(), a.k(action_input)), dim=1) + value = torch.cat((p.v(prefix_input).detach(), 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 Pi07Mini(nn.Module): + """Small pi0.7-style VLA for structural and overfitting experiments.""" + + def __init__(self, config: Optional[Pi07Config] = None) -> None: + super().__init__() + self.config = config or Pi07Config() + c = self.config + self.memory_encoder = MEMVideoEncoder( + MEMConfig( + image_channels=c.image_channels, + image_size=c.image_size, + patch_size=c.patch_size, + d_model=c.d_model, + n_heads=c.n_heads, + n_layers=c.memory_layers, + mlp_ratio=c.mlp_ratio, + max_history_frames=c.max_history_frames, + dropout=c.dropout, + ) + ) + self.state_projection = nn.Linear(c.state_dim, c.d_model) + self.token_embedding = nn.Embedding(c.vocab_size, c.d_model) + self.segment_embedding = nn.Embedding(3, c.d_model) + self.prefix_position = nn.Parameter( + torch.randn(1, c.max_prefix_tokens, c.d_model) * 0.01 + ) + self.action_projection = nn.Linear(c.action_dim, c.d_model) + 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 _adapt_goal_channels(self, pixels: Tensor) -> Tensor: + if pixels.shape[1] == self.config.image_channels: + return pixels + if pixels.shape[1] == 1 and self.config.image_channels == 3: + return pixels.expand(-1, 3, -1, -1) + raise ValueError("subgoal image has unsupported channel count") + + def encode_prefix( + self, + pixel_values: Tensor, + history_pixel_values: Tensor | None, + history_states: Tensor | None, + current_state: Tensor, + input_ids: Tensor, + subgoal_pixel_values: Tensor | None = None, + history_mask: Tensor | None = None, + subgoal_mask: Tensor | None = None, + attention_mask: Tensor | None = None, + ) -> tuple[Tensor, Tensor, PrefixLayout]: + observation_images = self.memory_encoder( + pixel_values, history_pixel_values, history_mask + ) + batch = pixel_values.shape[0] + if current_state.shape != (batch, self.config.state_dim): + raise ValueError("current_state must have shape [batch, state_dim]") + + if history_states is None: + history_states = current_state.new_empty(batch, 0, self.config.state_dim) + if history_states.ndim != 3 or history_states.shape[0] != batch: + raise ValueError("history_states must have shape [batch, time, state_dim]") + if history_states.shape[-1] != self.config.state_dim: + raise ValueError("history_states has the wrong state dimension") + history_length = history_states.shape[1] + if history_length > self.config.max_history_frames: + raise ValueError("state history exceeds configured maximum") + if history_pixel_values is not None and history_pixel_values.shape[1] != history_length: + raise ValueError("visual and proprioceptive history lengths must match") + if history_mask is None: + history_mask = torch.ones( + batch, history_length, dtype=torch.bool, device=pixel_values.device + ) + if history_mask.shape != (batch, history_length): + raise ValueError("history_mask must have shape [batch, history]") + + all_states = torch.cat((history_states, current_state[:, None]), dim=1) + state_tokens = self.state_projection(all_states) + ages = torch.arange(history_length, -1, -1, device=state_tokens.device) + state_tokens = state_tokens + MEMVideoEncoder.temporal_position( + ages, self.config.d_model + )[None].to(state_tokens.dtype) + state_valid = torch.cat( + ( + history_mask.bool(), + torch.ones(batch, 1, dtype=torch.bool, device=pixel_values.device), + ), + dim=1, + ) + state_tokens = state_tokens * state_valid[:, :, None].to(state_tokens.dtype) + observation = torch.cat((observation_images, state_tokens), dim=1) + observation_valid = torch.cat( + ( + torch.ones( + batch, + observation_images.shape[1], + dtype=torch.bool, + device=pixel_values.device, + ), + state_valid, + ), + dim=1, + ) + + if subgoal_pixel_values is None: + goal = observation.new_empty(batch, 0, self.config.d_model) + goal_valid = torch.empty(batch, 0, dtype=torch.bool, device=pixel_values.device) + else: + goal_pixels = self._adapt_goal_channels(subgoal_pixel_values) + goal = self.memory_encoder(goal_pixels) + if subgoal_mask is None: + subgoal_mask = torch.ones(batch, dtype=torch.bool, device=pixel_values.device) + if subgoal_mask.shape != (batch,): + raise ValueError("subgoal_mask must have shape [batch]") + goal_valid = subgoal_mask.bool()[:, None].expand(batch, goal.shape[1]) + goal = goal * goal_valid[:, :, None].to(goal.dtype) + + if input_ids.ndim != 2 or input_ids.shape[0] != batch: + raise ValueError("input_ids must have shape [batch, text]") + text = self.token_embedding(input_ids) + if attention_mask is None: + attention_mask = torch.ones_like(input_ids, dtype=torch.bool) + if attention_mask.shape != input_ids.shape: + raise ValueError("attention_mask must match input_ids") + text_valid = attention_mask.bool() + + layout = PrefixLayout(observation.shape[1], goal.shape[1], text.shape[1]) + if layout.total_tokens > self.config.max_prefix_tokens: + raise ValueError("prefix exceeds configured maximum length") + prefix = torch.cat((observation, goal, text), dim=1) + segments = torch.cat( + ( + torch.zeros(layout.observation_tokens, dtype=torch.long, device=prefix.device), + torch.ones(layout.goal_tokens, dtype=torch.long, device=prefix.device), + torch.full( + (layout.text_tokens,), 2, dtype=torch.long, device=prefix.device + ), + ) + ) + prefix = prefix + self.segment_embedding(segments)[None] + prefix = prefix + self.prefix_position[:, : layout.total_tokens] + valid = torch.cat((observation_valid, goal_valid, text_valid), dim=1) + return prefix, valid, layout + + @staticmethod + def build_prefix_attention_mask(layout: PrefixLayout, valid: Tensor) -> Tensor: + """Build obs -> goal -> causal-text block attention. + + Observation queries only read observations. Goal-image queries read + observations and all goal tokens bidirectionally. Text queries read + both visual blocks and earlier text tokens causally. + """ + + length = layout.total_tokens + if valid.shape[1] != length: + raise ValueError("valid mask does not match PrefixLayout") + device = valid.device + q = torch.arange(length, device=device)[:, None] + k = torch.arange(length, device=device)[None] + obs = q < layout.goal_start + goal = (q >= layout.goal_start) & (q < layout.text_start) + text = q >= layout.text_start + structural = ( + (obs & (k < layout.goal_start)) + | (goal & (k < layout.text_start)) + | (text & ((k < layout.text_start) | ((k >= layout.text_start) & (k <= q)))) + ) + edges = structural[None] & valid[:, None] + identity = torch.eye(length, dtype=torch.bool, device=device)[None] + return torch.where(valid[:, :, None], edges, identity) + + def _derive_action_prefix_mask(self, input_ids: Tensor) -> Tensor: + is_fast = input_ids.eq(self.config.action_start_token_id) + return is_fast.long().cumsum(dim=1).eq(0) + + def _action_attention_mask( + self, + valid: Tensor, + layout: PrefixLayout, + action_length: int, + action_prefix_mask: Tensor, + ) -> Tensor: + batch = valid.shape[0] + supplied = action_prefix_mask.bool() + if supplied.shape == (batch, layout.text_tokens): + supplied = torch.cat( + ( + torch.ones( + batch, + layout.text_start, + dtype=torch.bool, + device=valid.device, + ), + supplied, + ), + dim=1, + ) + if supplied.shape != valid.shape: + raise ValueError("action_prefix_mask must cover text or the full prefix") + readable = valid & supplied + prefix_keys = readable[:, None].expand(batch, action_length, layout.total_tokens) + action_keys = torch.ones( + batch, action_length, action_length, dtype=torch.bool, device=valid.device + ) + return torch.cat((prefix_keys, action_keys), dim=-1) + + def vlm_parameters(self) -> Iterator[nn.Parameter]: + modules: list[nn.Module] = [ + self.memory_encoder, + self.state_projection, + self.token_embedding, + self.segment_embedding, + self.prefix_norm, + self.text_head, + ] + for layer in self.layers: + modules.extend( + (layer.vlm_norm1, layer.vlm_attention, layer.vlm_norm2, layer.vlm_mlp) + ) + seen: set[int] = set() + if id(self.prefix_position) not in seen: + seen.add(id(self.prefix_position)) + yield self.prefix_position + 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]: + 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, + history_pixel_values: Tensor | None = None, + history_states: Tensor | None = None, + current_state: Tensor | None = None, + subgoal_pixel_values: Tensor | None = None, + history_mask: Tensor | None = None, + subgoal_mask: Tensor | None = None, + attention_mask: Tensor | None = None, + action_prefix_mask: Tensor | None = None, + generated_token_start: int | None = None, + text_labels: Tensor | None = None, + target_velocity: Tensor | None = None, + text_loss_weight: float = 1.0, + flow_loss_weight: float = 1.0, + ) -> Pi07Output: + if generated_token_start not in (None, 0): + raise ValueError("pi0.7 prompt text is causal from its first token") + 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") + if current_state is None: + current_state = noisy_actions.new_zeros( + noisy_actions.shape[0], self.config.state_dim + ) + + prefix, valid, layout = self.encode_prefix( + pixel_values, + history_pixel_values, + history_states, + current_state, + input_ids, + subgoal_pixel_values, + history_mask, + subgoal_mask, + attention_mask, + ) + 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 = self.build_prefix_attention_mask(layout, valid) + action_edges = self._action_attention_mask( + valid, layout, horizon, action_prefix_mask + ) + 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[:, layout.text_start :]) + 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 Pi07Output( + text_logits=text_logits, + vector_field=vector_field, + prefix_hidden=prefix_hidden, + action_hidden=action_hidden, + layout=layout, + loss=loss, + text_loss=text_loss, + flow_loss=flow_loss, + ) + + @staticmethod + def combine_metadata_cfg( + conditioned: Tensor, unconditioned: Tensor, guidance_scale: float = 1.5 + ) -> Tensor: + """pi0.7 metadata CFG: conditional + beta*(conditional-unconditional).""" + + if guidance_scale < 0: + raise ValueError("guidance_scale must be non-negative") + if conditioned.shape != unconditioned.shape: + raise ValueError("conditioned and unconditioned predictions must match") + return conditioned + guidance_scale * (conditioned - unconditioned) + + def metadata_cfg( + self, + conditioned_input_ids: Tensor, + unconditioned_input_ids: Tensor, + *, + guidance_scale: float = 1.5, + conditioned_attention_mask: Tensor | None = None, + unconditioned_attention_mask: Tensor | None = None, + conditioned_action_prefix_mask: Tensor | None = None, + unconditioned_action_prefix_mask: Tensor | None = None, + **common_inputs: Tensor | None, + ) -> Tensor: + """Run metadata-positive and metadata-dropped branches and combine them.""" + + conditioned = self( + input_ids=conditioned_input_ids, + attention_mask=conditioned_attention_mask, + action_prefix_mask=conditioned_action_prefix_mask, + **common_inputs, + ).vector_field + unconditioned = self( + input_ids=unconditioned_input_ids, + attention_mask=unconditioned_attention_mask, + action_prefix_mask=unconditioned_action_prefix_mask, + **common_inputs, + ).vector_field + return self.combine_metadata_cfg(conditioned, unconditioned, guidance_scale) + + +def flow_matching_sample(actions: Tensor) -> tuple[Tensor, Tensor, Tensor]: + """Construct the linear conditional-flow target used by the action expert.""" + + batch = actions.shape[0] + timestep = torch.rand(batch, device=actions.device, dtype=actions.dtype) + noise = torch.randn_like(actions) + broadcast = timestep.reshape(batch, *([1] * (actions.ndim - 1))) + noisy_actions = (1 - broadcast) * noise + broadcast * actions + return noisy_actions, timestep, actions - noise + + +class HighLevelPolicy(nn.Module): + """Tiny semantic policy that predicts the next subtask prompt token. + + The paper uses a separate policy based on the same VLM family. This proxy + shares the same MEM input contract while replacing the 4B VLM with a small + Transformer encoder suitable for a few-example overfit test. + """ + + def __init__(self, config: Optional[Pi07Config] = None) -> None: + super().__init__() + self.config = config or Pi07Config() + c = self.config + self.memory_encoder = MEMVideoEncoder( + MEMConfig( + image_channels=c.image_channels, + image_size=c.image_size, + patch_size=c.patch_size, + d_model=c.d_model, + n_heads=c.n_heads, + n_layers=c.memory_layers, + mlp_ratio=c.mlp_ratio, + max_history_frames=c.max_history_frames, + dropout=c.dropout, + ) + ) + self.state_projection = nn.Linear(c.state_dim, c.d_model) + self.token_embedding = nn.Embedding(c.vocab_size, c.d_model) + layer = nn.TransformerEncoderLayer( + c.d_model, + c.n_heads, + c.d_model * c.mlp_ratio, + c.dropout, + activation="gelu", + batch_first=True, + norm_first=True, + ) + self.transformer = nn.TransformerEncoder(layer, c.n_layers) + self.norm = nn.LayerNorm(c.d_model) + self.head = nn.Linear(c.d_model, c.vocab_size, bias=False) + self.head.weight = self.token_embedding.weight + + def forward( + self, + pixel_values: Tensor, + input_ids: Tensor, + history_pixel_values: Tensor | None = None, + history_states: Tensor | None = None, + current_state: Tensor | None = None, + history_mask: Tensor | None = None, + attention_mask: Tensor | None = None, + target_token: Tensor | None = None, + ) -> HighLevelOutput: + batch = pixel_values.shape[0] + if current_state is None: + current_state = pixel_values.new_zeros(batch, self.config.state_dim) + visual = self.memory_encoder(pixel_values, history_pixel_values, history_mask) + if history_states is None: + history_states = current_state.new_empty(batch, 0, self.config.state_dim) + states = self.state_projection(torch.cat((history_states, current_state[:, None]), 1)) + text = self.token_embedding(input_ids) + tokens = torch.cat((visual, states, text), dim=1) + observation_length = visual.shape[1] + states.shape[1] + length = tokens.shape[1] + q = torch.arange(length, device=tokens.device)[:, None] + k = torch.arange(length, device=tokens.device)[None] + allowed = torch.where(q < observation_length, k < observation_length, k <= q) + blocked = ~allowed + if attention_mask is None: + attention_mask = torch.ones_like(input_ids, dtype=torch.bool) + state_valid = torch.ones(batch, states.shape[1], dtype=torch.bool, device=tokens.device) + if history_mask is not None and states.shape[1] > 1: + state_valid[:, :-1] = history_mask.bool() + valid = torch.cat( + ( + torch.ones(batch, visual.shape[1], dtype=torch.bool, device=tokens.device), + state_valid, + attention_mask.bool(), + ), + dim=1, + ) + hidden = self.transformer(tokens, mask=blocked, src_key_padding_mask=~valid) + text_hidden = self.norm(hidden[:, observation_length:]) + lengths = attention_mask.long().sum(dim=1).clamp_min(1) - 1 + pooled = text_hidden[torch.arange(batch, device=tokens.device), lengths] + logits = self.head(pooled) + loss = None if target_token is None else F.cross_entropy(logits, target_token.long()) + return HighLevelOutput(logits, loss) + + +class TinyGoalGenerator(nn.Module): + """Small conditional-flow image model standing in for the BAGEL world model.""" + + def __init__( + self, + vocab_size: int, + image_channels: int = 3, + hidden_dim: int = 48, + text_dim: int = 64, + ) -> None: + super().__init__() + self.image_channels = image_channels + self.token_embedding = nn.Embedding(vocab_size, text_dim) + self.text_projection = nn.Linear(text_dim, hidden_dim) + self.time_embedding = TimeEmbedding(hidden_dim) + self.input_projection = nn.Conv2d(2 * image_channels, hidden_dim, 3, padding=1) + self.network = nn.Sequential( + nn.SiLU(), + nn.Conv2d(hidden_dim, hidden_dim, 3, padding=1), + nn.SiLU(), + nn.Conv2d(hidden_dim, hidden_dim, 3, padding=1), + nn.SiLU(), + nn.Conv2d(hidden_dim, image_channels, 3, padding=1), + ) + + def forward( + self, + current_pixel_values: Tensor, + input_ids: Tensor, + noisy_subgoal: Tensor, + timesteps: Tensor, + attention_mask: Tensor | None = None, + target_velocity: Tensor | None = None, + ) -> GoalGeneratorOutput: + if current_pixel_values.shape != noisy_subgoal.shape: + raise ValueError("current image and noisy subgoal must have identical shapes") + if current_pixel_values.ndim != 4 or current_pixel_values.shape[1] != self.image_channels: + raise ValueError("goal-generator images have the wrong shape") + if attention_mask is None: + attention_mask = torch.ones_like(input_ids, dtype=torch.bool) + embedded = self.token_embedding(input_ids) + weights = attention_mask.to(embedded.dtype)[..., None] + pooled = (embedded * weights).sum(1) / weights.sum(1).clamp_min(1) + condition = self.text_projection(pooled) + self.time_embedding(timesteps) + hidden = self.input_projection( + torch.cat((current_pixel_values, noisy_subgoal), dim=1) + ) + hidden = hidden + condition[:, :, None, None].to(hidden.dtype) + vector_field = self.network(hidden) + loss = None + if target_velocity is not None: + loss = F.mse_loss(vector_field, target_velocity) + return GoalGeneratorOutput(vector_field, loss) diff --git a/src/pi07mini/rtc.py b/src/pi07mini/rtc.py new file mode 100644 index 0000000..f0297f0 --- /dev/null +++ b/src/pi07mini/rtc.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import torch + + +MAX_RTC_DELAY_STEPS = 12 + + +@dataclass(frozen=True) +class RTCAlignment: + """A fixed-width target aligned to an inference/control delay.""" + + delayed_target_actions: torch.Tensor + valid_mask: torch.Tensor + executed_prefix_actions: torch.Tensor + delay_steps: int + + +def align_actions_for_rtc( + actions: torch.Tensor, + delay_steps: int, + *, + max_delay_steps: int = MAX_RTC_DELAY_STEPS, +) -> RTCAlignment: + """Shift targets left so prediction step zero starts after control delay. + + For delay ``d``, target position ``i`` supervises original action ``i+d``. + Vacated tail positions are zero padded and excluded by ``valid_mask``. + The already-executed prefix is returned for optional action-prefix input. + """ + + actions = torch.as_tensor(actions, dtype=torch.float32) + if actions.ndim < 2 or actions.shape[-2] < 1: + raise ValueError("actions must have shape [..., horizon, action_dim]") + if not 0 <= delay_steps <= max_delay_steps: + raise ValueError(f"delay_steps must lie in [0, {max_delay_steps}]") + horizon = actions.shape[-2] + if delay_steps >= horizon: + raise ValueError("delay_steps must be smaller than the action horizon") + aligned = torch.zeros_like(actions) + valid = torch.zeros(actions.shape[:-1], dtype=torch.bool, device=actions.device) + remaining = horizon - delay_steps + aligned[..., :remaining, :] = actions[..., delay_steps:, :] + valid[..., :remaining] = True + prefix = actions[..., :delay_steps, :].clone() + return RTCAlignment(aligned, valid, prefix, int(delay_steps)) + + +def sample_rtc_delay( + *, + generator: torch.Generator | None = None, + max_delay_steps: int = MAX_RTC_DELAY_STEPS, + action_horizon: int, +) -> int: + if action_horizon < 1: + raise ValueError("action_horizon must be positive") + maximum = min(int(max_delay_steps), action_horizon - 1) + if maximum < 0: + raise ValueError("no valid delay for an empty action horizon") + return int(torch.randint(maximum + 1, (), generator=generator).item()) diff --git a/src/pi07mini/runtime.py b/src/pi07mini/runtime.py new file mode 100644 index 0000000..d88b554 --- /dev/null +++ b/src/pi07mini/runtime.py @@ -0,0 +1,14 @@ +from __future__ import annotations +import json,random +from pathlib import Path +import numpy as np,torch,yaml +def load_config(path): + with Path(path).open() as f:return yaml.safe_load(f) +def seed_everything(seed:int):random.seed(seed);np.random.seed(seed);torch.manual_seed(seed) +def choose_device(name="auto"): + 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,value): + p=Path(path);p.parent.mkdir(parents=True,exist_ok=True);p.write_text(json.dumps(value,indent=2,ensure_ascii=False)) diff --git a/src/pi07mini/tokenizer.py b/src/pi07mini/tokenizer.py new file mode 100644 index 0000000..47392f0 --- /dev/null +++ b/src/pi07mini/tokenizer.py @@ -0,0 +1,393 @@ +"""Prompt and FAST-lite tokenization for the minimal pi0.7 policy. + +pi0.7 conditions the low-level policy on a richer prompt than earlier pi +models: task and subtask language, episode speed/quality/mistake metadata, +control mode, and data provenance. This tokenizer represents those concepts +with explicit marker/value pairs so the architecture can be validated without +shipping a full language tokenizer. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Iterable, Literal + +import torch +from torch import Tensor + + +ControlMode = Literal["joint", "ee"] + + +@dataclass(frozen=True) +class EpisodeMetadata: + speed_steps: int | None = None + quality: int | None = None + mistake: bool | None = None + control_mode: ControlMode = "joint" + source_type: str | None = None + + def __post_init__(self) -> None: + if self.speed_steps is not None and self.speed_steps <= 0: + raise ValueError("speed_steps must be positive") + if self.quality is not None and not 1 <= self.quality <= 5: + raise ValueError("quality must be in the inclusive range 1..5") + if self.control_mode not in ("joint", "ee"): + raise ValueError("control_mode must be 'joint' or 'ee'") + + +@dataclass(frozen=True) +class PolicySequence: + input_ids: Tensor + labels: Tensor + action_prefix_mask: Tensor + generated_token_start: int + action_start_index: int + prompt_length: int + metadata_indices: tuple[int, ...] + + +class _LabelVocabulary: + def __init__(self, labels: tuple[str, ...], first_token_id: int) -> None: + self.labels = labels + self.first_token_id = first_token_id + self._to_token = { + label: first_token_id + index for index, label in enumerate(labels) + } + self._to_label = {token: label for label, token in self._to_token.items()} + + @property + def token_ids(self) -> tuple[int, ...]: + return tuple(self._to_token[label] for label in self.labels) + + def encode(self, label: str) -> int: + try: + return self._to_token[label] + except KeyError as error: + raise ValueError(f"unknown categorical label: {label}") from error + + def decode(self, token: int | Tensor) -> str: + try: + return self._to_label[int(token)] + except KeyError as error: + raise ValueError(f"unknown categorical token: {int(token)}") from error + + +class MiniTokenizer: + """Explicit rich-prompt tokens plus a DCT-based FAST-lite action stream.""" + + BOS = 256 + TASK_MARKER = 257 + SUBTASK_MARKER = 258 + SPEED_MARKER = 259 + QUALITY_MARKER = 260 + MISTAKE_MARKER = 261 + CONTROL_MARKER = 262 + SOURCE_MARKER = 263 + + TASK_LABELS = ( + "drive", + "pick_place", + "fold", + "open_close", + "clean", + "cook", + "unknown", + ) + TASK_TOKEN_START = 264 + TASK_TOKEN_END = TASK_TOKEN_START + len(TASK_LABELS) - 1 + + SUBTASK_LABELS = ( + "keep_lane", + "steer_left", + "steer_right", + "slow_down", + "stop", + "accelerate", + "pick_place", + "fold", + "open_close", + "unknown", + ) + SUBTASK_TOKEN_START = TASK_TOKEN_END + 1 + SUBTASK_TOKEN_END = SUBTASK_TOKEN_START + len(SUBTASK_LABELS) - 1 + + SPEED_BINS = (500, 1000, 2000, 4000, 8000, 16000) + SPEED_TOKEN_START = SUBTASK_TOKEN_END + 1 + SPEED_TOKEN_END = SPEED_TOKEN_START + len(SPEED_BINS) - 1 + QUALITY_TOKEN_START = SPEED_TOKEN_END + 1 + QUALITY_TOKEN_END = QUALITY_TOKEN_START + 4 + MISTAKE_FALSE = QUALITY_TOKEN_END + 1 + MISTAKE_TRUE = MISTAKE_FALSE + 1 + CONTROL_JOINT = MISTAKE_TRUE + 1 + CONTROL_EE = CONTROL_JOINT + 1 + + SOURCE_LABELS = ( + "demonstration", + "autonomous", + "intervention", + "human_video", + "web", + "unknown", + ) + SOURCE_TOKEN_START = CONTROL_EE + 1 + SOURCE_TOKEN_END = SOURCE_TOKEN_START + len(SOURCE_LABELS) - 1 + + ACTION_START = SOURCE_TOKEN_END + 1 + ACTION_END = ACTION_START + 1 + ACTION_TOKEN_START = ACTION_END + 1 + ACTION_BINS = 64 + ACTION_TOKEN_END = ACTION_TOKEN_START + ACTION_BINS - 1 + ACTION_MINIMUM = -4.0 + ACTION_MAXIMUM = 4.0 + + def __init__(self) -> None: + self.tasks = _LabelVocabulary(self.TASK_LABELS, self.TASK_TOKEN_START) + self.subtasks = _LabelVocabulary(self.SUBTASK_LABELS, self.SUBTASK_TOKEN_START) + self.sources = _LabelVocabulary(self.SOURCE_LABELS, self.SOURCE_TOKEN_START) + + @property + def vocab_size(self) -> int: + return self.ACTION_TOKEN_END + 1 + + @property + def action_token_ids(self) -> range: + return range(self.ACTION_TOKEN_START, self.ACTION_TOKEN_END + 1) + + @staticmethod + def _classify_task(description: str) -> str: + text = description.casefold() + if any(word in text for word in ("drive", "lane", "road", "steer", "vehicle")): + return "drive" + if any(word in text for word in ("fold", "shirt", "towel", "laundry")): + return "fold" + if any(word in text for word in ("open", "close", "door", "drawer", "fridge")): + return "open_close" + if any(word in text for word in ("clean", "wipe", "wash", "tidy")): + return "clean" + if any(word in text for word in ("cook", "food", "recipe", "microwave")): + return "cook" + if any(word in text for word in ("pick", "place", "move", "put")): + return "pick_place" + return description if description in MiniTokenizer.TASK_LABELS else "unknown" + + @staticmethod + def _classify_subtask(description: str) -> str: + if description in MiniTokenizer.SUBTASK_LABELS: + return description + text = description.casefold() + if any(word in text for word in ("stop", "halt", "stationary")): + return "stop" + if any(word in text for word in ("slow", "brake", "deceler")): + return "slow_down" + if any(word in text for word in ("accelerat", "speed up")): + return "accelerate" + if "left" in text: + return "steer_left" + if "right" in text: + return "steer_right" + if any(word in text for word in ("lane", "straight", "continue", "follow")): + return "keep_lane" + if "fold" in text: + return "fold" + if any(word in text for word in ("open", "close")): + return "open_close" + if any(word in text for word in ("pick", "place", "put")): + return "pick_place" + return "unknown" + + @staticmethod + def _classify_source(source: str) -> str: + if source in MiniTokenizer.SOURCE_LABELS: + return source + text = source.casefold() + if any(word in text for word in ("demo", "teleop")): + return "demonstration" + if any(word in text for word in ("autonomous", "rollout", "policy")): + return "autonomous" + if any(word in text for word in ("intervention", "correction")): + return "intervention" + if any(word in text for word in ("human", "ego", "video")): + return "human_video" + if any(word in text for word in ("web", "internet")): + return "web" + return "unknown" + + def encode_prompt( + self, + task: str, + subtask: str | None, + metadata: EpisodeMetadata, + *, + include_episode_metadata: bool = True, + include_subtask: bool = True, + ) -> tuple[Tensor, tuple[int, ...]]: + """Encode prompt and return positions controlled by metadata CFG. + + Control mode is deliberately retained even in the unconditional branch, + matching pi0.7 training where control-mode dropout is not applied. + """ + + ids = [self.BOS, self.TASK_MARKER, self.tasks.encode(self._classify_task(task))] + if include_subtask and subtask is not None: + ids.extend( + (self.SUBTASK_MARKER, self.subtasks.encode(self._classify_subtask(subtask))) + ) + + metadata_indices: list[int] = [] + + def add_metadata(marker: int, value: int) -> None: + metadata_indices.extend((len(ids), len(ids) + 1)) + ids.extend((marker, value)) + + if include_episode_metadata: + if metadata.speed_steps is not None: + bin_index = min( + range(len(self.SPEED_BINS)), + key=lambda index: abs(self.SPEED_BINS[index] - metadata.speed_steps), + ) + add_metadata(self.SPEED_MARKER, self.SPEED_TOKEN_START + bin_index) + if metadata.quality is not None: + add_metadata( + self.QUALITY_MARKER, self.QUALITY_TOKEN_START + metadata.quality - 1 + ) + if metadata.mistake is not None: + add_metadata( + self.MISTAKE_MARKER, + self.MISTAKE_TRUE if metadata.mistake else self.MISTAKE_FALSE, + ) + if metadata.source_type is not None: + source = self._classify_source(metadata.source_type) + add_metadata(self.SOURCE_MARKER, self.sources.encode(source)) + + control = self.CONTROL_JOINT if metadata.control_mode == "joint" else self.CONTROL_EE + ids.extend((self.CONTROL_MARKER, control)) + return torch.tensor(ids, dtype=torch.long), tuple(metadata_indices) + + @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 FAST-lite 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, + task: str, + subtask: str | None, + actions: Tensor, + metadata: EpisodeMetadata, + *, + include_episode_metadata: bool = True, + include_subtask: bool = True, + ) -> PolicySequence: + prompt, metadata_indices = self.encode_prompt( + task, + subtask, + metadata, + include_episode_metadata=include_episode_metadata, + include_subtask=include_subtask, + ) + action_tokens = self.encode_actions(actions).cpu() + input_ids = torch.cat((prompt, action_tokens)) + action_start_index = prompt.numel() + labels = torch.full_like(input_ids, -100) + 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, + action_prefix_mask=action_prefix_mask, + generated_token_start=0, + action_start_index=action_start_index, + prompt_length=prompt.numel(), + metadata_indices=metadata_indices, + ) + + def build_metadata_cfg_pair( + self, + task: str, + subtask: str | None, + actions: Tensor, + metadata: EpisodeMetadata, + ) -> tuple[PolicySequence, PolicySequence]: + """Return conditioned and episode-metadata-dropped policy sequences.""" + + conditioned = self.build_policy_sequence(task, subtask, actions, metadata) + unconditional = self.build_policy_sequence( + task, + subtask, + actions, + metadata, + include_episode_metadata=False, + ) + return conditioned, unconditional + diff --git a/src/pi07mini/train.py b/src/pi07mini/train.py new file mode 100644 index 0000000..df6c8c3 --- /dev/null +++ b/src/pi07mini/train.py @@ -0,0 +1,40 @@ +from __future__ import annotations +import argparse,copy,json +from pathlib import Path +import torch +from .data import Pi07SteerableDataset,materialize_subset +from .flow import FlowMatching +from .model import Pi07Config,Pi07Mini,HighLevelPolicy,TinyGoalGenerator +from .runtime import load_config,seed_everything,choose_device,write_json +from .tokenizer import MiniTokenizer,EpisodeMetadata + +def build_config(cfg,tok): + m=cfg['model'];return Pi07Config(vocab_size=tok.vocab_size,action_dim=2,state_dim=2,image_size=cfg['data']['image_size'],patch_size=m['patch_size'],d_model=m['d_model'],n_heads=m['n_heads'],n_layers=m['n_layers'],mlp_ratio=m['mlp_ratio'],max_history_frames=cfg['data']['history_frames'],max_text_tokens=m['max_text_tokens'],max_action_tokens=cfg['data']['action_horizon'],action_start_token_id=tok.ACTION_START) + +def train(config_path): + cfg=load_config(config_path);root=Path(__file__).resolve().parents[2];seed_everything(cfg['seed']);dev=choose_device(cfg['device']) + manifest=materialize_subset(cfg['data']['source_manifest'],root/'data/overfit',max_samples=cfg['data']['max_samples']) + ds=Pi07SteerableDataset(manifest,image_size=cfg['data']['image_size'],history_frames=cfg['data']['history_frames'],action_horizon=cfg['data']['action_horizon'],augmentation_factor=cfg['data']['augmentation_factor']) + tok=MiniTokenizer();pcfg=build_config(cfg,tok);policy=Pi07Mini(pcfg).to(dev);high=HighLevelPolicy(pcfg).to(dev);goal=TinyGoalGenerator(tok.vocab_size).to(dev);flow=FlowMatching(cfg['flow']['integration_steps']);out=root/cfg['output_dir'];out.mkdir(parents=True,exist_ok=True);history=[] + # Goal generator + opt=torch.optim.AdamW(goal.parameters(),lr=cfg['train']['learning_rate']) + for step in range(1,cfg['train']['goal_steps']+1): + s=ds[(step-1)%len(ds)];meta=EpisodeMetadata(**s['token_metadata']);seq=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta);ids=seq.input_ids[:seq.action_start_index][None].to(dev);target=s['subgoal_pixel_values'][None].to(dev);t=torch.rand(1,device=dev);noise=torch.randn_like(target);noisy=t[:,None,None,None]*target+(1-t[:,None,None,None])*noise;o=goal(s['image'][None].to(dev),ids,noisy,t,target_velocity=target-noise);opt.zero_grad();o.loss.backward();opt.step() + if step==1 or step%cfg['train']['log_every']==0:row={'stage':'goal','step':step,'loss':float(o.loss.detach().cpu())};history.append(row);print(json.dumps(row)) + # High-level semantic policy + opt=torch.optim.AdamW(high.parameters(),lr=cfg['train']['learning_rate']) + for step in range(1,cfg['train']['high_level_steps']+1): + s=ds[(step-1)%len(ds)];meta=EpisodeMetadata(**s['token_metadata']);prompt,_=tok.encode_prompt('drive safely',None,meta,include_subtask=False);target=torch.tensor([tok.subtasks.encode(tok._classify_subtask(s['reasoning']))],device=dev);o=high(s['image'][None].to(dev),prompt[None].to(dev),s['history_images'][None].to(dev),s['history_states'][None].to(dev),s['current_state'][None].to(dev),target_token=target);opt.zero_grad();o.loss.backward();opt.step() + if step==1 or step%cfg['train']['log_every']==0:row={'stage':'high_level','step':step,'loss':float(o.loss.detach().cpu())};history.append(row);print(json.dumps(row)) + # Full steerable policy, alternating prompt-dropout ablations and RTC targets. + opt=torch.optim.AdamW(policy.parameters(),lr=cfg['train']['learning_rate']) + for step in range(1,cfg['train']['policy_steps']+1): + s=ds[(step-1)%len(ds)];meta=EpisodeMetadata(**s['token_metadata']);include_meta=(step%4)!=0;seq=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta,include_episode_metadata=include_meta);ids=seq.input_ids[None].to(dev);labels=seq.labels[None].to(dev);target=s['rtc_target_actions'][None].to(dev);noisy,t,vel=flow.training_batch(target);use_goal=(step%4)!=1;use_history=(step%5)!=1 + o=policy(s['image'][None].to(dev),ids,noisy,t,history_pixel_values=s['history_images'][None].to(dev) if use_history else None,history_states=s['history_states'][None].to(dev) if use_history else None,current_state=s['current_state'][None].to(dev),subgoal_pixel_values=s['subgoal_pixel_values'][None].to(dev) if use_goal else None,action_prefix_mask=seq.action_prefix_mask[None].to(dev),text_labels=labels) + valid=s['rtc_valid_mask'][None].to(dev)[:,:,None];flow_loss=((o.vector_field-vel).square()*valid).sum()/valid.sum().clamp_min(1);loss=o.text_loss+cfg['train']['flow_loss_weight']*flow_loss;opt.zero_grad();loss.backward();torch.nn.utils.clip_grad_norm_(policy.parameters(),cfg['train']['grad_clip_norm']);opt.step() + if step==1 or step%cfg['train']['log_every']==0:row={'stage':'policy','step':step,'loss':float(loss.detach().cpu()),'text_loss':float(o.text_loss.detach().cpu()),'flow_loss':float(flow_loss.detach().cpu())};history.append(row);print(json.dumps(row)) + ck={'policy':policy.state_dict(),'high_level':high.state_dict(),'goal':goal.state_dict(),'policy_config':pcfg.__dict__,'config':cfg,'action_statistics':ds.action_normalizer.as_dict()};torch.save(ck,out/'checkpoint.pt');write_json(out/'training_history.json',history);write_json(out/'summary.json',{'device':str(dev),'samples':len(ds),'policy_parameters':sum(p.numel() for p in policy.parameters()),'goal_parameters':sum(p.numel() for p in goal.parameters()),'high_level_parameters':sum(p.numel() for p in high.parameters())});return out/'checkpoint.pt' + +def main(): + p=argparse.ArgumentParser();p.add_argument('--config',default='configs/overfit.yaml');train(p.parse_args().config) +if __name__=='__main__':main() diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..0951622 --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import torch + +from pi07mini.context import ( + PolicyMetadata, + build_ambiguous_history_pair, + drop_prompt_components, + endpoint_subgoal_heatmap, + format_policy_prompt, + make_history_proxy_frames, + prompt_components, +) + + +def _history() -> torch.Tensor: + x = torch.linspace(-8.0, 0.0, 8) + return torch.stack((x, 0.04 * x.square()), -1) + + +def test_subgoal_heatmap_depends_on_endpoint_only() -> None: + endpoint = torch.tensor([20.0, 4.0]) + first = endpoint_subgoal_heatmap(endpoint, size=24) + # Two future paths with radically different interiors produce exactly the + # same condition because only their shared endpoint is passed to the API. + path_a = torch.stack((torch.linspace(0.0, 20.0, 16), torch.linspace(0.0, 4.0, 16)), -1) + path_b = path_a.clone() + path_b[1:-1, 1] += 100.0 + second = endpoint_subgoal_heatmap(path_b[-1], size=24) + torch.testing.assert_close(first, second, atol=0.0, rtol=0.0) + assert first.shape == (1, 24, 24) + assert float(first.max()) == 1.0 + assert torch.isfinite(first).all() + + +def test_history_proxy_is_oldest_to_newest_and_future_free() -> None: + image = torch.linspace(0.0, 1.0, 3 * 20 * 20).reshape(3, 20, 20) + frames = make_history_proxy_frames(image, _history(), frames=6) + assert frames.shape == (6, 3, 20, 20) + torch.testing.assert_close(frames[-1], image * 0.98) + assert not torch.equal(frames[0], frames[-1]) + + +def test_ambiguous_histories_are_distinct_but_share_current_state() -> None: + pair = build_ambiguous_history_pair(_history(), frames=6) + assert pair.shape == (2, 6, 2) + torch.testing.assert_close(pair[0, -1], pair[1, -1]) + assert not torch.allclose(pair[0, :-1], pair[1, :-1]) + + +def test_prompt_component_dropout_is_independent_and_deterministic() -> None: + metadata = PolicyMetadata("fast", "good", "none", "autonomous") + components = prompt_components(metadata, task="Drive safely.") + generator_a = torch.Generator().manual_seed(42) + generator_b = torch.Generator().manual_seed(42) + visible_a, mask_a = drop_prompt_components( + components, drop_probability=0.5, generator=generator_a + ) + visible_b, mask_b = drop_prompt_components( + components, drop_probability=0.5, generator=generator_b + ) + assert visible_a == visible_b + torch.testing.assert_close(mask_a, mask_b) + assert visible_a["task"] == "Drive safely." + assert mask_a.shape == (5,) + text = format_policy_prompt(visible_a) + assert "Drive safely." in text + for dropped in set(components) - set(visible_a): + assert f"{dropped}=" not in text + + +def test_full_metadata_dropout_keeps_only_task() -> None: + components = prompt_components(PolicyMetadata("slow", "poor", "stop", "autonomous")) + visible, mask = drop_prompt_components(components, drop_probability=1.0) + assert set(visible) == {"task"} + assert mask.tolist() == [True, False, False, False, False] diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..f5a7c1c --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import torch +from PIL import Image + +from pi07mini.data import ( + Pi07SteerableDataset, + build_policy_trajectories, + 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_policy_variants_and_metadata() -> None: + x = torch.linspace(-7.0, 0.0, 8) + history = torch.stack((x, torch.zeros_like(x)), -1) + fx = torch.linspace(1.0, 24.0, 16) + future = torch.stack((fx, 0.02 * fx.square()), -1) + policies = build_policy_trajectories(history, future) + assert [policy.kind for policy in policies] == [ + "expert", + "fast", + "slow", + "understeer", + "stop", + ] + metadata = {policy.kind: policy.metadata.as_dict() for policy in policies} + assert metadata["expert"] == { + "speed": "normal", + "quality": "expert", + "mistake": "none", + "control": "human", + } + assert metadata["understeer"]["mistake"] == "understeer" + assert metadata["stop"]["quality"] == "poor" + assert policies[0].metadata.as_token_metadata() == { + "speed_steps": 1_000, + "quality": 5, + "mistake": False, + "control_mode": "ee", + "source_type": "demonstration", + } + assert torch.allclose(policies[-1].future_xy[-1], policies[-1].future_xy[4]) + + +def test_dataset_contract_and_roundtrip(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 = Pi07SteerableDataset( + manifest, + image_size=32, + prompt_dropout=0.0, + max_rtc_delay_steps=12, + ) + assert len(dataset) == 5 + assert {policy.kind for policy in dataset.policies} == { + "expert", + "fast", + "slow", + "understeer", + "stop", + } + item = dataset[0] + assert item["pixel_values"].shape == (3, 32, 32) + assert item["history_pixel_values"].shape == (6, 3, 32, 32) + assert item["history_states"].shape == (6, 2) + assert item["current_state"].shape == (2,) + assert item["actions"].shape == (16, 2) + assert item["subgoal_heatmap"].shape == (1, 32, 32) + assert item["subgoal_pixel_values"].shape == (3, 32, 32) + assert item["ambiguous_history_pair"].shape == (2, 6, 2) + assert 0 <= item["rtc_delay_steps"] <= 12 + assert item["rtc_target_actions"].shape == (16, 2) + assert item["rtc_valid_mask"].shape == (16,) + assert item["reasoning"] == "Steer left." + assert item["prompt_component_mask"].all() + assert item["token_metadata"]["quality"] == 5 + assert item["control_mode"] == "ee" + decoded = dataset.action_normalizer.decode(item["actions"], item["history_xy"]) + torch.testing.assert_close(decoded, item["future_xy"], atol=1e-5, rtol=1e-5) + + +def test_dataset_is_deterministic_per_index(tmp_path: Path) -> None: + source = _make_source_project(tmp_path / "source") + dataset = Pi07SteerableDataset( + source, + image_size=24, + prompt_dropout=0.5, + seed=11, + ) + first = dataset[3] + second = dataset[3] + assert first["prompt_components"] == second["prompt_components"] + assert first["rtc_delay_steps"] == second["rtc_delay_steps"] + torch.testing.assert_close(first["rtc_target_actions"], second["rtc_target_actions"]) diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..f41bf62 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,63 @@ +import torch + +from pi07mini.memory import MEMConfig, MEMVideoEncoder + + +def tiny_encoder() -> MEMVideoEncoder: + return MEMVideoEncoder( + MEMConfig( + image_size=16, + patch_size=8, + d_model=16, + n_heads=4, + n_layers=2, + max_history_frames=6, + ) + ).eval() + + +def test_mem_output_token_count_is_independent_of_history_length() -> None: + encoder = tiny_encoder() + current = torch.randn(2, 3, 16, 16) + with torch.no_grad(): + no_history = encoder(current) + long_history = encoder(current, torch.randn(2, 6, 3, 16, 16)) + assert no_history.shape == (2, 4, 16) + assert long_history.shape == no_history.shape + + +def test_mem_is_sensitive_to_temporal_order() -> None: + torch.manual_seed(7) + encoder = tiny_encoder() + current = torch.randn(1, 3, 16, 16) + history = torch.stack( + ( + torch.zeros(3, 16, 16), + torch.ones(3, 16, 16), + torch.full((3, 16, 16), 2.0), + ) + )[None] + with torch.no_grad(): + chronological = encoder(current, history) + reversed_history = encoder(current, history.flip(1)) + assert not torch.allclose(chronological, reversed_history) + + +def test_masked_history_frame_cannot_change_memory() -> None: + torch.manual_seed(9) + encoder = tiny_encoder() + current = torch.randn(1, 3, 16, 16) + history = torch.randn(1, 2, 3, 16, 16) + changed = history.clone() + changed[:, 0] = 1000.0 + mask = torch.tensor([[False, True]]) + with torch.no_grad(): + first = encoder(current, history, mask) + second = encoder(current, changed, mask) + torch.testing.assert_close(first, second) + + +def test_current_frame_temporal_position_is_exactly_zero() -> None: + encoding = MEMVideoEncoder.temporal_position(torch.tensor([3, 2, 1, 0]), 15) + assert torch.count_nonzero(encoding[-1]) == 0 + diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..01acfed --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,180 @@ +import torch + +from pi07mini.model import ( + HighLevelPolicy, + Pi07Config, + Pi07Mini, + PrefixLayout, + TinyGoalGenerator, +) +from pi07mini.tokenizer import EpisodeMetadata, MiniTokenizer + + +def tiny_model() -> Pi07Mini: + tokenizer = MiniTokenizer() + return Pi07Mini( + Pi07Config( + vocab_size=tokenizer.vocab_size, + image_size=16, + patch_size=16, + d_model=32, + n_heads=4, + n_layers=1, + memory_layers=1, + max_history_frames=3, + max_text_tokens=64, + max_action_tokens=4, + action_start_token_id=tokenizer.ACTION_START, + ) + ) + + +def sample_sequence(): + tokenizer = MiniTokenizer() + metadata = EpisodeMetadata(2000, 5, False, "ee", "demonstration") + return tokenizer, tokenizer.build_policy_sequence( + "drive", "steer_left", torch.zeros(2, 2), metadata + ) + + +def common_inputs(sequence): + return dict( + pixel_values=torch.randn(1, 3, 16, 16), + history_pixel_values=torch.randn(1, 2, 3, 16, 16), + history_states=torch.randn(1, 2, 2), + current_state=torch.randn(1, 2), + subgoal_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], + ) + + +def test_block_causal_attention_matches_pi07_modal_order() -> None: + layout = PrefixLayout(observation_tokens=2, goal_tokens=2, text_tokens=3) + valid = torch.ones(1, layout.total_tokens, dtype=torch.bool) + edges = Pi07Mini.build_prefix_attention_mask(layout, valid)[0] + assert edges[0, :2].all() + assert not edges[0, 2:].any() # observation cannot read goal or text + assert edges[2, :4].all() + assert not edges[2, 4:].any() # goal reads observation + goal, not text + assert edges[4, :5].all() + assert not edges[4, 5:].any() # first text token is causal + assert edges[6].all() + + +def test_prefix_has_fixed_mem_tokens_and_one_token_per_state() -> None: + _, sequence = sample_sequence() + model = tiny_model() + inputs = common_inputs(sequence) + prefix, _, layout = model.encode_prefix( + inputs["pixel_values"], + inputs["history_pixel_values"], + inputs["history_states"], + inputs["current_state"], + inputs["input_ids"], + inputs["subgoal_pixel_values"], + ) + assert layout.observation_tokens == 1 + 2 + 1 # MEM + history states + current + assert layout.goal_tokens == 1 + assert prefix.shape[1] == layout.total_tokens + + +def test_flow_cannot_read_teacher_forced_fast_tokens() -> None: + torch.manual_seed(12) + tokenizer, sequence = sample_sequence() + model = tiny_model().eval() + changed_ids = sequence.input_ids.clone() + changed_ids[sequence.action_start_index + 1 : -1] = tokenizer.ACTION_TOKEN_START + inputs = common_inputs(sequence) + with torch.no_grad(): + first = model(**inputs).vector_field + inputs["input_ids"] = changed_ids[None] + second = model(**inputs).vector_field + torch.testing.assert_close(first, second) + + +def test_flow_loss_is_knowledge_insulated_from_all_vlm_parameters() -> None: + torch.manual_seed(13) + _, sequence = sample_sequence() + model = tiny_model() + output = model( + **common_inputs(sequence), target_velocity=torch.randn(1, 2, 2) + ) + assert output.flow_loss is not None + output.flow_loss.backward() + assert all( + parameter.grad is None or torch.count_nonzero(parameter.grad) == 0 + for parameter in model.vlm_parameters() + ) + assert any( + parameter.grad is not None and torch.count_nonzero(parameter.grad) > 0 + for parameter in model.action_parameters() + ) + + +def test_fast_cross_entropy_still_trains_vlm() -> None: + _, sequence = sample_sequence() + model = tiny_model() + output = model( + **common_inputs(sequence), text_labels=sequence.labels[None] + ) + assert output.text_loss is not None + output.text_loss.backward() + assert model.token_embedding.weight.grad is not None + assert torch.count_nonzero(model.token_embedding.weight.grad) > 0 + + +def test_visual_subgoal_conditions_flow_expert() -> None: + torch.manual_seed(14) + _, sequence = sample_sequence() + model = tiny_model().eval() + inputs = common_inputs(sequence) + with torch.no_grad(): + first = model(**inputs).vector_field + inputs["subgoal_pixel_values"] = inputs["subgoal_pixel_values"] + 3.0 + second = model(**inputs).vector_field + assert not torch.allclose(first, second) + + +def test_metadata_cfg_uses_paper_extrapolation_formula() -> None: + conditioned = torch.tensor([1.0, 2.0]) + unconditional = torch.tensor([0.5, 1.5]) + torch.testing.assert_close( + Pi07Mini.combine_metadata_cfg(conditioned, unconditional, 0.0), conditioned + ) + torch.testing.assert_close( + Pi07Mini.combine_metadata_cfg(conditioned, unconditional, 1.0), + torch.tensor([1.5, 2.5]), + ) + + +def test_high_level_policy_predicts_next_subtask_token() -> None: + model = HighLevelPolicy(tiny_model().config) + output = model( + pixel_values=torch.randn(2, 3, 16, 16), + history_pixel_values=torch.randn(2, 2, 3, 16, 16), + history_states=torch.randn(2, 2, 2), + current_state=torch.randn(2, 2), + input_ids=torch.tensor([[256, 257, 264], [256, 257, 264]]), + target_token=torch.tensor([271, 272]), + ) + assert output.logits.shape == (2, tiny_model().config.vocab_size) + assert output.loss is not None + + +def test_tiny_goal_generator_predicts_image_flow() -> None: + tokenizer = MiniTokenizer() + generator = TinyGoalGenerator(tokenizer.vocab_size) + current = torch.randn(2, 3, 16, 16) + noisy = torch.randn_like(current) + output = generator( + current, + torch.tensor([[256, 257, 264], [256, 257, 264]]), + noisy, + torch.rand(2), + target_velocity=torch.randn_like(current), + ) + assert output.vector_field.shape == current.shape + assert output.loss is not None diff --git a/tests/test_rtc.py b/tests/test_rtc.py new file mode 100644 index 0000000..c233e8a --- /dev/null +++ b/tests/test_rtc.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import pytest +import torch + +from pi07mini.rtc import align_actions_for_rtc, sample_rtc_delay + + +def _actions() -> torch.Tensor: + return torch.arange(32, dtype=torch.float32).reshape(16, 2) + + +def test_zero_delay_preserves_entire_chunk() -> None: + actions = _actions() + aligned = align_actions_for_rtc(actions, 0) + torch.testing.assert_close(aligned.delayed_target_actions, actions) + assert aligned.valid_mask.all() + assert aligned.executed_prefix_actions.shape == (0, 2) + + +def test_delay_shifts_target_and_masks_padded_tail() -> None: + actions = _actions() + aligned = align_actions_for_rtc(actions, 12) + torch.testing.assert_close(aligned.delayed_target_actions[:4], actions[12:]) + assert torch.count_nonzero(aligned.delayed_target_actions[4:]) == 0 + assert aligned.valid_mask.tolist() == [True] * 4 + [False] * 12 + torch.testing.assert_close(aligned.executed_prefix_actions, actions[:12]) + + +def test_rtc_alignment_preserves_batch_dimensions() -> None: + actions = torch.stack((_actions(), _actions() + 100.0)) + aligned = align_actions_for_rtc(actions, 3) + assert aligned.delayed_target_actions.shape == (2, 16, 2) + assert aligned.valid_mask.shape == (2, 16) + torch.testing.assert_close(aligned.delayed_target_actions[:, :13], actions[:, 3:]) + + +def test_delay_sampling_covers_closed_zero_to_twelve_range() -> None: + generator = torch.Generator().manual_seed(9) + values = { + sample_rtc_delay(generator=generator, action_horizon=16) for _ in range(300) + } + assert min(values) == 0 + assert max(values) == 12 + + +@pytest.mark.parametrize("delay", [-1, 13, 16]) +def test_invalid_delay_is_rejected(delay: int) -> None: + with pytest.raises(ValueError): + align_actions_for_rtc(_actions(), delay) diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py new file mode 100644 index 0000000..55f299c --- /dev/null +++ b/tests/test_tokenizer.py @@ -0,0 +1,65 @@ +import torch + +from pi07mini.tokenizer import EpisodeMetadata, MiniTokenizer + + +def metadata() -> EpisodeMetadata: + return EpisodeMetadata( + speed_steps=2100, + quality=5, + mistake=False, + control_mode="ee", + source_type="autonomous_rollout", + ) + + +def test_prompt_contains_rich_metadata_and_control_tokens() -> None: + tokenizer = MiniTokenizer() + prompt, metadata_indices = tokenizer.encode_prompt( + "drive on the road", "steer_left", metadata() + ) + assert tokenizer.SPEED_MARKER in prompt + assert tokenizer.QUALITY_MARKER in prompt + assert tokenizer.MISTAKE_MARKER in prompt + assert tokenizer.SOURCE_MARKER in prompt + assert tokenizer.CONTROL_MARKER in prompt + assert tokenizer.CONTROL_EE in prompt + assert len(metadata_indices) == 8 + + +def test_metadata_cfg_pair_drops_metadata_but_retains_control_mode() -> None: + tokenizer = MiniTokenizer() + actions = torch.zeros(3, 2) + conditioned, unconditional = tokenizer.build_metadata_cfg_pair( + "drive", "keep_lane", actions, metadata() + ) + assert tokenizer.QUALITY_MARKER in conditioned.input_ids + assert tokenizer.QUALITY_MARKER not in unconditional.input_ids + assert tokenizer.CONTROL_EE in conditioned.input_ids + assert tokenizer.CONTROL_EE in unconditional.input_ids + assert conditioned.metadata_indices + assert not unconditional.metadata_indices + + +def test_flow_prefix_ends_before_fast_stream() -> None: + tokenizer = MiniTokenizer() + sequence = tokenizer.build_policy_sequence( + "drive", "keep_lane", torch.zeros(4, 2), metadata() + ) + assert sequence.input_ids[sequence.action_start_index] == tokenizer.ACTION_START + assert sequence.action_prefix_mask[: sequence.action_start_index].all() + assert not sequence.action_prefix_mask[sequence.action_start_index :].any() + assert sequence.labels[sequence.action_start_index - 1] == 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 +