7e97081779
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 默认序显式区分(防阶梯序被污染)。
319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""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:
|
||
"""构建最小 workspace(skills/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、3;single 分居 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_id:pair 用 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 分母按 unit(2 单元,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_id,single→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()
|