---
type: design
node_id: design:fix-diagnosis-tree-data-link
title: "修复诊断 tree_data 断链 bug（TRM4→TRM5 迁移 regression）"
date: 2026-07-15
---

# 修复诊断 tree_data 断链 bug（TRM4→TRM5 迁移 regression）

> 诊断编排全程传 `tree_data={}`，导致 ground_truth 恒空、error_type 归因 100% 坍缩成 extraction_failure。本设计接通断掉的树加载环，补齐 P5 fail-loud，双注入点（离线诊断 + 训练循环）一并修复。
>
> **状态**：已含 Codex 独立审查修订（2 Critical + 8 Important 全部核验并采纳；展平实现从"复用 to_dict"改为"遍历 json"，level 改由遍历深度赋值，补视频覆盖 fail-loud，重冻 test 成员变化诚实化）。

## 1. 根因（已坐实）

| 环节 | 位置 | 事实 |
|------|------|------|
| CLI 注入空树 | `app/harness/video_split_cli.py:335` | `tree_data={}`，注释谎称"由诊断管线内部按需加载" |
| 训练循环同病 | `app/harness/runner.py:2180` | `tree_data={}` + 同一句假注释 |
| 编排层无加载 | `core/evolution/diagnose.py:2068-2074` | `if tree_data and "nodes"...` → `{}` 为假 → else 分支 `tree_data_by_video={}`，**根本无加载逻辑** |
| ground_truth 恒空 | `diagnose.py:736,755` | `{}.get("nodes",{})` → node card 取空 → `""` |
| span 评估无参照 | `diagnose.py:509-515` | judge 看不到"应提取什么"，`extraction_completeness` 系统性偏低，解析失败还默认 0.0 |
| 归因瀑布短路 | `diagnose.py:928` | `avg_completeness < 0.5` 恒真 → **100% extraction_failure** |

实测：82 个 T2 全部 extraction_failure，evolution_target 全 tool，48 格多样性退化成 11 格（error_type 维坍缩成单值）。`judge_missed_nodes` 的 `tree_content` 同样为空，search_failure 分支永不触发。

## 2. Regression 溯源：TRM4 无此 bug

| 层面 | TRM4（正常） | TRM5（坏了） |
|------|------|------|
| tree.json 结构 | 扁平 `{"nodes": {id: {node_id,level,time_range,parent_id,children_ids,card}}}` | 嵌套 `{"metadata", "roots": [{id,card,time_range,children}]}` |
| tree_cache 填充 | `diagnose.py:1677` `_load_json(tree.json)` 直接得 nodes | `tree_data={}`，填充逻辑被删 |
| 诊断拿 ground_truth | ✅ `_load_json(...)["nodes"]` 直接可用 | ❌ 恒空 |

**双重 regression**：(a) 建树模块重写把产物格式从扁平 nodes 演进成嵌套 roots（合理架构升级）；(b) 诊断代码迁移时既未适配新格式、又把加载逻辑删成空 dict。因此 TRM5 需要一个 TRM4 不需要的**格式桥接展平器**（roots → nodes）。

## 3. 影响范围

| 维度 | 是否污染 | 原因 |
|------|---------|------|
| tier 分层 T0/T1/T2/uncertain | ✅ 干净 | `classify_defect_vs_lapse` **不吃 tree_data** |
| floor_k / test 代表性 | ✅ 干净 | 按 task_type 总数，与 error_type 无关 |
| error_type 归因 | ❌ 全坍缩 | ground_truth 恒空 |
| evolution_target 路由 | ❌ 全 tool | error_type 的下游派生 |
| 48 格多样性覆盖 | ⚠️ 退化 | error_type 维失效 → 实际仅按 task_type 单维 |
| `judge_missed_nodes` | ⚠️ 退化 | tree_content 空 |

结论：已冻结 `pools.json` 的 tier/floor/代表性**可信**，仅 error_type 维及其下游被污染。

## 4. 修复设计

### 4.1 单元拆分（单一职责）

| 单元 | 位置 | 职责 | 依赖 |
|------|------|------|------|
| 树展平器 | `app/harness/tree_nodes.py`（新） | `load_tree_nodes(store_dir, video_id) -> dict`：**递归遍历 tree.json 的 `roots`（嵌套 dict）**，按遍历深度赋 `level`（root=1/child=2/孙=3），抽取每节点 `id/card/time_range`，组装成扁平 `{"nodes": {node_id: {card, level, time_range}}}` | 只读文件 + json（**不走 TreeIndex 对象层**，见 §4.2 核验） |
| 离线注入 | `app/harness/video_split_cli.py`（`build_diagnosis_deps`/`run_pipeline`） | 由 `wrong_ids` → 涉及 video 去重加载成 `{video_id: {"nodes":...}}`，填充 `DiagnosisDeps.tree_data`（`baseline_diagnosis` 仅透传） | ← 展平器 |
| 训练注入 | `app/harness/runner.py`（`_run_diagnosis`） | 由 batch `question_ids` → 涉及 video 同法加载注入，删 `tree_data={}` 假注释 | ← 展平器 |

### 4.2 关键决策

| 边界 | 决策 | 理由 |
|------|------|------|
| 加载职责 | **app 层**（video_split_cli / runner），core 只消费 dict | Clean Architecture：core 不碰文件系统；`run_diagnosis` else 分支已支持 `{video_id:...}` 形态，core 零改动 |
| 展平实现 | **递归遍历 tree.json `roots` dict**（原始 json，card 已是 dict 直接取），不走对象层 | 核验：仅 `L1Node` 有 `to_dict`（`index.py:260`），L2/L3 是其内部闭包；且 to_dict 输出无 `level`、L3 无 `time_range`（用 `timestamp`）。遍历 json 更省且零改建树模块 |
| level 字段 | **由遍历深度直接赋值**（root=1/child=2/孙=3），不解析 node_id | node_id 累积式 `..._L1_..._L2_..._L3_`，正则首匹配会把 L2/L3 误判成 1（Codex I7） |
| 预加载范围 | 只加载被诊断题涉及的 video，按 video 缓存去重 | YAGNI；236 错题涉及 <200 视频，避免加载无关树 |
| fail-loud | tree.json 缺失 → `FileNotFoundError`（沿用 `factory.py:87` 先例）；展平后 nodes 为空 → `ValueError`；**本次诊断涉及的每个 video 必须被 tree_data 覆盖**，`run_diagnosis` 的 `.get(vid, {})` 静默回退（`diagnose.py:2144-2145`）改为缺失即 raise | 补 P5：空树/漏加载本应报错，不再静默退化 |
| 诊断瀑布本身 | **不动**（algo §4.7 #7） | 修复只接通输入，不改归因逻辑 |

> **L3 无 `time_range` 的已知次要行为**：`node.to_dict` 语义下 L3 节点用 `timestamp`；`_load_tree_content` 取 `time_range` 时 L3 退化为默认 `[0,0]`，仅影响 `judge_missed_nodes` 文本里 L3 的时间显示，不影响 ground_truth（card）。展平器可选：从 L3 `timestamp` 合成 `[timestamp, timestamp]`。

## 5. 非功能性四维

| 维度 | 结论 |
|------|------|
| 持久化 | 展平器纯内存只读，不落盘；诊断结果仍逐题 upsert（不变） |
| 幂等性 | 展平确定性（同 tree.json → 同 nodes）；诊断 upsert 幂等（不变） |
| 断点续跑 | `done_question_ids` 续跑机制不变；fingerprint 因代码改动而变 → 全量重跑一次 |
| 原子性 | 无新写操作；pools.json 重冻仍走既有原子写 |

## 6. 重跑与重冻衔接

代码改动 → git short SHA 变 → `diag_fingerprint` 变 → 全量重跑 236 题诊断。

- `classify_defect_vs_lapse` / evidence / bias / skill judge 输入不含 tree_data → **预期 Redis 缓存命中，tier 分层 T2/T1 应不变**。但这是"大概率"而非"必然"：缓存键内容未逐字段核证，且存在非 tree_data 的不稳定源（如 C3 异常吞并 attribution，`diagnose.py:2186`）。**重跑后须实测校验 T2=82/T1=152 是否保持**，偏差需归因。
- `evaluate_span`（ground_truth 由空变有）+ `judge_missed_nodes`（tree_content 由空变有）缓存失效 → 重算（约半数 LLM 调用，~1h）。
- 诊断完成后重跑 Phase 2 重新冻结覆盖 `pools.json`：**error_type 恢复判别力会改变多样性阶段的选择顺序**（cells 按 `(task_type, error_type)` 排序，`split_selection.py:139/193`）→ trainval 组成变 → **test 补集成员随之变化，不保证稳定**。floor 与代表性 ε 约束仍保证 test **代表性合格**，但具体成员会变——用户已接受重冻覆盖，这是预期结果而非风险。

## 7. 测试策略

| 测试 | 类型 | 验证点 |
|------|------|--------|
| 展平器正确性 | unit | 真实 tree.json → `{"nodes":{...}}`，节点数=递归总数，node 含 card(dict)/level/time_range |
| 展平器 level 赋值 | unit | 遍历深度 → level 1/2/3，与节点嵌套层级一致（不依赖 node_id 解析） |
| 展平器 fail-loud | unit | tree.json 缺失 → FileNotFoundError；空树/空 nodes → ValueError |
| 视频覆盖校验 | unit | 诊断涉及的 video 未被 tree_data 覆盖 → raise（不静默回退） |
| ground_truth 接通 | integration | 对真实 T2 样本注入真实树，断言 `evaluate_span` 收到**非空 ground_truth**（充分条件）；**观察** error_type 恢复多值（软验收——分布是否脱离单值取决于 judge，非硬保证） |
| core 依赖方向 | 结构检查 | `core/evolution/diagnose.py` 不 import `app.tree` |

## 8. 算法保真声明

本修复触及核心算法 **§4.7 #7 诊断瀑布**：仅接通其输入（tree_data），**不改** `attribute_error` 归因分支、severity 函数或 defect/lapse 判定逻辑。参考 TRM4 `core/harness/diagnose.py` 的 tree_cache 语义对照，确保 TRM5 诊断拿到与 TRM4 一致的 ground_truth。

## 9. 被拒方案

| 方案 | 拒因 |
|------|------|
| 复用 `TreeIndex.load_json` + `node.to_dict` 走对象层 | 核验否决：仅 `L1Node` 有 `to_dict`（`index.py:260`），L2/L3 为其内部闭包；输出无 `level`、L3 无 `time_range`，补了也要后处理 |
| 补 L2/L3Node.to_dict 再走对象层 | 改核心建树模块 `index.py`（推理/save_json/建树全链路依赖，algo §4.7 #1-3）；为诊断读取需求反向改生产方接口，违反 YAGNI |
| 让 core 诊断直接吃 `TreeEnvironment` 对象 | 破坏依赖方向（core 依赖 app.tree）；且需构建 env（要 frames_dir）过重 |
| 保留现有 pools.json 不重冻 | train 多样性仍基于污染的 error_type，违背修复初衷 |
| 只修离线诊断、不修 runner | 训练循环 backward 每轮复现坍缩，进化盲目只改 tool |

## 关联
- 影响指标：`metric:split-cell-coverage`（48 格覆盖）、`metric:split-signal-tier-distribution`
- 数据表：`schema:baseline-diagnosis`
- 上游设计：`design:results-driven-video-split`
- 参考实现：TRM4 `core/harness/diagnose.py:1677`（tree_cache 填充）
