Files
Video-Tree-TRM5/research-wiki/plans/2026-07-16-preflight-wp3-train-loop.md
T

610 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WP3 训练循环与进化引擎 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **前置依赖:WP1(模板已迁移,进化引擎可运行)+ WP4(cache_salt 能力已就绪)必须先完成。**
**Goal:** 修复训练循环与进化引擎的 9 处缺陷,使诊断拿到真实轨迹、早停按 epoch 语义、微型题型不崩、崩溃可幂等续跑、进化 patch 不破坏冻结区、降级信号不驱动错误进化、跨 epoch 评估真实重采样。
**Architecture:** 诊断经 `StepsJsonRunLog` 从 steps_json 重建轨迹(算法 #7 恢复);早停计数单位 step→epoch;可训练性预检在 gate 建立前剔除微型题型;`_run_step` 幂等(先 DELETE 再写);patch 冻结区检查整个 target 跨度(算法 #8 加固);降级/未判定题按 lapse 保守分流;训练推理用 run_id 作 cache_saltrun_id 已含 epoch,天然跨 epoch 重采样)。
**Tech Stack:** Python 3.11、asyncio、SQLite、pytest。
**设计源**`research-wiki/designs/2026-07-16-preflight-fixes-design.md §6-7`
---
## 关键锚点(实现前必读)
| 用途 | 位置 |
|------|------|
| 训练主流程 | `app/harness/runner.py:789-859` `train``_setup_train_run:865-879`(预检插入点 L874 前);`_run_step:1001-1026`run_id L1011、诊断 L1019、无 DELETE |
| 早停 | `app/harness/runner.py:291-316` `_should_early_stop`L315 `+= steps_this_epoch`);`_TrainState:95-121``steps_since_best_improved` L118);`_maybe_promote_best:1587`(置 0 |
| 诊断调用 | `app/harness/runner.py:2163-2196` `_run_diagnosis``RunLogImpl` L2173 未包 StepsJsonRunLog);DiagnosisResult `degraded_count` 未被引用 |
| gate 刷新 | `app/harness/runner.py:1833-1879` `_refresh_gate_ladder`save L1878 → set observed L1879);checkpoint 落盘晚在 train L837 |
| holdout | `app/harness/runner.py:1881-1921` `_holdout_four_way``_pick_mixed_best:1923-1963``_eval_version_on_pool:2148-2161` |
| 推理落库 | `app/harness/inference.py:363-447` `_run_single_question`prediction L422 未归一、insert L446 try 外);`_to_text_field:147-162``run_inference:473` |
| Agent Loop | `core/agent/loop.py:103` `run`session_id L110);`_call_llm` chat 调用 `:329``self._llm.chat(messages, session_id=session_id)` |
| batching | `app/harness/batching.py:186-202` `_classify_unit`L200 缺 correctness→None |
| checkpoint | `app/harness/checkpoint.py:37-44` `_STRUCTURAL_KEYS``serialize_state:76-106``write_checkpoint:212-260`(原子写) |
| 诊断分流 | `core/evolution/diagnose.py:1485-1519` `_build_skill_case_packs`lapse 分流 L1492);`_process_question:2139-2206`except L2194 cause_category 留 None |
| traces 适配 | `app/harness/baseline_run_log.py:13` `StepsJsonRunLog``steps_json_traces.py:13` |
| patch | `core/evolution/patch.py:285-287` `_in_ranges``_do_insert_after:309-326`L320);`_do_replace_delete:329-347`L343);markers L11-18`validate_skill` in `evolve.py:296` |
## 核心算法保真校验
触及算法 #7(诊断瀑布,Task 1 恢复轨迹)、#8patch 引擎,Task 5 冻结区加固)、#10Agent LoopTask 9 透传 cache_salt)、#5/#12(信息阶梯/训练编排,Task 7 checkpoint 时序)。**均为恢复/加固/透传,不改算法逻辑**:Task 1 让诊断拿到本就该有的轨迹;Task 5 把"只查起点"补成"查整跨度"(保护方向不变);Task 9 只加透传参数;Task 7 只调 checkpoint 落盘时机。每个相关 Task 设保真检查点。
---
## Task 1: traces 适配(诊断拿到真实轨迹,算法 #7)
**Files:**
- Modify: `app/harness/runner.py:2163-2196`_run_diagnosis
- Test: `tests/unit/test_runner_diag_tree_inject.py``test_harness_runner.py`
- [ ] **Step 1: 写失败测试**
`tests/unit/test_runner_diag_tree_inject.py` 追加(构造只写 steps_json 不写 traces 表的 run,断言诊断能拿到轨迹):
```python
@pytest.mark.asyncio
async def test_diagnosis_reads_traces_from_steps_json(...):
"""traces 表为空但 predictions.steps_json 有轨迹时,诊断仍拿到非空 traces。"""
# 依现有 runner 测试 fixture 造一个 runpredictions 有 steps_jsontraces 表空;
# 调 _run_diagnosis 后断言 diagnose 收到的 traces 非空(可 patch run_diagnosis 捕获入参)
...
```
> 依 `test_runner_diag_tree_inject.py` 现有 fixture;实现前读对齐 runner 构造与 patch 点。
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_runner_diag_tree_inject.py -k reads_traces_from_steps_json -v`
Expected: FAIL(当前 RunLogImpl 直读空 traces 表)。
- [ ] **Step 3: 包 StepsJsonRunLog**
`app/harness/runner.py` `_run_diagnosis`,把传给 `run_diagnosis``run_log`(当前 `RunLogImpl(...)`L2173 附近)包一层:
```python
from app.harness.baseline_run_log import StepsJsonRunLog
from app.harness.log import RunLogImpl
run_log = StepsJsonRunLog(RunLogImpl(str(self._paths.db_path)))
```
`StepsJsonRunLog.get_traces` 在底层 traces 空时从 predictions.steps_json 经 `steps_json_to_trace_rows` 重建;`get_predictions` 透传。)
- [ ] **Step 4: 保真检查点 + 测试通过**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_runner_diag_tree_inject.py tests/unit/test_baseline_run_log.py -q`
Expected: 全 PASS。保真:确认诊断瀑布拿到的是逐 step `{tool_name,tool_args,tool_output,thought}` 行(对齐 TRM4 诊断输入)。
- [ ] **Step 5: 提交**
```bash
git add app/harness/runner.py tests/unit/test_runner_diag_tree_inject.py
git commit -m "fix: wrap diagnosis run_log with StepsJsonRunLog (restore algo #7 traces)"
```
---
## Task 2: prediction 归一化 + 落库加固
**Files:**
- Modify: `app/harness/inference.py:417-447`
- Test: `tests/unit/test_harness_inference.py`
- [ ] **Step 1: 写失败测试**
`tests/unit/test_harness_inference.py` 追加(LLM 提交非标量 answer 不崩 gather):
```python
@pytest.mark.asyncio
async def test_nonscalar_prediction_does_not_crash(...):
"""submit_answer 返回 {'answer': ['B']} 等非标量时归一化落库,不抛 sqlite 绑定异常。"""
# 依现有 inference 测试 fixture,让 AgentLoop 返回 result={'answer': ['B']}
# run_inference 应正常完成、predictions 行 prediction 为字符串(如 '["B"]'),不崩
...
```
> 依 `test_harness_inference.py` 现有 fake loop/dispatch fixture;实现前读对齐。
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_inference.py -k nonscalar_prediction -v`
Expected: FAILsqlite `InterfaceError: Error binding parameter`)。
- [ ] **Step 3: 归一化 prediction + insert 加固**
`app/harness/inference.py`:新增归一化 helper(None 保留、str 原样、其余 `_to_text_field`):
```python
def _normalize_prediction(answer: object) -> str | None:
"""归一化 predictionNone 保留(INFRA 空预测语义),str 原样,其余 JSON 序列化。"""
if answer is None or isinstance(answer, str):
return answer
return _to_text_field(answer)
```
L422 `"prediction": result_dict.get("answer"),` 改为 `"prediction": _normalize_prediction(result_dict.get("answer")),`
L446 的 `await asyncio.to_thread(log.insert, "predictions", record)` 包 try,绑定异常降级为最小 error 行不击穿 gather
```python
try:
await asyncio.to_thread(log.insert, "predictions", record)
except (sqlite3.InterfaceError, sqlite3.ProgrammingError):
logger.exception("[{}] QA {} 落库绑定异常,降级为 error 行", qa.video_id, qa.question_id)
record["prediction"] = None
record["stop_reason"] = "error"
await asyncio.to_thread(
log.insert, "predictions",
{k: v for k, v in record.items() if isinstance(v, (str, int, float, type(None)))},
)
```
确认 `import sqlite3` 在文件顶部(无则加)。
- [ ] **Step 4: 测试通过 + 回归**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_inference.py -q`
Expected: 全 PASS。
- [ ] **Step 5: 提交**
```bash
git add app/harness/inference.py tests/unit/test_harness_inference.py
git commit -m "fix: normalize non-scalar prediction; harden predictions insert"
```
---
## Task 3: early_stop 改 epoch 计数
**Files:**
- Modify: `app/harness/runner.py:291-316,118,1587,849-857`
- Modify: `app/harness/checkpoint.py`(若字段入 state
- Test: `tests/unit/test_harness_runner.py`
- [ ] **Step 1: 写失败测试**
`tests/unit/test_harness_runner.py` 追加:
```python
def test_early_stop_counts_epochs_not_steps(tmp_path):
"""patience=2 表示连续 2 个 epoch 无 best 刷新才停(不是步数)。"""
from app.harness.runner import _should_early_stop, _TrainState
# 造 state + workspacebest 停在 epoch 1
# epoch 2 无刷新 → epochs_since_best_improved=1 → 不停;
# epoch 3 无刷新 → =2 → 停
...
```
> 依现有 `_TrainState`/`read_best` fixture;实现前读对齐 workspace best 写入。
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -k early_stop_counts_epochs -v`
Expected: FAIL(当前累加 steps_this_epoch)。
- [ ] **Step 3: 字段改名 + 计数改 epoch**
全局把 `steps_since_best_improved` 改名 `epochs_since_best_improved``grep -rn steps_since_best_improved app/``_TrainState:118``_should_early_stop:313,315``_maybe_promote_best:1587`,以及 checkpoint serialize/deserialize 若含此字段)。
`_should_early_stop`L315`state.steps_since_best_improved += steps_this_epoch` 改为 `state.epochs_since_best_improved += 1`;签名删除 `steps_this_epoch` 参数(改为 `_should_early_stop(workspace_dir, epoch, state, patience)`),train 调用点(L849-857)同步去掉 `len(batches)` 实参。docstring 改为"epoch 粒度"。
- [ ] **Step 4: checkpoint 兼容**
`epochs_since_best_improved` 入 checkpoint state`grep -n steps_since_best_improved app/harness/checkpoint.py`),同步改名。本轮为 fresh 训练无旧 checkpoint,无迁移负担。
- [ ] **Step 5: 测试通过 + 回归**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py tests/unit/test_harness_checkpoint.py -q`
Expected: 全 PASS。
- [ ] **Step 6: 提交**
```bash
git add app/harness/runner.py app/harness/checkpoint.py tests/unit/test_harness_runner.py
git commit -m "fix: early_stop patience counts epochs not steps"
```
---
## Task 4: 可训练性预检
**Files:**
- Modify: `app/harness/runner.py``train` 入口过滤 + `_setup_train_run` 接收 filtered task_types
- Modify: `app/harness/config.py`RunConfig 加 `trainable_min_units` + 正整数校验)
- Modify: `app/harness/checkpoint.py:37``trainable_min_units``_STRUCTURAL_KEYS` 指纹)
- Test: `tests/unit/test_harness_runner.py``tests/unit/test_harness_checkpoint.py`
- [ ] **Step 1: 写失败测试**
```python
def test_untrainable_types_filtered_before_gate():
"""val<eval_min_per_class 或 非test单元<trainable_min_units 的题型从 diag/val/task_types 剔除。"""
from app.harness.runner import _filter_untrainable_types
# 构造 pools:题型 Aval=5, units=40)可训;Bval=0, units=2)不可训
# 调 _filter_untrainable_types(pools, task_types=[A,B], eval_min_per_class=2, trainable_min_units=8)
# 断言返回 pools 不含 B、task_types 不含 B
...
```
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -k untrainable_types_filtered -v`
Expected: FAIL`_filter_untrainable_types` 不存在)。
- [ ] **Step 3: 实现纯函数预检**
`app/harness/runner.py` 新增模块级纯函数:
```python
def _filter_untrainable_types(
pools: Pools,
task_types: list[str] | None,
eval_min_per_class: int,
trainable_min_units: int,
) -> tuple[Pools, list[str] | None]:
"""剔除不可训练题型(val<eval_min_per_class 或 非test单元<trainable_min_units)。
非test单元数 = 该题型 diag+val 题数(single 题 unit==题;等于 gate 阶梯该类候选数)。
test 池不过滤(继续报告全题型准确率)。返回过滤后 (pools, task_types)。
"""
from collections import Counter
diag_by_type = Counter(q.task_type for q in pools.diagnosis)
val_by_type = Counter(q.task_type for q in pools.validation)
keep: set[str] = set()
dropped: list[tuple[str, str]] = []
for tt in set(diag_by_type) | set(val_by_type):
n_val = val_by_type.get(tt, 0)
n_units = diag_by_type.get(tt, 0) + n_val
if n_val < eval_min_per_class:
dropped.append((tt, f"val={n_val}<{eval_min_per_class}"))
elif n_units < trainable_min_units:
dropped.append((tt, f"units={n_units}<{trainable_min_units}"))
else:
keep.add(tt)
for tt, why in sorted(dropped):
logger.warning("可训练性预检剔除题型 {}{}", tt, why)
new_pools = replace(
pools,
diagnosis=[q for q in pools.diagnosis if q.task_type in keep],
validation=[q for q in pools.validation if q.task_type in keep],
)
new_types = [t for t in task_types if t in keep] if task_types is not None else sorted(keep)
return new_pools, new_types
```
(确认 `from dataclasses import replace` 已 import`Pools` 是否 frozen dataclass 支持 `replace`——若非,按其构造方式重建。)
**过滤结果必须回传主循环(Codex Critical**`RunConfig``@dataclass(frozen=True)`**不能** `self._config.task_types = ...`(会 FrozenInstanceError),且过滤后的 pools 必须被 `train()` 后续的 batch/step/slow-update/final-eval 全部使用。实现方式:
-`train(pools)` **入口第一步**`_setup_train_run` 调用之前)过滤:
```python
pools, filtered_task_types = _filter_untrainable_types(
pools, self._config.task_types,
self._config.eval_min_per_class, self._config.trainable_min_units,
)
```
- 把 filtered `pools` 传给 `_setup_train_run(pools)` 与 train() 后续所有消费点(build_batches / slow_update / final_eval 均用这个 filtered pools,不再触碰原始 pools)。
-`filtered_task_types` 传给 gate 建立(`_setup_train_run`/`_init_gate_pools` 用它而非 `self._config.task_types`)——新增参数透传,不改 frozen config。
`app/harness/config.py``RunConfig` 加字段 `trainable_min_units: int`(无默认,显式配置;train yaml 提供);在配置校验函数(如 `validate_config`config.py:277 附近)加 `trainable_min_units >= 1` 断言(<1 报错)。
`app/harness/checkpoint.py:37` `_STRUCTURAL_KEYS` 加入 `"trainable_min_units"`——该值改变会改变 pools 过滤结果与训练轨迹,必须纳入 checkpoint 结构指纹,resume 时变化即拒绝复用旧 checkpoint。
- [ ] **Step 4: 测试通过 + 回归**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -q`
Expected: 全 PASS。
- [ ] **Step 5: 提交**
```bash
git add app/harness/runner.py app/harness/config.py tests/unit/test_harness_runner.py
git commit -m "feat: pre-flight filter of untrainable task types before gate"
```
---
## Task 5: patch 冻结区跨度 + 注入 + marker 校验(算法 #8
**Files:**
- Modify: `core/evolution/patch.py:285-347`
- Modify: `core/evolution/evolve.py:296`validate_skill
- Test: `tests/unit/test_patch.py`
- [ ] **Step 1: 写失败测试**
`tests/unit/test_patch.py` 追加:
```python
def test_replace_spanning_into_protected_is_skipped():
"""target 起点在正文、末端伸入冻结区的 replace 被跳过(不破坏 marker)。"""
from core.evolution.patch import apply_patch_with_report, APPENDIX_START, APPENDIX_END
body = "正文最后一段。"
appendix = f"{APPENDIX_START}\n## 执行提醒\n- 规则A\n{APPENDIX_END}"
content = body + "\n\n" + appendix
# target 从正文末尾跨入 APPENDIX_START
target = "正文最后一段。\n\n" + APPENDIX_START
edits = [{"op": "delete", "target": target, "content": ""}]
new_content, report = apply_patch_with_report(content, edits, protected_spans=[appendix])
assert APPENDIX_START in new_content and APPENDIX_END in new_content # marker 未被破坏
def test_edit_payload_with_marker_literal_rejected():
"""edit payload/target 含 marker 字面量 → 拒绝该 edit。"""
from core.evolution.patch import apply_patch_with_report, APPENDIX_START
edits = [{"op": "append", "target": "", "content": f"注入 {APPENDIX_START} 破坏"}]
_, report = apply_patch_with_report("正文", edits, protected_spans=[])
assert any("marker" in str(s).lower() or "reject" in str(s).lower() for s in report)
```
> marker 常量名以 `core/evolution/patch.py:11-18` 为准(`APPENDIX_START` 等),实现前读对齐 import 名。
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_patch.py -k "spanning_into_protected or marker_literal" -v`
Expected: FAIL(当前只查起点 pos,跨入未拦;无注入检查)。
- [ ] **Step 3: 跨度检查 + 注入检查**
`core/evolution/patch.py` 新增跨度 helper
```python
def _span_overlaps_ranges(pos: int, length: int, ranges: list[tuple[int, int]]) -> bool:
"""判断 [pos, pos+length) 是否与任一冻结区间相交(不止起点)。"""
end = pos + length
return any(start < end and pos < r_end for start, r_end in ranges)
```
`_do_insert_after` L320 `if _in_ranges(pos, ranges):` 改为 `if _span_overlaps_ranges(pos, len(target), ranges):`
`_do_replace_delete` L343 `if _in_ranges(pos, ranges):` 改为 `if _span_overlaps_ranges(pos, len(target), ranges):`
`apply_patch_with_report`(L387)应用每个 edit 前加注入检查:payload/target 含任一 marker 字面量(`APPENDIX_START/END``MOMENTUM_START/END`)→ 跳过该 edit 并记 `skipped_marker_injection`
- [ ] **Step 4: validate_skill 加 marker 完整性校验**
`core/evolution/evolve.py:296` `validate_skill`:在现有 frontmatter/长度/代码块校验后加——统计 evolved 中 `APPENDIX_START/END``MOMENTUM_START/END` 出现次数,要求成对(START 数==END 数)、各至多一对、START 在 END 前;违反则返回校验失败(该候选整体 reject)。
- [ ] **Step 5: 保真检查点 + 测试通过**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_patch.py tests/unit/test_evolve.py -q`
Expected: 全 PASS。保真:确认"保护跨度"方向未变(仍是保护 appendix/momentum 不被误改),只是从"查起点"补成"查整跨度"+ 注入/完整性双防线。
- [ ] **Step 6: 提交**
```bash
git add core/evolution/patch.py core/evolution/evolve.py tests/unit/test_patch.py
git commit -m "fix: patch checks full target span + marker injection/integrity (algo #8)"
```
---
## Task 6: 诊断降级分流 + 占比中止
**Files:**
- Modify: `core/evolution/diagnose.py:1485-1497`
- Modify: `app/harness/runner.py:1019`(诊断后 degraded 占比检查)
- Test: `tests/unit/test_diagnose.py``tests/unit/test_harness_runner.py`
- [ ] **Step 1: 写失败测试(分流)**
`tests/unit/test_diagnose.py` 追加:
```python
def test_none_cause_and_degraded_route_to_lapse():
"""cause_category=None(判别失败)与 degraded 题按 lapse 处置,不进 defect 正文路径。"""
from core.evolution.diagnose import _build_skill_case_packs
# 构造 metrics_group:一题 attr.cause_category=None(非 degraded)、一题 qm.degraded=True
# 断言二者都不出现在 failure_caseswrong_by_error),只可能进 lapse_notes
...
```
> 依 `test_diagnose.py` 现有 QuestionMetrics/ErrorAttribution 构造(`:753` 附近);实现前读对齐字段。
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_diagnose.py -k none_cause_and_degraded_route -v`
Expected: FAIL(当前 None → wrong_by_error 走 defect 正文)。
- [ ] **Step 3: 改分流逻辑**
`core/evolution/diagnose.py` `_build_skill_case_packs` 的分流循环(L1488-1497)改为"仅明确 defect 且非 degraded 才进正文路径"
```python
for qm in metrics_group:
if qm.correct:
continue
attr = attribution_map.get(qm.question_id)
is_defect = (
attr is not None
and attr.cause_category == "defect"
and not qm.degraded
)
if not is_defect:
# lapse / None(判别失败)/ degraded → 保守,不驱动正文进化
if attr is not None and attr.lapse_note and attr.lapse_note.strip():
lapse_notes.append(attr.lapse_note)
continue
wrong_by_error[attr.error_type].append(qm)
```
- [ ] **Step 4: 写失败测试(占比中止)+ 实现**
`tests/unit/test_harness_runner.py` 追加:诊断结果 degraded_count/总题数 > 0.5 时 `_run_step` 后应 raise(疑似基础设施故障)。
`app/harness/runner.py` `_run_step`L1019 拿到 `diagnosis` 后)加:
```python
n_wrong = sum(1 for q in batch if not state.correctness.get(q.question_id, True))
if n_wrong > 0 and diagnosis.degraded_count / n_wrong > 0.5:
raise RuntimeError(
f"本 step 诊断降级占比 {diagnosis.degraded_count}/{n_wrong} > 50%"
"疑似 judge 基础设施故障,中止训练(不以降级信号驱动进化)。"
)
```
(确认 `DiagnosisResult.degraded_count` 字段可用,定义在 `core/evolution/types.py:299`。)
- [ ] **Step 5: 测试通过 + 回归**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_diagnose.py tests/unit/test_harness_runner.py -q`
Expected: 全 PASS。
- [ ] **Step 6: 提交**
```bash
git add core/evolution/diagnose.py app/harness/runner.py tests/unit/test_diagnose.py tests/unit/test_harness_runner.py
git commit -m "fix: route None/degraded diagnoses to lapse; abort on high degrade rate"
```
---
## Task 7: step 幂等(DELETE+ gate_epoch_observed 立即落盘
**Files:**
- Modify: `app/harness/runner.py:1001-1026`_run_step 开头 DELETE
- Modify: `app/harness/runner.py:1378-1498,1833-1879`checkpoint 提到 gate save 之后)
- Test: `tests/unit/test_harness_runner.py`
- [ ] **Step 1: 写失败测试(幂等)**
```python
@pytest.mark.asyncio
async def test_run_step_deletes_stale_rows_before_rerun(...):
"""同 run_id 重跑前先清 predictions/traces,避免重复行双计。"""
# 预置该 step run_id 的旧 predictions 行;调 _run_step;断言旧行被清、只剩本次
...
```
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -k deletes_stale_rows -v`
Expected: FAIL(当前 append,无 DELETE)。
- [ ] **Step 3: _run_step 开头 DELETE**
`app/harness/runner.py` `_run_step`,在 rolloutL1012)之前加(**用 `IF EXISTS` 避免 fresh workspace 首跑时 predictions/traces 表尚未由 `run_inference._ensure_tables` 创建导致 `OperationalError: no such table`**):
```python
with HarnessLog(str(self._paths.db_path), run_id, register_run=False) as log:
log.execute("DELETE FROM predictions WHERE run_id=?", (run_id,))
log.execute("DELETE FROM traces WHERE run_id=?", (run_id,))
```
> SQLite `DELETE FROM <t>` 对不存在的表会抛 `no such table`。两种消解方式择一(实现前读 log.py 确认):① rollout 由 `run_inference` 先建表——把 DELETE 移到**首次 rollout 之后、诊断之前**并只在 resume 重跑(step 已有旧行)时执行;② 或 DELETE 前先 `CREATE TABLE IF NOT EXISTS`(复用 inference 的 PREDICTIONS_SCHEMA/TRACES_SCHEMA),保证幂等无害。推荐 ②(无害且简单)。`register_run=False` 来自 WP4;若 HarnessLog 无 `execute` 便捷方法,用其现有连接接口。
- [ ] **Step 4: checkpoint 提到 gate save 之后(消除双计窗口)**
目标:`_refresh_gate_ladder``gate_pools.save`L1878+ `gate_epoch_observed=True`L1879)之后,**立即** `write_checkpoint`phase="epoch_done"),不等到 `train` L837。实现:把 `_slow_update_cycle` Phase 10(调 `_refresh_gate_ladder` L1496-1498)之后的 checkpoint 落盘从 `train`L837)移入 `_slow_update_cycle` 末尾,或让 `_refresh_gate_ladder` 接收 checkpoint 所需上下文(epoch/progress/batches)并在 save 后落盘。实现前读 `write_checkpoint` 签名(checkpoint.py:212)与 `train` L833-848 对齐参数,确保 gate_pools.json 与 checkpoint 的 `gate_epoch_observed` 同一时刻一致。
- [ ] **Step 5: 保真检查点 + 测试通过**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py tests/unit/test_harness_checkpoint.py -q`
Expected: 全 PASS。保真(算法 #5/#12):确认 γ-EMA 更新(`update_probs`)仍每 epoch 一次、checkpoint 落盘不改变慢更新十步序的语义顺序。
- [ ] **Step 6: 提交**
```bash
git add app/harness/runner.py tests/unit/test_harness_runner.py
git commit -m "fix: idempotent _run_step (DELETE stale) + checkpoint after gate save"
```
---
## Task 8: holdout 四向去重
**Files:**
- Modify: `app/harness/runner.py:1881-1963`_holdout_four_way / _pick_mixed_best
- Test: `tests/unit/test_harness_runner.py`
> **说明**:目标是每 epoch 的四向 test 评估从"4×600 全跑"降为"仅 final 必跑 + best_hard 未评过才跑 + baseline 从基线预测推导 + best_mixed 引用赢家"。去重做在 harness 逻辑层(配合 WP4 epoch 盐,同版本不重采样)。
- [ ] **Step 1: 写失败测试**
```python
@pytest.mark.asyncio
async def test_holdout_dedup_skips_reevaluated_versions(...):
"""baseline 不跑推理(从基线预测推导);best_hard==final 时不重复评估。"""
# 统计 _eval_version_on_pool 被调次数:baseline=0best_hard==final 时该向复用不重跑
...
```
> 依现有 runner holdout fixture;实现前读 `_holdout_four_way`/`write_holdout_eval` 对齐。
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -k holdout_dedup -v`
Expected: FAIL(当前四向各跑一次)。
- [ ] **Step 3: 实现去重(进程内备忘录,不改 holdout_eval schema**
> **schema 约束(Codex Critical**:当前 `holdout_eval` 表(observation.py:78)不存 skills_version/prompts_version/pointer,无法按版本反查做跨-run hydrate。本 task **不扩展该 schema**(避免结构性风险),改用 **train() 进程内备忘录** `dict[(skills_v,prompts_v), float]` 去重。代价:resume 后备忘录清空、已评版本会重评一次——resume 是异常路径、重评 600 题成本可接受,换取零 schema 变更风险。完整跨-run hydrate 记 future work。
`app/harness/runner.py` `_holdout_four_way`(在 `_TrainState` 加一个 `holdout_memo: dict[tuple[str,str], float] = field(default_factory=dict)` 字段):
- **baseline 向**:不调 `_eval_version_on_pool`,改从基线 predictions`baseline_run_id`)读 test 题对错算 acctest 题在 infer_adhoc 已全推理过);结果存 memoepoch>1 直接复用(0 推理)。
- **final 向**:真评 600,算完存 `memo[(final_sv,final_pv)]`
- **best_hard 向**:若 `(best_sv,best_pv)` 已在 `memo`(== final 或往轮已评)则引用,否则真评并存 memo。
- **best_mixed 向**`_pick_mixed_best` 选出的赢家必是 best_hard 或 final 之一,其 test acc 已在 memo,直接引用写 holdout_eval0 推理。
实现前完整读 `_holdout_four_way`~1885)、`write_holdout_eval``_eval_version_on_pool` 对齐;`write_holdout_eval` 调用保持不变(仍逐向写观测行,只是 acc 来源改为 memo 复用/基线推导)。
- [ ] **Step 4: 测试通过 + 回归**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -q`
Expected: 全 PASS。
- [ ] **Step 5: 提交**
```bash
git add app/harness/runner.py tests/unit/test_harness_runner.py
git commit -m "perf: dedup holdout four-way eval (baseline derive, best_hard memo)"
```
---
## Task 9: 训练推理注入 epoch 盐(cache_salt=run_id,算法 #10 透传)
**Files:**
- Modify: `core/agent/loop.py:103-141,271-329`
- Modify: `app/harness/inference.py:408-415,473`
- Test: `tests/unit/test_agent_loop`(或现有 loop 测试)、`tests/unit/test_harness_inference.py`
> **原理**:训练/val/test/holdout 推理的 run_id 已含 `_e{epoch}`(如 `{base}_e{epoch}_s{step}`、`{run_id}_holdout_{kind}_e{epoch}`),用 run_id 作 cache_salt 即天然跨 epoch 重采样、同 epoch 续跑仍命中。judge/evolve 不经此路径(默认 salt=None)。gate 基线臂走 BaselineCache 不受影响;候选臂 messages 含 skill 版本天然区分。
- [ ] **Step 1: 写失败测试**
```python
@pytest.mark.asyncio
async def test_agent_loop_forwards_cache_salt():
"""AgentLoop.run(cache_salt=...) 透传到 llm.chat。"""
from core.agent.loop import AgentLoop
# fake llm 记录 chat 收到的 cache_salt kwargloop.run(..., cache_salt='run:e2')
# 断言 fake_llm.chat 收到 cache_salt='run:e2'
...
```
- [ ] **Step 2: 运行确认失败**
Run: `conda run -n Video-Tree-TRM python -m pytest -k agent_loop_forwards_cache_salt -v`
Expected: FAIL`run()` 无 cache_salt 参数)。
- [ ] **Step 3: AgentLoop 透传 cache_salt**
`core/agent/loop.py``run`L103)、`_step`/`_call_llm`L271,317)签名加 `cache_salt: str | None = None`keyword,随 session_id 透传);L329 `self._llm.chat(messages, session_id=session_id)` 改为 `self._llm.chat(messages, session_id=session_id, cache_salt=cache_salt)`
- [ ] **Step 4: inference 用 run_id 作 salt**
`app/harness/inference.py` `_run_single_question``loop.run(...)`L409)加 `cache_salt=run_id`run_id 从 run_inference 透传到每题;`_run_single_question` 已有 run_id 上下文——若无则从 run_inference 参数透传)。确认 run_inference→_run_single_question 的 run_id 传递链完整。
- [ ] **Step 5: 保真检查点 + 测试通过**
Run: `conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_inference.py tests/integration/test_agent_governed_e2e.py -q`
Expected: 全 PASS。保真(算法 #10):确认只加透传参数,Thinking+JSON/json_repair/pluggy hook 逻辑不变。
- [ ] **Step 6: 提交**
```bash
git add core/agent/loop.py app/harness/inference.py tests/unit/
git commit -m "feat: inject run_id as cache_salt for per-epoch resampling (algo #10)"
```
---
## Self-Review(作者自查,执行者复核)
- [ ] traces 适配后诊断拿到真实轨迹(算法 #7 恢复)。
- [ ] prediction 归一化 None 保留、非标量序列化;insert 绑定异常不击穿 gather。
- [ ] early_stop 字段全局改名一致、计数改 epoch。
- [ ] 预检剔除不可训练题型(test 池不动)。
- [ ] patch 查整跨度 + 注入 + marker 完整性三防线(算法 #8 保护方向不变)。
- [ ] None/degraded 按 lapse 保守分流;降级占比>50% 中止。
- [ ] _run_step 幂等;gate_pools 与 checkpoint 的 gate_epoch_observed 同刻一致。
- [ ] holdout 去重后 baseline 0 推理、best_hard 备忘录 resume 可 hydrate。
- [ ] cache_salt=run_id 贯穿 AgentLooprun_id 含 epoch 保证跨 epoch 重采样。
## 核心算法保真校验结论
触及算法 #5/#7/#8/#10/#12,均为恢复(#7 轨迹)/加固(#8 跨度)/透传(#10 salt/时序(#5/#12 checkpoint),各 Task 已设保真检查点,不改算法核心逻辑。Task 5/7/9 需在实现时对照 TRM4 参考确认无行为漂移。
## 验收标准
1. `pytest tests/unit/test_harness_runner.py tests/unit/test_harness_inference.py tests/unit/test_diagnose.py tests/unit/test_patch.py tests/unit/test_harness_checkpoint.py tests/unit/test_baseline_run_log.py tests/unit/test_runner_diag_tree_inject.py` 全绿。
2. 诊断拿到非空轨迹;非标量 prediction 不崩;early_stop 按 epoch。
3. 微型题型被预检剔除;patch 不破坏 marker;降级题不驱动进化。
4. _run_step 幂等;cache_salt=run_id 贯穿。