Files
Video-Tree-TRM5/tests/unit/test_gate_block_unit.py
T
iomgaa 7e97081779 test(harness): 补 _ladder_units 直测 + 澄清 gate 观测表 unit_id 口径注释
M1:quadrant_pair / gate_evidence 的 question_id 列注释与 write_* docstring
更正为承载 unit_id(single=question_id、pair=pair_id),提示逐题明细在
predictions 表溯源、按 pair_id join 真实 question 表会 join 不上。

M2:给 _ladder_units 补直接单测——纯非 AR 恒等(unit 序==原题序、
unit_id==question_id)、混格交错保持信息阶梯序(按单元最早出现下标重排、
pair 整锁)、且与 build_units 的 single-first 默认序显式区分(防阶梯序被污染)。
2026-07-15 07:40:36 -04:00

319 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""tests/unit/test_gate_block_unit.py — gate 块实际执行路径按 unit 跑。
针对 app/harness/validate.py::validate_skill_local(真实 gate 执行路径),
断言混格阶梯下 gate 块按 unit 口径运行:baseline_cache 键含 unit_id、
n_used 按 unit 累加、pair_block 折叠 AR pair、逐题 predictions 仍溯源。
核心算法保真 #5(信息阶梯 e-process 口径从 question_id 迁至 unit_id)。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from app.harness.gate_ladder import BaselineCache, skill_hash
from app.harness.inference import PREDICTIONS_SCHEMA, InferenceResult
from app.harness.log import HarnessLog
from app.harness.validate import _ladder_units, validate_skill_local
from core.evolution import GateParams
from core.types import GeneratedQuestion
if TYPE_CHECKING:
from pathlib import Path
_DEFAULT_GATE_PARAMS = GateParams(
e_confirm=20.0,
e_provisional=3.0,
w_net_min=2,
delta_min=0.05,
lambda_dir=-2.0,
e_rollback=10.0,
)
def _single(qid: str) -> GeneratedQuestion:
"""构造非 AR single 题。"""
return GeneratedQuestion(
question_id=qid,
video_id=f"v_{qid}",
task_type="temporal",
question="Q?",
options=("A", "B", "C", "D"),
answer="A",
source_nodes=(),
difficulty="easy",
)
def _pair(pair_id: str) -> list[GeneratedQuestion]:
"""构造 AR 孪生对(original + mirror,共享 pair_id)。"""
common = {
"video_id": f"v_{pair_id}",
"task_type": "temporal",
"question": "Q?",
"options": ("A", "B", "C", "D"),
"answer": "A",
"source_nodes": (),
"difficulty": "easy",
"pair_id": pair_id,
"flip_axis": "before_after",
}
return [
GeneratedQuestion(question_id=f"{pair_id}_o", question_role="pair_original", **common),
GeneratedQuestion(question_id=f"{pair_id}_m", question_role="pair_mirror", **common),
]
def _setup_workspace(tmp_path: Path) -> Path:
"""构建最小 workspaceskills/v1/temporal.md)。"""
skills_dir = tmp_path / "skills" / "v1"
skills_dir.mkdir(parents=True)
(skills_dir / "temporal.md").write_text("baseline skill content", encoding="utf-8")
return tmp_path
def _make_log(workspace: Path, run_id: str = "test_master") -> HarnessLog:
"""创建 HarnessLog 并建 predictions 表。"""
log = HarnessLog(str(workspace / "harness.db"), run_id)
log.create_table("predictions", PREDICTIONS_SCHEMA)
return log
def _insert_predictions(log: HarnessLog, run_id: str, per_q: dict[str, bool]) -> None:
"""逐题写 predictions(溯源仍逐题)。"""
for qid, correct in per_q.items():
log.insert(
"predictions",
{
"run_id": run_id,
"video_id": "v0",
"question_id": qid,
"task_type": "temporal",
"prediction": "A" if correct else "Z",
"answer": "A",
"evidence": "",
"reasoning": "",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 10,
"stop_reason": "completed",
"steps_json": "[]",
},
)
def _make_mock_run_inference(
log: HarnessLog,
baseline_correct: dict[str, bool],
candidate_correct: dict[str, bool],
):
"""构建 mock RunInferenceFn,按 run_id 的 arm 后缀选基线/候选逐题对错。"""
call_log: list[dict[str, Any]] = []
async def mock_fn(
questions: list[GeneratedQuestion],
*,
run_id: str,
skills_dir: Path,
) -> InferenceResult:
src = baseline_correct if run_id.endswith("_base") else candidate_correct
per_q = {q.question_id: src.get(q.question_id, False) for q in questions}
_insert_predictions(log, run_id, per_q)
call_log.append({"run_id": run_id, "qids": [q.question_id for q in questions]})
total = len(questions)
return InferenceResult(
run_id=run_id,
accuracy=sum(per_q.values()) / total if total else 0.0,
total=total,
correct=sum(per_q.values()),
per_task_type={},
steps_mean=1.0,
token_usage={"prompt_tokens": 10, "completion_tokens": 10},
stop_reason_counts={"completed": total},
)
return mock_fn, call_log
class TestLadderUnits:
"""_ladder_units:阶梯题序聚合为单元并保持信息阶梯序。"""
def test_pure_non_ar_is_identity(self) -> None:
"""纯非 AR 输入 → 单元序恒等(unit 序==原题序、unit_id==question_id)。"""
ladder = [_single("s0"), _single("s1"), _single("s2")]
units = _ladder_units(ladder)
assert [u.kind for u in units] == ["single", "single", "single"]
# unit_id 逐一等于原 question_id,且顺序与输入完全一致
assert [u.unit_id for u in units] == ["s0", "s1", "s2"]
assert [q.question_id for u in units for q in u.questions] == ["s0", "s1", "s2"]
def test_mixed_preserves_ladder_order(self) -> None:
"""混格交错输入 → 按单元最早出现下标重排,pair 整锁、不被 single-first 污染。"""
p1o, p1m = _pair("p1")
# 交错布置:pair 两成员分居 idx 1、3single 分居 idx 0、2、4
ladder = [_single("s0"), p1o, _single("s1"), p1m, _single("s2")]
units = _ladder_units(ladder)
# 最早出现下标:s0=0, p1=min(1,3)=1, s1=2, s2=4 → 阶梯序 [s0,p1,s1,s2]
assert [u.unit_id for u in units] == ["s0", "p1", "s1", "s2"]
# pair 整锁为一个单元(含两成员),不被拆
p1_unit = next(u for u in units if u.unit_id == "p1")
assert p1_unit.kind == "pair"
assert {q.question_id for q in p1_unit.questions} == {"p1_o", "p1_m"}
def test_mixed_differs_from_build_units_default(self) -> None:
"""混格重排必须纠正 build_units 的 single-first 顺序(否则阶梯序被污染)。"""
from app.harness.question_units import build_units
p1o, p1m = _pair("p1")
ladder = [_single("s0"), p1o, _single("s1"), p1m, _single("s2")]
default_order = [u.unit_id for u in build_units(ladder)]
ladder_order = [u.unit_id for u in _ladder_units(ladder)]
# build_units 把 pair 排到 single 之后;_ladder_units 恢复信息阶梯序
assert default_order == ["s0", "s1", "s2", "p1"]
assert ladder_order == ["s0", "p1", "s1", "s2"]
assert ladder_order != default_order
@pytest.mark.asyncio
async def test_gate_n_used_counts_units_not_questions(tmp_path: Path) -> None:
"""混格阶梯(1 pair + 2 single)→ n_used=3 单元,非 4 题。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
cache = BaselineCache(workspace / "baseline_cache.json")
ladder = [*_pair("p1"), _single("s0"), _single("s1")]
# 基线全错、候选全对 → 3 单元齐翻 W=3
baseline = {"p1_o": False, "p1_m": False, "s0": False, "s1": False}
candidate = {"p1_o": True, "p1_m": True, "s0": True, "s1": True}
mock_fn, call_log = _make_mock_run_inference(log, baseline, candidate)
accept_params = GateParams(
e_confirm=15.0,
e_provisional=3.0,
w_net_min=2,
delta_min=0.05,
lambda_dir=-2.0,
e_rollback=10.0,
)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="improved skill",
base_skill_content="baseline skill content",
ladder_items=ladder,
gate_params=accept_params,
gate_block=10,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
# n_used 按 unit 计(3),W 按 unit 计(3
assert outcome.n_used == 3
assert outcome.w == 3
assert outcome.l == 0
# 证据行按 unit 口径(3 行)
assert len(outcome.evidence_rows) == 3
# baseline_cache 键含 unit_idpair 用 pair_id、single 用 question_id
s_hash = skill_hash("baseline skill content")
assert cache.get("temporal", s_hash, "p1", "p1") is False
assert cache.get("temporal", s_hash, "p1", "s0") is False
# 逐题 question_id 不作为 baseline_cache 键(pair 成员未单独缓存)
assert cache.get("temporal", s_hash, "p1", "p1_o") is None
# 逐题 predictions 仍溯源:候选 per-q 含两 pair 成员
assert set(outcome.candidate_correctness) >= {"p1_o", "p1_m", "s0", "s1"}
finally:
log.close()
@pytest.mark.asyncio
async def test_gate_pair_partial_flip_not_counted(tmp_path: Path) -> None:
"""AR pair 候选仅单向翻(T,F)→单元仍错,W 不被单题污染。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
cache = BaselineCache(workspace / "baseline_cache.json")
ladder = [*_pair("p1"), _single("s0")]
baseline = {"p1_o": False, "p1_m": False, "s0": False}
# pair 只翻一半(p1_o 对、p1_m 错)→ 单元 AND 仍错;s0 翻对
candidate = {"p1_o": True, "p1_m": False, "s0": True}
mock_fn, _ = _make_mock_run_inference(log, baseline, candidate)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="improved skill",
base_skill_content="baseline skill content",
ladder_items=ladder,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=10,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
# 只有 s0 单元翻转,pair 单元不计 W(保真 #5:不被 P/Q 单题污染)
assert outcome.w == 1
assert outcome.l == 0
assert outcome.n_used == 2
# candidate_acc 分母按 unit2 单元,1 对)→ 0.5
assert outcome.candidate_acc == 0.5
finally:
log.close()
@pytest.mark.asyncio
async def test_gate_baseline_cache_hit_by_unit(tmp_path: Path) -> None:
"""基线缓存按 unit_id 预填充 → 基线侧全命中不发起推理。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
cache = BaselineCache(workspace / "baseline_cache.json")
ladder = [*_pair("p1"), _single("s0")]
s_hash = skill_hash("baseline skill content")
# 按 unit_id 预填充(pair→pair_idsingle→question_id),全错
cache.put("temporal", s_hash, "p1", "p1", False)
cache.put("temporal", s_hash, "p1", "s0", False)
baseline = {"p1_o": False, "p1_m": False, "s0": False}
candidate = {"p1_o": True, "p1_m": True, "s0": True}
mock_fn, call_log = _make_mock_run_inference(log, baseline, candidate)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="improved skill",
base_skill_content="baseline skill content",
ladder_items=ladder,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=10,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
base_calls = [c for c in call_log if c["run_id"].endswith("_base")]
assert base_calls == [], "unit 键全命中不应发起基线推理"
assert outcome.n_used == 2
finally:
log.close()