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

244 lines
8.1 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.
"""训练循环诊断注入:_run_diagnosis 按 batch question_ids 加载真实树注入 run_diagnosis。
覆盖诊断 tree_data 断链修复的训练循环侧(Task 4):验证 runner._run_diagnosis 会把
batch 涉及 video 的真实树作为 tree_data 传给 core.run_diagnosis,而非空 dict。
"""
from __future__ import annotations
import json
from pathlib import Path # noqa: TC003 — 运行时 tmp_path 标注使用
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.harness.config import RunConfig
from app.harness.runner import Runner
from core.evolution.types import DiagnosisResult
# 真实样本:question 604-2 属于 video 0RxMZBLeqRI111 节点树)。
_REAL_QUESTION_ID = "604-2"
_REAL_VIDEO_ID = "0RxMZBLeqRI"
def _empty_diagnosis_result(run_id: str = "infer_adhoc") -> DiagnosisResult:
"""构造仅含 run_id 的空诊断结果(其余字段走 dataclass 默认值)。"""
return DiagnosisResult(run_id=run_id)
def _base_config(workspace_dir: Path, store_dir: Path) -> RunConfig:
"""构造 diagnose 模式 RunConfig,所有必填字段给测试默认值。
参数:
workspace_dir: 已写入 manifest 的 workspace 根目录。
store_dir: 真实 store 根目录(含 questions/ 与 videos/)。
"""
return RunConfig(
workspace_dir=workspace_dir,
store_dir=store_dir,
mode="diagnose",
concurrency=1,
max_steps=5,
skill_mode="none",
n_samples=0,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
epochs=1,
diag_size=10,
diag_correct_ratio=0.5,
val_size=24,
val_correct_ratio=0.5,
edit_budget_start=5,
edit_budget_end=2,
batch_size=5,
min_class_per_batch=2,
eval_min_per_class=2,
early_stop_patience=3,
test_size=10,
use_slow_momentum=False,
gate_e_confirm=20.0,
gate_e_provisional=3.0,
gate_w_net_min=2,
gate_delta_min=0.02,
gate_lambda_dir=-0.642,
gate_e_rollback=10.0,
gate_block=8,
gate_n_max=40,
gate_p_low=0.05,
gate_p_high=0.95,
gate_probe_quota=0.2,
gate_gamma_decay=0.9,
gate_cooldown_steps=2,
gate_guard_err=0.10,
skill_update_mode="patch",
appendix_consolidate_threshold=6,
run_id="infer_adhoc",
)
@pytest.fixture
def runner_with_real_store(tmp_path: Path) -> Runner:
"""构造 diagnose 模式 runnerquestions/videos 指向真实 store。
manifest.store 写真实 store 绝对路径,使 resolve_paths 的 questions_dir
命中含 604-2 的题库、store_dir 命中 0RxMZBLeqRI 的真实 tree.json。
"""
store_dir = Path(__file__).resolve().parents[2] / "store"
ws = tmp_path / "ws"
ws.mkdir()
(ws / "skills" / "v1").mkdir(parents=True)
manifest = {
"name": "ws",
"created_at": "",
"store": store_dir.as_posix(),
"current": {
"videos": "videos",
"questions": "questions/benchmarks/Video-MME",
"skills": "skills/v1",
"prompts": "prompts/v1",
},
"history": [],
}
(ws / "manifest.json").write_text(json.dumps(manifest))
return Runner(
_base_config(ws, store_dir),
llm=MagicMock(),
evolve_llm=MagicMock(),
vlm=MagicMock(),
telemetry=MagicMock(),
)
@pytest.mark.asyncio
async def test_run_diagnosis_injects_tree_data(runner_with_real_store: Runner) -> None:
"""_run_diagnosis 把 batch question_ids 对应 video 的真实树注入 run_diagnosis。"""
captured: dict[str, object] = {}
async def _fake_run_diagnosis(**kwargs: object) -> DiagnosisResult:
captured["tree_data"] = kwargs["tree_data"]
return _empty_diagnosis_result()
with patch(
"core.evolution.diagnose.run_diagnosis",
new=AsyncMock(side_effect=_fake_run_diagnosis),
):
await runner_with_real_store._run_diagnosis("infer_adhoc", question_ids=[_REAL_QUESTION_ID])
tree_data = captured["tree_data"]
assert _REAL_VIDEO_ID in tree_data
assert tree_data[_REAL_VIDEO_ID]["nodes"]
def _fake_question(question_id: str, video_id: str) -> object:
"""构造仅设置 question_id/video_id 的 GeneratedQuestion(其余字段给占位默认)。"""
from core.types import GeneratedQuestion
return GeneratedQuestion(
question_id=question_id,
video_id=video_id,
task_type="Action Reasoning",
question="问题",
options=("A", "B", "C", "D"),
answer="A",
source_nodes=(),
difficulty="medium",
)
@pytest.mark.asyncio
async def test_diagnosis_reads_traces_from_steps_json(
runner_with_real_store: Runner,
) -> None:
"""traces 表为空但 predictions.steps_json 有轨迹时,诊断仍拿到非空 traces。
构造一条只写 steps_json、不写 traces 表的 predictions 行;patch run_diagnosis
捕获传入的 run_log,直接 await 其 get_traces 断言经 StepsJsonRunLog 从
steps_json 重建出非空轨迹(算法 #7 恢复)。
"""
from app.harness.inference import PREDICTIONS_SCHEMA
from app.harness.log import HarnessLog
steps_json = json.dumps(
[
{
"thought": "先看整体",
"tool_call": {"tool": "search_tree", "args": {"query": "开场"}},
"tool_output": "命中 L2 节点 A",
}
],
ensure_ascii=False,
)
with HarnessLog(str(runner_with_real_store._paths.db_path), "infer_adhoc") as log:
log.create_table("predictions", PREDICTIONS_SCHEMA)
log.create_table("traces", {"video_id": "TEXT", "question_id": "TEXT", "step": "INTEGER"})
log.insert(
"predictions",
{
"video_id": _REAL_VIDEO_ID,
"question_id": _REAL_QUESTION_ID,
"task_type": "Action Reasoning",
"prediction": "A",
"answer": "B",
"evidence": "",
"reasoning": "",
"steps_used": 1,
"prompt_tokens": 0,
"completion_tokens": 0,
"stop_reason": "finished",
"steps_json": steps_json,
},
)
captured: dict[str, object] = {}
async def _fake_run_diagnosis(**kwargs: object) -> DiagnosisResult:
captured["run_log"] = kwargs["run_log"]
return _empty_diagnosis_result()
with patch(
"core.evolution.diagnose.run_diagnosis",
new=AsyncMock(side_effect=_fake_run_diagnosis),
):
await runner_with_real_store._run_diagnosis(
"infer_adhoc", question_ids=[_REAL_QUESTION_ID]
)
run_log = captured["run_log"]
traces = await run_log.get_traces("infer_adhoc", question_ids=[_REAL_QUESTION_ID])
assert traces, "traces 表空时应从 steps_json 重建出非空轨迹"
assert traces[0]["tool_name"] == "search_tree"
@pytest.mark.asyncio
async def test_run_diagnosis_full_scan_loads_all_video_trees(
runner_with_real_store: Runner,
) -> None:
"""question_ids=None(全量诊断)路径:加载全部 questions 涉及 video 的树。
轻量验证:patch load_benchmark 返回两个不同 video 的假题,patch
load_tree_data_for_videos 捕获 video_ids,断言全量路径覆盖全部 video。
"""
fake_qs = [
_fake_question("q-a", "vA"),
_fake_question("q-b", "vB"),
]
captured: dict[str, object] = {}
def _cap(store_dir: Path, video_ids: list[str]) -> dict[str, object]:
captured["video_ids"] = list(video_ids)
return {v: {"nodes": {}} for v in video_ids}
with (
patch("app.question_gen.load_benchmark", return_value=fake_qs),
patch("app.harness.tree_nodes.load_tree_data_for_videos", side_effect=_cap),
patch(
"core.evolution.diagnose.run_diagnosis",
new=AsyncMock(return_value=_empty_diagnosis_result()),
),
):
await runner_with_real_store._run_diagnosis("infer_adhoc", question_ids=None)
assert set(captured["video_ids"]) == {"vA", "vB"}