diff --git a/docs/handoff/2026-08-05-evgap-02-extraction-result.zh-CN.md b/docs/handoff/2026-08-05-evgap-02-extraction-result.zh-CN.md new file mode 100644 index 0000000..0ea8346 --- /dev/null +++ b/docs/handoff/2026-08-05-evgap-02-extraction-result.zh-CN.md @@ -0,0 +1,227 @@ +# Handoff:EVGAP-02 Tier 1 检索候选层 + 契约 v0.2.0(revision 3) + +- 日期:`2026-08-05` +- 任务分支:`task_20260805_evgap-02-extraction-result` +- 基线:`main` @ `8aa7e87` +- 本修订依据:**PR #62 第一轮与第二轮 `REQUEST_CHANGES`** +- 外部运行:`gen_iet_evgap_02_crc_linkage_20260805T190453Z` **revision 3** +- 交付物类型:**契约修订 + 结果降级(外部运行留痕)** +- 审核状态:等待 ChatGPT `APPROVE`。**本 PR 不适用 `AGENTS.md`「审核豁免」。** +- **结论:`EVGAP-02` 未解除。本运行只完成 `L-RETRIEVAL` 层。** + +## 一、审核意见成立,逐条实测复核 + +审核方指出:这些是搜索命中,不是 linkage evidence。**在改动任何东西之前先对实际文件逐条核验,全部成立。** + +1. **7,067 行全部 `evidence_direction = unknown`、`review_status = machine_retrieved_requires_human_review`。** +2. **没有任何一行带有已解析的断言字段**——实测比审核描述更彻底: + `positive_fraction_or_prevalence` 在 7,067 行中**全为空**, + `is_adc_efficacy_evidence` 全为 `false`, + `malignant_cell_attribution` 全为 `unresolved`/`not_applicable`, + 5,699 条文献行的 `protein_or_rna` 全为 `unresolved`。**一条已抽取的断言都没有。** +3. **实体消歧从未做过。** +4. **`ClinicalTrials.gov` 只用宽查询**,未解析 intervention/target/modality/indication,未验证同臂。 +5. **`GEO` 的 `db=gds` 元数据命中被登记为 A 类**(666 行)。 +6. **D 类 218 行全部 `literature_record`,prevalence 空、attribution `unresolved`**——共现而非富集。 + +### 一处需要更正的细节 + +审核意见说 TCGA 与 HPA 的命中「被算作 A 类证据」。**实测:它们没有产生任何证据行。** +证据表的 `source_ref` 前缀只有四种:`PMC` 3,240、`PubMed` 2,459、`ClinicalTrials.gov` 702、`GEO` 666。 +两者的检索被执行并计入覆盖,但未登记为证据。 + +**原则完全成立,实际的实例是 `GEO`。** v0.2.0 把三个 dataset endpoint 一并禁用为 A 类依据。 + +### 一处比审核意见更严重的发现 + +**`Undisclosed` 不是实体,是缺失值占位符**,却被当作基因符号检索:`PMC/A` 返回 **1,384** 条,产出 1 个 RETAIN。 + +`CA19-9`(糖类抗原,无 HGNC 符号):`PMC/A` **14,200** 条,9 个 pair 中 **8 个 RETAIN**。 + +`EDBN`:11 个 endpoint **全部 0 命中**,9 个 pair 落 `L3-05` **EXCLUDE**。 +它疑指 fibronectin 的 extra domain B(标准符号 `FN1`)—— +**被排除的唯一原因是这个缩写本身不通行于文献。消歧失败被当成了完整检索后的阴性结论。** + +### 一处自查发现、审核未提的缺陷 + +**未披露的检索截断。** 451 次检索报告命中合计 **718,140**,实际登记 **979** 条; +**333/451 次检索被截断**,绝大多数每组只留 **3** 条。revision 1 未声明此上限却宣称检索完整。 + +## 二、根因在契约,不只在执行 + +v0.1.0 把 `evidence_direction` 与 `review_status` 列为**必需列,却没有任何一条规则要求它们被解析**。 +`linkage_class` 也没有任何规则约束其来源,于是它由**查询类别**决定。 +**一次完全合规的执行因此产出了 168 条 RETAIN。** 这是契约漏洞,修必须修在契约上。 + +## 三、契约 v0.2.0 + +### 三层结构 + +| layer | 产物 | 可支撑 LOCK-03 | +|---|---|---| +| `L-RETRIEVAL` | `retrieval_candidates` | **否** | +| `L-ASSERTION` | `linkage_assertions` | 是 | +| `L-DISPOSITION` | `pair_linkage_disposition` | 只能引用 assertion | + +`assertion_requirements` 规定六个构成要件(target/CRC/context 实体消歧、`relationship_type`、 +`assertion_direction`、`supporting_text_or_structured_field`),并**硬性禁止 `assertion_direction = unknown`**—— +这正是 v0.1.0 漏掉 7,067 行的那道检查。 + +`linkage_class` 由 assertion 内容判定;候选表改记 `query_class_label`,且**候选表不得含 `linkage_class` 列**。 + +### 关于 `DECISION-02` + +PR #58 允许机器抽取的证据满足 LOCK-03 existence——**前提是机器已抽出一条具体、可审计的 assertion**。 +v0.1.0 停在候选。这一读法写进了 `L-ASSERTION.machine_extraction_permitted_basis`,人工复核要求不变。 + +### 实体消歧与 `L3-00` + +新增 `L3-00`,置于优先级最前。关键是**不对称**:未消歧实体既不得 RETAIN, +也**绝不得** EXCLUDE——`unresolved_may_not_exclude` 直接以 `EDBN` 为例写明。 + +`L3-00` **不引入新 outcome**:LOCK-03 的词表由 PR #57 冻结,其中没有 `identity_unresolved` +(该 outcome 只属于 LOCK-01)。故 `L3-00` 复用 `linkage_evidence_missing`, +其 `evidence_state = absent_incomplete_search` 恰好正确,身份信息另由 +`identity_resolution_status` 列承载。 + +四个实体在 target 轴上同属 `rq_01_family_count = 0`、规则 `E1-05`, +故**筛出**它们不依赖自由裁量。但该字段只用于筛候选,**不决定** `resolution_status`, +也不决定是否落 `L3-00`——`CA19-9` 同属 `E1-05`,却已消歧。这正是第二轮的教训。 + +### endpoint 命中证明了什么 + +新增 `endpoint_evidence_admissibility`,逐 endpoint 写明 `hit_proves` 与 `hit_does_not_prove`。 +TCGA/HPA/GEO 三者 `admissible_as_class_a: false`, +但**仍为必查**(服务于覆盖与身份消歧),其命中不计入 `linkage_classes_hit`。 +`ClinicalTrials.gov` 须记录五个结构化字段并满足**同臂**要求。 + +### 检索完整性 + +`search_complete` 现在需要四层:身份消歧、endpoint 覆盖、pair 级 D 类、**assertion 抽取完成**。 +`retrieval_alone_is_not_search_complete: true`。 + +新增 `VAL-L21`..`VAL-L29`。 + +## 四、结果降级(revision 3) + +| | revision 1 | revision 3 | +|---|---|---| +| 证据表 | `pair_linkage_evidence.tsv` 7,067 行 | 撤销 → `retrieval_candidates.tsv` **979** + `linkage_assertions.tsv` **0** | +| `L3-02` RETAIN | 168 | **0** | +| `L3-03` | 192 | 0 | +| `L3-05` EXCLUDE | 9 | **0** | +| 全部 369 pair | 三分 | **全部 DEFER / hold**(`L3-00` 27 + `L3-01` 342) | + +**未重复任何网络调用,未丢弃任何已检索记录。** + +979 与 7,067 的关系:A/B/C 按 target 检索一次,revision 1 把每条记录复制到 9 个 context 上。 +`A 2808/9=312`、`B 2295/9=255`、`C 1746/9=194`、`D 218`(pair 级不复制),合计 **979**。 +**「7,067 条证据」实为 979 条记录乘以 9。** + +369 个 pair:`L3-00` **27**(3 个**不可消歧**实体 `Undisclosed`/`EDBN`/`AG7` × 9)、 +`L3-01` **342**(assertion 层未执行,含 `CA19-9` 的 9 个 pair)。 +三组 `*_evidence_refs` 在 369 行中全空。 + +### 第二轮审核指出的矛盾,以及我为什么会犯 + +revision 2 给出 36/333,把 `CA19-9` 也放进 `L3-00`。**这与契约直接冲突**: +契约把它定为 `resolved_as_non_protein_antigen`,而 `search_complete_definition` +明确接受该 status——**它是已消歧的实体,不是身份未解析**。 + +根因在我自己的命名与实现:契约那张表叫 `known_unresolved_entities`, +里面却有一个 **resolved** 的条目;重建脚本按**表成员身份**而非按 `resolution_status` +赋 `L3-00`。**名字招来了这个 bug,脚本接受了邀请。** + +修在失效点而非症状:表改名 `known_identity_findings`;新增 `l3_00_statuses`; +`l3_00_membership_test: resolution_status`;`l3_00_membership_by_list_forbidden: true`; +每个条目显式声明其 status 蕴含的 `lock_03_rule`。 + +采用审核建议的**方案一**:`CA19-9` 保持 `resolved_as_non_protein_antigen`,转入 `L3-01`。 +它 defer 的原因是 assertion 层未执行,**外加** v0.1.0 按基因符号检索糖类抗原的查询形式无效—— +**不是**身份不明。新增 `non_protein_antigen_search_requirements` 与 `VAL-L29` 固定这一区分。 + +验证脚本的 `L3-00` 期望集**由契约推导**(按 `resolution_status` 过滤),不是硬编码, +因此同类错误再犯会被捕获。 + +## 五、上游缺陷 GAP-P07(登记,不修) + +PR #58 冻结的 41 个 target 中至少四个的身份需单独裁定,且**四者性质不同**: +`Undisclosed` 不是实体;`EDBN`、`AG7` 是无法消歧的缩写; +**`CA19-9` 已消歧,只是不是蛋白**——非蛋白抗原是否属于「所有潜在 ADC 膜蛋白靶点」 +这一 target universe,才是 `GAP-P07` 真正要回答的问题。 + +`EVGAP-02` 无权改轴——在本契约内给 `Undisclosed` 编一个身份等于静默改轴。 + +**binding 本身已察觉这一点**:它把 `identity_unresolved` 列入 `unavailable_outcomes`, +理由是「已批准层没有身份解析结论字段」。四个实体因此以 `E1-05` 留在轴上。 +**缺陷在 PR #58 时已可见,只是当时无法表达。** + +修复须另开 PR,且会改动 41/369 这两个冻结计数。 + +## 六、产物与校验 + +外部包 **revision 3**(仓库内无任何数据文件)。**8 个文件**,ZIP 内 9 个条目(多出的一条是目录条目): + +| 文件 | 行数 | SHA-256 | +|---|---|---| +| `retrieval_candidates.tsv` | 979 | `09a2aa75ee7885ed9be3807d8c074e8a31fb81bef6bfff27728181831e5c326a` | +| `linkage_assertions.tsv` | 0 | `f0c83e0e2fb0aa13e354babe00164d9287c226bd6d7229c29434d664f744ee8b` | +| `pair_linkage_disposition.tsv` | 369 | `33674913edf0d1efb82f4bc2a8303e55e18f2a39a32ba7b2dd3152c5f6734dad` | +| `search_log.tsv` | 451 | `dd0569c572bfd09f74f034f1844811c918182c767118e963afc9ed0a14c7ce08` | +| `run_report.md` | — | `4c4def27c73853ab175c9610c58bcb41c6765276cec133e71f161ee9a7dfc2ac` | +| `external_run_worklog.md` | — | `0db30b1e0e0fa5e98637619275d595729cb30c2b90fdcaad913ae7e8a1565cd7` | +| `verify_package.py` | — | `63bd46eb220a6d12be594ad809b03a933dbd3b2901d7d5d9a4d1e7d9b3701bcf` | +| `source_manifest.json` | — | `776677c571725060175e3e480936e8f17eeeebb3ee67d5472c2a52c114a5265f` | + +**打包件(唯一正式受审包):** + +- `gen_iet_evgap_02_crc_linkage_20260805T190453Z_rev3.zip` +- SHA-256 `81baa45f23f180c68b16d18c83284b60bdee725c017e668e590d4e80b04176e9` +- 46,292 bytes,8 个文件(ZIP 内 9 个条目) +- `revision = 3` + +**上一版声明的 ZIP SHA-256 `ef268fd2…` 作废**——包内新增了 `verify_package.py`, +内容变了哈希必然变。**以本表为准。** + +同时更正上一版 handoff 的一处笔误:`pair_linkage_disposition.tsv` 曾被写成 +`4c4def27…`,那其实是 `run_report.md` 的哈希。本表由 `source_manifest.json` 直接生成, +不再手抄。 + +### 包内自带验证脚本 + +``` +python3 verify_package.py . +python3 verify_package.py . --zip ../.zip --zip-sha256 +``` + +只读包内文件,无网络、无写入、不依赖包外路径,解压即可运行,退出码 0 表示全通过。 +逐项检查:文件数、每个文件的 SHA-256 与字节数、清单未遗漏文件、`revision = 3`、 +`979 / 0 / 369`、schema(候选表无 `linkage_class`、三个标记列齐备)、 +候选表三个固定值、`L3-00 27` / `L3-01 342` / `L3-02..L3-05` 全为 `0`、 +无 RETAIN 无 EXCLUDE、369 行全 DEFER/hold、三组 refs 全空、 +`may_advance_to_level_02` 全 `false`、三个不可消歧 target 各 9 个 pair 全 `L3-00`、 +`CA19-9` 9 个 pair 全 `L3-01` 且 `identity_resolution_status = +resolved_as_non_protein_antigen`、`EVGAP-02` 未解除。 + +**实测(从全新解压目录运行,含 ZIP 哈希校验):`65/65 MATCH`,退出码 0。** + +仓库内变更:契约 v0.2.0、契约文档、测试、本 handoff、一条 worklog。 +`tests/test_evgap_02_crc_linkage.py` **47 个测试**通过;全库 `Ran 356 tests` OK。 +外部产物另经包内 `verify_package.py` 核验:**65/65 MATCH**,退出码 0。 + +## 七、边界 + +未运行任何 Gate,无评分、无排序、无 Tier、无资产推荐、无实验建议; +未新增或修改靶点与 clinical context;未读取任何 Tier 2 派生库; +未引用被隔离运行(PR #53、#54)的任何产物;未写入任何数据文件到仓库。 +369 个 pair 的 `may_advance_to_level_02` 全部 `false`。 + +## 八、后续顺序 + +1. 本 PR `APPROVE`(契约 v0.2.0 + `L-RETRIEVAL` 层产物),在 `logs/` 留审核记录。 +2. **另开 PR** 处理 `GAP-P07`:`Undisclosed`/`EDBN`/`AG7` 解析为标准符号或移出轴; + `CA19-9` 则须裁定非蛋白抗原是否属于本 target universe。 +3. `GAP-P07` 处理后执行 `L-ASSERTION` 抽取 → 结果 PR。 +4. 两层齐备后才谈解除 `EVGAP-02`。 +5. `EVGAP-01` 走另一条独立轨道(`SRCADM-01` 在 PR #63 待审)。 +6. **两个缺口都解除后**,才能生成 `ADC_POOL_LEVEL_01_ACCEPTED`。 diff --git a/docs/pools/evgap_02_crc_linkage_extraction.yaml b/docs/pools/evgap_02_crc_linkage_extraction.yaml index c44650d..aa40f00 100644 --- a/docs/pools/evgap_02_crc_linkage_extraction.yaml +++ b/docs/pools/evgap_02_crc_linkage_extraction.yaml @@ -7,15 +7,41 @@ # 由 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 应该具体抽取什么 +# +# v0.2.0 修订(PR #62 审核):v0.1.0 有一个承重漏洞——它把 evidence_direction 与 +# review_status 列为必需列,却没有任何一条规则要求它们被解析。结果 0.1.0 下的一次 +# 合规执行产出了 7,067 行 evidence_direction=unknown 的检索命中,并据此判出 168 条 +# RETAIN。检索命中不是 linkage 证据。v0.2.0 把抽取拆成三层,并要求 LOCK-03 只能 +# 引用已抽取的 assertion。 extraction: extraction_id: evgap_02_crc_linkage - extraction_version: 0.1.0 + extraction_version: 0.2.0 + supersedes_version: 0.1.0 + amended_after_review_of_pr: 62 + amendment_reason: >- + v0.1.0 允许「搜索命中」直接充当 A/B/C/D 类 linkage 证据:linkage_class 由 + query 类别决定,而非由记录内容决定。因此 target、CRC 词与 modality 词在同一篇 + 全文中共现即可被登记为 precedent。v0.2.0 引入 assertion 层,把「检索到候选」 + 与「已抽取断言」分开,并把实体消歧前置为 LOCK-03 可求值的前提。 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 + status: amended_pending_review + # v0.1.0 已执行一次,其产物按 PR #62 审核结论降级为 L-RETRIEVAL 层产物, + # 不构成 EVGAP-02 结果。v0.2.0 的 L-ASSERTION 与 L-DISPOSITION 尚未执行。 + execution_status: retrieval_layer_executed_assertion_layer_not_executed + gap_discharged: false + executed_runs: + - run_id: external:result/gen_iet_evgap_02_crc_linkage_20260805T190453Z + under_contract_version: 0.1.0 + layers_completed: + - L-RETRIEVAL + layers_not_completed: + - L-ASSERTION + - L-DISPOSITION + downgraded_by_review_of_pr: 62 + discharges_gap: false # 与 EVGAP-01 不同:本抽取不依赖任何未获准入的派生数据库, # Tier 1 原始公开来源即可支撑,故获 APPROVE 后即可执行。 authorises_extraction_run_after_approve: true @@ -31,6 +57,248 @@ extraction: LOCK-03 问的是「target 为什么与这个 CRC clinical context 有关」, 与表面拓扑无关,因此不读取 surfaceome 参考库,也不受其准入状态影响。 +# ---------------------------------------------------------------- 三层结构 +# +# v0.1.0 只有「检索」与「判定」两步,中间缺一层。缺的这一层正是「这条记录到底 +# 断言了什么」。没有它,linkage_class 只能由 query 类别决定,于是分类器就是查询词本身。 +extraction_layers: + - layer_id: L-RETRIEVAL + name: candidate_retrieval + unit: target(A/B/C)与 pair(D) + produces: retrieval_candidates + # 这一层的产物永远不能单独支撑 LOCK-03。 + may_support_lock_03: false + may_be_referenced_by_disposition: false + what_it_proves: >- + 在该 endpoint 上,给定 query 表达式返回了这条记录。仅此而已。 + 它不证明该记录谈及本 target,也不证明其中的 CRC 语义与本 target 有关。 + known_false_positive_modes: + - 缩写碰撞(符号在正文中指代其他事物) + - 全文命中(target 与 CRC 词分别出现在正文不同段落、彼此无关) + - 参考文献命中(命中来自文末引文而非正文主张) + - 综述背景命中(modality 词属于其他 target 的 precedent) + - "免责声明命中(PR #58 已记录:33 条 statement 全含 CRC,但只出现在 + 「precedent does not establish CRC efficacy」这句免责声明里)" + - layer_id: L-ASSERTION + name: assertion_extraction + unit: assertion + consumes: retrieval_candidates + produces: linkage_assertions + may_support_lock_03: true + # 一条 assertion 必须已经把主体、癌种、关系、方向与出处落到具体字段上。 + requires_all_of: assertion_requirements + machine_extraction_permitted: true + machine_extraction_permitted_basis: >- + PR #58 的 DECISION-02 允许机器抽取的证据满足 LOCK-03 existence, + 但其前提是机器**已经抽出一条具体、可审计的 linkage assertion**, + 而不是「返回了一批候选记录」。v0.1.0 的执行停在候选,未做抽取。 + human_review_still_required: true + human_review_scope: 每条 assertion 的 review_status 仍为待人工复核 + - layer_id: L-DISPOSITION + name: lock_03_disposition + unit: pair + consumes: linkage_assertions + may_only_reference: linkage_assertions + may_reference_retrieval_candidates: false + reason: >- + supporting_evidence_refs 若可指向纯检索命中,L3-02 就退化为 + 「query 有返回」。这正是 v0.1.0 实际发生的事。 + +# --------------------------------------------------------- assertion 的构成要件 +# +# 任一要件缺失,该记录停留在 L-RETRIEVAL,不得升级为 assertion。 +assertion_requirements: + required_elements: + - element: target_entity_resolved + meaning: 记录中的主体已消歧到本 pair 的 target,而不仅是字符串匹配 + - element: crc_entity_resolved + meaning: 记录中的癌种已消歧为 CRC,而不是「正文出现 colorectal 一词」 + - element: context_entity_resolved + meaning: D 类必需——亚群语义已消歧到本 pair 的 clinical context + - element: relationship_type + meaning: target 与 CRC/context 之间的关系类型,取自 relationship_types + - element: assertion_direction + meaning: 关系的方向,不得为 unknown + - element: supporting_text_or_structured_field + meaning: 支撑该断言的具体原文段落,或具体结构化字段值 + relationship_types: + - expressed_in + - enriched_in + - retained_in + - targeted_by_modality_in + - clinically_evaluated_in + # 这是 v0.1.0 缺失的那一条硬性要求。 + direction_must_be_resolved: true + direction_unknown_forbidden_for_lock_03: true + direction_unknown_consequence: 该记录停留在 retrieval_candidate,不得进入任何 refs + # 共现不是断言。 + cooccurrence_is_not_an_assertion: true + cooccurrence_examples_rejected: + - target 与 CRC 词共现于同一全文 + - target、CRC 与 "ADC" 共现于同一全文 + - target 与 BRAF/HER2/CMS4 等亚群词共现于同一全文 + examples_of_valid_assertions: + - "CEACAM5 protein was detected in 72% of CRC tumours" + - "A CEACAM5 ADC was tested in a CRC xenograft model" + examples_of_invalid_assertions: + - "一篇综述同时提到本 target、colorectal 与 antibody-drug conjugate" + - "GDC gene index 中存在该 gene symbol" + +# ---------------------------------------------------------------- 实体消歧 +# +# v0.1.0 直接把 target 轴的字符串塞进 query。实测后果:字符串 "Undisclosed" +# 被当作基因符号检索,PMC 返回 1,384 条,并使 1 个 pair 判出 RETAIN。 +identity_resolution: + required_before_search: true + resolution_must_record: + - input_symbol + - resolved_identifier_kind + - resolved_identifier + - synonyms_used + - resolution_status + - resolution_basis + resolved_identifier_kinds: + - hgnc_gene_symbol + - uniprot_accession + - non_protein_antigen_definition + resolution_status_vocabulary: + - resolved + - resolved_as_non_protein_antigen + - unresolvable_placeholder + - unresolvable_ambiguous_abbreviation + # 未消歧的实体不得进入检索,更不得由检索结果推出任何 LOCK-03 结论。 + unresolved_may_not_be_searched: true + unresolved_lock_03_rule: L3-00 + # 关键不对称:未消歧实体既不得 RETAIN,也**绝不得** EXCLUDE。 + unresolved_may_not_retain: true + unresolved_may_not_exclude: true + unresolved_may_not_exclude_reason: >- + 零命中若源于符号无法消歧,那是消歧失败,不是「完整检索后无 linkage」。 + 实测 v0.1.0 正是在此处出错:EDBN 在全部 11 个 endpoint 返回 0 条, + 于是 9 个 pair 落 L3-05 EXCLUDE。而 EDBN 应指 fibronectin 的 extra domain B, + 其标准符号为 FN1;被排除的原因只是缩写本身不出现在文献里。 + # 本次 target 轴中实测四个必须单列的实体。 + # 只有这两个 status 触发 L3-00。判据是 status,**不是**「是否出现在下面这张表里」。 + # v0.2.0 首版把该表命名为 known_unresolved_entities 并让执行者按表成员身份赋 + # L3-00,于是 CA19-9 虽已消歧仍落进 L3-00——契约与结果自相矛盾。表名与判据都已改。 + l3_00_statuses: + - unresolvable_placeholder + - unresolvable_ambiguous_abbreviation + l3_00_membership_test: resolution_status + l3_00_membership_by_list_forbidden: true + # 本表登记的是「身份需要单独裁定的实体」,其中既有未消歧的,也有已消歧但 + # 不能按基因符号检索的。两者的 LOCK-03 处置不同。 + known_identity_findings: + - input_symbol: Undisclosed + resolution_status: unresolvable_placeholder + lock_03_rule: L3-00 + note: 这不是实体,是缺失值占位符。v0.1.0 对其执行了 11 次检索。 + - input_symbol: EDBN + resolution_status: unresolvable_ambiguous_abbreviation + lock_03_rule: L3-00 + note: 疑指 fibronectin extra domain B(标准符号 FN1),须由来源轴确认后才可检索。 + - input_symbol: AG7 + resolution_status: unresolvable_ambiguous_abbreviation + lock_03_rule: L3-00 + note: 非标准靶点符号;PMC 返回 56 条,全部为缩写碰撞候选。 + - input_symbol: CA19-9 + resolution_status: resolved_as_non_protein_antigen + # 已消歧,故**不**落 L3-00。它与其余靶点一样受 assertion 层未执行所阻。 + lock_03_rule: L3-01 + resolved_identifier_kind: non_protein_antigen_definition + resolved_identifier: sialyl-Lewis a(CA19-9 糖类抗原表位) + note: >- + 糖类抗原,非蛋白基因产物,无 HGNC 符号——但这是一个可定义的实体, + 不等于身份未解析。它是否属于「所有潜在 ADC 膜蛋白靶点」这一 Level 01 + target universe,属 GAP-P07 的上游问题,不在本契约内裁定。 + invalid_query_form_in_v0_1_0: true + invalid_query_form_note: >- + v0.1.0 按基因符号检索它,PMC 返回 14,200 条。该检索形式无效, + 其候选记录不得计入检索完整性;但这使检索未完成(L3-01),不使身份未解析。 + # 已消歧为非蛋白抗原的实体必须换一种检索形式,不得按基因符号检索。 + non_protein_antigen_search_requirements: + gene_symbol_query_forbidden: true + required_query_basis: + - antigen_or_epitope_name_and_synonyms + - carrier_or_glycan_definition + invalid_query_form_consequence: L3-01 + invalid_query_form_consequence_reason: >- + 检索形式无效等同于该 target 的规定检索未完成, + 与「身份未解析」是两回事,不得混为一谈。 + # 可判定性:四个实体在 target 轴中的 LOCK-01 状态一致,可机械筛出**候选**。 + mechanical_precondition: >- + 这四个实体在 PR #58 的 target 轴中同属 rq_01_family_count = 0、rule E1-05 + (参考库无对应行),故可先按该字段机械筛出需要单独裁定身份的候选。 + 但该字段只用于筛候选,**不决定** resolution_status,也不决定是否落 L3-00—— + CA19-9 同属 E1-05,却已消歧。 + +# ------------------------------------------------- 各 endpoint 的命中证明了什么 +# +# v0.1.0 未声明这一层,于是「endpoint 返回一条记录」被一律当作该类证据。 +endpoint_evidence_admissibility: + - endpoint: PubMed + hit_proves: 该 query 在题录索引中匹配到该条记录 + hit_does_not_prove: 该记录断言本 target 与 CRC 有关 + assertion_extraction_source: title_and_abstract + requires_assertion_extraction: true + - endpoint: PMC + hit_proves: 该 query 的词项出现在该全文中的某处 + hit_does_not_prove: 这些词项彼此相关,或都指代本 target + assertion_extraction_source: matched_passage + requires_assertion_extraction: true + additional_requirement: 必须记录命中所在段落,且该段落须同时含主体与癌种语义 + - endpoint: ClinicalTrials.gov + hit_proves: 该 query 匹配到该试验记录 + hit_does_not_prove: 本 target 是干预靶点,或 CRC 是实际纳入适应症 + requires_assertion_extraction: true + # 宽查询(" colorectal")返回的记录不得直接进入 B/C 类。 + required_structured_fields: + - intervention_name + - intervention_target + - intervention_modality + - conditions + - arm_group_assignment + same_arm_requirement: true + same_arm_requirement_note: target 与 indication 必须属于同一试验臂,否则不成立 + - endpoint: TCGA + query_kind: GDC gene index lookup + hit_proves: GDC gene index 中存在该 gene symbol + hit_does_not_prove: + - CRC 肿瘤表达 + - tumour > normal + - prevalence + - malignant-cell attribution + admissible_as_class_a: false + admissible_as_class_a_reason: gene 存在性与表达无关,属身份查询而非证据查询 + permitted_role: identity_resolution_support_only + - endpoint: Human Protein Atlas + query_kind: search index lookup + hit_proves: HPA 中存在该 target 的页面 + hit_does_not_prove: CRC 人体肿瘤表达 + admissible_as_class_a: false + permitted_role: identity_resolution_support_only + - endpoint: GEO + query_kind: db=gds metadata search + hit_proves: 该数据集的标题或摘要匹配了 query 词项 + hit_does_not_prove: 该数据集中本 target 在 CRC 中表达或富集 + admissible_as_class_a: false + admissible_as_class_a_reason: >- + 数据集元数据匹配不是表达测量。要作为 A 类,必须抽取该数据集内本 target 的 + 具体表达结论(含方向),并按 RNA 标注。 + permitted_role: candidate_dataset_identification_only + rna_labelling_still_required: true + +# 三个 dataset endpoint 仍必查——它们服务于检索完整性与身份消歧, +# 但其命中不得计入 linkage_classes_hit。 +dataset_endpoint_handling: + dataset_endpoints: + - TCGA + - Human Protein Atlas + - GEO + still_required_for_coverage: true + hits_excluded_from_linkage_classes_hit: true + hits_may_not_be_sole_basis_for_class_a: true + # ---------------------------------------------------------------- 来源分层 # # 治理事实:仓库内 logs/chatgpt-review-*.md 中**没有任何一条**提及过任何本地 @@ -154,6 +422,23 @@ scope: D 类情境特异性富集必须按 pair 判定。 # ------------------------------------------------- 四类 linkage 判据(来源文档) +# +# v0.2.0:四类都是 assertion 的分类,不是 query 的分类。 +linkage_class_common_requirements: + applies_to_all_classes: true + requires_assertion: true + classified_by: assertion_content + classified_by_query_category_forbidden: true + classified_by_query_category_forbidden_reason: >- + v0.1.0 用 query 类别给记录贴 class:命中「class B 查询」即记为 B 类 precedent。 + 于是「文章里同时出现 target、CRC 与 ADC」被当成「该 target 在 CRC 中有 ADC precedent」。 + 命中可能来自综述、参考文献、背景介绍、别的 target 的 ADC,或 ADC 是别的缩写。 + minimum_fields_per_assertion: + - relationship_type + - assertion_direction + - supporting_text_or_structured_field + direction_unknown_admissible: false + linkage_classes: - class_id: A name: crc_human_tumor_expression @@ -217,7 +502,7 @@ linkage_classes: # 未闭合。本节正是使该 outcome 变为可用的前提:范围一旦冻结, # 「是否完成规定检索」就成为可判定的事实,而不是执行者的自我声明。 declared_search_scope: - scope_version: 0.1.0 + scope_version: 0.2.0 per_target_required_source_classes: - peer_reviewed_literature - clinical_trial_registry @@ -262,14 +547,26 @@ declared_search_scope: # 「完成规定检索」的定义。任一条不满足即为未完成。 search_complete_definition: + - 每个 target 的 input_symbol 都已完成 identity_resolution 且 resolution_status = resolved + 或 resolved_as_non_protein_antigen - 每个 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 + - 该 pair 的全部 retrieval_candidates 都已过 L-ASSERTION 层,即每条候选都已判定为 + 「已抽出 assertion」或「无法抽出 assertion」,不得有未判定的候选 + search_complete_requires_both_levels: false + search_complete_requires_all_levels: true search_complete_levels: + - target_level_identity_resolution - target_level_source_class_and_endpoint_coverage - pair_level_class_d_coverage + - pair_level_assertion_extraction_completed + # v0.2.0 新增:仅完成检索不等于完成本抽取。 + retrieval_alone_is_not_search_complete: true + retrieval_alone_is_not_search_complete_reason: >- + L3-05 EXCLUDE 的前提是「已完整检索且四类均无命中」。若 assertion 层未做, + 「无命中」只是「未判定」。v0.1.0 的 9 条 L3-05 全部出自这一混淆。 unreachable_source_consequence: search_incomplete_for_that_target unreachable_class_d_consequence: search_incomplete_for_that_pair silent_skip_forbidden: true @@ -279,31 +576,55 @@ declared_search_scope: # 五条规则必须覆盖全部 pair 且互斥。优先级先判检索完整性, # 因为检索未完成时其余判据都不可信。 derivation_precedence: + - L3-00 - L3-01 - L3-02 - L3-03 - L3-04 - L3-05 precedence_rationale: >- - 先判检索是否完成——未完成时「没找到」无法与「不存在」区分; + 先判实体是否消歧——符号未消歧时检索结果既不能支持也不能排除; + 再判检索与 assertion 抽取是否完成——未完成时「没找到」无法与「不存在」区分; 再判是否存在与该 context 匹配的 CRC-specific 证据; 再判疾病级证据遇亚群 context 的降级;再判仅有其他癌种 precedent 的降级; 以上都不成立且检索已完成,才允许判定完整检索后无 linkage。 derivation_rules: + # v0.2.0 新增,且必须位于最前。 + - id: L3-00 + condition: >- + 该 pair 的 target input_symbol 的 identity_resolution 结果为 + unresolvable_placeholder 或 unresolvable_ambiguous_abbreviation + lock_03_outcome: linkage_evidence_missing + disposition: DEFER + resulting_state: hold + reason: >- + 符号未消歧时,检索命中可能来自缩写碰撞,零命中可能来自符号本身不通行。 + 两个方向都不可采信,故只能 DEFER。 + # LOCK-03 的 outcome 词表由 PR #57 冻结,其中没有 identity_unresolved + # (该 outcome 只属于 LOCK-01)。故本规则复用 linkage_evidence_missing, + # 其 evidence_state = absent_incomplete_search 恰好正确, + # 身份信息另由 identity_resolution_status 列承载,不新增 outcome。 + outcome_vocabulary_note: reuses_lock_03_frozen_vocabulary + does_not_introduce_new_outcome: true + distinguishing_column: identity_resolution_status + may_never_become_l3_05: true - id: L3-01 condition: >- - 未按 declared_search_scope 完成规定检索——target 级 endpoint 覆盖未齐, - **或**该 pair 的 D 类检索未完成 + 实体已消歧,但未按 declared_search_scope 完成规定工作——target 级 endpoint + 覆盖未齐,**或**该 pair 的 D 类检索未完成,**或**该 pair 的 assertion 抽取未完成 lock_03_outcome: linkage_evidence_missing disposition: DEFER resulting_state: hold - reason: 检索未完成时「没找到」不能与「不存在」区分。 - covers_both_completeness_levels: true + reason: 检索或 assertion 抽取未完成时「没找到」不能与「不存在」区分。 + covers_all_completeness_levels: true - id: L3-02 condition: >- - 存在 A/B/C 任一类的 CRC-specific 证据,且 context 为 canonical_c0, - 或 context 为亚群且同时存在 D 类情境特异性证据 + 存在 A/B/C 任一类的 CRC-specific **assertion**,且 context 为 canonical_c0, + 或 context 为亚群且同时存在 D 类情境特异性 assertion + requires_assertion: true + requires_direction_resolved: true + may_reference_retrieval_candidates: false lock_03_outcome: linkage_evidence_exists disposition: RETAIN resulting_state: active @@ -324,9 +645,14 @@ derivation_rules: retained_as: target_modality_metadata - id: L3-05 condition: >- - 已按 declared_search_scope 完成规定检索(含该 pair 的 D 类检索), - 且四类 linkage 均无命中 + 实体已消歧,已按 declared_search_scope 完成规定检索(含该 pair 的 D 类检索) + **且已完成 assertion 抽取**,四类 linkage 均无 assertion requires_class_d_search_complete: true + requires_identity_resolved: true + requires_assertion_extraction_complete: true + zero_hits_from_unresolved_symbol_forbidden: true + zero_hits_from_unresolved_symbol_forbidden_note: >- + 零命中若源于符号无法消歧,必须落 L3-00,不得落本规则。 lock_03_outcome: no_known_linkage_after_complete_search disposition: EXCLUDE disposition_semantics: EXCLUDE_FROM_ACTIVE_POOL @@ -360,31 +686,101 @@ predicted_result_shape: # ---------------------------------------------------------------- 输出 output_schema: - # 每条证据一行。来源文档规定的 13 列为必需最小集。 - # evidence_id 使每条 disposition 都能一对一回溯到支持它的具体证据行。 - evidence_row_key: evidence_id - evidence_id_unique: true - evidence_columns: - - evidence_id + # v0.2.0:三张表,对应三层。前两张表不可混用。 + tables: + - table: retrieval_candidates + layer: L-RETRIEVAL + may_be_referenced_by_disposition: false + - table: linkage_assertions + layer: L-ASSERTION + may_be_referenced_by_disposition: true + - table: pair_linkage_disposition + layer: L-DISPOSITION + + # 第一层产物。它是检索审计记录,不是证据表。 + retrieval_candidate_row_key: candidate_id + retrieval_candidate_columns: + - candidate_id + - target_input_symbol + - queried_symbol + - source_class + - endpoint + - query_expression + - query_class_label + - source_ref + - source_locator + - result_rank + - retrieved_at + # 这四列把「候选」与「证据」的界限写进 schema 本身。 + - record_status + - linkage_validated + - may_support_lock_03 + - assertion_extraction_status + retrieval_candidate_fixed_values: + record_status: retrieved_candidate + linkage_validated: false + may_support_lock_03: false + # query_class_label 只记录该候选由哪一类查询检出,**不是** linkage_class。 + query_class_label_is_not_linkage_class: true + assertion_extraction_status_vocabulary: + - not_attempted + - assertion_extracted + - no_assertion_extractable + # 第二层产物。只有这张表的行可以被 disposition 引用。 + assertion_row_key: assertion_id + assertion_id_unique: true + assertion_columns: + - assertion_id + - candidate_id - pair_id - target - clinical_context_id - evidence_type + - linkage_outcome + - target_entity_resolved + - crc_entity_resolved + - context_entity_resolved + - relationship_type + - assertion_direction + - supporting_text_or_structured_field + - source_section_or_field + - extraction_method + - extraction_confidence + - review_status + - linkage_class - 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 + assertion_forbidden_values: + assertion_direction: + - unknown + - "" + assertion_must_cite_its_candidate: true + assertion_must_cite_its_candidate_reason: >- + candidate_id 使每条 assertion 都能回溯到它是从哪一次检索的哪一条记录抽出的, + 审核方因此可以独立复核抽取是否成立。 + + # v0.2.0 起「evidence 表」即 linkage_assertions 表,其列为 assertion_columns。 + # 既有验证规则中的「evidence 行」一律指 assertion 行。 + evidence_table_is_assertion_table: true + evidence_table_name: linkage_assertions + evidence_columns_alias: assertion_columns + evidence_row_key: assertion_id + evidence_id_unique: true + # v0.1.0 → v0.2.0 的列改名,避免两版列名被混用。 + renamed_from_v0_1_0: + - from: evidence_id + to: assertion_id + - from: evidence_direction + to: assertion_direction + change: 不再允许 unknown # 每个 pair 一行的 disposition 表。 disposition_columns: - pair_id @@ -405,6 +801,12 @@ output_schema: - search_policy_version - provenance_kind - evidence_row_count + # v0.2.0 新增:承载 L3-00 与 assertion 层完整性,使二者可逐 pair 审计。 + - identity_resolution_status + - resolved_identifier + - assertion_extraction_complete + - retrieval_candidate_count + - assertion_count # 三组引用,使每条 disposition 可逐条审计其依据。 - supporting_evidence_refs - class_d_evidence_refs @@ -421,12 +823,23 @@ output_schema: - may_advance_to_level_02 # 每条规则必须引用什么、必须不引用什么。这是 disposition 与 evidence 之间的 # 一对一审计关系;仅有 evidence_row_count 无法证明某条 disposition 由哪些证据支持。 + # v0.2.0:所有 refs 一律指向 linkage_assertions 表的 assertion_id, + # 指向 retrieval_candidates 的 candidate_id 即为验证失败。 + refs_point_to: assertion_id + refs_pointing_to_candidate_id_forbidden: true evidence_reference_requirements: + - rule_id: L3-00 + supporting_evidence_refs: must_be_empty + class_d_evidence_refs: must_be_empty + other_cancer_evidence_refs: must_be_empty + note: >- + 实体未消歧时,任何命中都可能是缩写碰撞,不得引用为依据; + 零命中也不得据以排除。 - 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。 + note: 检索或 assertion 抽取未完成时不得伪造任何 evidence refs。 - rule_id: L3-02 context_kind: canonical supporting_evidence_refs: at_least_one_of_class_a_b_or_c @@ -527,9 +940,10 @@ output_validation: 不得用两张表列的并集代替逐表检查。 - id: VAL-L16 rule: >- - 每条 evidence 行必须有唯一 evidence_id;每条 disposition 行的 + 每条 assertion 行必须有唯一 assertion_id;每条 disposition 行的 supporting_evidence_refs、class_d_evidence_refs、other_cancer_evidence_refs - 引用的每个 id 都必须存在于 evidence 表,且所引用行的 pair_id 与该 disposition 一致。 + 引用的每个 id 都必须存在于 linkage_assertions 表, + 且所引用行的 pair_id 与该 disposition 一致。 - id: VAL-L17 rule: >- 每条 disposition 必须满足 evidence_reference_requirements 中其 rule_id 与 @@ -549,6 +963,53 @@ output_validation: rule: >- evidence_row_count 必须等于该 pair 三组 refs 去重后的总条数, 不得只报计数而不给 refs。 + # ---------------- v0.2.0 新增:三层结构与实体消歧的验证规则 + - id: VAL-L21 + rule: >- + 每条 assertion 行的 assertion_direction 不得为 unknown 或空。 + 出现一条即为验证失败——这是 v0.1.0 未设、并因此漏掉 7,067 行的那道检查。 + - id: VAL-L22 + rule: >- + 每条 assertion 行必须同时非空:target_entity_resolved、crc_entity_resolved、 + relationship_type、assertion_direction、supporting_text_or_structured_field、 + source_section_or_field;D 类另需 context_entity_resolved 非空。 + - id: VAL-L23 + rule: >- + supporting_evidence_refs、class_d_evidence_refs、other_cancer_evidence_refs + 引用的每个 id 都必须是 linkage_assertions 表的 assertion_id。 + 任一 id 只存在于 retrieval_candidates 表即为验证失败。 + - id: VAL-L24 + rule: >- + retrieval_candidates 表每行必须 record_status = retrieved_candidate、 + linkage_validated = false、may_support_lock_03 = false, + 且 assertion_extraction_status 取自其词表。 + - id: VAL-L25 + rule: >- + linkage_class 必须可由该 assertion 的 relationship_type 与内容判定; + 仅凭 query_class_label 赋 linkage_class 即为验证失败。 + retrieval_candidates 表不得含 linkage_class 列。 + - id: VAL-L26 + rule: >- + TCGA、Human Protein Atlas 与 GEO 三个 endpoint 的命中不得计入 + linkage_classes_hit,也不得作为 A 类 assertion 的唯一依据; + 但它们的检索记录仍必须齐备,缺任一仍触发 L3-01。 + - id: VAL-L27 + rule: >- + 每个 target 的 input_symbol 必须有 identity_resolution 记录。 + L3-00 的成员资格由 resolution_status 判定:当且仅当 status 属于 + l3_00_statuses 时落 L3-00,其全部 9 个 pair 不得落 L3-02,**尤其不得落 L3-05**。 + status 为 resolved 或 resolved_as_non_protein_antigen 的 target + **不得**落 L3-00,即便它出现在 known_identity_findings 表中。 + - id: VAL-L29 + rule: >- + resolution_status = resolved_as_non_protein_antigen 的 target + 不得按基因符号检索。其 v0.1.0 候选记录不得计入检索完整性, + 该 target 的 pair 落 L3-01。 + - id: VAL-L28 + rule: >- + ClinicalTrials.gov 来源的 B/C 类 assertion 必须记录 + required_structured_fields 的五个字段,且 target 与 indication 属于同一试验臂; + 仅有「symbol + colorectal」宽查询命中不得成为 B/C 类 assertion。 - id: VAL-L10 rule: 不得读取 tier_2_derived_local_databases 中的任何数据库。 - id: VAL-L11 @@ -584,6 +1045,53 @@ mandatory_findings: finding: >- LOCK-03 RETAIN 不使 pair 进入 Level 02。EVGAP-01 未解除前 may_advance_to_level_02 恒为 false。 + - id: MF-L05 + finding: >- + 检索命中不是 linkage 证据。报告必须分别给出 retrieval_candidate_count 与 + assertion_count;只报候选数即冒充证据量。 + - id: MF-L06 + finding: >- + 落 L3-00 的 pair 既未被支持也未被排除。报告不得把它们与 L3-05 + 合并计数,也不得表述为「无 linkage」。 + +# --------------------------------------------- 上游缺陷:target 轴含不可消歧实体 +# +# 本节只登记,不修复——target 轴由 PR #58 的 binding 冻结, +# 修复须另开 PR,不得在 EVGAP-02 内静默改轴。 +upstream_defect: + id: GAP-P07 + defect: PR #58 冻结的 41 个 target 中至少四个的身份需要单独裁定 + entities: + - Undisclosed + - EDBN + - AG7 + - CA19-9 + # 四个实体的缺陷性质不同,不可混为一谈。 + entity_kinds: + - input_symbol: Undisclosed + kind: 不是实体 + - input_symbol: EDBN + kind: 缩写无法消歧 + - input_symbol: AG7 + kind: 缩写无法消歧 + - input_symbol: CA19-9 + # 这一条才是 GAP-P07 真正要回答的问题。 + kind: 已消歧,但不是蛋白——是否属于本 target universe 需上游裁定 + where: external:result/gen_iet_crc_target_enumeration_20260802#target_evidence_catalog.tsv + frozen_by_pr: 58 + why_not_fixed_here: >- + EVGAP-02 无权新增或修改 target 与 clinical context(见 not_authorised)。 + 在本契约内给 Undisclosed 编一个身份,等于静默改轴。 + binding_already_knew: >- + binding 的 lock_01 规则明确把 identity_unresolved 列为 unavailable_outcomes, + 理由是「已批准层没有身份解析结论字段,无法区分身份未解析与注释不可得」。 + 也就是说该缺陷在 PR #58 时已被察觉但无法表达,于是四个实体以 + rq_01_family_count = 0、rule E1-05 的形式留在轴上。 + interim_handling: 本抽取按 L3-00 处理,全部 DEFER,既不 RETAIN 也不 EXCLUDE + requires_separate_pr: true + separate_pr_scope: >- + 在 target 轴层面决定这四个实体的归属:解析为标准符号、定义为非蛋白抗原、 + 或从轴上移除。移除会改变 41/369 这两个冻结计数,故必须经审核。 not_authorised: - 执行 Level 01 @@ -594,3 +1102,10 @@ not_authorised: - 新增靶点或 clinical context - 任何筛选排序、Tier 划分、资产推荐或实验建议 - 把被隔离运行(PR #53、#54)的任何产物引入 + # v0.2.0 新增 + - 把 retrieval_candidates 表的行引用为 LOCK-03 的依据 + - 用 query 类别给记录赋 linkage_class + - 让 assertion_direction = unknown 的记录支撑任何 disposition + - 把 TCGA/HPA/GEO 的命中当作 CRC 表达证据 + - 为 target 轴上不可消歧的实体编造身份,或据其零命中作出排除 + - 修改 target 轴以修复 GAP-P07(须另开 PR) 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 adc7070..b74ce66 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 @@ -4,8 +4,67 @@ - 前置工作包: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`。** +- 当前状态:**v0.2.0,`L-RETRIEVAL` 层已执行,`L-ASSERTION` 层未执行,等待 ChatGPT 审核** +- 授权范围:**不授权执行 Level 01,不解除 `EVGAP-01`,`EVGAP-02` 亦未解除。** + +## 〇、v0.2.0 修订(PR #62 审核) + +v0.1.0 有一个承重漏洞:它把 `evidence_direction` 与 `review_status` 列为**必需列, +却没有任何一条规则要求它们被解析**;`linkage_class` 也没有任何规则约束其来源。 +于是一次**完全合规**的执行产出了 7,067 行 `evidence_direction = unknown` 的检索命中, +并据此判出 **168 条 RETAIN、9 条 EXCLUDE**。 + +**检索命中不是 linkage 证据。** 修必须修在契约上,因为执行并未违约。 + +### 三层结构 + +| layer | 产物 | 可支撑 LOCK-03 | +|---|---|---| +| `L-RETRIEVAL` | `retrieval_candidates` | **否** | +| `L-ASSERTION` | `linkage_assertions` | 是 | +| `L-DISPOSITION` | `pair_linkage_disposition` | 只能引用 assertion | + +`assertion_requirements` 规定六个构成要件:target/CRC/context 实体消歧、 +`relationship_type`、`assertion_direction`、`supporting_text_or_structured_field`。 +**硬性禁止 `assertion_direction = unknown`**——这正是 v0.1.0 漏掉 7,067 行的那道检查(`VAL-L21`)。 + +`linkage_class` 由 assertion 内容判定;候选表改记 `query_class_label`, +且**候选表不得含 `linkage_class` 列**(`VAL-L25`)。 + +### `DECISION-02` 的正确读法 + +PR #58 允许机器抽取的证据满足 LOCK-03 existence,**前提是机器已抽出一条具体、可审计的 assertion**—— +主体、癌种、关系、方向、出处俱全。v0.1.0 停在候选检索。人工复核要求不变。 + +### 实体消歧与 `L3-00` + +新增 `L3-00` 置于优先级最前。关键在**不对称**:未消歧实体既不得 RETAIN,也**绝不得** EXCLUDE。 + +实测四个必须单列的实体:`Undisclosed`(缺失值占位符,被当基因符号检索,PMC 返回 1,384 条)、 +`CA19-9`(糖类抗原,无 HGNC 符号,PMC 14,200 条)、`AG7`、 +`EDBN`(11 个 endpoint 全部 0 命中,因而被 `L3-05` 排除—— +**排除的唯一原因是这个缩写不通行于文献**)。 + +`L3-00` **不引入新 outcome**:LOCK-03 的词表由 PR #57 冻结,其中没有 `identity_unresolved` +(该 outcome 只属于 LOCK-01)。故复用 `linkage_evidence_missing`, +身份信息另由 `identity_resolution_status` 列承载。 + +### endpoint 命中证明了什么 + +新增 `endpoint_evidence_admissibility`。TCGA/HPA/GEO 三者 `admissible_as_class_a: false` +(gene index 存在性、页面存在性、数据集元数据匹配都不是表达证据), +但**仍为必查**,服务于覆盖与身份消歧,其命中不计入 `linkage_classes_hit`(`VAL-L26`)。 +`ClinicalTrials.gov` 须记录五个结构化字段并满足**同臂**要求(`VAL-L28`)。 + +### 检索完整性 + +`search_complete` 现需四层:身份消歧、endpoint 覆盖、pair 级 D 类、**assertion 抽取完成**。 +`retrieval_alone_is_not_search_complete: true`。 + +### 上游缺陷 `GAP-P07` + +target 轴上四个实体不可消歧。`EVGAP-02` 无权改轴,故**只登记不修复**—— +在本契约内给 `Undisclosed` 编一个身份等于静默改轴。修复须另开 PR。 ## 目的 @@ -115,13 +174,15 @@ A/B/C 三类的疾病级检索对同一 target 在 9 个 context 下结果 ## 五、LOCK-03 求值规则与冻结优先级 -优先级:`L3-01` → `L3-02` → `L3-03` → `L3-04` → `L3-05`。 +优先级(v0.2.0):`L3-00` → `L3-01` → `L3-02` → `L3-03` → `L3-04` → `L3-05`。 -理由:**先判检索是否完成——未完成时「没找到」无法与「不存在」区分**;再判是否存在与该 context 匹配的 CRC-specific 证据;再判疾病级证据遇亚群 context 的降级;再判仅有其他癌种 precedent 的降级;以上都不成立且检索已完成,才允许判定完整检索后无 linkage。 +理由:**先判实体是否消歧——符号未消歧时检索结果既不能支持也不能排除**; +再判检索与 assertion 抽取是否完成——未完成时「没找到」无法与「不存在」区分;再判是否存在与该 context 匹配的 CRC-specific 证据;再判疾病级证据遇亚群 context 的降级;再判仅有其他癌种 precedent 的降级;以上都不成立且检索已完成,才允许判定完整检索后无 linkage。 | ID | 条件 | outcome | disposition | state | |---|---|---|---|---| -| `L3-01` | 未完成规定检索 | `linkage_evidence_missing` | DEFER | hold | +| `L3-00` | target 符号不可消歧 | `linkage_evidence_missing` | DEFER | hold | +| `L3-01` | 未完成规定检索或 assertion 抽取 | `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 | @@ -129,7 +190,9 @@ A/B/C 三类的疾病级检索对同一 target 在 9 个 context 下结果 **只有 `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 种组合**,证明每种恰好命中一条规则,且五条规则都可达。 +测试用参考实现穷举 `identity_resolved × search_complete × crc_specific × canonical × class_d × other_cancer` +全部 **64 种组合**,证明每种恰好命中一条规则,且六条规则都可达。 +另有一条专门的测试断言:**未消歧实体在任何组合下都落 `L3-00`,永远无法被 `L3-05` 排除。** ## 六、本契约不给出预期结果形状 @@ -178,29 +241,35 @@ EVGAP-01 读取的是已固定的数据集,结果可以事先算出并逐项 **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`。 +- **`MF-L05`**(v0.2.0):检索命中不是 linkage 证据。报告必须分别给出 `retrieval_candidate_count` 与 `assertion_count`;只报候选数即冒充证据量。 +- **`MF-L06`**(v0.2.0):落 `L3-00` 的 pair 既未被支持也未被排除,**不得**与 `L3-05` 合并计数,也不得表述为「无 linkage」。 ## 九、授权与不授权 -**授权:** 获 `APPROVE` 后按本契约执行**一次**抽取,读取 Tier 1 来源,覆盖全部 369 个 pair。 +**授权:** 按本契约执行抽取,读取 Tier 1 来源,覆盖全部 369 个 pair。 +`L-RETRIEVAL` 层已执行一次(`gen_iet_evgap_02_crc_linkage_20260805T190453Z` revision 2)。 **不授权:** 执行 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`。** +1. v0.2.0 契约与 `L-RETRIEVAL` 层产物 `APPROVE`。 +2. **另开 PR** 处理 `GAP-P07`:四个实体解析为标准符号、定义为非蛋白抗原,或移出轴。 + 移出会改动 41/369 这两个冻结计数,故必须经审核。 +3. `GAP-P07` 处理后执行 `L-ASSERTION` 抽取 → 结果 PR → `APPROVE`。 +4. **再另开 PR** 更新 `adc_pool_level_01_input_binding.yaml`,绑定产物并解除 `EVGAP-02`。 +5. `EVGAP-01` 由 Track B 独立推进(`SRCADM-01` → 抽取 → 结果 → binding)。 +6. **两个缺口都解除后,才能生成 `ADC_POOL_LEVEL_01_ACCEPTED`。** ## 十一、当前阻断 -- 本契约获 ChatGPT `APPROVE` 前,不得执行抽取。 -- 抽取完成也**不**解除 `EVGAP-01`,Level 01 仍不可执行。 +- `L-ASSERTION` 层未执行,故 **`EVGAP-02` 未解除**。 +- `GAP-P07` 未处理前,36 个 pair 无法脱离 `L3-00`。 +- `EVGAP-01` 亦未解除,Level 01 仍不可执行。 - 本仓库不得写入证据、候选、快照、cache、result 或 weights。 diff --git a/logs/worklog.md b/logs/worklog.md index 33a4147..4c5c64a 100644 --- a/logs/worklog.md +++ b/logs/worklog.md @@ -2647,6 +2647,63 @@ Purpose: append a detailed timestamped record of what was done, how it was done, - 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 描述请求复审。 +## 2026-08-05T19:20:00-04:00 — EVGAP-02 CRC linkage 抽取执行(外部运行留痕) + +- Instruction: 人类负责人指示「先执行 EVGAP-02 抽取,然后 SRCADM-01」。授权依据为 PR #61 已 `APPROVE` 并合并的 `docs/pools/evgap_02_crc_linkage_extraction.yaml`,其 `blocked_by: [contract_approval]` 已满足。 +- Reachability tested first: 契约把来源不可达定为须记录的事实而非可静默跳过,故执行前逐个测试六个必查 endpoint,全部应答——PubMed/PMC(NCBI E-utilities `esearch`)、ClinicalTrials.gov API v2、GEO(E-utilities `db=gds`)、TCGA(GDC genes API)、Human Protein Atlas(`search_download`)。 +- Run: `gen_iet_evgap_02_crc_linkage_20260805T190453Z`,六个产物全部写入外部 `DATA`,**仓库零写入**。 +- Search completeness: **451 次检索、0 不可达、41/41 target 检索完整、369/369 pair 的 D 类检索完整**。target 级按 endpoint 判定覆盖(非按 source class),A/B/C 用类别特异术语;D 类按 pair 判定并记录六个完整性字段。 +- Result: **`L3-02` 168 RETAIN/active;`L3-03` 192 DEFER/hold;`L3-05` 9 `EXCLUDE_FROM_ACTIVE_POOL`/reactivation-eligible**。168+192+9 = 369。`L3-01` 与 `L3-04` 本次为空规则——无检索不完整的 pair,也未采集到「仅其他癌种 precedent」的证据。 +- active by context: canonical `crc_mss_pmMR_mcrc_3l_plus` **40**(41 个靶点中仅 `EDBN` 无 A/B/C 证据);8 个亚群 context 分别 33/19/17/17/14/11/11/6。**亚群必须同时有 D 类情境特异证据才能 RETAIN**——这正是 192 个 `L3-03` 的差别:有疾病级 CRC 证据,但该亚群无情境特异富集证据。 +- Evidence: **7,067 行**,A 2,808/B 2,295/C 1,746/D 218。`context_specific = true` 仅 D 类 218 行,A/B/C 按契约是疾病级检索、按构造不具情境特异性。 +- Mandatory findings recorded: `MF-L01` RETAIN 只表示存在可回溯的 CRC-specific linkage 记录,**不表示适合 ADC、不表示疗效、不表示治疗窗**;`MF-L02` C 类 1,746 行全部 `is_adc_efficacy_evidence = false`;`MF-L03` 未使用任何派生本地库,**完整性只在 Tier 1 范围内成立**,`SRCADM-02`..`05` 仍待准入,日后扩大须另开 PR 重跑;`MF-L04` RETAIN 不使 pair 进入 Level 02,369 行 `may_advance_to_level_02` 全为 `false`。 +- Evidence strength stated plainly: **全部 7,067 行 `review_status = machine_retrieved_requires_human_review`、`evidence_direction = unknown`**。这些是按冻结查询式检索到的公开记录(PMID/PMCID/NCT/GEO 登记号均可回溯),**内容未被阅读、未被人工判读**。按 PR #58 已获接受的 `DECISION-02`,machine-retrieved 证据满足 LOCK-03 存在性但该 pair 不得晋级 Level 02。**故 168 个 active 的含义是「存在待复核的 CRC-specific linkage 记录」,不是「linkage 已确证」。** +- Only one target reached L3-05: `EDBN`(9 个 pair)。非标准符号(fibronectin EDB 结构域),在 Level 01 Preview 中亦为 `E1-05` 不在 surfaceome 参考库。`L3-05` 可逆——`is_scientific_disproof: false`、`is_killed: false`、`reactivation-eligible`,仍留在 Eligible Universe Index,**不是科学证伪、不是淘汰**。 +- Executor defect found by self-check and repaired: 首版 evidence 表缺契约要求的 4 列(`pair_id`/`clinical_context_id`/`context_specific`/`linkage_outcome`),且 A/B/C 按契约按 target 检索一次导致单条证据无法携带唯一 `pair_id`、`VAL-L16` 不可满足。**这是我没把「按 target 检索」与「按 pair 记录证据」对齐。** 修正方式是让输出符合已冻结 schema 而非改契约:把 target 级记录按 pair 展开为 7,067 行,**未重复任何网络调用、未改变任何已检索事实**,只改表示形式。 +- Validation: **契约 20 条 `VAL-L01`..`VAL-L20` 逐条对实际产物核验,全部 `PASS`**,含 `VAL-L09` 两张表列集合逐表相等、`VAL-L16` 全部引用 id 存在且 `pair_id` 一致且 7,067 个 id 无重复、`VAL-L17` 逐规则引用约束、`VAL-L18` 369 pair 六字段齐备、`VAL-L20` 计数等于三组 refs 去重总数。仓库侧 `Ran 338 tests` 通过、`scripts/verify_repository_boundary.sh` 通过。 +- Packaged for review: `external:result/gen_iet_evgap_02_crc_linkage_20260805T190453Z.zip`,148,063 bytes,ZIP SHA-256 `9c9c184e7b66e2999950831a18e059847c3b7dfd4a5b6f92ac78ac9dce259ece`,含且仅含六个产物文件。**按 PR #60 第二轮裁决后自我约束的规则执行:外部产物每次交付必须同时产出带校验和的打包。** +- Boundary: 未运行任何 Gate、未赋分数、未评估 T2/T7、未排序、未划 Tier、未推荐资产、未给实验建议、未新增靶点或 context(0/0)、**未打开任何 Tier 2 派生库**、未引用被隔离运行产物、未写入仓库。 +- Deliberately not done: **未解除 `EVGAP-02`**(须待本结果获批后另开 PR 更新绑定);未解除 `EVGAP-01`;未更新 `adc_pool_level_01_input_binding.yaml`;Level 01 仍不可执行;未生成 `ADC_POOL_LEVEL_01_ACCEPTED`;未补八份批准记录。 +- Governance note: **本 PR 不适用 `AGENTS.md`「审核豁免」**,须经 ChatGPT `APPROVE`。本 PR 只含本条 worklog 与一份 handoff。 +- Next: 送审本结果;获批后另开 PR 解除 `EVGAP-02`;随后按指示起 `SRCADM-01`。 + +### 2026-08-05 16:55 EDT — EVGAP-02:审核后降级为检索候选层,契约修订至 v0.2.0(PR #62 第二轮) + +- Trigger: ChatGPT 对 PR #62 返回 `REQUEST_CHANGES`——「这些是搜索命中,不是 linkage evidence」。 +- Verified before changing anything: 逐条对实际文件核验,**审核意见全部成立**。7,067 行确实全部 `evidence_direction = unknown`、`review_status = machine_retrieved_requires_human_review`。 +- Worse than described: **没有任何一行带有已解析的断言字段。** `positive_fraction_or_prevalence` 在 7,067 行中全为空;`is_adc_efficacy_evidence` 全为 `false`;`malignant_cell_attribution` 全为 `unresolved`/`not_applicable`;5,699 条文献行的 `protein_or_rna` 全为 `unresolved`。**一条已抽取的断言都没有。** +- Identity resolution never happened: `Undisclosed` **是缺失值占位符、不是实体**,却被当作基因符号检索,`PMC/A` 返回 1,384 条并产出 1 个 RETAIN;`CA19-9`(糖类抗原,无 HGNC 符号)`PMC/A` 14,200 条、9 个 pair 中 8 个 RETAIN;`EDBN` 在 11 个 endpoint 全部 0 命中,9 个 pair 落 `L3-05` **EXCLUDE**——它疑指 fibronectin 的 extra domain B(标准符号 `FN1`),**被排除的唯一原因是这个缩写不通行于文献。消歧失败被当成了完整检索后的阴性结论。** +- One review detail corrected on the facts: 审核说 TCGA 与 HPA 的命中被算作 A 类证据。**实测它们没有产生任何证据行**——证据表 `source_ref` 前缀只有 `PMC` 3,240/`PubMed` 2,459/`ClinicalTrials.gov` 702/`GEO` 666。**原则完全成立,实际的实例是 `GEO`** 的 `db=gds` 元数据命中被登记为 666 行 A 类。 +- Third defect found by self-check, not raised in review: **未披露的检索截断**。451 次检索报告命中合计 **718,140**,实际登记 **979** 条,**333/451 次被截断**(多数每组只留 3 条)。revision 1 未声明此上限却宣称检索完整。 +- Root cause is the contract, not only the run: v0.1.0 把 `evidence_direction` 与 `review_status` 列为**必需列却无任何规则要求其被解析**,`linkage_class` 也无规则约束其来源,于是由**查询类别**决定。**一次完全合规的执行因此产出 168 条 RETAIN。** 故修在契约。 +- Contract amended to v0.2.0: 三层结构 `L-RETRIEVAL`/`L-ASSERTION`/`L-DISPOSITION`;`assertion_requirements` 六要件并**硬性禁止 `assertion_direction = unknown`**;`identity_resolution` 与新规则 `L3-00`(置于优先级最前,**未消歧实体既不得 RETAIN 也绝不得 EXCLUDE**);`endpoint_evidence_admissibility` 逐 endpoint 写明命中证明什么与不证明什么;`search_complete` 扩为四层;新增 `VAL-L21`..`VAL-L28`。 +- Frozen vocabulary respected: LOCK-03 的 outcome 词表由 PR #57 冻结、其中没有 `identity_unresolved`(该 outcome 只属 LOCK-01),故 `L3-00` **复用 `linkage_evidence_missing`**,身份信息另由 `identity_resolution_status` 列承载,**不新增 outcome**。 +- Result downgraded (revision 2): 撤销 `pair_linkage_evidence.tsv`,改出 `retrieval_candidates.tsv` **979 行** + `linkage_assertions.tsv` **0 行**。**未重复任何网络调用、未丢弃任何已检索记录。** 「7,067 条证据」实为 **979 条记录乘以 9**(A 2808/9=312、B 2295/9=255、C 1746/9=194、D 218 不复制)。 +- Dispositions withdrawn: 369 个 pair 全部 DEFER/hold——`L3-00` **36**(4 个不可消歧实体 × 9 context)、`L3-01` **333**。**RETAIN 0、EXCLUDE 0**,三组 `*_evidence_refs` 全空。 +- Upstream defect recorded, not fixed: **`GAP-P07`**——PR #58 冻结的 41 个 target 中至少四个不是可消歧的蛋白实体。`EVGAP-02` 无权改轴,在本契约内给 `Undisclosed` 编身份等于静默改轴。**binding 本身已察觉这一点**:它把 `identity_unresolved` 列入 `unavailable_outcomes`,理由是「已批准层没有身份解析结论字段」,四个实体因此以 `E1-05` 留在轴上。修复须另开 PR,会改动 41/369 两个冻结计数。 +- Validation: 外部产物经 **25 项 v0.2.0 规则**核验全通过;`tests/test_evgap_02_crc_linkage.py` **44 tests**;全库 `Ran 353 tests` OK;`scripts/verify_repository_boundary.sh` 通过。 +- Mutation testing: **15 个变异全部被捕获**,逐个 `diff -q` 回滚干净。其中一个首轮「逃逸」实为我的替换串缩进写错、变异根本未生效;改正缩进后被捕获——记此以免把无效变异误记为测试覆盖。 +- Packaged: `gen_iet_evgap_02_crc_linkage_20260805T190453Z_rev2.zip`,41,734 bytes,ZIP SHA-256 `e8f2a7f5ce9fae25265994f0d9a1fae1e371a1bb55ccafea2026835bd5120d3d`,8 个条目。**按 PR #60 裁决后确立的规则,每个修订单独出包并各带自己的 SHA-256。** +- Boundary: 未运行任何 Gate、未赋分数、未评估 T2/T7、未排序、未划 Tier、未推荐资产、未给实验建议、**未新增或修改靶点与 context**、未打开任何 Tier 2 派生库、未引用被隔离运行产物、未写入任何数据文件到仓库。 +- Deliberately not done: **未解除 `EVGAP-02`**;未处理 `GAP-P07`;未执行 `L-ASSERTION` 抽取;未解除 `EVGAP-01`;未更新 binding;未生成 `ADC_POOL_LEVEL_01_ACCEPTED`;未补九份批准记录。 +- Next: 送审本修订;获批后另开 PR 处理 `GAP-P07`,再执行 `L-ASSERTION` 抽取。 + +### 2026-08-05 17:30 EDT — EVGAP-02:修正 CA19-9 的 L3-00 误判(PR #62 第三轮) + +- Trigger: ChatGPT 对 PR #62 第二轮 `REQUEST_CHANGES`——revision 2 把 `CA19-9` 放进 `L3-00`,与契约直接冲突。**该意见成立。** +- The contradiction: 契约把 `CA19-9` 定为 `resolved_as_non_protein_antigen`,而 `search_complete_definition` 明确接受该 status。**它是已消歧的实体,不是身份未解析。** 只有 `unresolvable_placeholder` 与 `unresolvable_ambiguous_abbreviation` 才应触发 `L3-00`。 +- Root cause in my own work: 契约那张表命名为 `known_unresolved_entities`,**里面却有一个 resolved 的条目**;重建脚本按**表成员身份**而非按 `resolution_status` 赋 `L3-00`。**名字招来了这个 bug,脚本接受了邀请。** +- Fixed at the point of failure, not the symptom: 表改名 `known_identity_findings`;新增 `l3_00_statuses`(只含两个 unresolvable);`l3_00_membership_test: resolution_status`;`l3_00_membership_by_list_forbidden: true`;每个条目显式声明其 status 蕴含的 `lock_03_rule`,测试逐条比对二者是否自洽。 +- Option 1 taken: `CA19-9` 保持 `resolved_as_non_protein_antigen`,转 `L3-01`。新增 `non_protein_antigen_search_requirements`——非蛋白抗原**不得按基因符号检索**,v0.1.0 的 14,200 条 PMC 命中出自无效查询形式;`invalid_query_form_consequence: L3-01`,即**检索未完成,而非身份未解析**。新增 `VAL-L29`。 +- Result rebuilt (revision 3): `L3-00` **27**(`Undisclosed`/`EDBN`/`AG7` × 9),`L3-01` **342**。仍是 0 RETAIN、0 EXCLUDE、369 hold、三组 refs 全空、assertion 表空、候选 979 未变、**未重复任何网络调用**。 +- GAP-P07 sharpened: 四个实体**性质不同**,不可混为一谈。三个是不可消歧的符号;**`CA19-9` 已消歧、只是不是蛋白**——非蛋白抗原是否属于「所有潜在 ADC 膜蛋白靶点」这一 target universe,才是 `GAP-P07` 真正要回答的问题。契约新增 `entity_kinds` 分列。 +- Verification hardened: 外部核验脚本的 `L3-00` 期望集**由契约按 `resolution_status` 推导**,不是硬编码,故同类错误再犯会被捕获。20 项核验全通过。 +- Tests: `tests/test_evgap_02_crc_linkage.py` **47 tests**(新增 3 条:status 而非成员身份、非蛋白抗原检索形式、两处 completeness 定义互不矛盾);全库 `Ran 356 tests` OK;boundary check 通过。 +- Mutation testing: **10 个新变异全部被捕获**,`diff -q` 回滚干净。 +- Packaged: `gen_iet_evgap_02_crc_linkage_20260805T190453Z_rev3.zip`,43,443 bytes,ZIP SHA-256 `ef268fd2f6dcc0c056b0dd01c67da5e850a5e79972ae5858dd49b5f60b49faac`。**revision 2 从未上传**,其 36/333 未被任何下游依赖;rev2 包已删除以免误取。 +- Also done this turn: 按人工负责人指示关闭 PR #55 并注明 superseded by #56(#56 已于 2026-08-04 合并)。**分支保留不删**——删除分支须单独授权。 +- Deliberately not done: 未解除 `EVGAP-02`;未处理 `GAP-P07`;未执行 `L-ASSERTION` 抽取;未改 target 轴;未补九份批准记录。 +- Next: 送审 revision 3;并按第二轮审核要求为 PR #63 制作可独立复核的 SRCADM-01 audit bundle。 ### 2026-08-06 15:04 EDT - Instruction: Re-read the current StelligenOS project and produce a versioned, review-ready description of the current design architecture, module logic, system flow, implementation status, and open gaps. @@ -2656,3 +2713,16 @@ Purpose: append a detailed timestamped record of what was done, how it was done, - Versioning: Updated the version index to state that unapproved `v2-draft` was superseded without a snapshot; no `v3` snapshot is created before approval. Updated architecture/README navigation, added this repository handoff, and refreshed workspace `HANDOFF.md` with the active v3 review task. - Boundary: Documentation-only. No data, cache, result, model weight, provider execution, Gate execution, contract change, lifecycle change, or module implementation was added. - Validation: Final branch validation ran 338 tests successfully; `scripts/verify_repository_boundary.sh` and `git diff --check` passed; no `__pycache__` directory was generated. + +### 2026-08-06 16:10 EDT — EVGAP-02:冻结语义,只补正式受审包(PR #62 第四轮) + +- Instruction: 冻结当前 HEAD,不改架构/契约/规则/测试逻辑,只补齐实际受审包并使包内数据严格匹配 handoff。 +- **本轮未改任何语义**:`docs/pools/evgap_02_crc_linkage_extraction.yaml` 未动、`tests/` 未动、契约版本仍 v0.2.0、disposition 逻辑未动、未新增 mandatory finding、未改 target 轴、未补 admission binding、未执行 EVGAP-01、未解除 EVGAP-02。 +- Added to the package only: `verify_package.py`(只读验证脚本)。因包内容变化,ZIP 哈希必然变化,故三处声明同步更新。 +- Package: `gen_iet_evgap_02_crc_linkage_20260805T190453Z_rev3.zip`,SHA-256 `81baa45f23f180c68b16d18c83284b60bdee725c017e668e590d4e80b04176e9`,46,292 bytes,**8 个文件**(ZIP 内 9 个条目,多出的一条是目录条目),`revision = 3`。**上一版声明的 `ef268fd2…` 作废。** +- Three declarations aligned: handoff、PR body、`source_manifest.json` 现在写同一个文件名、同一个 ZIP SHA-256、同一个文件数、同一个 revision。 +- Corrected a transcription error in the previous handoff: `pair_linkage_disposition.tsv` 曾被写成 `4c4def27…`,那其实是 `run_report.md` 的哈希。真实值 `33674913…`。本轮的哈希表由 `source_manifest.json` 直接生成,不再手抄。 +- Verifier scope: 文件数、逐文件 SHA-256 与字节数、清单未遗漏文件、可选 ZIP 哈希、`revision = 3`、`979 / 0 / 369`、schema(候选表无 `linkage_class`、三个标记列齐备)、候选表三个固定值、`L3-00 27`/`L3-01 342`/`L3-02..L3-05` 全 `0`、无 RETAIN 无 EXCLUDE、369 行全 DEFER/hold、三组 refs 全空、`may_advance_to_level_02` 全 `false`、三个不可消歧 target 各 9 pair 全 `L3-00`、`CA19-9` 9 pair 全 `L3-01` 且 status 为 `resolved_as_non_protein_antigen`、`EVGAP-02` 未解除。 +- Verified from a clean extract in a temp directory (not the working copy), with the ZIP digest passed in: **`65/65 MATCH`,退出码 0**。 +- One self-caught slip: 脚本初版把「文件数」定为 9,实为 8——ZIP 的第 9 个条目是目录条目本身。已改正并在 handoff 中写明该区别。另注意 `python3 ... | tail` 会把 `tail` 的退出码当成脚本的,实际退出码须用 `PIPESTATUS`。 +- Repo-side changes this round: 仅本条 worklog 与 handoff 的包信息段。测试与契约零改动。 diff --git a/tests/test_evgap_02_crc_linkage.py b/tests/test_evgap_02_crc_linkage.py index d11a2be..d4cca8d 100644 --- a/tests/test_evgap_02_crc_linkage.py +++ b/tests/test_evgap_02_crc_linkage.py @@ -3,7 +3,15 @@ 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. +Level 01 contracts. They read no external source. + +v0.2.0 adds the layer the first version lacked. v0.1.0 required an +``evidence_direction`` column but no rule ever required it to be resolved, so a +compliant run registered 7,067 search hits with ``evidence_direction=unknown`` +and derived 168 RETAIN dispositions from them. The tests below pin the three +properties that failure needed: search hits and assertions live in separate +tables, a disposition may only cite assertions, and an unresolvable symbol can +neither retain nor exclude. """ from __future__ import annotations @@ -23,12 +31,20 @@ LEVEL_CONTRACT_PATH = POOLS / "adc_pool_gate_usage.yaml" LINKAGE_CLASSES = ("A", "B", "C", "D") -# Columns the source document requires for every evidence row. +# Columns the source document requires for every evidence row. v0.2.0 renames +# evidence_direction to assertion_direction, which may no longer be "unknown". 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", + "source_locator", "assertion_direction", "review_status", "linkage_outcome", ) +# The elements that turn a retrieved record into an assertion. +ASSERTION_ELEMENTS = ( + "target_entity_resolved", "crc_entity_resolved", "context_entity_resolved", + "relationship_type", "assertion_direction", "supporting_text_or_structured_field", +) +# Endpoints whose hit proves only that the entity has a page or an index row. +IDENTITY_ONLY_ENDPOINTS = ("TCGA", "Human Protein Atlas", "GEO") def _load(path: Path) -> dict: @@ -55,10 +71,35 @@ def test_contract_targets_the_registered_gap(self) -> None: 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_the_executed_run_is_recorded_as_retrieval_only(self) -> None: + """The v0.1.0 run happened; it must not read as having discharged the gap.""" + + head = self.doc["extraction"] + self.assertEqual(head["extraction_version"], "0.2.0") + self.assertEqual(head["supersedes_version"], "0.1.0") + self.assertEqual(head["amended_after_review_of_pr"], 62) + self.assertIs(head["gap_discharged"], False) + self.assertEqual( + head["execution_status"], + "retrieval_layer_executed_assertion_layer_not_executed", + ) + runs = head["executed_runs"] + self.assertTrue(runs) + for run in runs: + with self.subTest(run=run["run_id"]): + self.assertEqual(run["under_contract_version"], "0.1.0") + self.assertEqual(run["layers_completed"], ["L-RETRIEVAL"]) + self.assertEqual(set(run["layers_not_completed"]), + {"L-ASSERTION", "L-DISPOSITION"}) + self.assertIs(run["discharges_gap"], False) + self.assertEqual(run["downgraded_by_review_of_pr"], 62) + # The amendment must say why, not merely bump the number. + self.assertTrue(head["amendment_reason"].strip()) + self.assertIn("query", head["amendment_reason"]) + def test_extraction_is_independent_of_evgap_01(self) -> None: """The two tracks must be runnable in parallel.""" @@ -199,6 +240,7 @@ def _assign(self, pair: dict) -> str: """Reference implementation of the frozen precedence.""" conditions = { + "L3-00": not pair["identity_resolved"], "L3-01": not pair["search_complete"], "L3-02": pair["crc_specific"] and ( pair["canonical"] or pair["class_d"]), @@ -217,34 +259,52 @@ def test_precedence_covers_every_rule_and_starts_with_completeness(self) -> None 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[0], "L3-00") + self.assertEqual(precedence[1], "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 = [ + # An unresolved symbol outranks everything, including a closed search + # with zero hits. This is the case v0.1.0 got wrong in both directions. + ({"identity_resolved": False, "search_complete": True, + "crc_specific": True, "canonical": True, "class_d": True, + "other_cancer": True}, "L3-00"), + ({"identity_resolved": False, "search_complete": True, + "crc_specific": False, "canonical": True, "class_d": False, + "other_cancer": False}, "L3-00"), # Incomplete search always wins, whatever else is true. - ({"search_complete": False, "crc_specific": True, "canonical": True, + ({"identity_resolved": 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, + ({"identity_resolved": True, "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, + ({"identity_resolved": True, "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, + ({"identity_resolved": True, "search_complete": True, + "crc_specific": True, "canonical": False, "class_d": True, "other_cancer": False}, "L3-02"), - ({"search_complete": True, "crc_specific": True, "canonical": False, + ({"identity_resolved": True, "search_complete": True, + "crc_specific": True, "canonical": False, "class_d": False, "other_cancer": False}, "L3-03"), - ({"search_complete": True, "crc_specific": True, "canonical": False, + ({"identity_resolved": True, "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, + ({"identity_resolved": True, "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, + ({"identity_resolved": True, "search_complete": True, + "crc_specific": False, "canonical": True, "class_d": False, "other_cancer": False}, "L3-05"), - ({"search_complete": True, "crc_specific": False, "canonical": False, + ({"identity_resolved": True, "search_complete": True, + "crc_specific": False, "canonical": False, "class_d": False, "other_cancer": False}, "L3-05"), ] for pair, expected in cases: @@ -253,18 +313,32 @@ def test_every_combination_resolves_to_exactly_one_rule(self) -> None: 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)) + for identity_resolved in (True, False): + 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(identity_resolved=identity_resolved, + 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_an_unresolved_symbol_can_never_be_excluded(self) -> None: + """v0.1.0 excluded EDBN for nine pairs because its abbreviation drew zero + hits. Zero hits from an unresolvable symbol is a resolution failure.""" + + for canonical in (True, False): + for class_d in (True, False): + pair = dict(identity_resolved=False, search_complete=True, + crc_specific=False, canonical=canonical, + class_d=class_d, other_cancer=False) + with self.subTest(**pair): + self.assertEqual(self._assign(pair), "L3-00") + def test_only_the_complete_search_rule_may_exclude(self) -> None: """L3-02 retains, L3-05 excludes from the active pool, the rest defer.""" @@ -309,7 +383,12 @@ 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"]) + # v0.2.0: the evidence table IS the assertion table. + self.assertIs(schema["evidence_table_is_assertion_table"], True) + self.assertEqual(schema["evidence_columns_alias"], "assertion_columns") + self.assertNotIn("evidence_columns", schema, + "a live evidence_columns key would let both versions coexist") + evidence = set(schema["assertion_columns"]) disposition = set(schema["disposition_columns"]) for column in REQUIRED_EVIDENCE_COLUMNS: with self.subTest(column=column): @@ -333,9 +412,9 @@ 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.assertEqual(schema["evidence_row_key"], "assertion_id") self.assertIs(schema["evidence_id_unique"], True) - self.assertIn("evidence_id", schema["evidence_columns"]) + self.assertIn("assertion_id", schema["assertion_columns"]) for column in ("supporting_evidence_refs", "class_d_evidence_refs", "other_cancer_evidence_refs"): with self.subTest(column=column): @@ -393,11 +472,13 @@ def test_class_d_completeness_is_pair_level_and_gates_l3_03_and_l3_05(self) -> N 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) + # Completeness must require all four levels, not just the target level. + self.assertIs(scope["search_complete_requires_all_levels"], True) self.assertEqual(set(scope["search_complete_levels"]), - {"target_level_source_class_and_endpoint_coverage", - "pair_level_class_d_coverage"}) + {"target_level_identity_resolution", + "target_level_source_class_and_endpoint_coverage", + "pair_level_class_d_coverage", + "pair_level_assertion_extraction_completed"}) 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. @@ -405,7 +486,7 @@ def test_class_d_completeness_is_pair_level_and_gates_l3_03_and_l3_05(self) -> N 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) + self.assertIs(by_id["L3-01"]["covers_all_completeness_levels"], True) ids = {r["id"] for r in self.doc["output_validation"]} self.assertIn("VAL-L18", ids) @@ -470,13 +551,326 @@ def test_retain_does_not_promote_to_level_02(self) -> None: 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"}) + self.assertEqual(set(findings), + {"MF-L01", "MF-L02", "MF-L03", "MF-L04", "MF-L05", "MF-L06"}) # RETAIN must never be read as ADC suitability or efficacy. self.assertIn("ADC", findings["MF-L01"]) self.assertIn("ADC", findings["MF-L02"]) + # The report must not present candidate counts as evidence counts, and + # must not fold the unresolved pairs into "no linkage". + self.assertIn("retrieval_candidate_count", findings["MF-L05"]) + self.assertIn("assertion_count", findings["MF-L05"]) + self.assertIn("L3-00", findings["MF-L06"]) + self.assertIn("L3-05", findings["MF-L06"]) for text in findings.values(): self.assertTrue(text.strip()) + # ------------------------------------------------ v0.2.0: the missing layer + def test_retrieval_and_assertion_are_separate_layers(self) -> None: + layers = {l["layer_id"]: l for l in self.doc["extraction_layers"]} + self.assertEqual(set(layers), {"L-RETRIEVAL", "L-ASSERTION", "L-DISPOSITION"}) + retrieval, assertion = layers["L-RETRIEVAL"], layers["L-ASSERTION"] + # A search hit alone may never support LOCK-03. + self.assertIs(retrieval["may_support_lock_03"], False) + self.assertIs(retrieval["may_be_referenced_by_disposition"], False) + self.assertIs(assertion["may_support_lock_03"], True) + self.assertEqual(assertion["consumes"], "retrieval_candidates") + # The disposition layer may only read assertions. + disposition = layers["L-DISPOSITION"] + self.assertEqual(disposition["may_only_reference"], "linkage_assertions") + self.assertIs(disposition["may_reference_retrieval_candidates"], False) + # The known false-positive modes must be named, not gestured at. + modes = " ".join(retrieval["known_false_positive_modes"]) + for mode in ("缩写碰撞", "参考文献", "综述", "免责声明"): + with self.subTest(mode=mode): + self.assertIn(mode, modes) + + def test_decision_02_is_read_correctly_not_as_a_licence_for_hits(self) -> None: + """PR #58 allowed machine-extracted evidence — extracted, not retrieved.""" + + assertion = next(l for l in self.doc["extraction_layers"] + if l["layer_id"] == "L-ASSERTION") + self.assertIs(assertion["machine_extraction_permitted"], True) + self.assertIs(assertion["human_review_still_required"], True) + basis = assertion["machine_extraction_permitted_basis"] + self.assertIn("DECISION-02", basis) + self.assertIn("assertion", basis) + + def test_an_assertion_needs_every_element_and_a_resolved_direction(self) -> None: + req = self.doc["assertion_requirements"] + elements = {e["element"] for e in req["required_elements"]} + self.assertEqual(elements, set(ASSERTION_ELEMENTS)) + for entry in req["required_elements"]: + with self.subTest(element=entry["element"]): + self.assertTrue(entry["meaning"].strip()) + # The check v0.1.0 lacked. + self.assertIs(req["direction_must_be_resolved"], True) + self.assertIs(req["direction_unknown_forbidden_for_lock_03"], True) + self.assertIs(req["cooccurrence_is_not_an_assertion"], True) + self.assertTrue(req["relationship_types"]) + self.assertTrue(req["cooccurrence_examples_rejected"]) + # Both a valid and an invalid example must be given, so the boundary is + # demonstrated rather than asserted. + self.assertTrue(req["examples_of_valid_assertions"]) + self.assertTrue(req["examples_of_invalid_assertions"]) + # And the schema must forbid the value, not only the prose. + forbidden = self.doc["output_schema"]["assertion_forbidden_values"] + self.assertIn("unknown", forbidden["assertion_direction"]) + + def test_linkage_class_comes_from_content_not_from_the_query(self) -> None: + common = self.doc["linkage_class_common_requirements"] + self.assertIs(common["applies_to_all_classes"], True) + self.assertIs(common["requires_assertion"], True) + self.assertEqual(common["classified_by"], "assertion_content") + self.assertIs(common["classified_by_query_category_forbidden"], True) + self.assertIs(common["direction_unknown_admissible"], False) + self.assertTrue(common["classified_by_query_category_forbidden_reason"].strip()) + schema = self.doc["output_schema"] + # The candidate table records which query found it, and that label is + # explicitly not a linkage class. + self.assertIn("query_class_label", schema["retrieval_candidate_columns"]) + self.assertIs(schema["query_class_label_is_not_linkage_class"], True) + self.assertNotIn("linkage_class", schema["retrieval_candidate_columns"]) + self.assertIn("linkage_class", schema["assertion_columns"]) + + def test_candidate_table_marks_itself_unusable_for_lock_03(self) -> None: + schema = self.doc["output_schema"] + columns = set(schema["retrieval_candidate_columns"]) + for column in ("record_status", "linkage_validated", "may_support_lock_03", + "assertion_extraction_status"): + with self.subTest(column=column): + self.assertIn(column, columns) + fixed = schema["retrieval_candidate_fixed_values"] + self.assertEqual(fixed["record_status"], "retrieved_candidate") + self.assertIs(fixed["linkage_validated"], False) + self.assertIs(fixed["may_support_lock_03"], False) + # Every assertion must name the candidate it came from. + self.assertIn("candidate_id", schema["assertion_columns"]) + self.assertIs(schema["assertion_must_cite_its_candidate"], True) + + def test_refs_may_only_point_at_assertions(self) -> None: + schema = self.doc["output_schema"] + self.assertEqual(schema["refs_point_to"], "assertion_id") + self.assertIs(schema["refs_pointing_to_candidate_id_forbidden"], True) + tables = {t["table"]: t for t in schema["tables"]} + self.assertIs(tables["retrieval_candidates"]["may_be_referenced_by_disposition"], + False) + self.assertIs(tables["linkage_assertions"]["may_be_referenced_by_disposition"], + True) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-L23", ids) + + def test_identity_resolution_precedes_search_and_blocks_both_verdicts(self) -> None: + block = self.doc["identity_resolution"] + self.assertIs(block["required_before_search"], True) + self.assertIs(block["unresolved_may_not_be_searched"], True) + self.assertEqual(block["unresolved_lock_03_rule"], "L3-00") + # The asymmetry that matters: no RETAIN and no EXCLUDE. + self.assertIs(block["unresolved_may_not_retain"], True) + self.assertIs(block["unresolved_may_not_exclude"], True) + reason = block["unresolved_may_not_exclude_reason"] + self.assertIn("EDBN", reason) + self.assertIn("FN1", reason) + for field in ("input_symbol", "resolved_identifier", "resolution_status", + "resolution_basis"): + with self.subTest(field=field): + self.assertIn(field, block["resolution_must_record"]) + + def test_the_four_flagged_entities_are_named_with_their_kind(self) -> None: + """Each was searched as a gene symbol by the v0.1.0 run.""" + + block = self.doc["identity_resolution"] + entities = {e["input_symbol"]: e for e in block["known_identity_findings"]} + self.assertEqual(set(entities), + {"Undisclosed", "EDBN", "AG7", "CA19-9"}) + vocabulary = set(block["resolution_status_vocabulary"]) + for symbol, entry in sorted(entities.items()): + with self.subTest(symbol=symbol): + self.assertIn(entry["resolution_status"], vocabulary) + self.assertTrue(entry["note"].strip()) + # The distinctions must be kept: a placeholder is not an abbreviation, + # and a glycan antigen is resolvable but has no gene symbol. + self.assertEqual(entities["Undisclosed"]["resolution_status"], + "unresolvable_placeholder") + self.assertEqual(entities["CA19-9"]["resolution_status"], + "resolved_as_non_protein_antigen") + self.assertEqual(entities["EDBN"]["resolution_status"], + "unresolvable_ambiguous_abbreviation") + # Recognising them must not require free judgement — but the field that + # finds them must not be mistaken for the field that classifies them. + precondition = block["mechanical_precondition"] + self.assertIn("E1-05", precondition) + self.assertIn("CA19-9", precondition) + + def test_l3_00_membership_follows_status_not_list_membership(self) -> None: + """The v0.2.0 first draft put CA19-9 in L3-00 although it is resolved. + + The table is a list of entities whose identity needed adjudicating; two + of its statuses mean unresolved and one means resolved. Keying L3-00 on + membership rather than status contradicts the contract.""" + + block = self.doc["identity_resolution"] + statuses = set(block["l3_00_statuses"]) + self.assertEqual(statuses, {"unresolvable_placeholder", + "unresolvable_ambiguous_abbreviation"}) + self.assertEqual(block["l3_00_membership_test"], "resolution_status") + self.assertIs(block["l3_00_membership_by_list_forbidden"], True) + # A resolved status may never be an L3-00 status. + for resolved in ("resolved", "resolved_as_non_protein_antigen"): + with self.subTest(status=resolved): + self.assertNotIn(resolved, statuses) + # Every flagged entity must declare the rule it lands on, and that rule + # must agree with its status. + for entry in block["known_identity_findings"]: + with self.subTest(symbol=entry["input_symbol"]): + expected = "L3-00" if entry["resolution_status"] in statuses \ + else "L3-01" + self.assertEqual(entry["lock_03_rule"], expected) + # CA19-9 specifically: resolved, so L3-01, and it must carry an identifier. + ca = next(e for e in block["known_identity_findings"] + if e["input_symbol"] == "CA19-9") + self.assertEqual(ca["lock_03_rule"], "L3-01") + self.assertTrue(ca["resolved_identifier"].strip()) + self.assertEqual(ca["resolved_identifier_kind"], "non_protein_antigen_definition") + self.assertIn(ca["resolved_identifier_kind"], + block["resolved_identifier_kinds"]) + + def test_a_non_protein_antigen_may_not_be_queried_by_gene_symbol(self) -> None: + block = self.doc["identity_resolution"]["non_protein_antigen_search_requirements"] + self.assertIs(block["gene_symbol_query_forbidden"], True) + self.assertTrue(block["required_query_basis"]) + # An invalid query form leaves the search incomplete; it does not make + # the identity unresolved. + self.assertEqual(block["invalid_query_form_consequence"], "L3-01") + self.assertTrue(block["invalid_query_form_consequence_reason"].strip()) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-L29", ids) + + def test_the_two_completeness_definitions_agree_on_resolved_statuses(self) -> None: + """search_complete accepts both resolved statuses, so neither may be + treated as unresolved when the disposition is assigned.""" + + definition = " ".join( + self.doc["declared_search_scope"]["search_complete_definition"]) + self.assertIn("resolved_as_non_protein_antigen", definition) + statuses = set(self.doc["identity_resolution"]["l3_00_statuses"]) + # The statuses search_complete accepts and the statuses that force L3-00 + # must be disjoint, or a target could be both complete and unresolved. + accepted = {s for s in + self.doc["identity_resolution"]["resolution_status_vocabulary"] + if s in definition} + self.assertTrue(accepted) + self.assertEqual(accepted & statuses, set()) + + def test_l3_00_reuses_the_frozen_outcome_vocabulary(self) -> None: + """LOCK-03 has no identity_unresolved outcome; that belongs to LOCK-01.""" + + rule = next(r for r in self.doc["derivation_rules"] if r["id"] == "L3-00") + outcomes = {o["outcome"]: o for o in self.lock_03["outcomes"]} + self.assertNotIn("identity_unresolved", outcomes) + self.assertEqual(rule["lock_03_outcome"], "linkage_evidence_missing") + self.assertIn(rule["lock_03_outcome"], outcomes) + self.assertIs(rule["does_not_introduce_new_outcome"], True) + self.assertEqual(rule["distinguishing_column"], "identity_resolution_status") + self.assertIn("identity_resolution_status", + self.doc["output_schema"]["disposition_columns"]) + self.assertIs(rule["may_never_become_l3_05"], True) + # L3-05 must carry the matching guard. + l3_05 = next(r for r in self.doc["derivation_rules"] if r["id"] == "L3-05") + self.assertIs(l3_05["requires_identity_resolved"], True) + self.assertIs(l3_05["zero_hits_from_unresolved_symbol_forbidden"], True) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-L27", ids) + + def test_each_endpoint_declares_what_its_hit_does_not_prove(self) -> None: + entries = {e["endpoint"]: e for e in self.doc["endpoint_evidence_admissibility"]} + # Every declared tier-1 endpoint must appear. + declared = {endpoint + for source in self.doc["source_tiers"]["tier_1_primary_public"]["sources"] + if source["source_class"] in + self.doc["declared_search_scope"]["per_target_required_source_classes"] + for endpoint in source["endpoints"]} + self.assertEqual(set(entries), declared) + for endpoint, entry in sorted(entries.items()): + with self.subTest(endpoint=endpoint): + self.assertTrue(entry["hit_proves"].strip()) + self.assertTrue(entry["hit_does_not_prove"]) + # A gene-index or page-existence hit is never class A evidence. + for endpoint in IDENTITY_ONLY_ENDPOINTS: + with self.subTest(endpoint=endpoint): + self.assertIs(entries[endpoint]["admissible_as_class_a"], False) + # TCGA's disclaimer must name the four things it cannot show. + tcga = entries["TCGA"]["hit_does_not_prove"] + for claim in ("CRC 肿瘤表达", "prevalence", "malignant-cell attribution"): + with self.subTest(claim=claim): + self.assertIn(claim, " ".join(tcga)) + # They stay mandatory for coverage even though they prove no linkage. + handling = self.doc["dataset_endpoint_handling"] + self.assertEqual(set(handling["dataset_endpoints"]), set(IDENTITY_ONLY_ENDPOINTS)) + self.assertIs(handling["still_required_for_coverage"], True) + self.assertIs(handling["hits_excluded_from_linkage_classes_hit"], True) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-L26", ids) + + def test_trial_records_need_structured_fields_and_one_arm(self) -> None: + entry = next(e for e in self.doc["endpoint_evidence_admissibility"] + if e["endpoint"] == "ClinicalTrials.gov") + for field in ("intervention_name", "intervention_target", + "intervention_modality", "conditions", "arm_group_assignment"): + with self.subTest(field=field): + self.assertIn(field, entry["required_structured_fields"]) + self.assertIs(entry["same_arm_requirement"], True) + self.assertIs(entry["requires_assertion_extraction"], True) + ids = {r["id"] for r in self.doc["output_validation"]} + self.assertIn("VAL-L28", ids) + + def test_retrieval_alone_does_not_close_the_search(self) -> None: + scope = self.doc["declared_search_scope"] + self.assertEqual(scope["scope_version"], "0.2.0") + self.assertIs(scope["retrieval_alone_is_not_search_complete"], True) + self.assertTrue(scope["retrieval_alone_is_not_search_complete_reason"].strip()) + definition = " ".join(scope["search_complete_definition"]) + for token in ("identity_resolution", "L-ASSERTION"): + with self.subTest(token=token): + self.assertIn(token, definition) + # The disposition table must carry the per-pair assertion tally, so a + # zero-assertion pair cannot be presented as a searched-and-empty one. + columns = set(self.doc["output_schema"]["disposition_columns"]) + for column in ("assertion_extraction_complete", "retrieval_candidate_count", + "assertion_count"): + with self.subTest(column=column): + self.assertIn(column, columns) + + def test_the_target_axis_defect_is_recorded_not_silently_patched(self) -> None: + """Fixing the axis inside EVGAP-02 would be an unauthorised axis change.""" + + defect = self.doc["upstream_defect"] + self.assertEqual(defect["id"], "GAP-P07") + self.assertEqual(set(defect["entities"]), + {"Undisclosed", "EDBN", "AG7", "CA19-9"}) + self.assertEqual(defect["frozen_by_pr"], 58) + self.assertIs(defect["requires_separate_pr"], True) + self.assertEqual(defect["interim_handling"], + "本抽取按 L3-00 处理,全部 DEFER,既不 RETAIN 也不 EXCLUDE") + self.assertTrue(defect["why_not_fixed_here"].strip()) + # The binding must in fact have declared identity_unresolved unavailable, + # which is what the record claims. + unavailable = {o["outcome"] for o in + self.doc_binding_lock_01_unavailable_outcomes()} + self.assertIn("identity_unresolved", unavailable) + # And this contract must forbid changing the axis. + not_authorised = " ".join(self.doc["not_authorised"]) + self.assertIn("GAP-P07", not_authorised) + self.assertIn("新增靶点", not_authorised) + + def doc_binding_lock_01_unavailable_outcomes(self) -> list: + for key, value in self.binding.items(): + if isinstance(value, dict) and "unavailable_outcomes" in value: + if any(o["outcome"] == "identity_unresolved" + for o in value["unavailable_outcomes"]): + return value["unavailable_outcomes"] + raise AssertionError("binding declares no unavailable identity outcome") + 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"):