fix: count gate INFRA guard numerator by unit not record
This commit is contained in:
@@ -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_reason(error/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_error(per-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,
|
||||
|
||||
Reference in New Issue
Block a user