380 lines
16 KiB
Python
380 lines
16 KiB
Python
"""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 与旧逐题黄金参照产生漂移"
|