feat: aggregate inference by question unit with pair AND
Reuse build_units/unit_correctness (pair contract single entry) in the inference aggregation step: single questions count as one unit, AR pairs collapse original+mirror into one unit scored by bidirectional AND. total/ correct/per_task_type are unit-grained; orphan pairs (missing one side) are warned and dropped, not counted. Per-question predictions still land row by row (traceability unchanged).
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
"""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_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"}
|
||||
Reference in New Issue
Block a user