59 KiB
连续并发 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-TRMconda 环境:conda run -n Video-Tree-TRM pytest ... - 禁止
except Exception: pass;中文 docstring;loguru 日志 - 现有函数复用,不重写。路径钉死(Codex I4:勿在 core 层找它们):
app/harness/validate.py:_ladder_units/_load_run_rows/_infra_question_ids_from_db/_candidate_correctness_from_db/_count_infra_units/_check_infra_guard/_finalize_outcome/materialize_candidate_skillapp/harness/question_units.py:unit_correctness_view/build_units/flatten_unitscore/evolution(纯函数,零改动):pair_block/gate_decision/classify_quadrants
- 每个 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: 写失败测试(核心不变量:乱序到达下统计严格按阶梯序)
"""连续并发 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:
"""过线即冻结,后续已配齐单元不再消费。
数值:W 连胜 L=0 时 E=(2^(W+1)-1)/(W+1),W=6→18.14<20,W=7→31.875≥20,
故 7 连胜恰好 confirmed 过线(Codex 复核)。
"""
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 == 7 # 第 7 个净胜恰好过线,早停不吃满
def test_tail_infra_reaches_terminal_not_continue() -> None:
"""尾部全 INFRA:剔除后须重判(n_remaining 归 0 → 题尽第四出口),
verdict 不得停留在 continue(Codex plan 审 C1 回归锁)。"""
run = _mk_run(4)
run.slots[0].base = False
run.slots[0].cand_per_q = {"q0": True}
run.slots[1].base = True
run.slots[1].cand_per_q = {"q1": True}
for i in (2, 3):
run.slots[i].base_infra = True
run.slots[i].cand_per_q = {f"q{i}": True}
_advance_prefix(run, _PARAMS)
assert run.verdict is not None and run.verdict.decision != "continue"
assert run.frozen
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
# 精确锁定 futility 出口:首个消费后 W=L=0,n_remaining=2,
# 乐观 E=E(2,0)=(2^3-1)/3=2.33<3 → 立即 reject_futility(Codex 复核)
assert run.verdict is not None and run.verdict.decision == "reject_futility"
assert run.n_used == 1 and 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,旧块循环暂不删)
# ---------------------------------------------------------------------------
# 连续并发 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
# 剔除使 n_remaining 缩小,必须重判(Codex plan 审 C1):否则尾部全 INFRA
# 时 verdict 停留在 "continue",绕过题尽第四出口且 _finalize_outcome
# 查 stop_reason 映射 KeyError。n_used==0(纯前导 INFRA)时无证据可判,跳过。
if run.n_used > 0:
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
)
if run.verdict.decision != "continue":
run.frozen = True
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: 6 passed
- Step 5: Commit
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: 写失败测试
"""单元臂执行任务测试:缓存命中/新鲜跑/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
@pytest.mark.asyncio
async def test_question_slots_rejects_oversized_request() -> None:
"""申请槽数超宽度:fail-fast ValueError 而非自死锁(Codex C2 回归锁)。"""
slots = _QuestionSlots(1)
with pytest.raises(ValueError, match="自死锁"):
await slots.acquire(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 追加)
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._width = width
self._sem = asyncio.Semaphore(width)
self._acquire_lock = asyncio.Lock()
async def acquire(self, n: int) -> None:
"""原子获取 n 个题槽。
fail-fast:n > 宽度时任务持锁等待永不满足的槽位 → 自死锁
(AR pair 单元 2 题 + width=1 的病态配置,Codex plan 审 C2),直接报错。
"""
if n > self._width:
raise ValueError(f"单次申请题槽 {n} 超过并发宽度 {self._width},将自死锁")
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:
# 单元级去重(Codex plan 审 I3):同一单元双臂都 INFRA 只计 1 个 error,
# 与设计 §2.3"分子=INFRA 单元数(任一臂)"及旧块实现口径一致
# (旧实现 cand 不跑 base-INFRA 单元,天然无双计)。
if not slot.excluded():
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: 12 passed
- Step 5: Commit
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: 写失败测试
"""连续并发 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 追加)
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: 15 passed
注意:test_out_of_order_arrival_still_ladder_order 里 cand 臂识别依赖 run_id.endswith("_cand"),与实现的 run_id 命名 {prefix}_{arm} 一致;若失败先核对 run_id 拼接。
- Step 6: Commit
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: 写失败测试
"""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 附近的模块级函数区):
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)替换为一行:
_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
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 是纯逻辑,可直接测)
"""_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"}
)
# ---- runner 级红绿(Codex plan 审 I6):进化并行 + Phase D 字母序 + 共享 gate_log ----
# 实现说明:_gate_batch_skills 是 Runner 方法,构造完整 Runner 成本高。用
# `Runner.__new__(Runner)` 裸实例 + 手工挂属性(_config/_paths/_evolve_llm/
# _gate_questions_by_id/_gate_units_by_id/_current_version/_record_run/
# _class_baseline_acc/_accept_skill/_record_rejected_skill 等按 AttributeError
# 逐个补假实现),monkeypatch 以下三点后调用 _gate_batch_skills:
# 1. core.evolution.evolve_single_skill → 假实现:记录 (task_type, 开始/结束时间),
# 各 sleep 0.05s;断言两题型的时间窗重叠(证明 gather 并行而非串行)。
# 2. app.harness.runner.validate_skills_concurrent → 假实现:记录收到的 specs 与
# log 参数,返回两题型的假 ValidationOutcome(一 accept 一 reject)。
# 3. runner._accept_skill / _record_rejected_skill → 假实现:append 到调用序列表;
# 断言调用顺序 == sorted(题型)(Phase D 字母序),且 accept/reject 分派正确。
# 若挂属性成本失控(超过 ~60 行 fixture),降级为拆出可测纯函数并在 PR 说明,
# 但字母序与并行两条断言不得省略。
- 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 附近):
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 新建连接的锁竞争,对齐单连接+锁模式):
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):
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
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等引用被删符号的测试 -
Modify:
tests/integration/test_checkpoint_pair.py:128(含gate_block,Codex I7;grep 范围必须覆盖 tests/integration/ 与 tests/e2e/) -
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 改为:
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
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
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)
git add .env.example
git commit -m "chore: raise Redis cache TTL to 7 days"
运维 Runbook(实现完成后、重启训练前,控制器执行,不属实现任务)
- 一次性续期今日 Redis 键(scratchpad 脚本,用 dotenv 读 REDIS_URL):
for k in scan_iter('llm_cache:*'): 0 < ttl(k) < 604800 → expire(k, 604800) - 重启训练:
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不变 → 已烧调用命中复用) - 验证里程碑:启动检查点(预检剔除 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 前仍在)。