Files
Video-Tree-TRM5/tests/unit/test_harness_validate.py
T

555 lines
19 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_harness_validate.py — app/harness/validate.py 的单元测试。
覆盖:数据类型字段、materialize 物化与清理、async validate_skill_local
accept/reject/prefix 校验/INFRA 护栏/缓存命中/最后一块终态)。
"""
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 (
Probation,
ValidationOutcome,
materialize_candidate_skill,
validate_skill_local,
)
from core.evolution import GateParams, RejectedEdit
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 _make_questions(
n: int,
task_type: str = "temporal",
prefix: str = "q",
) -> list[GeneratedQuestion]:
"""生成 n 个测试用 GeneratedQuestion。"""
return [
GeneratedQuestion(
question_id=f"{prefix}{i}",
video_id=f"v{i}",
task_type=task_type,
question=f"Question {i}?",
options=("A", "B", "C", "D"),
answer="A",
source_nodes=(),
difficulty="easy",
)
for i in range(n)
]
def _setup_workspace(tmp_path: Path) -> Path:
"""在 tmp_path 下构建最小 workspace 结构。"""
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 表。"""
db_path = workspace / "harness.db"
log = HarnessLog(str(db_path), run_id)
log.create_table("predictions", PREDICTIONS_SCHEMA)
return log
def _insert_predictions(
log: HarnessLog,
run_id: str,
correctness: dict[str, bool],
answer: str = "A",
) -> None:
"""向 predictions 表插入指定 run_id 的逐题预测记录。
通过在 record 中显式传入 run_id 覆盖 log 的默认 run_id。
"""
for qid, correct in correctness.items():
prediction = answer if correct else "Z"
log.insert(
"predictions",
{
"run_id": run_id,
"video_id": "v0",
"question_id": qid,
"task_type": "temporal",
"prediction": prediction,
"answer": answer,
"evidence": "",
"reasoning": "",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 10,
"stop_reason": "completed",
"steps_json": "[]",
},
)
def _make_mock_run_inference(
log: HarnessLog,
baseline_correctness: dict[str, bool],
candidate_correctness: dict[str, bool],
error_count: int = 0,
):
"""构建 mock RunInferenceFn。
根据 run_id 中的 arm 标记(_base / _cand)决定使用基线或候选对错映射,
将预测写入 log 的同一 DB,返回 InferenceResult。
"""
call_log: list[dict[str, Any]] = []
async def mock_fn(
questions: list[GeneratedQuestion],
*,
run_id: str,
skills_dir: Path,
) -> InferenceResult:
is_baseline = run_id.endswith("_base")
correctness = baseline_correctness if is_baseline else candidate_correctness
call_log.append({"run_id": run_id, "skills_dir": skills_dir, "n": len(questions)})
per_q = {q.question_id: correctness.get(q.question_id, False) for q in questions}
_insert_predictions(log, run_id, per_q)
correct = sum(per_q.values())
total = len(questions)
stop_counts: dict[str, int] = {"completed": total - error_count}
if error_count > 0:
stop_counts["error"] = error_count
return InferenceResult(
run_id=run_id,
accuracy=correct / total if total else 0.0,
total=total,
correct=correct,
per_task_type={},
steps_mean=1.0,
token_usage={"prompt_tokens": 10, "completion_tokens": 10},
stop_reason_counts=stop_counts,
)
return mock_fn, call_log
# ===========================================================================
# 数据类型测试
# ===========================================================================
class TestValidationOutcomeFields:
"""ValidationOutcome 数据类型字段完整性测试。"""
def test_validation_outcome_fields(self) -> None:
"""所有字段可构造、默认值合理。"""
outcome = ValidationOutcome(
action="accept_confirmed",
accepted=True,
stop_reason="confirmed",
e_value=25.0,
w=5,
l=1,
n_used=10,
delta_hat=0.4,
delta_shrunk=0.3,
baseline_acc=0.6,
candidate_acc=0.9,
)
assert outcome.action == "accept_confirmed"
assert outcome.accepted is True
assert outcome.stop_reason == "confirmed"
assert outcome.e_value == 25.0
assert outcome.w == 5
assert outcome.l == 1
assert outcome.n_used == 10
assert outcome.delta_hat == 0.4
assert outcome.delta_shrunk == 0.3
assert outcome.baseline_acc == 0.6
assert outcome.candidate_acc == 0.9
assert outcome.improvements == []
assert outcome.regressions == []
assert outcome.persistent_fails == []
assert outcome.stable_successes == []
assert outcome.candidate_correctness == {}
assert outcome.evidence_rows == []
class TestProbationFields:
"""Probation 数据类型字段完整性测试。"""
def test_probation_fields(self) -> None:
"""所有字段可构造、pending_edits 默认空列表。"""
prob = Probation(
task_type="temporal",
anchor_skills_version="v1",
target_file="temporal.md",
correctness_snapshot={"q0": True, "q1": False},
opened_step=5,
)
assert prob.task_type == "temporal"
assert prob.anchor_skills_version == "v1"
assert prob.target_file == "temporal.md"
assert prob.correctness_snapshot == {"q0": True, "q1": False}
assert prob.opened_step == 5
assert prob.pending_edits == []
def test_probation_with_pending_edits(self) -> None:
"""pending_edits 可附加 RejectedEdit。"""
edit = RejectedEdit(
target_file="temporal.md",
target_type="skill",
change_summary="bad change",
delta=-0.1,
source_version="v2",
epoch=1,
)
prob = Probation(
task_type="temporal",
anchor_skills_version="v1",
target_file="temporal.md",
correctness_snapshot={},
opened_step=3,
pending_edits=[edit],
)
assert len(prob.pending_edits) == 1
assert prob.pending_edits[0].change_summary == "bad change"
# ===========================================================================
# materialize 测试
# ===========================================================================
class TestMaterializeCandidateSkill:
"""materialize_candidate_skill 物化与清理测试。"""
def test_materialize_candidate_skill(self, tmp_path: Path) -> None:
"""正常物化:基线目录被复制,target_file 被覆写为候选内容。"""
workspace = _setup_workspace(tmp_path)
cand_dir = materialize_candidate_skill(
workspace, "v1", "temporal.md", "candidate skill content"
)
try:
assert cand_dir.exists()
assert cand_dir.parent == workspace / ".cand_tmp"
assert (cand_dir / "temporal.md").read_text(encoding="utf-8") == (
"candidate skill content"
)
finally:
import shutil
shutil.rmtree(cand_dir)
def test_materialize_cleanup_on_failure(self, tmp_path: Path) -> None:
"""基线目录不存在时 OSError,临时目录被清理。"""
workspace = tmp_path / "ws"
workspace.mkdir()
# 不创建 skills/v1copytree 应失败
with pytest.raises(OSError):
materialize_candidate_skill(workspace, "v1", "temporal.md", "content")
# .cand_tmp 可能存在但内部应被清理
cand_tmp = workspace / ".cand_tmp"
if cand_tmp.exists():
remaining = list(cand_tmp.iterdir())
assert remaining == [], f"临时目录未被清理: {remaining}"
# ===========================================================================
# async 验证测试
# ===========================================================================
@pytest.mark.asyncio
async def test_validate_skill_local_accept(tmp_path: Path) -> None:
"""候选全对、基线全错 → 高 e 值 → accept_confirmed。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
# 基线全错,候选全对 → W=6, L=0 → E=18.14 → CONFIRMEDe_confirm=15
baseline_correct = {f"q{i}": False for i in range(6)}
candidate_correct = {f"q{i}": True for i in range(6)}
mock_fn, call_log = _make_mock_run_inference(log, baseline_correct, candidate_correct)
# e_confirm=15 使 E=18.14 超过阈值触发 CONFIRMED
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=questions,
gate_params=accept_params,
gate_block=6,
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",
)
assert outcome.accepted is True
assert outcome.action == "accept_confirmed"
assert outcome.stop_reason == "confirmed"
assert outcome.w == 6
assert outcome.l == 0
assert outcome.n_used == 6
assert outcome.candidate_acc == 1.0
assert outcome.baseline_acc == 0.0
assert len(outcome.evidence_rows) == 6
# 终态证据行携带 stop_reason
assert outcome.evidence_rows[-1]["stop_reason"] == "confirmed"
# 候选临时目录应被清理
cand_tmp = workspace / ".cand_tmp"
if cand_tmp.exists():
assert list(cand_tmp.iterdir()) == []
finally:
log.close()
@pytest.mark.asyncio
async def test_validate_skill_local_reject(tmp_path: Path) -> None:
"""候选全错、基线全对 → L 高 → 方向拒绝。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
# 基线全对,候选全错 → W=0, L=6 → 方向拒绝
baseline_correct = {f"q{i}": True for i in range(6)}
candidate_correct = {f"q{i}": False for i in range(6)}
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="bad skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=6,
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",
)
assert outcome.accepted is False
assert outcome.action == "reject"
assert outcome.stop_reason == "directional"
assert outcome.w == 0
assert outcome.l == 6
finally:
log.close()
@pytest.mark.asyncio
async def test_gate_prefix_must_contain_gate(tmp_path: Path) -> None:
"""gate_run_prefix 不含 '_gate_' 时抛 ValueError。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
async def noop_fn(questions, *, run_id, skills_dir):
raise AssertionError("不应被调用")
try:
with pytest.raises(ValueError, match="_gate_"):
await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="content",
base_skill_content="baseline",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=noop_fn,
log=log,
gate_run_prefix="step1_no_marker",
)
finally:
log.close()
@pytest.mark.asyncio
async def test_infra_guard_threshold(tmp_path: Path) -> None:
"""推理错误率超阈值时抛 RuntimeError。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
# 需要 >=10 题次才触发 INFRA 护栏
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
baseline_correct = {f"q{i}": False for i in range(6)}
candidate_correct = {f"q{i}": False for i in range(6)}
# 每次 run_inference 报 error_count=5,两侧各 5 → 10/12 > 0.5
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct, error_count=5)
try:
with pytest.raises(RuntimeError, match="错误率过高"):
await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="content",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=6,
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",
)
finally:
log.close()
@pytest.mark.asyncio
async def test_baseline_cache_hit(tmp_path: Path) -> None:
"""基线缓存全命中时不发起基线侧推理。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
s_hash = skill_hash("baseline skill content")
# 预填充缓存:全部题目基线全错
for q in questions:
cache.put("temporal", s_hash, "p1", q.question_id, False)
# 候选全对 → accept
candidate_correct = {f"q{i}": True for i in range(4)}
baseline_correct = {f"q{i}": False for i in range(4)}
mock_fn, call_log = _make_mock_run_inference(log, baseline_correct, candidate_correct)
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=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
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",
)
# 只有候选侧调用了 run_inference(_cand),基线侧全命中不调用
base_calls = [c for c in call_log if c["run_id"].endswith("_base")]
cand_calls = [c for c in call_log if c["run_id"].endswith("_cand")]
assert len(base_calls) == 0, "基线缓存全命中不应发起推理"
assert len(cand_calls) == 1
assert outcome.accepted is True
finally:
log.close()
@pytest.mark.asyncio
async def test_last_block_terminal(tmp_path: Path) -> None:
"""单块 + n_remaining=0 → 终态判定(provisional 或 inertia),非 continue。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
# 4 题,gate_block=4 → 一块走完,n_remaining=0
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
# 两题翻转(W=2, L=0),但 e_confirm=20 难以达到 → provisional 或 inertia
baseline_correct = {"q0": False, "q1": False, "q2": True, "q3": True}
candidate_correct = {"q0": True, "q1": True, "q2": True, "q3": True}
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="candidate skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
gate_n_max=4,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
# n_remaining=0 → 不可能是 continue
assert outcome.stop_reason in (
"confirmed",
"provisional",
"inertia",
"directional",
"futility",
)
assert outcome.n_used == 4
# 终态行标记 stop_reason
assert outcome.evidence_rows[-1]["stop_reason"] != ""
finally:
log.close()