diff --git a/CURRENT.md b/CURRENT.md index b1d3b56..0612466 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -44,14 +44,18 @@ Phase 4AK D18 表征验证 PASS 原样保留。1.8 ms 为工程 APL 时序假设 Phase 4AN 的有限工程门已封存;21 份实际使用源码已按 SHA 锁定,lab 整个目录未封存。科学学习草案仍未登记,没有候选;工程测试不是条件化实验。 -完整 conditioning 草案已有 68,000 epochs 与合计 10,760 生物秒(含 idle),但 evaluation/probe 库存未完整。按当前 100 ms 样本算术外推,仅核心模拟约 64–66 小时,不能直接作为完整工期或预算。先补齐评估/控制/保持集成与有限预算,再登记小规模机制验证,不直接启动大批量训练。 +新增科学代码仍为未注册 DRAFT:评估清单、按真实 CS 序列对齐的控制政策、完整 63 位种子的输入接口,以及重用固定 AM/J 实现的单轮 eligibility 更新桥。后者只返回 delta,不安装权重。详见[条件化准备进度与限制](docs/progress/conditioning-preparation-2026-09-30.md)。 + +服务器 CPU 合成测试 119/119 通过,0 跳过;21 个 AN 注册源测试前后 SHA 均未变。当前续跑器禁止父子段更换 overlay,因此“保留末态并使用新 delta”的 retention 更新边界仍需新增接口验证,不是现有工程 PASS 已覆盖的能力。 + +完整 conditioning 草案为 68,000 epochs、10,760 生物秒(含 idle),新显式评估政策再列出 9,440 次 probes / 1,132.8 生物秒,总计 11,892.8 生物秒。这是未执行的条件性计划库存,不是工期预算。仍缺实际输入/状态/delta 绑定协调器、真实 CUDA 输入一致性验证、反转/连续保持功能门和完整资源预算;先登记有限实现检查,不直接启动大批量训练。 目标仍是“学习可以使用”,当前没有达到。接口验证或静态权重效果不能替代真实条件化。 后续须预注册固定 olfactory A/B、具体 DAN type/compartment、至少五个互不重叠训练种子流及独立 evaluation streams;保留原 eligibility-v2、人工 US 与实际 DAN 放电的明确区分,以及配对/控制、反转、延迟奖励、保持等功能验证。 继续冻结 raw/base CSR/W_syn/sign、既有 APL 参数、eligibility-v2、14,232-edge mask 和全部已封存产物。不调参挽救结果,不接游戏、视觉、decoder、RL 或 Web。 -AM 已正式封存并在服务器发布 3,020 份小证据;本地已按发布收据逐项验收 3,040 个文件(含控制与清单文件)。Git 同步范围仅为源码、文档及小证据,须单独验证提交载荷。大型轨迹保留在服务器,小证据同步不等于全量异机备份。 +AM 已正式封存并在服务器发布 3,020 份小证据;本地已按发布收据逐项验收 3,040 个文件(含控制与清单文件)。AN 的 599 个小证据文件已逐项本地验收;620 个实际 Git blobs 校验通过并推送为 `aeccb7d0ccdcf6788e0a8de60e7ab96f70dbe734`。Git 同步范围仅为源码、文档及小证据。大型轨迹保留在服务器,小证据同步不等于全量异机备份。 模型 manifest SHA-256:`7edeb251ecbeac5a64805fefe171a0a0bf8bba2675919ef922cc1a329c203ea8`。 实验 manifest SHA-256:`9e24b682935a9b18e05db7a933080c5f63ddbeb8a0c85cc0df1cfe1d06d88ab4`。 diff --git a/docs/progress/conditioning-preparation-2026-09-30.md b/docs/progress/conditioning-preparation-2026-09-30.md new file mode 100644 index 0000000..f490cac --- /dev/null +++ b/docs/progress/conditioning-preparation-2026-09-30.md @@ -0,0 +1,56 @@ +# 独立条件化分支:准备进度与边界 + +状态:**未注册的科学实现草案,不是 learning PASS,不是 learned candidate**。 + +已完成的 Phase 4AN 工程证据另行封存并推送至 +`aeccb7d0ccdcf6788e0a8de60e7ab96f70dbe734`:32/32 次 GPU 运行、74 项精确比较通过。 +599 个小证据文件已下载到本地逐项验收,共 5,508,335 bytes;620 个实际 Git blobs +(含 21 个注册源文件)核对通过。大体积轨迹仍在服务器,不是全量异机备份。 + +## 本次补齐的实现 + +1. 评估清单:完整重建训练计划和独立评估种子来源;同一训练重复内复用 A/B + 评估输入作配对比较,不能把反复 probe 当成更多训练重复。 +2. 显式评估政策:CS 为 0–100 ms,观察到 120 ms;保留 0/1/2/5/10/20/50 + 检查点。控制组核对实际有序 CS 前缀及源种子,不只核对数量。 +3. 输入接口:保留原离散 float32 Bernoulli 抽样、PN 列顺序和完整 63 位种子; + 必须指定 CUDA 设备和抽样上限。CS/US 重叠默认报错,显式布尔合并才允许并记录冲突。 +4. 单轮更新桥:复用固定 SHA 的 AM/J 原实现,从本轮真实稀疏事件计算 eligibility, + 在完整 epoch 结束后返回下一轮 delta;只计算,不安装权重,不改 base。 + +这里的输入单元测试使用 CPU/mock,不代表已经实测 CUDA 随机序列或高位种子的作用。 +单轮更新测试使用合成事件,不是已经运行条件化。人工 D 指令与实际 DAN 放电仍是两项证据。 +attempted update、实际 delta 改变和已达 depression floor 分别统计。 + +服务器 CPU 合成测试:**119/119 通过,0 跳过**,测试框架记录耗时 241.205 s。 +其中包含原 Torch CPU eligibility 表达式与 NumPy 路径的合成数值比较,不是 CUDA 比较。 +运行时隐藏全部 CUDA 设备;没有真实脑输入、网络模拟或 conditioning。21 个 AN 注册源在 +测试前后均另行校验保持原 SHA。 + +## 对齐不等于所有条件相同 + +非配对控制的中间奖励次数、经过时间可以与配对组不同;随机奖励的中间奖励次数也可能不同。 +这些差异单独记录,不称为全量匹配。DAN-only 的 CS 被有意移除,不能声称 CS exposure 相同。 +反转两臂各按自己的强化机会取点,但保留原始 acquisition 的气味方向与 pre baseline。 +所有检查点都在完整 epoch 及其边界更新之后,不是在 US tick 当场插入 probe。 + +该政策条件性列出 9,440 次评估(1,132.8 生物秒),加已有训练/控制/连续 idle 共 +11,892.8 生物秒;这是完整草案的计划库存,不是已执行数量,也不是工期预算。 +完整批量训练尚未启动。 + +## 下一步仍然缺少 + +- 把每个真实输入、delta-before/after、状态和队列绑定外部 SHA 与祖先记录的完整协调器。 +- 在指定 4090 上预先登记有限输入/更新衔接验证,实测完整种子、参考一致性和实际开销。 +- 完成反转与连续 retention 的功能门实现,并核算完整存储、时间上限和停止条件。 +- retention 还有明确接口缺口:当前冻结续跑器要求子段 overlay 与父检查点完全相同。 + 最后训练 epoch 的末态属于 delta-before,边界更新却产生 delta-after;不能把这组 + “原神经末态 + 新权重”伪装成已经验证的同权重续跑。必须在新文件中明确实现并有限验证 + 更新边界的状态交接,不能修改封存续跑器来绕过约束。 +- 以上通过后才登记执行 acquisition 及全部控制;若失败,保留失败证据,不通过改参数补救。 + +全部新增科学代码仍是 DRAFT;源文件清单与测试记录保存于 +`models/male-cns-lif/olfactory-conditioning-lab-v1/draft-source-inventory.json`。 +该记录不是实验注册或目录封存。既有 21 个 AN 注册源按其原清单单独核验,不纳入新草案清单。 +不运行游戏、RL、decoder、视觉、Web 或 3D;不改 frozen reference/runtime、图、符号、 +W_syn、APL 标定、eligibility-v2 或 14,232-edge mask。 diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/EVALUATION_POLICY_DRAFT.md b/models/male-cns-lif/olfactory-conditioning-lab-v1/EVALUATION_POLICY_DRAFT.md new file mode 100644 index 0000000..364063c --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/EVALUATION_POLICY_DRAFT.md @@ -0,0 +1,68 @@ +# Explicit evaluation policy — draft implementation decision + +Status: **DRAFT / NOT REGISTERED / NO SCIENTIFIC EXECUTION**. + +This is a pre-outcome implementation choice by the coordinator for the existing +conditioning proposal. It does not change a frozen brain, eligibility-v2, +anatomical mask, odor identity, five-replicate matrix or scientific pass gate. +The AN engineering result remains separate; none of its 21 sources is edited. + +## Probe timing and snapshots + +- Every evaluation probe lasts 120 ms, with CS requests only in the half-open + interval [0, 100 ms). The remaining 20 ms is a no-CS tail. This matches the + proposed normal training CS duration; it is not the old L full-120-ms input. +- Normal, delayed and reversal curves use 0, 1, 2, 5, 10, 20 and 50 scheduled + reinforcement opportunities. Intermediate snapshots are taken after the + **complete epoch containing** that opportunity and its boundary update, + not immediately at the US tick. +- Terminal checkpoint 50 includes all planned epochs, including trailing + neutral or blank epochs. It is not an early snapshot chosen for response. +- Each snapshot executes both A and B evaluation inputs. No baseline-output + deduplication is assumed. Repeated probes use the same independent evaluation + input within a training replicate; they are paired observations, not new + training replicates. There are still five independent training replicates + per experimental cell and 80 independent A/B evaluation streams overall. +- Probe US and weight updates are disabled. A probe cannot update the training + or retention trajectory. Acquisition/reversal/delayed start from the common + declared reset state; retention forks the actual continuous saved state. + +## Intermediate control alignment + +For normal and delayed experiments, obtain the paired arm's complete ordered +CS prefix at the chosen epoch boundary. Match actual cue, occurrence, odor, +source random stream and full seed, not just the number of epochs. + +- D=0, frozen, random reward and CS-only use that identical CS prefix. +- DAN-only uses the corresponding removed-CS/yoked epoch positions. It has + **zero CS exposure**, not equal CS exposure; this is an intended control. +- Unpaired uses the earliest epoch boundary that has consumed the same ordered + CS prefix, including US-only epochs already inserted before that boundary. + Its terminal checkpoint includes the entire 150-epoch inventory. + +Unpaired intermediate US counts and elapsed time can differ from paired. +Random-reward intermediate US counts can also differ. These differences must +be reported rather than called fully matched exposure. All complete terminal +inventories remain fixed by the original proposal. The terminal gate, not the +most favorable intermediate point, decides the result. + +Reversal and no-reversal use each arm's own kth reinforcement epoch boundary. +Their intermediate CS prefixes may differ because their reinforced cues differ; +their complete 100-epoch inventories remain fixed. The signed readout always +keeps the original acquisition cue orientation and original pre baseline. + +## Inventory, not approval + +Under these choices the pure planner is expected to enumerate 9,440 probes +(1,132.8 biological seconds), in addition to the existing 10,760 seconds of +training, controls, reversal and continuous idle. This arithmetic is not a +measured wall-time or disk estimate and excludes engineering validation, +encoding, output, regressions and reserves. Test fixtures use synthetic masters; +none is adopted as a scientific master seed. + +Before any scientific run, complete the actual input/state/delta file bindings, +exact event-to-eligibility update coordinator, implemented assay gates and a +finite resource plan. Register the protocol and bounded implementation check +before observing scientific outcomes. Only after those checks may the staged +acquisition suite run. A failed acquisition is sealed as failure; it must not +trigger more pairings, parameter changes, odor reselection or control removal. diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/EVIDENCE_DRAFT.md b/models/male-cns-lif/olfactory-conditioning-lab-v1/EVIDENCE_DRAFT.md new file mode 100644 index 0000000..5fed11b --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/EVIDENCE_DRAFT.md @@ -0,0 +1,84 @@ +# Phase 4AN:条件化接口的生物学证据与解释边界 + +状态:**未注册 DRAFT;不是实验结果、科学 PASS 或学习候选声明。** +日期:2026-09-30(Asia/Shanghai)。 + +本文件记录为后续协议准备的一手文献核对。未修改冻结的脑、符号策略、4J eligibility-v2、mask、eta 或受体假设;未运行远端实验。文献证据不能代替新实验的完整门禁。 + +## 1. 本地身份、文献类型和具体个体必须分层 + +冻结 H 的静态身份依据是 `annotation_class`、`annotation_type`、`annotation_instance` 中精确的 compartment token,以及真实 KC→MBON 和 DAN→MBON 边;不是根据 PAM/PPL1 前缀猜测效价或 compartment。可读源码依据为 `../models/male-cns-lif/phase4h.yaml` 的 `audit`/`conditioning` 字段、`phase4h.py` 的 mask/confirmed-DAN 加载,以及 `phase4h_reinforcement_provenance.json`。 + +下表本地索引、body ID 和侧别是本轮协调者从冻结 H mask/metadata 的实际静态查询确认的目标集合,**不是文献记录的实验个体**。对应冻结 H 文件为 `/data/digital-drosophila/experiments/phase4h-dopamine-plasticity/plastic-mask/plastic-edge-candidates.parquet`,协调者提供的 SHA256 为 `e5075ee3e5a0edf3a4d197453847163cc295b254f21c67203159bba204b12a76`。本次文献核对没有重新下载该表。正式注册仍须将完整静态映射、来源路径及 SHA 与 mask 一同绑定;不得把本表作为绕过静态核验的硬编码替代。 + +| 本地类型与精确 token | 固定本地 neuron_idx | 已提供的本地 body ID | 文献类型对应 | +| --- | --- | --- | --- | +| MBON01 / `y5B'2a` | `9, 130579` | `9 → 10013`;`130579 → 520151` | MBON-01 = MBON-γ5β′2a | +| MBON12 / `y2a'1` | `130136, 131044, 3477, 3513` | `130136 → 519368 (R)`;`131044 → 521086 (R)`;`3477 → 13728 (L)`;`3513 → 13768 (L)` | MBON-12 = MBON-γ2α′1 | + +文献编号与名称的对应来自 [Aso 等,2014,解剖论文 Table 1](https://elifesciences.org/articles/04577/figures)。ASCII token 与希腊字母类型名的对应是本项目跨数据集映射层的一部分,不证明每个 MaleCNS body 的生理效应、性别适用性、行为效价或受体组成。文献不能提供这些本地 neuron_idx/body ID 的身份核验。 + +冻结 mask 仍为 14,232 条边、四个精确 compartment;另外的 MBON07/`a1` 四个细胞和 MBON02/`B2B'2a` 两个细胞不会因为是 mask 成员而被自动赋予与下面两组相同的行为功能,也不因本次聚焦而从 mask 删除。 + +## 2. MBON01 与 MBON12 的一手功能证据 + +### MBON01:avoidance-related 类型,不等于每个本地细胞已验证 + +Stahl 等在同一条件化实验框架中测得,食欲条件化后 MBON-γ5β′2a 的 CS+ 相对 CS− 响应降低;论文将该 compartment 的输出联系到 avoidance-promoting MBON。该结果支持“减少这一类型的条件气味输出可能产生 appetitive-like 偏移”,不证明所有条件下它都是单一行为开关。[Stahl 等,2022,Figure 5 及 Figure 5—figure supplement 1](https://pmc.ncbi.nlm.nih.gov/articles/PMC8956283/) + +较早的光遗传行为实验包含表达于多个 M4/M6 MBON 类型的驱动系;不能把多类型联合激活结果重述为每个 MBON01 个体的独立因果验证。[Aso 等,2014,行为论文 Table 2 与 Figures 2–5](https://cdn.elifesciences.org/articles/04580/elife-04580-v1.pdf) + +### MBON12:approach-related 输出与 aversive-learning depression + +Aso 等明确报告激活 MBON-γ2α′1 可引起趋近。Berry 等在气味与电击配对后观察到该 MBON 对 CS+ 的响应特异性抑制,未配对/气味和 US 分离的控制不产生相同结果;同一 DAN 的后续刺激还可恢复已抑制响应。因此这里有类型特异的趋近相关输出和厌恶记忆证据,但不支持“DAN 的任何刺激只会继续 LTD”。[Aso 等,2014,行为论文 pp.26–27](https://cdn.elifesciences.org/articles/04580/elife-04580-v1.pdf);[Berry 等,2018,Figures 2–4](https://pmc.ncbi.nlm.nih.gov/articles/PMC6239218/) + +Stahl 等还观察到食欲条件化后 MBON-γ2α′1 的相对 CS+ 响应提高,但不能明确全部归因于 CS+ potentiation 或 CS− depression;不能用该结果论证所有相关 plasticity 都是单向 LTD。[Stahl 等,2022](https://pmc.ncbi.nlm.nih.gov/articles/PMC8956283/) + +## 3. 两个人工 US 接口只具有预先声明的 like 解释 + +| 固定的源类型与 compartment 接口 | 类型层面的预期 | 不可升级成的结论 | +| --- | --- | --- | +| PAM15 → `y5B'2a` / MBON01 | 若目标气味的相关 KC 输入发生 LTD,可预期 avoidance-related 输出相对减弱,即 appetitive-like 输出偏移 | PAM15 全体天然奖励、所有 PAM 为正效价、这些本地细胞已经证明奖赏学习 | +| PPL103 → `y2a'1` / MBON12 | 若目标气味的相关 KC 输入发生 LTD,可预期 approach-related 输出相对减弱,即 aversive-like 输出偏移 | 所有 PPL1 为负效价、DAN 任意刺激均是惩罚、这些本地细胞已经证明厌恶学习 | + +**PAM15 不是 PAM01 的同义词。** Li 等把 PAM15(γ5β′2a)列为相对既有分类新增的一类;PAM01 对应 γ5,PPL103 对应 γ2α′1。旧文献中 PAM-γ5/PAM01 的全部奖励结果不能不经核对就转记为 PAM15 特异性证据。[Li 等,2020,DAN 分类、Figure 6 与 Figures 35–36](https://pmc.ncbi.nlm.nih.gov/articles/PMC7909955/);[同文图表页](https://elifesciences.org/articles/62576/figures) + +这里的 D 仍是冻结规则中的人工标量命令,不是测得的 dopamine 浓度,也不是由实际 DAN spike/接受刺激自动推定的生物学门控。命令、刺激是否被接受、实际 DAN events、选定 compartment 与权重更新必须分别记录。两个接口共用既定 polarity = −1;不因为接口名字不同而翻转 D、eta、base sign 或下游权重。 + +## 4. 顺序和时间窗证据不等于冻结参数的数值标定 + +- Hige 等在 γ1pedc 测得正向配对后的抑制;气味改为在最后一束 DAN 激活光后 0.5 秒开始的 backward protocol 未产生同样变化。这是特定 compartment 和刺激协议的结果,不是所有类型的通用截止时间。[Hige 等,2015,Figure 1](https://pmc.ncbi.nlm.nih.gov/articles/PMC4674068/) +- Aso–Rubin 显示,单一 DAN 类型可因相对气味的刺激时序不同而写入、削弱或改变记忆效价;不同 compartment 的学习和更新规则并不相同。[Aso–Rubin,2016](https://pmc.ncbi.nlm.nih.gov/articles/PMC4987137/) +- Handler 等在其协议中测得 forward pairing 对应 depression、backward pairing 对应 potentiation,并观察到 PAM/PPL 群均可随时序改变行为偏移方向。这阻止我们把 PAM/PPL 名称当作固定效价开关;本项目不据此加入新受体、LTP 或重设参数。[Handler 等,2019](https://pmc.ncbi.nlm.nih.gov/articles/PMC9012144/) + +出版年复核:该文正式题名为 *Distinct Dopamine Receptor Pathways Underlie the Temporal Sensitivity of Associative Learning*,刊于 **Cell,2019-06-27,178(1):60–75.e19**,online publication 为 2019-06-20,DOI 为 [10.1016/j.cell.2019.05.040](https://doi.org/10.1016/j.cell.2019.05.040)。[PubMed 原文书目记录 PMID 31230716](https://pubmed.ncbi.nlm.nih.gov/31230716/) 明确同时关联 PMC9012144。因此本草稿保留 2019;旧材料中的 2022 不是本论文正式出版年,本次不修改旧冻结材料,也不猜测该旧标注的来源。 + +冻结 4J eligibility-v2 的 τ = 100 ms、trace cap = 1、eta = 0.00275 mV、polarity = −1、最多 25% depression 仍是**既定 causal-LTD 工程假说**,不是上述论文为当前全脑提供的定量标定。负时序零更新和 +1000 ms/零延迟比例门只验证冻结规则的一致性;不能据此声称复现了生物学 relief learning、受体机制、bidirectional plasticity 或遗忘。4J 中另列的 bidirectional branch 也不是本接口可偷偷启用的替代规则。 + +## 5. 固定 B/S:新的离线分析约定,不是 decoder + +首要输出应保留每个目标细胞的原始 spike/count/rate、训练前后变化,以及两个类型各自的结果。可供正式协议审议的固定类型对比为: + +```text +R12(o) = mean of the fixed four MBON12 cells' rates for odor o +R01(o) = mean of the fixed two MBON01 cells' rates for odor o +B(o) = R12(o) - R01(o) +DeltaB(o) = B_post(o) - B_pre(o) +S = DeltaB(CS+) - DeltaB(CS-) +``` + +所有项使用预先锁定的同一评估窗,分别按类型取均值,避免四个 versus 两个细胞的数量差异直接决定符号。CS+/CS− 的角色须事先指定并 counterbalance,不能根据结果挑选气味方向、细胞或 seed。 + +这是**新的、需要预注册的离线分析约定**;不是文献给出的定量行为公式,不进入动力学、不拟合权重或阈值、不构建选择策略、classifier 或 decoder,也不替换旧封存指标。它不要求未训练 B = 0,不把 spike rate 等同于真实选择概率。 + +两个接口永远使用同一个 B 方向:PAM15 分支的假说是 S > 0,PPL103 分支的假说是 S < 0。不得按接口乘效价符号把所有结果改成“正值即成功”,不得取绝对值掩盖方向错误。若反馈使方向相反,应报告为该假说的反证,不改读出或重命名接口。还须与实际运行的预注册控制比较;单独 S 的方向、MBON separation 增大或权重改变都不构成完整 learning PASS。 + +## 6. 正式协议不可省略的解释门 + +- 文献类型功能、本地精确身份、人工 US 命令、实际 DAN 激活和实际 KC→MBON 更新须为不同证据字段;任何一层未知都不得用另一层填补。 +- 冻结脑与 v2 不变;不得新设 receptor sign、改变 eta、扩 mask、选择最有利 compartment、挑训练 checkpoint 或把 direct/static25 诊断作为条件化本身。 +- paired、unpaired、zero-D、frozen、CS-only、DAN-only 应为真实执行控制,不是 summary 中的标签;独立评估 seeds 和预先锁定的 cue 角色不能在看到结果后改变。 +- 必须区分 weight update、实际 MBON 响应变化、类型对比方向和下游功能传递。纯 separation 可能在错误方向上增大;“appetitive-like/aversive-like”不能在没有对应证据时升级为行为学习结论。 +- 本文件不新增 success threshold;完整通过条件应由尚待注册的协议明示,并保留失败、无效应和反向结果。没有训练结果就不生成 learned candidate。 + +浏览核对仅采用上列原始研究的期刊、官方 PDF 或 PMC 全文。没有把综述、搜索摘要的二次推断、Google FlyBody 或其他身体运动项目作为本阶段学习机制证据。 diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/PROTOCOL_DRAFT.md b/models/male-cns-lif/olfactory-conditioning-lab-v1/PROTOCOL_DRAFT.md new file mode 100644 index 0000000..9353338 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/PROTOCOL_DRAFT.md @@ -0,0 +1,469 @@ +# DRAFT — fixed-v2 olfactory conditioning in the independent D18 branch + +**Status: DRAFT / NOT REGISTERED / NOT APPROVED FOR EXECUTION.** + +This document is a proposal for review. It does not authorize a run, record a +successful gate, freeze a protocol, or generate a learned candidate. It was +written from frozen H/J/L source and the declared AM interface contract, not +from selecting favorable AM outcomes. The coordinator must approve the final +scope, budget, source/input inventory, and exact gate implementation before +registration. No training or remote action is performed by this document. + +## 1. Purpose and entry conditions + +The next scientific endpoint should be actual acquisition under the unchanged +TemporalEligibility-v2 rule, followed, if its registered acquisition gate passes, +by fixed reversal, delayed-reward, and real-time retention assays. File validity, +static 25% sensitivity, or a change in delta alone is not learning. + +Entry requires the **complete** AM whole-brain interface gate, including its +registered zero-overlay comparisons, nonzero internal/repeat validation, old +reference regression, and frozen integrity checks. A partially completed zero +suite is not this gate. Continue to use the independent D18 branch; do not edit +or overwrite Phase 1–AM artifacts. The original base CSR, anatomical counts, +transmitter signs, W_syn, LIF coefficients, APL calibration, D18 timing, +14,232-edge candidate mask, and eligibility-v2 remain unchanged. + +New long-run/continuation plumbing must preserve the exact existing timestep +operation order. Validate zero-delta and fixed-delta continuation, including a +split across pending synaptic/APL arrivals, against an unsplit run before using +it for retention. This is an implementation prerequisite, not another tunable +scientific endpoint. No game, decoder, RL, visual branch, or brain-gain search +is in scope. + +## 2. Fixed population and replicate matrix + +Use the original externally selected 4L odors without re-selection: + +* ODOR_A: **4-ethylphenol**. +* ODOR_B: **3-hexanone**. +* Both frozen encoder modes: `physiological` and `matched_energy`. +* Both reciprocal assignments: A reinforced/B neutral, and B reinforced/A + neutral. +* Both exact DAN/type-compartment branches below. +* Five independent training replicates in each cell. + +Thus there are **2 modes × 2 DAN branches × 2 assignments × 5 replicates = +40 blocks**, or eight experimental cells with five training replicates each. + +| Branch identity | Exact raw compartment string | Proposed signed prediction | +| --- | --- | --- | +| PAM15 | `y5B'2a` | S > 0 | +| PPL103 | `y2a'1` | S < 0 | + +These identities are drawn from old H/J configuration, not from a rule that all +PAM is appetitive or all PPL1 is aversive. Preparation must materialize and hash +the actual DAN body IDs/neuron indices, exact annotation values, confirmed +DAN→MBON/compartment mapping, and affected existing plastic edges from the +sealed H mapping. Missing, ambiguous, or unmatched mappings remain `unknown`; +they cannot be completed by cell-name inference. + +The type-level signed predictions above are **new, falsifiable engineering +hypotheses**. Evidence specifically establishing PAM15's proposed functional +direction is limited. Correspondence of type labels across fly datasets is not +proof that these individual MaleCNS bodies have been functionally validated. +Neither branch is labeled a biologically demonstrated reward/aversion channel. + +### Seed inventory + +Before outcomes, generate and save the complete RNG lineage and explicit seed +table for replicate, cell, arm, epoch, CS events, US ordering, and evaluation. +Use distinct replicate streams, for example an explicitly registered +`SeedSequence` hierarchy with a newly audited master seed. Test expanded seed +identities for collisions. Merely choosing five different base integers is +insufficient: H/J/L used consecutive bases plus the trial number, producing +overlapping nominal replicate streams. + +Matched counterfactual arms may deliberately reuse the same input event table; +list each such common-random-number alias. They are paired controls, not extra +independent replicates. All evaluation RNG streams must be disjoint from all +training/US/control RNG streams. Within a replicate use the same registered +evaluation events for pre/post/control comparisons. If more than one evaluation +stream is used per replicate, report/aggregate it within that replicate; +**five evaluation streams never substitute for five independent training +replicates**. The minimal budget below uses one independent A/B evaluation +stream pair per training replicate, reused for paired contrasts. + +## 3. Frozen update and explicit US interface + +Use the exact frozen J/L v2 implementation and operation order: + +* KC trace float32, tau = 100 ms, cap = 1. +* Every timestep: separate multiply, add actual KC spikes, then clamp. +* At the registered US tick, sample after that tick's multiply/add/clamp; + include its KC spikes. Do not insert an anatomical-delay shift into eligibility. +* eta = 0.00275 mV, compartment polarity = −1. +* Additive update only on the already-confirmed compartment mask, with the + old float32 floor `−0.25 × base_candidate_mV`. +* Update once at the end of the training epoch; it affects subsequent epochs, + not an earlier part of the same epoch. No recovery, potentiation, or new mask. +* Install via the verified AM bridge: immutable base SI float64 plus the + float32 delta widened to float64 and converted from mV to V. Preserve all + untouched weights and fast-APL handling exactly. + +The first proposal preserves the old **scheduled dimensionless D = 1** artificial +unconditioned-signal interface. It is neither measured dopamine concentration +nor an actual-DAN-spike count. At the same scheduled tick, request spikes in the +fixed matched DAN population. Record requested, accepted, rejected, and actual +DAN spikes, including zero-delivery epochs, separately from D. Do not resample, +boost, or silently discard failed delivery to obtain a preferred result. + +Multiplying D by actual acceptance, changing D to a spike count, or adding a +measured dopamine kinetic model would be a distinct, explicitly registered +interface hypothesis; none is hidden in this draft. If neural DAN delivery is +absent, any update still demonstrates scheduled artificial gating, not that +actual DAN activity caused plasticity. This limitation must remain visible even +if an engineering output gate passes. + +## 4. Epochs, timing, and all actual controls + +Training epochs retain the old trial-boundary reset policy: start V/g/ref, +spike-delay queues, APL state/queue and KC trace from their declared initial +values; carry only plastic_delta between epochs. Log the state policy. This is +not continuous natural behavior. Retention deliberately uses the different, +explicit continuous-state policy in section 7. + +Normal epochs last 120 ms. CS occupies 0–100 ms, and a paired US is at 90 ms. +Delayed epochs last 180 ms; the identical CS occupies 0–100 ms, and US is at +150 ms, i.e. **50 ms after CS offset** and 60 ms later than the normal US. +All delayed controls use that same 180 ms duration and 150 ms US slot, including +the neutral epochs; do not reuse 90 ms controls to attribute delayed effects. + +The frozen L encoder mapping/rates and original A/B identity are retained. +However, old `phase4l.py::run_rates` generated PN events throughout its passed +120 ms duration. The present 100 ms CS plus a no-CS tail is an explicit new +experimental timing choice aligned with validated AK input duration, **not** a +claim of bitwise identity to old L's full training input. `matched_energy` +matches the frozen encoder's expected total rate, not an artificially edited +actual Poisson event count; report actual requests and acceptance by condition. + +Within each arm, present 50 reinforced-cue epochs and 50 neutral-cue epochs in +a pre-generated balanced order. The **50 reinforcement opportunities** come +from L's fixed formal conditioning count, not from a new magnitude search. + +| Arm | Actual execution in each normal or delayed suite | +| --- | --- | +| paired | 50 CS+ with its matched DAN/US and 50 CS− without US | +| D=0 | Identical input events and US schedule to paired; D=0 in updates | +| frozen | Identical input events and US schedule; no delta updates | +| random reward | Same 50 A/50 B and 50 US opportunities; exactly 25 A and 25 B get US, selected by a pre-generated random balanced schedule independent of responses | +| unpaired | Same 100 CS epochs and same 50 US events in 50 additional separate, no-CS epochs; preserve total event inventory, declare 150 rather than 100 epochs | +| CS-only | Same 100 CS epochs, no DAN/US; no scheduled D | +| DAN-only | Same 50 US epochs and 50 matched blank epochs, no CS | + +Every control is genuinely executed; neither identity reasoning nor a text label +replaces its trajectory. The unpaired arm is specifically **separate-epoch, +reset-trace unpairing**, not a claim about a long continuous biological +interstimulus interval. Actual DAN-only network activity may recruit KC and +produce eligibility; it must be measured, not assumed zero. Blank and US-only +epochs retain the corresponding 90/150 ms timing slot and 120/180 ms duration. + +## 5. Offline signed readout — do not discard contrary signs + +Preparation defines complete, exact-annotation MBON12 and MBON01 populations +from MaleCNS metadata, with all body/index membership and side information. No +best responding neuron, hemisphere, compartment subset, or evaluation pair is +selected after outcomes. Missing either population makes this specific readout +`NOT-TESTABLE`; do not substitute another type or silently use an empty mean. + +For an evaluation stimulus X and a fixed 0–120 ms probe window, define: + +`B(X) = mean_spike_count(MBON12, X) − mean_spike_count(MBON01, X)`. + +This is **offline analysis only**, not an action decoder, a feedback signal, +or proof of approach/avoidance. Also save both population means separately, +their sizes and counts, all-MBON output, genuine downstream output, and latency. + +Keep two distinct downstream inventories. The frozen H actual-edge table's +complete target union is `legacyDownstream` (11,342 neurons in the verified +local data), and contains **all 97 MBONs**. Do not relabel or silently edit that +old population. Define `nonMBONDownstream` as the exact set difference of that +union and the complete MBON population (11,245 here). Report both. Requiring +an effect outside the MBON population is an additional pre-outcome hypothesis, +not an already inherited H gate; it prevents a purely MBON-local change from +being counted twice as evidence of broader propagation. + +With C denoting that block's reinforced cue and N its neutral cue: + +`S_arm = [B_arm(C) − B_arm(N)] − [B_pre(C) − B_pre(N)]`. + +Always emit the raw signed B and S, pre/post components and control S. For PAM15 +the proposed relation is **S > 0**; for PPL103 it is **S < 0**. Never replace raw +S in the report with `abs(S)`, `polarity × S`, a magnitude-only score, or a +renamed “positive learning score.” An opposite sign is a counterexample to the +prediction, even if a magnitude or unsigned separation statistic increases. + +The expected-control relation is equally explicit: + +* PAM15: `S_paired > S_control` for each registered control. +* PPL103: `S_paired < S_control` for each registered control. + +Use matched evaluation inputs and each arm's matched pre baseline. All probe +updates and US inputs are off. Evaluation begins from the same frozen initial +state for acquisition/reversal/delayed comparisons; retention uses its saved +continuation state and time-matched frozen control as specified below. + +## 6. Acquisition and strict reversal gates + +### 6.1 Acquisition — newly proposed signed gate + +For each of the eight cells, a successful training replicate must satisfy all: + +1. The raw S has the registered direction (PAM15 >0; PPL103 <0). +2. It lies in that direction beyond **each of the six actually executed + timing-matched controls**, not just the easiest control. +3. The inherited L functional requirement holds: conditioned-cue MBON + `Delta_g_max_abs` exceeds neutral-cue `Delta_g_max_abs`, and the conditioned + cue has a nonzero MBON spike-count or first-latency change versus its own pre + probe. Undefined latency is separately reported, not silently zero-filled + into proof of a latency effect. +4. The inherited H MBON and complete `legacyDownstream` A/B Euclidean + separation gains are positive. Independently require positive separation + gain in `nonMBONDownstream` as a newly declared specificity/transfer gate. + Report all three underlying count vectors and gains, never select whichever + downstream definition is more favorable after outcomes. + +Require **at least 3 of the 5 independent training replicates to satisfy the +entire conjunction in the same replicate**. Also calculate the inherited H +mean-control rule separately. This exact new combined signed gate is a proposal, +not a claim that H/L already implemented it. + +Record acquisition at fixed update counts 0, 1, 2, 5, 10, 20 and 50. Only the +registered terminal count 50 determines its gate. Do not stop at the best +earlier count or add pairings after failure. + +### 6.2 Reversal — new hypothesis, not H's permissive sign test + +After normal acquisition, fork its exact delta into two arms: + +* **Reversal:** 50 pairings of the former neutral cue with the same specific DAN + branch, and 50 formerly reinforced cue epochs without US. +* **No-reversal continuation:** 50 further original-cue pairings and 50 original + neutral epochs, with matched epoch totals and registered independent input + streams. This is an additional real control, included in the budget. + +Never reset the learned delta between acquisition and reversal; do not restore +depressed old-cue edges. Keep the old epoch-reset neural state policy in both +arms and evaluate from common fresh states. The pure-LTD/no-recovery rule may +prevent reversal; that is a falsifiable model limitation, not permission to add +potentiation, recovery, or more trials. + +For strict reversal, retain the original C−N orientation when calculating +`S_acquisition`, `S_reversal`, and `S_continuation` against the **same original +pre-training baseline**. At the fixed terminal reversal count 50 require: + +| DAN branch | Acquisition | Reversal | No-reversal continuation | +| --- | --- | --- | --- | +| PAM15 | `S_acquisition > 0` | `S_reversal < 0` | `S_continuation > 0` | +| PPL103 | `S_acquisition < 0` | `S_reversal > 0` | `S_continuation < 0` | + +Additionally require a nonzero change in the former neutral cue's MBON spike +count or defined first latency between acquisition and reversal, and a nonzero +change in its actual-downstream event/count output. Save both cue vectors; +counts alone can conceal a shift confined to the original cue. The same +replicate must satisfy all conditions, with a 3/5 majority per cell. These are +**new terminal reversal hypotheses**, fixed before the learning results. + +Zero is failure of a strict sign-crossing gate, not reversal. Report H's original +`sign(acquisition) != sign(reversal)` separately as a legacy statistic; its +nonzero→zero success case does not satisfy this new gate. Do not claim that +these engineering sign changes establish reversal of real appetitive/aversive +behavior. + +## 7. Delayed reward and genuine retention gates + +### Delayed-reward test — new hypothesis + +Run delayed acquisition from zero delta, using the same fixed 50 reinforcement +opportunities and all six controls in the **180 ms / US-at-150 ms** suite. Sample +the unchanged trace at 150 ms after that tick's update. Do not extend tau or +redefine the trace to force delayed learning. + +At count 50, require the same signed, functional, downstream and control +conjunction as section 6.1, with at least 3/5 independent training replicates per +cell and raw S direction intact. This explicitly tests whether the unchanged +rule supports this particular delayed schedule. H did not supply an independent +delayed-reward success threshold; this is a new pre-outcome gate. + +Report delayed and immediate signed S side by side and their per-replicate +difference. Do not require a invented retained percentage, impose a monotonic +amplitude ratio, pool their seeds, or call an opposite delayed sign a successful +larger absolute response. Delayed controls cannot be substituted by normal ones. + +### Retention — new continuous-state implementation and gate + +Fork the terminal normal-acquisition checkpoint and the matched terminal normal +frozen-control checkpoint. Each includes V/g/ref, pending spike-delay state, +APL state, the complete D18 APL queue, absolute tick, delta, and relevant RNG +state. Run real no-input/no-update dynamics continuously to 1 s and 10 s; the 1 s +checkpoint is on the same idle trajectory used to reach 10 s. Do not reset, +discard pending events, analytically decay only delta, or fabricate elapsed time +by changing a seed. + +At 0, 1, 10 s, fork independent A/B probes from each checkpoint; probes do not feed +back into the idle continuation. Use the same registered evaluation streams +for trained/frozen comparisons at every elapsed time. The probe lasts 120 ms, +without US or updates. Keep delta constant throughout idle; the model does not +contain molecular consolidation or recovery, and a pass cannot prove either. + +Define signed `S_trained(t)` and `S_frozen(t)` using the original C−N orientation +and their registered pre baselines. Require, in the **same replicate at both 1 s +and 10 s**: + +* PAM15: `S_trained(t) > 0` and `S_trained(t) > S_frozen(t)`. +* PPL103: `S_trained(t) < 0` and `S_trained(t) < S_frozen(t)`. +* Conditioned-cue MBON output has a nonzero spike-count or defined first-latency + difference from the time-matched frozen probe; actual-downstream event/count + output also differs. Save neutral-cue differences and the complete signed + specificity contrasts so a generic global response is not presented as memory. + +Require 3/5 independent training replicates per cell, without a fitted amplitude +tolerance or an arbitrarily chosen “percent retained.” This is a new functional +retention hypothesis, not the old H reset-and-retest result. Report an optional +predeclared fresh-state/persistent-delta probe separately as a weight-storage +diagnostic; it cannot satisfy the real-time gate. + +## 8. Legacy gates, new hypotheses, and candidate limits + +Retain the following old predicates verbatim as separately labeled audit fields +where their data definitions apply: + +* H: minimum seed majority 3; positive MBON and downstream Euclidean separation + gains; the smaller mean gain of its two DAN branches exceeds the maximum + mean gain among D0/random/frozen controls. +* H: legacy reversal sign-inequality and last-retention separation>pre. Disclose + the old retention had no actual idle dynamics and is not direct validation of + the new real-time protocol. +* L: conditioned `Delta_g_max_abs`>neutral plus a functional spike/latency change, + in at least 3/5 seeds. Preserve the exact old implementation's legacy result as + well as the new explicitly defined handling of missing latencies. + +There was **no completed inherited delayed-reward gate**, no proven signed +MBON12−MBON01 valence mapping for these bodies, and no complete L reversal or +retention assay. The strict signed gates in sections 6–7 are new proposals, not +historical passes and not thresholds obtained from AL/AM responses. + +For the strongest proposed engineering candidate claim, all eight cells must +pass their registered acquisition and subsequent assay gates, old required +integrity/control gates, and numerical safety checks. This all-cell conjunction +is itself an explicit proposed scope of claim; it is not a pre-existing H/L +aggregate gate. Emit a complete 8-cell×assay table, including contrary signs, +failures, `NOT-RUN`, `NOT-TESTABLE`, and actual delivery limitations. Do not pool +one mode/compartment/assignment to rescue another, or silently retain only +successful cells as the candidate. + +Suggested staged execution: run normal acquisition and all six normal controls +for all 40 blocks first. If the approved overall acquisition gate fails, seal a +real acquisition failure and stop, recording subsequent assays as `NOT-RUN`. +Otherwise execute the already registered complete delayed, reversal, +continuation and retention suites. No gate changes, tuning or new pair choices +occur between stages. A scientific failure is not a reason to delete evidence; +an integrity/numerical/resource failure stops execution without a scientific +success claim. + +Even a full engineering pass would not establish odor preference behavior, +motor learning, natural dopamine concentration, appetitive/aversive valence, +long-term memory, or readiness for a game. Delta change alone, a file/checksum +pass, static-overlay sensitivity, or signed output readout alone is insufficient. + +## 9. Transparent worst-case runtime and data budget + +The original estimate of 38,000 epochs omitted the new no-reversal continuation +and a full timing-matched delayed-control suite. They cannot be silently added +without revising the budget. + +| Per block | Epochs | Biological seconds | +| --- | ---: | ---: | +| Normal paired + five 100-epoch controls + 150-epoch unpaired | 750 | 90 | +| Delayed paired + five 100-epoch controls + 150-epoch unpaired | 750 | 135 | +| Reversal | 100 | 12 | +| No-reversal continuation | 100 | 12 | +| Total training/control/continuation | 1700 | 249 | + +Across 40 blocks this is **68,000 epochs and 9,960 biological seconds**. Real 10 s +idle progression of trained and frozen retention branches adds 800 s, totaling +**at least 10,760 s biological time** (107.6 million dt=0.1 ms steps), before +evaluation probes, implementation validation, and old regressions. Checkpoints +at 1 s are reused within the 10 s trajectory, not separate additional 10 s runs. + +Relative to 38,000: add 4,000 continuation epochs and 26,000 delayed-control epochs. +All delayed neutral/control epochs are 180 ms, not shortened to 120 ms. The table +includes this duration correction. Any approved reduction of scope must be +decided and documented before registration, not after results or a resource +overrun. + +Let measured AM walltime per 100 ms be `t100`. A preliminary linear conversion is +`107,600 × t100`, plus input generation, updates, evaluation, I/O and validation. +If `t100=1 s`, this base is 29.9 h; if 5 s, 149.4 h. These are arithmetic scenarios, not +claimed measured performance: learning may change spike load. Measure and +reserve a conservative actual budget before registration; enforce a resource +stop rather than changing the protocol during a run. + +Full generated+applied APL traces alone use approximately +`2 × 4064 × 4 × 10000 = 325,120,000 bytes/biological-second`, hence **more than +3.18 TiB** for the above minimum. Do not silently carry the small AM experiment +disk budget into this suite. Per-epoch full neuron counts also total about 45 GB +at 68,000×166,700×4 bytes, before events or full checkpoints. + +The proposed recording policy to approve before registration is: all spike +events, actual input/delivery logs, per-epoch counts, signed metrics, every delta +update and full terminal V/g/ref/APL/queue state. Every timestep retains a GPU +finite-value latch and first-failure tick, **not a CPU copy/hash of all state**. +At epoch boundaries hash the files and bind the complete chain of parent, +input, delta-before, events/state/queues, eligibility/D and delta-after. Full +APL/per-tick reference recordings are restricted to a fixed pre-outcome +validation inventory, not a result-selected subset. That subset's exact +comparison must not be described as per-tick regression of all 68,000 epochs. +Compute capacity from measured event/log +sizes plus a stated safety reserve, and register an exclusive-output storage +cap. Do not publish a guessed final compressed size or omit encoder/update/I/O +latency from walltime accounting. + +The frozen L/J metric called `MBON_g_max_abs` is the maximum **across neurons +of the terminal post-minus-pre g difference**, not a temporal maximum. Save +`MBON_final_delta_g_max_abs` with explicit SI units and the legacy alias when +needed. Complete terminal g and sparse events suffice to reconstruct that +metric; adding a dense time-by-neuron recording is not required by its name. + +Even without full histories, all 68,000 terminal state/ring payloads would use +roughly 507 GB uncompressed, before events and evaluation. Use lossless +compression and measure real complete-episode outputs before setting the hard +storage cap. Do not infer final compressed usage from sparse activity alone. + +## 10. Evidence, provenance, and unresolved biology + +Local source basis (read-only, relative to the project): + +* `23-ai-gpu/source/models/male-cns-lif/phase4h.yaml` and `phase4h.py`: + exact DAN configuration, old control/majority predicates, old nominal retention. +* `23-ai-gpu/source/models/male-cns-lif/phase4j.yaml` and `phase4j.py`: + frozen float32 trace order and `apply_v2`. +* `23-ai-gpu/source/models/male-cns-lif/phase4l.yaml` and `phase4l.py`: + original odor mapping, 50 formal pairings, input timing, and acquisition gate. +* `23-ai-gpu/source/wholebrain-plastic-interface-v1/PROTOCOL.md`: + unchanged-base bridge, scheduled-US semantics and later-learning boundary. + +Relevant primary evidence, with narrow use: + +* [Hige et al., Neuron 2015, author institution](https://www.janelia.org/publication/heterosynaptic-plasticity-underlies-aversive-olfactory-learning-drosophila): + compartmental KC→MBON plasticity and odor-specific effects motivate local LTD; + this does not measure this MaleCNS model's eta, tau or DAN concentration. +* [Aso and Rubin, eLife 2016, author institution](https://www.janelia.org/publication/dopaminergic-neurons-write-and-update-memories-cell-type-specific-rules): + cell-type-specific DAN learning/update rules oppose a universal cluster-valence + assumption. They do not functionally identify every MaleCNS body here. +* [Aso et al., eLife 2014](https://elifesciences.org/articles/04580): + MBON population outputs motivate population-level readout. The exact + MBON12−MBON01 signed hypothesis and the two predictions in this draft remain + explicit cross-dataset/model assumptions, not a demonstrated behavioral scale. + +The exact scalar D interface, fixed trace/eta/floor, absence of recovery, +trial-boundary resetting, simplified signed readout, and 100/120/180 ms timings +are declared model/engineering choices. In particular, the 1.8 ms anatomical +synaptic delay is not an eligibility time constant. Physiological applicability, +PAM15 functional direction in these bodies, continuous intertrial learning, +behavioral valence and long-term retention remain unresolved. + +Before formal registration the coordinator must approve and hash the completed +population/seed/input inventory, new readout/gate code, exact continuation +semantics, storage policy/cap, resource budget and final candidate wording. +Until then this remains **DRAFT — NOT REGISTERED — NO EXECUTION AUTHORITY**. diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/README_DRAFT.md b/models/male-cns-lif/olfactory-conditioning-lab-v1/README_DRAFT.md new file mode 100644 index 0000000..604316b --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/README_DRAFT.md @@ -0,0 +1,118 @@ +# Olfactory conditioning lab — unregistered preparation + +This directory is an independent, unfinished model-branch implementation. +Its 21 engineering sources are registered and sealed by Phase 4AN; those files +must not be edited. Phase 4AM and the finite Phase 4AN engineering gate passed. +The scientific conditioning proposal and additional planning modules remain +unregistered, have not run training, and have not produced a learned candidate. +Engineering PASS is not learning PASS. + +Engineering evidence: `registry/phase4an-engineering-validation` in the project +repository, commit `aeccb7d0ccdcf6788e0a8de60e7ab96f70dbe734`. Its exact +21-source inventory is in `source-identity.json`; this README is not one of +those registered sources. The experiment manifest SHA-256 is +`e8952a48cd3ea6a93c811a1183a1f7b79e19646299924e7688e5f393737a9aa2`. + +## Existing code + +- `runtime_light.py`: derives a fixed-weight trial runner from the SHA-pinned + AJ source. Its full-recording reference retains the entire original tick + loop. The light variant removes an explicit diagnostics whitelist while + preserving the ordered mathematical AST. It retains sparse events, stimulus + acceptance, terminal neural state and delay queues. It supports an explicit + longer admission bound, not hidden time truncation or approximate delays. +- `seed_registry.py`: explicitly separates training, evaluation and schedule + design random streams. Declared same-split aliases can give controls common + random inputs; aliases do not count as independent replicates. Duplicate + keys, cross-split aliases and seed collisions stop preparation. A full + manifest is independently reconstructible. +- `runtime_continuation.py`: carries full terminal state, spike/APL delay + queues and the absolute clock across fixed-weight segments. It protects + every ancestor checkpoint against output/build writes. The explicit + arrival-admission change and reporting hooks are AST-audited. The finite AN + gate passed 32 actual GPU calls and 74 exact comparisons, including pending + arrivals across a split. This does not verify every future 120/180 ms + training epoch or the unexecuted 1/10 s retention protocol. +- `biological_contract.py`: binds observed canonical identity, exact DAN/MBON + compartment tokens and real graph edges without filling unknown relations. +- `trial_plan.py`: constructs and independently validates the proposed complete + 40-block training/control matrix and explicit random-stream aliases. It has + not selected a formal master seed or generated scientific brain inputs. + Complete-protocol measured storage and runtime budgets remain unfinished. +- `evaluation_plan.py`: requires explicit evaluation timing, reversal points + and per-control snapshot bindings. It reconstructs the parent inventory and + retains 80 independent A/B evaluation streams, paired across snapshots within + each training replicate. It does not bind actual state files or authorize a run. +- `evaluation_policy.py`: supplies the explicit draft policy in + `EVALUATION_POLICY_DRAFT.md`: 100 ms CS, 120 ms probes, seven curve points, + actual ordered-CS-prefix control alignment and separate US/time accounting. + Its conditional inventory contains 9,440 probes, not executed trials. +- `conditioning_inputs.py`: consumes a full 63-bit semantic seed in the original + discrete float32 Bernoulli CUDA sampler, with explicit device/draw budget and + CS window. CPU/mock tests do not establish CUDA random-sequence equivalence or + the influence of high seed bits. It never substitutes a CPU random generator. +- `conditioning_update.py`: uses the SHA-pinned AM/J bridge to compute a single + epoch-end delta from supplied actual sparse events. It never installs weights; + scheduled D, actual DAN activity, attempted increments and actual changed + edges remain distinct. Synthetic validation does not establish a trained brain. +- `metrics.py`: pure, explicitly draft functional gates; keeps the historical + downstream population and the non-MBON difference as distinct readouts. + Caller-bound receipts and anatomical membership are still required. +- `test_*.py`: CPU synthetic/structural checks. These cannot establish real + GPU equivalence, biological validity or successful learning. +- `EVIDENCE_DRAFT.md` and `PROTOCOL_DRAFT.md`: evidence and protocol proposals; + neither is an approved or frozen experiment. + +## Required before registration or training + +1. Preserve completed AM/AN engineering evidence. AM completed its 144 zero + and 144 fixed-overlay runs, five old frozen reference trials, and integrity + checks. AN's 32-call continuation gate passed its finite exact comparisons + and 28-root integrity checks; it did not rerun the five old reference trials. + Neither gate executed conditioning. +2. Review and lock the complete experimental matrix, true executed controls, + separate training/evaluation stream table, data budget and stop rules. + Report any additional gates as new model hypotheses, not inherited facts. + The generic seed registry alone does not prove protocol coverage or semantic + independence: preparation must require every planned stream and permit + aliases only within the same training replicate and input role, for named + matched controls/pre-post comparisons. Never alias two training replicates + or CS and US roles. Save generator/version, target ordering, rates, timing, + consumed stream ID and actual requests hash. Do not truncate a 63-bit seed + to 32 bits or derive a new seed by adding the epoch number downstream. +3. Bind **all** graph arrays, APL bundle, coefficient JSON, base-candidate + vector, full 14,232-edge mask, DAN/MBON identity tables and input files to + the registered manifest. A runtime's self-consistent spec or source hash + is not sufficient authority for these data. +4. Preserve the completed finite real-GPU light/reference comparisons, including exact + ordered events, per-neuron counts, terminal V/g/ref, external-request + acceptance, APL terminal state and both pending delay queues. No tolerance + may excuse a spike divergence. Benchmark encoding, loading, simulation, + validation and output time separately and together. +5. Validate the actual retention protocol before claiming retention. The + separate continuation module passed the finite AN GPU gate. V/g/ref, + spike-ring phase, APL state/pending phase and global tick must survive; + segmented and monolithic executions must agree. A checkpoint seal binds + fresh state bytes when created; it cannot prove pre-seal immutability. +6. Verify every learned snapshot's parent chain, input events and exact + frozen eligibility-v2 update. The single-trial runtime verifies the + bounded overlay and parent-hash declaration, but does not prove the + coordinator actually computed that update. Within each neural trial the + delta is fixed. The original base is never written. +7. Record the realized wall-time/output budget before the full acquisition + suite. A failed acquisition gate must not be rescued by tuning eta, + thresholds, APL gain, trial count, mask membership or selected odors. + +## Interpretation limits + +PAM15 and PPL103 are specific, statically mapped artificial US interfaces; +PAM/PPL cluster names are not general valence labels. The fixed eligibility +rule is a single-direction causal-LTD engineering hypothesis, not a complete +model of dopamine, bidirectional plasticity, forgetting or consolidation. +The proposed fixed MBON type contrast is offline analysis, not a decoder. + +No old sealed artifact, raw connectome, transmitter sign, eligibility-v2 or +base weight is modified here. No game, reinforcement-learning policy, +classifier, action decoder, visual encoder, web service or 3D interface is +introduced. A future learning PASS requires functional stimulus-specific +responses and executed controls, never merely a changed delta array. diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/biological_contract.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/biological_contract.py new file mode 100644 index 0000000..84508c2 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/biological_contract.py @@ -0,0 +1,205 @@ +"""Read-only exact-table contracts for the proposed conditioning branch. + +No body identity, compartment or behavioral function is inferred from a name. +The specific two interfaces are explicit protocol hypotheses checked against +the frozen H maps and actual graph edges. No training or file write occurs. +""" +from __future__ import annotations + +import hashlib +from pathlib import Path + +import numpy as np +import pandas as pd + + +INTERFACES = {"PAM15": ("y5B'2a", "MBON01"), "PPL103": ("y2a'1", "MBON12")} +N = 166700 +E = 25582938 +INPUT_PINS = { + "datasets/male-cns-v1.0/processed/neuron-only/neuron-index/unified-metadata.parquet": + "922af16846a065c3dbfca7c11146b83ca217fcbd7392edc745f444a04eae8c63", + "experiments/phase4h-dopamine-plasticity/plastic-mask/plastic-edge-candidates.parquet": + "e5075ee3e5a0edf3a4d197453847163cc295b254f21c67203159bba204b12a76", + "experiments/phase4h-dopamine-plasticity/mappings/dan-to-compartment.parquet": + "a16f72c3121ed0ec638c1a2b249ace446e6ccee020b49679818eb42589d00662", + "experiments/phase4h-dopamine-plasticity/mappings/mbon-to-compartment.parquet": + "54f371f7e35b9def44becd1ba9bd2872861f4876b255b2c8ec46feee6473b70c", + "experiments/phase4h-dopamine-plasticity/mappings/dan-to-mbon-confirmed.parquet": + "730d000f25667b59181d2ee6703c64591229a8beab0856197ed71e68d799d191", +} + + +def require(ok, message): + if not ok: + raise ValueError(message) + + +def digest(path): + h = hashlib.sha256() + with Path(path).open("rb") as stream: + for block in iter(lambda: stream.read(8 << 20), b""): + h.update(block) + return h.hexdigest() + + +def _columns(frame, names, label): + require(isinstance(frame, pd.DataFrame) and set(names) <= set(frame.columns), + "Missing observed schema columns: " + label) + + +def _identity(frame, meta, label): + _columns(frame, ("neuron_idx", "body_id", "annotation_type", "annotation_instance"), label) + require(frame.neuron_idx.dtype == np.int32 and frame.body_id.dtype == np.uint64, + "Canonical identity dtypes changed: " + label) + ids = frame.neuron_idx.to_numpy() + require(len(np.unique(ids)) == len(ids) and np.all((ids >= 0) & (ids < len(meta))), + "Ambiguous/out-of-range identity: " + label) + for field in ("body_id", "annotation_type", "annotation_instance"): + actual = frame[field].reset_index(drop=True) + expected = meta.iloc[ids][field].reset_index(drop=True) + require(actual.equals(expected), "Identity field mismatch: " + label + "/" + field) + + +def _edges(frame, meta, src, dst, signed_raw, label): + names = ("edge_id", "source_neuron_idx", "source_body_id", "target_neuron_idx", "target_body_id", + "raw_synapse_count", "source_annotation_type", "source_annotation_instance", + "target_annotation_type", "target_annotation_instance") + _columns(frame, names, label) + require(frame.edge_id.dtype == np.int64 and frame.edge_id.is_unique, "Invalid edge IDs: " + label) + ids = frame.edge_id.to_numpy() + require(np.all((ids >= 0) & (ids < len(src))), "Edge outside graph: " + label) + require(frame.raw_synapse_count.dtype == np.int64 and (frame.raw_synapse_count > 0).all(), + "Invalid anatomical contact count: " + label) + require(np.array_equal(np.abs(signed_raw[ids]), frame.raw_synapse_count.to_numpy()), + "Anatomical edge weight mismatch: " + label) + for side, expected_idx in (("source", src[ids]), ("target", dst[ids])): + require(frame[side + "_neuron_idx"].dtype == np.int32 and + frame[side + "_body_id"].dtype == np.uint64, + "Canonical edge identity dtype mismatch: " + label) + require(np.array_equal(frame[side + "_neuron_idx"].to_numpy(), expected_idx), + "Actual graph endpoint mismatch: " + label) + for field in ("body_id", "annotation_type", "annotation_instance"): + actual = frame[side + "_" + field].reset_index(drop=True) + expected = meta.iloc[expected_idx][field].reset_index(drop=True) + require(actual.equals(expected), "Actual edge identity mismatch: " + label + "/" + side + "/" + field) + + +def audit_interface_tables(meta, dan, mbon, confirmed, candidates, src, dst, signed_raw): + """Pure table audit; small synthetic graphs are allowed in unit tests only. + + The production loader below separately requires the complete N/E identity. + Supplied annotation population membership is never filled from a prefix. + """ + _columns(meta, ("neuron_idx", "body_id", "annotation_class", "annotation_type", "annotation_instance"), "metadata") + require(meta.neuron_idx.dtype == np.int32 and meta.body_id.dtype == np.uint64, + "Canonical metadata dtype mismatch") + require(np.array_equal(meta.neuron_idx.to_numpy(), np.arange(len(meta), dtype=np.int32)) and meta.body_id.is_unique, + "Metadata must have canonical complete ordered identities") + require(src.dtype == dst.dtype == np.int32 and signed_raw.dtype == np.int64 and + src.ndim == dst.ndim == signed_raw.ndim == 1 and len(src) == len(dst) == len(signed_raw), + "Graph endpoint/weight schema mismatch") + require(np.all((src >= 0) & (src < len(meta))) and np.all((dst >= 0) & (dst < len(meta))), + "Graph endpoint out of range") + _identity(dan, meta, "DAN map") + _identity(mbon, meta, "MBON map") + for table, klass, label in ((dan, "DAN", "DAN"), (mbon, "MBON", "MBON")): + require(np.array_equal(np.sort(table.neuron_idx.to_numpy()), np.flatnonzero(meta.annotation_class.eq(klass))), + "Mapping must include the complete exact-annotation population: " + label) + _columns(table, ("mb_compartment_verbatim", "mb_compartment_mapping_status"), label) + _edges(confirmed, meta, src, dst, signed_raw, "confirmed DAN-MBON") + _edges(candidates, meta, src, dst, signed_raw, "plastic candidates") + _columns(confirmed, ("source_compartment_verbatim", "target_compartment_verbatim", "exact_compartment_match"), "confirmed DAN-MBON") + _columns(candidates, ("target_compartment_verbatim", "plasticity_polarity"), "plastic candidates") + require(confirmed.exact_compartment_match.dtype == np.bool_ and confirmed.exact_compartment_match.all(), + "Confirmed table contains an unknown compartment relation") + require(confirmed.source_compartment_verbatim.notna().all() and + confirmed.source_compartment_verbatim.equals(confirmed.target_compartment_verbatim), + "Confirmed exact compartment relation differs") + require(meta.iloc[candidates.source_neuron_idx.to_numpy()].annotation_class.eq("Kenyon_Cell").all() and + meta.iloc[candidates.target_neuron_idx.to_numpy()].annotation_class.eq("MBON").all(), + "Candidate is not a real KC-MBON edge") + require((signed_raw[candidates.edge_id.to_numpy()] > 0).all() and candidates.plasticity_polarity.eq(-1).all(), + "Frozen KC base sign/polarity changed") + pop = {klass: np.flatnonzero(meta.annotation_class.eq(klass)).astype(np.int32) + for klass in ("Kenyon_Cell", "MBON", "DAN")} + reports = {} + for dan_type, (compartment, mbon_type) in INTERFACES.items(): + dd = dan[dan.annotation_type.eq(dan_type)].sort_values("neuron_idx") + mm = mbon[mbon.annotation_type.eq(mbon_type)].sort_values("neuron_idx") + require(len(dd) > 0 and len(mm) > 0, "Missing exact type: " + dan_type) + for table in (dd, mm): + require(table.mb_compartment_mapping_status.eq("explicit_single_parenthesized_token").all() and + table.mb_compartment_verbatim.eq(compartment).all(), + "Unknown or mismatched compartment: " + dan_type) + edges = confirmed[confirmed.source_neuron_idx.isin(dd.neuron_idx)] + require(len(edges) > 0 and edges.source_compartment_verbatim.eq(compartment).all() and + edges.target_compartment_verbatim.eq(compartment).all() and + edges.target_neuron_idx.isin(mm.neuron_idx).all(), + "Specific DAN interface has unknown or mismatched actual target") + require(set(edges.source_neuron_idx) == set(dd.neuron_idx) and + set(edges.target_neuron_idx) == set(mm.neuron_idx), + "Incomplete actual DAN/MBON type coverage") + plastic = candidates[candidates.target_compartment_verbatim.eq(compartment)] + require(len(plastic) > 0 and plastic.target_neuron_idx.isin(mm.neuron_idx).all() and + set(plastic.target_neuron_idx) == set(mm.neuron_idx), + "Frozen plastic subset is not the exact mapped target population") + pop[dan_type] = dd.neuron_idx.to_numpy(dtype=np.int32) + pop[mbon_type] = mm.neuron_idx.to_numpy(dtype=np.int32) + def members(frame): + return frame[["neuron_idx", "body_id", "annotation_type", "annotation_instance", "mb_compartment_verbatim"]].to_dict(orient="records") + reports[dan_type] = dict(compartment=compartment, MBON_type=mbon_type, + DAN_members=members(dd), MBON_members=members(mm), + confirmed_DAN_MBON_edge_ids=edges.edge_id.tolist(), + confirmed_DAN_MBON_edges=len(edges), confirmed_DAN_MBON_contacts=int(edges.raw_synapse_count.sum()), + candidate_edges=len(plastic), candidate_contacts=int(plastic.raw_synapse_count.sum()), + unknown_selected_interface_relations=0, + functional_valence="cross-dataset type-level hypothesis, not measured MaleCNS body function", + artificial_US=True, D_equals_measured_dopamine=False) + return pop, dict(interfaces=reports, whole_DAN_mapping_unknown=int(dan.mb_compartment_verbatim.isna().sum()), + whole_MBON_mapping_unknown=int(mbon.mb_compartment_verbatim.isna().sum()), + all_candidate_edges_retained=len(candidates), populations={k: len(v) for k, v in pop.items()}, + global_unknowns_filled=False, training=False, new_identity_inferred=False) + + +def _complete_graph_identity(meta, graph, n=N, e=E): + """Exact full-graph dimensions; small explicit sizes are for unit tests.""" + require(len(meta) == n and graph["nodes"].dtype == np.int32 and + np.array_equal(graph["nodes"], np.arange(n, dtype=np.int32)), "Complete canonical neuron set required") + require(graph["edge_id"].dtype == np.int64 and + np.array_equal(graph["edge_id"], np.arange(e, dtype=np.int64)), "Complete canonical edge set required") + require(all(graph[name].shape == (e,) for name in ("src", "dst", "signed_raw")), + "Complete graph endpoint/weight lengths required") + + +def load_bound_tables(root, graph_manifest): + """Require external, registered graph hashes, not self-authorized paths. + + graph_manifest maps the absolute graph paths to their authorized SHA-256. + The coordinator must bind this dictionary to the preregistration/parent. + """ + root = Path(root).resolve() + observed = {} + tables = [] + for relative, sha in INPUT_PINS.items(): + path = root / relative + require(digest(path) == sha, "Frozen table SHA mismatch: " + relative) + observed[str(path)] = sha + tables.append(pd.read_parquet(path)) + meta, candidates, dan, mbon, confirmed = tables + graph_dir = root / "experiments/phase4ag-author-apl-r2/graphs/full" + graph = {} + for name in ("nodes", "edge_id", "src", "dst", "signed_raw"): + path = graph_dir / (name + ".npy") + require(str(path) in graph_manifest and digest(path) == graph_manifest[str(path)], + "Unbound/changed complete graph input: " + name) + observed[str(path)] = graph_manifest[str(path)] + graph[name] = np.load(path, mmap_mode="r", allow_pickle=False) + _complete_graph_identity(meta, graph) + require(len(candidates) == 14232, "Complete frozen mask required") + populations, audit = audit_interface_tables(meta, dan, mbon, confirmed, candidates, + graph["src"], graph["dst"], graph["signed_raw"]) + require(all(digest(path) == sha for path, sha in observed.items()), "Input bytes changed during audit") + return populations, dict(audit, input_sha256=observed, schema={ + name: table.dtypes.astype(str).to_dict() for name, table in + (("metadata", meta), ("candidates", candidates), ("DAN", dan), ("MBON", mbon), ("confirmed", confirmed))}) diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/conditioning_inputs.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/conditioning_inputs.py new file mode 100644 index 0000000..53a2140 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/conditioning_inputs.py @@ -0,0 +1,221 @@ +"""UNREGISTERED input consumer; no I/O, brain execution, or training. + +The frozen L/AK interface is discrete float32 Bernoulli (one request per PN +and tick), not a continuous-time Poisson sampler. PN/rate mapping is supplied +and externally authenticated by the caller; this module never selects odors, +rebuilds mappings, rescales rates, clips probabilities, or resets brain RNGs. + +generate_cs_requests lazily uses the original CUDA Torch random operation. +CPU mocks test its contract only, not CUDA sequence equivalence. A full +(steps, PN-columns) matrix is drawn once, then the explicit half-open CS +window masks requests. A 100 ms CS in a 120 ms epoch is NOT the full old L +120 ms input. Different draw shapes need not share identical random prefixes. +The caller must register source/rate authority, device/environment, budgets, +stream aliases, and a separate bounded CUDA equivalence test before real use. +Returned request/source arrays are owned read-only copies, not immutable +capabilities; the consumer must recheck their recorded hashes before use. +""" +from __future__ import annotations + +import hashlib +import re + +import numpy as np + + +N_NEURONS = 166700 +L_SOURCE_SHA = 'e97d58d8c1f44920d0e953f8f5831ddc86fa0198784e86460e315de4116ac553' +AK_ENCODER_SHA = '0061689abb1832e963718bd836bc8a50009ee38b258143a753b4d59e63414925' + + +def require(value, message): + if not value: + raise ValueError(message) + + +def array_identity(value): + return dict(dtype=value.dtype.str, shape=list(value.shape), order='C-logical-bytes', + sha256=hashlib.sha256(value.tobytes(order='C')).hexdigest()) + + +def _vector(value, dtype, name): + require(isinstance(value, np.ndarray) and value.dtype == np.dtype(dtype) and value.ndim == 1, + name + ' must be an exact one-dimensional ' + np.dtype(dtype).name + ' array') + + +def _targets(value): + _vector(value, np.int32, 'targets') + require(np.all((value >= 0) & (value < N_NEURONS)), 'Target outside canonical global neuron range') + require(len(np.unique(value)) == len(value), 'Duplicate PN columns rejected; never merge rates or deduplicate RNG columns') + + +def _clock(steps, start, stop): + require(type(steps) is int and 0 < steps <= np.iinfo(np.int32).max, 'steps must be a positive int32-range integer') + require(type(start) is type(stop) is int and 0 <= start <= stop <= steps, 'Invalid explicit half-open CS tick window') + + +def probabilities_f32(targets, rates_hz, *, dt_ms=0.1): + """Preserve the L expression's separate float32 multiply then divide.""" + _targets(targets); _vector(rates_hz, np.float32, 'rates_hz') + require(rates_hz.shape == targets.shape and np.all(np.isfinite(rates_hz)) and np.all(rates_hz >= 0), 'Invalid PN rate vector') + require(type(dt_ms) in (int, float) and dt_ms == 0.1, 'Only the frozen dt_ms=0.1 contract is supported') + probability = np.divide(np.multiply(rates_hz, np.float32(dt_ms), dtype=np.float32), np.float32(1000.0), dtype=np.float32) + original = rates_hz * dt_ms / 1000.0 + require(original.dtype == np.float32 and original.tobytes() == probability.tobytes(), 'Installed NumPy changed the frozen rate expression') + require(np.all(np.isfinite(probability)) and np.all((probability >= 0) & (probability <= 1)), 'Probability outside [0,1]; no clipping or rate adjustment') + return probability + + +def forced_from_uniforms(uniforms, probability): + """Pure arithmetic fixture helper, NOT a replacement CUDA generator.""" + _vector(probability, np.float32, 'probability') + require(isinstance(uniforms, np.ndarray) and uniforms.dtype == np.float32 and uniforms.ndim == 2 and + uniforms.shape[1] == len(probability), 'Uniform fixture requires exact float32 [ticks,columns]') + require(np.all(np.isfinite(uniforms)) and np.all((uniforms >= 0) & (uniforms < 1)) and + np.all(np.isfinite(probability)) and np.all((probability >= 0) & (probability <= 1)), 'Invalid uniform/probability fixture') + return uniforms < probability[None, :] + + +def requests_from_forced(forced, targets, *, cs_start_tick, cs_stop_tick): + """Sort resulting requests only; never reorder the pre-sampling columns.""" + _targets(targets) + require(isinstance(forced, np.ndarray) and forced.dtype == np.bool_ and forced.ndim == 2 and + forced.shape[1] == len(targets), 'Forced samples must be Boolean [steps,original PN columns]') + steps = forced.shape[0]; _clock(steps, cs_start_tick, cs_stop_tick) + ticks, columns = np.nonzero(forced[cs_start_tick:cs_stop_tick]) + ticks = ticks.astype(np.int32) + np.int32(cs_start_tick) + neurons = targets[columns].copy() + order = np.lexsort((neurons, ticks)) + requests = {'tick': ticks[order], 'neuron_idx': neurons[order]} + validate_requests(requests, steps) + for value in requests.values(): value.setflags(write=False) + generated = int(np.count_nonzero(forced)); retained = len(ticks) + return dict(requests=requests, audit=dict(steps=steps, CS_window=[cs_start_tick, cs_stop_tick], + full_matrix_boolean_hits=generated, retained_CS_requests=retained, + outside_CS_boolean_hits_masked=generated - retained, + original_PN_column_order_preserved=True, sorted_only_after_sampling=True, + duplicate_requests_removed=0, probabilities_clipped=False, + returned_requests_read_only=True, consumer_must_recheck_array_hashes=True, + forced_identity=array_identity(forced), targets_identity=array_identity(targets), + requests_identity={k: array_identity(v) for k, v in requests.items()})) + + +def _load_torch(): + import torch + return torch + + +def generate_cs_requests(targets, rates_hz, *, seed, steps, cs_start_tick, cs_stop_tick, + device, max_uniform_draws, dt_ms=0.1): + """Future explicit CUDA input generation; no implicit CPU fallback. + + ``seed`` is the caller's full semantic seed (Python int in [0,2**63)). + The generator is private to this call. No offset/modulo/truncation or + global/manual brain seed is used. Aliases must reuse the same complete + sampling descriptor, including steps/column order/window, not seed alone. + The caller owns authorization, resource admission, and external rate pins. + ``device`` and ``max_uniform_draws`` must be explicitly supplied. The + latter bounds steps * PN columns, not total process/GPU memory. Retaining + initial_seed proves only integer delivery, not CUDA high-bit influence. + """ + require(type(seed) is int and 0 <= seed < 2**63, 'Exact full 63-bit Python integer seed required') + _clock(steps, cs_start_tick, cs_stop_tick) + probability = probabilities_f32(targets, rates_hz, dt_ms=dt_ms) + require(type(device) is str and re.fullmatch('cuda:[0-9]+', device), 'Explicit indexed CUDA device required; no CPU fallback') + require(type(max_uniform_draws) is int and max_uniform_draws > 0, 'Explicit positive integer uniform-draw budget required') + uniform_draws = steps * len(targets) + require(uniform_draws <= max_uniform_draws, 'Full matrix exceeds caller uniform-draw budget; no chunking or clipping') + torch = _load_torch() + require(torch.cuda.is_available() and torch.get_default_dtype() == torch.float32, 'Original CUDA/default-float32 sampler environment required') + actual_device = torch.device(device) + generator = torch.Generator(device=actual_device) + generator.manual_seed(seed) + require(generator.initial_seed() == seed, 'Generator did not retain the complete semantic seed') + p_gpu = torch.as_tensor(probability, dtype=torch.float32, device=actual_device) + # Exactly one complete draw. No chunking, per-PN loop, or tail-dependent redraw. + uniforms = torch.rand((steps, len(targets)), generator=generator, device=actual_device) + require(uniforms.dtype == torch.float32 and str(uniforms.device) == str(actual_device), 'Sampler dtype/device changed') + forced = (uniforms < p_gpu[None, :]).cpu().numpy() + result = requests_from_forced(forced, targets, cs_start_tick=cs_start_tick, cs_stop_tick=cs_stop_tick) + # Optional identifiers are recorded only when the installed Torch exposes + # them. An indexed device name is not presented as a UUID or external pin. + identity = {'uuid': None, 'pci_bus_id': None, 'query_error': None} + try: + properties = torch.cuda.get_device_properties(actual_device) + for key in ('uuid', 'pci_bus_id'): + value = getattr(properties, key, None) + if value is not None: identity[key] = str(value) + except (AttributeError, RuntimeError) as exc: + identity['query_error'] = type(exc).__name__ + ': ' + str(exc) + result['audit'].update(schema='phase4an-conditioning-input-v1-unregistered', + seed=seed, seed_bits=63, seed_hex=f'{seed:016x}', generator_initial_seed=generator.initial_seed(), + seed_conversion='exact integer; no truncation/modulo/offset', private_generator=True, + sampler='Torch CUDA rand default float32; strict U < float32 probability', + generator_type=type(generator).__module__ + '.' + type(generator).__qualname__, + numpy_version=str(np.__version__), torch_version=str(torch.__version__), torch_cuda_version=torch.version.cuda, + execution_device=str(uniforms.device), device_name=str(torch.cuda.get_device_name(actual_device)), + device_capability=list(torch.cuda.get_device_capability(actual_device)), + device_identity=identity, device_identity_verified_against_external_pin=False, + matrix_draw_calls=1, uniform_matrix_shape=[steps, len(targets)], uniform_scalar_draws=uniform_draws, + max_uniform_draws=max_uniform_draws, uniform_matrix_bytes=uniform_draws * np.dtype(np.float32).itemsize, + budget_scope='Uniform scalar count only, not total process or GPU memory', + sampled_before_window_mask=True, dt_ms=dt_ms, dt_float32_identity=array_identity(np.asarray([dt_ms], np.float32)), + rate_units='Hz', probability_identity=array_identity(probability), rates_identity=array_identity(rates_hz), + input_mapping_authority_verified_by_this_module=False, + reference_L_source_sha256=L_SOURCE_SHA, reference_AK_encoder_source_sha256=AK_ENCODER_SHA, + full_old_L_input_identity_claimed=False, CUDA_sequence_equivalence_tested_here=False, + CUDA_high_seed_bit_sequence_influence_tested_here=False, + timing_scope='Full epoch matrix drawn once, then explicit CS window; old L supplied PN requests over its full duration', + RNG_prefix_invariance_across_shapes_claimed=False, continuous_time_Poisson_claimed=False, + brain_reset_called=False, brain_executed=False, training_executed=False, protocol_registered=False) + return result + + +def validate_requests(requests, steps): + _clock(steps, 0, steps) + require(type(requests) is dict and set(requests) == {'tick', 'neuron_idx'}, 'Exact sparse request schema required') + for key, value in requests.items(): _vector(value, np.int32, key) + tick, neuron = requests['tick'], requests['neuron_idx'] + require(tick.shape == neuron.shape and np.all((tick >= 0) & (tick < steps)) and + np.all((neuron >= 0) & (neuron < N_NEURONS)), 'Requests outside local clock/global neuron range') + keys = tick.astype(np.int64) * N_NEURONS + neuron + require(len(keys) < 2 or np.all(keys[1:] > keys[:-1]), 'Requests must already be sorted unique; no silent sorting/deduplication') + return keys + + +class RequestConflictError(ValueError): + def __init__(self, audit): + super().__init__('CS/US request overlap rejected; explicit boolean_union is required to combine it') + self.audit = audit + + +def merge_requests(cs, us, *, steps, conflict_policy='error'): + """Compose two valid streams, recording every cross-source collision. + + boolean_union explicitly reproduces a single Boolean request per neuron / + tick, not two deliveries. Separate source attempts are retained in audit + and uint8 source_mask (CS=1, US=2, overlap=3). This is not refractory + acceptance, actual spiking, an artificial D value, or an update rule. + """ + require(conflict_policy in ('error', 'boolean_union'), 'Unknown explicit conflict policy') + cs_keys, us_keys = validate_requests(cs, steps), validate_requests(us, steps) + overlap = np.intersect1d(cs_keys, us_keys, assume_unique=True) + conflicts = [dict(tick=int(k // N_NEURONS), neuron_idx=int(k % N_NEURONS), CS_attempts=1, US_attempts=1, + combined_boolean_requests=1 if conflict_policy == 'boolean_union' else None) for k in overlap] + audit = dict(schema='phase4an-request-composition-v1-unregistered', policy=conflict_policy, + CS_attempts=len(cs_keys), US_attempts=len(us_keys), cross_source_conflict_count=len(overlap), conflicts=conflicts, + duplicate_within_source_allowed=False, refractory_acceptance_evaluated=False, actual_spikes_evaluated=False, + input_identity={name: {k: array_identity(v) for k, v in values.items()} for name, values in (('CS', cs), ('US', us))}) + if len(overlap) and conflict_policy == 'error': raise RequestConflictError(audit) + keys = np.union1d(cs_keys, us_keys) + sources = np.zeros(len(keys), np.uint8) + sources[np.searchsorted(keys, cs_keys)] |= np.uint8(1) + sources[np.searchsorted(keys, us_keys)] |= np.uint8(2) + requests = {'tick': (keys // N_NEURONS).astype(np.int32), 'neuron_idx': (keys % N_NEURONS).astype(np.int32)} + validate_requests(requests, steps) + for value in (*requests.values(), sources): value.setflags(write=False) + audit.update(output_requests=len(keys), explicit_boolean_overlap_collapses=len(overlap), + requests_identity={k: array_identity(v) for k, v in requests.items()}, source_mask_identity=array_identity(sources), + returned_arrays_read_only=True, consumer_must_recheck_array_hashes=True, + learning_or_D_command_defined=False, brain_executed=False, protocol_registered=False) + return dict(requests=requests, source_mask=sources, audit=audit) diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/conditioning_update.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/conditioning_update.py new file mode 100644 index 0000000..f5f8414 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/conditioning_update.py @@ -0,0 +1,205 @@ +"""Pure epoch-END adapter to the frozen AM/J eligibility implementation. + +Only the two SHA-pinned source files are read. No event files, brain state, +weights, base arrays or artifacts are written. The caller supplies actual sparse +events and externally authenticated COMPLETE KC/mask mappings. Hashing supplied +arrays identifies their bytes; it does not establish anatomical completeness. +The returned overlay is NOT installed. Scheduled D is not actual DAN delivery. +""" +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +import math +from pathlib import Path +import sys +from types import ModuleType + +import numpy as np + + +SCHEMA = "phase4an-conditioning-update-draft-v1" +BRIDGE_SHA256 = "174fb8d14619189e74b095e77a6db18b14c93046dc431c0b55710a5aa2dd2e61" +PHASE4J_SHA256 = "abb4574da85f00bd9ef61024c65993ef81d42651ff8d8d63c15dd69c6222471e" + + +def _require(condition, message): + if not condition: + raise ValueError(message) + + +def _canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) + + +def _sha(value): + return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest() + + +def _array(value, dtype, name): + _require(isinstance(value, np.ndarray) and value.ndim == 1 and value.dtype == np.dtype(dtype), + name + " requires an exact one-dimensional " + str(np.dtype(dtype)) + " array") + return value + + +def _evidence(array): + """Original dtype and logical C-order raw bytes; no cast or rounding.""" + return dict(dtype=array.dtype.str, shape=list(array.shape), nbytes=int(array.nbytes), + raw_c_order_sha256=hashlib.sha256(array.tobytes(order="C")).hexdigest()) + + +def _owned(array): + result = np.array(array, copy=True, order="C") + result.setflags(write=False) + return result + + +def _pinned_bytes(path, expected, label): + raw = Path(path).read_bytes() + _require(hashlib.sha256(raw).hexdigest() == expected, label + " source SHA256 mismatch") + return raw + + +def _load_bridge(path): + raw = _pinned_bytes(path, BRIDGE_SHA256, "AM eligibility bridge") + # Execute precisely the verified bytes, not a second path-based import. + # Temporary isolated module registration supports the frozen dataclass; + # restore any prior module, rather than colliding with old globals. + name = "_conditioning_update_bridge_" + BRIDGE_SHA256 + module = ModuleType(name) + module.__file__ = str(path) + previous = sys.modules.get(name) + sys.modules[name] = module + try: + exec(compile(raw, str(path), "exec"), module.__dict__) + finally: + if previous is None: + del sys.modules[name] + else: + sys.modules[name] = previous + _require(module.PHASE4J_SHA256 == PHASE4J_SHA256, "Frozen bridge/J pin disagreement") + return module + + +@dataclass(frozen=True) +class EpochUpdate: + delta_after: np.ndarray + sampled_trace: np.ndarray + final_trace: np.ndarray + receipt: dict + + +def update_epoch(*, step, neuron_idx, kc, candidate_edge_id, candidate_sources, + candidate_targets, candidate_compartments, delta_before, base_candidate, + steps, n_neurons, compartment, us_tick, d_command, frozen, + bridge_source, phase4j_source, trace_backend): + """Return the epoch-boundary update; never apply it to the running brain. + + trace_backend is mandatory: ``torch`` executes the frozen Torch CPU trace + expression; ``numpy`` remains its candidate compatibility implementation, + without a claim of per-call Torch equivalence. One declared US or no US is + supported. A US-less epoch requires D=0. Actual DAN delivery is deliberately + absent from this API and must be bound as separate coordinator evidence. + """ + _require(type(steps) is int and steps > 0, "steps must be a positive integer") + _require(type(n_neurons) is int and n_neurons > 0, "n_neurons must be a positive integer") + _require(us_tick is None or (type(us_tick) is int and 0 <= us_tick < steps), "us_tick must be None or an integer inside the epoch") + _require(type(d_command) in (int, float) and math.isfinite(d_command) and d_command in (0, 1), + "D must be the explicit scheduled scalar 0 or 1, not Boolean or measured DAN") + _require(us_tick is not None or d_command == 0, "No declared US tick requires D=0") + _require(type(frozen) is bool, "frozen must be Boolean") + _require(trace_backend in ("numpy", "torch"), "Explicit trace_backend must be numpy or torch") + arrays = dict(step=_array(step, "int32", "step"), neuron_idx=_array(neuron_idx, "int32", "neuron_idx"), + kc=_array(kc, "int32", "kc"), candidate_edge_id=_array(candidate_edge_id, "int64", "candidate_edge_id"), + candidate_sources=_array(candidate_sources, "int32", "candidate_sources"), + candidate_targets=_array(candidate_targets, "int32", "candidate_targets"), + delta_before=_array(delta_before, "float32", "delta_before"), + base_candidate=_array(base_candidate, "float32", "base_candidate")) + _require(isinstance(candidate_compartments, np.ndarray) and candidate_compartments.ndim == 1 and + candidate_compartments.dtype.kind == "U", "candidate_compartments must be a Unicode vector") + arrays["candidate_compartments"] = candidate_compartments + size = len(candidate_edge_id) + _require(size > 0 and np.all(candidate_edge_id >= 0) and np.all(candidate_edge_id[1:] > candidate_edge_id[:-1]), + "Candidate edge IDs must be nonempty, canonical sorted unique int64 IDs") + _require(all(len(arrays[name]) == size for name in ("candidate_sources", "candidate_targets", + "candidate_compartments", "delta_before", "base_candidate")), "Candidate mapping columns differ in length") + _require(np.all((candidate_targets >= 0) & (candidate_targets < n_neurons)), "Candidate target outside supplied graph size") + before_evidence = {name: _evidence(value) for name, value in arrays.items()} + # Every computation receives owned copies. Caller views/strides are accepted + # without changing their values or flags; dtype is never silently repaired. + work = {name: np.array(value, copy=True, order="C") for name, value in arrays.items()} + bridge = _load_bridge(bridge_source) + _pinned_bytes(phase4j_source, PHASE4J_SHA256, "Phase 4J") + trace = bridge.trace_from_events(work["step"], work["neuron_idx"], work["kc"], steps, + [] if us_tick is None else [us_tick], phase4j_source=phase4j_source, + backend=trace_backend, n_neurons=n_neurons) + sampled = trace.last_command_trace + after, original = bridge.apply_eligibility(sampled, work["kc"], work["candidate_sources"], + work["candidate_compartments"], compartment, work["delta_before"], work["base_candidate"], + d=d_command, frozen=frozen, phase4j_source=phase4j_source, n_neurons=n_neurons) + # Diagnostics only; the update is exclusively the frozen apply_v2 above. + selected = work["candidate_compartments"] == compartment + local = np.searchsorted(work["kc"], work["candidate_sources"]) + eligible = (sampled[local] > 0) & selected + changed = after.view(np.uint32) != work["delta_before"].view(np.uint32) + value_changed = after != work["delta_before"] + floor = -bridge.FLOOR_FRACTION * work["base_candidate"] + floor_before = work["delta_before"] == floor + floor_after = after == floor + _require(bridge.bit_equal(after[~selected], work["delta_before"][~selected]), "Off-compartment identity failed") + if frozen or d_command == 0: + _require(bridge.bit_equal(after, work["delta_before"]), "Zero-D/frozen identity failed") + original_report = original["original_report"] + attempted = 0 if original_report is None else original_report["updated_edges"] + if original_report is not None: + _require(original_report["eligible_edges"] == int(eligible.sum()), "Frozen eligible-edge report disagrees with its sampled trace") + delta_after, sampled_trace, final_trace = (_owned(a) for a in (after, sampled, trace.final)) + _require(all(_evidence(value) == before_evidence[name] for name, value in arrays.items()), "Caller input bytes changed") + _pinned_bytes(bridge_source, BRIDGE_SHA256, "AM eligibility bridge") + _pinned_bytes(phase4j_source, PHASE4J_SHA256, "Phase 4J") + receipt = dict(schema=SCHEMA, status="DRAFT-COMPUTED-EPOCH-UPDATE-NOT-INSTALLED", + source_pins=dict(eligibility_bridge_sha256=BRIDGE_SHA256, phase4j_sha256=PHASE4J_SHA256), + input_arrays=before_evidence, + command=dict(steps=steps, n_neurons=n_neurons, compartment=compartment, us_tick=us_tick, + D_scheduled=float(d_command), frozen=frozen, trace_backend=trace_backend), + semantics=dict(application="epoch_boundary_after_all_recorded_ticks; affects subsequent epoch only", + sampling="after_current_tick_spike", trace_initial_state="zero at epoch boundary", + no_US_sample="canonical float32 +0, not final trace", D_definition="artificial scheduled scalar", + actual_DAN_delivery_observed=False, actual_DAN_delivery_used_to_gate=False, + actual_DAN_evidence_authority="separate externally bound epoch record, not an input to this function", + original_base_units="mV", delta_units="mV", delta_dtype="float32", + candidate_target_role="identity evidence only; update uses original source/compartment mapping"), + fixed_rule=dict(dt_ms=bridge.DT_MS, trace_tau_ms=bridge.TAU_MS, trace_cap=bridge.TRACE_CAP, + eta_mv=bridge.ETA_MV, polarity=bridge.POLARITY, floor_fraction=bridge.FLOOR_FRACTION), + trace_audit=trace.audit, apply_audit=original, + counts=dict(events=len(step), actual_KC_events=trace.audit["actual_kc_events"], + candidates=size, selected_compartment_edges=int(selected.sum()), sampled_eligible_selected_edges=int(eligible.sum()), + original_updated_edges_attempted=int(attempted), original_apply_v2_executed=original["original_apply_v2_executed"], + actual_value_changed_edges=int(value_changed.sum()), actual_bitwise_changed_edges=int(changed.sum()), + actual_off_compartment_bitwise_changed_edges=int(changed[~selected].sum()), + selected_at_floor_before=int((selected & floor_before).sum()), + selected_at_floor_after=int((selected & floor_after).sum()), + selected_newly_at_floor=int((selected & floor_after & ~floor_before).sum()), + sampled_eligible_selected_already_at_floor=int((eligible & floor_before).sum())), + count_limitations="original updated_edges counts nonzero attempted increments, including saturation; floor counts do not prove why any edge was unchanged", + outputs={"delta_after": _evidence(delta_after), "sampled_trace": _evidence(sampled_trace), "final_trace": _evidence(final_trace)}, + inputs_exact_unchanged=True, off_compartment_exact_unchanged=True, + numpy_torch_bitwise_comparison_performed=False, + frozen_Torch_expression_executed=trace_backend == "torch", + complete_KC_identity_verified=False, complete_mask_identity_verified=False, full_graph_authority_verified=False, + mapping_hash_scope="supplied logical array bytes only; coordinator must bind complete canonical KC and candidate mask", + brain_dynamics_executed=False, weights_installed=False, base_modified=False, + training_executed_by_this_function=False, scientific_learning_pass_claimed=False, candidate_generated=False) + receipt["payload_sha256"] = _sha(receipt) + return EpochUpdate(delta_after, sampled_trace, final_trace, receipt) + + +def verify_receipt(receipt, **epoch_inputs): + """Recompute from supplied arrays/sources; never trust a self-hash alone.""" + _require(type(receipt) is dict and "payload_sha256" in receipt, "Invalid epoch receipt") + _require(receipt["payload_sha256"] == _sha({k: v for k, v in receipt.items() if k != "payload_sha256"}), + "Epoch receipt payload SHA mismatch") + actual = update_epoch(**epoch_inputs) + _require(_canonical(receipt) == _canonical(actual.receipt), "Receipt differs from exact recomputation in this environment") + return True diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/draft-cpu-test-results.json b/models/male-cns-lif/olfactory-conditioning-lab-v1/draft-cpu-test-results.json new file mode 100644 index 0000000..eee0a5d --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/draft-cpu-test-results.json @@ -0,0 +1,57 @@ +{ + "schema": "conditioning-preparation-cpu-test-results-v1", + "status": "PASS-SYNTHETIC-IMPLEMENTATION-TESTS-NOT-LEARNING", + "recorded_utc": "2026-09-30T09:05:52Z", + "source_inventory_sha256": "2c72d47d09b4ad1949dc6cfdc170f64847ff75f95ded5d20b421fb5a75e0eb84", + "source_files": 20, + "server": "192.168.10.51", + "environment": { + "frozen_AN_sources_unchanged_after_tests": 21, + "python": "3.10.12", + "numpy": "2.2.6", + "torch": "2.14.0+cu130", + "cuda_toolkit": "13.0" + }, + "test_result": { + "tests": 119, + "failures": 0, + "errors": 0, + "skipped": 0, + "wall_seconds_reported_by_unittest": 241.205, + "exit_code": 0, + "modules": [ + "test_seed_registry", + "test_trial_plan", + "test_biological_contract", + "test_metrics", + "test_evaluation_plan", + "test_evaluation_policy", + "test_conditioning_inputs", + "test_conditioning_update" + ], + "gpu_visible": false, + "brain_runs": 0 + }, + "invocation_scope": "Eight explicitly named unittest modules; CUDA_VISIBLE_DEVICES empty; Python -B; original Phase4J source pinned.", + "observed_stdout_stderr_summary": "Ran 119 tests in 241.205s; OK", + "local_optional_torch_test_skip_resolved_on_server": true, + "real_CUDA_sampler_validated": false, + "real_CUDA_high_seed_bit_influence_validated": false, + "synthetic_numpy_torch_CPU_trace_comparison_executed": true, + "full_frozen_roots_rehashed_by_this_test_run": false, + "frozen_AN_registered_sources_checked_before": 21, + "frozen_AN_registered_sources_checked_after": 21, + "scientific_master_selected": false, + "real_inputs_generated": false, + "real_brain_runs": 0, + "conditioning_executed": false, + "weights_installed": false, + "learned_candidate_generated": false, + "remaining": [ + "actual file/parent/state/delta authority coordinator", + "finite preregistered real CUDA input and two-epoch update installation check", + "explicit new-overlay plus retained-neural-state boundary for retention", + "reversal and retention functional gate implementation", + "measured full time/storage budget and scientific registration" + ] +} diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/draft-source-inventory.json b/models/male-cns-lif/olfactory-conditioning-lab-v1/draft-source-inventory.json new file mode 100644 index 0000000..78ff65a --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/draft-source-inventory.json @@ -0,0 +1,122 @@ +{ + "schema": "conditioning-preparation-source-inventory-v1", + "status": "DRAFT-NOT-REGISTERED", + "engineering_commit": "aeccb7d0ccdcf6788e0a8de60e7ab96f70dbe734", + "model_directory": "models/male-cns-lif/olfactory-conditioning-lab-v1", + "files": [ + { + "path": "EVIDENCE_DRAFT.md", + "bytes": 10788, + "sha256": "8455c12e3a6bb1b22a6e38f9017293fe5deea37ba3ec61e13598d00dafc12296" + }, + { + "path": "PROTOCOL_DRAFT.md", + "bytes": 27118, + "sha256": "8ebecb1f3d012ddc29a1cb727269dd277629c53565e09978bf9dbf7140cb8508" + }, + { + "path": "README_DRAFT.md", + "bytes": 7892, + "sha256": "50be97f64097b681355993a8afd7f467ea187e99e88b19ca30561e08d517999f" + }, + { + "path": "EVALUATION_POLICY_DRAFT.md", + "bytes": 4024, + "sha256": "a84170a61564715a1532f20df4c6a330f0f9725c383db48b8f3e1a4b5fba0bc8" + }, + { + "path": "biological_contract.py", + "bytes": 12374, + "sha256": "e09f0f2b5e6ec746176e3a1aa2fdee1a15385e14e96bd343d14379a759f3ae43" + }, + { + "path": "test_biological_contract.py", + "bytes": 7729, + "sha256": "0ce796e6ad1a4cbcb9bcb760eff628a9562b4a809cc9c177a9a0d88df68f02b3" + }, + { + "path": "metrics.py", + "bytes": 31945, + "sha256": "47319e21db3c8b030b1bafbf0703cd86fb588d23bb29436accbacdba73d08c3c" + }, + { + "path": "test_metrics.py", + "bytes": 18230, + "sha256": "1928894d248dd37fad0fc67bc9dcfeac27c4d8be4aec22f54b9ef20a10ea8645" + }, + { + "path": "seed_registry.py", + "bytes": 5651, + "sha256": "0da033b132095ec3087b5d9f64e53c2e391fa50ab0b7653b31296eeb84799b7e" + }, + { + "path": "test_seed_registry.py", + "bytes": 5489, + "sha256": "7aca38f3c975e3ad4f6337d83b826c09afef336381d124c22ff0eb2eb60a0834" + }, + { + "path": "trial_plan.py", + "bytes": 16606, + "sha256": "6fed3f494d075339a7e5b001d43b2598640b189e038952fbd4d41200bacac587" + }, + { + "path": "test_trial_plan.py", + "bytes": 9651, + "sha256": "927678956ae9959a047b1cd258ee8dee22eb6842a96ff4b2ba2db077310163c9" + }, + { + "path": "evaluation_plan.py", + "bytes": 16722, + "sha256": "991c813ff3322b230947f302b12b04ec382c3b632817f94b698101e4b17efa7d" + }, + { + "path": "test_evaluation_plan.py", + "bytes": 10923, + "sha256": "21d2402025122d8665fb1f32e184fd8e30fc17242810fc70901b7f7fc8c53bfa" + }, + { + "path": "evaluation_policy.py", + "bytes": 16826, + "sha256": "21331a9ff3567dc6f6399e16bb91ef486d74231f5db51799beb6dd1bf71d022b" + }, + { + "path": "test_evaluation_policy.py", + "bytes": 15656, + "sha256": "421a31b76eaa547e890c291d2c38a415352d9ccad6fccc800461f522ff4c83f1" + }, + { + "path": "conditioning_inputs.py", + "bytes": 13967, + "sha256": "19382e066e450140bb653a3661f980ffe7cc62f412020ef57df0b6e6b3cbb8ba" + }, + { + "path": "test_conditioning_inputs.py", + "bytes": 16186, + "sha256": "95e37c770e78ce722a3007c5b371c51aec2989634cddfe5b414ac3fa817e5c22" + }, + { + "path": "conditioning_update.py", + "bytes": 12222, + "sha256": "96efff678cff910654eb08e86f1a17789e4e5589e340345f419c7027a7d547e2" + }, + { + "path": "test_conditioning_update.py", + "bytes": 10625, + "sha256": "1c25d93753932ace06abc876fe0867546b3bb330de3e602da6aec7bb4588e4bd" + } + ], + "frozen_AN_sources_included": false, + "frozen_AN_inventory": "registry/phase4an-engineering-validation/source-identity.json", + "reference_pins": { + "AM_eligibility_bridge": "174fb8d14619189e74b095e77a6db18b14c93046dc431c0b55710a5aa2dd2e61", + "phase4J_source": "abb4574da85f00bd9ef61024c65993ef81d42651ff8d8d63c15dd69c6222471e", + "AK_encoder": "0061689abb1832e963718bd836bc8a50009ee38b258143a753b4d59e63414925" + }, + "cpu_test_receipt": "draft-cpu-test-results.json", + "inventory_is_experiment_registration": false, + "model_directory_sealed": false, + "scientific_inputs_generated": false, + "conditioning_executed": false, + "learned_candidate_generated": false, + "notes": "Versioned preparation snapshot only. The future coordinator must bind actual complete data, seeds, inputs, state/delta lineage, resource budget and registered gates. Reference pins identify dependencies; this inventory alone does not rerun them or prove scientific validity." +} diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/evaluation_plan.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/evaluation_plan.py new file mode 100644 index 0000000..e44991a --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/evaluation_plan.py @@ -0,0 +1,260 @@ +"""Pure, UNREGISTERED evaluation inventory; no files, RNG draws or execution. + +build_evaluation_plan(trial_payload, config) requires the complete existing +trial_plan payload, including its caller-chosen master and seed manifest. It +reuses that payload's 80 evaluation inputs; repeated probes are paired +observations, never extra independent training replicates. No defaults select +CS offset, reversal curve checkpoints, control snapshot boundaries, or baseline +execution policy. See required_choices(). A complete inventory is not approval, +an executed snapshot, an externally verified source pin, or a scientific gate. +""" +from __future__ import annotations + +from collections import Counter +import hashlib +import json + +import seed_registry +import trial_plan + + +SCHEMA = "phase4an-evaluation-plan-draft-v1" +CONFIG_SCHEMA = "phase4an-evaluation-config-draft-v1" +CONFIG_FIELDS = frozenset(("schema", "probe_steps", "cs_start_tick", + "cs_stop_tick_exclusive", "reversal_checkpoints", "baseline_execution", + "snapshot_binding_policy", "snapshot_bindings")) +BINDING_FIELDS = frozenset(("block_id", "assay", "arm", "checkpoint", "completed_epochs")) + + +def _require(condition, message): + if not condition: + raise ValueError(message) + + +def _canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False) + + +def _sha(value): + return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest() + + +def required_choices(): + """Describe mandatory choices, without filling or approving any of them.""" + return { + "probe_steps": "Explicit integer 1200: the draft's fixed 120 ms metric window.", + "cs_start_tick": "Explicit integer 0: stimulus onset defines probe latency.", + "cs_stop_tick_exclusive": "Explicit integer in [1,1200]; draft does not settle evaluation CS offset.", + "reversal_checkpoints": "Explicit sorted subset of [0,1,2,5,10,20,50], including 0 and 50.", + "baseline_execution": "Only execute_each_snapshot is implemented; declare it explicitly. No output deduplication.", + "snapshot_binding_policy": "Explicit completed_epochs; nominal control labels are not actual update counts.", + "snapshot_bindings": "Every block/assay/arm/checkpoint, in trial-plan order, with explicit completed_epochs. No control alignment is guessed.", + "authority": "Provide the complete existing trial payload; no master seed is chosen here. Source/input pins remain coordinator responsibilities.", + } + + +def _configuration(config): + _require(type(config) is dict, "Explicit evaluation config is required; see required_choices()") + _require(set(config) == CONFIG_FIELDS, + "Evaluation config fields: missing=" + str(sorted(CONFIG_FIELDS - set(config))) + + "; unknown=" + str(sorted(set(config) - CONFIG_FIELDS))) + _require(config["schema"] == CONFIG_SCHEMA, "Wrong evaluation config schema") + _require(type(config["probe_steps"]) is int and config["probe_steps"] == 1200, + "Explicit probe_steps must be integer 1200") + _require(type(config["cs_start_tick"]) is int and config["cs_start_tick"] == 0, + "Explicit CS onset must be integer tick 0") + stop = config["cs_stop_tick_exclusive"] + _require(type(stop) is int and 0 < stop <= 1200, + "Evaluation CS stop tick is required; no training-CS default is inferred") + checks = config["reversal_checkpoints"] + _require(type(checks) is list and len(checks) >= 2 and + all(type(v) is int and v in trial_plan.PROBE_CHECKPOINTS for v in checks) and + checks == sorted(set(checks)) and checks[0] == 0 and checks[-1] == 50, + "Explicit reversal checkpoints must include 0/50, sorted, unique, and within the draft curve") + _require(config["baseline_execution"] == "execute_each_snapshot", + "Declare execute_each_snapshot explicitly; baseline reuse is not implemented or inferred") + _require(config["snapshot_binding_policy"] == "explicit_completed_epochs", + "Explicit control snapshot epoch bindings are mandatory") + _require(type(config["snapshot_bindings"]) is list, "snapshot_bindings must be an explicit ordered list") + return json.loads(_canonical(config)) + + +def _binding_inventory(plan, config): + expected, specs = [], [] + for block in plan["blocks"]: + for suite in block["suites"]: + assay = suite["assay"] + points = config["reversal_checkpoints"] if assay == "reversal" else block["evaluation_checkpoints"] + for arm in suite["arms"]: + for point in points: + expected.append((block["block_id"], assay, arm["arm"], point)) + specs.append((block, suite, arm)) + bindings = config["snapshot_bindings"] + _require(len(bindings) == len(expected), "Missing/extra snapshot bindings: require every block, arm and checkpoint") + previous = {} + result = [] + for binding, identity, spec in zip(bindings, expected, specs): + _require(type(binding) is dict and set(binding) == BINDING_FIELDS, "Invalid snapshot binding fields") + actual = tuple(binding[key] for key in ("block_id", "assay", "arm", "checkpoint")) + _require(all(type(v) is str for v in actual[:3]) and type(actual[3]) is int and actual == identity, + "Snapshot bindings must match exact canonical coverage/order/types") + block, suite, arm = spec + count = binding["completed_epochs"] + _require(type(count) is int and 0 <= count <= len(arm["epochs"]), "Invalid completed_epochs") + point = identity[-1] + _require((count == 0) == (point == 0), "Checkpoint zero is before the arm's first epoch") + _require(point != 50 or count == len(arm["epochs"]), "Terminal checkpoint must include the entire arm, including neutral/blank epochs") + key = identity[:3] + _require(key not in previous or count > previous[key], "Snapshot epochs must advance strictly") + previous[key] = count + us = sum(e["us"]["requested"] for e in arm["epochs"][:count]) + updates = sum(e["update_enabled"] and e["D_command"] != 0 for e in arm["epochs"][:count]) + if arm["arm"] in ("paired", "reversal", "no_reversal"): + _require(us == point and updates == point, + "Paired/reversal checkpoint must contain exactly its declared reinforcement opportunities") + result.append((binding, block, suite, arm, us, updates)) + return result + + +def _streams(trial): + """Reconstruct evaluation semantic keys, not just plausible seed strings.""" + manifest = trial["seed_manifest"] + master = trial["plan"]["master"] + independent = {(r["split"], tuple(r["key"])): r for r in manifest["independent_streams"]} + non_eval = {r["seed"] for r in manifest["independent_streams"] if r["split"] != "evaluation"} + used, result = set(), {} + for block in trial["plan"]["blocks"]: + _require([v["cue"] for v in block["evaluation_streams"]] == ["A", "B"], "Both original evaluation cues are required") + for row in block["evaluation_streams"]: + cue = row["cue"] + key = (block["block_id"], "evaluation", "CS", cue, "00", "base-probe") + seed, derivation = seed_registry.seed_for(master, "evaluation", key) + registered = independent.get(("evaluation", key)) + _require(registered == {"split": "evaluation", "key": list(key), "seed": seed, + "derivation_sha256": derivation}, "Missing exact evaluation registry key") + stream_id = "evaluation:" + _sha([trial_plan.SCHEMA, "stream-id", master, "evaluation", list(key)]) + _require(type(row["seed"]) is int and row["seed"] == seed and row["seed_bits"] == 63 and + row["stream_id"] == stream_id and row["odor"] == trial_plan.ODORS[cue] and row["role"] == "CS", + "Evaluation seed/key/odor lineage mismatch") + _require(seed not in used and seed not in non_eval, "Evaluation seed overlaps another independent stream") + used.add(seed) + result[(block["block_id"], cue)] = dict(stream_id=stream_id, split="evaluation", + semantic_key=list(key), seed=seed, seed_bits=63, derivation_sha256=derivation, + input_id=block["block_id"] + "/evaluation/input-" + cue, + reuse="paired repeated probes within this training replicate; not additional independent seeds") + _require(len(result) == manifest["split_counts"]["evaluation"] == 80, "Exactly the existing 80 evaluation streams are required") + return result + + +def _snapshot_id(block_id, assay, arm, checkpoint): + return f"{block_id}/{assay}/{arm}/checkpoint-{checkpoint:02d}" + + +def _assemble(trial, config): + """Internal assembly; public entry points first validate full trial authority.""" + config = _configuration(config) + records = _binding_inventory(trial["plan"], config) + streams = _streams(trial) + snapshots, probes = [], [] + + def emit(block, snapshot): + common = dict(block_id=block["block_id"], mode=block["mode"], dan_type=block["dan_type"], + compartment_verbatim=block["compartment_verbatim"], training_replicate=block["replicate"], + original_reinforced_cue=block["initial_reinforced_cue"], original_neutral_cue=block["initial_neutral_cue"], + original_S_orientation="original reinforced minus original neutral, never flipped for reversal") + snapshot = dict(common, **snapshot) + snapshots.append(snapshot) + for cue in trial_plan.CUES: + probes.append(dict(common, probe_id=snapshot["snapshot_id"] + "/probe-" + cue, + snapshot_id=snapshot["snapshot_id"], baseline_snapshot_id=snapshot["baseline_snapshot_id"], + assay=snapshot["assay"], arm=snapshot["arm"], checkpoint=snapshot["checkpoint"], + elapsed_idle_ticks=snapshot["elapsed_idle_ticks"], cue=cue, odor=trial_plan.ODORS[cue], + steps=config["probe_steps"], dt_ms=0.1, cs_start_tick=config["cs_start_tick"], + cs_stop_tick_exclusive=config["cs_stop_tick_exclusive"], + input=streams[(block["block_id"], cue)], execution="independent fork; discard probe terminal state", + US_requested=False, D_command=0.0, weight_updates_enabled=False, + feedback_to_training_or_idle=False, adds_training_replicate=False)) + + for binding, block, suite, arm, us, updates in records: + block_id, assay, name = block["block_id"], suite["assay"], arm["arm"] + point, count = binding["checkpoint"], binding["completed_epochs"] + last = None if count == 0 else arm["epochs"][count - 1]["epoch_id"] + delta_source = (dict(arm["delta_start"]) if count == 0 else + {"kind": "epoch_delta_after", "source": last + "/delta-after"}) + baseline = _snapshot_id(block_id, "normal" if assay == "reversal" else assay, + "paired" if assay == "reversal" else name, 0) + emit(block, dict(snapshot_id=_snapshot_id(block_id, assay, name, point), assay=assay, arm=name, + checkpoint=point, checkpoint_semantics="nominal reinforcement progress; control epoch boundary explicitly supplied", + completed_epochs=count, last_completed_epoch_id=last, + actual_US_requests_completed=us, enabled_nonzero_D_update_opportunities_completed=updates, + arm_reinforced_cue=arm["reinforced_cue"], delta_source=delta_source, + neural_state_source={"kind": "common_frozen_reset", "from_training_terminal_state": False}, + baseline_snapshot_id=baseline, elapsed_idle_ticks=None, + probe_count=2, snapshot_mutated_by_planning=False)) + + for block in trial["plan"]["blocks"]: + block_id = block["block_id"] + normal = {a["arm"]: a for a in block["suites"][0]["arms"]} + for branch in block["retention"]["branches"]: + name = branch.split("/")[1] + terminal_epoch = normal[name]["epochs"][-1]["epoch_id"] + lineage = block_id + "/retention/" + name + "/idle-10s" + for seconds in block["retention"]["checkpoint_seconds"]: + ticks = seconds * 10000 + emit(block, dict(snapshot_id=f"{block_id}/retention/{name}/idle-{seconds:02d}s", + assay="retention", arm=name, checkpoint=None, completed_epochs=len(normal[name]["epochs"]), + last_completed_epoch_id=terminal_epoch, delta_source={"kind": "epoch_delta_after", "source": terminal_epoch + "/delta-after"}, + neural_state_source={"kind": "actual_terminal_checkpoint" if seconds == 0 else "continuous_idle_checkpoint", + "terminal_epoch_id": terminal_epoch, "idle_lineage_id": lineage, + "elapsed_ticks": ticks, "total_idle_ticks": 100000, + "one_second_is_prefix_of_same_ten_second_idle": True, + "carry": ["v", "g", "ref", "spike_ring", "apl_state", "apl_pending", "absolute_tick", "delta", "relevant_rng_state"], + "reset_or_analytic_time_advance": False}, + baseline_snapshot_id=_snapshot_id(block_id, "normal", name, 0), + elapsed_idle_ticks=ticks, probe_count=2, snapshot_mutated_by_planning=False)) + + ids = {row["snapshot_id"] for row in snapshots} + _require(len(ids) == len(snapshots) and all(p["baseline_snapshot_id"] in ids for p in probes), + "Duplicate snapshot or missing original pre baseline") + by_assay = Counter(p["assay"] for p in probes) + steps = len(probes) * config["probe_steps"] + result = dict(schema=SCHEMA, status="DRAFT-NOT-REGISTERED", config=config, + trial_payload_sha256=trial["payload_sha256"], seed_manifest_sha256=trial["seed_manifest"]["payload_sha256"], + config_sha256=_sha(config), snapshots=snapshots, probes=probes, + budget=dict(snapshot_count=len(snapshots), evaluation_probes=len(probes), + probes_by_assay=dict(sorted(by_assay.items())), evaluation_steps=steps, + evaluation_biological_seconds=steps / 10000, + preexisting_training_control_reversal_epochs=trial["budget"]["training_control_reversal_epochs"], + preexisting_minimum_steps_excluding_evaluation=trial["budget"]["minimum_steps_excluding_evaluation"], + planned_scientific_steps_including_evaluation=trial["budget"]["minimum_steps_excluding_evaluation"] + steps, + planned_scientific_biological_seconds=(trial["budget"]["minimum_steps_excluding_evaluation"] + steps) / 10000, + independent_evaluation_inputs=80, independent_training_blocks=40, + evaluation_probes_are_training_replicates=False, baseline_output_deduplication=False, + scope="conditional full draft inventory, not an executed count or wall-time/storage estimate", + excludes=["implementation validation", "frozen regressions", "encoding", "I/O", "resource reserve"]), + evaluation_inventory_structurally_complete=True, scientific_gate_added=False, + protocol_approval_claimed=False, execution_authorized=False, training_executed=False, + inputs_generated=False, input_generator_implementation_verified=False, candidate_generated=False, + learning_pass_claimed=False, external_source_and_snapshot_authority_verified=False, + subsequent_assays_condition="not authorized here; full planned inventory remains subject to the draft's acquisition gate") + result["payload_sha256"] = _sha(result) + # No returned nested object aliases caller-owned trial/config structures. + return json.loads(_canonical(result)) + + +def build_evaluation_plan(trial_payload, config): + """Build a complete draft inventory, with mandatory explicit choices.""" + _configuration(config) # Report missing choices before expensive reconstruction. + trial_plan.validate_trial_plan(trial_payload) + return _assemble(trial_payload, config) + + +def validate_evaluation_plan(payload, trial_payload, config): + """Reconstruct every probe/binding; an updated self-hash cannot excuse drift.""" + _require(type(payload) is dict and "payload_sha256" in payload, "Invalid evaluation payload") + _require(payload["payload_sha256"] == _sha({k: v for k, v in payload.items() if k != "payload_sha256"}), + "Evaluation payload SHA mismatch") + expected = build_evaluation_plan(trial_payload, config) + _require(_canonical(payload) == _canonical(expected), "Evaluation coverage, binding, type, seed, or order differs from exact reconstruction") + return True diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/evaluation_policy.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/evaluation_policy.py new file mode 100644 index 0000000..8c08061 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/evaluation_policy.py @@ -0,0 +1,294 @@ +"""Pure pre-outcome DRAFT snapshot policy; no I/O, inputs, execution or RNG. + +The caller must supply the complete existing trial_plan payload. This module +does not select a master seed. Its fixed policy choices are newly proposed +planning choices, not inherited biological measurements or scientific gates. + +build_evaluation_policy(trial_payload) returns a config directly consumable by +evaluation_plan.build_evaluation_plan, plus a separate ordered prefix audit. +Every snapshot boundary is AFTER the complete selected epoch and its scheduled +delta update, not immediately after the US tick. All returned timing and counts +describe planned exposures, never observed or accepted DAN spikes. +""" +from __future__ import annotations + +from collections import Counter +import hashlib +import json + +import evaluation_plan +import trial_plan + + +SCHEMA = "phase4an-evaluation-policy-draft-v1" +ALGORITHM = "fixed-ordered-CS-prefix-completed-epoch-v1" +POINTS = (0, 1, 2, 5, 10, 20, 50) +CS_FIELDS = frozenset(("cue", "odor", "occurrence", "stream_id", "source_stream_id", + "seed", "seed_bits", "seed_conversion", "role", "start_tick", "stop_tick_exclusive")) +IDENTITY_FIELDS = tuple(sorted(CS_FIELDS - {"stream_id"})) +SAME_POSITION_ARMS = frozenset(("d_zero", "frozen", "random_reward", "cs_only")) + + +def _require(condition, message): + if not condition: + raise ValueError(message) + + +def _canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False) + + +def _sha(value): + return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest() + + +def _registry_index(trial): + manifest = trial["seed_manifest"] + independent = {(r["split"], tuple(r["key"])): r for r in manifest["independent_streams"]} + aliases = {(r["split"], tuple(r["key"])): r for r in manifest["aliases"]} + _require(len(independent) == len(manifest["independent_streams"]) and + len(aliases) == len(manifest["aliases"]) and not (independent.keys() & aliases.keys()), + "Duplicate or overlapping seed registry keys") + return independent, aliases + + +def _stream_id(master, key): + return "training:" + _sha([trial_plan.SCHEMA, "stream-id", master, "training", list(key)]) + + +def _cs_identity(cs, *, block_id, assay, arm, base_arm, master, registry): + """Verify actual alias source/seed; omit only the control's own alias ID.""" + _require(type(cs) is dict and set(cs) == CS_FIELDS, "CS binding schema differs") + _require(cs["cue"] in trial_plan.CUES and cs["odor"] == trial_plan.ODORS[cs["cue"]] and + type(cs["occurrence"]) is int and 0 <= cs["occurrence"] < 50 and + type(cs["seed"]) is int and 0 <= cs["seed"] < 2**63 and + type(cs["seed_bits"]) is int and cs["seed_bits"] == 63 and cs["role"] == "CS" and + type(cs["start_tick"]) is int and cs["start_tick"] == 0 and + type(cs["stop_tick_exclusive"]) is int and cs["stop_tick_exclusive"] == 1000 and + cs["seed_conversion"] == "exact integer; no truncation or addition", + "CS cue/occurrence/full seed/role/timing differs") + key = (block_id, assay, "CS", cs["cue"], f'{cs["occurrence"]:02d}', arm) + source_key = key[:-1] + (base_arm,) + independent, aliases = registry + source = independent.get(("training", source_key)) + _require(source is not None and source["seed"] == cs["seed"] and + cs["source_stream_id"] == _stream_id(master, source_key) and + cs["stream_id"] == _stream_id(master, key), "CS seed/source stream authority differs") + if arm == base_arm: + _require(("training", key) not in aliases and cs["stream_id"] == cs["source_stream_id"], + "Original CS must be its own independent source") + else: + alias = aliases.get(("training", key)) + _require(alias is not None and alias["source_split"] == "training" and + alias["source_key"] == list(source_key) and alias["seed"] == cs["seed"] and + alias["independent_replicate"] is False, + "CS alias is not registered to this block/assay/cue/occurrence source") + return {key: cs[key] for key in IDENTITY_FIELDS} + + +def _prefixes(arm, *, block_id, assay, base_arm, master, registry): + """Return verified ordered CS records indexed by actual completed epochs.""" + identities = [] + prefixes = [[]] + for epoch in arm["epochs"]: + if epoch["cs"] is not None: + identities.append(_cs_identity(epoch["cs"], block_id=block_id, assay=assay, + arm=arm["arm"], base_arm=base_arm, master=master, registry=registry)) + prefixes.append(list(identities)) + if arm["arm"] == "dan_only": + _require(not identities, "DAN-only must contain no delivered CS") + else: + _require(len(identities) == 100, "Every CS-present arm must retain all 100 CS epochs") + counters = Counter() + for row in identities: + _require(row["occurrence"] == counters[row["cue"]], "CS occurrence order is not canonical") + counters[row["cue"]] += 1 + _require(dict(counters) == {"A": 50, "B": 50}, "Both original odors require 50 CS occurrences") + return prefixes + + +def _reinforcement_boundary(arm, point): + """The whole epoch containing the kth requested US; terminal includes all.""" + if point == 0: + return 0 + epochs = arm["epochs"] + us_indices = [i for i, e in enumerate(epochs) if e["us"]["requested"]] + _require(len(us_indices) == 50, "Reference arm must have exactly 50 requested US epochs") + return len(epochs) if point == 50 else us_indices[point - 1] + 1 + + +def _exposure(arm, count, cs_prefix): + epochs = arm["epochs"][:count] + return dict(completed_epochs=count, CS_epochs=len(cs_prefix), + CS_counts_by_cue={cue: sum(c["cue"] == cue for c in cs_prefix) for cue in trial_plan.CUES}, + ordered_CS_source_identity_sha256=_sha(cs_prefix), + requested_US_epochs=sum(e["us"]["requested"] for e in epochs), + enabled_nonzero_D_update_opportunities=sum(e["update_enabled"] and e["D_command"] != 0 for e in epochs), + elapsed_training_ticks=sum(e["steps"] for e in epochs), + actual_accepted_US_or_DAN_spikes_known=False) + + +def _suite_policy(block, suite, master, registry): + """Independent prefix/accounting construction, separate from evaluation_plan.""" + assay, block_id = suite["assay"], block["block_id"] + _require(assay in ("normal", "delayed", "reversal"), "Unexpected assay") + expected_arms = trial_plan.REVERSAL_ARMS if assay == "reversal" else trial_plan.ARMS + _require([a["arm"] for a in suite["arms"]] == list(expected_arms), "Missing, reordered or duplicate control arms") + arms = {a["arm"]: a for a in suite["arms"]} + base_name = "reversal" if assay == "reversal" else "paired" + base = arms[base_name] + _require(len(base["epochs"]) == 100, "Base arm must contain all 100 epochs") + prefixes = {name: _prefixes(arm, block_id=block_id, assay=assay, base_arm=base_name, + master=master, registry=registry) for name, arm in arms.items()} + base_full = prefixes[base_name][-1] + for name, arm in arms.items(): + _require(len(arm["epochs"]) == (150 if name == "unpaired" else 100), "Unexpected epoch coverage") + if name == "dan_only": + for i, epoch in enumerate(arm["epochs"]): + original = base["epochs"][i] + _require(epoch["cs"] is None and + epoch["yoked_cue_removed"] == original["cs"]["cue"] and + type(epoch["yoked_occurrence_removed"]) is int and + epoch["yoked_occurrence_removed"] == original["cs"]["occurrence"] and + _canonical(epoch["us"]) == _canonical(original["us"]) and + epoch["steps"] == original["steps"], + "DAN-only omitted CS/yoked US/epoch position differs from paired") + else: + _require(_canonical(prefixes[name][-1]) == _canonical(base_full), + "Actual ordered CS cue/occurrence/seed/source sequence differs") + if name != "unpaired": + _require(all(_canonical(a) == _canonical(b) + for a, b in zip(prefixes[name], prefixes[base_name])), + "CS sequence differs at a paired epoch position") + + bindings, audits = [], [] + for name in expected_arms: + arm = arms[name] + previous = -1 + for point in POINTS: + base_count = _reinforcement_boundary(base, point) + expected_prefix = prefixes[base_name][base_count] + if assay == "reversal": + count = _reinforcement_boundary(arm, point) + alignment = "own reinforced-US ordinal; intermediate CS prefixes need not match across arms" + elif name == "unpaired": + if point == 0: + count = 0 + elif point == 50: + count = len(arm["epochs"]) + else: + matches = [i for i, prefix in enumerate(prefixes[name]) if len(prefix) == len(expected_prefix)] + _require(bool(matches), "Unpaired arm never reaches the required CS prefix") + count = matches[0] + alignment = "earliest ordered-CS-prefix boundary; terminal includes all 150 epochs" + else: + count = base_count + alignment = ("paired yoked epoch positions; zero delivered CS, not equal CS exposure" + if name == "dan_only" else "exact paired completed-epoch and ordered-CS prefix") + _require(count > previous, "Policy snapshot epochs must advance strictly") + previous = count + own_prefix = prefixes[name][count] + same_cs = _canonical(own_prefix) == _canonical(expected_prefix) + if assay != "reversal" and name != "dan_only": + _require(same_cs, "Ordered CS prefix mismatch at a control checkpoint") + if name == "unpaired" and point not in (0, 50): + _require(count > 0 and len(prefixes[name][count - 1]) < len(expected_prefix), + "Unpaired snapshot is not the earliest matching completed-epoch boundary") + actual = _exposure(arm, count, own_prefix) + reference = _exposure(base, base_count, expected_prefix) + binding = dict(block_id=block_id, assay=assay, arm=name, + checkpoint=point, completed_epochs=count) + bindings.append(binding) + audits.append(dict(binding=binding, alignment=alignment, + reference_arm=base_name, reference_completed_epochs=base_count, + boundary="after complete epoch and scheduled delta update; not at the US tick", + terminal_includes_all_epochs=point == 50, + actual=actual, reference=reference, + ordered_CS_prefix_exact=same_cs if name != "dan_only" else None, + CS_exposure_equal=same_cs if name != "dan_only" else False, + DAN_only_yoke_verified=name == "dan_only", + requested_US_count_equal_to_reference=actual["requested_US_epochs"] == reference["requested_US_epochs"], + requested_US_count_difference=actual["requested_US_epochs"] - reference["requested_US_epochs"], + elapsed_ticks_equal_to_reference=actual["elapsed_training_ticks"] == reference["elapsed_training_ticks"], + elapsed_ticks_difference=actual["elapsed_training_ticks"] - reference["elapsed_training_ticks"], + nonzero_D_update_opportunity_difference=actual["enabled_nonzero_D_update_opportunities"] - reference["enabled_nonzero_D_update_opportunities"], + all_US_timing_or_total_exposure_matched_claimed=False)) + return bindings, audits + + +def _assemble(trial): + registry = _registry_index(trial) + bindings, audits = [], [] + blocks = trial["plan"]["blocks"] + _require(len(blocks) == 40, "Exactly 40 original training blocks are required") + for block in blocks: + _require(block["evaluation_checkpoints"] == list(POINTS) and + [s["assay"] for s in block["suites"]] == ["normal", "delayed", "reversal"], + "Original assay/checkpoint coverage differs") + for suite in block["suites"]: + suite_bindings, suite_audits = _suite_policy(block, suite, trial["plan"]["master"], registry) + bindings.extend(suite_bindings) + audits.extend(suite_audits) + config = dict(schema=evaluation_plan.CONFIG_SCHEMA, probe_steps=1200, + cs_start_tick=0, cs_stop_tick_exclusive=1000, reversal_checkpoints=list(POINTS), + baseline_execution="execute_each_snapshot", snapshot_binding_policy="explicit_completed_epochs", + snapshot_bindings=bindings) + # Check the unchanged consumer's declared schema/coverage as well as our + # independent ordered-prefix construction. Do not generate its probes here. + evaluation_plan._configuration(config) + evaluation_plan._binding_inventory(trial["plan"], config) + _require(len(bindings) == 4480 and len({_canonical(v) for v in bindings}) == 4480, + "Incomplete or duplicate snapshot coverage") + counts = Counter(row["assay"] for row in bindings) + result = dict(schema=SCHEMA, status="DRAFT-NOT-REGISTERED", algorithm=ALGORITHM, + trial_payload_sha256=trial["payload_sha256"], + seed_manifest_sha256=trial["seed_manifest"]["payload_sha256"], + config_sha256=_sha(config), config=config, + audit=dict(bindings=audits, + coverage=dict(training_blocks=40, training_replicates_per_cell=5, + nonretention_snapshots=4480, snapshots_by_assay=dict(sorted(counts.items())), + original_full_CS_alias_sequences_verified=True, + new_random_streams=0, new_master_seed_selected=False), + exposure_limits=[ + "CS-present normal/delayed controls match actual ordered CS prefixes, not just counts.", + "US counts, nonzero-D opportunities and elapsed time are separately reported, not assumed matched.", + "Unpaired boundaries include earlier inserted US; terminal includes all 150 epochs and trailing US.", + "DAN-only has zero delivered CS; yoked positions are not equal CS exposure.", + "Reversal and continuation use their own kth US; intermediate CS prefixes can differ.", + "All snapshots are after a full epoch plus its delta update, not the US tick.", + "Requested US is not accepted DAN activity, dopamine concentration or learning evidence."], + unchanged_retention_scope=dict(arms=["normal/paired", "normal/frozen"], + idle_checkpoints_seconds=[0, 1, 10], + same_continuous_ten_second_lineage=True, probes_fork_and_do_not_advance_idle=True, + retained_snapshot_execution_or_lineage_verified=False), + conditional_evaluation_inventory=dict(nonretention_probes=8960, + retention_snapshots=240, retention_probes=480, all_snapshots=4720, + all_probes=9440, probe_steps=1200, evaluation_steps=11328000, + includes_full_seven_point_reversal=True, + baseline_output_deduplication=False, + actual_execution_count=0)), + policy_structural_checks_complete=True, choices_fixed_before_outcomes=True, + choices_are_new_draft_proposals_not_inherited_gates=True, + protocol_approval_claimed=False, execution_authorized=False, training_executed=False, + inputs_generated=False, input_generator_implementation_verified=False, + source_and_snapshot_authority_verified=False, candidate_generated=False, + learning_pass_claimed=False, scientific_gate_added=False) + result["payload_sha256"] = _sha(result) + return json.loads(_canonical(result)) + + +def build_evaluation_policy(trial_payload): + """Return fixed DRAFT config/audit; caller provides, we never choose, master.""" + trial_plan.validate_trial_plan(trial_payload) + return _assemble(trial_payload) + + +def validate_evaluation_policy(payload, trial_payload): + """Exact reconstruction, including JSON types; a replaced self-hash is not authority.""" + _require(type(payload) is dict and "payload_sha256" in payload, "Invalid policy payload") + _require(payload["payload_sha256"] == _sha({k: v for k, v in payload.items() if k != "payload_sha256"}), + "Actual evaluation policy SHA mismatch") + expected = build_evaluation_policy(trial_payload) + _require(_canonical(payload) == _canonical(expected), "Policy config/audit/coverage differs from exact reconstruction") + return True diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/metrics.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/metrics.py new file mode 100644 index 0000000..e09274f --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/metrics.py @@ -0,0 +1,494 @@ +"""Pure Phase 4AN proposal metrics; no file I/O, simulation or fitted decoder. + +The caller must bind complete anatomical population membership (not a plastic +mask subset), finalized execution receipts, input identities and seed lineages +to its preregistration. These functions validate arrays/declared coverage; they +cannot establish provenance by trusting a caller's Boolean or SHA string. +All gates below are NEW DRAFT hypotheses, not a historical learning result. +""" +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Mapping + +import numpy as np + + +SCHEMA = 'phase4an-metrics-draft-v1' +CUES = ('4-ethylphenol', '3-hexanone') +MODES = ('physiological', 'matched_energy') +DAN_TYPES = ('PAM15', 'PPL103') +CONTROLS = ('D=0', 'frozen', 'random reward', 'unpaired', 'CS-only', 'DAN-only') +ARMS = ('paired',) + CONTROLS +POPULATIONS = ('MBON', 'legacyDownstream', 'nonMBONDownstream', 'MBON01', 'MBON12') +SEPARATION_POPULATIONS = ('MBON', 'legacyDownstream', 'nonMBONDownstream') +POPULATION_SEMANTICS = { + 'MBON': 'complete caller-bound anatomical MBON population', + 'legacyDownstream': 'complete frozen H downstream membership, including MBONs; never mask-filtered', + 'nonMBONDownstream': 'exact legacyDownstream minus MBON; NEW hypothesis readout, not the old H population', + 'MBON01': 'complete caller-bound MBON01 type, not plastic-mask targets only', + 'MBON12': 'complete caller-bound MBON12 type, not plastic-mask targets only', +} +STATE_KEYS = frozenset(('step', 'neuron_idx', 'counts', 'v', 'g', 'ref')) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def _hash(value): + return hashlib.sha256(np.ascontiguousarray(value, dtype=value.dtype.newbyteorder('<')).tobytes()).hexdigest() + + +def _json_hash(value): + raw = json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8') + return hashlib.sha256(raw).hexdigest() + + +def _sha(value): + return type(value) is str and len(value) == 64 and all(c in '0123456789abcdef' for c in value) + + +def _token(value, name): + require(type(value) is str and bool(value) and value.strip() == value, 'Invalid ' + name) + return value + + +def _vector(value, dtype, name, size=None): + require(isinstance(value, np.ndarray) and value.dtype == np.dtype(dtype) and value.ndim == 1, + name + ' requires an exact ' + dtype + ' vector') + require(size is None or value.shape == (size,), name + ' shape mismatch') + return value + + +def _ids(value, name): + _vector(value, 'int32', name) + require(len(value) > 0 and np.all((value >= 0) & (value < 166700)) and np.all(value[1:] > value[:-1]), + name + ' must be nonempty sorted unique global neuron indices') + return value + + +def _positions(nodes, values, name): + position = np.searchsorted(nodes, values) + require(np.all(position < len(nodes)), name + ' contains absent neurons') + require(np.array_equal(nodes[position], values), name + ' contains absent neurons') + return position + + +def validate_populations(nodes, populations): + """Validate full caller-bound type lists; never derive them from an edge mask.""" + _ids(nodes, 'nodes') + require(isinstance(populations, Mapping) and set(populations) == set(POPULATIONS), + 'Require exactly MBON/legacyDownstream/nonMBONDownstream/MBON01/MBON12 memberships') + for name in POPULATIONS: + _positions(nodes, _ids(populations[name], name), name) + for name in ('MBON01', 'MBON12'): + _positions(populations['MBON'], populations[name], name + ' outside MBON') + require(not len(np.intersect1d(populations['MBON01'], populations['MBON12'])), 'Type populations overlap') + _positions(populations['legacyDownstream'], populations['MBON'], 'MBON outside legacyDownstream') + require(np.array_equal(populations['nonMBONDownstream'], + np.setdiff1d(populations['legacyDownstream'], populations['MBON'])), + 'nonMBONDownstream must equal the complete legacyDownstream minus MBON') + payload = {name: populations[name].tolist() for name in POPULATIONS} + return dict(indices=payload, membership_sha256=_json_hash(payload), + population_semantics=dict(POPULATION_SEMANTICS), + complete_anatomical_membership_authority='caller preregistration; never inferred from mask') + + +def validate_state(state, *, nodes, steps, dt_ms=.1): + """Check every original state element and all sparse events without mutation.""" + _ids(nodes, 'nodes') + require(type(steps) is int and 0 < steps <= np.iinfo(np.int32).max, 'Invalid steps') + require(type(dt_ms) in (int, float) and math.isfinite(dt_ms) and dt_ms > 0, 'Invalid dt_ms') + require(isinstance(state, Mapping) and set(state) == STATE_KEYS, 'State must contain exactly six runtime keys') + n = len(nodes) + for key, dtype in (('counts', 'int32'), ('ref', 'int32'), ('v', 'float64'), ('g', 'float64')): + _vector(state[key], dtype, key, n) + require(np.isfinite(state[key]).all(), 'Nonfinite state: ' + key) + require(np.all((state['counts'] >= 0) & (state['counts'] <= steps)), 'Invalid counts') + require(np.all((state['ref'] >= 0) & (state['ref'] <= 22)), 'Invalid refractory state') + ticks = _vector(state['step'], 'int32', 'step') + events = _vector(state['neuron_idx'], 'int32', 'neuron_idx', len(ticks)) + require(np.all((ticks >= 0) & (ticks < steps)), 'Event tick outside trial') + require(len(ticks) < 2 or np.all((ticks[1:] > ticks[:-1]) | + ((ticks[1:] == ticks[:-1]) & (events[1:] > events[:-1]))), + 'Events must be sorted unique by tick/global neuron') + local = _positions(nodes, events, 'events') + require(np.array_equal(np.bincount(local, minlength=n), state['counts']), 'Sparse events/counts mismatch') + first = np.full(n, -1, dtype=np.int32) + if len(ticks): + first.fill(steps) + np.minimum.at(first, local, ticks) + first[first == steps] = -1 + return dict(first_tick=first, local_events=local, + hashes={key: _hash(state[key]) for key in sorted(STATE_KEYS)}) + + +def _first_ms(first, dt_ms): + present = first[first >= 0] + return float(int(present.min()) * dt_ms) if len(present) else None + + +def _latencies(first, dt_ms): + return [None if int(tick) < 0 else float(int(tick) * dt_ms) for tick in first] + + +def _population_change(pre, post, checked_pre, checked_post, nodes, local, dt_ms, vectors): + cp, cq = pre['counts'][local].astype(np.int64), post['counts'][local].astype(np.int64) + fp, fq = checked_pre['first_tick'][local], checked_post['first_tick'][local] + lp, lq = _first_ms(fp, dt_ms), _first_ms(fq, dt_ms) + both = (fp >= 0) & (fq >= 0) + ep = np.isin(checked_pre['local_events'], local) + eq = np.isin(checked_post['local_events'], local) + events_same = (np.array_equal(pre['step'][ep], post['step'][eq]) and + np.array_equal(pre['neuron_idx'][ep], post['neuron_idx'][eq])) + try: + with np.errstate(over='raise', invalid='raise'): + dv, dg = post['v'][local] - pre['v'][local], post['g'][local] - pre['g'][local] + vmax, gmax = float(np.max(np.abs(dv))), float(np.max(np.abs(dg))) + except FloatingPointError as error: + raise ValueError('Nonfinite derived final-state difference') from error + result = dict(neurons=len(local), pre_total_spikes=int(cp.sum()), post_total_spikes=int(cq.sum()), + spike_count_l1=int(np.abs(cq - cp).sum()), spike_count_changed_neurons=int(np.count_nonzero(cp != cq)), + events_changed=not events_same, pre_first_latency_ms=lp, post_first_latency_ms=lq, + first_latency_delta_ms=None if lp is None or lq is None else float(lq - lp), + defined_first_latency_changed=bool(lp is not None and lq is not None and lp != lq), + first_latency_both_defined_neurons=int(both.sum()), + first_latency_changed_among_both_defined_neurons=int(np.count_nonzero(both & (fp != fq))), + first_latency_became_defined_neurons=int(np.count_nonzero((fp < 0) & (fq >= 0))), + first_latency_became_undefined_neurons=int(np.count_nonzero((fp >= 0) & (fq < 0))), + final_delta_v_max_abs=vmax, final_delta_g_max_abs=gmax, + final_ref_changed_neurons=int(np.count_nonzero(pre['ref'][local] != post['ref'][local])), + final_state_units='SI volts; refractory integer ticks', + max_abs_scope='maximum across population neurons at terminal tick; NOT a temporal peak') + if vectors: + result.update(neuron_idx=nodes[local].tolist(), pre_counts=cp.tolist(), post_counts=cq.tolist(), + pre_first_latency_ms_per_neuron=_latencies(fp, dt_ms), + post_first_latency_ms_per_neuron=_latencies(fq, dt_ms)) + for key in ('v', 'g', 'ref'): + result['pre_final_' + key] = pre[key][local].tolist() + result['post_final_' + key] = post[key][local].tolist() + return result + + +def functional_summary(pre, post, *, nodes, populations, steps, dt_ms=.1): + """Full-state validation plus wholebrain and anatomical-group differences. + + The population's earliest event defines the legacy-style first latency. + Per-neuron first events and missingness transitions are additional diagnostics. + A missing latency is None, never zero or evidence of a defined latency shift. + """ + membership = validate_populations(nodes, populations) + a = validate_state(pre, nodes=nodes, steps=steps, dt_ms=dt_ms) + b = validate_state(post, nodes=nodes, steps=steps, dt_ms=dt_ms) + groups = {name: _population_change(pre, post, a, b, nodes, + _positions(nodes, populations[name], name), dt_ms, True) for name in POPULATIONS} + return dict(schema=SCHEMA, full_six_key_state_validated=True, steps=steps, dt_ms=float(dt_ms), + nodes=len(nodes), nodes_raw_sha256=_hash(nodes), membership=membership, + pre_state_raw_sha256=a['hashes'], post_state_raw_sha256=b['hashes'], + wholebrain=_population_change(pre, post, a, b, nodes, np.arange(len(nodes)), dt_ms, False), + populations=groups, MBON_final_delta_g_max_abs=groups['MBON']['final_delta_g_max_abs'], + MBON_final_delta_g_units='SI volts', MBON_final_delta_g_scope='terminal across-neuron maximum, not time peak') + + +def arm_metrics(pre_by_cue, post_by_cue, *, conditioned_cue, nodes, populations, steps=1200, dt_ms=.1): + """New fixed, unscaled type-count contrast; neither decoder nor feedback.""" + require(conditioned_cue in CUES, 'Unknown conditioned odor label') + require(isinstance(pre_by_cue, Mapping) and isinstance(post_by_cue, Mapping) and + set(pre_by_cue) == set(post_by_cue) == set(CUES), 'Both original odor labels are required') + neutral = next(cue for cue in CUES if cue != conditioned_cue) + summaries = {cue: functional_summary(pre_by_cue[cue], post_by_cue[cue], nodes=nodes, + populations=populations, steps=steps, dt_ms=dt_ms) for cue in CUES} + b, means = {}, {} + for phase in ('pre', 'post'): + b[phase], means[phase] = {}, {} + for cue in CUES: + groups = summaries[cue]['populations'] + means[phase][cue] = {name: float(np.mean(groups[name][phase + '_counts'])) for name in ('MBON01', 'MBON12')} + b[phase][cue] = means[phase][cue]['MBON12'] - means[phase][cue]['MBON01'] + raw_s = (b['post'][conditioned_cue] - b['post'][neutral]) - (b['pre'][conditioned_cue] - b['pre'][neutral]) + separation = {} + for name in SEPARATION_POPULATIONS: + values = {} + for phase in ('pre', 'post'): + first = np.asarray(summaries[CUES[0]]['populations'][name][phase + '_counts'], dtype=np.float64) + second = np.asarray(summaries[CUES[1]]['populations'][name][phase + '_counts'], dtype=np.float64) + values[phase] = float(np.linalg.norm(first - second)) + separation[name] = dict(values, gain=values['post'] - values['pre'], + vector='all bound member spike counts', population_semantics=POPULATION_SEMANTICS[name]) + return dict(schema=SCHEMA, analysis_status='DRAFT_NEW_HYPOTHESIS', conditioned_cue=conditioned_cue, + neutral_cue=neutral, steps=steps, dt_ms=float(dt_ms), rawB=b, rawS=float(raw_s), + type_mean_counts=means, B_definition='mean full MBON12 counts - mean full MBON01 counts', + B_is_decoder=False, S_orientation='conditioned minus neutral; post minus own pre', + separation=separation, cues=summaries, + historical_implementation_equivalence_claimed=False, learning_pass_claimed=False) + + +def _finite_tree(value): + if isinstance(value, Mapping): + for item in value.values(): + _finite_tree(item) + elif isinstance(value, (list, tuple)): + for item in value: + _finite_tree(item) + elif isinstance(value, (float, np.floating)): + require(math.isfinite(float(value)), 'Nonfinite metric value') + + +def _bound_metric(metric, cue): + require(isinstance(metric, Mapping) and metric.get('schema') == SCHEMA and + metric.get('analysis_status') == 'DRAFT_NEW_HYPOTHESIS', 'Unexpected metric schema') + _finite_tree(metric) + require(metric['conditioned_cue'] == cue and metric['neutral_cue'] == next(c for c in CUES if c != cue), + 'Cue role mismatch') + require(metric['steps'] == 1200 and metric['dt_ms'] == .1, 'Evaluation must use the fixed 120 ms probe') + # Derive gate quantities from recorded raw vectors, not a supplied gate Boolean. + for odor in CUES: + summary = metric['cues'][odor] + require(summary.get('full_six_key_state_validated') is True and summary['steps'] == 1200 and + summary['dt_ms'] == .1 and _sha(summary['nodes_raw_sha256']), 'Unvalidated cue state') + membership = summary['membership']['indices'] + require(set(membership) == set(POPULATIONS) and summary['membership']['membership_sha256'] == _json_hash(membership), + 'Population membership digest mismatch') + require(summary['membership'].get('population_semantics') == POPULATION_SEMANTICS, + 'Population semantics must distinguish old H membership from the new non-MBON readout') + for key in ('pre_state_raw_sha256', 'post_state_raw_sha256'): + require(set(summary[key]) == STATE_KEYS and all(_sha(v) for v in summary[key].values()), 'Missing state digest') + for name in POPULATIONS: + group = summary['populations'][name] + ids, count = group['neuron_idx'], group['neurons'] + require(type(count) is int and count > 0 and len(ids) == count and ids == membership[name] and + all(type(v) is int and 0 <= v < 166700 for v in ids) and ids == sorted(set(ids)), + 'Empty or mismatched anatomical population') + for phase in ('pre', 'post'): + counts = group[phase + '_counts'] + latency = group[phase + '_first_latency_ms_per_neuron'] + require(len(counts) == len(latency) == count and + all(type(v) is int and 0 <= v <= 1200 for v in counts), 'Invalid count/latency vector') + for c, value in zip(counts, latency): + require((c == 0 and value is None) or (c > 0 and type(value) in (int, float) and + 0 <= value < 120 and value == round(value / .1) * .1), 'Missing/nonphysical first latency') + present = [v for v in latency if v is not None] + require(group[phase + '_first_latency_ms'] == (min(present) if present else None), + 'Population first latency differs from raw cells') + for key in ('v', 'g', 'ref'): + values = group[phase + '_final_' + key] + require(len(values) == count and all(type(v) in (int, float) and math.isfinite(v) for v in values), + 'Invalid final-state vector') + if key == 'ref': + require(all(type(v) is int and 0 <= v <= 22 for v in values), 'Invalid final ref') + l1 = sum(abs(a - b) for a, b in zip(group['pre_counts'], group['post_counts'])) + lp, lq = group['pre_first_latency_ms'], group['post_first_latency_ms'] + changed = lp is not None and lq is not None and lp != lq + require(type(group['spike_count_l1']) is int and group['spike_count_l1'] == l1 and + group['defined_first_latency_changed'] is changed and + group['first_latency_delta_ms'] == (None if lp is None or lq is None else lq - lp), + 'Functional predicate differs from raw counts/defined latency') + try: + with np.errstate(over='raise', invalid='raise'): + for key in ('v', 'g'): + difference = np.asarray(group['post_final_' + key]) - np.asarray(group['pre_final_' + key]) + require(group['final_delta_' + key + '_max_abs'] == float(np.max(np.abs(difference))), + 'Final-state maximum differs from raw terminal vectors') + except FloatingPointError as error: + raise ValueError('Nonfinite derived terminal metric') from error + require(not set(membership['MBON01']).intersection(membership['MBON12']), 'Type population overlap') + require(set(membership['MBON']) <= set(membership['legacyDownstream']), + 'Legacy downstream must retain the complete MBON population') + require(membership['nonMBONDownstream'] == sorted(set(membership['legacyDownstream']) - set(membership['MBON'])), + 'nonMBONDownstream differs from exact legacyDownstream minus MBON') + for name, parent in (('MBON01', 'MBON'), ('MBON12', 'MBON'), + ('MBON', 'legacyDownstream'), ('nonMBONDownstream', 'legacyDownstream')): + require(set(membership[name]) <= set(membership[parent]), 'Population is not a subset of its bound parent') + lookup = {v: i for i, v in enumerate(membership[parent])} + for phase in ('pre', 'post'): + for field in ('counts', 'first_latency_ms_per_neuron', 'final_v', 'final_g', 'final_ref'): + key = phase + '_' + field + require(summary['populations'][name][key] == [ + summary['populations'][parent][key][lookup[v]] for v in membership[name]], + 'Overlapping population vectors disagree with the bound parent') + for phase in ('pre', 'post'): + for odor in CUES: + groups = metric['cues'][odor]['populations'] + means = [] + for name in ('MBON12', 'MBON01'): + counts = groups[name][phase + '_counts'] + require(len(counts) > 0 and all(type(v) is int and 0 <= v <= 1200 for v in counts), + 'Empty or invalid type count vector') + means.append(sum(counts) / len(counts)) + require(metric['type_mean_counts'][phase][odor][name] == means[-1], 'Type mean differs from raw counts') + require(metric['rawB'][phase][odor] == means[0] - means[1], 'Raw B disagrees with full type vectors') + neutral = metric['neutral_cue'] + calculated = (metric['rawB']['post'][cue] - metric['rawB']['post'][neutral]) - ( + metric['rawB']['pre'][cue] - metric['rawB']['pre'][neutral]) + require(type(metric['rawS']) in (int, float) and metric['rawS'] == calculated, 'Raw S disagrees with signed components') + for name in SEPARATION_POPULATIONS: + calculated = {} + for phase in ('pre', 'post'): + a = metric['cues'][CUES[0]]['populations'][name][phase + '_counts'] + b = metric['cues'][CUES[1]]['populations'][name][phase + '_counts'] + require(metric['cues'][CUES[0]]['membership'] == metric['cues'][CUES[1]]['membership'], + 'A/B population mismatch') + calculated[phase] = float(np.linalg.norm(np.asarray(a, dtype=np.float64) - np.asarray(b, dtype=np.float64))) + require(metric['separation'][name]['pre'] == calculated['pre'] and + metric['separation'][name]['post'] == calculated['post'] and + metric['separation'][name]['gain'] == calculated['post'] - calculated['pre'], + 'Separation gain differs from raw count vectors') + require(metric['separation'][name].get('population_semantics') == POPULATION_SEMANTICS[name], + 'Separation population semantics mismatch') + return metric + + +def _execution(arm, name, assay, used_receipts): + require(isinstance(arm, Mapping) and set(arm) == {'metrics', 'execution'}, 'Arm needs metrics and actual execution evidence') + e = arm['execution'] + required = {'complete', 'recording_verified', 'receipt_sha256', 'assay', 'epoch_duration_ms', + 'cs_duration_ms', 'us_tick', 'completed_epochs', 'reinforcement_opportunities', 'terminal_pairings'} + require(isinstance(e, Mapping) and set(e) == required, 'Incomplete execution evidence') + require(e['complete'] is True and e['recording_verified'] is True and _sha(e['receipt_sha256']), + 'All arms require an actual verified completed execution receipt') + require(e['receipt_sha256'] not in used_receipts, 'Execution receipt reused across arms/replicates') + used_receipts.add(e['receipt_sha256']) + require(e['assay'] == assay and e['epoch_duration_ms'] == (120. if assay == 'normal' else 180.) and + e['cs_duration_ms'] == 100. and type(e['us_tick']) is int and + e['us_tick'] == (900 if assay == 'normal' else 1500), 'Normal/delayed control timing mismatch') + require(type(e['completed_epochs']) is int and e['completed_epochs'] == (150 if name == 'unpaired' else 100), + 'Incomplete arm epoch inventory') + require(type(e['reinforcement_opportunities']) is int and e['reinforcement_opportunities'] == (0 if name == 'CS-only' else 50), + 'Wrong arm US opportunity inventory') + require(type(e['terminal_pairings']) is int and e['terminal_pairings'] == 50, 'Only fixed terminal count 50 may gate') + + +def evaluate_acquisition(replicates, *, assay, mode, dan_type, conditioned_cue, registered_training_streams): + """Strict new normal/delayed 3-of-5 SAME-training-replicate conjunction. + + Each row has replicate_id, replicate_unit='independent_training', + training_stream_ids (all independent source IDs, not aliases), + evaluation_stream_ids, assay/mode/dan_type/conditioned_cue, and seven arms. + Arm = {metrics: arm_metrics(...), execution: complete receipt declaration}. + registered_training_streams maps exactly five predeclared replicate IDs to + their source-ID lists. Aliased controls stay within a row; evaluation cannot + become new training replication. This pure function does NOT verify a file + receipt/seed derivation; the coordinator must independently do so. + """ + require(assay in ('normal', 'delayed') and mode in MODES and dan_type in DAN_TYPES and conditioned_cue in CUES, + 'Unknown raw assay/mode/DAN/odor label') + require(isinstance(registered_training_streams, Mapping) and len(registered_training_streams) == 5, + 'Require five predeclared independent training replicates') + registered, training_all = {}, set() + for identity, sources in registered_training_streams.items(): + _token(identity, 'training replicate identity') + require(isinstance(sources, (tuple, list)) and len(sources) > 0, 'Missing actual training source identities') + values = tuple(_token(v, 'training source identity') for v in sources) + require(len(set(values)) == len(values) and not training_all.intersection(values), + 'Training source reused across independent replicates') + training_all.update(values) + registered[identity] = values + require(isinstance(replicates, (tuple, list)) and len(replicates) == 5, 'Exactly five training rows required; do not count evaluation draws') + used_ids, used_eval, receipts, reports = set(), set(), set(), [] + for row in replicates: + keys = {'replicate_id', 'replicate_unit', 'training_stream_ids', 'evaluation_stream_ids', + 'assay', 'mode', 'dan_type', 'conditioned_cue', 'arms'} + require(isinstance(row, Mapping) and set(row) == keys, 'Invalid replicate schema') + identity = row['replicate_id'] + require(identity in registered and identity not in used_ids and row['replicate_unit'] == 'independent_training', + 'Unknown/duplicate training identity or evaluation pseudo-replication') + used_ids.add(identity) + require(isinstance(row['training_stream_ids'], (list, tuple)) and + tuple(row['training_stream_ids']) == registered[identity], 'Training lineage does not match preregistration') + require((row['assay'], row['mode'], row['dan_type'], row['conditioned_cue']) == (assay, mode, dan_type, conditioned_cue), + 'Cannot pool modes, DAN branches, cue assignments or timing suites') + ev = row['evaluation_stream_ids'] + require(isinstance(ev, (tuple, list)) and len(ev) > 0, 'Missing independent evaluation identity') + ev = tuple(_token(v, 'evaluation identity') for v in ev) + require(len(set(ev)) == len(ev) and not training_all.intersection(ev) and not used_eval.intersection(ev), + 'Evaluation sources overlap training or another training replicate') + used_eval.update(ev) + require(isinstance(row['arms'], Mapping) and set(row['arms']) == set(ARMS), 'All six actual controls and paired arm are mandatory') + metrics = {} + for name in ARMS: + _execution(row['arms'][name], name, assay, receipts) + metrics[name] = _bound_metric(row['arms'][name]['metrics'], conditioned_cue) + baseline = metrics['paired'] + # State baseline and populations must really be matched for each control. + for metric in metrics.values(): + for cue in CUES: + a, b = baseline['cues'][cue], metric['cues'][cue] + require(a['membership'] == b['membership'] and a['nodes_raw_sha256'] == b['nodes_raw_sha256'] and + a['pre_state_raw_sha256'] == b['pre_state_raw_sha256'], 'Control population or pre-baseline mismatch') + s = baseline['rawS'] + control_s = {name: metrics[name]['rawS'] for name in CONTROLS} + direction = s > 0 if dan_type == 'PAM15' else s < 0 + beyond = {name: (s > value if dan_type == 'PAM15' else s < value) for name, value in control_s.items()} + neutral = baseline['neutral_cue'] + c = baseline['cues'][conditioned_cue]['populations']['MBON'] + n = baseline['cues'][neutral]['populations']['MBON'] + functional = c['spike_count_l1'] > 0 or c['defined_first_latency_changed'] is True + predicates = dict(raw_signed_direction=bool(direction), beyond_each_actual_control=all(beyond.values()), + conditioned_final_g_exceeds_neutral=c['final_delta_g_max_abs'] > n['final_delta_g_max_abs'], + conditioned_count_or_defined_population_first_latency_change=bool(functional), + all_MBON_AB_euclidean_gain_positive=baseline['separation']['MBON']['gain'] > 0, + legacyDownstream_AB_euclidean_gain_positive=baseline['separation']['legacyDownstream']['gain'] > 0, + nonMBONDownstream_AB_euclidean_gain_positive=baseline['separation']['nonMBONDownstream']['gain'] > 0) + reports.append(dict(replicate_id=identity, rawS=s, raw_control_S=control_s, + population_separation_gains={name: baseline['separation'][name]['gain'] for name in SEPARATION_POPULATIONS}, + beyond_controls= beyond, predicates=predicates, conjunction_pass=all(predicates.values()), + legacy_audit=dict(L_like_functional_with_explicit_missing_latency=bool( + predicates['conditioned_final_g_exceeds_neutral'] and functional), + H_like_MBON_positive_gain=predicates['all_MBON_AB_euclidean_gain_positive'], + H_like_downstream_population='legacyDownstream', + H_like_downstream_positive_gain=predicates['legacyDownstream_AB_euclidean_gain_positive'], + original_implementation_equivalence_claimed=False), + new_hypothesis_audit=dict(nonMBONDownstream_positive_gain= + predicates['nonMBONDownstream_AB_euclidean_gain_positive'], + inherited_from_old_H=False, either_downstream_may_rescue_other=False))) + reports.sort(key=lambda r: r['replicate_id']) + successful = sum(r['conjunction_pass'] for r in reports) + return dict(schema=SCHEMA, status='DRAFT-PROPOSAL-GATE-PASS' if successful >= 3 else 'DRAFT-PROPOSAL-GATE-FAIL', + proposal_gate_pass=successful >= 3, scientific_learning_pass_claimed=False, candidate_generated=False, + assay=assay, mode=mode, dan_type=dan_type, conditioned_cue=conditioned_cue, + required_training_replicates=5, successful_same_replicate_conjunctions=successful, required_majority=3, + actual_control_receipts=len(receipts) - 5, evaluation_draws_count_as_replicates=False, + execution_receipt_authority='caller must verify finalized receipts and seed registry; pure metrics do not read files', + historical_full_H_mean_control_rule_computed=False, replicates=reports) + + +def legacy_h_mean_control_audit(protocol_mbon_gains): + """Old H scalar predicate only, kept separate from the new signed gate. + + Input uses the ORIGINAL five protocol labels, each with five already-bound + training-replicate MBON Euclidean gains. The caller must explicitly define + any AN-to-old-protocol mapping; this function never picks a DAN/control or + pools AN cells. It is not the entire old H gate or a reproduced old result. + """ + names = ('appetitive-conditioning', 'aversive-conditioning', 'd-zero-control', + 'random-reward-control', 'frozen-brain-control') + require(isinstance(protocol_mbon_gains, Mapping) and set(protocol_mbon_gains) == set(names), + 'Legacy audit requires exactly the five original H protocol labels') + means = {} + for name in names: + values = protocol_mbon_gains[name] + require(isinstance(values, (list, tuple)) and len(values) == 5 and + all(type(v) in (int, float) and math.isfinite(v) for v in values), + 'Legacy audit needs five finite, separately bound training-replicate gains') + means[name] = float(np.mean(values)) + require(math.isfinite(means[name]), 'Nonfinite legacy mean') + floor, ceiling = min(means[names[0]], means[names[1]]), max(means[name] for name in names[2:]) + return dict(schema=SCHEMA, audit='legacy_H_mean_control_predicate_only', protocol_means=means, + condition_floor=floor, control_ceiling=ceiling, predicate_pass=floor > ceiling, + source_reference='frozen phase4h.py:412-414', full_old_H_gate_computed=False, + scientific_learning_pass_claimed=False, AN_to_legacy_protocol_mapping_authority='caller; no automatic pooling') + + +def unavailable_assay(assay): + """Do not turn a terminal delta or fresh reset into a retention/reversal PASS.""" + require(assay in ('reversal', 'retention'), 'Unknown unavailable assay') + missing = {'reversal': ['bound acquisition/reversal/no-reversal lineage', + 'original pre baseline and original cue orientation', 'terminal reversal count and both cue event outputs'], + 'retention': ['validated continuous-state checkpoint/queue/RNG schema', + 'bound same idle trajectory at 1 s and 10 s', 'matched frozen continuation and non-feedback probes']} + return dict(schema=SCHEMA, assay=assay, status='NOT-IMPLEMENTED', proposal_gate_pass=None, + scientific_learning_pass_claimed=False, candidate_generated=False, missing=missing[assay]) diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/seed_registry.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/seed_registry.py new file mode 100644 index 0000000..7226702 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/seed_registry.py @@ -0,0 +1,118 @@ +"""Explicit, order-independent random streams for an unregistered protocol. + +This module does not select odors, generate neural input, or run experiments. +Every independent stream has a semantic key and a SHA-derived 63-bit seed. +Intentional common-random-number controls must be declared as aliases of a +same-split stream. Aliases are never counted as independent replicates. +""" +from __future__ import annotations + +import hashlib +import json + + +SCHEMA = "phase4an-seed-registry-v1" +SPLITS = frozenset(("training", "evaluation", "design")) + + +def _require(condition, message): + if not condition: + raise ValueError(message) + + +def _canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False) + + +def _key(key): + _require(isinstance(key, (tuple, list)) and len(key) > 0, + "A nonempty list/tuple semantic key is required") + _require(all(type(v) is str and v and v.strip() == v for v in key), + "Semantic key tokens must be nonempty, unpadded strings") + return tuple(key) + + +def seed_for(master, split, key): + _require(type(master) is int and 0 <= master < 2**63, "master must be a 63-bit integer") + _require(type(split) is str and split in SPLITS, "Unknown stream split") + key = _key(key) + message = _canonical([SCHEMA, master, split, list(key)]).encode("utf-8") + full_sha = hashlib.sha256(message).hexdigest() + return int(full_sha[:16], 16) & ((1 << 63) - 1), full_sha + + +class SeedRegistry: + """Fail closed on duplicate keys, collisions or cross-split aliasing. + + No collision rehashing or seed search is performed: such a failure must be + resolved before registration, never after observing a neural response. + """ + + def __init__(self, master): + seed_for(master, "design", ("validate-master",)) + self.master = master + self._streams = {} + self._aliases = {} + self._seed_to_key = {} + + def add(self, split, key): + key = _key(key) + identity = (split, key) + _require(identity not in self._streams and identity not in self._aliases, + "Duplicate semantic stream key") + seed, full_sha = seed_for(self.master, split, key) + _require(seed not in self._seed_to_key, "Seed collision: stop before registration") + row = dict(split=split, key=list(key), seed=seed, derivation_sha256=full_sha) + self._streams[identity] = row + self._seed_to_key[seed] = identity + return seed + + def alias(self, split, key, source_split, source_key, *, reason): + """Declare paired controls, never silently create/reuse a stream.""" + key, source_key = _key(key), _key(source_key) + identity, source = (split, key), (source_split, source_key) + _require(split == source_split and split in SPLITS, + "Cross-split random-stream sharing is forbidden") + _require(identity not in self._streams and identity not in self._aliases, + "Duplicate semantic stream key") + _require(source in self._streams, "Alias must target an independent stream, not another alias") + _require(type(reason) is str and bool(reason.strip()), "Intentional sharing requires a reason") + seed = self._streams[source]["seed"] + self._aliases[identity] = dict(split=split, key=list(key), seed=seed, + source_split=source_split, source_key=list(source_key), reason=reason, + independent_replicate=False) + return seed + + def manifest(self): + streams = [dict(self._streams[k]) for k in sorted(self._streams)] + aliases = [dict(self._aliases[k]) for k in sorted(self._aliases)] + split_counts = {split: sum(r["split"] == split for r in streams) for split in sorted(SPLITS)} + result = dict(schema=SCHEMA, master=self.master, + algorithm="SHA-256 canonical JSON [schema,master,split,key]; first64 bits masked to63", + independent_streams=streams, aliases=aliases, + independent_count=len(streams), alias_count=len(aliases), split_counts=split_counts, + all_independent_seeds_unique=True, training_evaluation_seed_overlap=0, + aliases_count_as_independent_replicates=False, + training_executed=False, protocol_approval_claimed=False) + result["payload_sha256"] = hashlib.sha256(_canonical(result).encode("utf-8")).hexdigest() + # JSON round-trip makes nested data independent of the mutable registry. + return json.loads(_canonical(result)) + + +def validate_manifest(manifest): + """Reconstruct every seed/alias and require the complete canonical payload.""" + _require(isinstance(manifest, dict) and manifest.get("schema") == SCHEMA, "Invalid seed manifest") + payload = {key: value for key, value in manifest.items() if key != "payload_sha256"} + actual_sha = hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest() + _require(manifest.get("payload_sha256") == actual_sha, "Actual manifest payload SHA mismatch") + rebuilt = SeedRegistry(manifest["master"]) + for row in manifest["independent_streams"]: + rebuilt.add(row["split"], row["key"]) + for row in manifest["aliases"]: + rebuilt.alias(row["split"], row["key"], row["source_split"], row["source_key"], reason=row["reason"]) + # Python dict equality conflates False/0 and 1/1.0; the serialized contract + # must retain JSON Boolean versus integer versus floating-point identity. + _require(_canonical(manifest) == _canonical(rebuilt.manifest()), + "Seed manifest differs from full independent reconstruction") + return True diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/test_biological_contract.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_biological_contract.py new file mode 100644 index 0000000..d3a83ec --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_biological_contract.py @@ -0,0 +1,153 @@ +import unittest + +import numpy as np +import pandas as pd + +import biological_contract as b + + +def fixture(): + types = ["KC-a", "KC-b", "PAM15", "PPL103", "MBON01", "MBON12", "other-DAN"] + classes = ["Kenyon_Cell", "Kenyon_Cell", "DAN", "DAN", "MBON", "MBON", "DAN"] + instances = ["KC-a_L", "KC-b_R", "PAM15(y5B'2a)_L", "PPL103(y2a'1)_R", + "MBON01(y5B'2a)_L", "MBON12(y2a'1)_R", "other-DAN"] + meta = pd.DataFrame(dict(neuron_idx=np.arange(7, dtype=np.int32), + body_id=np.arange(7, dtype=np.uint64) + np.uint64(2**63), + annotation_type=pd.Series(types, dtype=object), annotation_class=pd.Series(classes, dtype=object), + annotation_instance=pd.Series(instances, dtype=object))) + dan = meta.iloc[[2, 3, 6]].drop(columns="annotation_class").copy() + mbon = meta.iloc[[4, 5]].drop(columns="annotation_class").copy() + dan["mb_compartment_verbatim"] = ["y5B'2a", "y2a'1", None] + dan["mb_compartment_mapping_status"] = ["explicit_single_parenthesized_token"] * 2 + ["no_parenthesized_token"] + mbon["mb_compartment_verbatim"] = ["y5B'2a", "y2a'1"] + mbon["mb_compartment_mapping_status"] = "explicit_single_parenthesized_token" + src = np.array([0, 0, 1, 1, 2, 3], np.int32) + dst = np.array([4, 5, 4, 5, 4, 5], np.int32) + raw = np.array([4, 8, 12, 16, 20, 24], np.int64) + def table(ids): + rows = [] + for i in ids: + row = dict(edge_id=i, raw_synapse_count=int(raw[i])) + for label, node in (("source", src[i]), ("target", dst[i])): + for key in ("neuron_idx", "body_id", "annotation_type", "annotation_instance"): + row[label + "_" + key] = meta.iloc[node][key] + row["target_compartment_verbatim"] = "y5B'2a" if dst[i] == 4 else "y2a'1" + rows.append(row) + out = pd.DataFrame(rows) + for key in ("source_neuron_idx", "target_neuron_idx"): + out[key] = out[key].astype(np.int32) + for key in ("source_body_id", "target_body_id"): + out[key] = out[key].astype(np.uint64) + # Match the observed frozen parquet object dtype; local pandas 3 may + # infer its new string extension dtype from constructor literals. + for prefix in ("source", "target"): + for suffix in ("annotation_type", "annotation_instance"): + out[prefix + "_" + suffix] = out[prefix + "_" + suffix].astype(object) + out["edge_id"] = out.edge_id.astype(np.int64) + out["raw_synapse_count"] = out.raw_synapse_count.astype(np.int64) + return out + candidates = table(range(4)) + candidates["plasticity_polarity"] = np.int64(-1) + confirmed = table([4, 5]) + confirmed["source_compartment_verbatim"] = confirmed.target_compartment_verbatim + confirmed["exact_compartment_match"] = True + return meta, dan, mbon, confirmed, candidates, src, dst, raw + + +class Biology(unittest.TestCase): + def test_complete_graph_binds_every_edge_array_length(self): + meta, *_, src, dst, raw = fixture() + graph = dict(nodes=np.arange(7, dtype=np.int32), edge_id=np.arange(6, dtype=np.int64), + src=src, dst=dst, signed_raw=raw) + b._complete_graph_identity(meta, graph, n=7, e=6) + for names in (("src",), ("dst",), ("signed_raw",), ("src", "dst", "signed_raw")): + changed = dict(graph) + for name in names: + changed[name] = changed[name][:-1] + with self.subTest(names=names), self.assertRaisesRegex(ValueError, "endpoint/weight lengths"): + b._complete_graph_identity(meta, changed, n=7, e=6) + for name in ("nodes", "edge_id"): + changed = dict(graph) + changed[name] = changed[name].reshape(1, -1) + with self.subTest(name=name), self.assertRaises(ValueError): + b._complete_graph_identity(meta, changed, n=7, e=6) + + def test_exact_id_compartment_and_real_edge_contract(self): + values = fixture() + populations, audit = b.audit_interface_tables(*values) + self.assertEqual(populations["PAM15"].tolist(), [2]) + self.assertEqual(populations["MBON12"].tolist(), [5]) + self.assertEqual(audit["whole_DAN_mapping_unknown"], 1) + self.assertFalse(audit["global_unknowns_filled"]) + self.assertEqual(audit["all_candidate_edges_retained"], 4) + self.assertEqual(audit["interfaces"]["PAM15"]["candidate_contacts"], 16) + self.assertEqual(audit["interfaces"]["PPL103"]["confirmed_DAN_MBON_contacts"], 24) + self.assertGreater(audit["interfaces"]["PAM15"]["DAN_members"][0]["body_id"], 2**63) + self.assertTrue(pd.isna(values[1].iloc[-1].mb_compartment_verbatim)) + + def test_unknown_selected_mapping_is_not_filled_from_type(self): + for field, new_value in (("mb_compartment_verbatim", None), + ("mb_compartment_mapping_status", "unknown")): + values = fixture() + values[1].loc[2, field] = new_value + with self.assertRaisesRegex(ValueError, "Unknown or mismatched"): + b.audit_interface_tables(*values) + + def test_changed_body_id_or_type_or_instance_rejected(self): + for field, new_value in (("body_id", np.uint64(5)), ("annotation_type", "PAM_OTHER"), + ("annotation_instance", "inferred")): + values = fixture() + values[1].loc[2, field] = new_value + with self.assertRaisesRegex(ValueError, "Identity field mismatch"): + b.audit_interface_tables(*values) + + def test_unmapped_dan_cannot_be_silently_removed(self): + values = list(fixture()) + values[1] = values[1].iloc[:2].copy() + with self.assertRaisesRegex(ValueError, "complete exact-annotation"): + b.audit_interface_tables(*values) + + def test_missing_actual_edge_fails_not_guessed(self): + values = list(fixture()) + values[3] = values[3].iloc[:1].copy() + with self.assertRaisesRegex(ValueError, "unknown or mismatched actual target"): + b.audit_interface_tables(*values) + + def test_edge_identity_and_contacts_and_polarity(self): + changes = ((3, "source_body_id", np.uint64(8)), + (3, "target_annotation_type", "MBON_OTHER"), + (4, "raw_synapse_count", np.int64(1)), + (4, "plasticity_polarity", np.int64(1))) + for which, field, value in changes: + values = fixture() + values[which].loc[values[which].index[0], field] = value + with self.subTest(field=field), self.assertRaises(ValueError): + b.audit_interface_tables(*values) + + def test_false_or_nonboolean_confirmed_flag_rejected(self): + for value in (False, "True"): + values = fixture() + values[3]["exact_compartment_match"] = value + with self.assertRaisesRegex(ValueError, "unknown compartment"): + b.audit_interface_tables(*values) + + def test_graph_endpoint_and_sign_change_rejected(self): + for where in (5, 6, 7): + values = fixture() + values[where][0] = -1 if where == 7 else 6 + with self.assertRaises(ValueError): + b.audit_interface_tables(*values) + + def test_schema_and_duplicate_identity_rejected(self): + values = list(fixture()) + values[0] = values[0].drop(columns="annotation_class") + with self.assertRaises(ValueError): + b.audit_interface_tables(*values) + values = fixture() + values[0].loc[1, "body_id"] = values[0].loc[0, "body_id"] + with self.assertRaises(ValueError): + b.audit_interface_tables(*values) + + +if __name__ == "__main__": + unittest.main() diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/test_conditioning_inputs.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_conditioning_inputs.py new file mode 100644 index 0000000..b8d1123 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_conditioning_inputs.py @@ -0,0 +1,258 @@ +"""Synthetic CPU/mocked-CUDA contract tests; never run a real input generator.""" +import ast +import hashlib +import json +from pathlib import Path +import types +import unittest +from unittest.mock import patch + +import numpy as np + +import conditioning_inputs as c + + +def req(rows): + values = np.asarray(rows, np.int32).reshape(-1, 2) + return {'tick': values[:, 0].copy(), 'neuron_idx': values[:, 1].copy()} + + +class FakeTensor: + def __init__(self, values, device): + self.values = np.array(values, copy=True) + self.device, self.dtype = device, self.values.dtype + + def __getitem__(self, key): return FakeTensor(self.values[key], self.device) + def __lt__(self, other): return FakeTensor(self.values < other.values, self.device) + def cpu(self): return self + def numpy(self): return self.values.copy() + + +class FakeTorch: + """Observable adapter, not a CUDA or random-number emulation.""" + float32 = np.dtype(np.float32) + __version__ = 'SYNTHETIC-CONTRACT-MOCK-NOT-TORCH' + version = types.SimpleNamespace(cuda=None) + + def __init__(self, uniforms, truncate=False): + self.uniforms = uniforms + self.calls, self.seeds, self.truncate = [], [], truncate + self.cuda = types.SimpleNamespace(is_available=lambda: True, + get_device_name=lambda device: 'SYNTHETIC-NOT-A-REAL-GPU', get_device_capability=lambda device: (0, 0), + get_device_properties=lambda device: types.SimpleNamespace()) + + def get_default_dtype(self): return self.float32 + def device(self, name): return name + + def Generator(self, device): + owner = self + class Generator: + def manual_seed(self, value): + owner.seeds.append(value); self.seed = value & 0xffffffff if owner.truncate else value + def initial_seed(self): return self.seed + self.calls.append(('generator', device)); return Generator() + + def as_tensor(self, values, dtype, device): + self.calls.append(('probability', np.array(values, copy=True), dtype, device)) + return FakeTensor(values, device) + + def rand(self, shape, generator, device): + self.calls.append(('rand', shape, generator.initial_seed(), device)) + if shape != self.uniforms.shape: raise AssertionError('Unexpected draw shape') + return FakeTensor(self.uniforms, device) + + +class InputsTests(unittest.TestCase): + def setUp(self): + self.targets = np.array([19, 3], np.int32) + self.rates = np.array([5000, 10000], np.float32) + + def test_frozen_probability_dtype_order_signed_zero_and_no_mutation(self): + targets = np.arange(6, dtype=np.int32) + rates = np.array([0., -0., np.nextafter(np.float32(0), np.float32(1)), .1, 123.456, 10000.], np.float32) + before = rates.tobytes(); rates.setflags(write=False) + actual = c.probabilities_f32(targets, rates) + self.assertEqual(actual.dtype, np.float32) + self.assertEqual(actual.tobytes(), (rates * .1 / 1000.).tobytes()) + self.assertEqual(rates.tobytes(), before) + + def test_uniform_threshold_is_strict_and_bernoulli_not_poisson_counts(self): + p = np.array([0, .5, 1], np.float32) + u = np.array([[0, .5, np.nextafter(np.float32(1), np.float32(0))], [0, .499, 0]], np.float32) + forced = c.forced_from_uniforms(u, p) + np.testing.assert_array_equal(forced, [[False, False, True], [False, True, True]]) + self.assertEqual(forced.dtype, np.bool_) + + def test_original_PN_column_order_then_sparse_tick_global_sort_only(self): + forced = np.array([[True, True], [True, False], [False, True]], np.bool_) + before = forced.copy(); self.targets.setflags(write=False) + result = c.requests_from_forced(forced, self.targets, cs_start_tick=0, cs_stop_tick=3) + np.testing.assert_array_equal(result['requests']['tick'], [0, 0, 1, 2]) + np.testing.assert_array_equal(result['requests']['neuron_idx'], [3, 19, 19, 3]) + np.testing.assert_array_equal(forced, before) + self.assertEqual(result['audit']['duplicate_requests_removed'], 0) + + def test_half_open_window_masks_full_matrix_including_empty_window(self): + forced = np.ones((5, 2), np.bool_) + result = c.requests_from_forced(forced, self.targets, cs_start_tick=1, cs_stop_tick=4) + self.assertEqual(result['audit']['full_matrix_boolean_hits'], 10) + self.assertEqual(result['audit']['outside_CS_boolean_hits_masked'], 4) + self.assertEqual(set(result['requests']['tick']), {1, 2, 3}) + empty = c.requests_from_forced(forced, self.targets, cs_start_tick=2, cs_stop_tick=2) + self.assertEqual(len(empty['requests']['tick']), 0) + self.assertEqual(empty['audit']['outside_CS_boolean_hits_masked'], 10) + + def test_full_63bit_seed_single_full_draw_and_mock_scope_receipt(self): + seed = (1 << 62) + (1 << 32) + 19 + fake = FakeTorch(np.array([[.2, .6], [.7, .7], [.1, .2], [.8, .1], [.1, .1]], np.float32)) + with patch.object(c, '_load_torch', return_value=fake): + result = c.generate_cs_requests(self.targets, self.rates, seed=seed, steps=5, cs_start_tick=1, cs_stop_tick=4, + device='cuda:0', max_uniform_draws=10) + self.assertEqual(fake.seeds, [seed]); draws = [x for x in fake.calls if x[0] == 'rand'] + self.assertEqual(draws, [('rand', (5, 2), seed, 'cuda:0')]) + a = result['audit']; self.assertEqual(a['seed_hex'], f'{seed:016x}') + self.assertEqual(a['numpy_version'], np.__version__) + self.assertEqual(a['torch_version'], fake.__version__) + self.assertEqual(a['device_name'], 'SYNTHETIC-NOT-A-REAL-GPU') + self.assertFalse(a['CUDA_sequence_equivalence_tested_here']) + self.assertFalse(a['CUDA_high_seed_bit_sequence_influence_tested_here']) + self.assertFalse(a['full_old_L_input_identity_claimed']) + self.assertEqual(a['uniform_scalar_draws'], 10) + self.assertEqual((a['max_uniform_draws'], a['uniform_matrix_bytes']), (10, 40)) + self.assertEqual(a['device_identity'], {'uuid': None, 'pci_bus_id': None, 'query_error': None}) + np.testing.assert_array_equal(result['requests']['tick'], [1, 2, 2, 3]) + np.testing.assert_array_equal(result['requests']['neuron_idx'], [3, 3, 19, 3]) + json.dumps(a, allow_nan=False) + + def test_high_seed_bits_preserved_and_bad_consumer_detected_before_draw(self): + for seed in (0, 17, (1 << 32) + 17, (1 << 63) - 1): + fake = FakeTorch(np.zeros((1, 2), np.float32)) + with patch.object(c, '_load_torch', return_value=fake): + result = c.generate_cs_requests(self.targets, self.rates, seed=seed, steps=1, cs_start_tick=0, cs_stop_tick=1, + device='cuda:0', max_uniform_draws=2) + self.assertEqual(result['audit']['generator_initial_seed'], seed) + self.assertEqual(fake.seeds, [seed]) + fake = FakeTorch(np.zeros((1, 2), np.float32), truncate=True) + with patch.object(c, '_load_torch', return_value=fake), self.assertRaisesRegex(ValueError, 'complete semantic seed'): + c.generate_cs_requests(self.targets, self.rates, seed=(1 << 32) + 17, steps=1, cs_start_tick=0, cs_stop_tick=1, + device='cuda:0', max_uniform_draws=2) + self.assertFalse(any(x[0] == 'rand' for x in fake.calls)) + + def test_invalid_seed_clock_or_rate_never_imports_torch(self): + for seed in (-1, 2**63, True, np.int64(3), 3.): + with patch.object(c, '_load_torch') as loader, self.assertRaises(ValueError): + c.generate_cs_requests(self.targets, self.rates, seed=seed, steps=5, cs_start_tick=0, cs_stop_tick=5, + device='cuda:0', max_uniform_draws=10) + loader.assert_not_called() + for start, stop in ((-1, 1), (2, 1), (0, 6), (False, 1)): + with patch.object(c, '_load_torch') as loader, self.assertRaises(ValueError): + c.generate_cs_requests(self.targets, self.rates, seed=3, steps=5, cs_start_tick=start, cs_stop_tick=stop, + device='cuda:0', max_uniform_draws=10) + loader.assert_not_called() + + def test_invalid_arrays_and_no_clipping_or_duplicate_merging(self): + for rates in (np.array([-1, 2], np.float32), np.array([np.nan, 2], np.float32), np.array([np.inf, 2], np.float32), + np.array([10001, 2], np.float32), self.rates.astype(np.float64), self.rates[:1]): + with self.assertRaises(ValueError): c.probabilities_f32(self.targets, rates) + for targets in (np.array([3, 3], np.int32), np.array([-1, 3], np.int32), np.array([166700, 3], np.int32), self.targets.astype(np.int64)): + with self.assertRaises(ValueError): c.probabilities_f32(targets, self.rates) + with self.assertRaises(ValueError): c.probabilities_f32(self.targets, self.rates, dt_ms=.2) + with self.assertRaises(ValueError): c.requests_from_forced(np.ones((2, 2), np.uint8), self.targets, cs_start_tick=0, cs_stop_tick=2) + + def test_no_cpu_fallback_or_changed_default_float_dtype(self): + for variant in ('cpu', 'unavailable', 'float64'): + fake = FakeTorch(np.zeros((1, 2), np.float32)); device = 'cuda:0' + if variant == 'cpu': device = 'cpu' + if variant == 'unavailable': fake.cuda.is_available = lambda: False + if variant == 'float64': fake.get_default_dtype = lambda: np.dtype(np.float64) + with patch.object(c, '_load_torch', return_value=fake), self.assertRaises(ValueError): + c.generate_cs_requests(self.targets, self.rates, seed=3, steps=1, cs_start_tick=0, cs_stop_tick=1, + device=device, max_uniform_draws=2) + self.assertFalse(any(x[0] == 'rand' for x in fake.calls)) + + def test_explicit_device_and_budget_required_and_budget_checked_before_torch(self): + common = dict(seed=3, steps=5, cs_start_tick=0, cs_stop_tick=1) + for missing in ({}, {'device': 'cuda:0'}, {'max_uniform_draws': 10}): + with patch.object(c, '_load_torch') as loader, self.assertRaises(TypeError): + c.generate_cs_requests(self.targets, self.rates, **common, **missing) + loader.assert_not_called() + for budget in (0, -1, 9, True, 10., np.int64(10)): + with patch.object(c, '_load_torch') as loader, self.assertRaises(ValueError): + c.generate_cs_requests(self.targets, self.rates, **common, device='cuda:0', max_uniform_draws=budget) + loader.assert_not_called() + # Full 5x2 matrix admission, not merely the 1-tick active CS window. + fake = FakeTorch(np.zeros((5, 2), np.float32)) + with patch.object(c, '_load_torch', return_value=fake): + result = c.generate_cs_requests(self.targets, self.rates, **common, device='cuda:1', max_uniform_draws=10) + self.assertEqual(result['audit']['execution_device'], 'cuda:1') + self.assertEqual(result['audit']['uniform_scalar_draws'], 10) + + def test_optional_device_uuid_recorded_as_reported_or_explicitly_unavailable(self): + fake = FakeTorch(np.zeros((1, 2), np.float32)) + fake.cuda.get_device_properties = lambda device: types.SimpleNamespace(uuid='SYNTHETIC-UUID', pci_bus_id='SYNTHETIC-PCI') + args = dict(seed=3, steps=1, cs_start_tick=0, cs_stop_tick=1, device='cuda:7', max_uniform_draws=2) + with patch.object(c, '_load_torch', return_value=fake): + audit = c.generate_cs_requests(self.targets, self.rates, **args)['audit'] + self.assertEqual(audit['device_identity'], {'uuid': 'SYNTHETIC-UUID', 'pci_bus_id': 'SYNTHETIC-PCI', 'query_error': None}) + self.assertFalse(audit['device_identity_verified_against_external_pin']) + def unavailable(device): raise RuntimeError('Synthetic property unavailable') + fake.cuda.get_device_properties = unavailable + with patch.object(c, '_load_torch', return_value=fake): + audit = c.generate_cs_requests(self.targets, self.rates, **args)['audit'] + self.assertIsNone(audit['device_identity']['uuid']) + self.assertEqual(audit['device_identity']['query_error'], 'RuntimeError: Synthetic property unavailable') + + def test_returned_audited_arrays_are_owned_readonly_and_inputs_not_frozen(self): + forced = np.ones((2, 2), np.bool_) + generated = c.requests_from_forced(forced, self.targets, cs_start_tick=0, cs_stop_tick=2) + merged = c.merge_requests(req([[0, 9]]), req([[1, 8]]), steps=2) + for value in (*generated['requests'].values(), *merged['requests'].values(), merged['source_mask']): + self.assertTrue(value.flags.owndata) + self.assertFalse(value.flags.writeable) + with self.assertRaises(ValueError): value[0] = 0 + self.assertTrue(forced.flags.writeable) + self.assertTrue(self.targets.flags.writeable) + + def test_empty_targets_and_p0_p1_are_explicit_valid_limits(self): + result = c.requests_from_forced(np.zeros((3, 0), np.bool_), np.empty(0, np.int32), cs_start_tick=0, cs_stop_tick=3) + self.assertEqual(result['requests']['tick'].dtype, np.int32); self.assertEqual(len(result['requests']['tick']), 0) + np.testing.assert_array_equal(c.probabilities_f32(self.targets, np.array([0, 10000], np.float32)), [0, 1]) + + def test_merging_disjoint_sources_is_sorted_without_mutating_inputs(self): + cs, us = req([[0, 9], [3, 4]]), req([[0, 2], [2, 5]]) + for value in [*cs.values(), *us.values()]: value.setflags(write=False) + result = c.merge_requests(cs, us, steps=4) + np.testing.assert_array_equal(result['requests']['tick'], [0, 0, 2, 3]) + np.testing.assert_array_equal(result['requests']['neuron_idx'], [2, 9, 5, 4]) + np.testing.assert_array_equal(result['source_mask'], [2, 1, 2, 1]) + self.assertEqual(result['audit']['explicit_boolean_overlap_collapses'], 0) + + def test_collision_rejected_by_default_or_explicitly_audited_boolean_union(self): + cs, us = req([[0, 9], [3, 4]]), req([[0, 9], [2, 5], [3, 4]]) + with self.assertRaises(c.RequestConflictError) as error: c.merge_requests(cs, us, steps=4) + self.assertEqual(error.exception.audit['cross_source_conflict_count'], 2) + result = c.merge_requests(cs, us, steps=4, conflict_policy='boolean_union') + np.testing.assert_array_equal(result['source_mask'], [3, 2, 3]) + a = result['audit']; self.assertEqual((a['CS_attempts'], a['US_attempts'], a['output_requests']), (2, 3, 3)) + self.assertEqual(a['explicit_boolean_overlap_collapses'], 2) + self.assertEqual([x['combined_boolean_requests'] for x in a['conflicts']], [1, 1]) + json.dumps(a, allow_nan=False) + + def test_within_source_duplicate_unsorted_and_bad_range_never_deduped(self): + for rows in ([[0, 3], [0, 3]], [[1, 3], [0, 3]], [[4, 3]], [[0, -1]]): + with self.assertRaises(ValueError): c.merge_requests(req(rows), req([]), steps=4, conflict_policy='boolean_union') + with self.assertRaises(ValueError): c.merge_requests(req([]), req([]), steps=4, conflict_policy='clip') + + def test_frozen_AK_pure_conversion_AST_matches_for_full_window(self): + local = Path(__file__).resolve().parent.parent / 'apl-timing-validation-v1/encoder_inputs.py' + raw = local.read_bytes(); self.assertEqual(hashlib.sha256(raw).hexdigest(), c.AK_ENCODER_SHA) + tree = ast.parse(raw); node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'requests_from_forced') + namespace = {'np': np}; exec(compile(ast.Module(body=[node], type_ignores=[]), str(local), 'exec'), namespace) + forced = np.array([[True, True], [False, True], [True, False]], np.bool_) + expected = namespace['requests_from_forced'](forced, self.targets) + actual = c.requests_from_forced(forced, self.targets, cs_start_tick=0, cs_stop_tick=3)['requests'] + np.testing.assert_array_equal(expected, np.column_stack([actual['tick'], actual['neuron_idx']])) + + +if __name__ == '__main__': + unittest.main() diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/test_conditioning_update.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_conditioning_update.py new file mode 100644 index 0000000..742e4aa --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_conditioning_update.py @@ -0,0 +1,197 @@ +"""Synthetic sparse-event updates only; no recorded trajectories or training.""" +import copy +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest + +import numpy as np + +import conditioning_update as m + + +def source_path(): + # Same PHASE4J_SOURCE and ordered local candidates as frozen AM tests. + explicit = os.environ.get("PHASE4J_SOURCE") + if explicit: + return Path(explicit) + parent = Path(__file__).resolve().parents[1] + paths = (parent / "models/male-cns-lif/phase4j.py", parent / "mb-physiology-v1/phase4j.py") + for path in paths: + if path.is_file(): + return path + raise FileNotFoundError("Set PHASE4J_SOURCE to the SHA-pinned original phase4j.py") + + +SOURCE = source_path() +BRIDGE = Path(os.environ.get("AM_ELIGIBILITY_BRIDGE", + str(Path(__file__).resolve().parents[1] / "wholebrain-plastic-interface-v1/eligibility_bridge.py"))) + + +def args(rows=((0, 1), (3, 3)), us_tick=3, steps=8): + events = np.asarray(rows, np.int32).reshape(-1, 2) + return dict(step=events[:, 0], neuron_idx=events[:, 1], kc=np.array([1, 3, 7], np.int32), + candidate_edge_id=np.array([1, 4, 5, 10, 12], np.int64), + candidate_sources=np.array([1, 1, 3, 7, 3], np.int32), + candidate_targets=np.array([2, 2, 5, 5, 9], np.int32), + candidate_compartments=np.array(["y5B'2a", "a1", "y5B'2a", "y5B'2a", "y2a'1"]), + delta_before=np.zeros(5, np.float32), base_candidate=np.array([.275, .55, .0001, .275, 2.75], np.float32), + steps=steps, n_neurons=10, compartment="y5B'2a", us_tick=us_tick, + d_command=1.0 if us_tick is not None else 0.0, frozen=False, + bridge_source=BRIDGE, phase4j_source=SOURCE, trace_backend="numpy") + + +class ConditioningUpdate(unittest.TestCase): + def test_pinned_original_calls_and_recomputable_receipt(self): + inputs = args() + result = m.update_epoch(**inputs) + bridge = m._load_bridge(BRIDGE) + traced = bridge.trace_from_events(inputs["step"], inputs["neuron_idx"], inputs["kc"], 8, [3], + phase4j_source=SOURCE, backend="numpy", n_neurons=10) + expected, _ = bridge.apply_eligibility(traced.last_command_trace, inputs["kc"], + inputs["candidate_sources"], inputs["candidate_compartments"], inputs["compartment"], + inputs["delta_before"], inputs["base_candidate"], d=1., phase4j_source=SOURCE, n_neurons=10) + self.assertTrue(bridge.bit_equal(result.delta_after, expected)) + self.assertTrue(m.verify_receipt(result.receipt, **inputs)) + self.assertEqual(result.receipt["source_pins"]["eligibility_bridge_sha256"], hashlib.sha256(BRIDGE.read_bytes()).hexdigest()) + self.assertEqual(result.receipt["trace_audit"]["sampling"], "after_current_tick_spike") + json.dumps(result.receipt, allow_nan=False) + + def test_zero_d_and_frozen_preserve_existing_delta(self): + for d, frozen in ((0., False), (1., True), (0., True)): + inputs = args() + inputs["delta_before"][:] = -.25 * inputs["base_candidate"] + inputs.update(d_command=d, frozen=frozen) + result = m.update_epoch(**inputs) + self.assertEqual(result.delta_after.tobytes(), inputs["delta_before"].tobytes()) + self.assertEqual(result.receipt["counts"]["original_updated_edges_attempted"], 0) + self.assertEqual(result.receipt["counts"]["actual_bitwise_changed_edges"], 0) + + def test_us_before_cs_does_not_use_future_trace(self): + result = m.update_epoch(**args(rows=((2, 1),), us_tick=1)) + self.assertFalse(np.any(result.sampled_trace)) + self.assertTrue(np.any(result.final_trace)) + self.assertFalse(np.any(result.delta_after)) + + def test_current_tick_and_reentrant_kc_saturate_before_sample(self): + result = m.update_epoch(**args(rows=((0, 1), (1, 1), (3, 1), (3, 3)), us_tick=3)) + self.assertEqual(result.sampled_trace.tolist(), [1., 1., 0.]) + self.assertEqual(result.delta_after[0].view(np.uint32), np.float32(-.00275).view(np.uint32)) + self.assertEqual(result.receipt["counts"]["actual_KC_events"], 4) + + def test_non_kc_only_changes_event_evidence_not_update(self): + a = m.update_epoch(**args(rows=((0, 1), (3, 3)))) + b = m.update_epoch(**args(rows=((0, 0), (0, 1), (2, 9), (3, 3)))) + self.assertEqual(a.delta_after.tobytes(), b.delta_after.tobytes()) + self.assertEqual(a.sampled_trace.tobytes(), b.sampled_trace.tobytes()) + self.assertNotEqual(a.receipt["input_arrays"], b.receipt["input_arrays"]) + + def test_no_us_uses_zero_not_final_trace(self): + result = m.update_epoch(**args(us_tick=None)) + self.assertFalse(np.any(result.sampled_trace)) + self.assertFalse(np.any(np.signbit(result.sampled_trace))) + self.assertTrue(np.any(result.final_trace)) + self.assertEqual(result.receipt["counts"]["actual_value_changed_edges"], 0) + + def test_attempts_and_fully_saturated_changes_are_distinct(self): + inputs = args(rows=((3, 1), (3, 3), (3, 7))) + inputs["delta_before"][:] = -.25 * inputs["base_candidate"] + result = m.update_epoch(**inputs) + counts = result.receipt["counts"] + self.assertEqual(counts["original_updated_edges_attempted"], 3) + self.assertEqual(counts["actual_value_changed_edges"], 0) + self.assertEqual(counts["selected_at_floor_before"], 3) + self.assertEqual(counts["selected_at_floor_after"], 3) + self.assertEqual(counts["selected_newly_at_floor"], 0) + + def test_new_floor_and_off_compartment_identity(self): + inputs = args() + inputs["delta_before"][1] = -.01 + result = m.update_epoch(**inputs) + self.assertEqual(result.receipt["counts"]["selected_newly_at_floor"], 1) + self.assertEqual(result.receipt["counts"]["actual_bitwise_changed_edges"], 2) + self.assertEqual(result.delta_after[[1, 3, 4]].tobytes(), inputs["delta_before"][[1, 3, 4]].tobytes()) + + def test_owned_readonly_results_and_all_input_bytes_unchanged(self): + inputs = args() + before = {k: v.tobytes() for k, v in inputs.items() if isinstance(v, np.ndarray)} + result = m.update_epoch(**inputs) + for key, raw in before.items(): + self.assertEqual(inputs[key].tobytes(), raw) + for output in (result.delta_after, result.sampled_trace, result.final_trace): + self.assertTrue(output.flags.owndata) + self.assertFalse(output.flags.writeable) + self.assertTrue(all(not np.shares_memory(output, inputs[key]) for key in before)) + inputs["delta_before"][:] = -.000001 + self.assertNotEqual(result.delta_after.tobytes(), inputs["delta_before"].tobytes()) + + def test_scope_claims_do_not_prove_graph_or_dan_delivery(self): + receipt = m.update_epoch(**args()).receipt + for field in ("complete_KC_identity_verified", "complete_mask_identity_verified", "full_graph_authority_verified", + "brain_dynamics_executed", "weights_installed", "base_modified", "training_executed_by_this_function", + "scientific_learning_pass_claimed", "candidate_generated", "numpy_torch_bitwise_comparison_performed", + "frozen_Torch_expression_executed"): + self.assertIs(receipt[field], False) + self.assertFalse(receipt["semantics"]["actual_DAN_delivery_observed"]) + self.assertFalse(receipt["semantics"]["actual_DAN_delivery_used_to_gate"]) + + def test_invalid_events_mapping_commands_and_values_fail_closed(self): + mutations = ( + ("step", np.array([0, 3], np.int64)), ("steps", True), ("us_tick", False), + ("d_command", True), ("d_command", float("nan")), ("frozen", 1), ("trace_backend", "auto"), + ("candidate_edge_id", np.array([1, 1, 5, 10, 12], np.int64)), + ("candidate_targets", np.array([2, 2, 5, 5, 10], np.int32)), + ("candidate_sources", np.array([0, 1, 3, 7, 3], np.int32)), + ("base_candidate", np.array([.275, .55, float("inf"), .275, 2.75], np.float32)), + ("delta_before", np.full(5, -0., np.float32)), + ("kc", np.array([1, 1, 7], np.int32)), + ) + for key, value in mutations: + inputs = args() + inputs[key] = value + with self.subTest(key=key), self.assertRaises(ValueError): + m.update_epoch(**inputs) + for rows in (((0, 1), (0, 1)), ((3, 3), (0, 1)), ((8, 1),)): + with self.assertRaises(ValueError): + m.update_epoch(**args(rows=rows)) + inputs = args(us_tick=None) + inputs["d_command"] = 1 + with self.assertRaises(ValueError): + m.update_epoch(**inputs) + + def test_receipt_forgery_fails_after_new_self_hash(self): + inputs = args() + bad = copy.deepcopy(m.update_epoch(**inputs).receipt) + bad["counts"]["actual_value_changed_edges"] = 99 + bad["payload_sha256"] = m._sha({k: v for k, v in bad.items() if k != "payload_sha256"}) + with self.assertRaisesRegex(ValueError, "exact recomputation"): + m.verify_receipt(bad, **inputs) + + def test_source_drift_rejected_before_loading(self): + with tempfile.TemporaryDirectory() as directory: + for field, source in (("bridge_source", BRIDGE), ("phase4j_source", SOURCE)): + path = Path(directory) / (field + ".py") + path.write_bytes(source.read_bytes() + b"\nraise RuntimeError('never execute')\n") + inputs = args() + inputs[field] = path + with self.subTest(field=field), self.assertRaisesRegex(ValueError, "SHA256 mismatch"): + m.update_epoch(**inputs) + + @unittest.skipUnless(importlib.util.find_spec("torch") is not None, + "Torch absent: per-call NumPy/Torch exact compatibility NOT established locally") + def test_frozen_torch_cpu_matches_numpy_on_synthetic_events(self): + inputs = args(rows=((0, 1), (1, 1), (3, 3), (7, 7)), us_tick=7) + numpy_result = m.update_epoch(**inputs) + inputs["trace_backend"] = "torch" + torch_result = m.update_epoch(**inputs) + for field in ("delta_after", "sampled_trace", "final_trace"): + self.assertEqual(getattr(numpy_result, field).tobytes(), getattr(torch_result, field).tobytes()) + self.assertTrue(torch_result.receipt["frozen_Torch_expression_executed"]) + self.assertFalse(torch_result.receipt["numpy_torch_bitwise_comparison_performed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/test_evaluation_plan.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_evaluation_plan.py new file mode 100644 index 0000000..c4c879d --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_evaluation_plan.py @@ -0,0 +1,195 @@ +"""Synthetic planning tests only; no input generation, files or brain runs.""" +import copy +from collections import Counter +import unittest +from unittest.mock import patch + +import evaluation_plan as m +import trial_plan + + +def synthetic_config(trial, reversal_points=(0, 50)): + """An explicit TEST choice, not a proposed production default. + + Controls here use a simple fixed epoch fraction; the planner never supplies + this policy. Only paired/reversal checkpoints track actual scheduled USs. + """ + config = dict(schema=m.CONFIG_SCHEMA, probe_steps=1200, cs_start_tick=0, + cs_stop_tick_exclusive=1000, reversal_checkpoints=list(reversal_points), + baseline_execution="execute_each_snapshot", snapshot_binding_policy="explicit_completed_epochs", + snapshot_bindings=[]) + for block in trial["plan"]["blocks"]: + for suite in block["suites"]: + points = list(reversal_points) if suite["assay"] == "reversal" else block["evaluation_checkpoints"] + for arm in suite["arms"]: + for point in points: + if point in (0, 50): + completed = 0 if point == 0 else len(arm["epochs"]) + elif arm["arm"] in ("paired", "reversal", "no_reversal"): + rewarded = [i + 1 for i, e in enumerate(arm["epochs"]) if e["us"]["requested"]] + completed = rewarded[point - 1] + else: + completed = point * len(arm["epochs"]) // 50 + config["snapshot_bindings"].append(dict(block_id=block["block_id"], assay=suite["assay"], + arm=arm["arm"], checkpoint=point, completed_epochs=completed)) + return config + + +class EvaluationPlan(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Existing synthetic master from the trial-plan tests, not a real choice. + cls.trial = trial_plan.build_trial_plan(461) + cls.config = synthetic_config(cls.trial) + cls.plan = m.build_evaluation_plan(cls.trial, cls.config) + + def test_public_full_authority_reconstruction(self): + self.assertTrue(m.validate_evaluation_plan(self.plan, self.trial, self.config)) + + def test_exact_inventory_budget_and_no_execution_claim(self): + b = self.plan["budget"] + self.assertEqual(b["probes_by_assay"], dict(normal=3920, delayed=3920, reversal=320, retention=480)) + self.assertEqual(b["evaluation_probes"], 8640) + self.assertEqual(b["snapshot_count"], 4320) + self.assertEqual(b["evaluation_steps"], 10368000) + self.assertEqual(b["planned_scientific_steps_including_evaluation"], 117968000) + self.assertEqual(b["independent_evaluation_inputs"], 80) + for name in ("execution_authorized", "training_executed", "inputs_generated", "candidate_generated", + "learning_pass_claimed", "scientific_gate_added", "external_source_and_snapshot_authority_verified"): + self.assertIs(self.plan[name], False) + + def test_full_cells_and_all_control_coverage(self): + cells = {} + for p in self.plan["probes"]: + cells.setdefault((p["mode"], p["dan_type"], p["original_reinforced_cue"]), set()).add(p["training_replicate"]) + self.assertEqual(p["odor"], trial_plan.ODORS[p["cue"]]) + self.assertFalse(p["weight_updates_enabled"]) + self.assertFalse(p["US_requested"]) + self.assertFalse(p["feedback_to_training_or_idle"]) + self.assertFalse(p["adds_training_replicate"]) + self.assertEqual(len(cells), 8) + self.assertTrue(all(v == {1, 2, 3, 4, 5} for v in cells.values())) + first = self.trial["plan"]["blocks"][0]["block_id"] + normal = [p for p in self.plan["probes"] if p["block_id"] == first and p["assay"] == "normal"] + self.assertEqual(Counter(p["arm"] for p in normal), Counter({arm: 14 for arm in trial_plan.ARMS})) + + def test_stream_keys_disjoint_and_reused_as_paired_probes(self): + non_eval = {r["seed"] for r in self.trial["seed_manifest"]["independent_streams"] if r["split"] != "evaluation"} + grouped = {} + for p in self.plan["probes"]: + grouped.setdefault((p["block_id"], p["cue"]), set()).add((p["input"]["seed"], p["input"]["stream_id"])) + self.assertEqual(p["input"]["semantic_key"], [p["block_id"], "evaluation", "CS", p["cue"], "00", "base-probe"]) + self.assertEqual(p["input"]["seed_bits"], 63) + seeds = {next(iter(values))[0] for values in grouped.values()} + self.assertEqual(len(grouped), 80) + self.assertTrue(all(len(v) == 1 for v in grouped.values())) + self.assertEqual(len(seeds), 80) + self.assertFalse(seeds & non_eval) + self.assertTrue(any(seed > 2**32 for seed in seeds)) + + def test_reversal_original_baseline_and_orientation(self): + for s in self.plan["snapshots"]: + if s["assay"] == "reversal": + self.assertEqual(s["baseline_snapshot_id"], s["block_id"] + "/normal/paired/checkpoint-00") + if s["checkpoint"] == 0: + self.assertEqual(s["delta_source"], {"kind": "fork", "source": s["block_id"] + "/normal/paired/terminal-delta"}) + self.assertEqual(s["neural_state_source"]["kind"], "common_frozen_reset") + + def test_retention_same_idle_lineage_independent_forks(self): + grouped = {} + for s in self.plan["snapshots"]: + if s["assay"] != "retention": + continue + src = s["neural_state_source"] + grouped.setdefault((s["block_id"], s["arm"]), []).append(src) + self.assertFalse(src["reset_or_analytic_time_advance"]) + self.assertFalse(s["snapshot_mutated_by_planning"]) + self.assertEqual(s["baseline_snapshot_id"], s["block_id"] + "/normal/" + s["arm"] + "/checkpoint-00") + self.assertEqual(len(grouped), 80) + for rows in grouped.values(): + self.assertEqual([r["elapsed_ticks"] for r in rows], [0, 10000, 100000]) + self.assertEqual(len({r["idle_lineage_id"] for r in rows}), 1) + self.assertEqual(rows[0]["kind"], "actual_terminal_checkpoint") + self.assertEqual(rows[1]["kind"], "continuous_idle_checkpoint") + + def test_missing_choices_no_hidden_default(self): + for key in m.CONFIG_FIELDS: + bad = copy.deepcopy(self.config) + del bad[key] + with self.subTest(key=key), self.assertRaisesRegex(ValueError, "missing="): + m.build_evaluation_plan(self.trial, bad) + self.assertIn("snapshot_bindings", m.required_choices()) + + def test_bad_configuration_types_and_unimplemented_reuse(self): + for key, value in (("probe_steps", 1200.0), ("cs_start_tick", False), + ("cs_stop_tick_exclusive", True), ("cs_stop_tick_exclusive", 1201), + ("reversal_checkpoints", [False, 50]), ("reversal_checkpoints", [50]), + ("baseline_execution", "reuse_identical"), ("snapshot_binding_policy", "guess_from_paired")): + bad = copy.deepcopy(self.config) + bad[key] = value + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + m._assemble(self.trial, bad) + + def test_binding_coverage_order_types_and_pairing_boundary(self): + changes = ( + lambda rows: rows.pop(), lambda rows: rows.reverse(), + lambda rows: rows[0].update(checkpoint=False), + lambda rows: rows[0].update(completed_epochs=False), + lambda rows: rows[1].update(completed_epochs=0), + lambda rows: rows[6].update(completed_epochs=99), + lambda rows: rows[1].update(completed_epochs=100), + lambda rows: rows[0].update(extra="unknown"), + ) + for index, change in enumerate(changes): + bad = copy.deepcopy(self.config) + change(bad["snapshot_bindings"]) + with self.subTest(index=index), self.assertRaises(ValueError): + m._assemble(self.trial, bad) + + def test_explicit_alternative_curve_changes_budget_not_seed_inventory(self): + config = synthetic_config(self.trial, trial_plan.PROBE_CHECKPOINTS) + result = m._assemble(self.trial, config) + self.assertEqual(result["budget"]["evaluation_probes"], 9440) + self.assertEqual(result["budget"]["probes_by_assay"]["reversal"], 1120) + self.assertEqual(result["budget"]["independent_evaluation_inputs"], 80) + config["cs_stop_tick_exclusive"] = 1200 + alternate = m._assemble(self.trial, config) + self.assertNotEqual(result["config_sha256"], alternate["config_sha256"]) + self.assertTrue(all(p["cs_stop_tick_exclusive"] == 1200 for p in alternate["probes"])) + + def test_payload_mutations_rejected_even_when_self_hash_updated(self): + changes = ( + lambda p: p["probes"].pop(), lambda p: p["probes"].reverse(), + lambda p: p["probes"][0]["input"].update(seed=1), + lambda p: p["probes"][0].update(training_replicate=True), + lambda p: p["probes"][0].update(adds_training_replicate=True), + lambda p: p.update(execution_authorized=True), + ) + # Full trial authority was validated in setUpClass and again above. + # Isolate evaluation reconstruction here, avoiding six repeated rebuilds + # of the large, unchanged 68k-epoch synthetic training schedule. + with patch.object(m.trial_plan, "validate_trial_plan", return_value=True): + for index, change in enumerate(changes): + bad = copy.deepcopy(self.plan) + change(bad) + bad["payload_sha256"] = m._sha({k: v for k, v in bad.items() if k != "payload_sha256"}) + with self.subTest(index=index), self.assertRaises(ValueError): + m.validate_evaluation_plan(bad, self.trial, self.config) + + def test_parent_authority_failure_propagates(self): + with patch.object(m.trial_plan, "validate_trial_plan", side_effect=ValueError("invalid full parent")): + with self.assertRaisesRegex(ValueError, "invalid full parent"): + m.build_evaluation_plan(self.trial, self.config) + + def test_no_mutable_alias_to_caller(self): + result = m._assemble(self.trial, self.config) + result["config"]["snapshot_bindings"][0]["completed_epochs"] = 900 + result["snapshots"][0]["delta_source"]["kind"] = "corruption" + result["probes"][0]["input"]["semantic_key"][0] = "corruption" + self.assertEqual(self.config["snapshot_bindings"][0]["completed_epochs"], 0) + self.assertEqual(self.trial["plan"]["blocks"][0]["suites"][0]["arms"][0]["delta_start"]["kind"], "zero") + self.assertEqual(m._assemble(self.trial, self.config), self.plan) + + +if __name__ == "__main__": + unittest.main() diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/test_evaluation_policy.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_evaluation_policy.py new file mode 100644 index 0000000..fac653b --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_evaluation_policy.py @@ -0,0 +1,262 @@ +"""Synthetic plans only; no neural input generation, files, remote or execution.""" +import copy +import unittest +from unittest import mock + +import evaluation_plan +import evaluation_policy as policy +import trial_plan + + +class EvaluationPolicyTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Explicit synthetic fixture seed, never a proposed production master. + cls.trial = trial_plan.build_trial_plan(461) + cls.result = policy.build_evaluation_policy(cls.trial) + cls.registry = policy._registry_index(cls.trial) + cls.rows = cls.result["audit"]["bindings"] + + def suite(self, assay="normal"): + block = self.trial["plan"]["blocks"][0] + suite = next(s for s in block["suites"] if s["assay"] == assay) + return block, copy.deepcopy(suite) + + def check_suite(self, block, suite, registry=None): + return policy._suite_policy(block, suite, self.trial["plan"]["master"], + self.registry if registry is None else registry) + + def test_fixed_config_and_complete_consumer_inventory(self): + config = self.result["config"] + self.assertEqual(set(config), evaluation_plan.CONFIG_FIELDS) + self.assertEqual(config["probe_steps"], 1200) + self.assertEqual(config["cs_start_tick"], 0) + self.assertEqual(config["cs_stop_tick_exclusive"], 1000) + self.assertEqual(config["reversal_checkpoints"], [0, 1, 2, 5, 10, 20, 50]) + self.assertEqual(config["baseline_execution"], "execute_each_snapshot") + self.assertEqual(len(config["snapshot_bindings"]), 4480) + # The original public consumer performs its own full authority check. + inventory = evaluation_plan.build_evaluation_plan(self.trial, config) + self.assertEqual(inventory["budget"]["evaluation_probes"], 9440) + self.assertEqual(inventory["budget"]["snapshot_count"], 4720) + self.assertEqual(inventory["budget"]["evaluation_steps"], 11328000) + self.assertEqual(inventory["budget"]["planned_scientific_steps_including_evaluation"], 118928000) + self.assertEqual(inventory["budget"]["probes_by_assay"], + {"normal": 3920, "delayed": 3920, "reversal": 1120, "retention": 480}) + self.assertFalse(inventory["execution_authorized"]) + self.assertFalse(inventory["budget"]["baseline_output_deduplication"]) + + def test_each_binding_audited_in_exact_order(self): + bindings = self.result["config"]["snapshot_bindings"] + self.assertEqual([r["binding"] for r in self.rows], bindings) + identities = [(r["block_id"], r["assay"], r["arm"], r["checkpoint"]) for r in bindings] + self.assertEqual(len(identities), len(set(identities))) + self.assertEqual({r["checkpoint"] for r in bindings if r["assay"] == "reversal"}, set(policy.POINTS)) + self.assertEqual(self.result["audit"]["coverage"]["snapshots_by_assay"], + {"normal": 1960, "delayed": 1960, "reversal": 560}) + + def test_five_independent_replicates_not_relabelled_from_probes(self): + coverage = self.result["audit"]["coverage"] + self.assertEqual(coverage["training_blocks"], 40) + self.assertEqual(coverage["training_replicates_per_cell"], 5) + self.assertEqual(coverage["new_random_streams"], 0) + self.assertFalse(coverage["new_master_seed_selected"]) + self.assertEqual(self.result["seed_manifest_sha256"], self.trial["seed_manifest"]["payload_sha256"]) + + def test_normal_and_delayed_controls_match_actual_ordered_source_prefixes(self): + selected = [r for r in self.rows if r["binding"]["assay"] in ("normal", "delayed") and + r["binding"]["arm"] != "dan_only"] + self.assertEqual(len(selected), 3360) + for row in selected: + self.assertIs(row["ordered_CS_prefix_exact"], True) + self.assertEqual(row["actual"]["ordered_CS_source_identity_sha256"], + row["reference"]["ordered_CS_source_identity_sha256"]) + self.assertEqual(row["actual"]["CS_counts_by_cue"], row["reference"]["CS_counts_by_cue"]) + if row["binding"]["arm"] != "unpaired": + self.assertEqual(row["binding"]["completed_epochs"], row["reference_completed_epochs"]) + self.assertTrue(row["elapsed_ticks_equal_to_reference"]) + + def test_unpaired_earliest_matching_prefix_and_full_terminal_including_tail(self): + any_earlier_US = False + any_trailing_US = False + for block in self.trial["plan"]["blocks"]: + for suite in block["suites"][:2]: + arms = {a["arm"]: a for a in suite["arms"]} + paired, unpaired = arms["paired"], arms["unpaired"] + rows = [r for r in self.rows if r["binding"]["block_id"] == block["block_id"] and + r["binding"]["assay"] == suite["assay"] and r["binding"]["arm"] == "unpaired"] + for row in rows: + point = row["binding"]["checkpoint"] + count = row["binding"]["completed_epochs"] + needed = row["reference_completed_epochs"] + if point == 0: + self.assertEqual(count, 0) + elif point == 50: + self.assertEqual(count, 150) + self.assertEqual(row["actual"]["CS_epochs"], 100) + self.assertEqual(row["actual"]["requested_US_epochs"], 50) + self.assertEqual(row["elapsed_ticks_difference"], 50 * paired["epochs"][0]["steps"]) + any_trailing_US |= unpaired["epochs"][-1]["cs"] is None + else: + actual_positions = [i + 1 for i, e in enumerate(unpaired["epochs"]) if e["cs"] is not None] + self.assertEqual(count, actual_positions[needed - 1]) + self.assertIsNotNone(unpaired["epochs"][count - 1]["cs"]) + self.assertEqual(row["actual"]["requested_US_epochs"], count - needed) + any_earlier_US |= count > needed + self.assertTrue(any_earlier_US) + self.assertTrue(any_trailing_US) + + def test_DAN_only_is_yoked_zero_CS_not_equal_exposure(self): + rows = [r for r in self.rows if r["binding"]["arm"] == "dan_only"] + self.assertEqual(len(rows), 560) + for row in rows: + self.assertEqual(row["actual"]["CS_epochs"], 0) + self.assertIsNone(row["ordered_CS_prefix_exact"]) + self.assertFalse(row["CS_exposure_equal"]) + self.assertTrue(row["DAN_only_yoke_verified"]) + self.assertTrue(row["requested_US_count_equal_to_reference"]) + self.assertTrue(row["elapsed_ticks_equal_to_reference"]) + self.assertEqual(row["binding"]["completed_epochs"], row["reference_completed_epochs"]) + + def test_reversal_each_own_US_boundary_and_original_terminal(self): + saw_different_middle_CS_prefix = False + for block in self.trial["plan"]["blocks"]: + suite = block["suites"][2] + arms = {a["arm"]: a for a in suite["arms"]} + for row in [r for r in self.rows if r["binding"]["block_id"] == block["block_id"] and + r["binding"]["assay"] == "reversal"]: + binding = row["binding"] + arm = arms[binding["arm"]] + point, count = binding["checkpoint"], binding["completed_epochs"] + if point in (0, 50): + self.assertEqual(count, 0 if point == 0 else 100) + self.assertTrue(row["ordered_CS_prefix_exact"]) + else: + ordinal_positions = [i + 1 for i, e in enumerate(arm["epochs"]) if e["us"]["requested"]] + self.assertEqual(count, ordinal_positions[point - 1]) + self.assertTrue(arm["epochs"][count - 1]["us"]["requested"]) + saw_different_middle_CS_prefix |= not row["ordered_CS_prefix_exact"] + self.assertEqual(row["actual"]["requested_US_epochs"], point) + self.assertEqual(row["actual"]["enabled_nonzero_D_update_opportunities"], point) + self.assertTrue(saw_different_middle_CS_prefix) + + def test_intermediate_US_time_and_update_mismatches_are_explicit_not_failed(self): + middle = [r for r in self.rows if r["binding"]["checkpoint"] not in (0, 50)] + self.assertTrue(any(r["binding"]["arm"] == "random_reward" and r["requested_US_count_difference"] != 0 for r in middle)) + self.assertTrue(any(r["binding"]["arm"] == "unpaired" and r["elapsed_ticks_difference"] > 0 for r in middle)) + self.assertTrue(any(r["binding"]["arm"] == "frozen" and r["nonzero_D_update_opportunity_difference"] < 0 for r in middle)) + self.assertTrue(all(r["all_US_timing_or_total_exposure_matched_claimed"] is False for r in self.rows)) + self.assertTrue(all(r["actual"]["actual_accepted_US_or_DAN_spikes_known"] is False for r in self.rows)) + + def test_all_boundaries_follow_complete_epoch_update_not_US_tick(self): + for row in self.rows: + self.assertEqual(row["boundary"], "after complete epoch and scheduled delta update; not at the US tick") + steps = 1800 if row["binding"]["assay"] == "delayed" else 1200 + self.assertEqual(row["actual"]["elapsed_training_ticks"], row["binding"]["completed_epochs"] * steps) + self.assertEqual(row["terminal_includes_all_epochs"], row["binding"]["checkpoint"] == 50) + + def test_control_seed_or_source_alias_mutations_fail_without_relying_on_counts(self): + for field, change in (("seed", lambda v: v + 1), + ("source_stream_id", lambda v: v + "x"), + ("stream_id", lambda v: v + "x"), + ("start_tick", lambda v: False)): + with self.subTest(field=field): + block, suite = self.suite() + cs = suite["arms"][1]["epochs"][0]["cs"] + cs[field] = change(cs[field]) + with self.assertRaises(ValueError): + self.check_suite(block, suite) + + def test_reordered_cues_with_same_counts_and_valid_individual_aliases_fail(self): + block, suite = self.suite() + epochs = suite["arms"][1]["epochs"] + index = next(i for i in range(1, len(epochs)) if epochs[i]["cs"]["cue"] != epochs[0]["cs"]["cue"]) + epochs[0]["cs"], epochs[index]["cs"] = epochs[index]["cs"], epochs[0]["cs"] + with self.assertRaisesRegex(ValueError, "CS occurrence order|ordered CS|CS sequence"): + self.check_suite(block, suite) + + def test_corrupt_alias_registry_is_not_excused_by_equal_CS_seed(self): + block, suite = self.suite() + independent, aliases = self.registry + changed = copy.deepcopy(aliases) + cs = suite["arms"][1]["epochs"][0]["cs"] + key = (block["block_id"], "normal", "CS", cs["cue"], f'{cs["occurrence"]:02d}', "d_zero") + changed[("training", key)]["source_key"][-1] = "frozen" + with self.assertRaisesRegex(ValueError, "alias is not registered"): + self.check_suite(block, suite, (independent, changed)) + + def test_DAN_yoked_cue_US_or_added_CS_mutations_fail(self): + for mutation in ("cue", "US", "CS"): + with self.subTest(mutation=mutation): + block, suite = self.suite() + paired = suite["arms"][0] + dan = suite["arms"][-1] + epoch = dan["epochs"][0] + if mutation == "cue": + epoch["yoked_cue_removed"] = "B" if epoch["yoked_cue_removed"] == "A" else "A" + elif mutation == "US": + epoch["us"]["nominal_slot_tick"] += 1 + else: + epoch["cs"] = copy.deepcopy(paired["epochs"][0]["cs"]) + with self.assertRaises(ValueError): + self.check_suite(block, suite) + + def test_missing_reordered_or_duplicate_control_arms_fail(self): + for mutation in ("missing", "reorder", "duplicate"): + with self.subTest(mutation=mutation): + block, suite = self.suite() + if mutation == "missing": + suite["arms"].pop() + elif mutation == "reorder": + suite["arms"][0], suite["arms"][1] = suite["arms"][1], suite["arms"][0] + else: + suite["arms"].append(suite["arms"][0]) + with self.assertRaisesRegex(ValueError, "control arms"): + self.check_suite(block, suite) + + def test_result_not_aliased_no_execution_or_new_scientific_gate(self): + fields = ("protocol_approval_claimed", "execution_authorized", "training_executed", "inputs_generated", + "input_generator_implementation_verified", "source_and_snapshot_authority_verified", + "candidate_generated", "learning_pass_claimed", "scientific_gate_added") + self.assertTrue(all(self.result[field] is False for field in fields)) + self.assertEqual(self.result["status"], "DRAFT-NOT-REGISTERED") + self.assertTrue(self.result["choices_are_new_draft_proposals_not_inherited_gates"]) + self.assertTrue(self.result["audit"]["unchanged_retention_scope"]["same_continuous_ten_second_lineage"]) + self.assertFalse(self.result["audit"]["unchanged_retention_scope"]["retained_snapshot_execution_or_lineage_verified"]) + # Config and audit may describe the same row, but neither is a caller alias. + block = self.trial["plan"]["blocks"][0] + self.assertIsNot(self.result["config"]["reversal_checkpoints"], block["evaluation_checkpoints"]) + self.assertEqual(self.result["trial_payload_sha256"], self.trial["payload_sha256"]) + + def test_validation_rejects_rehashed_config_type_and_audit_mutations(self): + # Separate reconstruction is supplied by the already independently built + # fixture to keep mutation tests small; public full reconstruction is + # exercised once below, not hidden behind this mocked trust boundary. + with mock.patch.object(policy, "build_evaluation_policy", return_value=self.result): + for mutation in ("point", "type", "audit", "authorization"): + with self.subTest(mutation=mutation): + changed = copy.deepcopy(self.result) + if mutation == "point": + changed["config"]["snapshot_bindings"][1]["completed_epochs"] += 1 + elif mutation == "type": + changed["config"]["cs_start_tick"] = False + elif mutation == "audit": + changed["audit"]["bindings"][0]["actual"]["CS_epochs"] += 1 + else: + changed["execution_authorized"] = True + changed["payload_sha256"] = policy._sha({k: v for k, v in changed.items() if k != "payload_sha256"}) + with self.assertRaisesRegex(ValueError, "exact reconstruction"): + policy.validate_evaluation_policy(changed, self.trial) + + def test_public_validation_full_reconstruction(self): + self.assertTrue(policy.validate_evaluation_policy(self.result, self.trial)) + + def test_trial_authority_hash_rejected_before_planning(self): + changed = dict(self.trial) + changed["payload_sha256"] = "0" * 64 + with self.assertRaisesRegex(ValueError, "payload SHA"): + policy.build_evaluation_policy(changed) + + +if __name__ == "__main__": + unittest.main() diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/test_metrics.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_metrics.py new file mode 100644 index 0000000..53286ad --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_metrics.py @@ -0,0 +1,318 @@ +"""Synthetic arrays/declared receipts only; no real recordings or science run.""" +import copy +import hashlib +import json +import unittest + +import numpy as np + +import metrics as m + + +NODES = np.arange(6, dtype=np.int32) +POPS = {'MBON': np.array([0, 1, 2], np.int32), 'legacyDownstream': np.arange(6, dtype=np.int32), + 'nonMBONDownstream': np.array([3, 4, 5], np.int32), + 'MBON01': np.array([0], np.int32), 'MBON12': np.array([1], np.int32)} + + +def state(counts=(1, 1, 0, 1, 0, 0), *, times=None, g=None): + events = [(10 + 20 * k if times is None else times[i][k], i) for i, count in enumerate(counts) for k in range(count)] + events.sort() + return dict(step=np.array([t for t, _ in events], np.int32), + neuron_idx=np.array([i for _, i in events], np.int32), counts=np.array(counts, np.int32), + v=np.full(6, -.052, np.float64), g=np.zeros(6, np.float64) if g is None else np.array(g, np.float64), + ref=np.zeros(6, np.int32)) + + +def effect(*, direction=1, gain=True, g_effect=True, conditioned=m.CUES[0], neutral_g=0.): + pre = {cue: state() for cue in m.CUES} + post = {cue: state() for cue in m.CUES} + counts = [1, 1, 0, 1, 0, 0] + counts[1 if direction > 0 else 0] += abs(direction) + if gain: + counts[3] += abs(direction) + g = np.zeros(6, np.float64) + if g_effect: + g[0] = .002 + post[conditioned] = state(counts, g=g) + neutral = next(c for c in m.CUES if c != conditioned) + post[neutral]['g'][0] = neutral_g + return m.arm_metrics(pre, post, conditioned_cue=conditioned, nodes=NODES, populations=POPS) + + +def inventory(*, assay='normal', dan='PAM15', paired=None): + registration = {f'rep{i}': [f'training-source-{i}'] for i in range(5)} + rows = [] + for i in range(5): + arms = {} + for name in m.ARMS: + metric = effect(direction=0, g_effect=False) if name != 'paired' else ( + effect(direction=1 if dan == 'PAM15' else -1) if paired is None else copy.deepcopy(paired[i])) + evidence = dict(complete=True, recording_verified=True, + receipt_sha256=hashlib.sha256(f'{i}:{name}:{assay}'.encode()).hexdigest(), assay=assay, + epoch_duration_ms=120. if assay == 'normal' else 180., cs_duration_ms=100., + us_tick=900 if assay == 'normal' else 1500, completed_epochs=150 if name == 'unpaired' else 100, + reinforcement_opportunities=0 if name == 'CS-only' else 50, terminal_pairings=50) + arms[name] = dict(metrics=metric, execution=evidence) + rows.append(dict(replicate_id=f'rep{i}', replicate_unit='independent_training', + training_stream_ids=registration[f'rep{i}'], evaluation_stream_ids=[f'evaluation-source-{i}'], + assay=assay, mode='physiological', dan_type=dan, conditioned_cue=m.CUES[0], arms=arms)) + return rows, registration + + +def evaluate(rows, registration, *, assay='normal', dan='PAM15'): + return m.evaluate_acquisition(rows, assay=assay, mode='physiological', dan_type=dan, + conditioned_cue=m.CUES[0], registered_training_streams=registration) + + +class StateTests(unittest.TestCase): + def test_every_key_validated_and_no_input_mutation(self): + a, b = state(), state((1, 2, 0, 2, 0, 0), g=[.001, -.004, 0, 0, 0, 0]) + before = {key: value.tobytes() for key, value in a.items()} + r = m.functional_summary(a, b, nodes=NODES, populations=POPS, steps=1200) + self.assertEqual(r['MBON_final_delta_g_max_abs'], .004) + self.assertEqual(r['MBON_final_delta_g_units'], 'SI volts') + self.assertIn('not time peak', r['MBON_final_delta_g_scope']) + self.assertEqual(r['wholebrain']['spike_count_l1'], 2) + self.assertEqual(before, {key: value.tobytes() for key, value in a.items()}) + json.dumps(r, allow_nan=False) + + def test_first_event_minimum_not_last_and_zero_tick_is_defined(self): + a = state((3, 0, 0, 0, 0, 0), times=[[0, 20, 100], [], [], [], [], []]) + r = m.validate_state(a, nodes=NODES, steps=1200) + self.assertEqual(r['first_tick'].tolist(), [0, -1, -1, -1, -1, -1]) + + def test_missing_latencies_never_zero_filled(self): + a, b = state((0, 0, 0, 0, 0, 0)), state((1, 0, 0, 0, 0, 0)) + r = m.functional_summary(a, b, nodes=NODES, populations=POPS, steps=1200)['populations']['MBON'] + self.assertIsNone(r['pre_first_latency_ms']) + self.assertIsNone(r['first_latency_delta_ms']) + self.assertFalse(r['defined_first_latency_changed']) + self.assertEqual(r['first_latency_became_defined_neurons'], 1) + self.assertGreater(r['spike_count_l1'], 0) + + def test_nonfinite_input_all_neurons_not_only_scored_targets(self): + for key in ('v', 'g'): + for value in (np.nan, np.inf, -np.inf): + with self.subTest(key=key, value=value): + a = state(); a[key][5] = value + with self.assertRaisesRegex(ValueError, 'Nonfinite'): + m.functional_summary(a, state(), nodes=NODES, populations=POPS, steps=1200) + + def test_nonfinite_derived_difference_rejected(self): + a, b = state(), state() + a['g'][0], b['g'][0] = -1e308, 1e308 + with self.assertRaisesRegex(ValueError, 'Nonfinite derived'): + m.functional_summary(a, b, nodes=NODES, populations=POPS, steps=1200) + + def test_bad_state_dtype_shape_counts_order_bounds_rejected(self): + variants = [] + a = state(); a['counts'] = a['counts'].astype(np.int64); variants.append(a) + a = state(); a['counts'][0] += 1; variants.append(a) + a = state(); a['step'][0] = -1; variants.append(a) + a = state(); a['neuron_idx'][0] = 99; variants.append(a) + a = state(); a['ref'][0] = 23; variants.append(a) + a = state(); a['v'] = a['v'][:-1]; variants.append(a) + a = state(); a['neuron_idx'][1] = 0; variants.append(a) + a = state(); a['extra'] = np.zeros(1); variants.append(a) + for a in variants: + with self.subTest(keys=list(a)), self.assertRaises(ValueError): + m.validate_state(a, nodes=NODES, steps=1200) + + def test_empty_wrong_duplicate_and_overlapping_memberships_rejected(self): + for key, value in [('MBON12', []), ('MBON01', [0, 0]), ('MBON12', [3]), + ('MBON12', [0]), ('nonMBONDownstream', [2, 3]), ('MBON', [0, 1, 99]), + ('nonMBONDownstream', [3, 4]), ('legacyDownstream', [0, 1, 3, 4, 5])]: + populations = dict(POPS, **{key: np.array(value, np.int32)}) + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + m.validate_populations(NODES, populations) + + def test_legacy_downstream_retains_mbon_and_new_setdiff_is_explicit(self): + result = m.validate_populations(NODES, POPS) + self.assertEqual(result['indices']['legacyDownstream'], [0, 1, 2, 3, 4, 5]) + self.assertEqual(result['indices']['nonMBONDownstream'], [3, 4, 5]) + self.assertIn('NEW hypothesis', result['population_semantics']['nonMBONDownstream']) + old_alias = dict(POPS) + old_alias['trueDownstream'] = old_alias.pop('nonMBONDownstream') + with self.assertRaisesRegex(ValueError, 'exactly'): + m.validate_populations(NODES, old_alias) + + +class ContrastTests(unittest.TestCase): + def test_fixed_types_not_mask_and_type_mean_not_population_sum(self): + p = dict(POPS, MBON=np.array([0, 1, 2], np.int32), MBON01=np.array([0], np.int32), MBON12=np.array([1, 2], np.int32)) + pre = {c: state() for c in m.CUES} + post = {c: state() for c in m.CUES}; post[m.CUES[0]] = state((1, 3, 1, 2, 0, 0)) + r = m.arm_metrics(pre, post, conditioned_cue=m.CUES[0], nodes=NODES, populations=p) + self.assertEqual(r['rawB']['post'][m.CUES[0]], 1.) + self.assertEqual(r['rawS'], 1.5) + self.assertFalse(r['B_is_decoder']) + + def test_reciprocal_role_keeps_signed_orientation(self): + first, second = effect(), effect(conditioned=m.CUES[1]) + self.assertEqual(first['rawS'], second['rawS']) + self.assertEqual(second['conditioned_cue'], m.CUES[1]) + + def test_raw_labels_not_aliases(self): + with self.assertRaises(ValueError): + m.arm_metrics({'A': state()}, {'B': state()}, conditioned_cue='A', nodes=NODES, populations=POPS) + + def test_legacy_and_non_mbon_gains_are_separate_not_an_alias(self): + result = effect(gain=False) + self.assertEqual(result['separation']['MBON']['gain'], 1.) + self.assertEqual(result['separation']['legacyDownstream']['gain'], 1.) + self.assertEqual(result['separation']['nonMBONDownstream']['gain'], 0.) + pre = {cue: state((4, 0, 0, 0, 0, 0) if cue == m.CUES[0] else (0, 0, 0, 0, 0, 0)) for cue in m.CUES} + post = {cue: state((3, 0, 0, 1, 0, 0) if cue == m.CUES[0] else (0, 0, 0, 0, 0, 0)) for cue in m.CUES} + result = m.arm_metrics(pre, post, conditioned_cue=m.CUES[0], nodes=NODES, populations=POPS) + self.assertLess(result['separation']['legacyDownstream']['gain'], 0.) + self.assertGreater(result['separation']['nonMBONDownstream']['gain'], 0.) + + +class GateTests(unittest.TestCase): + def test_both_directions_and_suites_pass_only_as_draft_proposal(self): + for assay in ('normal', 'delayed'): + for dan in m.DAN_TYPES: + with self.subTest(assay=assay, dan=dan): + rows, registration = inventory(assay=assay, dan=dan) + r = evaluate(rows, registration, assay=assay, dan=dan) + self.assertTrue(r['proposal_gate_pass']) + self.assertEqual(r['actual_control_receipts'], 30) + self.assertEqual(r['successful_same_replicate_conjunctions'], 5) + self.assertFalse(r['scientific_learning_pass_claimed']) + self.assertFalse(r['candidate_generated']) + self.assertTrue(all(v['rawS'] < 0 for v in r['replicates']) if dan == 'PPL103' else + all(v['rawS'] > 0 for v in r['replicates'])) + json.dumps(r, allow_nan=False) + + def test_opposite_or_zero_signed_effect_is_failure_not_abs_success(self): + for direction in (-1, 0): + rows, registry = inventory(paired=[effect(direction=direction)] * 5) + r = evaluate(rows, registry) + self.assertFalse(r['proposal_gate_pass']) + self.assertEqual(r['successful_same_replicate_conjunctions'], 0) + + def test_equal_control_is_failure(self): + rows, registry = inventory() + for row in rows: + row['arms']['CS-only']['metrics'] = copy.deepcopy(row['arms']['paired']['metrics']) + self.assertFalse(evaluate(rows, registry)['proposal_gate_pass']) + + def test_same_replicate_conjunction_not_majority_of_each_predicate(self): + effects = [effect(), effect(), effect(gain=False), effect(g_effect=False), effect(direction=-1)] + rows, registry = inventory(paired=effects) + r = evaluate(rows, registry) + self.assertEqual(r['successful_same_replicate_conjunctions'], 2) + self.assertFalse(r['proposal_gate_pass']) + + def test_exact_three_of_five_pass(self): + rows, registry = inventory(paired=[effect()] * 3 + [effect(direction=0)] * 2) + self.assertTrue(evaluate(rows, registry)['proposal_gate_pass']) + + def test_positive_legacy_gain_cannot_rescue_new_non_mbon_gate(self): + rows, registry = inventory(paired=[effect(gain=False)] * 5) + result = evaluate(rows, registry) + self.assertFalse(result['proposal_gate_pass']) + for row in result['replicates']: + self.assertTrue(row['predicates']['legacyDownstream_AB_euclidean_gain_positive']) + self.assertFalse(row['predicates']['nonMBONDownstream_AB_euclidean_gain_positive']) + self.assertEqual(row['population_separation_gains']['legacyDownstream'], 1.) + self.assertEqual(row['population_separation_gains']['nonMBONDownstream'], 0.) + self.assertEqual(row['legacy_audit']['H_like_downstream_population'], 'legacyDownstream') + self.assertFalse(row['new_hypothesis_audit']['inherited_from_old_H']) + + def test_population_metadata_or_overlapping_raw_vectors_cannot_be_forged(self): + for mutation in ('semantics', 'setdiff', 'subset_final'): + rows, registry = inventory() + summary = rows[0]['arms']['paired']['metrics']['cues'][m.CUES[0]] + if mutation == 'semantics': + summary['membership']['population_semantics']['nonMBONDownstream'] = 'old H population' + elif mutation == 'setdiff': + ids = summary['membership']['indices'] + ids['nonMBONDownstream'] = [2, 3, 4] + summary['membership']['membership_sha256'] = m._json_hash(ids) + summary['populations']['nonMBONDownstream']['neuron_idx'] = [2, 3, 4] + else: + # Self-consistent terminal deltas can still contradict the full parent population. + child = summary['populations']['nonMBONDownstream'] + child['pre_final_v'][0] += .001 + child['post_final_v'][0] += .001 + with self.subTest(mutation=mutation), self.assertRaises(ValueError): + evaluate(rows, registry) + + def test_missing_control_incomplete_receipt_and_wrong_delayed_schedule_rejected(self): + for mutation in ('missing', 'incomplete', 'timing', 'epochs', 'duplicate_receipt', 'best_checkpoint'): + rows, registry = inventory() + if mutation == 'missing': + rows[0]['arms'].pop('unpaired') + else: + e = rows[0]['arms']['unpaired']['execution'] + if mutation == 'incomplete': e['complete'] = False + if mutation == 'timing': e['us_tick'] = 1500 + if mutation == 'epochs': e['completed_epochs'] = 100 + if mutation == 'duplicate_receipt': e['receipt_sha256'] = rows[0]['arms']['paired']['execution']['receipt_sha256'] + if mutation == 'best_checkpoint': e['terminal_pairings'] = 20 + with self.subTest(mutation=mutation), self.assertRaises(ValueError): evaluate(rows, registry) + + def test_evaluation_draws_cannot_be_training_replicates(self): + for mutation in ('unit', 'duplicate_id', 'training_overlap', 'eval_training_overlap', 'eval_overlap', 'extra_draw'): + rows, registry = inventory() + if mutation == 'unit': rows[0]['replicate_unit'] = 'evaluation' + if mutation == 'duplicate_id': rows[1]['replicate_id'] = rows[0]['replicate_id'] + if mutation == 'training_overlap': registry['rep1'] = registry['rep0'] + if mutation == 'eval_training_overlap': rows[0]['evaluation_stream_ids'] = registry['rep0'] + if mutation == 'eval_overlap': rows[1]['evaluation_stream_ids'] = rows[0]['evaluation_stream_ids'] + if mutation == 'extra_draw': rows.append(copy.deepcopy(rows[0])) + with self.subTest(mutation=mutation), self.assertRaises(ValueError): evaluate(rows, registry) + + def test_raw_nan_forged_B_g_function_and_separation_rejected(self): + for mutation in ('nan', 'rawB', 'g', 'count', 'separation', 'latency'): + rows, registry = inventory() + metric = rows[0]['arms']['paired']['metrics'] + group = metric['cues'][m.CUES[0]]['populations']['MBON'] + if mutation == 'nan': metric['rawS'] = np.inf + if mutation == 'rawB': metric['rawB']['post'][m.CUES[0]] = 100. + if mutation == 'g': group['final_delta_g_max_abs'] = 100. + if mutation == 'count': group['spike_count_l1'] = 100 + if mutation == 'separation': metric['separation']['MBON']['gain'] = 100. + if mutation == 'latency': group['defined_first_latency_changed'] = True + with self.subTest(mutation=mutation), self.assertRaises(ValueError): evaluate(rows, registry) + + def test_control_baseline_and_cell_pooling_rejected(self): + for mutation in ('baseline', 'mode', 'DAN', 'assay', 'cue'): + rows, registry = inventory() + if mutation == 'baseline': rows[0]['arms']['frozen']['metrics']['cues'][m.CUES[0]]['pre_state_raw_sha256']['g'] = 'a' * 64 + if mutation == 'mode': rows[0]['mode'] = 'matched_energy' + if mutation == 'DAN': rows[0]['dan_type'] = 'PAM-positive' + if mutation == 'assay': rows[0]['assay'] = 'delayed' + if mutation == 'cue': rows[0]['conditioned_cue'] = m.CUES[1] + with self.subTest(mutation=mutation), self.assertRaises(ValueError): evaluate(rows, registry) + + def test_reversal_retention_remain_missing_not_fabricated_pass(self): + for assay in ('reversal', 'retention'): + r = m.unavailable_assay(assay) + self.assertEqual(r['status'], 'NOT-IMPLEMENTED') + self.assertIsNone(r['proposal_gate_pass']) + self.assertTrue(r['missing']) + + def test_legacy_mean_control_audit_separate_and_strict_not_new_signed_gate(self): + raw = {'appetitive-conditioning': [2.] * 5, 'aversive-conditioning': [1.] * 5, + 'd-zero-control': [0.] * 5, 'random-reward-control': [.5] * 5, 'frozen-brain-control': [0.] * 5} + r = m.legacy_h_mean_control_audit(raw) + self.assertTrue(r['predicate_pass']) + self.assertFalse(r['full_old_H_gate_computed']) + raw['random-reward-control'] = [1.] * 5 + self.assertFalse(m.legacy_h_mean_control_audit(raw)['predicate_pass']) + raw.pop('d-zero-control') + with self.assertRaises(ValueError): m.legacy_h_mean_control_audit(raw) + + def test_legacy_nan_or_evaluation_count_not_admitted(self): + raw = {'appetitive-conditioning': [2.] * 5, 'aversive-conditioning': [1.] * 5, + 'd-zero-control': [0.] * 5, 'random-reward-control': [.5] * 5, 'frozen-brain-control': [0.] * 5} + for values in ([0.] * 25, [0., 0., np.nan, 0., 0.]): + raw['frozen-brain-control'] = values + with self.assertRaises(ValueError): m.legacy_h_mean_control_audit(raw) + + +if __name__ == '__main__': + unittest.main() diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/test_seed_registry.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_seed_registry.py new file mode 100644 index 0000000..933b8ef --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_seed_registry.py @@ -0,0 +1,123 @@ +import copy +import hashlib +import json +import unittest +from unittest.mock import patch + +import seed_registry as m + + +class Seeds(unittest.TestCase): + def test_order_independent(self): + a, b = m.SeedRegistry(461), m.SeedRegistry(461) + keys = [("mode", "PAM15", str(replicate), str(epoch)) for replicate in range(5) for epoch in range(50)] + for key in keys: + a.add("training", key) + for key in reversed(keys): + b.add("training", key) + self.assertEqual(a.manifest(), b.manifest()) + self.assertEqual(a.manifest()["independent_count"], 250) + self.assertTrue(m.validate_manifest(a.manifest())) + + def test_splits_disjoint_and_no_sequential_overlap(self): + r = m.SeedRegistry(461) + for split in ("training", "evaluation", "design"): + for replicate in range(5): + for epoch in range(100): + r.add(split, ("shared-name", str(replicate), str(epoch))) + out = r.manifest() + seeds = [x["seed"] for x in out["independent_streams"]] + self.assertEqual(len(set(seeds)), 1500) + self.assertEqual(out["training_evaluation_seed_overlap"], 0) + self.assertTrue(all(0 <= s < 2**63 for s in seeds)) + + def test_alias_explicit_not_independent(self): + r = m.SeedRegistry(461) + seed = r.add("training", ("paired", "0")) + self.assertEqual(seed, r.alias("training", ("frozen", "0"), "training", ("paired", "0"), reason="Same prescheduled CS inputs, different learning policy")) + out = r.manifest() + self.assertEqual(out["independent_count"], 1) + self.assertEqual(out["alias_count"], 1) + self.assertFalse(out["aliases_count_as_independent_replicates"]) + self.assertTrue(m.validate_manifest(out)) + + def test_no_cross_split_alias(self): + r = m.SeedRegistry(1) + r.add("training", ("a",)) + with self.assertRaisesRegex(ValueError, "Cross-split"): + r.alias("evaluation", ("a",), "training", ("a",), reason="Not permitted") + + def test_no_alias_chain(self): + r = m.SeedRegistry(1) + r.add("training", ("a",)) + r.alias("training", ("b",), "training", ("a",), reason="Control") + with self.assertRaisesRegex(ValueError, "not another alias"): + r.alias("training", ("c",), "training", ("b",), reason="Invalid chain") + + def test_no_silent_duplicate(self): + r = m.SeedRegistry(1) + r.add("training", ("a",)) + with self.assertRaisesRegex(ValueError, "Duplicate"): + r.add("training", ("a",)) + with self.assertRaisesRegex(ValueError, "Duplicate"): + r.alias("training", ("a",), "training", ("a",), reason="Invalid") + r.alias("training", ("b",), "training", ("a",), reason="Control") + with self.assertRaisesRegex(ValueError, "Duplicate"): + r.add("training", ("b",)) + + def test_collision_fails_without_resampling(self): + r = m.SeedRegistry(1) + with patch.object(m, "seed_for", return_value=(123, "a" * 64)): + r.add("training", ("a",)) + with self.assertRaisesRegex(ValueError, "Seed collision"): + r.add("evaluation", ("b",)) + self.assertEqual(len(r._streams), 1) + + def test_mutations_detected(self): + r = m.SeedRegistry(1) + r.add("training", ("a",)) + original = r.manifest() + for mutate in (lambda x: x["independent_streams"][0].update(seed=7), + lambda x: x.update(training_evaluation_seed_overlap=1), + lambda x: x.update(independent_count=2), + lambda x: x.update(payload_sha256="a" * 64), + lambda x: x.update(training_executed=True)): + corrupt = copy.deepcopy(original) + mutate(corrupt) + with self.assertRaises(ValueError): + m.validate_manifest(corrupt) + + def test_manifest_copy_not_mutable_alias(self): + r = m.SeedRegistry(1) + r.add("training", ("a",)) + out = r.manifest() + out["independent_streams"][0]["key"][0] = "changed" + self.assertEqual(r.manifest()["independent_streams"][0]["key"], ["a"]) + self.assertTrue(m.validate_manifest(json.loads(json.dumps(r.manifest())))) + + def test_json_numeric_boolean_type_changes_rejected_even_when_rehashed(self): + r = m.SeedRegistry(1) + r.add("training", ("a",)) + for field, replacement in (("training_executed", 0), ("independent_count", 1.0)): + for rehash in (False, True): + corrupt = r.manifest() + corrupt[field] = replacement + if rehash: + payload = {key: value for key, value in corrupt.items() if key != "payload_sha256"} + corrupt["payload_sha256"] = hashlib.sha256(m._canonical(payload).encode("utf-8")).hexdigest() + with self.subTest(field=field, rehash=rehash), self.assertRaises(ValueError): + m.validate_manifest(corrupt) + + def test_reject_invalid_values(self): + for master in (True, -1, 2**63, 1.0, "1"): + with self.assertRaises(ValueError): + m.SeedRegistry(master) + for key in ([], "a", [""], [" a"], ["a "]): + with self.assertRaises(ValueError): + m.seed_for(1, "training", key) + with self.assertRaises(ValueError): + m.seed_for(1, "unknown", ["a"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/test_trial_plan.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_trial_plan.py new file mode 100644 index 0000000..03f61d2 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/test_trial_plan.py @@ -0,0 +1,171 @@ +import copy +from collections import Counter +import unittest +from unittest.mock import patch + +import seed_registry +import trial_plan as m + + +class TrialPlan(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.original = m.build_trial_plan(461) + + def test_complete_reconstruction_and_budget(self): + self.assertTrue(m.validate_trial_plan(self.original)) + budget = self.original["budget"] + self.assertEqual(budget["training_control_reversal_epochs"], 68000) + self.assertEqual(budget["training_control_reversal_steps"], 99600000) + self.assertEqual(budget["minimum_biological_seconds_excluding_evaluation"], 10760) + self.assertEqual(budget["minimum_steps_excluding_evaluation"], 107600000) + self.assertFalse(budget["evaluation_probe_inventory_complete"]) + self.assertIsNone(budget["evaluation_epochs"]) + self.assertFalse(self.original["plan"]["training_executed"]) + + def test_exact_cells_five_training_replicates_not_evaluation_streams(self): + cells = {} + for block in self.original["plan"]["blocks"]: + key = block["mode"], block["dan_type"], block["initial_reinforced_cue"] + cells.setdefault(key, []).append(block["replicate"]) + self.assertEqual(len(block["evaluation_streams"]), 2) + self.assertEqual(len(cells), 8) + self.assertTrue(all(replicates == [1, 2, 3, 4, 5] for replicates in cells.values())) + + def test_all_epoch_control_roles_and_matched_delayed_slots(self): + for block in self.original["plan"]["blocks"]: + for suite in block["suites"]: + assay = suite["assay"] + arms = {a["arm"]: a for a in suite["arms"]} + self.assertEqual(tuple(arms), m.REVERSAL_ARMS if assay == "reversal" else m.ARMS) + expected_steps, expected_us = (1800, 1500) if assay == "delayed" else (1200, 900) + for arm, spec in arms.items(): + epochs = spec["epochs"] + self.assertEqual(len(epochs), 150 if arm == "unpaired" else 100) + cues = Counter(e["cs"]["cue"] for e in epochs if e["cs"] is not None) + self.assertEqual(cues, Counter() if arm == "dan_only" else Counter(A=50, B=50)) + us = [e for e in epochs if e["us"]["requested"]] + self.assertEqual(len(us), 0 if arm == "cs_only" else 50) + self.assertEqual(len({e["us"]["inventory_id"] for e in us}), len(us)) + for i, e in enumerate(epochs): + self.assertEqual(e["epoch_index"], i) + self.assertEqual(e["steps"], expected_steps) + self.assertEqual(e["us"]["nominal_slot_tick"], expected_us) + self.assertEqual(e["us"]["tick"], expected_us if e["us"]["requested"] else None) + self.assertIsNone(e["us"]["rng_seed"]) + self.assertEqual(e["us"]["rng_draws"], 0) + self.assertEqual(e["update_enabled"], arm != "frozen") + self.assertEqual(e["D_command"], 1.0 if e["us"]["requested"] and arm != "d_zero" else 0.0) + if arm == "random_reward": + self.assertEqual(Counter(e["cs"]["cue"] for e in us), Counter(A=25, B=25)) + if arm == "unpaired": + self.assertTrue(all(e["cs"] is None for e in us)) + if arm == "dan_only": + self.assertEqual(sum(not e["us"]["requested"] for e in epochs), 50) + + def test_alias_binding_same_occurrence_even_unpaired_insertion(self): + for block in self.original["plan"]["blocks"]: + for suite in block["suites"]: + base = suite["arms"][0] + lookup = {(e["cs"]["cue"], e["cs"]["occurrence"]): e["cs"] + for e in base["epochs"]} + base_order = [(e["cs"]["cue"], e["cs"]["occurrence"]) for e in base["epochs"]] + for arm in suite["arms"][1:]: + actual_order = [] + for e in arm["epochs"]: + if e["cs"] is None: + continue + cs = e["cs"] + source = lookup[(cs["cue"], cs["occurrence"])] + self.assertEqual(cs["seed"], source["seed"]) + self.assertEqual(cs["source_stream_id"], source["stream_id"]) + self.assertNotEqual(cs["stream_id"], source["stream_id"]) + actual_order.append((cs["cue"], cs["occurrence"])) + if arm["arm"] != "dan_only": + self.assertEqual(actual_order, base_order) + + def test_reversal_not_extra_acquisition_or_new_compartment(self): + for b in self.original["plan"]["blocks"]: + reversal, continuation = b["suites"][2]["arms"] + original = b["initial_reinforced_cue"] + other = b["initial_neutral_cue"] + for spec, cue in ((reversal, other), (continuation, original)): + self.assertEqual(spec["delta_start"], {"kind": "fork", "source": b["block_id"] + "/normal/paired/terminal-delta"}) + self.assertEqual({e["cs"]["cue"] for e in spec["epochs"] if e["us"]["requested"]}, {cue}) + + def test_split_and_replicate_seeds_are_independent_fullwidth(self): + manifest = self.original["seed_manifest"] + all_seeds = [r["seed"] for r in manifest["independent_streams"]] + self.assertEqual(len(all_seeds), len(set(all_seeds))) + by_block = {} + by_split = {} + for row in manifest["independent_streams"]: + by_block.setdefault(row["key"][0], set()).add(row["seed"]) + by_split.setdefault(row["split"], set()).add(row["seed"]) + self.assertEqual(len(by_block), 40) + blocks = list(by_block) + for i, block in enumerate(blocks): + for other in blocks[i + 1:]: + self.assertFalse(by_block[block] & by_block[other]) + self.assertFalse(by_split["training"] & by_split["evaluation"]) + self.assertTrue(any(seed > 2**32 for seed in all_seeds)) + for block in self.original["plan"]["blocks"]: + for probe in block["evaluation_streams"]: + self.assertIn(probe["seed"], by_split["evaluation"]) + self.assertEqual(probe["seed_bits"], 63) + + def test_generic_valid_alias_can_still_be_protocol_illegal(self): + for source, destination in ( + (("b-rep1", "normal", "CS", "A", "00", "paired"), ("b-rep2", "normal", "CS", "A", "00", "frozen")), + (("b", "normal", "US", "A", "00", "paired"), ("b", "normal", "CS", "A", "00", "frozen")), + (("b", "normal", "CS", "A", "00", "paired"), ("b", "delayed", "CS", "A", "00", "frozen")), + (("b", "normal", "CS", "A", "00", "paired"), ("b", "normal", "CS", "B", "00", "frozen")), + (("b", "normal", "CS", "A", "00", "paired"), ("b", "normal", "CS", "A", "01", "frozen")), + (("b", "normal", "CS", "A", "00", "paired"), ("b", "normal", "CS", "A", "00", "undeclared-control")), + ): + r = seed_registry.SeedRegistry(1) + r.add("training", source) + r.alias("training", destination, "training", source, reason="Synthetic prohibited crossing") + self.assertTrue(seed_registry.validate_manifest(r.manifest())) + with self.assertRaises(ValueError): + m._validate_alias_permissions(r.manifest()) + + def test_mutations_fail_even_after_forging_payload_sha(self): + def first(p): + return p["plan"]["blocks"][0]["suites"][0]["arms"][0]["epochs"][0] + mutations = ( + lambda p: p["plan"]["blocks"].pop(), + lambda p: p["plan"]["blocks"].__setitem__(1, copy.deepcopy(p["plan"]["blocks"][0])), + lambda p: p["plan"]["blocks"][0]["suites"][0]["arms"][0]["epochs"].pop(), + lambda p: first(p)["cs"].update(seed=first(p)["cs"]["seed"] & 0xffffffff), + lambda p: first(p)["cs"].update(seed_bits=32), + lambda p: first(p)["cs"].update(odor="post-outcome-selected odor"), + lambda p: p["plan"].update(execution_authorized=0), + lambda p: p["budget"].update(evaluation_epochs=0), + ) + for index, mutate in enumerate(mutations): + payload = copy.deepcopy(self.original) + mutate(payload) + payload["payload_sha256"] = m._sha({k: v for k, v in payload.items() if k != "payload_sha256"}) + with self.subTest(mutation=index), self.assertRaises(ValueError): + m.validate_trial_plan(payload) + + def test_deterministic_small_shuffle_and_fullwidth_seed(self): + a = m._Shuffle(23).shuffled(range(30)) + self.assertEqual(a, m._Shuffle(23).shuffled(range(30))) + self.assertNotEqual(a, m._Shuffle(23 + 2**32).shuffled(range(30))) + self.assertEqual(sorted(a), list(range(30))) + with self.assertRaises(ValueError): + m._Shuffle(True) + + def test_integer_rejection_advances_counter_not_seed(self): + shuffle = m._Shuffle(23 + 2**32) + with patch.object(m, "_sha", side_effect=["f" * 64, "0" * 64]) as hashed: + self.assertEqual(shuffle.below(3), 0) + self.assertEqual(shuffle.counter, 2) + self.assertEqual(hashed.call_args_list[0].args[0], [m.SHUFFLE_VERSION, 23 + 2**32, 0]) + self.assertEqual(hashed.call_args_list[1].args[0], [m.SHUFFLE_VERSION, 23 + 2**32, 1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/models/male-cns-lif/olfactory-conditioning-lab-v1/trial_plan.py b/models/male-cns-lif/olfactory-conditioning-lab-v1/trial_plan.py new file mode 100644 index 0000000..4020541 --- /dev/null +++ b/models/male-cns-lif/olfactory-conditioning-lab-v1/trial_plan.py @@ -0,0 +1,325 @@ +"""Pure, unregistered conditioning inventory; no files, brain inputs or runs. + +build_trial_plan(master) returns plan/seed_manifest/budget. validate_trial_plan +checks seed authority, alias permissions, and exact deterministic reconstruction +of the complete inventory, not just totals. A consumer must still bind the exact +63-bit seed and generator to its generated input receipt; this module cannot +certify a future consumer that silently truncates an integer. +""" +from __future__ import annotations + +from collections import Counter +import hashlib +import json + +import seed_registry + + +SCHEMA = "phase4an-trial-plan-v1-draft" +SHUFFLE_VERSION = "sha256-counter-rejection-fisher-yates-v1" +MODES = ("physiological", "matched_energy") +DAN_BRANCHES = (("PAM15", "y5B'2a"), ("PPL103", "y2a'1")) +CUES = ("A", "B") +ODORS = {"A": "4-ethylphenol", "B": "3-hexanone"} +ARMS = ("paired", "d_zero", "frozen", "random_reward", "unpaired", "cs_only", "dan_only") +REVERSAL_ARMS = ("reversal", "no_reversal") +CONTROL_ALIASES = frozenset(("d_zero", "frozen", "random_reward", "unpaired", "cs_only")) +PROBE_CHECKPOINTS = (0, 1, 2, 5, 10, 20, 50) + + +def _require(value, message): + if not value: + raise ValueError(message) + + +def _canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False) + + +def _sha(value): + return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest() + + +class _Shuffle: + """Explicit portable integer algorithm, unrelated to neural input RNG. + + Each draw hashes canonical UTF-8 JSON [version, full63bitseed, counter]. + Interpret all 256 digest bits as a big-endian unsigned integer; increment + counter even for a rejection. Reject x >= 2**256 - (2**256 % bound), then + return x % bound. Fisher-Yates descends i=n-1..1 and swaps with draw(i+1). + No Python random/NumPy algorithm, modulo bias, or seed search is used. + """ + def __init__(self, seed): + _require(type(seed) is int and 0 <= seed < 2**63, "Full 63-bit shuffle seed required") + self.seed = seed + self.counter = 0 + + def below(self, bound): + _require(type(bound) is int and 0 < bound <= 2**256, "Invalid shuffle bound") + limit = 2**256 - (2**256 % bound) + while True: + raw = bytes.fromhex(_sha([SHUFFLE_VERSION, self.seed, self.counter])) + self.counter += 1 + value = int.from_bytes(raw, "big") + if value < limit: + return value % bound + + def shuffled(self, values): + result = list(values) + for i in range(len(result) - 1, 0, -1): + j = self.below(i + 1) + result[i], result[j] = result[j], result[i] + return result + + +def _stream_id(master, split, key): + return split + ":" + _sha([SCHEMA, "stream-id", master, split, list(key)]) + + +def _block_id(mode, dan, reinforced, replicate): + return f"{mode}__{dan}__reinforce_{reinforced}__rep{replicate:02d}" + + +def _input_key(block, assay, cue, occurrence, arm): + return (block, assay, "CS", cue, f"{occurrence:02d}", arm) + + +def _cs_binding(registry, block, assay, cue, occurrence, arm, base_arm): + key = _input_key(block, assay, cue, occurrence, arm) + source_key = _input_key(block, assay, cue, occurrence, base_arm) + if arm == base_arm: + seed = registry.add("training", key) + else: + seed = registry.alias("training", key, "training", source_key, + reason="Same block/assay/CS cue/occurrence; named counterfactual control") + return {"cue": cue, "odor": ODORS[cue], "occurrence": occurrence, + "stream_id": _stream_id(registry.master, "training", key), + "source_stream_id": _stream_id(registry.master, "training", source_key), + "seed": seed, "seed_bits": 63, "seed_conversion": "exact integer; no truncation or addition", + "role": "CS", "start_tick": 0, "stop_tick_exclusive": 1000} + + +def _ordered_cues(seed): + counts = {"A": 0, "B": 0} + result = [] + for cue in _Shuffle(seed).shuffled(["A"] * 50 + ["B"] * 50): + result.append((cue, counts[cue])) + counts[cue] += 1 + return result + + +def _epoch(registry, block, assay, arm, ordinal, cue, occurrence, base_arm, + us_ordinal, yoked_cue=None, yoked_occurrence=None): + delayed = assay == "delayed" + steps, us_tick = (1800, 1500) if delayed else (1200, 900) + requested = us_ordinal is not None + cs = None if cue is None else _cs_binding(registry, block, assay, cue, occurrence, arm, base_arm) + return { + "epoch_id": f"{block}/{assay}/{arm}/{ordinal:03d}", + "epoch_index": ordinal, "steps": steps, "duration_ms": steps // 10, + "initial_neural_state": "frozen_reset_including_KC_trace_and_both_delay_queues", + "cs": cs, + "us": {"requested": requested, + "inventory_id": None if not requested else f"{block}/{assay}/US/{us_ordinal:02d}", + "target_binding": None if not requested else block + "/confirmed-DAN-targets", + "tick": us_tick if requested else None, + "nominal_slot_tick": us_tick, + "request_rule": "fixed_single_pulse_all_bound_DAN_targets" if requested else "none", + "rng_seed": None, "rng_draws": 0}, + "D_command": 1.0 if requested and arm != "d_zero" else 0.0, + "D_definition": "scheduled artificial scalar, not multiplied by acceptance", + "update_enabled": arm != "frozen", + "update_schedule": "epoch_boundary_only", + "yoked_cue_removed": yoked_cue, + "yoked_occurrence_removed": yoked_occurrence, + } + + +def _suite(registry, block, assay, reinforced): + base_arm = "reversal" if assay == "reversal" else "paired" + order_key = (block, assay, "cue-order") + order_seed = registry.add("design", order_key) + ordered = _ordered_cues(order_seed) + designs = [{"purpose": "cue-order", "stream_id": _stream_id(registry.master, "design", order_key), + "seed": order_seed, "algorithm": SHUFFLE_VERSION}] + random_sets = None + placements = None + if assay != "reversal": + key = (block, assay, "random-reward-placement") + seed = registry.add("design", key) + shuffle = _Shuffle(seed) + random_sets = {cue: sorted(shuffle.shuffled(range(50))[:25]) for cue in CUES} + designs.append({"purpose": "random-reward-placement", "stream_id": _stream_id(registry.master, "design", key), + "seed": seed, "algorithm": SHUFFLE_VERSION}) + key = (block, assay, "unpaired-US-placement") + seed = registry.add("design", key) + placements = _Shuffle(seed).shuffled(["CS"] * 100 + ["US"] * 50) + designs.append({"purpose": "unpaired-US-placement", "stream_id": _stream_id(registry.master, "design", key), + "seed": seed, "algorithm": SHUFFLE_VERSION}) + + arms = [] + for arm in REVERSAL_ARMS if assay == "reversal" else ARMS: + events = [] + arm_reinforced = ("B" if reinforced == "A" else "A") if arm == "reversal" else reinforced + if arm == "unpaired": + cursor, us_cursor = 0, 0 + for token in placements: + if token == "CS": + cue, occurrence = ordered[cursor] + cursor += 1 + events.append(_epoch(registry, block, assay, arm, len(events), cue, occurrence, base_arm, None)) + else: + events.append(_epoch(registry, block, assay, arm, len(events), None, None, base_arm, us_cursor)) + us_cursor += 1 + else: + reward_us_ids = None + if arm == "random_reward": + reward_us_ids = {(cue, occurrence): offset + i + for cue, offset in (("A", 0), ("B", 25)) + for i, occurrence in enumerate(random_sets[cue])} + for cue, occurrence in ordered: + if arm == "random_reward": + us_ordinal = reward_us_ids.get((cue, occurrence)) + elif arm == "cs_only": + us_ordinal = None + else: + us_ordinal = occurrence if cue == arm_reinforced else None + if arm == "dan_only": + events.append(_epoch(registry, block, assay, arm, len(events), None, None, base_arm, + us_ordinal, yoked_cue=cue, yoked_occurrence=occurrence)) + else: + events.append(_epoch(registry, block, assay, arm, len(events), cue, occurrence, base_arm, us_ordinal)) + arms.append({ + "arm": arm, + "delta_start": {"kind": "fork", "source": f"{block}/normal/paired/terminal-delta"} + if assay == "reversal" else {"kind": "zero", "source": None}, + "carry_between_epochs": ["plastic_delta_only"], + "reinforced_cue": arm_reinforced if arm not in ("random_reward", "unpaired", "cs_only", "dan_only") else None, + "random_reward_occurrences": random_sets if arm == "random_reward" else None, + "epochs": events, + }) + return {"assay": assay, "design_streams": designs, "arms": arms} + + +def _assemble(master): + registry = seed_registry.SeedRegistry(master) + blocks = [] + for mode in MODES: + for dan, compartment in DAN_BRANCHES: + for reinforced in CUES: + for replicate in range(1, 6): + block = _block_id(mode, dan, reinforced, replicate) + evaluations = [] + for cue in CUES: + key = _input_key(block, "evaluation", cue, 0, "base-probe") + seed = registry.add("evaluation", key) + evaluations.append({"cue": cue, "odor": ODORS[cue], "role": "CS", "seed": seed, + "seed_bits": 63, "stream_id": _stream_id(master, "evaluation", key), + "use": "same saved input across pre/post/controls; never extra training replicate"}) + blocks.append({ + "block_id": block, "mode": mode, "dan_type": dan, + "compartment_verbatim": compartment, "replicate": replicate, + "initial_reinforced_cue": reinforced, + "initial_neutral_cue": "B" if reinforced == "A" else "A", + "DAN_target_binding": {"binding_id": block + "/confirmed-DAN-targets", + "source": "sealed H exact confirmed mapping; not materialized by this planner", + "required_before_execution": True, "body_ids": None}, + "suites": [_suite(registry, block, assay, reinforced) + for assay in ("normal", "delayed", "reversal")], + "evaluation_streams": evaluations, + "evaluation_checkpoints": list(PROBE_CHECKPOINTS), + "evaluation_policy": "separate future probe inventory required; evaluation exposure not included in epoch budget", + "retention": {"branches": ["normal/paired", "normal/frozen"], + "checkpoint_seconds": [0, 1, 10], "idle_seconds_each": 10, + "state_continuous": True, "probes_fork_and_do_not_advance_idle": True}, + }) + + plan = { + "schema": SCHEMA, "status": "DRAFT-NOT-REGISTERED", "master": master, + "execution_authorized": False, "brain_inputs_generated": False, + "training_executed": False, "shuffle_algorithm": SHUFFLE_VERSION, + "shuffle_specification": _Shuffle.__doc__.strip(), + "input_seed_contract": "full63bit integer; no uint32 truncation, offset, modulo or hidden global RNG", + "consumer_generator_implementation_verified": False, + "fixed_odors": dict(ODORS), "dt_ms": 0.1, + "training_replicates_per_cell": 5, "blocks": blocks, + "alias_policy": "training CS only; same block/assay/cue/occurrence; named controls only", + "scheduled_US_rng": "none; deterministic pulse; design shuffle randomness is separately registered", + } + counts, steps = Counter(), 0 + cs_epochs, us_epochs, effective_updates = 0, 0, 0 + for block in blocks: + for suite in block["suites"]: + for arm in suite["arms"]: + counts[(suite["assay"], arm["arm"])] += len(arm["epochs"]) + for epoch in arm["epochs"]: + steps += epoch["steps"] + cs_epochs += epoch["cs"] is not None + us_epochs += epoch["us"]["requested"] + effective_updates += epoch["update_enabled"] and epoch["D_command"] != 0 + budget = { + "blocks": 40, "training_replicates_per_cell": 5, + "epoch_counts_by_assay_arm": [{"assay": a, "arm": b, "epochs": count} + for (a, b), count in sorted(counts.items())], + "training_control_reversal_epochs": sum(counts.values()), + "CS_epochs": cs_epochs, "US_request_epochs": us_epochs, + "enabled_nonzero_D_update_opportunities": effective_updates, + "training_control_reversal_steps": steps, + "training_control_reversal_biological_seconds": steps // 10000, + "retention_idle_seconds": 40 * 2 * 10, + "retention_idle_steps": 40 * 2 * 10 * 10000, + "minimum_biological_seconds_excluding_evaluation": steps // 10000 + 800, + "minimum_steps_excluding_evaluation": steps + 8000000, + "evaluation_epochs": None, "evaluation_biological_seconds": None, + "evaluation_probe_inventory_complete": False, + "excludes": ["evaluation probes", "implementation validation", "frozen regressions", "I/O and encoding wall time"], + "reason_not_total_runtime": "Only training/control/reversal and continuous retention idle are budgeted; never label this complete total exposure", + } + _require(budget["training_control_reversal_epochs"] == 68000 and steps == 99600000, + "Internal protocol inventory arithmetic changed") + result = {"plan": plan, "seed_manifest": registry.manifest(), "budget": budget} + result["payload_sha256"] = _sha(result) + return result + + +def _validate_alias_permissions(manifest): + for row in manifest["aliases"]: + key, source = row["key"], row["source_key"] + _require(row["split"] == row["source_split"] == "training", + "Only named training CS controls may alias streams") + _require(len(key) == len(source) == 6 and key[2] == source[2] == "CS", + "CS/US/design/evaluation role aliasing is forbidden") + _require(key[:5] == source[:5], "Alias crosses block/replicate/assay/cue/occurrence") + if key[1] in ("normal", "delayed"): + _require(source[5] == "paired" and key[5] in CONTROL_ALIASES, + "Unknown normal/delayed control alias") + else: + _require(key[1] == "reversal" and source[5] == "reversal" and key[5] == "no_reversal", + "Unknown reversal control alias") + + +def validate_trial_plan(payload): + """Fail closed on incomplete coverage, illegal aliasing, mutation or types. + + Rebuild the whole fixed 40-block schedule and every RNG derivation. This + does not trust a count, a caller's declared sha, or a row labeled replicate. + Canonical comparison distinguishes Boolean/integer/floating-point JSON. + """ + _require(type(payload) is dict and set(payload) == {"plan", "seed_manifest", "budget", "payload_sha256"}, + "Invalid complete trial-plan envelope") + actual = {key: value for key, value in payload.items() if key != "payload_sha256"} + _require(type(payload["payload_sha256"]) is str and payload["payload_sha256"] == _sha(actual), + "Actual trial-plan payload SHA mismatch") + seed_registry.validate_manifest(payload["seed_manifest"]) + _validate_alias_permissions(payload["seed_manifest"]) + _require(payload["plan"]["master"] == payload["seed_manifest"]["master"], "Master identities differ") + expected = _assemble(payload["plan"]["master"]) + _require(_canonical(payload) == _canonical(expected), + "Full protocol coverage/input binding/order/type reconstruction mismatch") + return True + + +def build_trial_plan(master): + """Return a deterministic JSON-serializable DRAFT inventory without I/O.""" + return _assemble(master)