feat: wire end-to-end results-driven split with defensive asserts

This commit is contained in:
2026-07-15 12:43:25 -04:00
parent b497db97ba
commit 43d7346526
2 changed files with 659 additions and 0 deletions
+504
View File
@@ -0,0 +1,504 @@
"""结果驱动视频级切分的顶层编排:诊断信号 → 冻结 pools.json + manifest。
把已实现的组件串成 capstone 管线:从 harness.db 读 canonical 基线预测、从
DiagnosisSignalStore 读逐题诊断信号,构建全视频画像、贪心联合约束选择 trainval /
test,再以视频组为原子切出诊断 / 验证池,原子冻结 pools.json 并写溯源 manifest。
全程带六条防御断言(P5,任一不满足即 fail-fast,绝不静默兜底)。
只有基线推理与诊断是上游产物;本模块纯 code-controlled,不发起任何 LLM 调用,
读预测走只读连接,不改动 harness.db。
"""
from __future__ import annotations
import hashlib
import sqlite3
from collections import Counter
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING
from loguru import logger
from app.harness.pools import save_pools, split_by_video_assignment
from app.harness.split_manifest import write_manifest
from app.harness.split_selection import (
SelectConfig,
build_video_records,
derive_reportable_types,
select_split,
)
from app.question_gen.loader import load_benchmark
if TYPE_CHECKING:
from pathlib import Path
from app.harness.pools import Pools
from app.harness.split_selection import SplitAssignment, VideoRecord
from core.evolution.protocols import DiagnosisSignalStore
from core.evolution.types import DiagnosisSignalRow
from core.types import GeneratedQuestion
# 多样性主格子 = 12 题型 × 4 错误类别 = 48 格,覆盖报告以此为分母。
_DIVERSITY_GRID_TOTAL = 48
_QUESTIONS_PER_VIDEO = 3
@dataclass(frozen=True)
class SplitBuildConfig:
"""结果驱动切分的旋钮快照(科研配置,随实验扫动)。
承载贪心选择器与视频组题级切分的全部可扫参数;asdict 后直接写入 manifest 的
config 快照,保证复现时可比对。floor_k 为不可哈希容器,标 hash=False 排除出
自动 __hash__,避免 frozen dataclass 被哈希时报错(本类不作字典键,仅承载配置)。
字段:
n_trainval: trainval 目标视频数(多样性阶段填充上限)。
floor_k: 各高信号 task_type 的 T2 defect 下限(select_split 硬约束)。
epsilon: test 相对全局的最大允许分布偏差(题型 / 难度两维)。
report_floor: per-type 报告门限,题数 ≥ 此值的 task_type 才入 ε 约束。
select_seed: 贪心选择器预洗牌种子(打破等增益平局)。
val_ratio: validation 占 trainval 视频组总数的比例。
split_seed: 视频组题级切分的洗牌种子。
"""
n_trainval: int
floor_k: dict[str, int] = field(hash=False)
epsilon: float
report_floor: int
select_seed: int
val_ratio: float
split_seed: int
@dataclass(frozen=True)
class SplitBuildResult:
"""build_split 的返回结果(冻结产物 + 溯源)。
字段:
pools: 冻结的三池(diagnosis / validation / test)。
manifest: 写入 split_manifest.json 的溯源字典(含 pools_sha256)。
assignment: video_id -> "trainval" | "test" 归属字典。
"""
pools: Pools
manifest: dict
assignment: dict[str, str]
def __getitem__(self, key: str) -> object:
"""兼容字典式访问(result["pools"] / ["manifest"] / ["assignment"])。
参数:
key: 字段名,取值 pools / manifest / assignment。
返回:
对应字段值。
异常:
KeyError: key 非上述三者之一。
"""
if key not in {"pools", "manifest", "assignment"}:
raise KeyError(f"未知字段: {key}")
return getattr(self, key)
def build_split(
*,
db_path: Path,
baseline_run_id: str,
signal_store: DiagnosisSignalStore,
diag_fingerprint: str,
questions_dir: Path,
config: SplitBuildConfig,
out_path: Path,
manifest_path: Path,
generated_at: str,
) -> SplitBuildResult:
"""顶层编排结果驱动视频级切分,冻结 pools.json + manifest 并跑防御断言。
步骤:读 canonical 基线预测 → 读诊断信号 → 构建全视频画像 → 贪心选择 trainval /
test → 加载题库并以视频归属切三池 → 原子冻结 pools.json → 写溯源 manifest →
六条防御断言 fail-fast 校验。
参数:
db_path: harness.db 路径(只读读取 predictions,不改动)。
baseline_run_id: 基线 run 标识(如 "infer_adhoc")。
signal_store: 逐题诊断信号存储端口,读 (run, fingerprint) 下全部信号行。
diag_fingerprint: 诊断口径指纹,隔离不同诊断配置的信号。
questions_dir: benchmark 题库目录,加载 GeneratedQuestion。
config: 切分旋钮快照。
out_path: 冻结 pools.json 目标路径(原子写)。
manifest_path: 溯源 manifest 目标路径(原子写)。
generated_at: 生成时间戳(ISO 字符串),由调用方传入以保证可复现。
返回:
SplitBuildResult,含 pools / manifest / assignment,支持字典式访问。
异常:
AssertionError: 六条防御断言任一不满足(fail-fast,不静默)。
ValueError: 上游依赖校验失败(如 correctness 缺题、assignment 非法)。
"""
# Phase 1: canonical 基线预测 + 诊断信号。
preds = _read_canonical_predictions(db_path, baseline_run_id)
signal_rows_raw = signal_store.load(baseline_run_id, diag_fingerprint)
_assert_fingerprint_consistent(signal_rows_raw, diag_fingerprint)
signal_rows = [
{
"question_id": row.question_id,
"task_type": row.task_type,
"error_type": row.error_type,
"tier": row.tier,
}
for row in signal_rows_raw
]
# Phase 2: 全视频画像 + 贪心联合约束选择。
videos = build_video_records(preds, signal_rows)
total_by_type = Counter(pred["task_type"] for pred in preds)
reportable_types = derive_reportable_types(dict(total_by_type), config.report_floor)
assignment_obj = select_split(
videos,
config=SelectConfig(
n_trainval=config.n_trainval,
floor_k=config.floor_k,
epsilon=config.epsilon,
reportable_types=reportable_types,
seed=config.select_seed,
),
)
assignment = _assignment_to_dict(assignment_obj)
logger.info(
"视频级切分完成: trainval={} test={} (总 {} 视频)",
len(assignment_obj.trainval),
len(assignment_obj.test),
len(videos),
)
# Phase 3: 加载题库 + 视频归属切三池 + 原子冻结。
questions = load_benchmark(questions_dir)
correctness = {pred["question_id"]: pred["correct"] for pred in preds}
pools = split_by_video_assignment(
questions,
assignment,
correctness,
config.val_ratio,
config.split_seed,
baseline_run_id=baseline_run_id,
)
save_pools(pools, out_path)
# Phase 4: 溯源 manifestpools_sha256 锚定冻结内容)。
coverage_report = _build_coverage_report(
videos, assignment_obj, signal_rows_raw, reportable_types, config
)
manifest = write_manifest(
manifest_path,
baseline_run_id=baseline_run_id,
diag_fingerprint=diag_fingerprint,
seed=config.split_seed,
config=asdict(config),
pools_json_text=out_path.read_text(encoding="utf-8"),
coverage_report=coverage_report,
generated_at=generated_at,
)
# Phase 5: 防御断言 fail-fast。
_assert_split_invariants(
pools=pools,
expected_question_ids={q.question_id for q in questions},
signal_rows_raw=signal_rows_raw,
diag_fingerprint=diag_fingerprint,
out_path=out_path,
manifest=manifest,
)
return SplitBuildResult(pools=pools, manifest=manifest, assignment=assignment)
def _normalize_choice(choice: str | None) -> str:
"""选项归一:strip → 大写 → 取首字母,None 归一为空串。
参数:
choice: 原始选项文本(预测或答案),可为 None。
返回:
归一后的单字母(无内容时为空串)。
"""
return (choice or "").strip().upper()[:1]
def _read_canonical_predictions(db_path: Path, baseline_run_id: str) -> list[dict]:
"""从 harness.db 只读取指定 run 每题首行(ORDER BY rowid)为 canonical 预测。
同一 question_id 可能有多行(重跑 / 补测),canonical 口径取 rowid 最小的首行,
保证 distinct question 计数与对错判定确定。correct = 预测与答案归一后逐字符相等。
参数:
db_path: harness.db 路径。
baseline_run_id: 基线 run 标识。
返回:
canonical 预测行列表,每行 {video_id, question_id, task_type, correct}。
异常:
ValueError: 该 run 无任何预测行(fail-fast,不返回空切分)。
实现细节:
以 URI mode=ro 打开只读连接,绝不改动基线 db;按 rowid 升序遍历,
首次见到的 question_id 即 canonical 行,后续同 qid 行跳过。
"""
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT question_id, video_id, task_type, prediction, answer "
"FROM predictions WHERE run_id = ? ORDER BY rowid",
(baseline_run_id,),
).fetchall()
finally:
conn.close()
canonical: dict[str, dict] = {}
for row in rows:
qid = row["question_id"]
if qid in canonical:
continue
canonical[qid] = {
"question_id": qid,
"video_id": row["video_id"],
"task_type": row["task_type"],
"correct": _normalize_choice(row["prediction"]) == _normalize_choice(row["answer"]),
}
if not canonical:
raise ValueError(f"run_id={baseline_run_id} 无任何预测行,无法切分")
return list(canonical.values())
def _assignment_to_dict(assignment_obj: SplitAssignment) -> dict[str, str]:
"""把 SplitAssignment 展平为 video_id -> "trainval" | "test" 归属字典。
参数:
assignment_obj: 贪心选择器产出的切分归属。
返回:
全部视频的归属字典(trainval 与 test 并集,键互斥)。
"""
assignment = dict.fromkeys(assignment_obj.trainval, "trainval")
for vid in assignment_obj.test:
assignment[vid] = "test"
return assignment
def _assert_fingerprint_consistent(
signal_rows_raw: list[DiagnosisSignalRow],
diag_fingerprint: str,
) -> None:
"""防御④:全部诊断信号行的 diag_fingerprint 必须与传入指纹一致。
参数:
signal_rows_raw: store 读回的诊断信号行。
diag_fingerprint: 期望的诊断口径指纹。
异常:
AssertionError: 存在指纹不一致的信号行(store 未正确按指纹过滤)。
"""
mismatched = [
row.question_id for row in signal_rows_raw if row.diag_fingerprint != diag_fingerprint
]
if mismatched:
raise AssertionError(
f"诊断信号指纹不一致 {len(mismatched)} 行,期望 {diag_fingerprint}: {mismatched[:5]}"
)
def _fraction_by_type(records: list[VideoRecord], keys: set[str]) -> dict[str, float]:
"""各 task_type 在给定视频集中的承载占比(含该题型的视频数 / 总视频数)。
参数:
records: 视频记录子集。
keys: 需计算占比的 task_type 键集。
返回:
{task_type: 占比}records 为空时全部记 0.0。
"""
total = len(records)
if total == 0:
return dict.fromkeys(keys, 0.0)
return {key: sum(1 for r in records if key in r.type_set) / total for key in keys}
def _fraction_by_difficulty(records: list[VideoRecord], buckets: set[int]) -> dict[int, float]:
"""各难度桶(错题数)在给定视频集中的占比。
参数:
records: 视频记录子集。
buckets: 难度桶键集。
返回:
{难度桶: 占比};records 为空时全部记 0.0。
"""
total = len(records)
if total == 0:
return dict.fromkeys(buckets, 0.0)
return {bucket: sum(1 for r in records if r.difficulty == bucket) / total for bucket in buckets}
def _max_dev(global_dist: dict, subset_dist: dict, keys: set) -> float:
"""逐键取全局与子集分布的最大绝对偏差(键集为空约定 0.0)。"""
if not keys:
return 0.0
return max(abs(global_dist.get(k, 0.0) - subset_dist.get(k, 0.0)) for k in keys)
def _build_coverage_report(
videos: list[VideoRecord],
assignment_obj: SplitAssignment,
signal_rows_raw: list[DiagnosisSignalRow],
reportable_types: set[str],
config: SplitBuildConfig,
) -> dict:
"""组装 manifest 覆盖报告:48 格覆盖 / floor 达标 / test 代表性偏差 / tier 占比。
参数:
videos: 全视频画像记录。
assignment_obj: 切分归属(trainval / test)。
signal_rows_raw: 诊断信号行(统计 tier 占比)。
reportable_types: 参与 ε 代表性校验的题型集。
config: 切分旋钮(floor_k / epsilon)。
返回:
覆盖报告字典,含 cells_covered / grid_total / floor_satisfied /
test_representativeness_deviation / tier_distribution。
"""
by_id = {v.video_id: v for v in videos}
trainval = [by_id[vid] for vid in assignment_obj.trainval]
test = [by_id[vid] for vid in assignment_obj.test]
covered_cells: set[tuple[str, str]] = set()
trainval_wrong: Counter[str] = Counter()
for video in trainval:
covered_cells |= set(video.cells)
trainval_wrong.update(video.wrong_by_type)
floor_satisfied = {
task_type: trainval_wrong.get(task_type, 0) >= floor
for task_type, floor in config.floor_k.items()
}
type_dev = _max_dev(
_fraction_by_type(videos, reportable_types),
_fraction_by_type(test, reportable_types),
reportable_types,
)
diff_buckets = {r.difficulty for r in videos}
diff_dev = _max_dev(
_fraction_by_difficulty(videos, diff_buckets),
_fraction_by_difficulty(test, diff_buckets),
diff_buckets,
)
tier_counts = Counter(row.tier for row in signal_rows_raw)
total_signals = sum(tier_counts.values())
tier_distribution = (
{tier: count / total_signals for tier, count in tier_counts.items()}
if total_signals
else {}
)
return {
"cells_covered": len(covered_cells),
"grid_total": _DIVERSITY_GRID_TOTAL,
"floor_satisfied": floor_satisfied,
"test_representativeness_deviation": {
"type_max": type_dev,
"difficulty_max": diff_dev,
"epsilon": config.epsilon,
},
"tier_distribution": tier_distribution,
}
def _assert_split_invariants(
*,
pools: Pools,
expected_question_ids: set[str],
signal_rows_raw: list[DiagnosisSignalRow],
diag_fingerprint: str,
out_path: Path,
manifest: dict,
) -> None:
"""六条防御断言 fail-fast:任一不满足即 AssertionErrorP5,不静默不兜底)。
① 三池视频集两两不相交;② 三池覆盖全部题(按 distinct question);
③ 每 video 恰 3 题;④ 诊断信号指纹一致;⑤ manifest pools_sha256 == sha256(冻结内容)
⑥ question_id 全局唯一(无重复行)。
参数:
pools: 冻结的三池。
expected_question_ids: 加载题库的 question_id 全集(覆盖基准)。
signal_rows_raw: 诊断信号行(指纹校验)。
diag_fingerprint: 期望诊断指纹。
out_path: 冻结 pools.json 路径。
manifest: 已写入的 manifest 字典。
异常:
AssertionError: 任一防御断言不满足。
"""
all_questions = pools.diagnosis + pools.validation + pools.test
_assert_pools_video_disjoint(pools) # ①
_assert_question_ids_unique(all_questions) # ⑥
_assert_question_coverage(all_questions, expected_question_ids) # ②
_assert_three_questions_per_video(all_questions) # ③
_assert_fingerprint_consistent(signal_rows_raw, diag_fingerprint) # ④
_assert_pools_sha256(out_path, manifest) # ⑤
def _assert_pools_video_disjoint(pools: Pools) -> None:
"""防御①:diagnosis / validation / test 三池视频集两两不相交。"""
diag_v = {q.video_id for q in pools.diagnosis}
val_v = {q.video_id for q in pools.validation}
test_v = {q.video_id for q in pools.test}
if diag_v & val_v or diag_v & test_v or val_v & test_v:
raise AssertionError(
f"三池视频集非互斥: diag∩val={diag_v & val_v}, "
f"diag∩test={diag_v & test_v}, val∩test={val_v & test_v}"
)
def _assert_question_ids_unique(all_questions: list[GeneratedQuestion]) -> None:
"""防御⑥:三池合并后 question_id 全局唯一(无重复行)。"""
qids = [q.question_id for q in all_questions]
if len(qids) != len(set(qids)):
duplicates = [qid for qid, count in Counter(qids).items() if count > 1]
raise AssertionError(f"question_id 重复 {len(duplicates)} 个: {duplicates[:5]}")
def _assert_question_coverage(
all_questions: list[GeneratedQuestion],
expected_question_ids: set[str],
) -> None:
"""防御②:三池覆盖题库全部题(按 distinct question,缺题 / 多题均 fail-fast)。"""
actual = {q.question_id for q in all_questions}
if actual != expected_question_ids:
missing = expected_question_ids - actual
extra = actual - expected_question_ids
raise AssertionError(
f"三池题目覆盖不完整: 缺 {len(missing)}{len(extra)} (缺样例 {sorted(missing)[:5]})"
)
def _assert_three_questions_per_video(all_questions: list[GeneratedQuestion]) -> None:
"""防御③:每 video 恰 3 题(视频组原子切分不应劈裂视频的题)。"""
per_video = Counter(q.video_id for q in all_questions)
bad_videos = {vid: n for vid, n in per_video.items() if n != _QUESTIONS_PER_VIDEO}
if bad_videos:
raise AssertionError(
f"存在 video 题数 != {_QUESTIONS_PER_VIDEO}: {dict(list(bad_videos.items())[:5])}"
)
def _assert_pools_sha256(out_path: Path, manifest: dict) -> None:
"""防御⑤:manifest 的 pools_sha256 == sha256(冻结 pools.json 内容)。"""
actual_sha = hashlib.sha256(out_path.read_text(encoding="utf-8").encode("utf-8")).hexdigest()
if actual_sha != manifest["pools_sha256"]:
raise AssertionError(
f"pools_sha256 不一致: manifest={manifest['pools_sha256']} 实际={actual_sha}"
)
+155
View File
@@ -0,0 +1,155 @@
"""build_split 端到端集成测试:真实 infer_adhoc 预测 + 构造诊断信号 → 冻结三池。
用 workspaces/default/harness.db 的真实 infer_adhoc 基线预测(900 题 / 300 视频,
每视频 3 题)驱动结果驱动视频级切分,构造一份 T2 诊断信号 store 喂给选择器,
验证冻结的 pools.json 满足全部防御断言(三池视频互斥、覆盖 900 题、每视频 3 题、
内容指纹一致),确保 capstone 编排把前面所有组件正确串联。
"""
from __future__ import annotations
import hashlib
import sqlite3
from pathlib import Path
import pytest
from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore
from app.harness.build_split import SplitBuildConfig, build_split
from app.harness.split_selection import evolution_target_of
from core.evolution.types import DiagnosisSignalRow
_HARNESS_DB = Path("workspaces/default/harness.db")
_BENCHMARK_DIR = Path("store/questions/benchmarks/Video-MME")
_BASELINE_RUN_ID = "infer_adhoc"
_DIAG_FINGERPRINT = "diag_test_v1"
# 四类错误类别轮转,确定性铺满多样性格子(cell = task_type × error_type)。
_ERROR_TYPES = ("extraction_failure", "search_failure", "reasoning_failure", "mixed")
def _normalize(choice: str | None) -> str:
"""选项归一:strip → 大写 → 取首字母(None 归一为空串)。"""
return (choice or "").strip().upper()[:1]
def _read_canonical_predictions() -> list[dict]:
"""从真实 harness.db 读 infer_adhoc 每题首行(ORDER BY rowid)作为 canonical 预测。"""
conn = sqlite3.connect(f"file:{_HARNESS_DB}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT question_id, video_id, task_type, prediction, answer "
"FROM predictions WHERE run_id = ? ORDER BY rowid",
(_BASELINE_RUN_ID,),
).fetchall()
conn.close()
seen: dict[str, dict] = {}
for row in rows:
qid = row["question_id"]
if qid in seen:
continue
seen[qid] = {
"question_id": qid,
"video_id": row["video_id"],
"task_type": row["task_type"],
"correct": _normalize(row["prediction"]) == _normalize(row["answer"]),
}
return list(seen.values())
def _populate_signal_store(store: SqliteDiagnosisSignalStore, preds: list[dict]) -> None:
"""为全部错题写入 T2 诊断信号(error_type 轮转),构造可训练缺陷多样性。"""
wrong = [p for p in preds if not p["correct"]]
for idx, pred in enumerate(wrong):
error_type = _ERROR_TYPES[idx % len(_ERROR_TYPES)]
store.upsert(
DiagnosisSignalRow(
question_id=pred["question_id"],
video_id=pred["video_id"],
baseline_run_id=_BASELINE_RUN_ID,
diag_fingerprint=_DIAG_FINGERPRINT,
task_type=pred["task_type"],
error_type=error_type,
cause_category="defect",
tier="T2",
evolution_target=evolution_target_of(error_type),
degraded=False,
infra=False,
session_id=None,
)
)
@pytest.mark.skipif(
not _HARNESS_DB.exists() or not _BENCHMARK_DIR.exists(),
reason="需要真实 workspaces/default/harness.db 与 Video-MME benchmark",
)
def test_end_to_end_freezes_valid_pools(tmp_path: Path) -> None:
"""真实基线预测 + 构造 T2 信号 → 冻结 pools.json,校验六条防御断言。"""
out = tmp_path / "pools.json"
manifest_path = tmp_path / "split_manifest.json"
signal_db = tmp_path / "signals.db"
preds = _read_canonical_predictions()
store = SqliteDiagnosisSignalStore(str(signal_db))
_populate_signal_store(store, preds)
config = SplitBuildConfig(
n_trainval=100,
floor_k={"Object Reasoning": 3},
epsilon=0.1,
report_floor=30,
select_seed=7,
val_ratio=0.3,
split_seed=7,
)
result = build_split(
db_path=_HARNESS_DB,
baseline_run_id=_BASELINE_RUN_ID,
signal_store=store,
diag_fingerprint=_DIAG_FINGERPRINT,
questions_dir=_BENCHMARK_DIR,
config=config,
out_path=out,
manifest_path=manifest_path,
generated_at="2026-07-15T00:00:00Z",
)
store.close()
pools = result["pools"]
# 防御① 三池视频集两两不相交
diag_v = {q.video_id for q in pools.diagnosis}
val_v = {q.video_id for q in pools.validation}
test_v = {q.video_id for q in pools.test}
assert diag_v & test_v == set()
assert val_v & test_v == set()
assert diag_v & val_v == set()
# 防御② 三池覆盖 900 题(按 distinct question
all_q = pools.diagnosis + pools.validation + pools.test
assert len({q.question_id for q in all_q}) == 900
assert len(all_q) == 900
# 防御③ 每 video 恰 3 题
per_video: dict[str, int] = {}
for q in all_q:
per_video[q.video_id] = per_video.get(q.video_id, 0) + 1
assert set(per_video.values()) == {3}
# 防御⑤ manifest 的 pools_sha256 == sha256(out 文件内容)
assert (
result["manifest"]["pools_sha256"]
== hashlib.sha256(out.read_text(encoding="utf-8").encode("utf-8")).hexdigest()
)
# assignment 覆盖全部 300 视频且取值合法
assignment = result["assignment"]
assert set(assignment.values()) <= {"trainval", "test"}
assert len(assignment) == 300
# manifest 覆盖报告含关键指标
coverage = result["manifest"]["coverage_report"]
assert coverage["grid_total"] == 48
assert "tier_distribution" in coverage
assert manifest_path.exists()