diff --git a/docs/handoff/2026-08-04-evgap-01-surface-localization-contract.zh-CN.md b/docs/handoff/2026-08-04-evgap-01-surface-localization-contract.zh-CN.md new file mode 100644 index 0000000..7946835 --- /dev/null +++ b/docs/handoff/2026-08-04-evgap-01-surface-localization-contract.zh-CN.md @@ -0,0 +1,189 @@ +# Handoff:EVGAP-01 target surface localization 证据抽取契约 + +- 日期:`2026-08-04` +- 任务分支:`task_20260804_evgap-01-surface-localization-contract` +- 基线:`main` @ `cd0e041` +- 前置:PR #57、PR #58,均已 `APPROVE` 并合并 +- 交付物类型:**contract-only** +- 外部运行:**无。没有执行抽取,没有产生任何证据、判定或候选。** +- 授权范围:**只冻结抽取边界;抽取本身尚未获授权(待 `SRCADM-01`);不授权执行 Level 01** +- 架构变更:`NO_ARCHITECTURE_CHANGE`(依据是 diff 范围,可由 `git diff --stat` 核验) +- 审核状态:等待 ChatGPT `APPROVE`。**本 PR 不适用 `AGENTS.md`「审核豁免」。** + +## 一、本次范围 + +人类负责人指示继续起 `EVGAP-01` 契约。`EVGAP-01` 是 PR #58 登记的两个缺口之一,阻断 `LOCK-01`:已批准证据层没有蛋白层面的质膜定位与细胞外结构域证据,导致 41 个靶点全部 DEFER、`eligible = 0`、Level 01 无法录入任何 pair。 + +本次交付该缺口的抽取契约。**不执行抽取。** + +## 二、关键发现:所需数据已在本地,但那个数据库从未被批准 + +`DATA/1.Databases/ADC_surfaceome_reference/processed/v0.3.0` 恰好覆盖三项 RQ 要求。但治理状态必须先讲清楚: + +> **该数据库从未被审核批准。** 仓库内 `logs/chatgpt-review-*.md` 无任何一条提及 surfaceome;worklog 唯一提及是 2026-08-01 的 mock 运行。已获批的证据抽取(PR #31)在 `source_manifest.json` 中声明的来源是 `ADC_internalization_reference`,**没有接入本库**。 + +这解释了一件之前没解释的事:**为什么已批准层只有跨膜段注释。** 不是数据不存在,是当时没接。 + +初稿据此请求把这一个版本纳入已批准来源。 + +> **本段与下一小节已被第十节阻断 1 推翻。** 派生数据库不能靠自声明 + 哈希纳入;admission 已剥离为独立依赖项 `SRCADM-01`,本契约只引用不批准。原文保留以对照裁决前后差异。 + +### 主张纳入它的核心理由 + +该库 `build_manifest.json` 的 `consensus_semantics` 已经写死了本仓库反复要求的守卫,其中两条正是前几轮审核的阻断本身: + +- `membrane_topology_is_independent_surface_localization: false` ← **PR #58 第二轮阻断** +- `absence_is_negative_evidence: false` ← 缺失一律 DEFER +- `generic_membrane_is_surface_confirmation: false`、`cci_receptor_role_is_surface_confirmation: false`、`tumor_ihc_is_surface_density: false` +- 排除 `GSE160572_MM_surfaceome.csv.gz`,理由是它是 RNA FPKM 而非蛋白测量 ← RNA 规则 + +它还自设 `full_t7_gate_confidence_cap: 0.55`,并声明自己不建立 malignant-cell positive fraction、isoform usage、calibrated treatment stability 或 ADC accessibility——即它自己就划开了 Level 02 边界。 + +守卫是构建时写死的,不是本契约事后附加的。这是主张纳入它、而不是另起一次网络抽取的核心理由。 + +> **更正(第十节阻断 1)**:以上六条现一律标记 `status: claim_pending_audit`。它们是主张 admission 的**理由**,不是 admission 已完成的**证据**——库自己声明遵守某条规则,不等于已验证它确实遵守。 + +## 三、仓库内交付了什么 + +| 文件 | 作用 | +|---|---| +| `docs/tasks/EVGAP_01_SURFACE_LOCALIZATION_EXTRACTION_CONTRACT.zh-CN.md` | 抽取契约(面向操作者,中文) | +| `docs/pools/evgap_01_surface_localization_extraction.yaml` | 机器可读绑定:来源依赖项 `SRCADM-01`+完整性校验和、字段白名单、禁读清单、RQ 映射、求值优先级、五条判据规则、输出 schema 与 15 条验证 | +| `tests/test_evgap_01_surface_localization.py` | 26 项校验 | + +## 四、确定性映射与实测结果 + +`RQ-01` 用 `independent_evidence_family_count ≥ 2`。该库的三个独立家族是 `curated_knowledge`/`imaging`/`cell_surface_capture_ms`,**拓扑与泛膜已被排除在家族计数之外**,所以家族计数在结构上不可能把跨膜段当定位证据。 + +`RQ-02` 有两条路径。**`ECD-b` 是必需的,理由是修正数据表示假象。** UniProt 的 extracellular domain 字段由跨膜蛋白的 TOPO_DOM 推导;GPI 锚定蛋白零跨膜段、无 TOPO_DOM,该字段一律 `false`。只用 `ECD-a` 会让 `CEACAM5`、`MSLN`、`FOLR1`、`MELTF` 因假象落 hold——四者在库中都是 `confirmed_surface`、都带信号肽与 GPI 锚。GPI 锚 + 信号肽 + 零跨膜段在结构上即意味着全长成熟蛋白位于胞外。 + +`LAMP1` 是对照:有跨膜段与信号肽,但结构域朝向溶酶体腔内、无胞外 TOPO_DOM,两条路径都不满足,落 hold。规则自然落在正确一侧,无需特判。 + +| ID | 条件 | outcome | 数量 | +|---|---|---|---| +| `E1-01` | 三项 RQ 全满足且无冲突 | `eligible_surface_target` RETAIN | **22** | +(按冻结的优先级 `E1-05` → `E1-04` → `E1-03` → `E1-02` → `E1-01` 求值,见第十节阻断 4) +| `E1-02` | 独立家族数 < 2 | `possible_surface_target` DEFER | **6** | +| `E1-03` | 两条 ECD 路径都不满足 | `possible_surface_target` DEFER | **3** | +| `E1-04` | `discordance_flags` 非空 | `possible_surface_target` DEFER | **6** | +| `E1-05` | 不在参考库中 | `possible_surface_target` DEFER | **4** | + +22 + 6 + 3 + 6 + 4 = 41,零自由裁量、零排除。`not_surface_target` 与 `identity_unresolved` 仍不可用。 + +## 五、三条必须写进结果报告的发现 + +- **`MF-01`:`GUCY2C` 落 hold。** 只有 `curated_knowledge` 一个独立家族,`consensus_class` 是 `supported_surface` 而非 `confirmed_surface`。**这与此前多模型共识把 GUCY2C 列为首选、以及被隔离运行的 Tier A 选择相反。** 必须原样写出,不得因与既有偏好冲突而弱化。测试会检查 `MF-01` 声称 hold 的靶点确实出现在某条 DEFER 规则的 `measured_targets` 里,防止这条发现被改成空话。 +- **`MF-02`:`eligible` 只是身份与拓扑层面的结论**,不表示在 CRC 肿瘤细胞表面可得——那是 Level 02 的 T7。 +- **`MF-03`:零排除。** hold 不是淘汰,是待证据。 + +## 六、明确没有做什么 + +- **没有执行抽取**,没有产生任何证据行、判定或候选。 +- 没有执行 Level 01,也不授权执行——`EVGAP-02` 仍未解除。 +- 没有读取被禁的四个文件(`tumor_surface_measurement`、`tumor_protein_context`、`treatment_surface_response`、`receptor_evidence`)作为判据;本 handoff 中引用的覆盖率与计数只来自白名单字段。 +- 没有解除 `EVGAP-01` 本身——解除要等抽取执行、结果 PR 获批,再另开 PR 更新输入绑定。 +- 没有引用被隔离运行(PR #53、#54)的任何产物。 +- 没有新增靶点或 clinical context。 +- **没有补 #52/#53/#54/#57/#58 的批准记录**(现在是五份)。事实已查全,未写文件。 +- 没有修 `requirements.txt` 注释里过期的「207 tests」(实测 309)。属无关改动。 + +## 七、验证结果 + +- `Ran 309 tests` 全部通过(`main` 基线 283 + 本次新增 26)。 +- `scripts/verify_repository_boundary.sh`:`Repository boundary check passed.` +- `git diff --check`:通过;零 `__pycache__`。 +- 所有计数由脚本读取外部数据库实测,非估计:41 靶点中 37 覆盖 / 4 未覆盖;`consensus_class` 为 29 `confirmed_surface` / 7 `supported_surface` / 1 `no_surface_support`;独立家族数分布 3 家族 10 个、2 家族 19 个、1 家族 7 个、0 家族 1 个;`discordance_flags` 非空 6 个;最终 22 eligible(`ECD-a` 18 + `ECD-b` 4)/ 19 hold / 0 killed。 +- 四个数据库文件的 SHA-256 由 `shasum -a 256` 实算。 + +## 八、后续顺序 + +1. 本契约 `APPROVE`。 +2. **`SRCADM-01` 独立 source admission PR** 审计 `AUD-01`..`AUD-09` → `APPROVE`,在 `logs/` 留记录。 +3. 把 `admission_record_ref` 指向该记录,执行抽取 → 结果 PR → `APPROVE`。 +4. 另开 PR 更新 `adc_pool_level_01_input_binding.yaml`,绑定抽取产物并解除 `EVGAP-01`。 +5. `EVGAP-02` 需其独立契约。**两个缺口都解除后,Level 01 才能执行。** + +## 九、当前阻断 + +- 本契约获 `APPROVE` 前,不得执行抽取。 +- **获 `APPROVE` 后仍不得执行抽取**,直到 `SRCADM-01` 取得独立 `APPROVE` 记录(`VAL-E13`)。 +- 抽取完成也**不**解除 `EVGAP-02`,Level 01 仍不可执行。 +- 本仓库不得写入证据、候选、快照、cache、result 或 weights。 + +## 十、第一轮审核裁决与修订(`REQUEST_CHANGES`,四条阻断全部接受) + +ChatGPT 对 PR #59(HEAD `570562c`)返回 `REQUEST_CHANGES`。四条阻断**全部接受**,已在同一 PR 内做最小修订。 + +### 阻断 1(接受):未批准数据库不能靠自声明升级为 approved source + +`ADC_surfaceome_reference@0.3.0` 是**派生数据库**,不是原始公开数据源。初稿只记录了 snapshot、SHA-256、builder 路径与该库自己的语义守卫,而测试明确不读外部数据库,因此实际上**没有审计** builder 实现、raw manifest、原始来源清单、license、evidence family 是否真正独立、去重与冲突处理、代表性行回溯、snapshot 可重建性。 + +审核方的判断是对的:**该库自己声明 `membrane_topology_is_independent_surface_localization = false`,不等于它确实遵守这条规则。** 我把「它的语义写得对」当成了「它已被验证」。 + +修订:把 source admission 从本契约剥离,改为依赖项 `SRCADM-01`——`admission_status: pending_separate_admission_pr`、`admission_record_ref: null`(本契约不得代填)、`authorises_extraction_run: false`、`extraction_blocked_by: [SRCADM-01]`。登记该 admission PR 必须逐项审计的 `AUD-01`..`AUD-09`(builder 与版本、raw manifest 对应、来源清单与 release、license 与再分发、family 独立性、去重逻辑、discordance 生成、代表性靶点回溯、可复现构建)。六条自声明守卫一律标 `status: claim_pending_audit`——它们是主张 admission 的理由,不是 admission 已完成的证据。四个 SHA-256 保留但角色降为 `files_pinned_for_integrity_only`。新增 `VAL-E13`:抽取前 `admission_record_ref` 必须指向实际存在的独立 `APPROVE` 记录,为空即不得执行。 + +### 阻断 2(接受):RQ-02 路径计数与 E1-02 自相矛盾 + +初稿写 `ECD-a measured_count = 18`、`ECD-b = 4`,并让测试强制 18 + 4 = 22 eligible。但 `E1-02` 的 6 个靶点是「`RQ-02` 满足但家族数 < 2」——它们必然也命中某条 ECD 路径。**我把「满足 ECD 路径」错等于「最终 eligible」。** + +实测后修订为两个分开的计数:`ECD-a` 路径命中 **30**、其中最终 eligible **18**;`ECD-b` 路径命中 **4**、其中 eligible **4**;两条路径无重叠。并写入分解恒等式:**34 个 RQ-02 阳性 = 22 eligible + 6 `E1-02` + 6 `E1-04` 中 RQ-02 阳性者**。新增 `VAL-E12` 与两条测试:eligible 不得超过路径命中数;`eligible_via_path` 之和必须等于 `E1-01`,而路径命中之和减去重叠必须等于 RQ-02 阳性总数。 + +### 阻断 3(接受):VAL-E05 与 reference-absent 靶点冲突 + +`E1-05` 的 4 个靶点不在参考库中,`source_evidence.tsv` 里本就没有它们的行,而初稿的 `VAL-E05` 要求**每行**都有非空 `source_ids`/`source_releases`/`source_urls`/`licenses`。后果正如审核方所说:合法的 hold 行无法通过验证,或迫使执行者伪造 provenance。 + +修订:provenance 拆成两类。覆盖行须有完整来源 provenance;未覆盖行的 `source_*` 允许为空,但必须有完整**缺失 provenance**——`reference_dataset_id`、`reference_dataset_version`、`reference_snapshot_id`、`target_axis_ref`、`absence_reason`(只能取 `gene_symbol_not_present_in_reference`)、`lookup_at`。新增 `VAL-E05b`/`VAL-E05c`:**禁止伪造 source evidence,禁止把缺失表述为 source-supported**——`provenance_kind = reference_absent` 的行若出现非空 `source_ids` 即为验证失败。输出列 21 → 26,新增 `provenance_kind`、`absence_reason`、`target_axis_ref`、`lookup_at`。 + +### 阻断 4(接受):五条规则没有冻结优先级 + +条件并非天然互斥,而 `VAL-E01` 要求「命中且仅命中一条」。实测本 snapshot 下有 **2 个**靶点同时满足 `E1-03` 与 `E1-02`:`TM4SF1`、`TDGF1`。初稿只验证了预写计数之和等于 41,**没有证明真实条件下的 one-and-only-one**。 + +修订:冻结 `derivation_precedence` = `E1-05` → `E1-04` → `E1-03` → `E1-02` → `E1-01`,并写明理由;登记 `measured_multi_condition_targets`,两个靶点解析到 `E1-03`。新增 `VAL-E11`。测试新增三项:优先级覆盖全部规则且首尾正确;用等价 fixture 逐例证明九种条件组合(含 absent+conflict、conflict+low-family、conflict+no-ECD、no-ECD+low-family)各有唯一结果;重叠靶点必须落在优先级更高的规则、且**不得被计入被压制的那条规则的 `measured_targets`**。 + +**计数未变**:按冻结后的优先级重算仍是 22/6/3/6/4,与初稿一致——因为初稿的计算脚本已隐含同一顺序,只是没把顺序写进契约。 + +### 本轮变异检验 + +12 个变异全部被捕获后精确回滚,与备份 `diff -q` 一致、测试恢复 `OK`:自行填入 admission 记录并放行、把自声明守卫标为已验证、删掉 license 审计项、让 eligible 超过路径命中数、用 eligible 冒充路径命中数、破坏分解恒等式、要求未覆盖行也有 source 字段、允许伪造 source evidence、删掉 `absence_reason` 要求、把 `E1-01` 提到优先级最前、把冲突优先级降到最后、让重叠靶点解析到被压制的规则。 + +### 审核方认可、本轮未改动的部分 + +`ECD-b` 路径(在 GPI 注释可靠、同时有信号肽、零跨膜段与蛋白级 provenance 的前提下)可作为 extracellular topology 路径;跨膜段不再单独满足 `RQ-01`;GPI 路径没被当成普通跨膜蛋白规则;`LAMP1` 的腔内结构域不被当成胞外;缺失与冲突均 DEFER 不 EXCLUDE;`not_surface_target` 仍不可用;T7 与肿瘤定量文件明确禁止;不执行 Level 01;不新增 target/context;仓库不存 evidence/result;`EVGAP-02` 继续未解除。 + +### 审核回写状态 + +审核方尝试通过 GitHub 连接器提交正式 `REQUEST_CHANGES` review,连接器返回 403,未写回 GitHub。裁决以人类负责人转述为准,已记录于本节与 `logs/worklog.md`。 + +## 十一、第二轮审核裁决与修订(`REQUEST_CHANGES`,两条阻断全部接受) + +ChatGPT 对 PR #59(HEAD `f236287`)返回 `REQUEST_CHANGES`。上一轮四条确认已基本修复;本轮两条**契约缺口**全部接受。两条都是我留下的洞。 + +### 阻断 1(接受):covered target 的 RQ-03 缺失没有对应 derivation rule + +审核方构造的组合是对的:一个在库中的靶点,`RQ-01` 满足、`RQ-02` 满足、无 discordance,但 `source_evidence.tsv` 字段不全导致 `RQ-03` 不满足——它不命中 `E1-01`(RQ-03 未满足)、不命中 `E1-02`(家族数不低)、不命中 `E1-03`(RQ-02 满足)、不命中 `E1-04`(无冲突)、不命中 `E1-05`(在库中)。**`VAL-E01` 的「恰好命中一条」因此无从满足**,而 `VAL-E05` 只写「缺失即该行降为 hold」,没说降到哪条规则,也没有对应 `rule_id`。 + +修订:新增 `E1-04b`——「在库中但 `RQ-03` provenance 不成立」→ `possible_surface_target` DEFER `hold`。插入优先级第三位:`E1-05` → `E1-04` → **`E1-04b`** → `E1-03` → `E1-02` → `E1-01`。理由写入契约:provenance 不成立时该行证据本身不可引用,再谈拓扑与家族数没有意义,所以排在两个 RQ 判据之前、冲突之后。其 disposition 只能是 DEFER——既不得 RETAIN(无可回溯来源),也不得 EXCLUDE(缺 provenance 不是否定证据)。 + +**实测 37 个覆盖靶点全部满足 `RQ-03`**,故 `E1-04b` 在本 snapshot 下 `expected_count: 0`、`vacuous_this_run: true`,**计数不变**(22/6/3/6/0/4 = 41)。但它必须存在:provenance 完整性不由本契约保证,抽取时可能失败。 + +测试新增按验收标准的组合:`RQ-01=true, RQ-02=true, RQ-03=false` → `E1-04b`;`discordance=true 且 RQ-03=false` → `E1-04`(冲突优先);`RQ-02=false 且 RQ-03=false` → `E1-04b`;共 13 种组合逐例证明恰好命中一条,且 provenance 缺失只能 DEFER。 + +### 阻断 2(接受):`VAL-E05b` 要求的字段没有全部进入 output schema + +`VAL-E05b` 要求六列,而 `per_target_columns` 初稿只加了 `absence_reason`、`target_axis_ref`、`lookup_at` 三列,缺 `reference_dataset_id`、`reference_dataset_version`、`reference_snapshot_id`。**执行者无法同时遵守 output schema 与 validation rule。** 这是我上一轮补 blocker 3 时漏改的。 + +修订:三列补入 `per_target_columns`(21 → 26 → **29** 列)。新增 `conditionally_required_columns` 明确条件必填——`provenance_kind = reference_absent` 时六列必填、`source_*` 可空;`provenance_kind = source_supported` 时 `source_*` 必填、这六列可空。并加 `pinned_to_admission_snapshot`:三列必须分别等于 `ADC_surfaceome_reference`/`0.3.0`/`2026-07-29-quant-topology-mm`,不得自由填写;新增 `VAL-E05d` 强制。 + +测试直接断言 `required_absence_fields ⊆ per_target_columns`,并断言 pinned 值与 `source_admission_dependency` 的 `dataset_id`/`dataset_version`/`snapshot_id` 逐项相等——admission 版本一变,pinned 值不同步就会失败。 + +### 本轮变异检验 + +8 个变异全部被捕获后精确回滚,与备份 `diff -q` 一致、测试恢复 `OK`:从优先级里删掉 `E1-04b`、把 `E1-04b` 改判 RETAIN、把 `E1-04b` 排到 `E1-01` 之后、让 RQ-03 不指向失败规则、删掉 `reference_dataset_id` 列、让 pinned 值与 admission 不符、让 `source_supported` 不要求来源字段、谎报 RQ-03 有失败靶点。 + +### 审核方认可、本轮未改动的部分 + +`AUD-01`..`AUD-09` 足以覆盖 builder、raw manifest、license、family independence、去重、discordance、行级 provenance 与重建;`admission_record_ref = null` 时不得执行抽取;自声明守卫仅作 pending claim;RQ-02 分解自洽(34 = 22 + 6 + 6);`ECD-b` 路径合理;reference-absent 靶点不再被迫伪造 source evidence;precedence 已解决 `TM4SF1`/`TDGF1` 的多条件命中;不执行 Level 01;不评估 T7;不新增 target/context;不读取被禁文件;`EVGAP-02` 仍未解除;仓库内无 evidence 或结果数据。 + +### 审核回写状态 + +审核方尝试通过 GitHub 连接器提交正式 `REQUEST_CHANGES` review,连接器返回 403,未写回 GitHub。裁决以人类负责人转述为准,已记录于本节与 `logs/worklog.md`。 diff --git a/docs/pools/evgap_01_surface_localization_extraction.yaml b/docs/pools/evgap_01_surface_localization_extraction.yaml new file mode 100644 index 0000000..d5da347 --- /dev/null +++ b/docs/pools/evgap_01_surface_localization_extraction.yaml @@ -0,0 +1,535 @@ +# EVGAP-01:target surface localization 证据抽取绑定(machine-readable) +# +# 目的:解除 PR #58 登记的 EVGAP-01,使 Level 01 的 LOCK-01 可以真正产出 +# eligible_surface_target。本文件不含任何证据数据,只含来源身份、字段白名单、 +# 到 RQ 要求的映射、输出 schema 与验证规则。 +# +# 由 tests/test_evgap_01_surface_localization.py 校验。 +# LOCK-01 的 RQ-01/RQ-02/RQ-03 定义见 docs/pools/adc_pool_level_01_input_binding.yaml。 + +extraction: + extraction_id: evgap_01_surface_localization + extraction_version: 0.1.0 + discharges_gap: EVGAP-01 + gap_source: docs/pools/adc_pool_level_01_input_binding.yaml + serves_lock: LOCK-01 + status: contract_only_pending_review + execution_status: not_authorized_not_executed + # 本契约冻结抽取边界,但**尚不授权执行抽取**:所依赖的数据库 snapshot + # 必须先通过独立的 source admission PR 取得自己的 APPROVE 记录。见 + # source_admission_dependency。 + authorises_extraction_run: false + authorises_level_01_execution: false + extraction_blocked_by: + - SRCADM-01 + # 抽取完成并经结果 PR 接受后,需再开一个 PR 更新 Level 01 输入绑定以解除 EVGAP-01。 + requires_followup_binding_pr: true + +# 依赖的数据库 snapshot。**本契约不批准它。** +# +# 该库是派生数据库,不是原始公开数据源。哈希与它自己的语义声明只能证明 +# 「读到的就是这个版本」,不能证明该版本确实遵守它所声明的规则。 +# 因此纳入已批准来源必须由独立的 source admission PR 完成,本契约只引用其结论。 +source_admission_dependency: + id: SRCADM-01 + source_id: adc_surfaceome_reference_v0_3_0 + dataset_id: ADC_surfaceome_reference + dataset_version: 0.3.0 + snapshot_id: 2026-07-29-quant-topology-mm + root: external:databases/ADC_surfaceome_reference/processed/v0.3.0 + is_derived_database: true + previously_approved: false + previously_approved_evidence: 仓库内 logs/chatgpt-review-*.md 无任何一条提及 surfaceome;worklog 仅有一次 2026-08-01 mock 运行提及。 + admission_status: pending_separate_admission_pr + admission_record_ref: null + admission_record_required: true + # 抽取执行前必须存在的独立 APPROVE 记录路径形态;本契约不得代填。 + admission_record_path_pattern: logs/chatgpt-review--adc-surfaceome-reference-v0-3-0-admission-final.md + # 该 admission PR 必须逐项审计以下内容。哈希与自声明不在其列。 + required_audit_items: + - id: AUD-01 + item: builder 实现与版本(AssetGenOS/scripts/build_t7_surfaceome_reference.py@0.3.0) + - id: AUD-02 + item: raw manifest 与 raw_manifest_sha256 884f4191… 的对应关系 + - id: AUD-03 + item: 原始来源清单与各自的 release 版本 + - id: AUD-04 + item: 每个原始来源的 license 与再分发条件 + - id: AUD-05 + item: independent_evidence_family 的独立性是否真实成立,而非同源数据的重复计数 + - id: AUD-06 + item: 来源去重逻辑 + - id: AUD-07 + item: discordance_flags 的生成规则与覆盖范围 + - id: AUD-08 + item: 代表性靶点的逐行来源回溯 + - id: AUD-09 + item: snapshot 是否可由 builder 与 raw manifest 复现 + # 自声明的语义守卫在 admission 中只能作为待验证项,不能作为已验证结论。 + self_declared_guards_are_claims_not_verification: true + self_declared_guards: + - guard: absence_is_negative_evidence + declared_value: false + status: claim_pending_audit + relates_to_repo_rule: 缺失证据一律 DEFER,永不 EXCLUDE + - guard: membrane_topology_is_independent_surface_localization + declared_value: false + status: claim_pending_audit + relates_to_repo_rule: PR #58 第二轮阻断——跨膜段不足以判定细胞外可及 + - guard: generic_membrane_is_surface_confirmation + declared_value: false + status: claim_pending_audit + relates_to_repo_rule: 泛膜注释不等于质膜定位 + - guard: cci_receptor_role_is_surface_confirmation + declared_value: false + status: claim_pending_audit + relates_to_repo_rule: 受体角色推断不等于定位证据 + - guard: tumor_ihc_is_surface_density + declared_value: false + status: claim_pending_audit + relates_to_repo_rule: 表达强度不等于表面密度,属 Level 02 T7 + - guard: excluded_rna_source + declared_value: GSE160572_MM_surfaceome.csv.gz 因其为 RNA FPKM 而被排除 + status: claim_pending_audit + relates_to_repo_rule: RNA 不得当作蛋白层面验证,无例外 + # 抽取执行时仍须校验这些校验和;它们是版本固定手段,不是批准依据。 + files_pinned_for_integrity_only: + - path: surfaceome_consensus.tsv + sha256: c7baa2ac86e1d173ef555e92036c2841efb5d106c4931bcf71507fbd5cbc1e7b + rows: 20797 + - path: membrane_topology_evidence.tsv + sha256: d2680b961793f8ed83decbc92cdf04d5818f4cdbcb1e241c0ef34c4f06e77eca + rows: 4863 + - path: source_evidence.tsv + sha256: a61222f03f2763af7733793d0bc85e19185515683a8fa1956d1559667cd662ab + rows: 41204 + - path: build_manifest.json + sha256: 9872dacd2f30532364576f29ce8c7428a8df9c6bc5abea408472bb3f79069fe2 + +# 抽取范围:只处理已批准枚举里的 41 个靶点,不新增任何靶点。 +scope: + target_axis_source: external:result/gen_iet_crc_target_enumeration_20260802 + target_axis_file: target_evidence_catalog.tsv + target_axis_sha256: 27bb81ebe5f1048b732f62c530095e0007212f818f3844e004118bb94525cc28 + target_count: 41 + join_key: gene_symbol + new_targets_allowed: false + measured_coverage_in_reference: 37 + measured_absent_from_reference: 4 + measured_absent_targets: + - AG7 + - CA19-9 + - EDBN + - Undisclosed + +# 只允许读取这些字段。白名单之外一律不得进入推导,防止把 Level 02 的量化 +# 与肿瘤上下文数据洗进 LOCK-01。 +allowed_fields: + from_surfaceome_consensus: + - gene_symbol + - uniprot_accessions + - uniprot_signal_peptide + - uniprot_gpi_anchor + - uniprot_transmembrane_segment_count + - uniprot_extracellular_domain + - uniprot_extracellular_domain_count + - uniprot_extracellular_max_length_aa + - uniprot_ecd_meets_min_length + - independent_evidence_family_count + - independent_evidence_families + - consensus_class + - discordance_flags + - dataset_version + - snapshot_id + - row_checksum + from_source_evidence: + - gene_symbol + - source_id + - source_release + - evidence_family + - evidence_kind + - evidence_codes + - source_url + - license + +# 明确禁止读取的文件与字段:它们属 Level 02 / T7,读入即重演 PR #58 的越界。 +barred_files: + - path: tumor_surface_measurement.tsv + reason: 肿瘤表面定量属 Level 02 T7,不属 LOCK-01。 + - path: tumor_protein_context.tsv + reason: 肿瘤上下文蛋白组属 Level 02 T7。 + - path: treatment_surface_response.tsv + reason: 治疗后表面变化属 T5/T7,Level 01 不评估。 + - path: receptor_evidence.tsv + reason: 受体角色推断不构成定位证据(该库自身亦声明 cci_receptor_role_is_surface_confirmation=false)。 +barred_fields: + - field: cci_receptor_role + reason: 受体角色不是定位证据。 + - field: uniprot_generic_membrane + reason: 泛膜注释不等于质膜定位。 + - field: full_t7_gate_confidence_cap + reason: T7 置信上限属 Level 02,Level 01 不得引用。 + +# RQ-01:质膜定位。要求至少两个独立证据家族。 +# 该库定义的三个独立家族为 curated_knowledge / imaging / cell_surface_capture_ms, +# 且已把拓扑与泛膜排除在家族计数之外,故家族计数天然不会把拓扑当定位。 +rq_01_plasma_membrane_localization: + decisive_field: independent_evidence_family_count + minimum_independent_families: 2 + independent_families: + - curated_knowledge + - imaging + - cell_surface_capture_ms + topology_counts_as_family: false + generic_membrane_counts_as_family: false + rna_may_satisfy: false + +# RQ-02:细胞外结构域。两条合格路径,任一成立即满足。 +rq_02_extracellular_domain: + # 两个计数必须分开:满足某条路径 != 最终 eligible。 + # 满足 RQ-02 的靶点还可能因独立家族数不足或定位证据冲突而落 hold。 + paths: + - path_id: ECD-a + condition: uniprot_ecd_meets_min_length = true + applies_to: 有跨膜段且带 TOPO_DOM Extracellular 注释的蛋白 + measured_path_satisfied_count: 30 + eligible_via_path_count: 18 + # 必需的第二条路径。UniProt 的 extracellular domain 字段由跨膜蛋白的 + # TOPO_DOM 推导,GPI 锚定蛋白零跨膜段、无 TOPO_DOM,故该字段一律为 false。 + # 若只用 ECD-a,CEACAM5、MSLN、FOLR1、MELTF 会因数据表示假象落入 hold, + # 而四者都是 confirmed_surface 且带信号肽与 GPI 锚——成熟蛋白整体位于胞外。 + - path_id: ECD-b + condition: uniprot_gpi_anchor = true 且 uniprot_signal_peptide = true 且 uniprot_transmembrane_segment_count = 0 + applies_to: GPI 锚定、带信号肽、零跨膜段的蛋白,成熟蛋白整体位于胞外 + rationale_note: >- + 这是修正数据表示假象,不是放宽标准:GPI 锚定加信号肽加零跨膜段在结构上 + 即意味着全长成熟蛋白位于细胞外侧,其胞外长度等于成熟蛋白长度。 + measured_path_satisfied_count: 4 + eligible_via_path_count: 4 + measured_targets: + - CEACAM5 + - FOLR1 + - MELTF + - MSLN + paths_are_mutually_exclusive_in_this_snapshot: true + measured_path_overlap: 0 + # RQ-02 阳性总数的分解恒等式。执行结果必须满足它。 + rq_02_positive_decomposition: + total: 34 + final_eligible: 22 + hold_insufficient_localization_families: 6 + hold_discordance: 6 + identity: "|RQ-02 positive| = |E1-01| + |E1-02| + |E1-04 中 RQ-02 阳性者|" + luminal_domain_is_not_extracellular: true + luminal_domain_note: >- + 溶酶体/内体膜蛋白的大结构域朝向腔内而非胞外。LAMP1 即属此类: + 有跨膜段与信号肽,但无胞外 TOPO_DOM,故两条路径都不满足,落 hold。 + 这正是审核方要求的「细胞器膜定位必须 DEFER」。 + +# RQ-03:蛋白层面 provenance。分两类,不得混用。 +rq_03_protein_level_provenance: + rna_derived_rows_admissible: false + # 库中覆盖的靶点:必须有完整的来源 provenance。 + covered_rows: + requires_rows_in: source_evidence.tsv + required_fields: + - source_id + - source_release + - evidence_family + - source_url + - license + # 库中未覆盖的靶点(E1-05):source_evidence.tsv 里本就没有它们的行, + # 因此不得要求来源 provenance——否则合法的 hold 行无法通过验证, + # 或迫使执行者伪造 provenance。改为要求「缺失 provenance」。 + absent_rows: + requires_rows_in: null + source_provenance_fields_may_be_empty: true + fabricating_source_evidence_forbidden: true + required_absence_fields: + - reference_dataset_id + - reference_dataset_version + - reference_snapshot_id + - target_axis_ref + - absence_reason + - lookup_at + absence_reason_allowed_values: + - gene_symbol_not_present_in_reference + never_present_absence_as_source_supported: true + # 覆盖靶点 RQ-03 不成立时的归属规则。 + covered_row_failure_rule: E1-04b + covered_row_failure_disposition: DEFER + measured_covered_targets_satisfying_rq_03: 37 + measured_covered_targets_failing_rq_03: 0 + +# 规则求值优先级。五条规则的条件并非天然互斥——同一靶点可能同时满足 +# 「两条 ECD 路径都不满足」与「独立家族数 < 2」。实测本 snapshot 下有 2 个 +# 这样的靶点(TM4SF1、TDGF1)。因此必须冻结优先级,按此顺序取第一个命中者, +# 每个靶点恰好命中一条规则。 +derivation_precedence: + - E1-05 + - E1-04 + - E1-04b + - E1-03 + - E1-02 + - E1-01 +precedence_rationale: >- + 先判「不在库中」,因为无数据时其余条件都无法求值;再判定位证据冲突, + 因为冲突使后续判据不可信;再判 provenance 是否成立,因为 provenance 不成立时 + 该行的证据本身不可引用,再谈拓扑与家族数没有意义;再判细胞外结构域, + 再判独立家族数;全部通过才 eligible。 +measured_multi_condition_targets: + - gene_symbol: TM4SF1 + conditions_matched: + - E1-03 + - E1-02 + resolved_to: E1-03 + - gene_symbol: TDGF1 + conditions_matched: + - E1-03 + - E1-02 + resolved_to: E1-03 + +# 确定性映射。五条规则覆盖全部 41 个靶点,无自由裁量,零排除。 +derivation_rules: + - id: E1-01 + condition: RQ-01 满足且 RQ-02 任一路径满足且 RQ-03 满足,且 discordance_flags 为空 + lock_01_outcome: eligible_surface_target + disposition: RETAIN + resulting_state: eligible + expected_count: 22 + - id: E1-02 + condition: RQ-02 满足但独立证据家族数 < 2 + lock_01_outcome: possible_surface_target + disposition: DEFER + resulting_state: hold + expected_count: 6 + measured_targets: + - CLDN18 + - GUCY2C + - LGR5 + - PRLR + - RNF43 + - SLC44A4 + - id: E1-03 + condition: 两条 RQ-02 路径都不满足(含细胞器腔内结构域) + lock_01_outcome: possible_surface_target + disposition: DEFER + resulting_state: hold + expected_count: 3 + measured_targets: + - LAMP1 + - TDGF1 + - TM4SF1 + - id: E1-04 + condition: discordance_flags 非空 + lock_01_outcome: possible_surface_target + disposition: DEFER + resulting_state: hold + reason: 定位证据冲突一律 DEFER,不得由抽取者裁决。 + expected_count: 6 + measured_targets: + - CD276 + - F3 + - IL2RA + - MET + - MST1R + - TACSTD2 + # 覆盖靶点的 RQ-03 不成立时必须有归属规则,否则 VAL-E01 的「恰好命中一条」 + # 无从满足。实测本 snapshot 下 37 个覆盖靶点全部满足 RQ-03,故本规则为空规则; + # 但它必须存在,因为 provenance 完整性不由本契约保证,抽取时可能失败。 + - id: E1-04b + condition: 靶点在参考库中,但 RQ-03 provenance 不成立 + lock_01_outcome: possible_surface_target + disposition: DEFER + resulting_state: hold + reason: >- + provenance 不成立时该行证据不可引用,只能 DEFER; + 既不得 RETAIN(无可回溯来源),也不得 EXCLUDE(缺 provenance 不是否定证据)。 + expected_count: 0 + vacuous_this_run: true + vacuous_reason: 实测 37 个覆盖靶点在 source_evidence.tsv 中均有齐备的 source_id/source_release/evidence_family/source_url/license。 + - id: E1-05 + condition: 靶点不在参考库中 + lock_01_outcome: possible_surface_target + disposition: DEFER + resulting_state: hold + reason: 缺失是 DEFER,永不 EXCLUDE;该库自身 absence_is_negative_evidence 亦为 false。 + expected_count: 4 + measured_targets: + - AG7 + - CA19-9 + - EDBN + - Undisclosed + +# not_surface_target 仍然不可用。 +unavailable_outcomes: + - outcome: not_surface_target + reason: >- + 本库明确 absence_is_negative_evidence=false,不产出「该靶点不是表面蛋白」 + 这类阳性否定断言。consensus_class = no_surface_support 只表示无支持证据, + 不等于已证伪。故本次抽取仍不得排除任何靶点。 + - outcome: identity_unresolved + reason: 本库无身份解析结论字段;未覆盖的 4 个靶点一律落 possible_surface_target。 + +# 抽取后的预期结果,逐项可核对。 +predicted_result_shape: + targets_total: 41 + eligible: 22 + hold: 19 + killed: 0 + hold_breakdown: + E1-02_no_independent_localization: 6 + E1-03_no_extracellular_domain: 3 + E1-04_conflict: 6 + E1-05_not_in_reference: 4 + eligible_via_path: + ECD-a: 18 + ECD-b: 4 + # 执行结果必须逐项等于上列数字,任一项不符即视为偏离本契约。 + +# 必须写进结果报告的发现,避免被误读。 +mandatory_findings: + - id: MF-01 + finding: >- + GUCY2C 落 hold。它在本参考库中只有 curated_knowledge 一个独立证据家族, + 未达两家族门槛,consensus_class 为 supported_surface 而非 confirmed_surface。 + 这与此前多模型共识把 GUCY2C 列为首选、以及被隔离运行的 Tier A 选择相反。 + 结果报告必须原样写出,不得因与既有偏好冲突而弱化。 + - id: MF-02 + finding: >- + eligible 只表示身份与拓扑层面存在有合理依据的细胞外可及蛋白形式, + 不表示该靶点在 CRC 肿瘤细胞表面可得——后者是 Level 02 的 T7,本次未评估。 + - id: MF-03 + finding: >- + 零排除。41 个靶点中没有任何一个被判为 not_surface_target。 + hold 不是淘汰,是待证据。 + +output_schema: + # provenance_kind 决定哪一组列必填。 + conditionally_required_columns: + - when_provenance_kind: reference_absent + required_columns: + - reference_dataset_id + - reference_dataset_version + - reference_snapshot_id + - absence_reason + - target_axis_ref + - lookup_at + may_be_empty_columns: + - source_ids + - source_releases + - source_urls + - licenses + # 这三列必须分别等于 admission snapshot 的对应值,不得自由填写。 + pinned_to_admission_snapshot: + reference_dataset_id: ADC_surfaceome_reference + reference_dataset_version: 0.3.0 + reference_snapshot_id: 2026-07-29-quant-topology-mm + - when_provenance_kind: source_supported + required_columns: + - source_ids + - source_releases + - source_urls + - licenses + may_be_empty_columns: + - reference_dataset_id + - reference_dataset_version + - reference_snapshot_id + - absence_reason + - lookup_at + per_target_columns: + - gene_symbol + - uniprot_accessions + - rule_id + - lock_01_outcome + - disposition + - evaluation_status + - rq_01_satisfied + - rq_01_family_count + - rq_01_families + - rq_02_satisfied + - rq_02_path + - rq_03_satisfied + - consensus_class + - discordance_flags + - source_ids + - source_releases + - source_urls + - licenses + - dataset_version + - snapshot_id + - row_checksum + - extracted_at + - provenance_kind + - reference_dataset_id + - reference_dataset_version + - reference_snapshot_id + - absence_reason + - target_axis_ref + - lookup_at + +output_validation: + - id: VAL-E01 + rule: 输出必须恰好 41 行,每行命中且仅命中一条 derivation_rules 规则,并记录 rule_id。 + - id: VAL-E02 + rule: 计数必须等于 predicted_result_shape:22 eligible + 19 hold + 0 killed,且 hold_breakdown 逐项相符。 + - id: VAL-E03 + rule: 输出中不得出现 not_surface_target 或 identity_unresolved。 + - id: VAL-E04 + rule: 每个 eligible 行必须记录 rq_02_path,取值限于 ECD-a 或 ECD-b。 + - id: VAL-E05 + rule: >- + provenance_kind = source_supported 的行必须有非空 source_ids、source_releases、 + source_urls、licenses;缺失即该行降为 hold。 + - id: VAL-E05b + rule: >- + provenance_kind = reference_absent 的行(即 E1-05)允许 source_* 字段为空, + 但必须有完整的缺失 provenance:reference_dataset_id、reference_dataset_version、 + reference_snapshot_id、target_axis_ref、absence_reason、lookup_at。 + absence_reason 只能取 gene_symbol_not_present_in_reference。 + 这六列全部在 output_schema.per_target_columns 之内。 + - id: VAL-E05d + rule: >- + reference_dataset_id、reference_dataset_version、reference_snapshot_id 必须分别 + 等于 admission snapshot 的 ADC_surfaceome_reference、0.3.0、 + 2026-07-29-quant-topology-mm,不得自由填写。 + - id: VAL-E05c + rule: >- + 禁止为未覆盖靶点伪造 source evidence,也禁止把缺失表述为 source-supported: + provenance_kind = reference_absent 的行若出现非空 source_ids 即为验证失败。 + - id: VAL-E06 + rule: 四个输入文件与靶点轴文件的 SHA-256 必须与本契约记录一致,不一致即中止执行。 + - id: VAL-E07 + rule: 不得读取 barred_files 或 barred_fields 中的任何文件或字段。 + - id: VAL-E08 + rule: 输出中不得出现任何 Gate 分数、Gate 状态、T7 判定或肿瘤表面定量。 + - id: VAL-E09 + rule: mandatory_findings 的三条必须原样出现在结果报告中。 + - id: VAL-E10 + rule: 每个产物文件必须逐文件记录 SHA-256 并写入结果 PR 的 handoff。 + - id: VAL-E11 + rule: >- + 规则求值必须按 derivation_precedence 顺序取第一个命中者;每个靶点恰好命中 + 一条规则。同时满足多条条件的靶点必须落在优先级更高的那条,并在结果中 + 记录其余被压制的条件。覆盖靶点若 RQ-03 不成立,必须归入 E1-04b, + 不得因无归属规则而落空。 + - id: VAL-E12 + rule: >- + RQ-02 阳性靶点数必须满足 rq_02_positive_decomposition 的恒等式; + 每条路径必须同时报出 path-satisfied 与 eligible-via-path 两个计数, + 不得只报其一或用后者冒充前者。 + - id: VAL-E13 + rule: >- + 抽取执行前,source_admission_dependency.admission_record_ref 必须指向一份 + 实际存在的独立 APPROVE 记录;为空即不得执行。 + +not_authorised: + - 在 SRCADM-01 取得独立 APPROVE 记录前执行抽取 + - 把 ADC_surfaceome_reference 纳入已批准来源(本契约只引用,不批准) + - 执行 Level 01 + - 评估 T7 或任何肿瘤细胞表面可得性 + - 新增任何靶点或 clinical context + - 任何筛选排序、Tier 划分、资产推荐或实验建议 + - 任何 Gate 执行或评分 + - 读取被禁文件或字段 + - 把被隔离运行(PR #53、#54)的任何产物引入 diff --git a/docs/tasks/EVGAP_01_SURFACE_LOCALIZATION_EXTRACTION_CONTRACT.zh-CN.md b/docs/tasks/EVGAP_01_SURFACE_LOCALIZATION_EXTRACTION_CONTRACT.zh-CN.md new file mode 100644 index 0000000..07bf617 --- /dev/null +++ b/docs/tasks/EVGAP_01_SURFACE_LOCALIZATION_EXTRACTION_CONTRACT.zh-CN.md @@ -0,0 +1,192 @@ +# EVGAP-01:target surface localization 证据抽取契约 + +- 任务分支:`task_20260804_evgap-01-surface-localization-contract` +- 前置工作包:PR #57(Level 01 判据定义)、PR #58(输入绑定与缺口登记),均已 `APPROVE` 并合并 +- 机器可读绑定:[`../pools/evgap_01_surface_localization_extraction.yaml`](../pools/evgap_01_surface_localization_extraction.yaml),由 `tests/test_evgap_01_surface_localization.py` 校验 +- 当前状态:**contract-only,未执行,等待 ChatGPT 审核** +- 授权范围:**只冻结抽取边界。抽取本身尚未获授权**——所依赖的数据库 snapshot 必须先通过独立的 source admission PR 取得自己的 `APPROVE`(`SRCADM-01`)。也不授权执行 Level 01。 + +## 目的 + +解除 PR #58 登记的 `EVGAP-01`,使 Level 01 的 `LOCK-01` 能够真正产出 `eligible_surface_target`。 + +PR #58 的实测结论是:已批准证据层里 `plasma membrane`/`extracellular`/`localization`/`signal peptide`/`GPI` 关键词命中数**均为 0**,只有 `transmembrane_segment_count`,而跨膜拓扑不足以判定细胞外可及。因此 41 个靶点全部落 `possible_surface_target`,`eligible = 0`,Level 01 无法录入任何 pair。 + +本契约冻结一次抽取运行的范围、字段白名单、判据映射与输出验证。**不执行抽取,不执行 Level 01,也不批准所依赖的数据库。** + +## 一、所依赖的数据库不由本契约批准(`SRCADM-01`) + +所需数据**已经存在于本地**:`DATA/1.Databases/ADC_surfaceome_reference/processed/v0.3.0`。它的治理状态是: + +> **该数据库从未被审核批准。** 仓库内 `logs/chatgpt-review-*.md` 没有任何一条提及 surfaceome;`logs/worklog.md` 里唯一的提及是 2026-08-01 一次 mock 运行。已获批的证据抽取(PR #31)在其 `source_manifest.json` 中声明的来源是 `ADC_internalization_reference`,**没有接入本库**——这正是已批准层只有跨膜段注释、没有质膜定位证据的直接原因。 + +**初稿试图在本契约内直接把它升级为 approved source,那是错的,已按审核裁决更正。** 它是**派生数据库**,不是原始公开数据源。哈希与它自己的语义声明只能证明「读到的就是这个版本」,**不能证明该版本确实遵守它所声明的规则**。 + +因此纳入已批准来源改由**独立的 source admission PR** 完成,本契约只引用其结论: + +- `admission_status: pending_separate_admission_pr` +- `admission_record_ref: null`(本契约不得代填) +- `authorises_extraction_run: false`,`extraction_blocked_by: [SRCADM-01]` +- `VAL-E13`:抽取执行前 `admission_record_ref` 必须指向一份实际存在的独立 `APPROVE` 记录,为空即不得执行 + +### 那个 admission PR 必须逐项审计的内容 + +哈希与自声明**不在其列**。 + +| ID | 审计项 | +|---|---| +| `AUD-01` | builder 实现与版本(`build_t7_surfaceome_reference.py@0.3.0`) | +| `AUD-02` | raw manifest 与 `raw_manifest_sha256 884f4191…` 的对应关系 | +| `AUD-03` | 原始来源清单与各自 release 版本 | +| `AUD-04` | 每个原始来源的 license 与再分发条件 | +| `AUD-05` | `independent_evidence_family` 的独立性是否真实成立,而非同源数据的重复计数 | +| `AUD-06` | 来源去重逻辑 | +| `AUD-07` | `discordance_flags` 的生成规则与覆盖范围 | +| `AUD-08` | 代表性靶点的逐行来源回溯 | +| `AUD-09` | snapshot 是否可由 builder 与 raw manifest 复现 | + +### 该库的自声明守卫:待验证项,不是已验证结论 + +以下六条在本契约中一律标记 `status: claim_pending_audit`。它们是主张 admission 的**理由**,不是 admission 已完成的**证据**: + +`membrane_topology_is_independent_surface_localization: false`(正是 PR #58 第二轮阻断本身)、`absence_is_negative_evidence: false`、`generic_membrane_is_surface_confirmation: false`、`cci_receptor_role_is_surface_confirmation: false`、`tumor_ihc_is_surface_density: false`、以及因 RNA FPKM 排除 `GSE160572_MM_surfaceome.csv.gz`。 + +四个文件的 SHA-256 仍记录,但角色降为 `files_pinned_for_integrity_only`——版本固定手段,不是批准依据。 + +## 二、抽取范围 + +只处理已批准枚举的 41 个靶点(`target_evidence_catalog.tsv`,SHA-256 `27bb81eb…`),**不新增任何靶点**,按 `gene_symbol` 连接。 + +实测覆盖:**37 覆盖 / 4 未覆盖**。未覆盖的是 `AG7`、`CA19-9`、`EDBN`、`Undisclosed`——两个占位符、一个碳水化合物抗原、一个非标准符号。四者一律落 `possible_surface_target` DEFER(`E1-05`)。 + +## 三、字段白名单与明确禁止读取的内容 + +只允许读 `surfaceome_consensus.tsv` 的 16 个字段与 `source_evidence.tsv` 的 8 个字段(清单见 YAML `allowed_fields`)。 + +**禁止读取以下文件**,读入即重演 PR #58 被阻断的越界: + +| 文件 | 理由 | +|---|---| +| `tumor_surface_measurement.tsv` | 肿瘤表面定量属 Level 02 T7 | +| `tumor_protein_context.tsv` | 肿瘤上下文蛋白组属 Level 02 T7 | +| `treatment_surface_response.tsv` | 治疗后表面变化属 T5/T7 | +| `receptor_evidence.tsv` | 受体角色推断不构成定位证据(该库自身亦声明 `cci_receptor_role_is_surface_confirmation: false`) | + +**禁止读取以下字段**:`cci_receptor_role`(角色不是定位)、`uniprot_generic_membrane`(泛膜不等于质膜)、`full_t7_gate_confidence_cap`(T7 置信属 Level 02)。 + +## 四、判据映射 + +### `RQ-01` 质膜定位:要求 ≥ 2 个独立证据家族 + +判决字段是 `independent_evidence_family_count`。该库定义的三个独立家族为 `curated_knowledge`/`imaging`/`cell_surface_capture_ms`,且**已把拓扑与泛膜排除在家族计数之外**——所以家族计数在结构上不可能把跨膜段当成定位证据。RNA 不得满足。 + +### `RQ-02` 细胞外结构域:两条合格路径 + +**两个计数必须分开:满足某条路径 ≠ 最终 eligible。** 满足 `RQ-02` 的靶点还可能因独立家族数不足或定位证据冲突而落 hold。初稿把二者混为一谈,已按审核裁决更正。 + +| 路径 | 条件 | 路径命中 | 其中最终 eligible | +|---|---|---|---| +| `ECD-a` | `uniprot_ecd_meets_min_length = true` | **30** | **18** | +| `ECD-b` | `uniprot_gpi_anchor` 且 `uniprot_signal_peptide` 且 `transmembrane_segment_count = 0` | **4** | **4** | + +两条路径在本 snapshot 下无重叠(`measured_path_overlap: 0`)。`RQ-02` 阳性总数的分解恒等式: + +> **34 个 RQ-02 阳性 = 22 最终 eligible + 6 独立家族数不足(`E1-02`)+ 6 定位证据冲突(`E1-04` 中 RQ-02 阳性者)** + +`VAL-E12` 要求每条路径同时报出两个计数,不得只报其一或用后者冒充前者。 + +**`ECD-b` 是必需的,理由是修正数据表示假象而不是放宽标准。** UniProt 的 extracellular domain 字段由跨膜蛋白的 TOPO_DOM 推导;GPI 锚定蛋白零跨膜段、没有 TOPO_DOM,该字段一律读成 `false`。若只用 `ECD-a`,`CEACAM5`、`MSLN`、`FOLR1`、`MELTF` 会因此落 hold——而四者在本库中都是 `confirmed_surface`,都带信号肽与 GPI 锚,成熟蛋白整体位于细胞外侧。GPI 锚 + 信号肽 + 零跨膜段在结构上即意味着全长成熟蛋白朝向胞外,其胞外长度等于成熟蛋白长度。 + +**腔内结构域不算胞外。** `LAMP1` 即属此类:有跨膜段与信号肽,但无胞外 TOPO_DOM,两条路径都不满足,落 hold。这正是审核方要求的「细胞器膜定位必须 DEFER」,规则自然落在正确一侧。 + +### `RQ-03` 蛋白层面 provenance + +分两类,不得混用。RNA 来源行一概不可采纳。 + +**库中覆盖的靶点**:要求 `source_evidence.tsv` 有对应行,且 `source_id`、`source_release`、`evidence_family`、`source_url`、`license` 均非空。 + +**覆盖靶点若 `RQ-03` 不成立** → 归入 `E1-04b`,DEFER(见第五、六节)。 + +**库中未覆盖的靶点(`E1-05`)**:`source_evidence.tsv` 里本就没有它们的行。**初稿要求所有行都有来源 provenance,那会让合法的 `E1-05` hold 行无法通过验证,或迫使执行者伪造 provenance**——已按审核裁决更正。改为要求「缺失 provenance」六列:`reference_dataset_id`、`reference_dataset_version`、`reference_snapshot_id`、`target_axis_ref`、`absence_reason`(只能取 `gene_symbol_not_present_in_reference`)、`lookup_at`。**这六列全部在 `output_schema.per_target_columns` 之内**(初稿只加了后三列,导致 `VAL-E05b` 要求的字段 schema 里没有——已按审核裁决补齐)。前三列必须分别等于 admission snapshot 的 `ADC_surfaceome_reference`/`0.3.0`/`2026-07-29-quant-topology-mm`,不得自由填写(`VAL-E05d`)。 + +`source_*` 字段允许为空,但**禁止伪造 source evidence**,也**禁止把缺失表述为 source-supported**:`provenance_kind = reference_absent` 的行若出现非空 `source_ids` 即为验证失败(`VAL-E05b`/`VAL-E05c`)。 + +## 五、规则求值优先级(必须冻结) + +五条规则的条件**并非天然互斥**——同一靶点可能同时满足「两条 ECD 路径都不满足」与「独立家族数 < 2」。实测本 snapshot 下有 **2 个**这样的靶点:`TM4SF1` 与 `TDGF1`。初稿没有定义优先级,`VAL-E01` 的「命中且仅命中一条」无从保证,已按审核裁决更正。 + +冻结顺序,取第一个命中者: + +> `E1-05` 不在库中 → `E1-04` 定位证据冲突 → `E1-04b` provenance 不成立 → `E1-03` 无细胞外结构域 → `E1-02` 独立家族数不足 → `E1-01` eligible + +理由:先判「不在库中」,因为无数据时其余条件都无法求值;再判冲突,因为冲突使后续判据不可信;**再判 provenance 是否成立,因为 provenance 不成立时该行证据本身不可引用,再谈拓扑与家族数没有意义**;再判细胞外结构域,再判独立家族数;全部通过才 eligible。 + +`TM4SF1` 与 `TDGF1` 同时命中 `E1-03` 与 `E1-02`,按优先级解析到 **`E1-03`**,并须在结果中记录被压制的条件(`VAL-E11`)。测试用等价 fixture 覆盖了每一种重叠组合,逐例证明恰好命中一条。 + +## 六、确定性映射:五条规则覆盖 41 个靶点,零排除 + +| ID | 条件 | outcome | disposition | 数量 | +|---|---|---|---|---| +| `E1-01` | `RQ-01`+`RQ-02`+`RQ-03` 全满足且无冲突 | `eligible_surface_target` | RETAIN | **22** | +| `E1-02` | `RQ-02` 满足但独立家族数 < 2 | `possible_surface_target` | DEFER | **6** | +| `E1-03` | 两条 `RQ-02` 路径都不满足(含腔内结构域) | `possible_surface_target` | DEFER | **3** | +| `E1-04` | `discordance_flags` 非空 | `possible_surface_target` | DEFER | **6** | +| `E1-04b` | 在库中但 `RQ-03` provenance 不成立 | `possible_surface_target` | DEFER | **0**(空规则) | +| `E1-05` | 靶点不在参考库中 | `possible_surface_target` | DEFER | **4** | + +22 + 6 + 3 + 6 + 0 + 4 = 41。**零自由裁量、零排除。** + +**`E1-04b` 是按审核裁决新增的。** 初稿漏了一种组合:覆盖靶点 `RQ-01` 与 `RQ-02` 满足、无冲突,但 `source_evidence.tsv` 字段不全导致 `RQ-03` 不满足——它不命中 `E1-01`(RQ-03 未满足)、不命中 `E1-02`(家族数不低)、不命中 `E1-03`(RQ-02 满足)、不命中 `E1-04`(无冲突)、不命中 `E1-05`(在库中),因此 `VAL-E01` 的「恰好命中一条」无从满足;`VAL-E05` 只写「降为 hold」却没说降到哪条规则、也没有对应 `rule_id`。 + +实测 **37 个覆盖靶点全部满足 `RQ-03`**,故本规则在本 snapshot 下为空规则,计数不变。但它必须存在——provenance 完整性不由本契约保证,抽取时可能失败。其 disposition 只能是 DEFER:既不得 RETAIN(无可回溯来源),也不得 EXCLUDE(缺 provenance 不是否定证据)。 + +- `E1-02`:`CLDN18`、`GUCY2C`、`LGR5`、`PRLR`、`RNF43`、`SLC44A4` +- `E1-03`:`LAMP1`、`TDGF1`、`TM4SF1` +- `E1-04`:`CD276`、`F3`、`IL2RA`、`MET`、`MST1R`、`TACSTD2` +- `E1-05`:`AG7`、`CA19-9`、`EDBN`、`Undisclosed` + +`not_surface_target` 与 `identity_unresolved` **仍然不可用**:该库明确 `absence_is_negative_evidence: false`,不产出「该靶点不是表面蛋白」这类阳性否定断言;`consensus_class = no_surface_support` 只表示无支持证据,不等于已证伪。**本次抽取不得排除任何靶点。** + +## 七、抽取后的预期结果,逐项可核对 + +| 量 | 值 | +|---|---| +| 靶点总数 | 41 | +| eligible | **22**(经 `ECD-a` 18 + 经 `ECD-b` 4) | +| hold | **19**(6 + 3 + 6 + 4) | +| killed | **0** | + +**执行结果必须逐项等于上表,任一项不符即视为偏离本契约。** + +## 八、必须写进结果报告的三条发现 + +- **`MF-01`:`GUCY2C` 落 hold。** 它在本参考库中只有 `curated_knowledge` 一个独立证据家族,未达两家族门槛,`consensus_class` 是 `supported_surface` 而非 `confirmed_surface`。**这与此前多模型共识把 GUCY2C 列为首选、以及被隔离运行的 Tier A 选择相反。** 结果报告必须原样写出,不得因与既有偏好冲突而弱化。 +- **`MF-02`:`eligible` 只表示身份与拓扑层面存在有合理依据的细胞外可及蛋白形式**,不表示该靶点在 CRC 肿瘤细胞表面可得。后者是 Level 02 的 T7,本次未评估。 +- **`MF-03`:零排除。** 41 个靶点中没有任何一个被判为 `not_surface_target`。hold 不是淘汰,是待证据。 + +## 九、输出与验证 + +输出为每靶点一行、**29 列**(见 YAML `output_schema`),必含 `rule_id`、`rq_02_path`、`evaluation_status`、`provenance_kind`、`absence_reason`、`target_axis_ref`、`lookup_at` 与 `row_checksum`。 + +**16 条**验证规则 `VAL-E01`..`VAL-E13`(含 `VAL-E05b`/`c`/`d`) 见 YAML。要点:41 行且每行按优先级命中且仅命中一条规则(`VAL-E01`/`VAL-E11`);计数等于第七节且 `RQ-02` 分解恒等式成立(`VAL-E02`/`VAL-E12`);不得出现 `not_surface_target` 或 `identity_unresolved`;每个 eligible 必记 `rq_02_path`;覆盖行须有完整来源 provenance、未覆盖行须有完整缺失 provenance 且禁止伪造(`VAL-E05`/`E05b`/`E05c`);输入 SHA-256 不一致即中止;不得读取被禁文件或字段;输出中不得出现任何 Gate 分数、Gate 状态、T7 判定或肿瘤表面定量;三条 `mandatory_findings` 必须原样出现;每个产物文件逐文件记录 SHA-256;**抽取前 `admission_record_ref` 必须指向实际存在的独立 `APPROVE` 记录,为空即不得执行(`VAL-E13`)**。 + +## 十、本契约授权与不授权 + +**授权:** 冻结本次抽取的范围、字段白名单、判据映射、求值优先级与输出验证。 + +**不授权:** 在 `SRCADM-01` 取得独立 `APPROVE` 记录前执行抽取;把 `ADC_surfaceome_reference` 纳入已批准来源(本契约只引用,不批准);执行 Level 01;评估 T7 或任何肿瘤细胞表面可得性;新增任何靶点或 clinical context;任何筛选排序、Tier 划分、资产推荐或实验建议;任何 Gate 执行或评分;读取被禁文件或字段;把被隔离运行(PR #53、#54)的任何产物引入。 + +## 十一、后续顺序 + +1. 本契约获 `APPROVE`。 +2. **`SRCADM-01` 独立 source admission PR** 审计 `AUD-01`..`AUD-09` → `APPROVE`,并在 `logs/` 留下记录。 +3. 把 `admission_record_ref` 指向该记录,执行抽取 → 结果 PR → `APPROVE`。 +4. **另开一个 PR** 更新 `adc_pool_level_01_input_binding.yaml`,把抽取产物绑为 `LOCK-01` 的来源并解除 `EVGAP-01`。该 PR 不在本契约授权范围内。 +5. `EVGAP-02`(`LOCK-03` 的源级 CRC linkage 证据)仍未解除,需其独立契约。**两个缺口都解除后,Level 01 才能执行。** + +## 十二、当前阻断 + +- 本契约获 ChatGPT `APPROVE` 前,不得执行抽取。 +- **本契约获 `APPROVE` 后仍不得执行抽取**,直到 `SRCADM-01` 取得独立 `APPROVE` 记录。 +- 抽取完成也**不**解除 `EVGAP-02`,Level 01 仍不可执行。 +- 本仓库不得写入证据、候选、快照、cache、result 或 weights。 diff --git a/logs/worklog.md b/logs/worklog.md index 690b289..e65854a 100644 --- a/logs/worklog.md +++ b/logs/worklog.md @@ -2508,3 +2508,52 @@ Purpose: append a detailed timestamped record of what was done, how it was done, - Mutation-tested (本轮 5 个,全部 `FAILED` 后精确回滚,与备份 `diff -q` 一致、恢复 `OK`): `VAL-B07` 结构化计数改回 32、只改散文回 32 使其与结构化字段矛盾、`coverage` 改成与 `VAL-B07` 不一致、`predicted_result_shape` 改成与 `VAL-B07` 不一致、声明该规则可授权生成结果。 - Review write-back: 连接器 403,未写回 GitHub。裁决以人类负责人转述为准,已记录于本条与 handoff 第十三节。 - Next: 推送同一 PR 并同步 PR 描述,请求复审。 + +## 2026-08-04T21:45:00-04:00 — EVGAP-01 target surface localization 抽取契约(contract-only,未执行) + +- Instruction: 人类负责人指示继续起 `EVGAP-01` 契约。该缺口由 PR #58 登记,阻断 `LOCK-01`——已批准证据层无蛋白层面质膜定位与细胞外结构域证据,41 个靶点全部 DEFER、`eligible = 0`、Level 01 无法录入任何 pair。 +- Finding (high), governance: **所需数据已在本地 `DATA/1.Databases/ADC_surfaceome_reference/processed/v0.3.0`,但该数据库从未被审核批准。** `logs/chatgpt-review-*.md` 无任何一条提及 surfaceome;worklog 唯一提及是 2026-08-01 mock 运行;已获批的证据抽取(PR #31)`source_manifest.json` 声明的来源是 `ADC_internalization_reference`,**没有接入本库**。这解释了此前未解释的事:已批准层只有跨膜段注释,不是数据不存在,是当时没接。 +- Action: 本 PR 请求把该单一版本纳入已批准来源——`0.3.0`/snapshot `2026-07-29-quant-topology-mm`/`raw_manifest_sha256 884f4191…`,四个文件逐一记录 SHA-256(`surfaceome_consensus.tsv` 20,797 行、`membrane_topology_evidence.tsv` 4,863 行、`source_evidence.tsv` 41,204 行、`build_manifest.json`),执行前逐个校验、不一致即中止(`VAL-E06`)。 +- Rationale for admission: 该库 `consensus_semantics` 已把本仓库反复要求的守卫写死在构建时,其中两条正是前几轮审核的阻断本身——`membrane_topology_is_independent_surface_localization: false`(PR #58 第二轮阻断)、`absence_is_negative_evidence: false`(缺失一律 DEFER);另有 `generic_membrane_is_surface_confirmation: false`、`cci_receptor_role_is_surface_confirmation: false`、`tumor_ihc_is_surface_density: false`,并因 RNA FPKM 排除 `GSE160572_MM_surfaceome.csv.gz`。它还自设 `full_t7_gate_confidence_cap: 0.55` 并声明不建立 malignant-cell positive fraction/isoform usage/calibrated treatment stability/ADC accessibility,即自行划开 Level 02 边界。守卫是构建时写死的、不是本契约事后附加的——这是主张纳入它而非另起网络抽取的核心理由。 +- Scope frozen: 只处理已批准枚举的 41 靶点(`target_evidence_catalog.tsv`,SHA-256 `27bb81eb…`),不新增靶点,按 `gene_symbol` 连接。实测覆盖 37/未覆盖 4(`AG7`、`CA19-9`、`EDBN`、`Undisclosed`,两个占位符、一个碳水化合物抗原、一个非标准符号)。 +- Barred reads: 禁止读取 `tumor_surface_measurement.tsv`、`tumor_protein_context.tsv`、`treatment_surface_response.tsv`、`receptor_evidence.tsv`(前三属 Level 02 T7/T5,第四项该库自身即声明受体角色不构成定位证据);禁止字段 `cci_receptor_role`、`uniprot_generic_membrane`、`full_t7_gate_confidence_cap`。读入即重演 PR #58 被阻断的越界。 +- `RQ-01` mapping: 判决字段 `independent_evidence_family_count ≥ 2`。三个独立家族为 `curated_knowledge`/`imaging`/`cell_surface_capture_ms`,**拓扑与泛膜已被该库排除在家族计数之外**,故家族计数在结构上不可能把跨膜段当定位证据。RNA 不得满足。 +- `RQ-02` mapping, two paths: `ECD-a` = `uniprot_ecd_meets_min_length = true`(18);`ECD-b` = `uniprot_gpi_anchor` 且 `uniprot_signal_peptide` 且 `transmembrane_segment_count = 0`(4)。**`ECD-b` 是修正数据表示假象而非放宽标准**:UniProt 的 extracellular domain 字段由跨膜蛋白 TOPO_DOM 推导,GPI 锚定蛋白零跨膜段、无 TOPO_DOM,该字段一律 `false`;只用 `ECD-a` 会让 `CEACAM5`、`MSLN`、`FOLR1`、`MELTF` 因假象落 hold,而四者在库中皆为 `confirmed_surface` 且带信号肽与 GPI 锚,成熟蛋白整体位于胞外。对照:`LAMP1` 有跨膜段与信号肽但结构域朝向溶酶体腔内、无胞外 TOPO_DOM,两条路径都不满足、落 hold——审核方要求的「细胞器膜定位必须 DEFER」由规则自然落在正确一侧,无需特判。 +- Derivation, total and exclusion-free: `E1-01` 三项 RQ 全满足且无冲突 → RETAIN **22**;`E1-02` 家族数 < 2 → DEFER **6**(CLDN18、GUCY2C、LGR5、PRLR、RNF43、SLC44A4);`E1-03` 两条 ECD 路径都不满足 → DEFER **3**(LAMP1、TDGF1、TM4SF1);`E1-04` `discordance_flags` 非空 → DEFER **6**(CD276、F3、IL2RA、MET、MST1R、TACSTD2);`E1-05` 不在库中 → DEFER **4**。22+6+3+6+4 = 41,零自由裁量、零排除。`not_surface_target` 与 `identity_unresolved` 仍不可用(该库 `absence_is_negative_evidence: false`,`no_surface_support` 只表示无支持证据、不等于已证伪)。 +- Mandatory findings recorded: **`MF-01` GUCY2C 落 hold**——只有 `curated_knowledge` 一个独立家族,`consensus_class` 为 `supported_surface` 而非 `confirmed_surface`,**与此前多模型共识首选及被隔离运行的 Tier A 相反**,必须原样写入结果报告、不得弱化;测试会检查该靶点确实出现在某条 DEFER 规则的 `measured_targets` 中,防止这条发现被改成空话。`MF-02` eligible 只是身份与拓扑层面结论,不代表 CRC 肿瘤细胞表面可得(属 Level 02 T7)。`MF-03` 零排除,hold 不是淘汰。 +- Validation: `Ran 298 tests` 全部通过(`main` 基线 283 + 新增 15);`scripts/verify_repository_boundary.sh` 通过;`git diff --check` 通过;零 `__pycache__`。实测计数:37/4 覆盖;`consensus_class` 29 confirmed/7 supported/1 no_support;独立家族数 3 家族 10 个、2 家族 19 个、1 家族 7 个、0 家族 1 个;`discordance_flags` 非空 6 个;最终 22 eligible(ECD-a 18 + ECD-b 4)/19 hold/0 killed。四个数据库文件 SHA-256 由 `shasum -a 256` 实算。 +- Deliberately not done: 未执行抽取;未执行 Level 01 也不授权执行(`EVGAP-02` 仍未解除);未解除 `EVGAP-01` 本身(须待抽取执行、结果 PR 获批后另开 PR 更新输入绑定);未读取被禁文件或字段作为判据;未引用被隔离运行任何产物;未新增靶点或 clinical context;**未补 #52/#53/#54/#57/#58 的批准记录**(现为五份,事实已查全未写文件)。 +- Noticed, not fixed: `requirements.txt` 注释仍写「207 tests」,实测 298。属无关改动,只记录。 +- Governance note: **本 PR 不适用 `AGENTS.md`「审核豁免」**,须经 ChatGPT `APPROVE`。 +- Next: 推送并创建 PR 送审;请审核方裁决是否接受把 `ADC_surfaceome_reference@0.3.0` 纳入已批准来源,以及 `ECD-b` 路径是否成立。 + +## 2026-08-05T00:20:00-04:00 — PR #59 第一轮审核裁决与修订(四条阻断全部接受) + +- Review: ChatGPT 对 PR #59(HEAD `570562c`,可合并、CI 成功)返回 `REQUEST_CHANGES`,四条阻断。**全部接受**,同一 PR 内最小修订。 +- Finding 1 accepted: **未批准的派生数据库不能靠自声明 + 哈希升级为 approved source。** 初稿只记录 snapshot/SHA-256/builder 路径/该库自己的语义守卫,而测试明确不读外部数据库,故实际未审计 builder 实现、raw manifest、原始来源清单、license、evidence family 独立性、去重与冲突处理、代表性行回溯、snapshot 可重建性。**执行者错误:把「它的语义写得对」当成了「它已被验证」。** 该库声明 `membrane_topology_is_independent_surface_localization = false` 不等于它确实遵守该规则。 +- Fix 1: source admission 从本契约剥离为依赖项 `SRCADM-01`——`admission_status: pending_separate_admission_pr`、`admission_record_ref: null`(不得代填)、`authorises_extraction_run: false`、`extraction_blocked_by: [SRCADM-01]`;登记 `AUD-01`..`AUD-09` 九项必审内容;六条自声明守卫一律标 `status: claim_pending_audit`;四个 SHA-256 角色降为 `files_pinned_for_integrity_only`;新增 `VAL-E13`(抽取前 `admission_record_ref` 必须指向实际存在的独立 `APPROVE` 记录)。 +- Finding 2 accepted: **RQ-02 路径计数与 `E1-02` 自相矛盾。** 初稿写 `ECD-a=18`/`ECD-b=4` 并强制 18+4=22 eligible,但 `E1-02` 的 6 个靶点是「RQ-02 满足但家族数<2」,必然也命中某条 ECD 路径。**执行者把「满足 ECD 路径」错等于「最终 eligible」。** +- Fix 2: 实测后拆为两个计数——`ECD-a` 路径命中 **30**/其中 eligible **18**;`ECD-b` 命中 **4**/eligible **4**;两路径无重叠。写入分解恒等式 **34 RQ-02 阳性 = 22 eligible + 6 `E1-02` + 6 `E1-04` 中 RQ-02 阳性者**。新增 `VAL-E12` 与两条测试(eligible 不得超过路径命中;`eligible_via_path` 之和等于 `E1-01`,路径命中之和减重叠等于 RQ-02 阳性总数)。 +- Finding 3 accepted: **`VAL-E05` 与 reference-absent 靶点冲突。** `E1-05` 的 4 个靶点在 `source_evidence.tsv` 里本就没有行,而初稿要求每行都有非空 source provenance——会让合法 hold 行无法通过验证,或迫使执行者伪造 provenance。 +- Fix 3: provenance 拆两类。覆盖行须完整来源 provenance;未覆盖行 `source_*` 允许为空但须完整**缺失 provenance**(`reference_dataset_id`/`reference_dataset_version`/`reference_snapshot_id`/`target_axis_ref`/`absence_reason` 只能取 `gene_symbol_not_present_in_reference`/`lookup_at`)。新增 `VAL-E05b`/`VAL-E05c`:禁止伪造 source evidence,禁止把缺失表述为 source-supported,`provenance_kind = reference_absent` 行出现非空 `source_ids` 即验证失败。输出列 21 → 26。 +- Finding 4 accepted: **五条规则没有冻结优先级**,而 `VAL-E01` 要求命中且仅命中一条。实测本 snapshot 下 **2 个**靶点同时满足 `E1-03` 与 `E1-02`(`TM4SF1`、`TDGF1`)。初稿只验证预写计数之和等于 41,未证明真实条件下的 one-and-only-one。 +- Fix 4: 冻结 `derivation_precedence` = `E1-05` → `E1-04` → `E1-03` → `E1-02` → `E1-01` 并写明理由;登记 `measured_multi_condition_targets`(两靶点解析到 `E1-03`);新增 `VAL-E11`;新增三条测试——优先级覆盖全部规则且首尾正确、用等价 fixture 逐例证明九种条件组合(含 absent+conflict、conflict+low-family、conflict+no-ECD、no-ECD+low-family)各有唯一结果、重叠靶点必须落在优先级更高规则且不得计入被压制规则的 `measured_targets`。 +- Counts unchanged: 按冻结后优先级重算仍为 22/6/3/6/4,与初稿一致——初稿的计算脚本已隐含同一顺序,只是没把顺序写进契约。 +- Validation: `Ran 306 tests` 全部通过(`main` 基线 283 + 新增 23,由 15 增至 23);`scripts/verify_repository_boundary.sh` 通过;`git diff --check` 通过;零 `__pycache__`。 +- Mutation-tested (本轮 12 个,全部 `FAILED` 后精确回滚,与备份 `diff -q` 一致、恢复 `OK`): 自行填入 admission 记录并放行、自声明守卫标为已验证、删掉 license 审计项、eligible 超过路径命中数、用 eligible 冒充路径命中、破坏分解恒等式、要求未覆盖行也有 source 字段、允许伪造 source evidence、删掉 `absence_reason` 要求、`E1-01` 提到优先级最前、冲突优先级降到最后、重叠靶点解析到被压制规则。 +- Accepted by reviewer, unchanged: `ECD-b` 路径可作为 extracellular topology 路径(前提是 GPI 注释可靠、有信号肽、零跨膜段、蛋白级 provenance);跨膜段不再单独满足 `RQ-01`;GPI 路径未被当成普通跨膜蛋白规则;`LAMP1` 腔内结构域不被当成胞外;缺失与冲突均 DEFER 不 EXCLUDE;`not_surface_target` 仍不可用;T7 与肿瘤定量文件明确禁止;不执行 Level 01;不新增 target/context;仓库不存 evidence/result;`EVGAP-02` 继续未解除。 +- Review write-back: 连接器 403,未写回 GitHub。裁决以人类负责人转述为准,已记录于本条与 handoff 第十节。 +- Next: 推送同一 PR 并同步 PR 描述请求复审。此后欠着的工作变为三项:`SRCADM-01` 数据库 admission PR、`EVGAP-02` 契约、五份批准记录。 + +## 2026-08-05T01:05:00-04:00 — PR #59 第二轮审核裁决与修订(两条契约缺口全部接受) + +- Review: ChatGPT 对 PR #59(HEAD `f236287`,可合并、CI 成功)返回 `REQUEST_CHANGES`。上一轮四条确认已基本修复(source admission 拆为 `SRCADM-01`、当前契约不再授权抽取、RQ-02 两个计数分开、reference-absent provenance 与 source provenance 区分、precedence 已冻结并覆盖多条件重叠)。本轮两条**契约缺口**全部接受,两条都是执行者留下的洞。 +- Finding 1 accepted: **覆盖靶点的 `RQ-03` 缺失没有对应 derivation rule。** 审核方构造的组合成立:在库中、`RQ-01` 满足、`RQ-02` 满足、无 discordance,但 `source_evidence.tsv` 字段不全导致 `RQ-03` 不满足——不命中 `E1-01`(RQ-03 未满足)/`E1-02`(家族数不低)/`E1-03`(RQ-02 满足)/`E1-04`(无冲突)/`E1-05`(在库中),故 `VAL-E01` 的「恰好命中一条」无从满足;`VAL-E05` 只写「降为 hold」,未说降到哪条规则、无对应 `rule_id`。 +- Fix 1: 新增 `E1-04b`「在库中但 RQ-03 provenance 不成立」→ `possible_surface_target` DEFER `hold`,插入优先级第三位(`E1-05` → `E1-04` → **`E1-04b`** → `E1-03` → `E1-02` → `E1-01`)。理由写入契约:provenance 不成立时该行证据不可引用,再谈拓扑与家族数无意义,故排在两个 RQ 判据之前、冲突之后。disposition 只能 DEFER——不得 RETAIN(无可回溯来源),不得 EXCLUDE(缺 provenance 不是否定证据)。`rq_03` 增 `covered_row_failure_rule: E1-04b`。**实测 37 个覆盖靶点全部满足 RQ-03**,故 `expected_count: 0`、`vacuous_this_run: true`,计数不变(22/6/3/6/0/4 = 41);但规则必须存在,provenance 完整性不由本契约保证。测试按验收标准新增组合:`RQ-01=true,RQ-02=true,RQ-03=false` → `E1-04b`;`discordance=true 且 RQ-03=false` → `E1-04`(冲突优先);`RQ-02=false 且 RQ-03=false` → `E1-04b`;共 13 种组合逐例证明恰好命中一条,且 provenance 缺失只能 DEFER。 +- Finding 2 accepted: **`VAL-E05b` 要求的六列没有全部进入 output schema。** `per_target_columns` 上一轮只加了 `absence_reason`/`target_axis_ref`/`lookup_at`,缺 `reference_dataset_id`/`reference_dataset_version`/`reference_snapshot_id`,执行者无法同时遵守 schema 与 validation rule。**这是执行者上一轮补 blocker 3 时的漏改。** +- Fix 2: 三列补入 `per_target_columns`(21 → 26 → **29**)。新增 `conditionally_required_columns`:`reference_absent` 时六列必填、`source_*` 可空;`source_supported` 时 `source_*` 必填、六列可空。新增 `pinned_to_admission_snapshot`——三列必须分别等于 `ADC_surfaceome_reference`/`0.3.0`/`2026-07-29-quant-topology-mm`,不得自由填写;新增 `VAL-E05d` 强制。测试直接断言 `required_absence_fields ⊆ per_target_columns`,并断言 pinned 值与 `source_admission_dependency` 的 `dataset_id`/`dataset_version`/`snapshot_id` 逐项相等——admission 版本一变、pinned 不同步即失败。 +- Validation: `Ran 309 tests` 全部通过(`main` 基线 283 + 新增 26,由 23 增至 26);`scripts/verify_repository_boundary.sh` 通过;`git diff --check` 通过;零 `__pycache__`。 +- Mutation-tested (本轮 8 个,全部 `FAILED` 后精确回滚,与备份 `diff -q` 一致、恢复 `OK`): 从优先级删掉 `E1-04b`、`E1-04b` 改判 RETAIN、`E1-04b` 排到 `E1-01` 之后、RQ-03 不指向失败规则、删掉 `reference_dataset_id` 列、pinned 值与 admission 不符、`source_supported` 不要求来源字段、谎报 RQ-03 有失败靶点。 +- Accepted by reviewer, unchanged: `AUD-01`..`AUD-09` 足以覆盖 builder/raw manifest/license/family independence/去重/discordance/行级 provenance/重建;`admission_record_ref = null` 时不得执行抽取;自声明守卫仅作 pending claim;RQ-02 分解自洽(34 = 22 + 6 + 6);`ECD-b` 路径合理;reference-absent 靶点不再被迫伪造 source evidence;precedence 已解决 `TM4SF1`/`TDGF1`;不执行 Level 01;不评估 T7;不新增 target/context;不读取被禁文件;`EVGAP-02` 仍未解除;仓库内无 evidence 或结果数据。 +- Review write-back: 连接器 403,未写回 GitHub。裁决以人类负责人转述为准,已记录于本条与 handoff 第十一节。 +- Next: 推送同一 PR 并同步 PR 描述请求复审。 diff --git a/tests/test_evgap_01_surface_localization.py b/tests/test_evgap_01_surface_localization.py new file mode 100644 index 0000000..f882a25 --- /dev/null +++ b/tests/test_evgap_01_surface_localization.py @@ -0,0 +1,519 @@ +"""Verify docs/pools/evgap_01_surface_localization_extraction.yaml. + +The extraction contract freezes how one pinned database version would map onto +LOCK-01's RQ-01/RQ-02/RQ-03. It does not admit that database: admission needs its +own review, tracked as SRCADM-01. These tests check internal consistency and +agreement with the merged Level 01 contracts. They never read the external +database, so the recorded checksums are integrity pins, not verification. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +import yaml + +from genmodules.gen_indication_endpoint_target.contracts import CandidateDisposition + + +REPO_ROOT = Path(__file__).resolve().parents[1] +POOLS = REPO_ROOT / "docs" / "pools" +EXTRACTION_PATH = POOLS / "evgap_01_surface_localization_extraction.yaml" +BINDING_PATH = POOLS / "adc_pool_level_01_input_binding.yaml" +LEVEL_CONTRACT_PATH = POOLS / "adc_pool_gate_usage.yaml" + +# Level 02 / T7 material that must never enter LOCK-01. +FORBIDDEN_FILES = frozenset( + { + "tumor_surface_measurement.tsv", + "tumor_protein_context.tsv", + "treatment_surface_response.tsv", + "receptor_evidence.tsv", + } +) + + +def _load(path: Path) -> dict: + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +class Evgap01ExtractionContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.doc = _load(EXTRACTION_PATH) + cls.binding = _load(BINDING_PATH) + cls.level = { + entry["level"]: entry for entry in _load(LEVEL_CONTRACT_PATH)["levels"] + }["01"] + + def test_contract_targets_the_registered_gap(self) -> None: + head = self.doc["extraction"] + self.assertEqual(head["discharges_gap"], "EVGAP-01") + self.assertEqual(head["serves_lock"], "LOCK-01") + gaps = {g["id"]: g for g in self.binding["evidence_gaps"]} + self.assertIn("EVGAP-01", gaps) + self.assertEqual(gaps["EVGAP-01"]["blocks"], "LOCK-01") + self.assertEqual(head["execution_status"], "not_authorized_not_executed") + # The extraction is frozen but not yet authorised: SRCADM-01 blocks it. + self.assertIs(head["authorises_extraction_run"], False) + self.assertIs(head["authorises_level_01_execution"], False) + self.assertIs(head["requires_followup_binding_pr"], True) + + def test_database_admission_is_deferred_to_its_own_approval(self) -> None: + """Blocker 1: a derived database cannot be admitted by self-declaration.""" + + dep = self.doc["source_admission_dependency"] + self.assertEqual(dep["id"], "SRCADM-01") + self.assertIs(dep["is_derived_database"], True) + self.assertIs(dep["previously_approved"], False) + self.assertEqual(dep["admission_status"], "pending_separate_admission_pr") + self.assertIsNone(dep["admission_record_ref"]) + self.assertIs(dep["admission_record_required"], True) + # The extraction must be blocked while the admission is pending. + head = self.doc["extraction"] + self.assertIs(head["authorises_extraction_run"], False) + self.assertIn("SRCADM-01", head["extraction_blocked_by"]) + # Admitting the database is explicitly outside this contract. + not_authorised = " ".join(self.doc["not_authorised"]) + self.assertIn("SRCADM-01", not_authorised) + self.assertIn("纳入已批准来源", not_authorised) + + def test_admission_audit_covers_more_than_hashes(self) -> None: + dep = self.doc["source_admission_dependency"] + items = {a["id"]: a["item"] for a in dep["required_audit_items"]} + self.assertGreaterEqual(len(items), 9) + joined = " ".join(items.values()) + for topic in ("builder", "raw manifest", "license", "去重", + "discordance_flags", "回溯", "复现"): + with self.subTest(topic=topic): + self.assertIn(topic, joined) + # Independence of the evidence families must be audited, not assumed. + self.assertIn("独立性", joined) + + def test_self_declared_guards_are_claims_not_verification(self) -> None: + dep = self.doc["source_admission_dependency"] + self.assertIs(dep["self_declared_guards_are_claims_not_verification"], True) + guards = dep["self_declared_guards"] + self.assertGreaterEqual(len(guards), 6) + for guard in guards: + with self.subTest(guard=guard["guard"]): + self.assertEqual(guard["status"], "claim_pending_audit") + self.assertTrue(guard["relates_to_repo_rule"].strip()) + names = {g["guard"] for g in guards} + for required in ("absence_is_negative_evidence", + "membrane_topology_is_independent_surface_localization"): + self.assertIn(required, names) + + def test_checksums_are_integrity_pins_not_approval(self) -> None: + dep = self.doc["source_admission_dependency"] + pinned = dep["files_pinned_for_integrity_only"] + self.assertEqual(len(pinned), 4) + for item in pinned: + with self.subTest(path=item["path"]): + digest = item["sha256"] + self.assertEqual(len(digest), 64, digest) + self.assertTrue(all(c in "0123456789abcdef" for c in digest)) + + def test_scope_is_the_approved_target_axis_only(self) -> None: + scope = self.doc["scope"] + self.assertIs(scope["new_targets_allowed"], False) + self.assertEqual( + scope["target_count"], self.binding["scope_consequences"]["raw_targets"] + ) + self.assertEqual( + scope["target_axis_sha256"], + next( + f["sha256"] + for s in self.binding["accepted_sources"] + for f in s["files"] + if f["path"] == "target_evidence_catalog.tsv" + ), + ) + self.assertEqual( + scope["measured_coverage_in_reference"] + + scope["measured_absent_from_reference"], + scope["target_count"], + ) + self.assertEqual( + len(scope["measured_absent_targets"]), scope["measured_absent_from_reference"] + ) + + def test_level_02_material_is_barred(self) -> None: + barred = {item["path"] for item in self.doc["barred_files"]} + self.assertEqual(barred, FORBIDDEN_FILES) + for item in self.doc["barred_files"]: + self.assertTrue(item["reason"].strip()) + allowed = set(self.doc["allowed_fields"]["from_surfaceome_consensus"]) | set( + self.doc["allowed_fields"]["from_source_evidence"] + ) + for item in self.doc["barred_fields"]: + with self.subTest(field=item["field"]): + self.assertNotIn(item["field"], allowed) + self.assertTrue(item["reason"].strip()) + # Nothing capped for T7 may leak into the field whitelist. + self.assertNotIn("full_t7_gate_confidence_cap", allowed) + + def test_rq_01_never_counts_topology_or_rna(self) -> None: + rq1 = self.doc["rq_01_plasma_membrane_localization"] + self.assertGreaterEqual(rq1["minimum_independent_families"], 2) + self.assertIs(rq1["topology_counts_as_family"], False) + self.assertIs(rq1["generic_membrane_counts_as_family"], False) + self.assertIs(rq1["rna_may_satisfy"], False) + self.assertEqual(len(rq1["independent_families"]), 3) + + def test_rq_02_has_both_paths_and_excludes_luminal_domains(self) -> None: + rq2 = self.doc["rq_02_extracellular_domain"] + paths = {p["path_id"]: p for p in rq2["paths"]} + self.assertEqual(set(paths), {"ECD-a", "ECD-b"}) + self.assertTrue(paths["ECD-b"]["rationale_note"].strip()) + self.assertEqual(len(paths["ECD-b"]["measured_targets"]), + paths["ECD-b"]["eligible_via_path_count"]) + self.assertIs(rq2["luminal_domain_is_not_extracellular"], True) + self.assertIn("LAMP1", rq2["luminal_domain_note"]) + + def test_path_satisfaction_is_not_confused_with_eligibility(self) -> None: + """Blocker 2: satisfying an ECD path is not the same as being eligible.""" + + rq2 = self.doc["rq_02_extracellular_domain"] + by_id = {r["id"]: r for r in self.doc["derivation_rules"]} + for path in rq2["paths"]: + with self.subTest(path=path["path_id"]): + # Both counts must exist and eligibility cannot exceed satisfaction. + self.assertIn("measured_path_satisfied_count", path) + self.assertIn("eligible_via_path_count", path) + self.assertLessEqual( + path["eligible_via_path_count"], + path["measured_path_satisfied_count"], + ) + # eligible_via_path must sum to E1-01, not to the satisfied totals. + self.assertEqual( + sum(p["eligible_via_path_count"] for p in rq2["paths"]), + by_id["E1-01"]["expected_count"], + ) + satisfied = sum(p["measured_path_satisfied_count"] for p in rq2["paths"]) + self.assertEqual(satisfied - rq2["measured_path_overlap"], + rq2["rq_02_positive_decomposition"]["total"]) + + def test_rq_02_positive_decomposition_holds(self) -> None: + rq2 = self.doc["rq_02_extracellular_domain"] + decomposition = rq2["rq_02_positive_decomposition"] + by_id = {r["id"]: r for r in self.doc["derivation_rules"]} + self.assertEqual( + decomposition["final_eligible"] + + decomposition["hold_insufficient_localization_families"] + + decomposition["hold_discordance"], + decomposition["total"], + ) + self.assertEqual(decomposition["final_eligible"], by_id["E1-01"]["expected_count"]) + self.assertEqual( + decomposition["hold_insufficient_localization_families"], + by_id["E1-02"]["expected_count"], + ) + # RQ-02 positives must outnumber the eligible set: holds exist downstream. + self.assertGreater(decomposition["total"], decomposition["final_eligible"]) + self.assertTrue(decomposition["identity"].strip()) + + def test_rq_03_requires_protein_level_provenance(self) -> None: + rq3 = self.doc["rq_03_protein_level_provenance"] + self.assertIs(rq3["rna_derived_rows_admissible"], False) + covered = rq3["covered_rows"] + for field in ("source_id", "source_release", "source_url", "license"): + self.assertIn(field, covered["required_fields"]) + allowed = set(self.doc["allowed_fields"]["from_source_evidence"]) + self.assertTrue(set(covered["required_fields"]) <= allowed) + + def test_reference_absent_rows_need_absence_provenance_not_fabrication(self) -> None: + """Blocker 3: absent targets have no source rows and must not invent any.""" + + rq3 = self.doc["rq_03_protein_level_provenance"] + absent = rq3["absent_rows"] + self.assertIsNone(absent["requires_rows_in"]) + self.assertIs(absent["source_provenance_fields_may_be_empty"], True) + self.assertIs(absent["fabricating_source_evidence_forbidden"], True) + self.assertIs(rq3["never_present_absence_as_source_supported"], True) + for field in ("reference_dataset_id", "reference_dataset_version", + "reference_snapshot_id", "target_axis_ref", + "absence_reason", "lookup_at"): + with self.subTest(field=field): + self.assertIn(field, absent["required_absence_fields"]) + # The absence path must not reuse the covered-row requirement. + self.assertEqual( + set(absent["required_absence_fields"]) + & set(rq3["covered_rows"]["required_fields"]), + set(), + ) + # Validation must carry all three provenance rules. + ids = {r["id"] for r in self.doc["output_validation"]} + for rule_id in ("VAL-E05", "VAL-E05b", "VAL-E05c"): + self.assertIn(rule_id, ids) + columns = set(self.doc["output_schema"]["per_target_columns"]) + for column in ("provenance_kind", "absence_reason", "target_axis_ref", "lookup_at"): + with self.subTest(column=column): + self.assertIn(column, columns) + + def test_derivation_rules_are_total_and_never_exclude(self) -> None: + rules = self.doc["derivation_rules"] + ids = [r["id"] for r in rules] + self.assertEqual(len(ids), len(set(ids))) + outcomes = { + o["outcome"]: o + for lock in self.level["locks"] + if lock["lock_id"] == "LOCK-01" + for o in lock["outcomes"] + } + for rule in rules: + with self.subTest(rule=rule["id"]): + self.assertIn(rule["lock_01_outcome"], outcomes) + self.assertNotEqual( + rule["disposition"], CandidateDisposition.EXCLUDE.value + ) + self.assertEqual( + rule["resulting_state"], + outcomes[rule["lock_01_outcome"]]["resulting_state"], + ) + self.assertEqual( + sum(r["expected_count"] for r in rules), self.doc["scope"]["target_count"] + ) + # Every rule that names targets must name exactly as many as it counts. + for rule in rules: + if "measured_targets" in rule: + with self.subTest(rule=rule["id"]): + self.assertEqual( + len(rule["measured_targets"]), rule["expected_count"] + ) + + def test_precedence_is_frozen_and_covers_every_rule(self) -> None: + """Blocker 4: the five conditions are not naturally mutually exclusive.""" + + precedence = self.doc["derivation_precedence"] + rule_ids = [r["id"] for r in self.doc["derivation_rules"]] + self.assertEqual(set(precedence), set(rule_ids)) + self.assertEqual(len(precedence), len(set(precedence))) + # Absence first, then conflict, then the RQ checks, eligible last. + self.assertEqual(precedence[0], "E1-05") + self.assertEqual(precedence[1], "E1-04") + self.assertEqual(precedence[-1], "E1-01") + self.assertTrue(self.doc["precedence_rationale"].strip()) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-E11", ids) + + def _assign(self, target: dict) -> str: + """Reference implementation of the frozen precedence.""" + + conditions = { + "E1-05": target["absent"], + "E1-04": not target["absent"] and target["conflict"], + "E1-04b": not target["absent"] and not target["rq3"], + "E1-03": not target["absent"] and not target["rq2"], + "E1-02": not target["absent"] and target["families"] < 2, + "E1-01": True, + } + for rule_id in self.doc["derivation_precedence"]: + if conditions[rule_id]: + return rule_id + raise AssertionError("precedence did not cover the target") + + def test_precedence_gives_one_and_only_one_rule_per_target(self) -> None: + floor = self.doc["rq_01_plasma_membrane_localization"][ + "minimum_independent_families" + ] + # Cases chosen to exercise every overlap the reviewer named. + cases = [ + ({"absent": True, "conflict": False, "rq2": False, "rq3": False, "families": 0}, "E1-05"), + ({"absent": True, "conflict": True, "rq2": False, "rq3": False, "families": 0}, "E1-05"), + ({"absent": False, "conflict": True, "rq2": True, "rq3": True, "families": 3}, "E1-04"), + ({"absent": False, "conflict": True, "rq2": True, "rq3": True, "families": 1}, "E1-04"), + ({"absent": False, "conflict": True, "rq2": False, "rq3": True, "families": 0}, "E1-04"), + # discordance outranks a provenance failure. + ({"absent": False, "conflict": True, "rq2": True, "rq3": False, "families": 3}, "E1-04"), + # The gap the reviewer found: RQ-01 and RQ-02 hold, no conflict, RQ-03 fails. + ({"absent": False, "conflict": False, "rq2": True, "rq3": False, "families": 3}, "E1-04b"), + ({"absent": False, "conflict": False, "rq2": False, "rq3": False, "families": 3}, "E1-04b"), + ({"absent": False, "conflict": False, "rq2": True, "rq3": False, "families": 1}, "E1-04b"), + ({"absent": False, "conflict": False, "rq2": False, "rq3": True, "families": 3}, "E1-03"), + ({"absent": False, "conflict": False, "rq2": False, "rq3": True, "families": 1}, "E1-03"), + ({"absent": False, "conflict": False, "rq2": True, "rq3": True, "families": 1}, "E1-02"), + ({"absent": False, "conflict": False, "rq2": True, "rq3": True, "families": floor}, "E1-01"), + ] + for target, expected in cases: + with self.subTest(**target): + self.assertEqual(self._assign(target), expected) + + def test_provenance_failure_on_a_covered_target_has_its_own_rule(self) -> None: + """Blocker 1: RQ-03 failure on a covered target must land somewhere.""" + + rules = {r["id"]: r for r in self.doc["derivation_rules"]} + self.assertIn("E1-04b", rules) + rule = rules["E1-04b"] + self.assertEqual(rule["lock_01_outcome"], "possible_surface_target") + self.assertEqual(rule["disposition"], CandidateDisposition.DEFER.value) + self.assertEqual(rule["resulting_state"], "hold") + # Vacuous on this snapshot, but it must be declared and justified. + self.assertEqual(rule["expected_count"], 0) + self.assertIs(rule["vacuous_this_run"], True) + self.assertTrue(rule["vacuous_reason"].strip()) + # It must sit in the precedence, after discordance. + precedence = self.doc["derivation_precedence"] + self.assertIn("E1-04b", precedence) + self.assertLess(precedence.index("E1-04"), precedence.index("E1-04b")) + self.assertLess(precedence.index("E1-04b"), precedence.index("E1-01")) + # RQ-03 must name this rule as the failure route. + rq3 = self.doc["rq_03_protein_level_provenance"] + self.assertEqual(rq3["covered_row_failure_rule"], "E1-04b") + self.assertEqual( + rq3["covered_row_failure_disposition"], CandidateDisposition.DEFER.value + ) + self.assertEqual(rq3["measured_covered_targets_failing_rq_03"], 0) + self.assertEqual( + rq3["measured_covered_targets_satisfying_rq_03"], + self.doc["scope"]["measured_coverage_in_reference"], + ) + + def test_absence_fields_are_all_present_in_the_output_schema(self) -> None: + """Blocker 2: VAL-E05b cannot demand columns the schema does not have.""" + + required = set( + self.doc["rq_03_protein_level_provenance"]["absent_rows"][ + "required_absence_fields" + ] + ) + columns = set(self.doc["output_schema"]["per_target_columns"]) + self.assertTrue(required <= columns, f"missing columns: {required - columns}") + for field in ("reference_dataset_id", "reference_dataset_version", + "reference_snapshot_id"): + with self.subTest(field=field): + self.assertIn(field, columns) + + def test_conditional_columns_pin_the_admission_snapshot(self) -> None: + blocks = { + b["when_provenance_kind"]: b + for b in self.doc["output_schema"]["conditionally_required_columns"] + } + self.assertEqual(set(blocks), {"reference_absent", "source_supported"}) + absent = blocks["reference_absent"] + required = set( + self.doc["rq_03_protein_level_provenance"]["absent_rows"][ + "required_absence_fields" + ] + ) + self.assertEqual(set(absent["required_columns"]), required) + for field in ("source_ids", "source_releases", "source_urls", "licenses"): + self.assertIn(field, absent["may_be_empty_columns"]) + # The three reference columns must equal the admission snapshot, not be free text. + dep = self.doc["source_admission_dependency"] + pinned = absent["pinned_to_admission_snapshot"] + self.assertEqual(pinned["reference_dataset_id"], dep["dataset_id"]) + self.assertEqual(str(pinned["reference_dataset_version"]), str(dep["dataset_version"])) + self.assertEqual(pinned["reference_snapshot_id"], dep["snapshot_id"]) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-E05d", ids) + # And source-supported rows must still carry real source provenance. + supported = blocks["source_supported"] + for field in ("source_ids", "source_releases", "source_urls", "licenses"): + self.assertIn(field, supported["required_columns"]) + + def test_multi_condition_targets_resolve_to_the_higher_rule(self) -> None: + recorded = self.doc["measured_multi_condition_targets"] + self.assertTrue(recorded) + precedence = self.doc["derivation_precedence"] + by_id = {r["id"]: r for r in self.doc["derivation_rules"]} + for item in recorded: + with self.subTest(target=item["gene_symbol"]): + matched = item["conditions_matched"] + self.assertGreater(len(matched), 1, "not a multi-condition target") + winner = min(matched, key=precedence.index) + self.assertEqual(item["resolved_to"], winner) + # The recorded resolution must match where the rule counts it. + self.assertIn( + item["gene_symbol"], by_id[item["resolved_to"]]["measured_targets"] + ) + # And it must NOT be counted under a suppressed condition. + for suppressed in matched: + if suppressed == winner: + continue + self.assertNotIn( + item["gene_symbol"], + by_id[suppressed].get("measured_targets", []), + ) + + def test_exclusion_outcomes_remain_unavailable(self) -> None: + unavailable = {u["outcome"]: u for u in self.doc["unavailable_outcomes"]} + self.assertIn("not_surface_target", unavailable) + self.assertIn("identity_unresolved", unavailable) + for item in unavailable.values(): + self.assertTrue(item["reason"].strip()) + produced = {r["lock_01_outcome"] for r in self.doc["derivation_rules"]} + self.assertEqual(produced & set(unavailable), set()) + forbidding = [ + r for r in self.doc["output_validation"] + if "not_surface_target" in r["rule"] + ] + self.assertTrue(forbidding) + + def test_predicted_shape_reconciles_with_the_rules(self) -> None: + shape = self.doc["predicted_result_shape"] + self.assertEqual( + shape["eligible"] + shape["hold"] + shape["killed"], shape["targets_total"] + ) + self.assertEqual(shape["targets_total"], self.doc["scope"]["target_count"]) + self.assertEqual(shape["killed"], 0) + by_id = {r["id"]: r for r in self.doc["derivation_rules"]} + self.assertEqual(by_id["E1-01"]["expected_count"], shape["eligible"]) + breakdown = shape["hold_breakdown"] + self.assertEqual(sum(breakdown.values()), shape["hold"]) + for key, count in breakdown.items(): + with self.subTest(key=key): + self.assertEqual(by_id[key.split("_")[0]]["expected_count"], count) + self.assertEqual(sum(shape["eligible_via_path"].values()), shape["eligible"]) + + def test_mandatory_findings_preserve_the_uncomfortable_ones(self) -> None: + findings = {f["id"]: f["finding"] for f in self.doc["mandatory_findings"]} + self.assertEqual(set(findings), {"MF-01", "MF-02", "MF-03"}) + # The finding that contradicts the earlier consensus must be kept. + self.assertIn("GUCY2C", findings["MF-01"]) + deferred = [ + r for r in self.doc["derivation_rules"] + if "GUCY2C" in r.get("measured_targets", []) + ] + self.assertTrue(deferred, "MF-01 claims GUCY2C defers; no rule shows it") + self.assertEqual( + deferred[0]["disposition"], CandidateDisposition.DEFER.value + ) + for text in findings.values(): + self.assertTrue(text.strip()) + + def test_output_schema_carries_rule_and_path_provenance(self) -> None: + columns = self.doc["output_schema"]["per_target_columns"] + self.assertEqual(len(columns), len(set(columns))) + for required in ("rule_id", "rq_02_path", "evaluation_status", "row_checksum", + "source_urls", "licenses", "dataset_version", "snapshot_id"): + with self.subTest(column=required): + self.assertIn(required, columns) + + def test_validation_rules_are_unique_and_bar_the_forbidden_reads(self) -> None: + rules = self.doc["output_validation"] + ids = [r["id"] for r in rules] + self.assertEqual(len(ids), len(set(ids))) + text = " ".join(r["rule"] for r in rules) + for phrase in ("SHA-256", "barred_files", "Gate", "mandatory_findings"): + with self.subTest(phrase=phrase): + self.assertIn(phrase, text) + + def test_authorisation_stops_short_of_level_01_and_t7(self) -> None: + not_authorised = " ".join(self.doc["not_authorised"]) + for phrase in ("执行 Level 01", "T7", "Gate"): + with self.subTest(phrase=phrase): + self.assertIn(phrase, not_authorised) + self.assertIs( + self.doc["extraction"]["authorises_level_01_execution"], False + ) + # Level 01 itself must still be unauthorised until the follow-up PR. + self.assertIs( + self.binding["binding"]["authorises_level_01_execution"], False + ) + + +if __name__ == "__main__": + unittest.main()