From 430e85ff81f38fc4bf4797d5e856e064e793a3f1 Mon Sep 17 00:00:00 2001 From: Zhixin Eason Li Date: Wed, 5 Aug 2026 12:28:01 -0400 Subject: [PATCH 1/2] task_20260805: contract for the EVGAP-02 CRC linkage extraction Freezes one evidence-extraction run that would discharge EVGAP-02, the gap that blocks LOCK-03. Nothing is executed, no search is issued, and Level 01 remains unauthorised because EVGAP-01 is still open. This is Track A, which the source document puts ahead of SRCADM-01 and EVGAP-01. The contract is independent of both: LOCK-03 asks why a target relates to a CRC clinical context, which has nothing to do with surface topology, so it reads no surfaceome reference and is unaffected by that database's admission status. Its only blocker is its own approval. That independence is also why the scope covers all 369 pairs rather than the 22 that EVGAP-01 might later make eligible - scoping to 22 would make this run depend on unadmitted surfaceome verdicts and would stop the two tracks running in parallel. Governance finding: no review record in the repository mentions any local derived database at all. The surfaceome admission problem found in PR #59 is the general case, not an exception. Sources are therefore tiered. Tier 1 primary public sources - PubMed/PMC, ClinicalTrials.gov, TCGA/GEO/HPA, plus the approved enumeration axis - are usable directly, on the distinction the reviewer drew in PR #59: a primary source is not a derived database and its content traces straight back to the original record. Tier 1 alone can carry this extraction, so the contract is executable on approval. Tier 2 derived local databases are barred until each is admitted, and four pending admissions are registered with null record refs. Four linkage classes are frozen. RNA may establish linkage existence but never satisfies LOCK-01, checked against the Level 01 contract in both directions. Other-cancer-only precedent is metadata and holds. Class C, new here at the source document's request, admits naked antibody, CAR-T, bispecific, radioimmunotherapy, immunotoxin and imaging antibody as proof that the target is reachable in CRC, while requiring is_adc_efficacy_evidence false on every such row. Disease-level CRC evidence supports the canonical context only; a subgroup needs class D. The declared search scope is the load-bearing part. PR #58 barred no_known_linkage_after_complete_search because the search scope was not closed; freezing it is what makes that outcome available, turning "was the required search completed" into a decidable fact rather than an executor's self-assertion. An unreachable source marks that target's search incomplete and silent skipping is forbidden. Precedence is frozen with completeness first, since "not found" cannot be distinguished from "does not exist" before the search closes. Exactly one rule may retain and exactly one may exclude, and the exclusion is EXCLUDE_FROM_ACTIVE_POOL with is_scientific_disproof and is_killed false. A reference implementation exhausts all 32 combinations of the five conditions and proves each resolves to exactly one reachable rule. No result shape is predicted, deliberately. EVGAP-01 reads a pinned dataset so its counts can be computed in advance; EVGAP-02 discovers evidence, and quoting counts now would pass a prediction off as a result, which the source document names as the second confusion to avoid. 334 tests pass. Thirteen mutations caught and rolled back exactly. One error in my own test was caught and fixed: it asserted every rule but L3-05 defers, missing that L3-02 is the retain rule. Co-Authored-By: Claude Opus 5 --- ...-05-evgap-02-crc-linkage-contract.zh-CN.md | 117 +++++ .../evgap_02_crc_linkage_extraction.yaml | 466 ++++++++++++++++++ ...2_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md | 166 +++++++ logs/worklog.md | 20 + tests/test_evgap_02_crc_linkage.py | 380 ++++++++++++++ 5 files changed, 1149 insertions(+) create mode 100644 docs/handoff/2026-08-05-evgap-02-crc-linkage-contract.zh-CN.md create mode 100644 docs/pools/evgap_02_crc_linkage_extraction.yaml create mode 100644 docs/tasks/EVGAP_02_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md create mode 100644 tests/test_evgap_02_crc_linkage.py 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..ffd957f --- /dev/null +++ b/docs/handoff/2026-08-05-evgap-02-crc-linkage-contract.zh-CN.md @@ -0,0 +1,117 @@ +# 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` | 25 项校验,含 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」(实测 334)。属无关改动。 + +## 十一、验证结果 + +- `Ran 334 tests` 全部通过(`main` 基线 309 + 本次新增 25)。 +- `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。 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..80f2cb6 --- /dev/null +++ b/docs/pools/evgap_02_crc_linkage_extraction.yaml @@ -0,0 +1,466 @@ +# 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 + endpoints: + - PubMed + - PMC + must_record: + - pmid_or_pmcid + - title + - journal + - year + - retrieved_at + - source_class: clinical_trial_registry + endpoints: + - ClinicalTrials.gov + must_record: + - nct_id + - phase + - status + - indication_text + - retrieved_at + - source_class: public_molecular_dataset + endpoints: + - 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 + # 「完成规定检索」的定义。任一条不满足即为未完成。 + search_complete_definition: + - 每个 target 都对 per_target_required_source_classes 的每一类执行过检索 + - 每次检索都记录了 query_expression、executed_at、result_count 与 reachable + - 所有 reachable = false 的来源类都被显式记录为未完成,而非静默跳过 + unreachable_source_consequence: search_incomplete_for_that_target + 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 完成规定检索 + lock_03_outcome: linkage_evidence_missing + disposition: DEFER + resulting_state: hold + reason: 检索未完成时「没找到」不能与「不存在」区分。 + - 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: 存在 CRC 疾病级证据,但 context 为亚群且无 D 类证据 + 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 完成规定检索,且四类 linkage 均无命中 + 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_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 + # 以下为可审计性所需的补充列。 + - 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 + - no_evidence_found_reason + - provisional_only + - may_advance_to_level_02 + provenance_kinds: + - source_supported + - no_evidence_found_after_complete_search + - search_incomplete + # 找不到证据的 pair 不得伪造 source provenance,须记录检索 provenance。 + conditionally_required_columns: + - when_provenance_kind: source_supported + 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 + 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 + 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 声明的必填列必须全部在 evidence_columns 或 disposition_columns 之内。 + - id: VAL-L10 + rule: 不得读取 tier_2_derived_local_databases 中的任何数据库。 + - id: VAL-L11 + rule: 每个 target 的检索记录必须覆盖 per_target_required_source_classes 的每一类,未达即该 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..1f401f6 --- /dev/null +++ b/docs/tasks/EVGAP_02_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md @@ -0,0 +1,166 @@ +# 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 变为可用的前提——范围一旦冻结,「是否完成规定检索」就成为可判定的事实,而不是执行者的自我声明。 + +- 每个 target 必须对三类 Tier 1 来源(文献、试验注册库、公开分子数据集)各执行检索 +- 必须记录 query template,含 target 符号与同义词、CRC 术语、类别特异术语、日期范围 +- 每次检索必须记录 `source_class`、`endpoint`、`query_expression`、`executed_at`、`result_count`、`reachable` +- **来源不可达 → 该 target 判为检索未完成**(`search_incomplete_for_that_target`),不得静默跳过(`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` 表每条证据一行,含来源文档规定的 13 列必需最小集,另加 `linkage_class`、`is_adc_efficacy_evidence`、`positive_fraction_or_prevalence`、`malignant_cell_attribution`、`retrieved_at`。`disposition` 表每个 pair 一行,21 列,含 `rule_id`、六项检索完整性字段、`provenance_kind`、`provisional_only`、`may_advance_to_level_02`。 + +**三种 `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`)。测试断言所有条件必填列都在输出 schema 之内(PR #59 阻断 2 的教训)。 + +15 条验证规则 `VAL-L01`..`VAL-L15` 见 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 e65854a..35c839d 100644 --- a/logs/worklog.md +++ b/logs/worklog.md @@ -2557,3 +2557,23 @@ Purpose: append a detailed timestamped record of what was done, how it was done, - 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 描述请求复审。 + +## 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 存在性依据是否可接受。 diff --git a/tests/test_evgap_02_crc_linkage.py b/tests/test_evgap_02_crc_linkage.py new file mode 100644 index 0000000..ca02139 --- /dev/null +++ b/tests/test_evgap_02_crc_linkage.py @@ -0,0 +1,380 @@ +"""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_all_exist_in_the_output_schema(self) -> None: + schema = self.doc["output_schema"] + columns = set(schema["evidence_columns"]) | set(schema["disposition_columns"]) + for column in REQUIRED_EVIDENCE_COLUMNS: + with self.subTest(column=column): + self.assertIn(column, schema["evidence_columns"]) + for block in schema["conditionally_required_columns"]: + with self.subTest(kind=block["when_provenance_kind"]): + missing = set(block["required_columns"]) - columns + self.assertEqual(missing, set(), f"missing columns: {missing}") + self.assertIn(block["when_provenance_kind"], schema["provenance_kinds"]) + + 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() From 57cb10b8043cf140c80c5592f0e9eb10421a8bee Mon Sep 17 00:00:00 2001 From: Zhixin Eason Li Date: Wed, 5 Aug 2026 13:18:17 -0400 Subject: [PATCH 2/2] task_20260805: close the class-D and evidence-reference gaps in PR #61 All three blockers accepted. Blocker 1: class D never entered search completeness, and endpoint coverage was left to the executor. The contract said A/B/C are searched per target and D is assessed per pair, but search_complete_definition only required the three source classes per target. Both consequences the reviewer named are real: a subgroup pair could land on L3-03 without D ever being searched, and L3-05's "no class hit" could fire with D unsearched. Separately, peer_reviewed_literature has two endpoints and public_molecular_dataset three, while the contract only demanded source-class coverage, so an executor could query one and stop. per_pair_required_class_d_search now covers all 369 pairs with six recorded fields that also appear in the disposition table, and an incomplete D search forces L3-01 rather than L3-03 or L3-05. Completeness now requires both levels, coverage_unit is endpoint, and every source class declares all_endpoints_required with an explicit minimum_endpoint_set. Blocker 2: nothing tied a disposition row to the evidence rows behind it. With only evidence_row_count, an L3-02 retain could not answer which A/B/C evidence supported it, which D evidence carried a subgroup, or whether other-cancer precedent was present without being counted as linkage. Worse, my test validated the conditionally required columns against the union of both tables, which is precisely what masked this. Evidence rows now carry a unique evidence_id, dispositions carry supporting, class-D and other-cancer reference lists, and evidence_reference_requirements freezes per rule what must and must not be cited, with L3-02 split by context kind. Each conditional block now names its table and the test checks per table instead of the union. Blocker 3: synced origin/main and resolved logs/worklog.md chronologically, with main's 12:45 entry before mine at 13:30. Verified afterwards that the diff against main is still exactly five files, that none of PR #60's preview output leaked in, and that no unrelated contract came with it. One operational mistake of my own, caught and repaired: switching branches to handle PR #60 I ran git stash -u, which swept the uncommitted blocker 1 and 2 YAML edits into the stash, and I did not restore them on returning, so later edits landed on the unrevised file and the tests raised KeyError. Located stash@{0}, discarded the conflicting edit, popped all 133 lines back and redid the work with nothing lost. 338 tests pass. Twelve mutations caught and rolled back exactly. Co-Authored-By: Claude Opus 5 --- ...-05-evgap-02-crc-linkage-contract.zh-CN.md | 58 ++++++- .../evgap_02_crc_linkage_extraction.yaml | 144 +++++++++++++++++- ...2_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md | 54 ++++++- logs/worklog.md | 15 ++ tests/test_evgap_02_crc_linkage.py | 124 ++++++++++++++- 5 files changed, 371 insertions(+), 24 deletions(-) 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 index ffd957f..3c8edcd 100644 --- 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 @@ -24,7 +24,7 @@ |---|---| | `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` | 25 项校验,含 32 种条件组合的穷举求值证明 | +| `tests/test_evgap_02_crc_linkage.py` | 29 项校验,含 32 种条件组合的穷举求值证明 | ## 三、与 EVGAP-01 独立,这决定了范围 @@ -92,11 +92,11 @@ EVGAP-01 读固定数据集,结果可事先算出并逐项核对(22/19) - 没有更新 `adc_pool_level_01_input_binding.yaml`——解除 `EVGAP-02` 须待抽取执行、结果 PR 获批后另开 PR。 - 没有预测结果计数(见第八节)。 - 未补 #52/#53/#54/#57/#58/#59/#60 的批准记录(现为七份),事实已查全但未写文件。 -- 没有修 `requirements.txt` 注释里过期的「207 tests」(实测 334)。属无关改动。 +- 没有修 `requirements.txt` 注释里过期的「207 tests」(实测 338)。属无关改动。 ## 十一、验证结果 -- `Ran 334 tests` 全部通过(`main` 基线 309 + 本次新增 25)。 +- `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 状态。 @@ -115,3 +115,55 @@ EVGAP-01 读固定数据集,结果可事先算出并逐项核对(22/19) - 本契约获 `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 index 80f2cb6..c44650d 100644 --- a/docs/pools/evgap_02_crc_linkage_extraction.yaml +++ b/docs/pools/evgap_02_crc_linkage_extraction.yaml @@ -44,9 +44,14 @@ source_tiers: 不存在「构建逻辑是否遵守声明」的问题。这正是 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 @@ -56,6 +61,9 @@ source_tiers: - source_class: clinical_trial_registry endpoints: - ClinicalTrials.gov + all_endpoints_required: true + minimum_endpoint_set: + - ClinicalTrials.gov must_record: - nct_id - phase @@ -67,6 +75,11 @@ source_tiers: - 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 @@ -223,12 +236,42 @@ declared_search_scope: - 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 的每一类执行过检索 + - 每个 target 都对 per_target_required_source_classes 的每一类的**每一个 endpoint** 执行过检索 - 每次检索都记录了 query_expression、executed_at、result_count 与 reachable - - 所有 reachable = false 的来源类都被显式记录为未完成,而非静默跳过 + - 每个 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 求值规则与冻结优先级 @@ -249,11 +292,14 @@ precedence_rationale: >- derivation_rules: - id: L3-01 - condition: 未按 declared_search_scope 完成规定检索 + 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, @@ -262,7 +308,9 @@ derivation_rules: disposition: RETAIN resulting_state: active - id: L3-03 - condition: 存在 CRC 疾病级证据,但 context 为亚群且无 D 类证据 + condition: >- + D 类检索已完成,存在 CRC 疾病级证据,但 context 为亚群且 D 类无命中 + requires_class_d_search_complete: true lock_03_outcome: linkage_unassessed disposition: DEFER resulting_state: hold @@ -275,7 +323,10 @@ derivation_rules: reason: 泛癌 precedent 只证明 modality precedent,不证明与 CRC context 的 linkage。 retained_as: target_modality_metadata - id: L3-05 - condition: 已按 declared_search_scope 完成规定检索,且四类 linkage 均无命中 + 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 @@ -310,7 +361,11 @@ predicted_result_shape: # ---------------------------------------------------------------- 输出 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 @@ -350,9 +405,52 @@ output_schema: - 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 @@ -360,6 +458,8 @@ output_schema: # 找不到证据的 pair 不得伪造 source provenance,须记录检索 provenance。 conditionally_required_columns: - when_provenance_kind: source_supported + # 必填列所属的表。审核必须逐表检查,不得用两张表列的并集代替。 + table: evidence required_columns: - source_ref - source_locator @@ -367,6 +467,7 @@ output_schema: 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 @@ -380,6 +481,7 @@ output_schema: - source_locator fabricating_source_evidence_forbidden: true - when_provenance_kind: search_incomplete + table: disposition required_columns: - search_complete - search_scope @@ -420,11 +522,39 @@ output_validation: provenance_kind = no_evidence_found_after_complete_search 或 search_incomplete 的行, source_ref 与 source_locator 允许为空,但不得伪造;出现非空 source_ref 即为验证失败。 - id: VAL-L09 - rule: conditionally_required_columns 声明的必填列必须全部在 evidence_columns 或 disposition_columns 之内。 + 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 的每一类,未达即该 target 的全部 pair 落 L3-01。 + rule: >- + 每个 target 的检索记录必须覆盖 per_target_required_source_classes 的每一类的 + 每一个 endpoint,未达即该 target 的全部 pair 落 L3-01。 - id: VAL-L12 rule: 输出中不得出现任何 Gate 分数、Gate 状态、T2/T7 判定、排序、Tier 划分或实验建议。 - id: VAL-L13 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 index 1f401f6..adc7070 100644 --- a/docs/tasks/EVGAP_02_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md +++ b/docs/tasks/EVGAP_02_CRC_LINKAGE_EXTRACTION_CONTRACT.zh-CN.md @@ -87,10 +87,27 @@ PR #58 曾判定 `no_known_linkage_after_complete_search` **不可用**,理由是检索范围未闭合。本节正是使该 outcome 变为可用的前提——范围一旦冻结,「是否完成规定检索」就成为可判定的事实,而不是执行者的自我声明。 -- 每个 target 必须对三类 Tier 1 来源(文献、试验注册库、公开分子数据集)各执行检索 -- 必须记录 query template,含 target 符号与同义词、CRC 术语、类别特异术语、日期范围 -- 每次检索必须记录 `source_class`、`endpoint`、`query_expression`、`executed_at`、`result_count`、`reachable` -- **来源不可达 → 该 target 判为检索未完成**(`search_incomplete_for_that_target`),不得静默跳过(`silent_skip_forbidden: true`) +完整性分**两级**,`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`。 ### 检索粒度 @@ -124,7 +141,28 @@ EVGAP-01 读取的是已固定的数据集,结果可以事先算出并逐项 ## 七、输出与 provenance 分层 -**两张表。** `evidence` 表每条证据一行,含来源文档规定的 13 列必需最小集,另加 `linkage_class`、`is_adc_efficacy_evidence`、`positive_fraction_or_prevalence`、`malignant_cell_attribution`、`retrieved_at`。`disposition` 表每个 pair 一行,21 列,含 `rule_id`、六项检索完整性字段、`provenance_kind`、`provisional_only`、`may_advance_to_level_02`。 +**两张表,且两表之间有稳定的一对一引用关系。** + +`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 的教训): @@ -134,9 +172,11 @@ EVGAP-01 读取的是已固定的数据集,结果可以事先算出并逐项 | `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`)。测试断言所有条件必填列都在输出 schema 之内(PR #59 阻断 2 的教训)。 +后两种**禁止伪造 source evidence**;出现非空 `source_ref` 即为验证失败(`VAL-L08`)。 + +每个 `conditionally_required_columns` 块都写明 `table`(`evidence` 或 `disposition`),**测试逐表检查必填列,不再用两张表列的并集代替**(`VAL-L09`)——初稿用并集,恰好掩盖了阻断 2 那个问题。 -15 条验证规则 `VAL-L01`..`VAL-L15` 见 YAML。 +**20 条**验证规则 `VAL-L01`..`VAL-L20` 见 YAML。 ## 八、必须写进结果报告的四条 diff --git a/logs/worklog.md b/logs/worklog.md index 73c6cc0..b56e675 100644 --- a/logs/worklog.md +++ b/logs/worklog.md @@ -2584,3 +2584,18 @@ Purpose: append a detailed timestamped record of what was done, how it was done, - 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 index ca02139..d11a2be 100644 --- a/tests/test_evgap_02_crc_linkage.py +++ b/tests/test_evgap_02_crc_linkage.py @@ -305,17 +305,127 @@ def test_no_result_shape_is_predicted_for_a_discovery_run(self) -> None: self.assertNotIn(key, predicted) # ------------------------------------------------------------- provenance - def test_required_columns_all_exist_in_the_output_schema(self) -> None: + 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"] - columns = set(schema["evidence_columns"]) | set(schema["disposition_columns"]) + evidence = set(schema["evidence_columns"]) + disposition = set(schema["disposition_columns"]) for column in REQUIRED_EVIDENCE_COLUMNS: with self.subTest(column=column): - self.assertIn(column, schema["evidence_columns"]) + self.assertIn(column, evidence) for block in schema["conditionally_required_columns"]: - with self.subTest(kind=block["when_provenance_kind"]): - missing = set(block["required_columns"]) - columns - self.assertEqual(missing, set(), f"missing columns: {missing}") - self.assertIn(block["when_provenance_kind"], schema["provenance_kinds"]) + 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"]