fix: count gate INFRA guard numerator by unit not record

This commit is contained in:
2026-07-16 07:16:33 -04:00
parent ee69721ea3
commit b3ba11c7a5
2 changed files with 177 additions and 57 deletions
+151 -51
View File
@@ -112,7 +112,6 @@ def _make_mock_run_inference(
log: HarnessLog,
baseline_correctness: dict[str, bool],
candidate_correctness: dict[str, bool],
error_count: int = 0,
):
"""构建 mock RunInferenceFn。
@@ -136,9 +135,6 @@ def _make_mock_run_inference(
correct = sum(per_q.values())
total = len(questions)
stop_counts: dict[str, int] = {"completed": total - error_count}
if error_count > 0:
stop_counts["error"] = error_count
return InferenceResult(
run_id=run_id,
accuracy=correct / total if total else 0.0,
@@ -147,7 +143,57 @@ def _make_mock_run_inference(
per_task_type={},
steps_mean=1.0,
token_usage={"prompt_tokens": 10, "completion_tokens": 10},
stop_reason_counts=stop_counts,
stop_reason_counts={"completed": total},
)
return mock_fn, call_log
def _make_all_infra_mock(log: HarnessLog, stop_reason: str):
"""构建基线全 INFRA 的 mock:每 record 写指定 INFRA stop_reasonerror/parse_error)。
与真实推理一致——per-record DB stop_reason 与汇总 stop_reason_counts 同源;护栏
分子按 unit 从 DB 读(_infra_question_ids_from_db),故须真实落 DB。total 返回
unit 粒度(single 时 == 题数),使护栏分子/分母同粒度。
"""
call_log: list[dict[str, Any]] = []
async def mock_fn(
questions: list[GeneratedQuestion],
*,
run_id: str,
skills_dir: Path,
) -> InferenceResult:
call_log.append({"run_id": run_id, "n": len(questions)})
for q in questions:
log.insert(
"predictions",
{
"run_id": run_id,
"video_id": "v0",
"question_id": q.question_id,
"task_type": "temporal",
"prediction": "",
"answer": "A",
"evidence": "",
"reasoning": "",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 10,
"stop_reason": stop_reason,
"steps_json": "[]",
},
)
total = len(questions) # 全 single → unit 数 == 题数
return InferenceResult(
run_id=run_id,
accuracy=0.0,
total=total,
correct=0,
per_task_type={},
steps_mean=1.0,
token_usage={"prompt_tokens": 10, "completion_tokens": 10},
stop_reason_counts={stop_reason: total},
)
return mock_fn, call_log
@@ -421,17 +467,14 @@ async def test_gate_prefix_must_contain_gate(tmp_path: Path) -> None:
@pytest.mark.asyncio
async def test_infra_guard_threshold(tmp_path: Path) -> None:
"""推理错误率超阈值时抛 RuntimeError。"""
"""推理错误率超阈值时抛 RuntimeError(护栏分子/分母 unit 同粒度)"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
# 需要 >=10 题次才触发 INFRA 护栏
questions = _make_questions(6)
# 需要 >=10 unit 分母才触发护栏:12 个 single,基线全 INFRA error。
# 首块全 INFRA → valid_chunk 空 → errors=12/denom=12=1.0>0.5 触发护栏。
questions = _make_questions(12)
cache = BaselineCache(workspace / "baseline_cache.json")
baseline_correct = {f"q{i}": False for i in range(6)}
candidate_correct = {f"q{i}": False for i in range(6)}
# 每次 run_inference 报 error_count=5,两侧各 5 → 10/12 > 0.5
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct, error_count=5)
mock_fn, _ = _make_all_infra_mock(log, "error")
try:
with pytest.raises(RuntimeError, match="错误率过高"):
@@ -444,7 +487,7 @@ async def test_infra_guard_threshold(tmp_path: Path) -> None:
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=6,
gate_block=12,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
@@ -575,6 +618,95 @@ async def test_baseline_infra_error_not_cached(tmp_path: Path) -> None:
log.close()
@pytest.mark.asyncio
async def test_infra_guard_counts_units_not_records(tmp_path: Path) -> None:
"""护栏分子按 unit 计:AR pair 两 record 全 INFRA 只计 1 个 INFRA unit(而非 2)。
回归 I-3:分子此前用 stop_reason_counts 逐 record 计数,分母 denom_inc=r.total
是 unit 粒度;AR pair(一 unit 两 record)致分子被放大、误触发 gate_guard_err。
分子改为"含 INFRA record 的 unit 数"后与分母同粒度(核心算法保真 #5/#6)。
"""
from app.harness.gate_ladder import skill_hash
from app.harness.question_units import build_units
from app.harness.validate import _resolve_baseline_block
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
# 一个 AR pair(两成员共享 pair_id)→ build_units 折叠为 1 个 pair unit
common = {
"video_id": "vp",
"task_type": "temporal",
"question": "Q?",
"options": ("A", "B", "C", "D"),
"answer": "A",
"source_nodes": (),
"difficulty": "easy",
"pair_id": "p1",
"flip_axis": "before_after",
}
pair = [
GeneratedQuestion(question_id="p1_o", question_role="pair_original", **common),
GeneratedQuestion(question_id="p1_m", question_role="pair_mirror", **common),
]
units = build_units(pair)
assert len(units) == 1 # 前置:pair 折叠为 1 个 unit
cache = BaselineCache(workspace / "baseline_cache.json")
s_hash = skill_hash("baseline skill content")
async def mock_fn(qs, *, run_id, skills_dir):
# 两 record 皆 INFRA error
for q in qs:
log.insert(
"predictions",
{
"run_id": run_id,
"video_id": "vp",
"question_id": q.question_id,
"task_type": "temporal",
"prediction": "",
"answer": "A",
"evidence": "",
"reasoning": "",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 10,
"stop_reason": "error",
"steps_json": "[]",
},
)
# total 为 unit 粒度(1 个 pair unit);stop_reason_counts 为 record 粒度(2
return InferenceResult(
run_id=run_id,
accuracy=0.0,
total=1,
correct=0,
per_task_type={},
steps_mean=1.0,
token_usage={"prompt_tokens": 20, "completion_tokens": 20},
stop_reason_counts={"error": 2},
)
try:
_b_units, valid_units, errors_inc, denom_inc = await _resolve_baseline_block(
units=units,
task_type="temporal",
s_hash=s_hash,
prompts_version="p1",
baseline_cache=cache,
base_skills_dir=workspace / "skills" / "v1",
run_inference=mock_fn,
log=log,
run_id="step1_gate_b0_base",
)
# 分子按 unit 计:1 个 INFRA unit(不是 2 条 record);分母同粒度 = r.total = 1
assert errors_inc == 1
assert denom_inc == 1
# 整对 INFRA → 从有效单元剔除
assert valid_units == []
finally:
log.close()
@pytest.mark.asyncio
async def test_all_infra_ladder_raises_clear_error(tmp_path: Path) -> None:
"""整个阶梯所有 unit 都被判为 INFRA 排除 → 明确 RuntimeError(非误导性空阶梯断言)。"""
@@ -651,43 +783,11 @@ async def test_parse_error_counts_toward_guard(tmp_path: Path) -> None:
"""stop_reason=parse_error 也计入护栏错误率(与 INFRA 判定口径一致)→ 超阈值熔断。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(6)
# 12 个 single,基线全 parse_errorper-record 落 DB,护栏按 unit 从 DB 读)。
# 首块全 INFRA → errors=12/denom=12=1.0>0.5 → parse_error 亦触发护栏。
questions = _make_questions(12)
cache = BaselineCache(workspace / "baseline_cache.json")
async def mock_fn(qs, *, run_id, skills_dir):
# 逐题 stop_reason 保持 completed(不触发 per-unit INFRA 排除),
# 但汇总 stop_reason_counts 报大量 parse_error(应计入护栏)。
for q in qs:
log.insert(
"predictions",
{
"run_id": run_id,
"video_id": "v0",
"question_id": q.question_id,
"task_type": "temporal",
"prediction": "Z",
"answer": "A",
"evidence": "",
"reasoning": "",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 10,
"stop_reason": "completed",
"steps_json": "[]",
},
)
total = len(qs)
# 两臂各 5 个 parse_error → 累计 10/12 > 0.5 触发护栏
return InferenceResult(
run_id=run_id,
accuracy=0.0,
total=total,
correct=0,
per_task_type={},
steps_mean=1.0,
token_usage={"prompt_tokens": 10, "completion_tokens": 10},
stop_reason_counts={"completed": 1, "parse_error": 5},
)
mock_fn, _ = _make_all_infra_mock(log, "parse_error")
try:
with pytest.raises(RuntimeError, match="错误率过高"):
@@ -700,7 +800,7 @@ async def test_parse_error_counts_toward_guard(tmp_path: Path) -> None:
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=6,
gate_block=12,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,