Files
Video-Tree-TRM5/research-wiki/plans/2026-07-16-gate-speedup.md
T

1350 lines
55 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.
# 连续并发 gate + Redis 复用 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 gate 验证从"题型串行 × 块串行 × 双臂串行"重构为"推理乱序并发 × 统计阶梯序前缀有序"的连续调度器,单 step gate 墙钟 ~9-18h → ~1.5-2.5h;顺手修复 step 重跑不清 gate 行的幂等 bug;Redis TTL 提到 7 天。
**Architecture:** 设计文档 `research-wiki/designs/2026-07-16-gate-speedup-design.md`(v3,已批准)。统计内核(`core/evolution/gate.py` 的 e-值/四出口、`pair_block` 配对、信息量阶梯、试用期)**一行不动**;只重构 `app/harness/validate.py` 的推理编排层与 `app/harness/runner.py``_gate_batch_skills` 装配层。核心不变量:**(W,L) 只按预声明阶梯序的"已配齐前缀"推进,严禁按完成到达序消费**(到达序消费会因两臂延迟不对称产生假接受偏差,Codex 审查 C1)。
**Tech Stack:** Python 3.11 asyncio(Semaphore/Lock/gather),pytest-asyncio,SQLite(HarnessLog 单连接+锁模式)。
**保真声明:** 本计划涉及核心算法 #6(块顺序验证)的**已批准语义修订**:块序贯 → 阶梯序前缀逐对序贯,判据不变。#4(CE-Gate e-process)、#5(信息阶梯)不动。每个触及 #6 的 commit message 须标注 `(algo #6)`
---
## 全局约束(每个 Task 都适用)
- 所有命令在 `Video-Tree-TRM` conda 环境:`conda run -n Video-Tree-TRM pytest ...`
- 禁止 `except Exception: pass`;中文 docstring;loguru 日志
- 现有纯函数复用,不重写:`_ladder_units` / `_load_run_rows` / `_infra_question_ids_from_db` / `_candidate_correctness_from_db` / `_count_infra_units` / `_check_infra_guard` / `_finalize_outcome` / `materialize_candidate_skill` / `unit_correctness_view` / `pair_block` / `gate_decision`
- 每个 Task 结束跑 `conda run -n Video-Tree-TRM pytest tests/unit -x -q` 保持全绿后才 commit
---
### Task 1: 前缀消费纯逻辑 + 数据结构(validate.py 新增,不删旧码)
**Files:**
- Modify: `app/harness/validate.py`(文件末尾追加新 section)
- Test: `tests/unit/test_gate_prefix.py`(新建)
- [ ] **Step 1: 写失败测试**(核心不变量:乱序到达下统计严格按阶梯序)
```python
"""连续并发 gate 的前缀消费纯逻辑测试。"""
from __future__ import annotations
import pytest
from app.harness.validate import GateSpec, _advance_prefix, _GateRun, _UnitSlot
from core.evolution import GateParams
from core.types import GeneratedQuestion, QuestionUnit
def _mk_question(qid: str, task_type: str = "Action Reasoning") -> GeneratedQuestion:
"""构造最小可用的 single 题(字段名以 core.types 实际定义为准,缺省值从简)。"""
return GeneratedQuestion(
question_id=qid, video_id="v1", task_type=task_type,
question=f"q-{qid}", options=["A. x", "B. y"], answer="A",
)
def _mk_unit(qid: str, task_type: str = "Action Reasoning") -> QuestionUnit:
return QuestionUnit(unit_id=qid, questions=[_mk_question(qid, task_type)])
def _mk_run(n_units: int) -> _GateRun:
spec = GateSpec(
task_type="Action Reasoning", target_file="action-reasoning.md",
candidate_content="cand", base_skill_content="base",
units=[_mk_unit(f"q{i}") for i in range(n_units)],
gate_run_prefix="r_e1_s0_gate_action-reasoning",
)
return _GateRun.from_spec(spec)
_PARAMS = GateParams(
e_confirm=20.0, e_provisional=3.0, w_net_min=2,
delta_min=0.02, lambda_dir=-0.642, e_rollback=10.0,
)
def test_prefix_blocks_on_unresolved_head() -> None:
"""阶梯头部单元未配齐时,即使尾部全部配齐也一个都不消费。"""
run = _mk_run(4)
for i in (1, 2, 3): # 尾部三个先到
run.slots[i].base = False
run.slots[i].cand_per_q = {f"q{i}": True}
_advance_prefix(run, _PARAMS)
assert run.n_used == 0 and run.w == 0 and run.verdict is None
def test_prefix_consumes_in_ladder_order_after_head_arrives() -> None:
"""头部补齐后一次性顺序消费到最长已配齐前缀。"""
run = _mk_run(4)
for i in (0, 1, 2):
run.slots[i].base = False
run.slots[i].cand_per_q = {f"q{i}": True}
_advance_prefix(run, _PARAMS)
assert run.n_used == 3 and run.w == 3 and run.l == 0
assert [r["ladder_rank"] for r in run.evidence_rows] == [0, 1, 2]
def test_freeze_on_terminal_verdict_stops_consumption() -> None:
"""过线(此处 8 连胜 E≥20 confirmed)即冻结,后续已配齐单元不再消费。"""
run = _mk_run(12)
for i in range(12):
run.slots[i].base = False
run.slots[i].cand_per_q = {f"q{i}": True}
_advance_prefix(run, _PARAMS)
assert run.frozen and run.verdict is not None
assert run.verdict.decision == "accept_confirmed"
assert run.n_used < 12 # 早停,没吃满
def test_infra_unit_skipped_not_counted() -> None:
"""INFRA 单元(任一臂)剔除:不入 (W,L)、计入 n_excluded、前缀继续推进。
注意 futility 出口在小 n_remaining 下很敏感,用 6 单元(首个 INFRA、其余 5 个
W 翻转)保证消费全程不提前触发 futility:题尽走 accept_provisional 终态。
"""
run = _mk_run(6)
run.slots[0].base_infra = True
run.slots[0].cand_per_q = {"q0": True}
for i in range(1, 6):
run.slots[i].base = False
run.slots[i].cand_per_q = {f"q{i}": True}
_advance_prefix(run, _PARAMS)
assert run.n_excluded == 1 and run.n_used == 5 and run.w == 5 and run.l == 0
assert run.verdict is not None and run.verdict.decision == "accept_provisional"
def test_ties_hit_futility_early_and_freeze() -> None:
"""全打平(无翻转对)时 futility 出口尽早触发并冻结——早停语义(数值:W=L=0
时乐观 E = E(n_remaining, 0),n 小易 <e_provisional=3)。"""
run = _mk_run(3)
for i in range(3):
run.slots[i].base = True
run.slots[i].cand_per_q = {f"q{i}": True}
_advance_prefix(run, _PARAMS)
assert run.frozen
assert run.verdict is not None and run.verdict.decision != "continue"
assert run.w == 0 and run.l == 0
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_prefix.py -x -q`
Expected: FAIL(ImportError: cannot import name 'GateSpec')
注意:若 `GeneratedQuestion` / `QuestionUnit` 构造字段与上述不符,先 `grep -n "class GeneratedQuestion\|class QuestionUnit" core/types.py` 按真实字段修 fixture,再确认失败原因是 ImportError。
- [ ] **Step 3: 在 `app/harness/validate.py` 末尾实现**(新 section,旧块循环暂不删)
```python
# ---------------------------------------------------------------------------
# 连续并发 gate:数据结构 + 前缀消费(algo #6 语义修订:块序贯 → 阶梯序前缀逐对序贯)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class GateSpec:
"""单题型 gate 验证规格(runner 装配阶段产物,调度器输入)。
字段:
task_type: 题型。
target_file: 解析后生效 skill 文件名(候选物化写此文件)。
candidate_content: 候选 skill 全文。
base_skill_content: 基线侧生效 skill 全文(skill_hash 作缓存键)。
units: 阶梯序单元列表(已排除案例单元、截断 gate_n_max)。
gate_run_prefix: run_id 前缀,必须含 "_gate_"(防泄露过滤依赖)。
"""
task_type: str
target_file: str
candidate_content: str
base_skill_content: str
units: list[QuestionUnit]
gate_run_prefix: str
@dataclass
class _UnitSlot:
"""单个阶梯单元的双臂到达状态。
base 为单元级对错(AR pair 已折叠);cand_per_q 为逐题对错(折叠交给消费时,
以复用 unit_correctness_view 并保留逐题溯源)。INFRA 标志与结果互斥。
"""
unit: QuestionUnit
base: bool | None = None
cand_per_q: dict[str, bool] | None = None
base_infra: bool = False
cand_infra: bool = False
def resolved(self) -> bool:
"""双臂均已出结果(含 INFRA 判定)。"""
base_done = self.base is not None or self.base_infra
cand_done = self.cand_per_q is not None or self.cand_infra
return base_done and cand_done
def excluded(self) -> bool:
"""任一臂 INFRA 即整单元剔除(不入配对)。"""
return self.base_infra or self.cand_infra
@dataclass
class _GateRun:
"""单题型 gate 的运行时状态(计数器 + 前缀指针 + 证据)。"""
spec: GateSpec
slots: list[_UnitSlot]
s_hash: str
prefix_ptr: int = 0
w: int = 0
l: int = 0 # noqa: E741
n_used: int = 0
n_excluded: int = 0
errors: int = 0
infra_denom: int = 0
frozen: bool = False
verdict: GateVerdict | None = None
base_obs: dict[str, bool] = field(default_factory=dict)
cand_obs: dict[str, bool] = field(default_factory=dict)
candidate_per_q: dict[str, bool] = field(default_factory=dict)
evidence_rows: list[dict] = field(default_factory=list)
@classmethod
def from_spec(cls, spec: GateSpec) -> _GateRun:
"""由规格构造初始状态(slots 与阶梯序一一对应)。"""
return cls(
spec=spec,
slots=[_UnitSlot(unit=u) for u in spec.units],
s_hash=skill_hash(spec.base_skill_content),
)
def _advance_prefix(run: _GateRun, params: GateParams) -> None:
"""沿阶梯序消费"已配齐前缀",逐单元更新 (W,L) 并判定,过线即冻结。
统计合法性关键(设计 v3 §1 / Codex C1):严禁按到达序消费——base 臂缓存命中
瞬间返回、cand 臂必新鲜跑,两臂延迟不对称,若 cand 延迟与对错相关,早到翻转
对系统性偏向 W 型 → e-值虚高假接受。前缀消费把判定顺序钉回预声明阶梯序,
anytime-valid 无条件成立;INFRA 单元视为"已解决(剔除)"不阻塞前缀。
"""
while not run.frozen and run.prefix_ptr < len(run.slots):
slot = run.slots[run.prefix_ptr]
if not slot.resolved():
return
rank = run.prefix_ptr
run.prefix_ptr += 1
if slot.excluded():
run.n_excluded += 1
continue
uid = slot.unit.unit_id
assert slot.base is not None and slot.cand_per_q is not None
c_units = unit_correctness_view([slot.unit], slot.cand_per_q)
pair_result = pair_block({uid: slot.base}, c_units, [uid])
run.candidate_per_q.update(slot.cand_per_q)
for u, (b, c) in pair_result.observed.items():
run.base_obs[u] = b
run.cand_obs[u] = c
run.w += pair_result.w
run.l += pair_result.l
run.n_used += 1
n_remaining = (len(run.slots) - run.n_excluded) - run.n_used
run.verdict = gate_decision(run.w, run.l, run.n_used, n_remaining, params=params)
run.evidence_rows.append(
{
"question_id": uid,
"task_type": run.spec.task_type,
"ladder_rank": rank,
"baseline_correct": slot.base,
"candidate_correct": c_units[uid],
"e_value": run.verdict.e_value,
"stop_reason": "",
}
)
if run.verdict.decision != "continue":
run.frozen = True
```
- [ ] **Step 4: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_prefix.py -x -q`
Expected: 5 passed
- [ ] **Step 5: Commit**
```bash
git add app/harness/validate.py tests/unit/test_gate_prefix.py
git commit -m "feat: gate prefix-ordered consumption core (algo #6)"
```
---
### Task 2: 题槽并发闸 + 单元臂执行任务
**Files:**
- Modify: `app/harness/validate.py`(继续追加)
- Test: `tests/unit/test_gate_unit_arm.py`(新建)
- [ ] **Step 1: 写失败测试**
```python
"""单元臂执行任务测试:缓存命中/新鲜跑/INFRA/冻结跳过/题槽并发上限。"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from app.harness.gate_ladder import BaselineCache
from app.harness.validate import (
GateSpec, _GateRun, _QuestionSlots, _run_unit_arm,
)
from core.evolution import GateParams
from tests.unit.test_gate_prefix import _mk_unit, _PARAMS # 复用 fixture
class _FakeLog:
"""假 HarnessLog:query 返回预置 predictions 行。"""
def __init__(self) -> None:
self.rows: list[dict] = []
def query(self, sql: str, params: tuple = ()) -> list[dict]:
run_id = params[0]
return [r for r in self.rows if r["run_id"] == run_id]
def _mk_gate_run(n: int, tmp_path: Path) -> tuple[_GateRun, BaselineCache]:
spec = GateSpec(
task_type="Action Reasoning", target_file="action-reasoning.md",
candidate_content="cand", base_skill_content="base",
units=[_mk_unit(f"q{i}") for i in range(n)],
gate_run_prefix="r_e1_s0_gate_action-reasoning",
)
return _GateRun.from_spec(spec), BaselineCache(tmp_path / "bc.json")
def _fake_run_inference(log: _FakeLog, correct: bool, stop_reason: str = "finished"):
"""构造假推理:把每题结果写进 _FakeLog 并返回带 total 的结果对象。"""
class _R:
def __init__(self, run_id: str, total: int) -> None:
self.run_id = run_id
self.total = total
async def _run(questions, *, run_id: str, skills_dir: Path):
for q in questions:
log.rows.append({
"run_id": run_id, "question_id": q.question_id,
"prediction": "A" if correct else "B", "answer": "A",
"stop_reason": stop_reason, "steps_json": "[]",
})
return _R(run_id, len(questions))
return _run
@pytest.mark.asyncio
async def test_base_arm_cache_hit_skips_inference(tmp_path) -> None:
"""base 臂缓存命中:不调推理,slot.base 直接就位,infra_denom 不增。"""
run, cache = _mk_gate_run(1, tmp_path)
cache.put("Action Reasoning", run.s_hash, "v1", "q0", True)
called = {"n": 0}
async def _boom(questions, *, run_id, skills_dir):
called["n"] += 1
raise AssertionError("缓存命中不应触发推理")
slots = _QuestionSlots(4)
await _run_unit_arm(
run, 0, "base", slots, _boom, _FakeLog(), cache, "v1",
Path("/nonexistent"), Path("/nonexistent"), _PARAMS, 0.10,
)
assert called["n"] == 0 and run.slots[0].base is True and run.infra_denom == 0
@pytest.mark.asyncio
async def test_base_arm_fresh_run_writes_cache(tmp_path) -> None:
"""base 臂 miss 新鲜跑:结果折叠入 slot 并回写缓存。"""
run, cache = _mk_gate_run(1, tmp_path)
log = _FakeLog()
slots = _QuestionSlots(4)
await _run_unit_arm(
run, 0, "base", slots, _fake_run_inference(log, correct=True), log,
cache, "v1", tmp_path, tmp_path, _PARAMS, 0.10,
)
assert run.slots[0].base is True
assert cache.get("Action Reasoning", run.s_hash, "v1", "q0") is True
assert run.infra_denom == 1
@pytest.mark.asyncio
async def test_infra_arm_marks_excluded_and_no_cache(tmp_path) -> None:
"""INFRA 臂:标记 infra、errors+1、不写缓存。"""
run, cache = _mk_gate_run(1, tmp_path)
log = _FakeLog()
slots = _QuestionSlots(4)
await _run_unit_arm(
run, 0, "base", slots, _fake_run_inference(log, correct=False, stop_reason="error"),
log, cache, "v1", tmp_path, tmp_path, _PARAMS, 0.10,
)
assert run.slots[0].base_infra and run.errors == 1
assert cache.get("Action Reasoning", run.s_hash, "v1", "q0") is None
@pytest.mark.asyncio
async def test_frozen_run_skips_launch(tmp_path) -> None:
"""已冻结题型的排队臂:直接返回,不占槽不推理。"""
run, cache = _mk_gate_run(1, tmp_path)
run.frozen = True
called = {"n": 0}
async def _boom(questions, *, run_id, skills_dir):
called["n"] += 1
await _run_unit_arm(
run, 0, "cand", _QuestionSlots(4), _boom, _FakeLog(), cache, "v1",
tmp_path, tmp_path, _PARAMS, 0.10,
)
assert called["n"] == 0
@pytest.mark.asyncio
async def test_question_slots_caps_inflight() -> None:
"""题槽闸:峰值在飞数严格 ≤ 宽度(多槽获取不交错死锁)。"""
slots = _QuestionSlots(2)
peak = {"cur": 0, "max": 0}
async def _job(n: int) -> None:
await slots.acquire(n)
peak["cur"] += n
peak["max"] = max(peak["max"], peak["cur"])
await asyncio.sleep(0.01)
peak["cur"] -= n
slots.release(n)
await asyncio.gather(*[_job(1) for _ in range(6)], *[_job(2) for _ in range(3)])
assert peak["max"] <= 2
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_unit_arm.py -x -q`
Expected: FAIL(ImportError: cannot import name '_QuestionSlots')
- [ ] **Step 3: 实现 `_QuestionSlots` 与 `_run_unit_arm`**(validate.py 追加)
```python
class _QuestionSlots:
"""按题数计数的共享并发闸:峰值在飞请求恒 ≤ width(设计 v3 §2.4)。
多槽获取(AR pair 一单元两题)经内部锁串行化,防多任务半持有交错死锁。
asyncio.Semaphore 等待队列 FIFO,任务按创建序(题型 round-robin)获得槽,
即公平调度的实现载体(Codex I2)。
"""
def __init__(self, width: int) -> None:
assert width > 0, f"并发宽度必须为正: {width}"
self._sem = asyncio.Semaphore(width)
self._acquire_lock = asyncio.Lock()
async def acquire(self, n: int) -> None:
"""原子获取 n 个题槽。"""
async with self._acquire_lock:
for _ in range(n):
await self._sem.acquire()
def release(self, n: int) -> None:
"""归还 n 个题槽。"""
for _ in range(n):
self._sem.release()
async def _run_unit_arm(
run: _GateRun,
slot_idx: int,
arm: str,
slots: _QuestionSlots,
run_inference: RunInferenceFn,
log: HarnessLog,
baseline_cache: BaselineCache,
prompts_version: str,
base_skills_dir: Path,
cand_dir: Path,
gate_params: GateParams,
gate_guard_err: float,
) -> None:
"""执行一个 (单元, 臂) 任务:缓存/推理 → 到达登记 → 前缀消费推进。
冻结检查两次:启动时(排队任务撤销点)与获得题槽后(获槽期间被冻结)。
base 臂缓存命中不占题槽(零推理);INFRA 单元不写缓存(不永久污染基线快照)。
护栏在每次臂完成时检查(等价迁移自跨块累计,设计 v3 §2.3),超阈值 raise
中止整轮(与现行行为一致)。
"""
assert arm in ("base", "cand")
if run.frozen:
return
slot = run.slots[slot_idx]
spec = run.spec
if arm == "base":
cached = baseline_cache.get(
spec.task_type, run.s_hash, prompts_version, slot.unit.unit_id
)
if cached is not None:
slot.base = cached
_advance_prefix(run, gate_params)
return
questions = list(slot.unit.questions)
await slots.acquire(len(questions))
try:
if run.frozen:
return
run_id = f"{spec.gate_run_prefix}_{arm}"
skills_dir = base_skills_dir if arm == "base" else cand_dir
r = await run_inference(questions, run_id=run_id, skills_dir=skills_dir)
infra_qids = _infra_question_ids_from_db(log, r.run_id, questions)
run.infra_denom += r.total
if infra_qids:
run.errors += 1
if arm == "base":
slot.base_infra = True
else:
slot.cand_infra = True
else:
per_q = _candidate_correctness_from_db(log, r.run_id, questions)
if arm == "base":
folded = unit_correctness_view([slot.unit], per_q)
slot.base = folded[slot.unit.unit_id]
baseline_cache.put(
spec.task_type, run.s_hash, prompts_version,
slot.unit.unit_id, slot.base,
)
else:
slot.cand_per_q = per_q
_check_infra_guard(run.errors, run.infra_denom, gate_guard_err)
finally:
slots.release(len(questions))
_advance_prefix(run, gate_params)
```
- [ ] **Step 4: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_unit_arm.py tests/unit/test_gate_prefix.py -x -q`
Expected: 10 passed
- [ ] **Step 5: Commit**
```bash
git add app/harness/validate.py tests/unit/test_gate_unit_arm.py
git commit -m "feat: gate unit-arm tasks with question-slot gate (algo #6)"
```
---
### Task 3: 调度编排 `validate_skills_concurrent` + evidence 表 ladder_rank
**Files:**
- Modify: `app/harness/validate.py`(追加编排函数)
- Modify: `app/harness/observation.py`(`_GATE_EVIDENCE_COLS``block_idx``ladder_rank`;同步改 docstring)
- Test: `tests/unit/test_gate_concurrent.py`(新建)
- [ ] **Step 1: 写失败测试**
```python
"""连续并发 gate 编排测试:乱序到达/多题型隔离/round-robin/终态组装。"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from app.harness.gate_ladder import BaselineCache
from app.harness.validate import GateSpec, validate_skills_concurrent
from tests.unit.test_gate_prefix import _mk_unit, _PARAMS
from tests.unit.test_gate_unit_arm import _FakeLog
def _mk_spec(task_type: str, slug: str, n: int) -> GateSpec:
return GateSpec(
task_type=task_type, target_file=f"{slug}.md",
candidate_content=f"cand-{slug}", base_skill_content=f"base-{slug}",
units=[_mk_unit(f"{slug}-q{i}", task_type) for i in range(n)],
gate_run_prefix=f"r_e1_s0_gate_{slug}",
)
def _scripted_inference(log: _FakeLog, script: dict[str, tuple[bool, float]]):
"""脚本化假推理:按 question_id 决定 (对错, 延迟秒),制造乱序到达。"""
class _R:
def __init__(self, run_id: str, total: int) -> None:
self.run_id = run_id
self.total = total
async def _run(questions, *, run_id: str, skills_dir: Path):
arm = "cand" if run_id.endswith("_cand") else "base"
correct, delay = script[f"{questions[0].question_id}|{arm}"]
await asyncio.sleep(delay)
for q in questions:
log.rows.append({
"run_id": run_id, "question_id": q.question_id,
"prediction": "A" if correct else "B", "answer": "A",
"stop_reason": "finished", "steps_json": "[]",
})
return _R(run_id, len(questions))
return _run
@pytest.mark.asyncio
async def test_out_of_order_arrival_still_ladder_order(tmp_path, monkeypatch) -> None:
"""尾部先到、头部后到:判定结果与顺序到达完全相同(前缀有序性端到端)。"""
spec = _mk_spec("Action Reasoning", "action-reasoning", 4)
log = _FakeLog()
# 头部 q0 最慢;全部翻转为 W(base 错 cand 对)
script = {}
for i in range(4):
script[f"action-reasoning-q{i}|base"] = (False, 0.05 if i == 0 else 0.0)
script[f"action-reasoning-q{i}|cand"] = (True, 0.05 if i == 0 else 0.0)
monkeypatch.setattr(
"app.harness.validate.materialize_candidate_skill",
lambda *a, **k: tmp_path / "cand",
)
outcomes = await validate_skills_concurrent(
workspace_dir=tmp_path, base_skills_version="v1", specs=[spec],
gate_params=_PARAMS, gate_guard_err=0.10,
baseline_cache=BaselineCache(tmp_path / "bc.json"),
prompts_version="v1", run_inference=_scripted_inference(log, script),
log=log, concurrency=8,
)
o = outcomes["Action Reasoning"]
assert o.w == 4 and o.l == 0
assert [r["ladder_rank"] for r in o.evidence_rows] == [0, 1, 2, 3]
@pytest.mark.asyncio
async def test_two_types_isolated(tmp_path, monkeypatch) -> None:
"""两题型并行:计数互不污染,各自独立判定。
A 型 4 单元全 W(题尽 accept_provisional);B 型 2 单元全平
(futility 早停,W=L=0)——两型结果都不受对方污染。
"""
spec_a = _mk_spec("Action Reasoning", "action-reasoning", 4)
spec_b = _mk_spec("Counting Problem", "counting-problem", 2)
log = _FakeLog()
script = {}
for i in range(4):
script[f"action-reasoning-q{i}|base"] = (False, 0.0)
script[f"action-reasoning-q{i}|cand"] = (True, 0.0)
for i in range(2):
script[f"counting-problem-q{i}|base"] = (True, 0.0)
script[f"counting-problem-q{i}|cand"] = (True, 0.0)
monkeypatch.setattr(
"app.harness.validate.materialize_candidate_skill",
lambda *a, **k: tmp_path / "cand",
)
outcomes = await validate_skills_concurrent(
workspace_dir=tmp_path, base_skills_version="v1",
specs=[spec_a, spec_b], gate_params=_PARAMS, gate_guard_err=0.10,
baseline_cache=BaselineCache(tmp_path / "bc.json"),
prompts_version="v1", run_inference=_scripted_inference(log, script),
log=log, concurrency=8,
)
assert outcomes["Action Reasoning"].w == 4
assert outcomes["Counting Problem"].w == 0
assert outcomes["Counting Problem"].l == 0
@pytest.mark.asyncio
async def test_all_infra_raises(tmp_path, monkeypatch) -> None:
"""全单元 INFRA:保留现行 RuntimeError 语义(检查推理基础设施)。"""
spec = _mk_spec("Action Reasoning", "action-reasoning", 2)
log = _FakeLog()
class _R:
def __init__(self, run_id, total):
self.run_id, self.total = run_id, total
async def _infra_run(questions, *, run_id, skills_dir):
for q in questions:
log.rows.append({
"run_id": run_id, "question_id": q.question_id,
"prediction": "", "answer": "A",
"stop_reason": "error", "steps_json": "[]",
})
return _R(run_id, len(questions))
monkeypatch.setattr(
"app.harness.validate.materialize_candidate_skill",
lambda *a, **k: tmp_path / "cand",
)
with pytest.raises(RuntimeError):
await validate_skills_concurrent(
workspace_dir=tmp_path, base_skills_version="v1", specs=[spec],
gate_params=_PARAMS, gate_guard_err=0.99, # 护栏放宽,逼出全 INFRA 分支
baseline_cache=BaselineCache(tmp_path / "bc.json"),
prompts_version="v1", run_inference=_infra_run,
log=log, concurrency=8,
)
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_concurrent.py -x -q`
Expected: FAIL(ImportError: cannot import name 'validate_skills_concurrent')
- [ ] **Step 3: 实现编排函数**(validate.py 追加)
```python
async def validate_skills_concurrent(
workspace_dir: Path,
base_skills_version: str,
specs: list[GateSpec],
gate_params: GateParams,
gate_guard_err: float,
baseline_cache: BaselineCache,
prompts_version: str,
run_inference: RunInferenceFn,
log: HarnessLog,
concurrency: int,
) -> dict[str, ValidationOutcome]:
"""连续并发 gate:多题型全部臂共享题槽并发,统计按阶梯序前缀有序推进。
发射顺序 = 题型 round-robin × 题型内阶梯序(base 先 cand 后),asyncio.Semaphore
FIFO 等待队列保证获槽顺序即发射顺序 → 公平调度,大题型不饿死小题型。
题型过线即冻结:其排队任务启动时自查冻结标志撤销;in-flight 跑完落库但
不计入(τ 之后样本,合法丢弃)。全部题型判定后统一组装 ValidationOutcome。
参数:
workspace_dir: workspace 根目录(候选物化用)。
base_skills_version: 基线 skills 版本名。
specs: 各题型 gate 规格(units 已阶梯序 + 截断 n_max)。
gate_params / gate_guard_err: e-process 判据与 INFRA 护栏阈值。
baseline_cache / prompts_version: 基线缓存及其键成分。
run_inference: 注入推理函数(调用方须绑定共享 HarnessLog,见 runner)。
log: HarnessLog 共享实例(推理后读预测,与 run_inference 同库)。
concurrency: 题槽宽度(峰值在飞题数上限)。
返回:
{task_type: ValidationOutcome}。
异常:
RuntimeError: INFRA 护栏超阈值,或某题型全部单元被 INFRA 排除。
ValueError: spec 校验失败(空阶梯 / run_prefix 缺 "_gate_")。
"""
for spec in specs:
if "_gate_" not in spec.gate_run_prefix:
raise ValueError(f"gate_run_prefix 必须含 '_gate_': {spec.gate_run_prefix!r}")
if not spec.units:
raise ValueError(f"task_type={spec.task_type} 阶梯为空,无法验证")
base_skills_dir = workspace_dir / "skills" / base_skills_version
runs = [_GateRun.from_spec(s) for s in specs]
cand_dirs = {
r.spec.task_type: materialize_candidate_skill(
workspace_dir, base_skills_version, r.spec.target_file, r.spec.candidate_content
)
for r in runs
}
slots_gate = _QuestionSlots(concurrency)
try:
# 发射队列:round-robin 交错(rank 0 各题型 → rank 1 各题型 → ...)
coros = []
max_rank = max(len(r.slots) for r in runs)
for rank in range(max_rank):
for r in runs:
if rank < len(r.slots):
for arm in ("base", "cand"):
coros.append(
_run_unit_arm(
r, rank, arm, slots_gate, run_inference, log,
baseline_cache, prompts_version,
base_skills_dir, cand_dirs[r.spec.task_type],
gate_params, gate_guard_err,
)
)
await asyncio.gather(*coros)
finally:
for d in cand_dirs.values():
try:
shutil.rmtree(d)
except OSError as e:
logger.warning("候选临时目录清理失败 {}: {}", d, e)
outcomes: dict[str, ValidationOutcome] = {}
for r in runs:
if r.verdict is None:
raise RuntimeError(
f"gate[{r.spec.task_type}] 全部 unit 被判为 INFRA 排除,无法验证(检查推理基础设施)"
)
outcomes[r.spec.task_type] = _finalize_outcome(
verdict=r.verdict, w=r.w, l=r.l, n_used=r.n_used, n_plan=len(r.slots),
base_obs=r.base_obs, cand_obs=r.cand_obs,
candidate_per_q=r.candidate_per_q, evidence_rows=r.evidence_rows,
task_type=r.spec.task_type,
)
return outcomes
```
顶部 import 需补 `import asyncio`
- [ ] **Step 4: `observation.py` 改 evidence 列**
找到 `_GATE_EVIDENCE_COLS` 定义(`grep -n "_GATE_EVIDENCE_COLS" app/harness/observation.py`),把 `"block_idx"` 列改名 `"ladder_rank"`(类型不变 INTEGER),`write_gate_evidence` docstring 中 `block_idx` 描述同步改为 `ladder_rank(阶梯序号)`
- [ ] **Step 5: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_concurrent.py tests/unit/test_gate_prefix.py tests/unit/test_gate_unit_arm.py -x -q`
Expected: 13 passed
注意:`test_out_of_order_arrival_still_ladder_order` 里 cand 臂识别依赖 `run_id.endswith("_cand")`,与实现的 run_id 命名 `{prefix}_{arm}` 一致;若失败先核对 run_id 拼接。
- [ ] **Step 6: Commit**
```bash
git add app/harness/validate.py app/harness/observation.py tests/unit/test_gate_concurrent.py
git commit -m "feat: continuous concurrent gate orchestrator (algo #6)"
```
---
### Task 4: step 重跑幂等修复(现行潜伏 bug)
**Files:**
- Modify: `app/harness/runner.py:1105-1109`(`_run_step` 开头的清行块)
- Test: `tests/unit/test_step_rerun_idempotent.py`(新建)
- [ ] **Step 1: 写失败测试**
```python
"""step 重跑幂等:gate 派生行必须随 step 清理,否则崩溃重跑累积重复。"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from app.harness.runner import _clear_step_rows
def _mk_db(tmp_path: Path) -> Path:
db = tmp_path / "harness.db"
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE predictions (run_id TEXT, question_id TEXT)")
conn.execute("CREATE TABLE traces (run_id TEXT, question_id TEXT)")
conn.execute("CREATE TABLE gate_evidence (run_id TEXT, epoch INTEGER, step INTEGER)")
conn.execute("CREATE TABLE quadrant_pair (run_id TEXT, epoch INTEGER, step INTEGER)")
rows = [
("infer_adhoc_e1_s0", "q1"), # rollout 行
("infer_adhoc_e1_s0_gate_action-reasoning_base", "q2"), # gate base 臂
("infer_adhoc_e1_s0_gate_action-reasoning_cand", "q3"), # gate cand 臂
("infer_adhoc_e1_s1", "q4"), # 其他 step,不许误删
("infer_adhoc_e1_s10_gate_x_base", "q5"), # s10 前缀陷阱,不许误删
]
conn.executemany("INSERT INTO predictions VALUES (?, ?)", rows)
conn.executemany("INSERT INTO traces VALUES (?, ?)", rows)
conn.execute("INSERT INTO gate_evidence VALUES ('infer_adhoc', 1, 0)")
conn.execute("INSERT INTO gate_evidence VALUES ('infer_adhoc', 1, 1)")
conn.execute("INSERT INTO quadrant_pair VALUES ('infer_adhoc', 1, 0)")
conn.commit()
conn.close()
return db
def test_clear_step_rows_removes_rollout_and_gate_rows(tmp_path) -> None:
db = _mk_db(tmp_path)
_clear_step_rows(str(db), baseline_run_id="infer_adhoc", epoch=1, step=0)
conn = sqlite3.connect(db)
left = {r[0] for r in conn.execute("SELECT run_id FROM predictions")}
assert left == {"infer_adhoc_e1_s1", "infer_adhoc_e1_s10_gate_x_base"}
left_t = {r[0] for r in conn.execute("SELECT run_id FROM traces")}
assert left_t == left
ge = list(conn.execute("SELECT step FROM gate_evidence"))
assert ge == [(1,)] # 只剩 step=1 的行
assert list(conn.execute("SELECT COUNT(*) FROM quadrant_pair"))[0][0] == 0
conn.close()
def test_clear_step_rows_missing_tables_is_noop(tmp_path) -> None:
"""gate_evidence/quadrant_pair 表尚未建(首个 step)时不报错。"""
db = tmp_path / "harness.db"
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE predictions (run_id TEXT)")
conn.execute("CREATE TABLE traces (run_id TEXT)")
conn.commit()
conn.close()
_clear_step_rows(str(db), baseline_run_id="infer_adhoc", epoch=1, step=0)
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_step_rerun_idempotent.py -x -q`
Expected: FAIL(ImportError: cannot import name '_clear_step_rows')
- [ ] **Step 3: 实现 `_clear_step_rows`(runner.py 模块级函数)并替换 `_run_step` 内清行块**
新函数(放在 `_write_skip_report` 附近的模块级函数区):
```python
def _clear_step_rows(db_path: str, *, baseline_run_id: str, epoch: int, step: int) -> None:
"""清空一个 step 的全部旧行(rollout + gate 派生),保证崩溃重跑幂等。
修复前序潜伏 bug:旧实现只清 rollout run_id,gate 派生 run_id
(`{step_run_id}_gate_%`)从不清理,重跑会累积重复 predictions(HarnessLog
无主键去重),_load_run_rows 的 dict 覆盖使结果依赖 SELECT 顺序。
gate_evidence / quadrant_pair 以 (run_id, epoch, step) 过滤删除;
表不存在(首个 step)时跳过。step_report 为按文件名覆盖写的 JSON,天然幂等。
"""
from app.harness.inference import PREDICTIONS_SCHEMA, TRACES_SCHEMA
from app.harness.log import HarnessLog
step_run_id = f"{baseline_run_id}_e{epoch}_s{step}"
with HarnessLog(db_path, step_run_id, register_run=False) as log:
log.create_table("predictions", PREDICTIONS_SCHEMA)
log.create_table("traces", TRACES_SCHEMA)
for table in ("predictions", "traces"):
log.execute(f"DELETE FROM {table} WHERE run_id=?", (step_run_id,))
# ESCAPE 显式声明,防 run_id 中出现 '_' 通配歧义(_gate_ 前缀含字面下划线,
# LIKE 的 '_' 单字符通配在此无害但语义须钉死为字面匹配 + '%' 后缀)
log.execute(
f"DELETE FROM {table} WHERE run_id LIKE ? ESCAPE '\\'",
(step_run_id.replace("_", r"\_") + r"\_gate\_%",),
)
for table in ("gate_evidence", "quadrant_pair"):
exists = log.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
)
if exists:
log.execute(
f"DELETE FROM {table} WHERE run_id=? AND epoch=? AND step=?",
(baseline_run_id, epoch, step),
)
```
`_run_step` 内原 `with HarnessLog(...) as log: ... DELETE ...` 五行(runner.py:1105-1109)替换为一行:
```python
_clear_step_rows(
str(self._paths.db_path),
baseline_run_id=pools.baseline_run_id, epoch=epoch, step=step,
)
```
- [ ] **Step 4: 跑测试确认通过 + 回归**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_step_rerun_idempotent.py tests/unit -x -q`
Expected: 全绿(重点:runner 相关既有测试不破)
- [ ] **Step 5: Commit**
```bash
git add app/harness/runner.py tests/unit/test_step_rerun_idempotent.py
git commit -m "fix: clear gate-derived rows on step rerun (idempotency)"
```
---
### Task 5: runner 重接线 —— 并行进化 + 装配 + 连续 gate + 字母序落账
**Files:**
- Modify: `app/harness/runner.py`(`_gate_batch_skills`:1143-1252 重写;`_run_gate_validation`:1254-1334 删除;`_make_validate_run_inference_fn` 改共享 log)
- Test: `tests/unit/test_gate_batch_parallel.py`(新建)
- [ ] **Step 1: 写失败测试**(target_file 冲突 fail-fast 是纯逻辑,可直接测)
```python
"""_gate_batch_skills 并行装配的纯逻辑护栏测试。"""
from __future__ import annotations
import pytest
from app.harness.runner import _assert_disjoint_target_files
def test_disjoint_target_files_pass() -> None:
_assert_disjoint_target_files(
{"Action Reasoning": "action-reasoning.md", "Counting Problem": "counting-problem.md"}
)
def test_shared_target_file_fails_fast() -> None:
"""两题型 fallback 到同一文件:并行进化会互相覆盖,必须 fail-fast。"""
with pytest.raises(RuntimeError, match="default-strategy.md"):
_assert_disjoint_target_files(
{"OCR Problems": "default-strategy.md", "Spatial Reasoning": "default-strategy.md"}
)
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_batch_parallel.py -x -q`
Expected: FAIL(ImportError)
- [ ] **Step 3: 实现 runner 侧改造**
3a. 模块级函数(放 `_write_skip_report` 附近):
```python
def _assert_disjoint_target_files(targets_by_type: dict[str, str]) -> None:
"""断言本 step 各题型进化目标文件互不相同(设计 v3 §1 fail-fast)。
题型并行进化 + 并行 gate 的前提是 skill 文件不相交;两题型 fallback 到
同一 default-strategy.md 时并行会互相覆盖候选与 accept,必须显式中止
而非静默串行(当前 12 题型均有专属文件,此断言防未来配置漂移)。
"""
seen: dict[str, str] = {}
for task_type, target in targets_by_type.items():
if target in seen:
raise RuntimeError(
f"题型 {seen[target]!r}{task_type!r} 映射同一 skill 文件 {target!r},"
"并行进化/gate 不支持共享目标文件(设计 v3 §1)"
)
seen[target] = task_type
```
3b. `_make_validate_run_inference_fn` 改为共享单一 HarnessLog(消除 per-unit 新建连接的锁竞争,对齐单连接+锁模式):
```python
def _make_validate_run_inference_fn(self, gate_log: HarnessLog):
"""构造 validate 用的 RunInferenceFn(绑定共享依赖与共享 HarnessLog)。
连续并发 gate 下本函数被逐单元高频并发调用:每次调用新建 HarnessLog
连接会重现多连接争 SQLite 写锁(遥测同款教训),故复用调用方传入的
单一 gate_log(单连接 + threading.Lock 串行化)。_record_run 按 run_id
去重,避免逐单元重复 upsert。
"""
from app.harness.inference import run_inference
recorded: set[str] = set()
async def _run(
questions: list[GeneratedQuestion],
*,
run_id: str,
skills_dir: Path,
) -> InferenceResult:
if run_id not in recorded:
recorded.add(run_id)
self._record_run(run_id)
return await run_inference(
questions=questions,
llm=self._llm,
tool_dispatch_fn=self._make_tool_dispatch_fn(skills_dir=skills_dir),
prompt_builder=self._make_prompt_builder(
skills_dir=skills_dir, prompts_dir=self._paths.prompts_dir
),
log=gate_log,
run_id=run_id,
concurrency=self._config.concurrency,
max_steps=self._config.max_steps,
skill_mode=self._config.skill_mode,
)
return _run
```
3c. `_gate_batch_skills` 重写为四阶段(替换 1143-1252 的串行 for;`_run_gate_validation` 整个删除,其阶梯装配逻辑收编进 Phase B):
```python
async def _gate_batch_skills(
self,
epoch: int,
step: int,
diagnosis: DiagnosisResult,
total_steps: int,
pools: Pools,
state: _TrainState,
) -> None:
"""按 task_type 并行 evolve → 连续并发 gate → 字母序统一落账。
四阶段(设计 v3 §2.1):Phase A 并行进化(cooldown/无改动照旧跳过);
Phase B 装配 GateSpec(阶梯出题 + 案例单元排除 + n_max 截断);
Phase C validate_skills_concurrent(共享题槽,统计按阶梯序前缀);
Phase D 唯一写 state 阶段——按字母序 accept/reject 落账,与原串行
语义等价(题型 skill 文件不相交,合并顺序仅为确定性)。
"""
from app.harness.workspace import VersionedSkillStore
from core.evolution import evolve_single_skill
budget = edit_budget_at(
global_step=state.global_step,
total_steps=total_steps,
start=self._config.edit_budget_start,
end=self._config.edit_budget_end,
)
# ---- Phase A: 并行进化(冷却/跳过路径先出清) ----
active_types: list[str] = []
for task_type in sorted(diagnosis.skill_case_packs):
if state.gate_cooldown.get(task_type, 0) > 0:
_write_skip_report(
self._config.workspace_dir, epoch, step, state.global_step,
task_type, action="cooldown",
baseline_acc=self._class_baseline_acc(
task_type, pools.validation, state.correctness
),
budget=budget,
)
continue
active_types.append(task_type)
if not active_types:
return
evolve_prompts = self._load_evolve_prompts()
skills_version = self._current_version("skills")
async def _evolve_one(task_type: str) -> EvolutionRecord:
pack = diagnosis.skill_case_packs[task_type]
skill_store = VersionedSkillStore(self._paths.skills_dir)
return await evolve_single_skill(
self._evolve_llm, pack, skill_store, evolve_prompts,
skills_version, budget, self._config.appendix_consolidate_threshold,
skill_update_mode=self._config.skill_update_mode,
rejected=state.rejected_buffer.get(task_type, []),
)
records = dict(
zip(
active_types,
await asyncio.gather(*[_evolve_one(t) for t in active_types]),
strict=True,
)
)
# 无真实改动的题型照旧写 skipped 后出队
gated_types: list[str] = []
for task_type in active_types:
record = records[task_type]
if record.status in ("rejected", "skipped") or (
record.evolved_content == record.original_content
):
_write_skip_report(
self._config.workspace_dir, epoch, step, state.global_step,
task_type, action="skipped",
baseline_acc=self._class_baseline_acc(
task_type, pools.validation, state.correctness
),
budget=budget,
rank_clip_triggered=bool(record.clip_info.get("triggered", False)),
)
continue
gated_types.append(task_type)
if not gated_types:
return
_assert_disjoint_target_files({t: records[t].target_file for t in gated_types})
# ---- Phase B: 装配 GateSpec(阶梯出题,收编原 _run_gate_validation 前半) ----
specs: list[GateSpec] = []
for task_type in gated_types:
record = records[task_type]
pack = diagnosis.skill_case_packs[task_type]
exclude_units = {
self._gate_questions_by_id[c.question_id].unit_id
for c in pack.failure_cases + pack.success_cases
if c.question_id in self._gate_questions_by_id
}
ladder_unit_ids = state.gate_pools.ladder_for(
task_type,
exclude_units,
p_low=self._config.gate_p_low,
p_high=self._config.gate_p_high,
cold=not state.gate_epoch_observed,
)
missing = [uid for uid in ladder_unit_ids if uid not in self._gate_units_by_id]
if missing:
raise RuntimeError(
f"gate 阶梯引用未知 unit: {missing[:5]}(gate_pools.json 与题库失配)"
)
ladder_items = [
q for uid in ladder_unit_ids for q in self._gate_units_by_id[uid].questions
]
slug = task_type.lower().replace(" ", "-")
specs.append(
GateSpec(
task_type=task_type,
target_file=record.target_file,
candidate_content=record.evolved_content,
base_skill_content=(
self._paths.skills_dir / record.target_file
).read_text(encoding="utf-8"),
units=_ladder_units(ladder_items)[: self._config.gate_n_max],
gate_run_prefix=f"{pools.baseline_run_id}_e{epoch}_s{step}_gate_{slug}",
)
)
# ---- Phase C: 连续并发 gate(只读 state) ----
with HarnessLog(str(self._paths.db_path), f"gate_e{epoch}_s{step}") as gate_log:
outcomes = await validate_skills_concurrent(
workspace_dir=self._config.workspace_dir,
base_skills_version=self._current_version("skills"),
specs=specs,
gate_params=GateParams(
e_confirm=self._config.gate_e_confirm,
e_provisional=self._config.gate_e_provisional,
w_net_min=self._config.gate_w_net_min,
delta_min=self._config.gate_delta_min,
lambda_dir=self._config.gate_lambda_dir,
e_rollback=self._config.gate_e_rollback,
),
gate_guard_err=self._config.gate_guard_err,
baseline_cache=state.baseline_cache,
prompts_version=self._current_version("prompts"),
run_inference=self._make_validate_run_inference_fn(gate_log),
log=gate_log,
concurrency=self._config.concurrency,
)
# ---- Phase D: 唯一写 state 阶段(字母序确定性落账) ----
for task_type in sorted(outcomes):
record = records[task_type]
outcome = outcomes[task_type]
write_gate_evidence(
str(self._paths.db_path),
run_id=pools.baseline_run_id, epoch=epoch, step=step,
rows=outcome.evidence_rows,
)
write_step_report(
self._config.workspace_dir,
epoch=epoch, step=step, global_step=state.global_step,
task_type=task_type, gate_action=outcome.action,
candidate_acc=outcome.candidate_acc,
class_baseline_acc=outcome.baseline_acc,
edit_budget=budget,
rank_clip_triggered=bool(record.clip_info.get("triggered", False)),
gate_w=outcome.w, gate_l=outcome.l, gate_e_value=outcome.e_value,
gate_n_used=outcome.n_used, gate_stop_reason=outcome.stop_reason,
)
write_quadrant_pairs(
str(self._paths.db_path),
run_id=pools.baseline_run_id, epoch=epoch, step=step,
pairs=_outcome_to_quadrant_pairs(task_type, outcome),
)
if outcome.accepted:
self._accept_skill(task_type, record, outcome, state, pools)
else:
self._record_rejected_skill(
state.rejected_buffer, task_type, record, outcome, state.global_step
)
```
导入区补:`from app.harness.validate import GateSpec, _ladder_units, validate_skills_concurrent`(与既有 validate 导入合并;`_ladder_units` 若被视为私有,可在 validate.py 里改名 `ladder_units` 导出并同步旧调用点)。
**语义等价性注意(实现者必读):**
- 原串行版中,题型 B 的 `base_skill_content` / `base_skills_version` 读的是"A accept 之后"的版本;新版全部读 step 起点版本。设计 v3 §1 已论证内容等价(文件不相交)+ 用户批准。**但** `_accept_skill` 内部按当前 manifest 版本推进——Phase D 串行调用时每次 accept 后版本前移,第二个 accept 基于第一个 accept 后的版本追加自己的 target_file 内容,文件不相交所以互不覆盖。逐一确认 `_accept_skill`(runner.py:1340 起)满足此性质:它以 `record.evolved_content``target_file`,复制其余文件自当前版本 → 满足。
- `gate_epoch_observed` / cooldown 递减等 step 级状态,原逻辑不在本函数内改动的,保持不动。
- [ ] **Step 4: 跑测试 + 全量回归**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_batch_parallel.py tests/unit -x -q`
Expected: 新测试过;既有 runner/gate 测试中直接调用 `_run_gate_validation``validate_skill_local` 的会失败——**属预期**,Task 6 处理;本步允许用 `--deselect` 记录清单但不得改产品代码迁就旧测试。
- [ ] **Step 5: Commit**
```bash
git add app/harness/runner.py tests/unit/test_gate_batch_parallel.py
git commit -m "feat: parallel evolve + continuous gate wiring in runner (algo #6)"
```
---
### Task 6: 删除旧块路径 + gate_block 全线移除 + 旧测试迁移
**Files:**
- Modify: `app/harness/validate.py`(删 `_run_local_validation``validate_skill_local``_resolve_baseline_block``_run_candidate_block``_build_evidence_rows`)
- Modify: `app/harness/config.py:73,128,372-375`(删 gate_block 字段/文档/校验;校验改为 `gate_n_max > 0`)
- Modify: `app/harness/checkpoint.py:61`(从字段清单删 `"gate_block"`)
- Modify: `config/*.yaml` 全部含 `gate_block:` 的文件(train_videomme / default / question_gen_180_补 / question_gen_360 / train_action_recognition / train_ar30)删该行
- Modify/Delete: `tests/unit/test_gate_block_unit.py``tests/unit/test_harness_validate.py` 等引用被删符号的测试
- [ ] **Step 1: 全局定位被删符号的引用**
Run: `grep -rn "validate_skill_local\|_run_local_validation\|_resolve_baseline_block\|_run_candidate_block\|_build_evidence_rows\|gate_block" app/ core/ config/ tests/ main.py --include="*.py" --include="*.yaml"`
逐一处置:产品代码引用应已在 Task 5 清零(若有残留即 Task 5 遗漏,回去补);测试引用见 Step 2。
- [ ] **Step 2: 迁移旧测试**
原则:**测的语义保留,测的载体更新**。
- 纯配对/四象限/单元折叠测试(`pair_block`/`classify_quadrants`/`unit_correctness_view`,在 test_validate.py 等):不动,它们测 core 纯函数。
- 块循环编排测试(`test_gate_block_unit.py``test_harness_validate.py` 中调 `validate_skill_local` 的用例):改写为经 `validate_skills_concurrent` 的等价断言(单 spec 输入,断言 action/W/L/E/evidence 与原期望一致;块边界断言删除,替换为 ladder_rank 连续性断言)。改写时保持原测试意图注释。
- INFRA 护栏测试:改为构造假推理触发 `_check_infra_guard` 阈值,断言 RuntimeError(语义同前)。
- [ ] **Step 3: 删除产品代码旧路径与 gate_block**
按 Files 清单逐个删除;`config.py` 校验块 372-375 改为:
```python
if config.gate_n_max <= 0:
raise ValueError(f"需 gate_n_max > 0,实际: n_max={config.gate_n_max}")
```
- [ ] **Step 4: 全量回归 + lint**
Run: `conda run -n Video-Tree-TRM pytest tests/ -x -q && conda run -n Video-Tree-TRM ruff check app/ core/ adapters/ && conda run -n Video-Tree-TRM ruff format --check app/ core/ adapters/`
Expected: 全绿、零 lint 错误。覆盖率:`conda run -n Video-Tree-TRM pytest tests/ --cov=app --cov=core -q | tail -3` ≥ 80%。
- [ ] **Step 5: Commit**
```bash
git add -u app/ config/ tests/
git commit -m "refactor: remove block-sequential gate path and gate_block knob (algo #6)"
```
---
### Task 7: Redis TTL + 收尾验证
**Files:**
- Modify: `.env`(仅 `REDIS_CACHE_TTL` 一行,**严禁**触碰其他行)
- Modify: `.env.example`(同键同值,保持模板同步)
- [ ] **Step 1: 改 TTL**
```bash
sed -i 's/^REDIS_CACHE_TTL=86400$/REDIS_CACHE_TTL=604800/' .env
sed -i 's/^REDIS_CACHE_TTL=.*/REDIS_CACHE_TTL=604800/' .env.example
grep -n "^REDIS_CACHE_TTL=" .env .env.example
```
Expected: 两文件均为 604800。
- [ ] **Step 2: 全量测试终验**
Run: `conda run -n Video-Tree-TRM pytest tests/ -q --cov=app --cov=core --cov-report=term-missing | tail -15`
Expected: 全绿,覆盖率 ≥80%。
- [ ] **Step 3: Commit**(.env 不入库,仅 .env.example)
```bash
git add .env.example
git commit -m "chore: raise Redis cache TTL to 7 days"
```
---
## 运维 Runbook(实现完成后、重启训练前,控制器执行,不属实现任务)
1. **一次性续期今日 Redis 键**(scratchpad 脚本,用 dotenv 读 REDIS_URL):
`for k in scan_iter('llm_cache:*'): 0 < ttl(k) < 604800 → expire(k, 604800)`
2. **重启训练**:
`tmux new-session -d -s train_videomme "CUDA_VISIBLE_DEVICES=0 bash scripts/train_videomme.sh 2>&1 | tee logs/train_videomme.log"`
(config 的 run_id 保持 `train_videomme_v2`;rollout/诊断的 Redis 盐派生自 `infer_adhoc` 不变 → 已烧调用命中复用)
3. **验证里程碑**:启动检查点(预检剔除 4 题型 / gate 阶梯 8 题型 / Best=0.7167)→ 首 batch rollout(应大量缓存命中,显著快于 27min)→ 首个 step 的连续 gate(观察多题型交错的 `_gate_` run 记录与判定日志)。
## 保真校验记录
- **#4 CE-Gate e-process**:`core/evolution/gate.py` 零改动;`gate_decision` 调用参数语义不变(n_remaining 等价迁移)。✅
- **#5 信息阶梯**:`ladder_for` / `_ladder_units` 排序与截断逻辑零改动;案例单元排除保留。✅
- **#6 块顺序验证**:**已批准语义修订**(设计 v3):块序贯 → 阶梯序前缀逐对序贯;基线缓存、INFRA 护栏、配对翻转等价迁移;本计划 Task 1-3/5-6 涉及,commit 均标注 `(algo #6)`。⚠️ 已声明
- 其余 #1-#3#7-#12:不涉及
## 风险与回滚
- 每 Task 独立 commit,任一集成点失败可 `git revert` 到上一 Task。
- Phase C 若在真实训练暴露调度死锁(理论上 `_QuestionSlots` 的锁序已防),现象为 gate 阶段静默无推理调用 → 立即 `tmux kill` + 回滚 Task 5/6 的 commit,退回块序贯版本重启(旧路径在 Task 6 前仍在)。