"""inference pair-level 双向 AND 聚合单元测试(Task 6)。 覆盖 question-gen v3 Phase 1 Task 6 的核心契约: - 逐题推理不变:每条 GeneratedQuestion 照常各答一次、per-question prediction 仍逐题落 predictions 表(保留逐题溯源)。 - pair 按 pair_id 收齐 original + mirror 后合成 1 条 unit-level 记录, pair 正确 = (P.pred==P.answer) AND (Q.pred==Q.answer)(双向 AND)。 - InferenceResult.total / correct / per_task_type 全部按 unit 粒度 (single 计 1,pair 计 1)。 - 孤儿 pair(收不齐 2 条)→ 告警 + 剔除该 unit、不计入 total(不静默)。 """ from __future__ import annotations import json from typing import Any from unittest.mock import AsyncMock import pytest from loguru import logger from app.harness.inference import _aggregate_results, run_inference from app.harness.log import HarnessLog from core.types import GeneratedQuestion, LLMResponse # ── 测试基础设施 ────────────────────────────────────────────────── def _make_question( question_id: str, *, task_type: str = "Action Reasoning", answer: str = "B", pair_id: str | None = None, question_role: str = "single", flip_axis: str | None = None, video_id: str = "v1", ) -> GeneratedQuestion: """构造测试题目;pair_id 非空时视为孪生对成员。""" return GeneratedQuestion( question_id=question_id, video_id=video_id, task_type=task_type, question="测试问题", options=("A. 选项A", "B. 选项B", "C. 选项C", "D. 选项D"), answer=answer, source_nodes=("L1_001",), difficulty="medium", pair_id=pair_id, question_role=question_role, flip_axis=flip_axis if pair_id else None, ) def _make_record( question_id: str, *, prediction: str | None, answer: str = "B", task_type: str = "Action Reasoning", steps_used: int = 2, prompt_tokens: int = 100, completion_tokens: int = 50, stop_reason: str = "finished", ) -> dict[str, Any]: """构造与题目匹配的 prediction record(键与 _run_single_question 一致)。""" return { "question_id": question_id, "prediction": prediction, "answer": answer, "task_type": task_type, "steps_used": steps_used, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "stop_reason": stop_reason, } def _make_llm_response(answer: str = "B") -> LLMResponse: """构造 submit_answer 场景的 LLMResponse。""" content = json.dumps( { "reflect": {"observation": "找到答案"}, "plan": {"next_step": "提交"}, "action": { "tool": "submit_answer", "args": {"answer": answer, "evidence": "证据", "reasoning": "推理"}, }, } ) return LLMResponse( content=content, thinking="思考", model="test-model", provider="test", prompt_tokens=100, completion_tokens=50, latency_ms=200, ttft_ms=30.0, max_inter_token_ms=5.0, cache_hit=False, call_id="test-call-001", ) async def _stub_tool_dispatch( tool_name: str, args: dict[str, Any], *, context: dict[str, Any] ) -> str: """测试用工具调度函数。""" if tool_name == "submit_answer": return "答案已提交" raise ValueError(f"未知工具: {tool_name}") def _stub_prompt_builder(qa: GeneratedQuestion) -> tuple[str, str]: """测试用 prompt 构建函数。""" return "系统提示词", f"用户问题: {qa.question}" @pytest.fixture def harness_log(tmp_path: Any, request: Any) -> HarnessLog: """创建临时 HarnessLog 实例。""" db_path = str(tmp_path / f"harness_{id(request)}.db") log = HarnessLog(db_path, "test-run") yield log log.close() # ── unit 粒度聚合(_aggregate_results 直测) ───────────────────────── class TestUnitLevelAggregation: """_aggregate_results 按 unit 粒度聚合测试。""" def test_single_units_counted_per_question(self) -> None: """全 single:total = single 数,correct 逐题判定。""" questions = [ _make_question("s1", answer="B"), _make_question("s2", answer="A"), ] records = [ _make_record("s1", prediction="B", answer="B"), _make_record("s2", prediction="C", answer="A"), ] result = _aggregate_results(records, questions, "run-single") assert result.total == 2 assert result.correct == 1 assert abs(result.accuracy - 0.5) < 1e-9 def test_pair_both_correct_is_one_correct_unit(self) -> None: """pair 两题皆对 → 1 个 unit、correct=1。""" questions = [ _make_question("po", answer="B", pair_id="p", question_role="pair_original"), _make_question("pm", answer="A", pair_id="p", question_role="pair_mirror"), ] records = [ _make_record("po", prediction="B", answer="B"), _make_record("pm", prediction="A", answer="A"), ] result = _aggregate_results(records, questions, "run-pair-ok") assert result.total == 1 assert result.correct == 1 assert result.accuracy == 1.0 def test_pair_one_wrong_fails_by_and(self) -> None: """pair 一题错 → 双向 AND 判 unit 错,correct=0。""" questions = [ _make_question("po", answer="B", pair_id="p", question_role="pair_original"), _make_question("pm", answer="A", pair_id="p", question_role="pair_mirror"), ] records = [ _make_record("po", prediction="B", answer="B"), # 对 _make_record("pm", prediction="D", answer="A"), # 错 ] result = _aggregate_results(records, questions, "run-pair-half") assert result.total == 1 assert result.correct == 0 assert result.accuracy == 0.0 def test_mixed_single_and_pair_unit_total(self) -> None: """single + pair 混合:total = single 数 + pair 数(pair 计 1)。""" questions = [ _make_question("s1", answer="B"), _make_question("po", answer="B", pair_id="p", question_role="pair_original"), _make_question("pm", answer="A", pair_id="p", question_role="pair_mirror"), ] records = [ _make_record("s1", prediction="B", answer="B"), _make_record("po", prediction="B", answer="B"), _make_record("pm", prediction="A", answer="A"), ] result = _aggregate_results(records, questions, "run-mixed") assert result.total == 2 # 1 single + 1 pair assert result.correct == 2 def test_per_task_type_by_unit(self) -> None: """per_task_type 按 unit 计数:pair 归入其 task_type 计 1 个 unit。""" questions = [ _make_question("s1", task_type="SP", answer="B"), _make_question( "po", task_type="AR", answer="B", pair_id="p", question_role="pair_original" ), _make_question( "pm", task_type="AR", answer="A", pair_id="p", question_role="pair_mirror" ), ] records = [ _make_record("s1", prediction="B", answer="B", task_type="SP"), _make_record("po", prediction="B", answer="B", task_type="AR"), _make_record("pm", prediction="C", answer="A", task_type="AR"), # pair 错 ] result = _aggregate_results(records, questions, "run-tt") assert result.per_task_type["AR"]["total"] == 1 # pair 计 1 个 unit assert result.per_task_type["AR"]["correct"] == 0 assert result.per_task_type["SP"]["total"] == 1 assert result.per_task_type["SP"]["correct"] == 1 def test_orphan_pair_dropped_and_warned(self) -> None: """孤儿 pair(收不齐 2 条)→ 告警 + 剔除、不计入 total。""" questions = [ _make_question("s1", answer="B"), _make_question( "po", answer="B", pair_id="orphan", question_role="pair_original" ), # 缺 mirror ] records = [ _make_record("s1", prediction="B", answer="B"), _make_record("po", prediction="B", answer="B"), ] captured: list[str] = [] sink_id = logger.add(captured.append, level="WARNING", format="{message}") try: result = _aggregate_results(records, questions, "run-orphan") finally: logger.remove(sink_id) assert result.total == 1 # 仅 single,孤儿 pair 被剔除 assert result.correct == 1 assert any("orphan" in msg for msg in captured), "孤儿 pair 未告警(静默)" def test_pair_with_extra_illegal_role_dropped(self) -> None: """pair_id 下混入额外非法 role 记录(total>2)→ 整对剔除、不计入 total。""" questions = [ _make_question("s1", answer="B"), _make_question("po", answer="B", pair_id="p", question_role="pair_original"), _make_question("pm", answer="A", pair_id="p", question_role="pair_mirror"), # 共享 pair_id 的额外非法记录(重复 original 角色) _make_question("px", answer="C", pair_id="p", question_role="pair_original"), ] records = [ _make_record("s1", prediction="B", answer="B"), _make_record("po", prediction="B", answer="B"), _make_record("pm", prediction="A", answer="A"), _make_record("px", prediction="C", answer="C"), ] captured: list[str] = [] sink_id = logger.add(captured.append, level="WARNING", format="{message}") try: result = _aggregate_results(records, questions, "run-illegal-role") finally: logger.remove(sink_id) assert result.total == 1 # 仅 single 存活,非法配对整对剔除 assert result.correct == 1 assert any("total=3" in msg for msg in captured), "非法配对未告警(静默)" def test_unit_missing_prediction_raises_descriptive_error(self) -> None: """unit 缺 prediction → 描述性 ValueError(fail-loud,非静默、非裸 KeyError)。 故意破坏聚合不变量(questions 含 s2 但 records 无 s2),验证带上下文报错。 """ questions = [ _make_question("s1", answer="B"), _make_question("s2", answer="A"), ] records = [_make_record("s1", prediction="B", answer="B")] # 缺 s2 的 record with pytest.raises(ValueError, match=r"s2.*缺 prediction.*聚合不变量被破坏"): _aggregate_results(records, questions, "run-broken-invariant") def test_empty_records_returns_zero(self) -> None: """空 records/questions → 零值结果。""" result = _aggregate_results([], [], "run-empty") assert result.total == 0 assert result.correct == 0 assert result.accuracy == 0.0 assert result.per_task_type == {} def test_token_and_steps_span_all_records(self) -> None: """token/steps 诊断字段覆盖全部 record(含 pair 两条)。""" questions = [ _make_question("po", answer="B", pair_id="p", question_role="pair_original"), _make_question("pm", answer="A", pair_id="p", question_role="pair_mirror"), ] records = [ _make_record("po", prediction="B", answer="B", steps_used=3, prompt_tokens=100), _make_record("pm", prediction="A", answer="A", steps_used=1, prompt_tokens=200), ] result = _aggregate_results(records, questions, "run-diag") assert result.token_usage["prompt_tokens"] == 300 assert abs(result.steps_mean - 2.0) < 1e-9 # (3+1)/2 record 粒度 # ── run_inference 端到端:逐题溯源 + pair 聚合 ────────────────────── class TestRunInferencePairEndToEnd: """run_inference pair 端到端:逐题落库不变 + unit 级聚合。""" @pytest.mark.asyncio async def test_pair_predictions_persisted_per_question(self, harness_log: HarnessLog) -> None: """pair 两题各自逐题落 predictions(保留逐题溯源),聚合按 unit。""" llm = AsyncMock() llm.chat.return_value = _make_llm_response(answer="B") questions = [ _make_question("po", answer="B", pair_id="p", question_role="pair_original"), _make_question("pm", answer="B", pair_id="p", question_role="pair_mirror"), ] result = await run_inference( questions, llm=llm, tool_dispatch_fn=_stub_tool_dispatch, prompt_builder=_stub_prompt_builder, log=harness_log, run_id="run-pair-e2e", concurrency=2, max_steps=10, skill_mode="auto", ) # unit 级:1 个 pair unit,两题皆对 → correct=1 assert result.total == 1 assert result.correct == 1 # 逐题溯源:predictions 表两条 record 都在 rows = harness_log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",)) qids = {r["question_id"] for r in rows} assert qids == {"po", "pm"} @pytest.mark.asyncio async def test_orphan_pair_excluded_single_survives(self, harness_log: HarnessLog) -> None: """run_inference 中孤儿 pair 被剔除、single 仍计入。""" llm = AsyncMock() llm.chat.return_value = _make_llm_response(answer="B") questions = [ _make_question("s1", answer="B"), _make_question("po", answer="B", pair_id="orphan", question_role="pair_original"), ] result = await run_inference( questions, llm=llm, tool_dispatch_fn=_stub_tool_dispatch, prompt_builder=_stub_prompt_builder, log=harness_log, run_id="run-orphan-e2e", concurrency=2, max_steps=10, skill_mode="auto", ) assert result.total == 1 # single 存活,孤儿剔除 # 逐题溯源:孤儿题仍逐题落库(推理不变) rows = harness_log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",)) assert {r["question_id"] for r in rows} == {"s1", "po"}