diff --git a/docs/handoff/2026-08-05-evgap-02-crc-linkage-contract.zh-CN.md b/docs/handoff/2026-08-05-evgap-02-crc-linkage-contract.zh-CN.md new file mode 100644 index 0000000..3c8edcd --- /dev/null +++ b/docs/handoff/2026-08-05-evgap-02-crc-linkage-contract.zh-CN.md @@ -0,0 +1,169 @@ +# Handoff:EVGAP-02 CRC-specific linkage 证据抽取契约 + +- 日期:`2026-08-05` +- 任务分支:`task_20260805_evgap-02-crc-linkage-contract` +- 基线:`main` @ `e30a430` +- 前置:PR #57、#58、#59,均已 `APPROVE` 并合并 +- 交付物类型:**contract-only** +- 外部运行:**无。没有执行抽取,没有检索,没有产生任何证据、判定或候选。** +- 授权范围:**获 `APPROVE` 后可执行一次抽取;不授权执行 Level 01,不解除 `EVGAP-01`** +- 架构变更:`NO_ARCHITECTURE_CHANGE`(依据是 diff 范围,可由 `git diff --stat` 核验) +- 审核状态:等待 ChatGPT `APPROVE`。**本 PR 不适用 `AGENTS.md`「审核豁免」。** + +## 一、本次范围 + +人类负责人指示起 `EVGAP-02` 契约。这是 Track A,来源文档给出的优先级高于 `SRCADM-01`/`EVGAP-01`(Track B)。 + +`EVGAP-02` 阻断 `LOCK-03`:`crc_prevalence` 41 条全为 `not_available`,33 条 `adc_precedent` supporting 单元无一附 indication,因此 LOCK-03 对全部 369 个 pair 只能是 `unresolved`——这一点已在 2026-08-05 的 Level 01 Preview 中实测确认。 + +抽取范围、来源分层、四类判据、求值优先级、检索范围与输出验证均取自来源文档 `# EVGAP-02 应该具体抽取什么` 与 `# EVGAP-02 最小结果标准`。 + +## 二、仓库内交付了什么 + +| 文件 | 作用 | +|---|---| +| `docs/tasks/EVGAP_02_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md` | 抽取契约(面向操作者,中文) | +| `docs/pools/evgap_02_crc_linkage_extraction.yaml` | 机器可读绑定:来源分层、四类 linkage、检索范围冻结、五条规则与优先级、输出 schema 与 15 条验证 | +| `tests/test_evgap_02_crc_linkage.py` | 29 项校验,含 32 种条件组合的穷举求值证明 | + +## 三、与 EVGAP-01 独立,这决定了范围 + +`independent_of: [EVGAP-01, SRCADM-01]`,`blocked_by: [contract_approval]`——唯一阻断是本契约自身的审核,获批即可执行。 + +LOCK-03 问的是「target 为什么与这个 CRC clinical context 有关」,**与表面拓扑无关**,故不读 surfaceome 参考库、不受其准入状态影响。 + +因此抽取覆盖**全部 369 个 pair**,而不是 EVGAP-01 之后可能 eligible 的 22 个。**若只覆盖 22 个,本抽取就会依赖尚未获准入的 surfaceome 判定结果,既污染来源,也使两条 track 无法并行。** + +## 四、核查发现:派生库未获准入是普遍状况,不是个例 + +**仓库内 `logs/chatgpt-review-*.md` 中没有任何一条提及过任何本地派生数据库。** PR #59 发现的 surfaceome 问题不是孤例。 + +据此把来源分成两层: + +- **Tier 1 原始公开来源可直接使用**——PubMed/PMC、ClinicalTrials.gov、TCGA/GEO/HPA,加上已批准的枚举轴(PR #29)。依据是 PR #59 审核所作的区分:原始公开来源不是派生数据库,内容可由 `source_locator` 直接回溯到原始记录,不存在「构建逻辑是否遵守声明」的问题。**这一层足以支撑本次抽取,故本契约获批后即可执行,不需要等任何 admission。** +- **Tier 2 派生本地库一律禁用**,并登记四个待准入项:`SRCADM-02` ADCdb(B 类)、`SRCADM-03` CRC 文献库(A 类)、`SRCADM-04` CRC Atlas ledger(A 类)、`SRCADM-05` 竞争格局库(B 类),`admission_record_ref` 全为 `null`。后果写明:**检索完整性只在 Tier 1 声明范围内成立**;某个派生库日后获准入需另开 PR 扩大范围并重跑,不得静默扩大。 + +## 五、四类判据,其中 C 类是新增 + +**A CRC human tumor expression**:蛋白优先;**RNA 可证明 linkage 存在但绝不得替代 LOCK-01**,且必须标注。测试双向校验——本契约的 `rna_may_satisfy_lock_01: false` 与 Level 01 契约的 `rna_may_not_satisfy` 含 `LOCK-01` 必须同时成立。 + +**B CRC-specific ADC precedent**:CRC 试验/preclinical/cell line/PDO/PDX/动物模型。**仅其他癌种 precedent 不算 linkage**,降为 `metadata_only_hold`(`L3-04`)——与 #58 已冻结规则一致。 + +**C CRC-specific target-directed modality evidence(本契约新增)**:naked antibody、CAR-T、bispecific、radioimmunotherapy、immunotoxin、imaging antibody。它们证明 target 在 CRC 中可接近或可干预,满足 LOCK-03 存在性(该锁只问关联存在、不问 ADC 疗效),但**必须显式标注 `is_adc_efficacy_evidence: false`**。这是现有契约尚未涵盖的依据,来源文档点名要求加入。 + +**D Context-specific enrichment**:疾病级 CRC 证据只支持 canonical context,亚群必须有 D 类证据才能 RETAIN。 + +## 六、检索范围冻结是本契约最关键的一节 + +PR #58 判定 `no_known_linkage_after_complete_search` **不可用**,理由是检索范围未闭合。本契约冻结范围,正是使该 outcome 变为可用的前提——范围一旦冻结,「是否完成规定检索」就成为**可判定的事实**,而不是执行者的自我声明。 + +每个 target 必须对三类 Tier 1 来源各执行检索;必须记录 query template(target 符号与同义词、CRC 术语、类别特异术语、日期范围);每次检索必须记录 `query_expression`、`executed_at`、`result_count`、`reachable`;**来源不可达即判该 target 检索未完成**,禁止静默跳过。 + +检索粒度也写明了:A/B/C 的疾病级检索按 target 一次(41),D 类按 pair(369)。避免「检索次数」这个数字被误读。 + +## 七、五条规则与穷举证明 + +优先级 `L3-01` → `L3-02` → `L3-03` → `L3-04` → `L3-05`。**先判检索是否完成——未完成时「没找到」无法与「不存在」区分。** + +只有 `L3-02` 可 RETAIN,只有 `L3-05` 可 EXCLUDE,测试断言各自恰好一条。`L3-05` 的 EXCLUDE 严格限定为 `EXCLUDE_FROM_ACTIVE_POOL`,`is_scientific_disproof` 与 `is_killed` 均为 `false`,状态 `reactivation-eligible`,并须六项检索完整性字段齐备。 + +测试用参考实现穷举 `search_complete × crc_specific × canonical × class_d × other_cancer` 的全部 **32 种组合**,证明每种恰好命中一条、且五条规则都可达——这是 PR #59 阻断 4 的教训,这次一开始就做。 + +## 八、本契约有意不给预期结果形状 + +EVGAP-01 读固定数据集,结果可事先算出并逐项核对(22/19)。**EVGAP-02 是发现型检索,事先给出这类数字就是把预测冒充成结果**——来源文档列为第二种必须避免的混淆。 + +因此不预测计数,改为冻结检索范围、完整性定义、求值优先级、provenance 要求与输出验证。测试断言 `provided: false` 且不得以别名偷偷塞入计数(变异检验含此项)。 + +## 九、沿用前几轮教训的三处设计 + +- **provenance 三分**(PR #59 阻断 3):`source_supported` / `no_evidence_found_after_complete_search` / `search_incomplete`,后两种允许 `source_ref` 为空但**禁止伪造**,出现非空即验证失败。 +- **条件必填列必须在 schema 之内**(PR #59 阻断 2):测试直接断言子集关系。 +- **冻结优先级并穷举证明**(PR #59 阻断 4 与 #58 阻断 2):见第七节。 + +## 十、明确没有做什么 + +- **没有执行抽取**,没有发起任何检索,没有产生任何证据行、disposition 或候选。 +- 没有执行 Level 01,也不授权执行;**没有解除 `EVGAP-01`**。 +- 没有读取任何 Tier 2 派生本地数据库;没有把任何派生数据库纳入已批准来源。 +- 没有评估 T2、T7 或任何 Gate;没有排序、Tier 划分、资产推荐或实验建议。 +- 没有新增靶点或 clinical context。 +- 没有引用被隔离运行(PR #53、#54)的任何产物。 +- 没有更新 `adc_pool_level_01_input_binding.yaml`——解除 `EVGAP-02` 须待抽取执行、结果 PR 获批后另开 PR。 +- 没有预测结果计数(见第八节)。 +- 未补 #52/#53/#54/#57/#58/#59/#60 的批准记录(现为七份),事实已查全但未写文件。 +- 没有修 `requirements.txt` 注释里过期的「207 tests」(实测 338)。属无关改动。 + +## 十一、验证结果 + +- `Ran 338 tests` 全部通过(`main` 基线 309 + 本次新增 29)。 +- `scripts/verify_repository_boundary.sh`:`Repository boundary check passed.` +- `git diff --check`:通过;零 `__pycache__`。 +- **13 个变异全部被捕获后精确回滚**,与备份 `diff -q` 一致、测试恢复 `OK`:让 RNA 满足 LOCK-01、把 C 类声明为 ADC 疗效证据、让泛癌 precedent 算作 linkage、让疾病级证据支撑亚群、把 `L3-03` 改判 RETAIN、把完整检索排除改判 killed、把检索完整性排到优先级最后、允许静默跳过来源、开放 Tier 2 派生库、自行填入 `SRCADM-02` 记录、偷偷加入预测计数、要求未找到证据的行也有 `source_ref`、把范围缩到依赖 LOCK-01 状态。 +- 一处测试自身的错误已修:初稿断言「除 `L3-05` 外全部 DEFER」,漏了 `L3-02` 是 RETAIN 规则;改为逐规则断言并加「恰好一条 RETAIN、恰好一条 EXCLUDE」。 + +## 十二、后续顺序 + +1. 本契约 `APPROVE`。 +2. 执行抽取 → 结果 PR → `APPROVE`。 +3. 另开 PR 绑定产物并解除 `EVGAP-02`。 +4. `EVGAP-01` 由 Track B 独立推进(`SRCADM-01` → 抽取 → 结果 → binding)。 +5. **两个缺口都解除后,才能生成 `ADC_POOL_LEVEL_01_ACCEPTED`。** + +## 十三、当前阻断 + +- 本契约获 `APPROVE` 前,不得执行抽取。 +- 抽取完成也**不**解除 `EVGAP-01`,Level 01 仍不可执行。 +- 本仓库不得写入证据、候选、快照、cache、result 或 weights。 + +## 十四、第一轮审核裁决与修订(`REQUEST_CHANGES`,三条阻断全部接受) + +ChatGPT 对 PR #61(HEAD `430e85f`)返回 `REQUEST_CHANGES`。三条**全部接受**。 + +### 阻断 1(接受):D 类检索没有真正进入 search completeness,且 endpoint 覆盖语义未冻结 + +契约声明 A/B/C 按 target 检索、D 类按 369 个 pair 评估,但 `search_complete_definition` **只要求每个 target 完成三类 source-class 检索**,没有要求每个 pair 对 D 类完成可审计的检索。审核方指出的两个后果都成立:subgroup pair 可能在未真正检索 D 类时直接落 `L3-03`;「四类均无命中」的 `L3-05` 也可能在 D 类未检索时被错误触发。 + +同时,`peer_reviewed_literature` 有 PubMed 与 PMC 两个 endpoint、`public_molecular_dataset` 有三个,而契约只要求覆盖 source class——**执行者可以只查其中一个,结果不可复现。** + +修订: + +- 新增 `per_pair_required_class_d_search`,369 个 pair 全覆盖,每 pair 记录六个字段(`class_d_query_expression`/`class_d_executed_at`/`class_d_result_count`/`class_d_reachable`/`class_d_source_coverage_ref`/`class_d_search_complete`),六个字段同时进入 `disposition_columns` +- `incomplete_consequence: L3-01`——**D 类未完成必须落 `L3-01`,不得落 `L3-03` 或 `L3-05`**(`VAL-L18`);`L3-03` 与 `L3-05` 都加 `requires_class_d_search_complete: true`;`L3-01` 加 `covers_both_completeness_levels: true` +- `search_complete_requires_both_levels: true`,两级为 target 级 endpoint 覆盖与 pair 级 D 类覆盖 +- `coverage_unit: endpoint`,每个 source class 加 `all_endpoints_required: true` 与 `minimum_endpoint_set`(PubMed+PMC;ClinicalTrials.gov;TCGA+GEO+HPA),缺任一 endpoint 该 target 全部 pair 落 `L3-01`(`VAL-L19`) +- 新增 `unreachable_class_d_consequence: search_incomplete_for_that_pair` + +### 阻断 2(接受):disposition 与 evidence 之间没有稳定引用关系 + +初稿 disposition 表只有 `evidence_row_count`,**单条 disposition 无法证明自己由哪些 evidence 行支持**。而我的测试用两张表列的**并集**验证条件必填字段存在——审核方指出这恰好掩盖了问题。这条批评对测试方法本身,比对契约更准。 + +修订: + +- evidence 表新增唯一 `evidence_id`(`evidence_row_key`/`evidence_id_unique`) +- disposition 表新增三组引用:`supporting_evidence_refs`、`class_d_evidence_refs`、`other_cancer_evidence_refs` +- 新增 `evidence_reference_requirements`,逐规则冻结「必须引用什么、必须不引用什么」,`L3-02` 按 canonical/subgroup 拆成两条:canonical 至少一条 A/B/C;subgroup 另需至少一条 D;`L3-03` 须有疾病级证据且 D refs 为空;`L3-04` supporting 必须为空、只能引用 other-cancer;`L3-05` 三组全空且检索 provenance 完整;`L3-01` 不得伪造 +- 新增 `VAL-L16`(引用的 id 必须存在于 evidence 表且 `pair_id` 一致)、`VAL-L17`(逐规则引用约束)、`VAL-L20`(`evidence_row_count` 必须等于三组 refs 去重后总条数) +- 每个 `conditionally_required_columns` 块加 `table` 字段,**测试改为逐表检查,不再用并集**(`VAL-L09` 同步改写) + +### 阻断 3(接受):PR 不可合并 + +同步 `origin/main`(`0190a73`)。`logs/worklog.md` 冲突按时间顺序解决:main 的 12:45 EDT 在前、我的 13:30 在后,断言无残留冲突标记、两侧每个条目与每个标题都在。 + +合并后核验:相对 `main` 仍**只有 5 个文件**(本 handoff、契约 YAML、契约文档、worklog、测试),**未混入 PR #60 的 preview 结果**(`grep -c preview` = 0),未引入其他无关契约。 + +### 一处执行者操作失误,已自查并修复 + +中途为处理 PR #60 而切分支时执行了 `git stash -u`,把阻断 1/2 的未提交 YAML 改动一并藏入栈中,切回后未恢复,导致随后的编辑落在了未修订的版本上、测试报 `KeyError`。已定位 `stash@{0}`、丢弃冲突编辑、`git stash pop` 恢复全部 133 行改动后重做,无内容丢失。 + +### 本轮变异检验 + +12 个变异全部被捕获后精确回滚,与备份 `diff -q` 一致、测试恢复 `OK`:把 D 类改为非必需、让 D 类未完成落 `L3-03`、让 `L3-05` 不要求 D 类完成、让完整性只要 target 级、覆盖粒度退回 source class、只查 PubMed 就算覆盖 class、去掉 `evidence_id`、让 `L3-04` 可引用 supporting refs、让 subgroup RETAIN 不需 D refs、让 `L3-05` 可引用证据、把必填列声明到错误的表、删掉 `L3-03` 的 D refs 为空约束。 + +### 审核方认可、本轮未改动的部分 + +EVGAP-02 与 EVGAP-01/SRCADM-01 独立;覆盖全部 369 pairs 而非 provisional 22;Tier 1/Tier 2 分层合理且 Tier 2 在 admission 前保持禁用;A/B/C/D 四类框架完整;RNA 可支持 linkage 但不满足 LOCK-01;C 类可作 CRC linkage existence 但必须明确不是 ADC efficacy;disease-level 不能自动支持 subgroup;other-cancer precedent 只能作 metadata;`L3-05` 是可逆的 `EXCLUDE_FROM_ACTIVE_POOL` 而非科学证伪或 killed;不执行 Gate、不执行 Level 01、不解除 `EVGAP-01`;不预写 discovery run 的结果数量。 + +### 审核回写状态 + +审核方尝试通过 GitHub 连接器提交正式 `REQUEST_CHANGES`,连接器返回 403,未写回 GitHub。裁决以人类负责人转述为准,已记录于本节与 `logs/worklog.md`。 diff --git a/docs/pools/evgap_02_crc_linkage_extraction.yaml b/docs/pools/evgap_02_crc_linkage_extraction.yaml new file mode 100644 index 0000000..c44650d --- /dev/null +++ b/docs/pools/evgap_02_crc_linkage_extraction.yaml @@ -0,0 +1,596 @@ +# EVGAP-02:CRC-specific target–context linkage 证据抽取绑定(machine-readable) +# +# 目的:解除 PR #58 登记的 EVGAP-02,使 Level 01 的 LOCK-03 可以真正求值。 +# 本文件不含任何证据数据,只含来源分层、四类 linkage 判据、求值优先级、 +# 检索范围冻结、输出 schema 与验证规则。 +# +# 由 tests/test_evgap_02_crc_linkage.py 校验。 +# LOCK-03 的 outcome 词表见 docs/pools/adc_pool_gate_usage.yaml(PR #57)。 +# 来源文档:Zhixins-KB/2.Biotech/Asset-Generation-OS-architecture.md#EVGAP-02 应该具体抽取什么 + +extraction: + extraction_id: evgap_02_crc_linkage + extraction_version: 0.1.0 + discharges_gap: EVGAP-02 + gap_source: docs/pools/adc_pool_level_01_input_binding.yaml + serves_lock: LOCK-03 + status: contract_only_pending_review + execution_status: not_authorized_not_executed + # 与 EVGAP-01 不同:本抽取不依赖任何未获准入的派生数据库, + # Tier 1 原始公开来源即可支撑,故获 APPROVE 后即可执行。 + authorises_extraction_run_after_approve: true + authorises_level_01_execution: false + blocked_by: + - contract_approval + requires_followup_binding_pr: true + # 本抽取与 EVGAP-01/SRCADM-01 完全独立,可并行执行。 + independent_of: + - EVGAP-01 + - SRCADM-01 + independence_reason: >- + LOCK-03 问的是「target 为什么与这个 CRC clinical context 有关」, + 与表面拓扑无关,因此不读取 surfaceome 参考库,也不受其准入状态影响。 + +# ---------------------------------------------------------------- 来源分层 +# +# 治理事实:仓库内 logs/chatgpt-review-*.md 中**没有任何一条**提及过任何本地 +# 派生数据库。surfaceome 的准入问题不是个例,而是普遍状况。因此本契约把来源 +# 分成两层:原始公开来源可直接使用;派生本地库一律须各自取得 admission。 +source_tiers: + tier_1_primary_public: + admissible_without_separate_admission: true + rationale: >- + 原始公开来源不是派生数据库,其内容可由 source_locator 直接回溯到原始记录, + 不存在「构建逻辑是否遵守声明」的问题。这正是 PR #59 审核所作的区分。 + sources: + - source_class: peer_reviewed_literature + # 每个 endpoint 都必查,不允许只查其中一个就算覆盖该 class。 + endpoints: + - PubMed + - PMC + all_endpoints_required: true + minimum_endpoint_set: + - PubMed + - PMC + must_record: + - pmid_or_pmcid + - title + - journal + - year + - retrieved_at + - source_class: clinical_trial_registry + endpoints: + - ClinicalTrials.gov + all_endpoints_required: true + minimum_endpoint_set: + - ClinicalTrials.gov + must_record: + - nct_id + - phase + - status + - indication_text + - retrieved_at + - source_class: public_molecular_dataset + endpoints: + - TCGA + - GEO + - Human Protein Atlas + all_endpoints_required: true + minimum_endpoint_set: + - TCGA + - GEO + - Human Protein Atlas + must_record: + - dataset_accession + - dataset_version_or_release + - query_expression + - retrieved_at + - source_class: approved_internal_result + endpoints: + - external:result/gen_iet_crc_target_enumeration_20260802 + authorising_pr: 29 + role: target 轴与 clinical context 轴,唯一用途是定义抽取范围 + must_record: + - source_id + - sha256 + + tier_2_derived_local_databases: + admissible_without_separate_admission: false + barred_until_admitted: true + reason: >- + 派生数据库的构建逻辑未经审计,自声明不等于已验证——PR #59 已就此裁决。 + 每一个都必须有自己的 admission 记录才能作为 EVGAP-02 的来源。 + pending_admissions: + - id: SRCADM-02 + dataset_id: ADCdb + would_serve_linkage_class: B + admission_record_ref: null + - id: SRCADM-03 + dataset_id: CRC_journal_whitelist_literature + would_serve_linkage_class: A + admission_record_ref: null + - id: SRCADM-04 + dataset_id: CRC_Atlas_fulltext_accession_ledger + would_serve_linkage_class: A + admission_record_ref: null + - id: SRCADM-05 + dataset_id: ADC_competitive_landscape_reference + would_serve_linkage_class: B + admission_record_ref: null + # 本次抽取不使用它们;使用即为违约。 + used_by_this_extraction: false + consequence_of_exclusion: >- + 检索完整性只能按 Tier 1 声明范围判定。若将来某个派生库获准入, + 需另开 PR 扩大 declared_search_scope 并重跑,不得静默扩大。 + +# ---------------------------------------------------------------- 抽取范围 +scope: + unit: pair + pair_definition: clinical_context_id x target + clinical_context_axis: external:result/gen_iet_crc_target_enumeration_20260802#indication_endpoint_universe.tsv + clinical_context_axis_sha256: b0b9506644df5453b97982f8c934b10795890b9c602342d7716124948c55d727 + clinical_context_count: 9 + target_axis: external:result/gen_iet_crc_target_enumeration_20260802#target_evidence_catalog.tsv + target_axis_sha256: 27bb81ebe5f1048b732f62c530095e0007212f818f3844e004118bb94525cc28 + target_count: 41 + pair_count: 369 + new_targets_allowed: false + new_contexts_allowed: false + # 覆盖全部 369 个 pair,而不是只覆盖 EVGAP-01 之后可能 eligible 的 22 个。 + covers_all_pairs: true + does_not_depend_on_lock_01_status: true + scope_reason: >- + 若只覆盖 22 个 pair,本抽取就会依赖尚未获准入的 surfaceome 判定结果, + 既污染来源,也使两条 track 无法并行。全部 369 个 pair 独立于 LOCK-01。 + # 检索粒度:疾病级证据按 target 检索一次,情境特异性按 pair 判定。 + search_granularity: + disease_level_search_unit: target + disease_level_search_count: 41 + context_specific_assessment_unit: pair + context_specific_assessment_count: 369 + reason: >- + A/B/C 三类的疾病级检索对同一 target 在 9 个 context 下结果相同, + 按 target 检索一次即可,避免 369 次冗余检索; + D 类情境特异性富集必须按 pair 判定。 + +# ------------------------------------------------- 四类 linkage 判据(来源文档) +linkage_classes: + - class_id: A + name: crc_human_tumor_expression + accepts: + - CRC patient samples + - primary tumour + - metastatic lesion + - treatment-resistant context + must_record_when_available: + - positive_fraction_or_prevalence + - malignant_cell_attribution + protein_preferred_over_rna: true + # RNA 可以证明 linkage 存在,但绝不得替代 LOCK-01 的蛋白层面判据。 + rna_admissible_for_linkage_existence: true + rna_may_satisfy_lock_01: false + rna_must_be_labelled: true + - class_id: B + name: crc_specific_adc_precedent + accepts: + - CRC clinical trial + - CRC preclinical ADC + - CRC cell line + - CRC PDO + - CRC PDX + - CRC animal model + other_cancer_only_precedent_counts_as_linkage: false + other_cancer_only_precedent_disposition: metadata_only_hold + - class_id: C + name: crc_specific_target_directed_modality + accepts: + - naked antibody + - CAR-T + - bispecific + - radioimmunotherapy + - immunotoxin + - imaging antibody + proves: target 在 CRC 中可接近或可干预 + # 必须显式标注:这不是 ADC 疗效证据。 + is_adc_efficacy_evidence: false + must_be_labelled_not_adc_efficacy: true + counts_as_linkage_existence: true + counts_as_linkage_reason: >- + LOCK-03 只问「是否存在公开证据表明该 target 与该 CRC context 有关」, + 不问 ADC 疗效。CRC 中的靶向干预证据满足前者。 + - class_id: D + name: context_specific_enrichment + accepts: + - MSS/pMMR + - post-anti-EGFR + - liver metastasis + - treatment-resistant + - HER2-positive + - refractory metastatic CRC + # 疾病级 CRC 证据只支持 canonical context,不自动支持任何亚群。 + disease_level_supports_canonical_only: true + subgroup_requires_class_d: true + +# ---------------------------------------------------- 检索范围冻结(可判定完整性) +# +# PR #58 曾判定 no_known_linkage_after_complete_search 不可用,理由是检索范围 +# 未闭合。本节正是使该 outcome 变为可用的前提:范围一旦冻结, +# 「是否完成规定检索」就成为可判定的事实,而不是执行者的自我声明。 +declared_search_scope: + scope_version: 0.1.0 + per_target_required_source_classes: + - peer_reviewed_literature + - clinical_trial_registry + - public_molecular_dataset + query_template_required: true + query_template_must_record: + - target_symbol_and_synonyms + - crc_terms + - class_specific_terms + - date_range + date_range_upper_bound: run_date + per_source_must_record: + - source_class + - endpoint + - query_expression + - executed_at + - result_count + - reachable + # 覆盖粒度:按 endpoint 判定,不是按 source class 判定。 + coverage_unit: endpoint + coverage_unit_reason: >- + 只要求覆盖 source class,执行者可以只查 PubMed 不查 PMC、只查 TCGA 不查 GEO 与 HPA, + 结果不可复现。故每个 source class 都写明 minimum_endpoint_set 且 all_endpoints_required。 + + # D 类是 pair 级的,必须单独有可审计的检索完整性契约, + # 否则 subgroup pair 可能在从未检索 D 类的情况下直接落 L3-03, + # 或使「四类均无命中」的 L3-05 被错误触发。 + per_pair_required_class_d_search: + required: true + applies_to: all_369_pairs + must_record: + - class_d_query_expression + - class_d_executed_at + - class_d_result_count + - class_d_reachable + - class_d_source_coverage_ref + - class_d_search_complete + incomplete_consequence: L3-01 + incomplete_consequence_note: >- + 某个 pair 的 D 类检索未完成时,该 pair 必须落 L3-01 linkage_evidence_missing, + **不得**落 L3-03,也**不得**落 L3-05。 + + # 「完成规定检索」的定义。任一条不满足即为未完成。 + search_complete_definition: + - 每个 target 都对 per_target_required_source_classes 的每一类的**每一个 endpoint** 执行过检索 + - 每次检索都记录了 query_expression、executed_at、result_count 与 reachable + - 每个 pair 都完成了 per_pair_required_class_d_search 并记录其六个字段 + - 所有 reachable = false 的 endpoint 与 D 类检索都被显式记录为未完成,而非静默跳过 + search_complete_requires_both_levels: true + search_complete_levels: + - target_level_source_class_and_endpoint_coverage + - pair_level_class_d_coverage + unreachable_source_consequence: search_incomplete_for_that_target + unreachable_class_d_consequence: search_incomplete_for_that_pair + silent_skip_forbidden: true + +# ------------------------------------------------ LOCK-03 求值规则与冻结优先级 +# +# 五条规则必须覆盖全部 pair 且互斥。优先级先判检索完整性, +# 因为检索未完成时其余判据都不可信。 +derivation_precedence: + - L3-01 + - L3-02 + - L3-03 + - L3-04 + - L3-05 +precedence_rationale: >- + 先判检索是否完成——未完成时「没找到」无法与「不存在」区分; + 再判是否存在与该 context 匹配的 CRC-specific 证据; + 再判疾病级证据遇亚群 context 的降级;再判仅有其他癌种 precedent 的降级; + 以上都不成立且检索已完成,才允许判定完整检索后无 linkage。 + +derivation_rules: + - id: L3-01 + condition: >- + 未按 declared_search_scope 完成规定检索——target 级 endpoint 覆盖未齐, + **或**该 pair 的 D 类检索未完成 + lock_03_outcome: linkage_evidence_missing + disposition: DEFER + resulting_state: hold + reason: 检索未完成时「没找到」不能与「不存在」区分。 + covers_both_completeness_levels: true + - id: L3-02 + condition: >- + 存在 A/B/C 任一类的 CRC-specific 证据,且 context 为 canonical_c0, + 或 context 为亚群且同时存在 D 类情境特异性证据 + lock_03_outcome: linkage_evidence_exists + disposition: RETAIN + resulting_state: active + - id: L3-03 + condition: >- + D 类检索已完成,存在 CRC 疾病级证据,但 context 为亚群且 D 类无命中 + requires_class_d_search_complete: true + lock_03_outcome: linkage_unassessed + disposition: DEFER + resulting_state: hold + reason: 疾病级 CRC 证据不能建立亚群特异 linkage。 + - id: L3-04 + condition: 仅有其他癌种的 precedent,无任何 CRC-specific 证据 + lock_03_outcome: linkage_unassessed + disposition: DEFER + resulting_state: hold + reason: 泛癌 precedent 只证明 modality precedent,不证明与 CRC context 的 linkage。 + retained_as: target_modality_metadata + - id: L3-05 + condition: >- + 已按 declared_search_scope 完成规定检索(含该 pair 的 D 类检索), + 且四类 linkage 均无命中 + requires_class_d_search_complete: true + lock_03_outcome: no_known_linkage_after_complete_search + disposition: EXCLUDE + disposition_semantics: EXCLUDE_FROM_ACTIVE_POOL + resulting_state: reactivation-eligible + is_scientific_disproof: false + is_killed: false + retained_in_eligible_universe_index: true + requires_search_completeness_record: true + required_search_fields: + - search_complete + - search_policy_ref + - source_coverage_ref + - search_scope + - searched_at + - search_policy_version + +# 本契约不给出预期结果形状,这与 EVGAP-01 不同。 +predicted_result_shape: + provided: false + reason: >- + EVGAP-01 读取的是已固定的数据集,结果可以事先算出并逐项核对。 + EVGAP-02 是发现型检索,事先给出 22/19 这类数字就会把预测冒充成结果—— + 正是来源文档列为第二种必须避免的混淆。因此本契约不预测计数, + 改为冻结 declared_search_scope,使完整性与每一条 disposition 都可事后核验。 + what_is_frozen_instead: + - declared_search_scope + - search_complete_definition + - derivation_precedence + - provenance requirements + - output validation + +# ---------------------------------------------------------------- 输出 +output_schema: + # 每条证据一行。来源文档规定的 13 列为必需最小集。 + # evidence_id 使每条 disposition 都能一对一回溯到支持它的具体证据行。 + evidence_row_key: evidence_id + evidence_id_unique: true + evidence_columns: + - evidence_id + - pair_id + - target + - clinical_context_id + - evidence_type + - crc_specific + - context_specific + - human_or_model + - protein_or_rna + - source_ref + - source_locator + - evidence_direction + - review_status + - linkage_outcome + # 以下为可审计性所需的补充列。 + - linkage_class + - is_adc_efficacy_evidence + - positive_fraction_or_prevalence + - malignant_cell_attribution + - retrieved_at + # 每个 pair 一行的 disposition 表。 + disposition_columns: + - pair_id + - target + - clinical_context_id + - rule_id + - lock_03_outcome + - disposition + - disposition_semantics + - resulting_state + - evaluation_status + - linkage_classes_hit + - search_complete + - search_policy_ref + - source_coverage_ref + - search_scope + - searched_at + - search_policy_version + - provenance_kind + - evidence_row_count + # 三组引用,使每条 disposition 可逐条审计其依据。 + - supporting_evidence_refs + - class_d_evidence_refs + - other_cancer_evidence_refs + # D 类 pair 级检索完整性的六个字段。 + - class_d_query_expression + - class_d_executed_at + - class_d_result_count + - class_d_reachable + - class_d_source_coverage_ref + - class_d_search_complete + - no_evidence_found_reason + - provisional_only + - may_advance_to_level_02 + # 每条规则必须引用什么、必须不引用什么。这是 disposition 与 evidence 之间的 + # 一对一审计关系;仅有 evidence_row_count 无法证明某条 disposition 由哪些证据支持。 + evidence_reference_requirements: + - rule_id: L3-01 + supporting_evidence_refs: must_be_empty + class_d_evidence_refs: must_be_empty + other_cancer_evidence_refs: may_be_empty + note: 检索未完成时不得伪造任何 evidence refs。 + - rule_id: L3-02 + context_kind: canonical + supporting_evidence_refs: at_least_one_of_class_a_b_or_c + class_d_evidence_refs: may_be_empty + other_cancer_evidence_refs: may_be_present_but_must_not_be_counted_as_linkage + - rule_id: L3-02 + context_kind: subgroup + supporting_evidence_refs: at_least_one_of_class_a_b_or_c + class_d_evidence_refs: at_least_one + other_cancer_evidence_refs: may_be_present_but_must_not_be_counted_as_linkage + - rule_id: L3-03 + supporting_evidence_refs: at_least_one_disease_level_crc_evidence + class_d_evidence_refs: must_be_empty + other_cancer_evidence_refs: may_be_empty + - rule_id: L3-04 + supporting_evidence_refs: must_be_empty + class_d_evidence_refs: must_be_empty + other_cancer_evidence_refs: at_least_one + note: 其他癌种 precedent 只能出现在 other_cancer_evidence_refs,不得进入 supporting。 + - rule_id: L3-05 + supporting_evidence_refs: must_be_empty + class_d_evidence_refs: must_be_empty + other_cancer_evidence_refs: must_be_empty + search_provenance: must_be_complete + provenance_kinds: + - source_supported + - no_evidence_found_after_complete_search + - search_incomplete + # 找不到证据的 pair 不得伪造 source provenance,须记录检索 provenance。 + conditionally_required_columns: + - when_provenance_kind: source_supported + # 必填列所属的表。审核必须逐表检查,不得用两张表列的并集代替。 + table: evidence + required_columns: + - source_ref + - source_locator + - retrieved_at + may_be_empty_columns: + - no_evidence_found_reason + - when_provenance_kind: no_evidence_found_after_complete_search + table: disposition + required_columns: + - search_complete + - search_policy_ref + - source_coverage_ref + - search_scope + - searched_at + - search_policy_version + - no_evidence_found_reason + may_be_empty_columns: + - source_ref + - source_locator + fabricating_source_evidence_forbidden: true + - when_provenance_kind: search_incomplete + table: disposition + required_columns: + - search_complete + - search_scope + - searched_at + - no_evidence_found_reason + may_be_empty_columns: + - source_ref + - source_locator + fabricating_source_evidence_forbidden: true + +output_validation: + - id: VAL-L01 + rule: disposition 表必须恰好 369 行,每行按 derivation_precedence 命中且仅命中一条规则并记录 rule_id。 + - id: VAL-L02 + rule: 每行的 lock_03_outcome、disposition、resulting_state 必须与 derivation_rules 中同一 rule_id 的声明逐项一致。 + - id: VAL-L03 + rule: >- + RNA 来源的证据行必须 protein_or_rna = rna 且显式标注; + 任何 RNA 行都不得被引用为 LOCK-01 依据。 + - id: VAL-L04 + rule: >- + C 类证据行必须 is_adc_efficacy_evidence = false; + 任何 C 类行被表述为 ADC 疗效证据即为验证失败。 + - id: VAL-L05 + rule: >- + 仅有其他癌种 precedent 的 pair 必须落 L3-04,其证据行 crc_specific = false, + 且不得计入 linkage_classes_hit。 + - id: VAL-L06 + rule: >- + context 为亚群且无 D 类证据的 pair 必须落 L3-03,不得因疾病级证据而 RETAIN。 + - id: VAL-L07 + rule: >- + L3-05 的行必须六项检索完整性字段齐备且 search_complete = true; + 其 disposition_semantics 必须为 EXCLUDE_FROM_ACTIVE_POOL, + is_scientific_disproof 与 is_killed 必须为 false,resulting_state 必须为 reactivation-eligible。 + - id: VAL-L08 + rule: >- + provenance_kind = no_evidence_found_after_complete_search 或 search_incomplete 的行, + source_ref 与 source_locator 允许为空,但不得伪造;出现非空 source_ref 即为验证失败。 + - id: VAL-L09 + rule: >- + conditionally_required_columns 声明的必填列必须存在于其所属的那张表内; + 不得用两张表列的并集代替逐表检查。 + - id: VAL-L16 + rule: >- + 每条 evidence 行必须有唯一 evidence_id;每条 disposition 行的 + supporting_evidence_refs、class_d_evidence_refs、other_cancer_evidence_refs + 引用的每个 id 都必须存在于 evidence 表,且所引用行的 pair_id 与该 disposition 一致。 + - id: VAL-L17 + rule: >- + 每条 disposition 必须满足 evidence_reference_requirements 中其 rule_id 与 + context_kind 对应的条目:L3-02 canonical 至少一条 A/B/C;L3-02 subgroup 另需至少一条 D; + L3-03 须有疾病级 CRC 证据且 D refs 为空;L3-04 只能引用 other-cancer refs; + L3-05 三组 refs 全空且检索 provenance 完整;L3-01 不得伪造任何 refs。 + - id: VAL-L18 + rule: >- + 每个 pair 必须记录 per_pair_required_class_d_search 的六个字段。 + 任一 pair 的 class_d_search_complete 为 false 时该 pair 必须落 L3-01, + 不得落 L3-03 或 L3-05。 + - id: VAL-L19 + rule: >- + target 级覆盖按 endpoint 判定:每个 source class 的 minimum_endpoint_set + 中每个 endpoint 都必须有检索记录,缺任一即该 target 的全部 pair 落 L3-01。 + - id: VAL-L20 + rule: >- + evidence_row_count 必须等于该 pair 三组 refs 去重后的总条数, + 不得只报计数而不给 refs。 + - id: VAL-L10 + rule: 不得读取 tier_2_derived_local_databases 中的任何数据库。 + - id: VAL-L11 + rule: >- + 每个 target 的检索记录必须覆盖 per_target_required_source_classes 的每一类的 + 每一个 endpoint,未达即该 target 的全部 pair 落 L3-01。 + - id: VAL-L12 + rule: 输出中不得出现任何 Gate 分数、Gate 状态、T2/T7 判定、排序、Tier 划分或实验建议。 + - id: VAL-L13 + rule: 输入轴文件的 SHA-256 必须与本契约记录一致,不一致即中止执行。 + - id: VAL-L14 + rule: 每个产物文件必须逐文件记录 SHA-256 并写入结果 PR 的 handoff。 + - id: VAL-L15 + rule: >- + 每行必须带 provisional_only 与 may_advance_to_level_02; + 在 EVGAP-01 未解除前 may_advance_to_level_02 恒为 false。 + +# 必须写进结果报告的约束。 +mandatory_findings: + - id: MF-L01 + finding: >- + LOCK-03 RETAIN 只表示存在可回溯的 CRC-specific linkage 证据, + 不表示该靶点适合 ADC、不表示疗效、不表示治疗窗。 + - id: MF-L02 + finding: >- + C 类证据(naked antibody/CAR-T/bispecific/RIT/immunotoxin/imaging) + 证明 target 在 CRC 中可接近或可干预,**不是 ADC 疗效证据**,必须在报告中原样标注。 + - id: MF-L03 + finding: >- + 本次抽取未使用任何派生本地数据库,检索完整性只在 Tier 1 声明范围内成立。 + 若某个派生库日后获准入,需另开 PR 扩大范围并重跑。 + - id: MF-L04 + finding: >- + LOCK-03 RETAIN 不使 pair 进入 Level 02。EVGAP-01 未解除前 + may_advance_to_level_02 恒为 false。 + +not_authorised: + - 执行 Level 01 + - 解除 EVGAP-01 + - 读取任何 tier_2 派生本地数据库 + - 把任何派生数据库纳入已批准来源 + - 评估 T2、T7 或任何 Gate + - 新增靶点或 clinical context + - 任何筛选排序、Tier 划分、资产推荐或实验建议 + - 把被隔离运行(PR #53、#54)的任何产物引入 diff --git a/docs/tasks/EVGAP_02_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md b/docs/tasks/EVGAP_02_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md new file mode 100644 index 0000000..adc7070 --- /dev/null +++ b/docs/tasks/EVGAP_02_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md @@ -0,0 +1,206 @@ +# EVGAP-02:CRC-specific target–context linkage 证据抽取契约 + +- 任务分支:`task_20260805_evgap-02-crc-linkage-contract` +- 前置工作包:PR #57(Level 01 判据定义)、#58(输入绑定与缺口登记)、#59(EVGAP-01 抽取契约),均已 `APPROVE` 并合并 +- 机器可读绑定:[`../pools/evgap_02_crc_linkage_extraction.yaml`](../pools/evgap_02_crc_linkage_extraction.yaml),由 `tests/test_evgap_02_crc_linkage.py` 校验 +- 来源文档:`Zhixins-KB/2.Biotech/Asset-Generation-OS-architecture.md` 的 `# EVGAP-02 应该具体抽取什么` 与 `# EVGAP-02 最小结果标准`(只读取,未修改) +- 当前状态:**contract-only,未执行,等待 ChatGPT 审核** +- 授权范围:**获 `APPROVE` 后即可执行一次抽取。不授权执行 Level 01,不解除 `EVGAP-01`。** + +## 目的 + +解除 PR #58 登记的 `EVGAP-02`,使 Level 01 的 `LOCK-03` 可以真正求值。 + +现状是:`crc_prevalence` 41 条全为 `not_available`,33 条 `adc_precedent` supporting 单元无一附 indication,因此 LOCK-03 对全部 369 个 pair 只能是 `unresolved`(已在 2026-08-05 的 Preview 中实测确认)。 + +本契约冻结抽取范围、来源分层、四类 linkage 判据、求值优先级、**检索范围**与输出验证。**不执行抽取。** + +## 一、本契约与 EVGAP-01 完全独立,可并行执行 + +`independent_of: [EVGAP-01, SRCADM-01]`,`blocked_by: [contract_approval]`——唯一的阻断是本契约自身的审核。 + +理由:LOCK-03 问的是「target 为什么与这个 CRC clinical context 有关」,**与表面拓扑无关**,因此不读取 surfaceome 参考库,也不受其准入状态影响。 + +这也是抽取范围必须覆盖**全部 369 个 pair**而不是 EVGAP-01 之后可能 eligible 的 22 个的原因:若只覆盖 22 个,本抽取就会依赖尚未获准入的 surfaceome 判定结果,既污染来源,也使两条 track 无法并行。 + +来源文档给出的优先级是 **Track A 先做 `EVGAP-02`,Track B 并行做 `SRCADM-01`**——因为 22 个靶点即使全部通过 surface identity,没有 CRC linkage 仍只是泛癌 surface targets,不是 CRC indication–target seeds。 + +## 二、来源分层:一个必须先讲清的普遍问题 + +核查结果:**仓库内 `logs/chatgpt-review-*.md` 中没有任何一条提及过任何本地派生数据库。** PR #59 发现的 surfaceome 准入问题不是个例,而是普遍状况。 + +因此本契约把来源分成两层。 + +### Tier 1:原始公开来源,可直接使用 + +| 来源类 | endpoints | 必须记录 | +|---|---|---| +| `peer_reviewed_literature` | PubMed、PMC | pmid_or_pmcid、title、journal、year、retrieved_at | +| `clinical_trial_registry` | ClinicalTrials.gov | nct_id、phase、status、indication_text、retrieved_at | +| `public_molecular_dataset` | TCGA、GEO、Human Protein Atlas | dataset_accession、dataset_version_or_release、query_expression、retrieved_at | +| `approved_internal_result` | `gen_iet_crc_target_enumeration_20260802`(PR #29) | source_id、sha256 | + +依据是 PR #59 审核所作的区分:原始公开来源**不是派生数据库**,其内容可由 `source_locator` 直接回溯到原始记录,不存在「构建逻辑是否遵守声明」的问题。 + +**这一层足以支撑本次抽取**——CRC-specific linkage 证据正是存在于文献、试验注册库与公开数据集中。所以本契约获批后即可执行,无需等待任何 admission。 + +### Tier 2:派生本地数据库,一律禁用至各自获准入 + +| ID | 数据库 | 本会服务的 linkage 类 | admission 记录 | +|---|---|---|---| +| `SRCADM-02` | `ADCdb` | B | `null` | +| `SRCADM-03` | `CRC_journal_whitelist_literature` | A | `null` | +| `SRCADM-04` | `CRC_Atlas_fulltext_accession_ledger` | A | `null` | +| `SRCADM-05` | `ADC_competitive_landscape_reference` | B | `null` | + +本次抽取**不使用**它们(`used_by_this_extraction: false`,`VAL-L10` 强制)。后果必须写明:**检索完整性只在 Tier 1 声明范围内成立。** 若将来某个派生库获准入,需另开 PR 扩大 `declared_search_scope` 并重跑,**不得静默扩大**。 + +## 三、四类 linkage 判据 + +### A. CRC human tumor expression + +接受 CRC patient samples、primary tumour、metastatic lesion、treatment-resistant context;可得时必须记录 `positive_fraction_or_prevalence` 与 `malignant_cell_attribution`。 + +**蛋白证据优先。RNA 可以证明 linkage 存在,但绝不得替代 LOCK-01 的蛋白层面判据**(`rna_admissible_for_linkage_existence: true`,`rna_may_satisfy_lock_01: false`,且必须标注)。这与 Level 01 契约「RNA 不得满足 LOCK-01」一致,测试双向校验。 + +### B. CRC-specific ADC precedent + +接受 CRC clinical trial、CRC preclinical ADC、CRC cell line、CRC PDO、CRC PDX、CRC animal model。 + +**仅其他癌种的 ADC precedent 不算 linkage**,降为 `metadata_only_hold`(`L3-04`)。这条与 #58 已冻结的规则一致。 + +### C. CRC-specific target-directed modality evidence(本契约新增) + +接受 naked antibody、CAR-T、bispecific、radioimmunotherapy、immunotoxin、imaging antibody。 + +它们证明 **target 在 CRC 中可接近或可干预**,因此满足 LOCK-03 的存在性——LOCK-03 只问「是否存在公开证据表明该 target 与该 CRC context 有关」,不问 ADC 疗效。 + +但必须显式标注 **`is_adc_efficacy_evidence: false`**(`VAL-L04`,`MF-L02`)。这是现有契约尚未涵盖的新增依据。 + +### D. Context-specific enrichment + +接受 MSS/pMMR、post-anti-EGFR、liver metastasis、treatment-resistant、HER2-positive、refractory metastatic CRC。 + +**疾病级 CRC 证据只支持 canonical context,不自动支持任何亚群**;亚群 context 必须有 D 类证据才能 RETAIN。 + +## 四、检索范围冻结:使完整性成为可判定事实 + +PR #58 曾判定 `no_known_linkage_after_complete_search` **不可用**,理由是检索范围未闭合。本节正是使该 outcome 变为可用的前提——范围一旦冻结,「是否完成规定检索」就成为可判定的事实,而不是执行者的自我声明。 + +完整性分**两级**,`search_complete_requires_both_levels: true`,两级都达标才算完成。 + +**第一级:target 级,按 endpoint 判定(不是按 source class 判定)。** + +初稿只要求「覆盖 source class」,执行者可以只查 PubMed 不查 PMC、只查 TCGA 不查 GEO 与 HPA,结果不可复现——这是审核裁决指出的漏洞。现改为 `coverage_unit: endpoint`,每个 source class 都写明 `minimum_endpoint_set` 且 `all_endpoints_required: true`: + +| source class | 必查 endpoints | +|---|---| +| `peer_reviewed_literature` | PubMed **与** PMC | +| `clinical_trial_registry` | ClinicalTrials.gov | +| `public_molecular_dataset` | TCGA **与** GEO **与** Human Protein Atlas | + +缺任一 endpoint,该 target 的全部 pair 落 `L3-01`(`VAL-L19`)。 + +**第二级:pair 级 D 类检索,369 个 pair 全覆盖。** + +D 类是 pair 级判据,初稿却没要求它进入 `search_complete`——后果是 subgroup pair 可能在从未检索 D 类的情况下直接落 `L3-03`,或使「四类均无命中」的 `L3-05` 被错误触发。现要求每个 pair 记录六个字段:`class_d_query_expression`、`class_d_executed_at`、`class_d_result_count`、`class_d_reachable`、`class_d_source_coverage_ref`、`class_d_search_complete`。 + +**某个 pair 的 D 类检索未完成时,该 pair 必须落 `L3-01`,不得落 `L3-03`,也不得落 `L3-05`**(`VAL-L18`)。`L3-03` 与 `L3-05` 都带 `requires_class_d_search_complete: true`。 + +**其他要求**:必须记录 query template(target 符号与同义词、CRC 术语、类别特异术语、日期范围);每次检索记录 `query_expression`、`executed_at`、`result_count`、`reachable`;来源不可达 → 该 target 检索未完成,D 类不可达 → 该 pair 检索未完成;`silent_skip_forbidden: true`。 + +### 检索粒度 + +A/B/C 三类的疾病级检索对同一 target 在 9 个 context 下结果相同,**按 target 检索一次**(41 次)即可,避免 369 次冗余检索;D 类情境特异性富集**按 pair 判定**(369 次)。此项写明是为了让「检索次数」这个数字不被误读。 + +## 五、LOCK-03 求值规则与冻结优先级 + +优先级:`L3-01` → `L3-02` → `L3-03` → `L3-04` → `L3-05`。 + +理由:**先判检索是否完成——未完成时「没找到」无法与「不存在」区分**;再判是否存在与该 context 匹配的 CRC-specific 证据;再判疾病级证据遇亚群 context 的降级;再判仅有其他癌种 precedent 的降级;以上都不成立且检索已完成,才允许判定完整检索后无 linkage。 + +| ID | 条件 | outcome | disposition | state | +|---|---|---|---|---| +| `L3-01` | 未完成规定检索 | `linkage_evidence_missing` | DEFER | hold | +| `L3-02` | 有 A/B/C 任一 CRC-specific 证据,且 context 为 canonical,或亚群且有 D 类证据 | `linkage_evidence_exists` | **RETAIN** | active | +| `L3-03` | 有 CRC 疾病级证据,但 context 为亚群且无 D 类 | `linkage_unassessed` | DEFER | hold | +| `L3-04` | 仅有其他癌种 precedent | `linkage_unassessed` | DEFER | hold | +| `L3-05` | 完成规定检索且四类均无命中 | `no_known_linkage_after_complete_search` | EXCLUDE | reactivation-eligible | + +**只有 `L3-02` 可以 RETAIN,只有 `L3-05` 可以 EXCLUDE**,测试断言各自恰好一条。`L3-05` 的 EXCLUDE 语义严格限定为 `EXCLUDE_FROM_ACTIVE_POOL`:`is_scientific_disproof: false`、`is_killed: false`、`retained_in_eligible_universe_index: true`,并须六项检索完整性字段齐备。 + +测试用参考实现穷举 `search_complete × crc_specific × canonical × class_d × other_cancer` 全部 **32 种组合**,证明每种恰好命中一条规则,且五条规则都可达。 + +## 六、本契约不给出预期结果形状 + +这与 EVGAP-01 不同,且是有意的。 + +EVGAP-01 读取的是已固定的数据集,结果可以事先算出并逐项核对(22/19)。**EVGAP-02 是发现型检索,事先给出这类数字就会把预测冒充成结果**——正是来源文档列为第二种必须避免的混淆。 + +因此本契约不预测计数,改为冻结 `declared_search_scope`、`search_complete_definition`、`derivation_precedence`、provenance 要求与输出验证,使完整性与每一条 disposition 都可事后核验。测试断言 `predicted_result_shape.provided = false`,且不得以任何别名偷偷塞入计数。 + +## 七、输出与 provenance 分层 + +**两张表,且两表之间有稳定的一对一引用关系。** + +`evidence` 表每条证据一行,**19 列**,含来源文档规定的 13 列必需最小集,外加 `evidence_id`(唯一)、`linkage_class`、`is_adc_efficacy_evidence`、`positive_fraction_or_prevalence`、`malignant_cell_attribution`、`retrieved_at`。 + +`disposition` 表每个 pair 一行,**30 列**,含 `rule_id`、六项检索完整性字段、六个 D 类 pair 级字段、`provenance_kind`、`provisional_only`、`may_advance_to_level_02`,以及**三组证据引用**:`supporting_evidence_refs`、`class_d_evidence_refs`、`other_cancer_evidence_refs`。 + +### 为什么需要这三组引用 + +初稿的 disposition 表只有 `evidence_row_count`——**单条 disposition 无法证明自己由哪些 evidence 行支持**。审核裁决指出,`L3-02` RETAIN 应当能回答:哪条 A/B/C 证据支持?subgroup RETAIN 时哪条 D 证据支持?是否有其他癌种 precedent 但没被错误算入 linkage?初稿都答不了。 + +每条规则必须引用什么、必须不引用什么,已逐条冻结(`VAL-L17`): + +| 规则 | `supporting_evidence_refs` | `class_d_evidence_refs` | `other_cancer_evidence_refs` | +|---|---|---|---| +| `L3-01` | 必须为空 | 必须为空 | 可空(不得伪造) | +| `L3-02` canonical | 至少一条 A/B/C | 可空 | 可有但不得计入 linkage | +| `L3-02` subgroup | 至少一条 A/B/C | **至少一条** | 可有但不得计入 linkage | +| `L3-03` | 至少一条疾病级 CRC 证据 | **必须为空** | 可空 | +| `L3-04` | **必须为空** | 必须为空 | 至少一条 | +| `L3-05` | 必须为空 | 必须为空 | 必须为空(检索 provenance 须完整) | + +另加 `VAL-L16`(每个引用的 id 必须存在于 evidence 表,且所引用行的 `pair_id` 与该 disposition 一致)与 `VAL-L20`(`evidence_row_count` 必须等于三组 refs 去重后的总条数,不得只报计数不给 refs)。 + +**三种 `provenance_kind`,要求不同**(这是 PR #59 阻断 3 的教训): + +| kind | 必填 | 可空 | +|---|---|---| +| `source_supported` | `source_ref`、`source_locator`、`retrieved_at` | `no_evidence_found_reason` | +| `no_evidence_found_after_complete_search` | 六项检索完整性字段 + `no_evidence_found_reason` | `source_ref`、`source_locator` | +| `search_incomplete` | `search_complete`、`search_scope`、`searched_at`、`no_evidence_found_reason` | `source_ref`、`source_locator` | + +后两种**禁止伪造 source evidence**;出现非空 `source_ref` 即为验证失败(`VAL-L08`)。 + +每个 `conditionally_required_columns` 块都写明 `table`(`evidence` 或 `disposition`),**测试逐表检查必填列,不再用两张表列的并集代替**(`VAL-L09`)——初稿用并集,恰好掩盖了阻断 2 那个问题。 + +**20 条**验证规则 `VAL-L01`..`VAL-L20` 见 YAML。 + +## 八、必须写进结果报告的四条 + +- **`MF-L01`**:LOCK-03 RETAIN 只表示存在可回溯的 CRC-specific linkage 证据,**不表示该靶点适合 ADC、不表示疗效、不表示治疗窗**。 +- **`MF-L02`**:C 类证据证明 target 在 CRC 中可接近或可干预,**不是 ADC 疗效证据**,必须原样标注。 +- **`MF-L03`**:本次抽取未使用任何派生本地数据库,检索完整性只在 Tier 1 声明范围内成立。 +- **`MF-L04`**:LOCK-03 RETAIN **不使 pair 进入 Level 02**。`EVGAP-01` 未解除前 `may_advance_to_level_02` 恒为 `false`。 + +## 九、授权与不授权 + +**授权:** 获 `APPROVE` 后按本契约执行**一次**抽取,读取 Tier 1 来源,覆盖全部 369 个 pair。 + +**不授权:** 执行 Level 01;解除 `EVGAP-01`;读取任何 Tier 2 派生本地数据库;把任何派生数据库纳入已批准来源;评估 T2、T7 或任何 Gate;新增靶点或 clinical context;任何筛选排序、Tier 划分、资产推荐或实验建议;引入被隔离运行(PR #53、#54)的任何产物。 + +## 十、后续顺序 + +1. 本契约 `APPROVE`。 +2. 执行抽取 → 结果 PR → `APPROVE`。 +3. **另开 PR** 更新 `adc_pool_level_01_input_binding.yaml`,绑定抽取产物并解除 `EVGAP-02`。 +4. `EVGAP-01` 由 Track B 独立推进(`SRCADM-01` → 抽取 → 结果 → binding)。 +5. **两个缺口都解除后,才能生成 `ADC_POOL_LEVEL_01_ACCEPTED`。** + +## 十一、当前阻断 + +- 本契约获 ChatGPT `APPROVE` 前,不得执行抽取。 +- 抽取完成也**不**解除 `EVGAP-01`,Level 01 仍不可执行。 +- 本仓库不得写入证据、候选、快照、cache、result 或 weights。 diff --git a/logs/worklog.md b/logs/worklog.md index 5406371..b56e675 100644 --- a/logs/worklog.md +++ b/logs/worklog.md @@ -2564,3 +2564,38 @@ Purpose: append a detailed timestamped record of what was done, how it was done, - Boundary: Documentation and audit trail only. No patient data, public datasets, raw files, cache, result, download, provider adapter, runtime dependency, or analysis was added. - Decision: Patient direct observation, patient-derived models, long-term cell-line perturbation, and clinical intervention evidence remain separate layers. Database presence does not automatically authorize a Gate claim. - Verification: Ran `scripts/verify_repository_boundary.sh` and `git diff --check` before the direct `main` commit. The current task branch's unrelated EVGAP-02 changes were not copied into `main`. + +## 2026-08-05T13:30:00-04:00 — EVGAP-02 CRC linkage 抽取契约(contract-only,未执行) + +- Instruction: 人类负责人指示起 `EVGAP-02` 契约。这是来源文档指定的 Track A,优先级高于 `SRCADM-01`/`EVGAP-01`(Track B)。判据、检索范围与结果 schema 取自 `Asset-Generation-OS-architecture.md` 的 `# EVGAP-02 应该具体抽取什么` 与 `# EVGAP-02 最小结果标准`(只读未改)。 +- Context: `EVGAP-02` 阻断 `LOCK-03`——`crc_prevalence` 41 条全 `not_available`、33 条 `adc_precedent` supporting 无一附 indication,故 LOCK-03 对 369 个 pair 只能 `unresolved`,已在 2026-08-05 Level 01 Preview 实测确认。 +- Finding (high), governance: **仓库内 `logs/chatgpt-review-*.md` 中没有任何一条提及过任何本地派生数据库。** PR #59 发现的 surfaceome 准入问题不是个例而是普遍状况。据此把来源分成 Tier 1(原始公开来源,可直接用)与 Tier 2(派生本地库,一律禁用至各自获准入)。 +- Design: Tier 1 = PubMed/PMC、ClinicalTrials.gov、TCGA/GEO/HPA,加已批准枚举轴(PR #29)。依据是 PR #59 审核所作的区分——原始公开来源不是派生数据库,内容可由 `source_locator` 回溯到原始记录,不存在「构建逻辑是否遵守声明」的问题。**该层足以支撑本抽取,故本契约获批即可执行、不需等任何 admission**(`blocked_by: [contract_approval]`)。Tier 2 登记四个待准入项 `SRCADM-02`..`SRCADM-05`,`admission_record_ref` 全为 `null`,并写明检索完整性只在 Tier 1 范围内成立、日后扩大须另开 PR 重跑。 +- Design: `independent_of: [EVGAP-01, SRCADM-01]`。LOCK-03 与表面拓扑无关,不读 surfaceome、不受其准入状态影响。故抽取覆盖**全部 369 个 pair**而非 EVGAP-01 后可能 eligible 的 22 个——**只覆盖 22 个会使本抽取依赖尚未准入的 surfaceome 判定,既污染来源又使两条 track 无法并行。** +- Linkage classes frozen: A CRC human tumor expression(蛋白优先;**RNA 可证 linkage 存在但绝不得替代 LOCK-01** 且须标注,测试与 Level 01 契约双向校验);B CRC-specific ADC precedent(**仅其他癌种 precedent 不算 linkage**,降 `metadata_only_hold`,与 #58 一致);**C CRC-specific target-directed modality evidence(本契约新增)**——naked antibody/CAR-T/bispecific/RIT/immunotoxin/imaging antibody,满足 LOCK-03 存在性但**必须标注 `is_adc_efficacy_evidence: false`**;D context-specific enrichment(疾病级只支持 canonical,亚群须有 D 类才能 RETAIN)。 +- Key design, declared_search_scope: PR #58 曾判 `no_known_linkage_after_complete_search` 不可用,理由是检索范围未闭合。本契约冻结范围正是使该 outcome 可用的前提——范围一旦冻结,「是否完成规定检索」成为**可判定事实**而非自我声明。每 target 对三类 Tier 1 来源各检索一次;须记录 query template 与 `query_expression`/`executed_at`/`result_count`/`reachable`;**来源不可达即判该 target 检索未完成**,`silent_skip_forbidden: true`。检索粒度写明:A/B/C 按 target(41 次),D 按 pair(369 次),避免「检索次数」被误读。 +- Precedence frozen: `L3-01`(检索未完成)→ `L3-02`(RETAIN/active)→ `L3-03`(亚群无 D 类,DEFER)→ `L3-04`(仅其他癌种,DEFER)→ `L3-05`(完整检索后无 linkage,`EXCLUDE_FROM_ACTIVE_POOL`/`reactivation-eligible`)。**先判检索完整性——未完成时「没找到」无法与「不存在」区分。** 只有 `L3-02` 可 RETAIN、只有 `L3-05` 可 EXCLUDE,测试断言各恰好一条。测试用参考实现穷举 `search_complete × crc_specific × canonical × class_d × other_cancer` 全部 **32 种组合**,证明每种恰好命中一条且五条规则均可达——PR #59 阻断 4 的教训,这次一开始就做。 +- Deliberate omission: **本契约不给预期结果形状。** EVGAP-01 读固定数据集可事先算出(22/19);EVGAP-02 是发现型检索,事先给数字即把预测冒充成结果——来源文档列为第二种必须避免的混淆。改为冻结检索范围、完整性定义、优先级、provenance 与输出验证;测试断言 `provided: false` 且不得以别名塞入计数。 +- Lessons carried forward: provenance 三分(`source_supported`/`no_evidence_found_after_complete_search`/`search_incomplete`,后两种可空 `source_ref` 但**禁止伪造**,出现非空即失败)——PR #59 阻断 3;条件必填列必须在 schema 之内,测试直接断言子集关系——PR #59 阻断 2。 +- Validation: `Ran 334 tests` 全部通过(`main` 基线 309 + 新增 25);`scripts/verify_repository_boundary.sh` 通过;`git diff --check` 通过;零 `__pycache__`。 +- Mutation-tested (13 个,全部 `FAILED` 后精确回滚,与备份 `diff -q` 一致、恢复 `OK`): RNA 满足 LOCK-01、C 类声明为 ADC 疗效证据、泛癌 precedent 算作 linkage、疾病级证据支撑亚群、`L3-03` 改判 RETAIN、完整检索排除改判 killed、检索完整性排到优先级最后、允许静默跳过来源、开放 Tier 2 派生库、自行填入 `SRCADM-02` 记录、偷偷加入预测计数、要求未找到证据的行也有 `source_ref`、范围缩到依赖 LOCK-01 状态。 +- Own test error, self-caught: 初稿断言「除 `L3-05` 外全部 DEFER」,漏了 `L3-02` 是 RETAIN 规则;改为逐规则断言并加「恰好一条 RETAIN、恰好一条 EXCLUDE」。 +- Deliberately not done: 未执行抽取、未发起任何检索;未执行 Level 01 也不授权;**未解除 `EVGAP-01`**;未读任何 Tier 2 派生库;未把任何派生库纳入已批准来源;未评估 T2/T7/任何 Gate;未排序/Tier/推荐/实验建议;未新增靶点或 context;未引用被隔离运行产物;未更新 `adc_pool_level_01_input_binding.yaml`;未预测结果计数;未补七份批准记录。 +- Noticed, not fixed: `requirements.txt` 注释仍写「207 tests」,实测 334。属无关改动,只记录。 +- Governance note: **本 PR 不适用 `AGENTS.md`「审核豁免」**,须经 ChatGPT `APPROVE`。 +- Next: 推送并创建 PR 送审。请审核方裁决 Tier 1/Tier 2 的划分是否成立,以及 C 类作为 linkage 存在性依据是否可接受。 + +## 2026-08-05T15:40:00-04:00 — PR #61 第一轮审核裁决与修订(三条阻断全部接受) + +- Review: ChatGPT 对 PR #61(HEAD `430e85f`,CI 成功但 `mergeable=false`)返回 `REQUEST_CHANGES`,三条阻断。**全部接受。** +- Finding 1 accepted: **D 类检索没有真正进入 search completeness,且 endpoint 覆盖语义未冻结。** 契约声明 A/B/C 按 target、D 类按 369 pair,但 `search_complete_definition` 只要求 target 完成三类 source-class 检索,未要求每 pair 对 D 类完成可审计检索——后果是 subgroup pair 可能在未检索 D 类时直接落 `L3-03`,`L3-05`「四类均无命中」也可能在 D 类未检索时被错误触发。同时 `peer_reviewed_literature` 有 PubMed/PMC 两个 endpoint、`public_molecular_dataset` 有三个,而契约只要求覆盖 source class,**执行者可只查其中一个,结果不可复现**。 +- Fix 1: 新增 `per_pair_required_class_d_search`(369 pair 全覆盖,六个字段并同时进入 `disposition_columns`),`incomplete_consequence: L3-01`——**D 类未完成必须落 `L3-01`,不得落 `L3-03` 或 `L3-05`**(`VAL-L18`);`L3-03`/`L3-05` 加 `requires_class_d_search_complete: true`,`L3-01` 加 `covers_both_completeness_levels: true`;`search_complete_requires_both_levels: true`;`coverage_unit: endpoint`,每个 source class 加 `all_endpoints_required: true` 与 `minimum_endpoint_set`(PubMed+PMC/ClinicalTrials.gov/TCGA+GEO+HPA),缺任一 endpoint 该 target 全部 pair 落 `L3-01`(`VAL-L19`);新增 `unreachable_class_d_consequence`。 +- Finding 2 accepted: **disposition 与 evidence 之间没有稳定引用关系。** 初稿 disposition 只有 `evidence_row_count`,单条 disposition 无法证明自己由哪些 evidence 行支持——`L3-02` RETAIN 无法回答「哪条 A/B/C 支持、subgroup 时哪条 D 支持、是否有 other-cancer precedent 但未被错误算入」。**而我的测试用两张表列的并集验证条件必填字段,恰好掩盖了这个问题——这条批评对测试方法本身比对契约更准。** +- Fix 2: evidence 表新增唯一 `evidence_id`;disposition 表新增 `supporting_evidence_refs`/`class_d_evidence_refs`/`other_cancer_evidence_refs`;新增 `evidence_reference_requirements` 逐规则冻结引用约束(`L3-02` 按 canonical/subgroup 拆两条,subgroup 另需至少一条 D;`L3-03` 须疾病级证据且 D refs 空;`L3-04` supporting 必空、只能引用 other-cancer;`L3-05` 三组全空且检索 provenance 完整;`L3-01` 不得伪造);新增 `VAL-L16`/`VAL-L17`/`VAL-L20`;每个 `conditionally_required_columns` 块加 `table` 字段,**测试改为逐表检查、不再用并集**。 +- Finding 3 accepted: PR 不可合并。已同步 `origin/main`(`0190a73`),`logs/worklog.md` 冲突按时间顺序解决(main 的 12:45 EDT 在前、我的 13:30 在后),断言无残留冲突标记、两侧条目与标题全在。合并后核验相对 `main` 仍**只有 5 个文件**,**未混入 PR #60 的 preview 结果**(`grep -c preview` = 0),未引入其他无关契约。 +- Executor mistake, self-caught: 中途为处理 PR #60 切分支时执行 `git stash -u`,把阻断 1/2 的未提交 YAML 改动一并藏入栈,切回后未恢复,导致后续编辑落在未修订版本上、测试报 `KeyError`。已定位 `stash@{0}`、丢弃冲突编辑、`git stash pop` 恢复全部 133 行改动后重做,无内容丢失。**教训:跨分支处理另一个 PR 前,未提交改动应先提交或明确记录 stash,切回后第一步就恢复。** +- Validation: `Ran 338 tests` 全部通过(`main` 基线 309 + 新增 29,由 25 增至 29);`scripts/verify_repository_boundary.sh` 通过;`git diff --check` 通过;零 `__pycache__`。 +- Mutation-tested (本轮 12 个,全部 `FAILED` 后精确回滚,与备份 `diff -q` 一致、恢复 `OK`): D 类改为非必需、D 类未完成落 `L3-03`、`L3-05` 不要求 D 类完成、完整性只要 target 级、覆盖粒度退回 source class、只查 PubMed 就算覆盖、去掉 `evidence_id`、`L3-04` 可引用 supporting refs、subgroup RETAIN 不需 D refs、`L3-05` 可引用证据、必填列声明到错误的表、删掉 `L3-03` 的 D refs 为空约束。 +- Accepted by reviewer, unchanged: EVGAP-02 与 EVGAP-01/SRCADM-01 独立;覆盖全部 369 pairs;Tier 1/Tier 2 分层与 Tier 2 禁用;A/B/C/D 四类框架;RNA 可支持 linkage 但不满足 LOCK-01;C 类可作 linkage existence 但非 ADC efficacy;disease-level 不自动支持 subgroup;other-cancer 只作 metadata;`L3-05` 可逆非证伪非 killed;不执行 Gate/Level 01、不解除 `EVGAP-01`;不预写 discovery run 结果数量。 +- Review write-back: 连接器 403,未写回 GitHub。裁决以人类负责人转述为准,已记录于本条与 handoff 第十四节。 +- Next: 推送同一 PR 并同步 PR 描述请求复审。 diff --git a/tests/test_evgap_02_crc_linkage.py b/tests/test_evgap_02_crc_linkage.py new file mode 100644 index 0000000..d11a2be --- /dev/null +++ b/tests/test_evgap_02_crc_linkage.py @@ -0,0 +1,490 @@ +"""Verify docs/pools/evgap_02_crc_linkage_extraction.yaml. + +The contract freezes the four linkage classes, the declared search scope, the +LOCK-03 evaluation precedence and the provenance split for the EVGAP-02 +extraction. These tests check internal consistency and agreement with the merged +Level 01 contracts. They read no external source: the extraction has not run. +""" + +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" +CONTRACT_PATH = POOLS / "evgap_02_crc_linkage_extraction.yaml" +BINDING_PATH = POOLS / "adc_pool_level_01_input_binding.yaml" +LEVEL_CONTRACT_PATH = POOLS / "adc_pool_gate_usage.yaml" + +LINKAGE_CLASSES = ("A", "B", "C", "D") +# Columns the source document requires for every evidence row. +REQUIRED_EVIDENCE_COLUMNS = ( + "pair_id", "target", "clinical_context_id", "evidence_type", "crc_specific", + "context_specific", "human_or_model", "protein_or_rna", "source_ref", + "source_locator", "evidence_direction", "review_status", "linkage_outcome", +) + + +def _load(path: Path) -> dict: + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +class Evgap02LinkageContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.doc = _load(CONTRACT_PATH) + cls.binding = _load(BINDING_PATH) + cls.level = { + entry["level"]: entry for entry in _load(LEVEL_CONTRACT_PATH)["levels"] + }["01"] + cls.lock_03 = next( + lock for lock in cls.level["locks"] if lock["lock_id"] == "LOCK-03" + ) + + # ------------------------------------------------------------ identity + def test_contract_targets_the_registered_gap(self) -> None: + head = self.doc["extraction"] + self.assertEqual(head["discharges_gap"], "EVGAP-02") + self.assertEqual(head["serves_lock"], "LOCK-03") + gaps = {g["id"]: g for g in self.binding["evidence_gaps"]} + self.assertEqual(gaps["EVGAP-02"]["blocks"], "LOCK-03") + self.assertEqual(head["execution_status"], "not_authorized_not_executed") + self.assertIs(head["authorises_level_01_execution"], False) + self.assertIs(head["requires_followup_binding_pr"], True) + + def test_extraction_is_independent_of_evgap_01(self) -> None: + """The two tracks must be runnable in parallel.""" + + head = self.doc["extraction"] + self.assertEqual(set(head["independent_of"]), {"EVGAP-01", "SRCADM-01"}) + self.assertTrue(head["independence_reason"].strip()) + # Only contract approval may gate it, not another gap. + self.assertEqual(head["blocked_by"], ["contract_approval"]) + self.assertIs(head["authorises_extraction_run_after_approve"], True) + scope = self.doc["scope"] + self.assertIs(scope["does_not_depend_on_lock_01_status"], True) + self.assertIs(scope["covers_all_pairs"], True) + + # -------------------------------------------------------- source tiers + def test_primary_sources_are_usable_and_derived_ones_are_not(self) -> None: + tiers = self.doc["source_tiers"] + tier1 = tiers["tier_1_primary_public"] + tier2 = tiers["tier_2_derived_local_databases"] + self.assertIs(tier1["admissible_without_separate_admission"], True) + self.assertTrue(tier1["rationale"].strip()) + self.assertIs(tier2["admissible_without_separate_admission"], False) + self.assertIs(tier2["barred_until_admitted"], True) + self.assertIs(tier2["used_by_this_extraction"], False) + # Every pending admission must be unfilled and identified. + pending = tier2["pending_admissions"] + self.assertTrue(pending) + ids = [p["id"] for p in pending] + self.assertEqual(len(ids), len(set(ids))) + for item in pending: + with self.subTest(dataset=item["dataset_id"]): + self.assertIsNone(item["admission_record_ref"]) + self.assertIn(item["would_serve_linkage_class"], LINKAGE_CLASSES) + # A validation rule must forbid reading them. + text = " ".join(r["rule"] for r in self.doc["output_validation"]) + self.assertIn("tier_2", text) + + def test_every_primary_source_class_records_its_provenance(self) -> None: + for source in self.doc["source_tiers"]["tier_1_primary_public"]["sources"]: + with self.subTest(source=source["source_class"]): + self.assertTrue(source["endpoints"]) + self.assertTrue(source["must_record"]) + + def test_scope_is_the_full_pair_matrix_from_approved_axes(self) -> None: + scope = self.doc["scope"] + self.assertIs(scope["new_targets_allowed"], False) + self.assertIs(scope["new_contexts_allowed"], False) + self.assertEqual( + scope["pair_count"], scope["clinical_context_count"] * scope["target_count"] + ) + consequences = self.binding["scope_consequences"] + self.assertEqual(scope["clinical_context_count"], consequences["raw_clinical_contexts"]) + self.assertEqual(scope["target_count"], consequences["raw_targets"]) + self.assertEqual(scope["pair_count"], consequences["raw_enumeration_matrix_pairs"]) + # Searching per target rather than per pair must be stated, not implied. + granularity = scope["search_granularity"] + self.assertEqual(granularity["disease_level_search_count"], scope["target_count"]) + self.assertEqual(granularity["context_specific_assessment_count"], scope["pair_count"]) + + # ------------------------------------------------------ linkage classes + def test_four_linkage_classes_are_defined(self) -> None: + classes = {c["class_id"]: c for c in self.doc["linkage_classes"]} + self.assertEqual(set(classes), set(LINKAGE_CLASSES)) + for item in classes.values(): + self.assertTrue(item["accepts"]) + + def test_rna_supports_linkage_but_never_lock_01(self) -> None: + klass = next(c for c in self.doc["linkage_classes"] if c["class_id"] == "A") + self.assertIs(klass["rna_admissible_for_linkage_existence"], True) + self.assertIs(klass["rna_may_satisfy_lock_01"], False) + self.assertIs(klass["rna_must_be_labelled"], True) + self.assertIs(klass["protein_preferred_over_rna"], True) + # The Level 01 contract must agree that RNA cannot satisfy LOCK-01. + standard = self.level["evidence_standard"] + self.assertIn("LOCK-01", standard["rna_may_not_satisfy"]) + + def test_other_cancer_precedent_is_not_linkage(self) -> None: + klass = next(c for c in self.doc["linkage_classes"] if c["class_id"] == "B") + self.assertIs(klass["other_cancer_only_precedent_counts_as_linkage"], False) + self.assertEqual( + klass["other_cancer_only_precedent_disposition"], "metadata_only_hold" + ) + rule = next(r for r in self.doc["derivation_rules"] if r["id"] == "L3-04") + self.assertEqual(rule["disposition"], CandidateDisposition.DEFER.value) + + def test_class_c_counts_as_linkage_but_never_as_adc_efficacy(self) -> None: + klass = next(c for c in self.doc["linkage_classes"] if c["class_id"] == "C") + self.assertIs(klass["counts_as_linkage_existence"], True) + self.assertIs(klass["is_adc_efficacy_evidence"], False) + self.assertIs(klass["must_be_labelled_not_adc_efficacy"], True) + self.assertTrue(klass["counts_as_linkage_reason"].strip()) + for modality in ("CAR-T", "bispecific", "radioimmunotherapy", "immunotoxin"): + self.assertIn(modality, klass["accepts"]) + text = " ".join(r["rule"] for r in self.doc["output_validation"]) + self.assertIn("is_adc_efficacy_evidence", text) + + def test_disease_level_evidence_cannot_carry_a_subgroup(self) -> None: + klass = next(c for c in self.doc["linkage_classes"] if c["class_id"] == "D") + self.assertIs(klass["disease_level_supports_canonical_only"], True) + self.assertIs(klass["subgroup_requires_class_d"], True) + rule = next(r for r in self.doc["derivation_rules"] if r["id"] == "L3-03") + self.assertEqual(rule["disposition"], CandidateDisposition.DEFER.value) + self.assertEqual(rule["resulting_state"], "hold") + + # ------------------------------------------------------- search closure + def test_declared_search_scope_makes_completeness_decidable(self) -> None: + scope = self.doc["declared_search_scope"] + self.assertTrue(scope["per_target_required_source_classes"]) + self.assertIs(scope["query_template_required"], True) + self.assertIs(scope["silent_skip_forbidden"], True) + self.assertTrue(scope["search_complete_definition"]) + self.assertEqual( + scope["unreachable_source_consequence"], "search_incomplete_for_that_target" + ) + # Every required source class must be a declared tier-1 class. + tier1 = {s["source_class"] + for s in self.doc["source_tiers"]["tier_1_primary_public"]["sources"]} + self.assertTrue(set(scope["per_target_required_source_classes"]) <= tier1) + + def test_complete_search_exclusion_becomes_available_and_stays_reversible(self) -> None: + """PR #58 barred this outcome; freezing the scope is what unlocks it.""" + + rule = next(r for r in self.doc["derivation_rules"] if r["id"] == "L3-05") + outcomes = {o["outcome"]: o for o in self.lock_03["outcomes"]} + self.assertEqual(rule["lock_03_outcome"], "no_known_linkage_after_complete_search") + self.assertIn(rule["lock_03_outcome"], outcomes) + self.assertEqual(rule["disposition"], CandidateDisposition.EXCLUDE.value) + self.assertEqual(rule["disposition_semantics"], "EXCLUDE_FROM_ACTIVE_POOL") + self.assertIs(rule["is_scientific_disproof"], False) + self.assertIs(rule["is_killed"], False) + self.assertEqual(rule["resulting_state"], "reactivation-eligible") + self.assertIs(rule["requires_search_completeness_record"], True) + # The six completeness fields must match the Level 01 contract's demand. + declared = set(outcomes[rule["lock_03_outcome"]]["required_search_fields"]) + self.assertEqual(set(rule["required_search_fields"]), declared) + + # ------------------------------------------------------------ precedence + def _assign(self, pair: dict) -> str: + """Reference implementation of the frozen precedence.""" + + conditions = { + "L3-01": not pair["search_complete"], + "L3-02": pair["crc_specific"] and ( + pair["canonical"] or pair["class_d"]), + "L3-03": pair["crc_specific"] and not pair["canonical"] + and not pair["class_d"], + "L3-04": not pair["crc_specific"] and pair["other_cancer"], + "L3-05": True, + } + for rule_id in self.doc["derivation_precedence"]: + if conditions[rule_id]: + return rule_id + raise AssertionError("precedence did not cover the pair") + + def test_precedence_covers_every_rule_and_starts_with_completeness(self) -> None: + 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))) + self.assertEqual(precedence[0], "L3-01") + self.assertEqual(precedence[-1], "L3-05") + self.assertTrue(self.doc["precedence_rationale"].strip()) + + def test_every_combination_resolves_to_exactly_one_rule(self) -> None: + cases = [ + # Incomplete search always wins, whatever else is true. + ({"search_complete": False, "crc_specific": True, "canonical": True, + "class_d": True, "other_cancer": True}, "L3-01"), + ({"search_complete": False, "crc_specific": False, "canonical": False, + "class_d": False, "other_cancer": False}, "L3-01"), + # CRC-specific evidence on the canonical context retains. + ({"search_complete": True, "crc_specific": True, "canonical": True, + "class_d": False, "other_cancer": False}, "L3-02"), + # A subgroup retains only with class D enrichment. + ({"search_complete": True, "crc_specific": True, "canonical": False, + "class_d": True, "other_cancer": False}, "L3-02"), + ({"search_complete": True, "crc_specific": True, "canonical": False, + "class_d": False, "other_cancer": False}, "L3-03"), + ({"search_complete": True, "crc_specific": True, "canonical": False, + "class_d": False, "other_cancer": True}, "L3-03"), + # Other-cancer precedent only. + ({"search_complete": True, "crc_specific": False, "canonical": True, + "class_d": False, "other_cancer": True}, "L3-04"), + # Nothing at all, search closed. + ({"search_complete": True, "crc_specific": False, "canonical": True, + "class_d": False, "other_cancer": False}, "L3-05"), + ({"search_complete": True, "crc_specific": False, "canonical": False, + "class_d": False, "other_cancer": False}, "L3-05"), + ] + for pair, expected in cases: + with self.subTest(**pair): + self.assertEqual(self._assign(pair), expected) + + def test_precedence_is_total_over_the_whole_condition_space(self) -> None: + seen = set() + for search_complete in (True, False): + for crc_specific in (True, False): + for canonical in (True, False): + for class_d in (True, False): + for other_cancer in (True, False): + pair = dict(search_complete=search_complete, + crc_specific=crc_specific, + canonical=canonical, class_d=class_d, + other_cancer=other_cancer) + seen.add(self._assign(pair)) + self.assertEqual(seen, set(self.doc["derivation_precedence"])) + + def test_only_the_complete_search_rule_may_exclude(self) -> None: + """L3-02 retains, L3-05 excludes from the active pool, the rest defer.""" + + for rule in self.doc["derivation_rules"]: + with self.subTest(rule=rule["id"]): + if rule["id"] == "L3-05": + self.assertEqual(rule["disposition"], CandidateDisposition.EXCLUDE.value) + elif rule["id"] == "L3-02": + self.assertEqual(rule["disposition"], CandidateDisposition.RETAIN.value) + self.assertEqual(rule["resulting_state"], "active") + else: + self.assertEqual(rule["disposition"], CandidateDisposition.DEFER.value) + self.assertEqual(rule["resulting_state"], "hold") + # Exactly one rule may retain and exactly one may exclude. + dispositions = [r["disposition"] for r in self.doc["derivation_rules"]] + self.assertEqual(dispositions.count(CandidateDisposition.RETAIN.value), 1) + self.assertEqual(dispositions.count(CandidateDisposition.EXCLUDE.value), 1) + + def test_rules_agree_with_the_level_01_outcome_vocabulary(self) -> None: + outcomes = {o["outcome"]: o for o in self.lock_03["outcomes"]} + for rule in self.doc["derivation_rules"]: + with self.subTest(rule=rule["id"]): + self.assertIn(rule["lock_03_outcome"], outcomes) + declared = outcomes[rule["lock_03_outcome"]] + self.assertEqual(rule["disposition"], declared["disposition"]) + self.assertEqual(rule["resulting_state"], declared["resulting_state"]) + + # ------------------------------------------------------------ no prediction + def test_no_result_shape_is_predicted_for_a_discovery_run(self) -> None: + predicted = self.doc["predicted_result_shape"] + self.assertIs(predicted["provided"], False) + self.assertTrue(predicted["reason"].strip()) + self.assertTrue(predicted["what_is_frozen_instead"]) + for key in ("declared_search_scope", "derivation_precedence"): + self.assertIn(key, predicted["what_is_frozen_instead"]) + # No count may be smuggled in under another name. + for key in ("eligible", "active", "hold", "counts", "totals"): + self.assertNotIn(key, predicted) + + # ------------------------------------------------------------- provenance + def test_required_columns_exist_in_their_own_table(self) -> None: + """Checking the union of both tables would mask a missing column.""" + + schema = self.doc["output_schema"] + evidence = set(schema["evidence_columns"]) + disposition = set(schema["disposition_columns"]) + for column in REQUIRED_EVIDENCE_COLUMNS: + with self.subTest(column=column): + self.assertIn(column, evidence) + for block in schema["conditionally_required_columns"]: + kind = block["when_provenance_kind"] + with self.subTest(kind=kind): + self.assertIn(kind, schema["provenance_kinds"]) + # Every conditionally required column must live in a named table, + # and the block must say which one. + table = block["table"] + self.assertIn(table, ("evidence", "disposition")) + owned = evidence if table == "evidence" else disposition + missing = set(block["required_columns"]) - owned + self.assertEqual(missing, set(), + f"{kind}: columns absent from {table}: {missing}") + may_empty = set(block.get("may_be_empty_columns", [])) + self.assertEqual(may_empty - (evidence | disposition), set()) + + def test_evidence_rows_are_individually_addressable(self) -> None: + """Blocker 2: a disposition must be able to name its supporting rows.""" + + schema = self.doc["output_schema"] + self.assertEqual(schema["evidence_row_key"], "evidence_id") + self.assertIs(schema["evidence_id_unique"], True) + self.assertIn("evidence_id", schema["evidence_columns"]) + for column in ("supporting_evidence_refs", "class_d_evidence_refs", + "other_cancer_evidence_refs"): + with self.subTest(column=column): + self.assertIn(column, schema["disposition_columns"]) + ids = {r["id"] for r in self.doc["output_validation"]} + for rule_id in ("VAL-L16", "VAL-L17", "VAL-L20"): + self.assertIn(rule_id, ids) + + def test_every_rule_states_what_it_must_and_must_not_cite(self) -> None: + requirements = self.doc["output_schema"]["evidence_reference_requirements"] + rule_ids = {r["id"] for r in self.doc["derivation_rules"]} + covered = {r["rule_id"] for r in requirements} + self.assertEqual(covered, rule_ids) + by_key = {(r["rule_id"], r.get("context_kind")): r for r in requirements} + # L3-02 must be split by context kind: a subgroup needs class D as well. + canonical = by_key[("L3-02", "canonical")] + subgroup = by_key[("L3-02", "subgroup")] + self.assertEqual(canonical["supporting_evidence_refs"], + "at_least_one_of_class_a_b_or_c") + self.assertEqual(subgroup["supporting_evidence_refs"], + "at_least_one_of_class_a_b_or_c") + self.assertEqual(subgroup["class_d_evidence_refs"], "at_least_one") + # L3-03 rests on disease-level evidence and must cite no class D. + l3_03 = by_key[("L3-03", None)] + self.assertEqual(l3_03["supporting_evidence_refs"], + "at_least_one_disease_level_crc_evidence") + self.assertEqual(l3_03["class_d_evidence_refs"], "must_be_empty") + # Other-cancer precedent may never be cited as support. + l3_04 = by_key[("L3-04", None)] + self.assertEqual(l3_04["supporting_evidence_refs"], "must_be_empty") + self.assertEqual(l3_04["other_cancer_evidence_refs"], "at_least_one") + # L3-05 cites nothing but must carry complete search provenance. + l3_05 = by_key[("L3-05", None)] + for key in ("supporting_evidence_refs", "class_d_evidence_refs", + "other_cancer_evidence_refs"): + self.assertEqual(l3_05[key], "must_be_empty") + self.assertEqual(l3_05["search_provenance"], "must_be_complete") + # L3-01 must not invent references. + l3_01 = by_key[("L3-01", None)] + self.assertEqual(l3_01["supporting_evidence_refs"], "must_be_empty") + self.assertEqual(l3_01["class_d_evidence_refs"], "must_be_empty") + + def test_class_d_completeness_is_pair_level_and_gates_l3_03_and_l3_05(self) -> None: + """Blocker 1: D-class search must enter search_complete per pair.""" + + scope = self.doc["declared_search_scope"] + block = scope["per_pair_required_class_d_search"] + self.assertIs(block["required"], True) + self.assertEqual(block["applies_to"], "all_369_pairs") + self.assertEqual(block["incomplete_consequence"], "L3-01") + for field in ("class_d_query_expression", "class_d_executed_at", + "class_d_result_count", "class_d_reachable", + "class_d_source_coverage_ref", "class_d_search_complete"): + with self.subTest(field=field): + self.assertIn(field, block["must_record"]) + # The field must actually be carried by the disposition table. + self.assertIn(field, self.doc["output_schema"]["disposition_columns"]) + # Completeness must require both levels, not just the target level. + self.assertIs(scope["search_complete_requires_both_levels"], True) + self.assertEqual(set(scope["search_complete_levels"]), + {"target_level_source_class_and_endpoint_coverage", + "pair_level_class_d_coverage"}) + self.assertEqual(scope["unreachable_class_d_consequence"], + "search_incomplete_for_that_pair") + # L3-03 and L3-05 may only fire once the D-class search has closed. + by_id = {r["id"]: r for r in self.doc["derivation_rules"]} + for rule_id in ("L3-03", "L3-05"): + with self.subTest(rule=rule_id): + self.assertIs(by_id[rule_id]["requires_class_d_search_complete"], True) + self.assertIs(by_id["L3-01"]["covers_both_completeness_levels"], True) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-L18", ids) + + def test_endpoint_coverage_is_frozen_not_left_to_the_executor(self) -> None: + """Blocker 1: covering a source class must mean covering its endpoints.""" + + scope = self.doc["declared_search_scope"] + self.assertEqual(scope["coverage_unit"], "endpoint") + self.assertTrue(scope["coverage_unit_reason"].strip()) + required = set(scope["per_target_required_source_classes"]) + for source in self.doc["source_tiers"]["tier_1_primary_public"]["sources"]: + if source["source_class"] not in required: + continue + with self.subTest(source=source["source_class"]): + self.assertIs(source["all_endpoints_required"], True) + # The minimum set may not be narrower than the declared endpoints. + self.assertEqual(set(source["minimum_endpoint_set"]), + set(source["endpoints"])) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-L19", ids) + + def test_pairs_without_evidence_may_not_fabricate_provenance(self) -> None: + schema = self.doc["output_schema"] + blocks = {b["when_provenance_kind"]: b + for b in schema["conditionally_required_columns"]} + self.assertEqual(set(blocks), set(schema["provenance_kinds"])) + for kind in ("no_evidence_found_after_complete_search", "search_incomplete"): + with self.subTest(kind=kind): + block = blocks[kind] + self.assertIs(block["fabricating_source_evidence_forbidden"], True) + for column in ("source_ref", "source_locator"): + self.assertIn(column, block["may_be_empty_columns"]) + supported = blocks["source_supported"] + for column in ("source_ref", "source_locator", "retrieved_at"): + self.assertIn(column, supported["required_columns"]) + + def test_completeness_fields_are_carried_by_the_disposition_table(self) -> None: + rule = next(r for r in self.doc["derivation_rules"] if r["id"] == "L3-05") + columns = set(self.doc["output_schema"]["disposition_columns"]) + for field in rule["required_search_fields"]: + with self.subTest(field=field): + self.assertIn(field, columns) + + # ------------------------------------------------------------ boundaries + def test_validation_rules_are_unique_and_cover_the_hard_limits(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", "Gate", "RNA", "369"): + with self.subTest(phrase=phrase): + self.assertIn(phrase, text) + + def test_retain_does_not_promote_to_level_02(self) -> None: + columns = set(self.doc["output_schema"]["disposition_columns"]) + self.assertIn("may_advance_to_level_02", columns) + self.assertIn("provisional_only", columns) + findings = {f["id"]: f["finding"] for f in self.doc["mandatory_findings"]} + self.assertIn("MF-L04", findings) + self.assertIn("EVGAP-01", findings["MF-L04"]) + self.assertIn("Level 02", findings["MF-L04"]) + + def test_mandatory_findings_keep_the_uncomfortable_qualifiers(self) -> None: + findings = {f["id"]: f["finding"] for f in self.doc["mandatory_findings"]} + self.assertEqual(set(findings), {"MF-L01", "MF-L02", "MF-L03", "MF-L04"}) + # RETAIN must never be read as ADC suitability or efficacy. + self.assertIn("ADC", findings["MF-L01"]) + self.assertIn("ADC", findings["MF-L02"]) + for text in findings.values(): + self.assertTrue(text.strip()) + + def test_authorisation_stops_short_of_level_01_and_other_gaps(self) -> None: + not_authorised = " ".join(self.doc["not_authorised"]) + for phrase in ("执行 Level 01", "EVGAP-01", "tier_2", "T7", "Gate"): + with self.subTest(phrase=phrase): + self.assertIn(phrase, not_authorised) + # Level 01 itself must remain unauthorised. + self.assertIs(self.binding["binding"]["authorises_level_01_execution"], False) + + +if __name__ == "__main__": + unittest.main()