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

59 KiB
Raw Blame History

连续并发 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 日志
  • 现有函数复用,不重写。路径钉死(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_skill
    • app/harness/question_units.py:unit_correctness_view / build_units / flatten_units
    • core/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_COLSblock_idxladder_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_contenttarget_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_validationvalidate_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_validationvalidate_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.pytests/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.pytest_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(实现完成后、重启训练前,控制器执行,不属实现任务)

  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 前仍在)。