"""question-gen v3 Phase 1 契约全链路集成回归(Task 13)。 把 T3-T12 建立的 QuestionUnit(single|pair) 契约在 build_pools → build_batches → run_inference 全链路上做一次端到端回归护栏。构造「混格题库」(若干 AR pair + 若干 非 AR single)真实驱动三个生产入口,逐条锁定六条契约: 1. pair 不拆:混格题库经 build_pools 后,任一 pair 的 original+mirror 落在同一池。 2. 同批:诊断池经 build_batches 后,任一 pair 的两题在同一个 batch。 3. 双向 AND 聚合:pair P 对 Q 错 → 该 unit 计错;P 对 Q 对 → 计对。 4. unit 粒度 total/correct:N single + M pair 的库,total == N + M(pair 计 1)。 5. 孤儿被剔:只有 original 没有 mirror 的悬挂 pair → 聚合剔除 + 告警、不计入 total。 6. 非 AR byte-identical:纯 single 题库 build_batches 输出与旧逐题黄金参照逐字节一致。 黄金参照 helper(_reference_build_batches / _ids)与最小构造 helper(_single / _pair)复用 tests/unit/test_non_ar_byte_identical.py,不重造。fake 只在 LLM/工具/ prompt 外部依赖层,build_pools/build_batches/run_inference/聚合逻辑全部真跑。 """ from __future__ import annotations import json from typing import Any from unittest.mock import AsyncMock import pytest from loguru import logger from app.harness.batching import build_batches from app.harness.inference import InferenceResult, run_inference from app.harness.log import HarnessLog from app.harness.pools import build_pools from core.types import GeneratedQuestion, LLMResponse from tests.unit.test_non_ar_byte_identical import ( _ids, _pair, _reference_build_batches, _single, ) # ── 外部依赖 fake(仅 LLM/工具/prompt,被测链路真跑) ────────────────── def _make_question( question_id: str, *, task_type: str = "RETRIEVAL", answer: str = "B", pair_id: str | None = None, question_role: str = "single", flip_axis: str | None = None, video_id: str = "v1", ) -> GeneratedQuestion: """构造混格题目;pair_id 非空时视为孪生对成员(共享 flip_axis)。""" 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_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: """测试用工具调度:只认 submit_answer,未知工具 fail-loud。""" 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 实例(run_id 标记为 test-run)。""" db_path = str(tmp_path / f"harness_{id(request)}.db") log = HarnessLog(db_path, "test-run") yield log log.close() # ── 断言 helper(把跨池/跨批的成员定位与 pair 不拆判定收口一处) ──────── def _membership(labeled_groups: list[tuple[str, list[GeneratedQuestion]]]) -> dict[str, str]: """把带标签的分组(池或 batch)展平成 question_id → 组标签,并断言组间互斥。 参数: labeled_groups: (标签, 题目列表) 列表;标签即池名或 batch 下标字符串。 返回: question_id → 所属组标签映射。 关键实现: 同一 question_id 落入两个组即 fail(三池互斥 / 单题不重复入批的前提被破坏)。 """ location: dict[str, str] = {} for label, group in labeled_groups: for q in group: assert q.question_id not in location, ( f"{q.question_id} 同属 {location.get(q.question_id)} 与 {label}(组未互斥)" ) location[q.question_id] = label return location def _pair_together(location: dict[str, str], pair_id: str, *, allow_absent: bool) -> bool: """判定某 pair 的 original+mirror 是否整锁同组,返回是否被采样进组。 参数: location: question_id → 组标签(来自 _membership)。 pair_id: 孪生对标识(成员 id 约定为 f"{pair_id}_o" / f"{pair_id}_m")。 allow_absent: True 时允许整对都未进组(build_pools 场景,采样可不命中); False 时要求必进组(build_batches 全错场景,必进批)。 返回: 该对是否被完整采样进某组(用于调用方统计非空护栏)。 异常: AssertionError: 半只进组(被拆)或两题分属不同组(跨组)。 """ o_loc, m_loc = location.get(f"{pair_id}_o"), location.get(f"{pair_id}_m") if allow_absent and o_loc is None and m_loc is None: return False assert o_loc is not None and m_loc is not None, ( f"pair {pair_id} 被拆:original@{o_loc} mirror@{m_loc}(半只进组)" ) assert o_loc == m_loc, f"pair {pair_id} 跨组:original@{o_loc} mirror@{m_loc}" return True async def _run_capturing_warnings( questions: list[GeneratedQuestion], log: HarnessLog ) -> tuple[InferenceResult, list[str]]: """用固定预测 "B" 的 fake LLM 真跑 run_inference,并捕获 WARNING 日志。 参数: questions: 待推理的混格题库。 log: HarnessLog 实例。 返回: (InferenceResult, 捕获到的 WARNING 消息列表)。 """ llm = AsyncMock() llm.chat.return_value = _make_llm_response(answer="B") captured: list[str] = [] sink_id = logger.add(captured.append, level="WARNING", format="{message}") try: result = await run_inference( questions, llm=llm, tool_dispatch_fn=_stub_tool_dispatch, prompt_builder=_stub_prompt_builder, log=log, run_id="run-v3-contract", concurrency=4, max_steps=10, skill_mode="auto", ) finally: logger.remove(sink_id) return result, captured # ── 契约 1:pair 全程不拆,两题永不跨池 ─────────────────────────────── class TestPairNeverSplitAcrossPools: """混格题库经 build_pools 后,任一 pair 的 original+mirror 落在同一池。""" def _mixed_benchmark(self) -> tuple[list[GeneratedQuestion], dict[str, bool]]: """24 non-AR single + 8 AR pair 混格库,附单元级基线对错。 single:偶数下标对、奇数下标错(12 对 12 错)。 pair:前 4 对两题皆对(单元对),后 4 对 original 错(单元错)。 """ singles = [_single(f"s{i}", task_type="RETRIEVAL") for i in range(24)] correctness: dict[str, bool] = {q.question_id: (i % 2 == 0) for i, q in enumerate(singles)} pair_questions: list[GeneratedQuestion] = [] for i in range(8): original, mirror = _pair(f"p{i}", task_type="AR") pair_questions.extend([original, mirror]) correctness[original.question_id] = i < 4 correctness[mirror.question_id] = True return singles + pair_questions, correctness def test_no_pair_lands_in_two_pools(self) -> None: """任一 pair 两题要么同池、要么都未被采样,绝不分属不同池。""" questions, correctness = self._mixed_benchmark() pools = build_pools( questions, correctness, diag_cfg={ "size": 10, "correct_ratio": 0.5, "task_types": None, "seed": 1, "min_per_class": None, }, val_cfg={ "size": 8, "correct_ratio": 0.5, "task_types": None, "seed": 1, "min_per_class": None, }, test_cfg={"size": 6, "seed": 1}, baseline_run_id="baseline", ) pool_of = _membership( [ ("diagnosis", pools.diagnosis), ("validation", pools.validation), ("test", pools.test), ] ) sampled = sum(_pair_together(pool_of, f"p{i}", allow_absent=True) for i in range(8)) # 非空护栏:确保确有 pair 被采样进池,断言不是空转 assert sampled > 0, "无 pair 进入任一池,pair-不拆断言未被实质覆盖" # ── 契约 2:pair 整锁同批 ───────────────────────────────────────────── class TestPairStaysInSameBatch: """诊断池经 build_batches 后,任一 pair 的两题落在同一个 batch。""" def _all_wrong_diagnosis(self) -> tuple[list[GeneratedQuestion], dict[str, bool]]: """全错混格诊断池:6 non-AR single + 4 AR pair,皆错以确保全部进批。""" singles = [_single(f"s{i}", task_type="RETRIEVAL") for i in range(6)] correctness: dict[str, bool] = {q.question_id: False for q in singles} items: list[GeneratedQuestion] = list(singles) for i in range(4): original, mirror = _pair(f"p{i}", task_type="AR") items.extend([original, mirror]) correctness[original.question_id] = False correctness[mirror.question_id] = False return items, correctness def test_pair_members_share_batch(self) -> None: """全错混格诊断池分批后,每对两题同批(pair 占 2 容量整锁不拆)。""" items, correctness = self._all_wrong_diagnosis() batches, selected = build_batches( items, correctness, batch_size=6, min_class_per_batch=2, seed=3, correct_ratio=0.0 ) assert selected == len(items), "全错单元应全部进批(selected == 展开题数)" batch_of = _membership([(str(idx), batch) for idx, batch in enumerate(batches)]) in_batches = sum(_pair_together(batch_of, f"p{i}", allow_absent=False) for i in range(4)) assert in_batches == 4, "并非全部 pair 都进批,同批断言未实质覆盖" # ── 契约 3+4+5:run_inference 双向 AND + unit 粒度 total + 孤儿剔除 ──── class TestInferenceUnitAggregationEndToEnd: """混格题库真跑 run_inference:双向 AND、unit total/correct、孤儿剔除+告警。""" def _mixed_questions(self) -> list[GeneratedQuestion]: """3 single(皆对)+ pairA(两题皆对)+ pairB(P 对 Q 错)+ 孤儿 pair。 LLM 固定预测 "B":answer=="B" 即对、answer=="A" 即错,从而在单次推理里 制造出 unit 级双向 AND 的对/错两种结果。 """ def _pair_member(qid: str, pid: str, role: str, answer: str) -> GeneratedQuestion: return _make_question( qid, task_type="AR", answer=answer, pair_id=pid, question_role=role, flip_axis="before_after", ) return [ _make_question("s1", answer="B"), _make_question("s2", answer="B"), _make_question("s3", answer="B"), # pairA:两题皆答对 → 双向 AND 判对 _pair_member("pa_o", "pa", "pair_original", "B"), _pair_member("pa_m", "pa", "pair_mirror", "B"), # pairB:original 对、mirror 错(answer=A)→ 双向 AND 判错 _pair_member("pb_o", "pb", "pair_original", "B"), _pair_member("pb_m", "pb", "pair_mirror", "A"), # 孤儿 pair:只有 original,没有 mirror → 聚合剔除 + 告警 _pair_member("orphan_o", "orphan_p", "pair_original", "B"), ] def _assert_task_type_breakdown(self, result: InferenceResult) -> None: """契约 3 细化到 task_type:AR 两对仅 pairA 对,RETRIEVAL 三题皆对。""" assert result.per_task_type["AR"]["total"] == 2 assert result.per_task_type["AR"]["correct"] == 1 assert result.per_task_type["RETRIEVAL"]["total"] == 3 assert result.per_task_type["RETRIEVAL"]["correct"] == 3 def _assert_all_persisted(self, log: HarnessLog, questions: list[GeneratedQuestion]) -> None: """逐题溯源保留:含被剔除的孤儿题在内,每题仍逐题落 predictions。""" rows = log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",)) persisted = {r["question_id"] for r in rows} assert "orphan_o" in persisted, "孤儿题未逐题落库(逐题溯源被破坏)" assert persisted == {q.question_id for q in questions}, "逐题落库题数与输入不符" @pytest.mark.asyncio async def test_double_and_unit_total_and_orphan(self, harness_log: HarnessLog) -> None: """一次全链路推理同时锁定契约 3(双向 AND)、4(unit total)、5(孤儿剔除)。""" questions = self._mixed_questions() result, captured = await _run_capturing_warnings(questions, harness_log) # 契约 4:3 single + 2 pair = 5 unit(pair 计 1、孤儿不计入 total) assert result.total == 5, f"unit 粒度 total 错误:{result.total}" # 契约 3:single 3 对 + pairA 对 + pairB 错(双向 AND)= 4 assert result.correct == 4, f"双向 AND 聚合错误:correct={result.correct}" self._assert_task_type_breakdown(result) # 契约 5:孤儿 pair 被告警(不静默) assert any("orphan_p" in msg for msg in captured), "孤儿 pair 未告警(静默剔除)" self._assert_all_persisted(harness_log, questions) # ── 契约 6:纯非 AR build_batches 与旧逐题黄金参照逐字节一致 ──────────── class TestNonARByteIdentical: """纯 single 题库 build_batches 输出与引入 QuestionUnit 前的旧逐题逻辑逐字节一致。""" def test_pure_single_matches_golden_reference(self) -> None: """混格链路对纯非 AR 输入不得引入任何漂移(黄金参照对照)。""" items = [_single(f"q{i}", task_type=f"t{i % 4}") for i in range(40)] correctness = {f"q{i}": (i % 3 == 0) for i in range(40)} got, _ = build_batches(items, correctness, 8, 3, seed=7, correct_ratio=0.5) ref = _reference_build_batches(items, correctness, 8, 3, seed=7, correct_ratio=0.5) # 非空护栏:确保是实质性非空比较(防空==空误通过) assert sum(len(b) for b in ref) > 0, "黄金参照为空,byte-identical 断言未实质覆盖" assert _ids(got) == _ids(ref), "纯非 AR build_batches 与旧逐题黄金参照产生漂移"