From 6a21d803135d4d74750869fc2f4b5b58446b2482 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 15 Jul 2026 12:54:25 -0400 Subject: [PATCH] feat: add video-split config knobs and reproducible script --- app/harness/pools.py | 22 +++ app/harness/split_selection.py | 25 ++++ config/video_split.yaml | 25 ++++ core/types.py | 15 +++ scripts/build_video_split.sh | 149 +++++++++++++++++++++ tests/unit/test_pool_config_video_split.py | 115 ++++++++++++++++ 6 files changed, 351 insertions(+) create mode 100644 config/video_split.yaml create mode 100755 scripts/build_video_split.sh create mode 100644 tests/unit/test_pool_config_video_split.py diff --git a/app/harness/pools.py b/app/harness/pools.py index 9be9e88..a7f72da 100644 --- a/app/harness/pools.py +++ b/app/harness/pools.py @@ -115,6 +115,14 @@ def build_pools( _VIDEO_ASSIGNMENT_LABELS = ("trainval", "test") +class InsufficientValSignal(Exception): # noqa: N818 领域名「验证信号不足」,非通用错误后缀更贴切 + """validation 池错题数不足以支撑可靠验证信号(如 McNemar 检验功效)时抛出。 + + fail loud(P5):不静默兜底、不放宽阈值,直接暴露 val 池错题数与所需下限, + 由调用方决定放大 val_ratio / 换 trainval 归属或调低 val_wrong_min。 + """ + + def split_by_video_assignment( questions: list[GeneratedQuestion], assignment: dict[str, str], @@ -122,6 +130,7 @@ def split_by_video_assignment( val_ratio: float, seed: int, baseline_run_id: str = "", + val_wrong_min: int = 0, ) -> Pools: """按视频归属做原子切分:同一视频所有题绝不跨 trainval/test 池。 @@ -136,6 +145,8 @@ def split_by_video_assignment( val_ratio: validation 占 trainval 视频组总数的比例,[0.0, 1.0]。 seed: 随机种子,保证视频组 shuffle 可复现。 baseline_run_id: 基线 run 标识;离线切分阶段可留空,由调用方回填。 + val_wrong_min: validation 池最少错题数(默认 0 = 不检查,保持既有调用契约)。 + > 0 时切出 val 后统计其错题数,不足即 fail loud(见 InsufficientValSignal)。 返回: 冻结的三池 Pools:diagnosis/validation 仍是逐题 GeneratedQuestion 列表 @@ -146,6 +157,8 @@ def split_by_video_assignment( ValueError: assignment 缺失某题 video_id(fail-fast 不静默丢题)、 assignment 取值非法、correctness 缺失任一参与 Pools 的题(trainval 或 test)、或 val_ratio 越界。 + InsufficientValSignal: val_wrong_min > 0 且 validation 池错题数 < val_wrong_min + (P5,验证信号不足以支撑可靠比较,直接报错而非静默放行)。 关键实现细节: 视频组 correctness 取组内全部题的 AND(组内均答对才记为 correct 组), @@ -163,6 +176,15 @@ def split_by_video_assignment( trainval_qs, correctness, val_ratio, random.Random(seed) ) + if val_wrong_min > 0: + val_wrong = sum(1 for q in validation if not correctness[q.question_id]) + if val_wrong < val_wrong_min: + raise InsufficientValSignal( + f"validation 池错题数 {val_wrong} < val_wrong_min={val_wrong_min}," + "验证信号不足以支撑可靠比较(如 McNemar 检验功效)," + "请放大 val_ratio / 调整 trainval 归属或调低阈值。" + ) + val_correct = sum(1 for q in validation if correctness.get(q.question_id)) baseline_val_accuracy = val_correct / len(validation) if validation else 0.0 return Pools( diff --git a/app/harness/split_selection.py b/app/harness/split_selection.py index b928201..c9c7206 100644 --- a/app/harness/split_selection.py +++ b/app/harness/split_selection.py @@ -7,11 +7,36 @@ build_video_records / select_split。 from __future__ import annotations +import hashlib import random from dataclasses import dataclass, field from loguru import logger + +def diag_fingerprint(prompt_version: str, model: str, code_version: str) -> str: + """由 (诊断 prompt 版本, 模型名, 代码 git 短 SHA) 合成诊断口径指纹。 + + 诊断信号以 (question_id, baseline_run_id, diag_fingerprint) 为主键持久化, + 指纹隔离不同诊断配置的信号——换 prompt 版本 / 换模型 / 换代码实现都会得到 + 新指纹,从而 `--force` 用新指纹重跑诊断时**不覆盖旧记录**(旧指纹行仍在), + 保证不同口径的诊断结果可并存、可回溯、可比对。 + + 参数: + prompt_version: 诊断 prompt 的版本标识(如 prompts/diagnose_*.md 的版本)。 + model: 执行诊断的模型名(如 "deepseek-v4")。 + code_version: 诊断代码的版本(约定为 git 短 SHA)。 + + 返回: + 16 位十六进制指纹(sha256 截断),对三分量任一变化敏感、对相同三元组确定。 + + 实现细节: + 三分量用 "|" 分隔后 sha256,取前 16 位;分隔符防止 ("ab","c") 与 ("a","bc") + 碰撞成同一指纹。纯函数,相同输入永远同输出,可安全用于主键。 + """ + return hashlib.sha256("|".join([prompt_version, model, code_version]).encode()).hexdigest()[:16] + + _EVOLUTION_TARGET = { "extraction_failure": "tool", "search_failure": "skill", diff --git a/config/video_split.yaml b/config/video_split.yaml new file mode 100644 index 0000000..127788b --- /dev/null +++ b/config/video_split.yaml @@ -0,0 +1,25 @@ +# config/video_split.yaml +# 结果驱动视频级切分 —— 科研旋钮快照(会在实验中被反复扫动 / 对比的参数)。 +# 工程配置(harness.db 路径、LLM 凭证、超时)由 .env / pydantic-settings 管理,不在此文件。 +# 优先级: CLI args > 此文件。CLI 仅用于单次临时覆盖。 +# +# ⚠️ 下列阈值为「占位值」,必须在离线诊断跑完后用真实 T2 分布标定。 +# 标定程序见 scripts/build_video_split.sh 顶部注释(诊断产物 → 逐旋钮定值)。 + +video_split: + baseline_run_id: infer_adhoc # 基线 run 标识(错题诊断与切分依据),对应 workspaces/default/harness.db + n_trainval: 100 # trainval 目标视频数(多样性阶段填充上限;标定取 ~100) + epsilon: 0.1 # test 相对全局最大允许分布偏差(题型占比 / 难度画像两维,逐桶) + report_floor: 27 # per-type 报告门限:题数 ≥ 此值的 task_type 才入 ε 代表性约束 + val_wrong_min: 20 # validation 池最少错题数(McNemar 检验功效阈 ≈ 20,低于则信号不足) + val_ratio: 0.3 # validation 占 trainval 视频组总数的比例 + seed: 7 # 贪心选择器预洗牌 + 视频组题级切分种子(打破等增益 / 等槽平局) + floor_k: # 各高信号 task_type 的 T2 defect 下限(硬约束)—— 占位,标定后替换 + Counting Problem: 3 # 取克制值 min(诊断可用 defect 数, 3),避免把信号全抽进 trainval + Object Reasoning: 3 + Action Reasoning: 3 + +diag: # 诊断口径指纹三分量(隔离不同诊断配置的信号,参与主键) + prompt_version: diagnose_v1 # 诊断 prompt 版本标识(换 prompt 即换指纹,旧记录不被覆盖) + model: deepseek-v4-pro # 执行诊断的模型名(与 .env JUDGE_LLM_MODEL 对齐) + # code_version 由 build_video_split.sh 注入 git 短 SHA,不写死在此(随代码变动) diff --git a/core/types.py b/core/types.py index fd32aad..bb74667 100644 --- a/core/types.py +++ b/core/types.py @@ -160,6 +160,16 @@ class PoolConfig: eval_min_per_class: 验证池中每类保底样本数(GlobalStrategy 用)。 train_ratio: train/(train+val) 比例(PerCategoryStrategy 用)。 test_questions_dir: 外部 test 题源路径(PerCategoryStrategy 用)。 + n_trainval: trainval 目标视频数(结果驱动视频级切分用;0 表示不启用)。 + floor_k: 各高信号 task_type 的 T2 defect 下限(视频级切分硬约束;空表示无约束)。 + epsilon: test 相对全局的最大允许分布偏差(视频级切分 test 代表性守护)。 + report_floor: per-type 报告门限,题数 ≥ 此值的 task_type 才入 ε 约束(视频级切分用)。 + val_wrong_min: validation 池最少错题数(McNemar 功效护栏;0 表示不检查)。 + + 实现细节: + 视频级切分五个旋钮均带惰性默认(0 / 空 dict),使现有 GlobalPoolStrategy / + PerCategoryStrategy 的构造点无需改动即可保持行为不变。floor_k 为不可哈希 dict, + 标 hash=False 排除出 frozen dataclass 的自动 __hash__,避免入 set/dict 键时报错。 """ task_types: tuple[str, ...] | None @@ -174,3 +184,8 @@ class PoolConfig: train_ratio: float test_questions_dir: _Path | None batch_correct_ratio: float | None = None + n_trainval: int = 0 + floor_k: dict[str, int] = field(default_factory=dict, hash=False) + epsilon: float = 0.0 + report_floor: int = 0 + val_wrong_min: int = 0 diff --git a/scripts/build_video_split.sh b/scripts/build_video_split.sh new file mode 100755 index 0000000..d248e39 --- /dev/null +++ b/scripts/build_video_split.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# ============================================================================ +# 结果驱动视频级切分 —— capstone 可复现实验(离线管线最后一步) +# ---------------------------------------------------------------------------- +# 把整条离线管线串成零参可复现实验:诊断信号 → 贪心联合约束选择 → 冻结 pools.json。 +# 全部旋钮写死在 config/video_split.yaml,零参即可复现(GPU 卡号除外)。 +# +# 用法: +# CUDA_VISIBLE_DEVICES=0 bash scripts/build_video_split.sh +# +# ============================================================================ +# 两阶段流程 +# ---------------------------------------------------------------------------- +# ① 离线诊断(Task 4,run_baseline_diagnosis)——【LLM 重活,需单独跑一次】 +# 对 baseline run(infer_adhoc,workspaces/default/harness.db 内 236 道错题) +# 逐题跑诊断瀑布,把 defect/lapse/infra/degraded 投影为 tier 信号,逐行 upsert +# 到 harness.db 的 baseline_diagnosis 表(主键含 diag_fingerprint,断点续跑幂等)。 +# 诊断依赖完整 harness 装配(GovernedLLMClient / 树数据 / 诊断 prompt), +# 不在本脚本内联,由诊断编排单独执行;本脚本前置校验诊断信号已落库,缺失即 fail loud。 +# +# ② 冻结切分(Task 11,build_split)——【本脚本执行】 +# 读 canonical 基线预测 + 诊断信号 → 构建全视频画像 → 贪心选择 trainval/test → +# 视频组原子切三池 → 原子冻结 pools.json + 溯源 manifest → 六条防御断言。 +# +# ============================================================================ +# 标定程序(诊断跑完 → 用真实 T2 分布把 config/video_split.yaml 的占位值定死) +# ---------------------------------------------------------------------------- +# 1. floor_k : 读 baseline_diagnosis 各 task_type 的 T2(defect) 计数, +# 每个高信号题型取克制值 floor = min(可用 defect 数, 3), +# 避免把该类信号全抽进 trainval(会让 test 失去代表性)。 +# 2. n_trainval : trainval 目标视频数,取 ~100(总 300 视频的约 1/3), +# 给多样性阶段留足填充预算,同时保证 test ≥ 2/3 保代表性。 +# 3. epsilon : test 相对全局的最大分布偏差,取 0.1(题型/难度逐桶容忍 10%)。 +# 4. val_wrong_min : validation 池最少错题数,取 McNemar 检验功效阈 ≈ 20; +# 切分后 val 错题不足此值 → 验证信号不可靠 → fail loud。 +# 5. report_floor : per-type 报告门限,题数 ≥ 27 的 task_type 才入 ε 约束(滤长尾)。 +# ============================================================================ + +set -euo pipefail + +cd "$(dirname "$0")/.." + +CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}" +export CUDA_VISIBLE_DEVICES + +# 日志即时输出,不缓存,便于实时排错。 +export PYTHONUNBUFFERED=1 + +# 工程配置(DB 路径、LLM 凭证)走 .env,不写死在脚本。 +set -a +source .env +set +a + +PYTHON="$(conda run -n Video-Tree-TRM which python)" + +CONFIG="config/video_split.yaml" +HARNESS_DB="workspaces/default/harness.db" +QUESTIONS_DIR="store/questions/benchmarks/Video-MME" +OUT_DIR="workspaces/video-split" + +echo "=== 结果驱动视频级切分:冻结 pools.json(读 ${CONFIG})===" + +# 诊断口径指纹的代码分量 = 当前 git 短 SHA(诊断代码变则指纹变,旧信号不被覆盖)。 +CODE_VERSION="$(git rev-parse --short HEAD)" +export CODE_VERSION HARNESS_DB QUESTIONS_DIR OUT_DIR CONFIG + +"${PYTHON}" -c " +import datetime +import os +from pathlib import Path + +import yaml +from loguru import logger + +from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore +from app.harness.build_split import SplitBuildConfig, build_split +from app.harness.split_selection import diag_fingerprint + +config_path = Path(os.environ['CONFIG']) +cfg = yaml.safe_load(config_path.read_text(encoding='utf-8')) +vs = cfg['video_split'] +dg = cfg['diag'] + +# 诊断口径指纹:诊断 prompt 版本 + 模型名 + 代码短 SHA(三分量任一变则换指纹)。 +fingerprint = diag_fingerprint(dg['prompt_version'], dg['model'], os.environ['CODE_VERSION']) +logger.info('诊断口径指纹 diag_fingerprint={} (prompt={} model={} code={})', + fingerprint, dg['prompt_version'], dg['model'], os.environ['CODE_VERSION']) + +harness_db = Path(os.environ['HARNESS_DB']) +questions_dir = Path(os.environ['QUESTIONS_DIR']) +out_dir = Path(os.environ['OUT_DIR']) +out_dir.mkdir(parents=True, exist_ok=True) + +store = SqliteDiagnosisSignalStore(str(harness_db)) +try: + # 前置校验(P5 fail loud):本指纹下必须已有诊断信号(Phase 1 诊断先跑完)。 + signals = store.load(vs['baseline_run_id'], fingerprint) + if not signals: + raise SystemExit( + f'诊断信号为空:baseline={vs[\"baseline_run_id\"]} fingerprint={fingerprint} ' + f'在 {harness_db} 的 baseline_diagnosis 表无任何行。' + '请先跑离线诊断(Phase 1 run_baseline_diagnosis)再执行本脚本。' + ) + logger.info('诊断信号就绪:{} 行,开始冻结切分。', len(signals)) + + result = build_split( + db_path=harness_db, + baseline_run_id=vs['baseline_run_id'], + signal_store=store, + diag_fingerprint=fingerprint, + questions_dir=questions_dir, + config=SplitBuildConfig( + n_trainval=vs['n_trainval'], + floor_k=vs['floor_k'], + epsilon=vs['epsilon'], + report_floor=vs['report_floor'], + select_seed=vs['seed'], + val_ratio=vs['val_ratio'], + split_seed=vs['seed'], + ), + out_path=out_dir / 'pools.json', + manifest_path=out_dir / 'split_manifest.json', + generated_at=datetime.datetime.now(datetime.timezone.utc).isoformat(), + ) +finally: + store.close() + +# McNemar 功效护栏(P5 fail loud):build_split 按 Task 11 契约不带 val_wrong_min, +# 冻结后在 capstone 层校验 validation 池错题数是否达 McNemar 功效阈。 +pools = result['pools'] +val_wrong = sum(1 for q in pools.validation if not pools.correctness[q.question_id]) +val_wrong_min = vs['val_wrong_min'] +if val_wrong < val_wrong_min: + raise SystemExit( + f'validation 池错题数 {val_wrong} < val_wrong_min={val_wrong_min},' + '验证信号不足以支撑可靠比较(McNemar 检验功效不够)。' + '请放大 val_ratio / 调整旋钮后重跑,勿静默放行。' + ) + +logger.info( + '切分冻结完成:pools={} manifest={} val错题={}/{}(阈)', + out_dir / 'pools.json', + out_dir / 'split_manifest.json', + val_wrong, + val_wrong_min, +) +" + +echo "=== 完成:冻结产物在 ${OUT_DIR}/ (pools.json + split_manifest.json) ===" diff --git a/tests/unit/test_pool_config_video_split.py b/tests/unit/test_pool_config_video_split.py new file mode 100644 index 0000000..af45db0 --- /dev/null +++ b/tests/unit/test_pool_config_video_split.py @@ -0,0 +1,115 @@ +"""视频级切分科研旋钮单元测试:诊断指纹 + val_wrong_min 功效护栏 + PoolConfig 新字段。 + +覆盖: + - diag_fingerprint 对 (prompt 版本 / 模型 / 代码版本) 三元组确定且敏感; + - split_by_video_assignment 的 val_wrong_min 门控 fail loud(验证信号不足即报错); + - val_wrong_min 默认 0 时行为与 Task 11 现有调用完全一致(不回归); + - PoolConfig 能接收视频级切分的五个新旋钮字段(纯 dataclass 装配)。 +""" + +from __future__ import annotations + +import pytest + +from app.harness.pools import InsufficientValSignal, split_by_video_assignment +from app.harness.split_selection import diag_fingerprint +from core.types import GeneratedQuestion, PoolConfig + + +def _q(qid: str, vid: str, tt: str = "Counting Problem") -> GeneratedQuestion: + """构造最小可用题目(补齐 GeneratedQuestion 的必填 source_nodes/difficulty)。""" + return GeneratedQuestion( + question_id=qid, + video_id=vid, + task_type=tt, + question="", + options=("A", "B", "C", "D"), + answer="A", + source_nodes=(), + difficulty="easy", + ) + + +def test_diag_fingerprint_deterministic_and_sensitive(): + """诊断指纹对相同三元组稳定,对任一分量变化敏感。""" + a = diag_fingerprint("p1", "deepseek-v4", "abc123") + assert a == diag_fingerprint("p1", "deepseek-v4", "abc123") # 确定性 + assert a != diag_fingerprint("p2", "deepseek-v4", "abc123") # prompt 变则变 + assert a != diag_fingerprint("p1", "kimi", "abc123") # model 变则变 + assert a != diag_fingerprint("p1", "deepseek-v4", "def456") # 代码版本变则变 + assert len(a) == 16 # sha256 截断 16 位十六进制 + + +def test_val_wrong_min_enforced(): + """val 错题数 < val_wrong_min 时 fail loud(InsufficientValSignal),不静默兜底。""" + qs = [_q("v1-1", "v1"), _q("v1-2", "v1"), _q("v1-3", "v1")] + correctness = {q.question_id: True for q in qs} # 全对 → val 无错题 + with pytest.raises(InsufficientValSignal): # val 错题 < val_wrong_min + split_by_video_assignment( + qs, + {"v1": "trainval"}, + correctness=correctness, + val_ratio=0.5, + seed=0, + val_wrong_min=5, + ) + + +def test_val_wrong_min_default_zero_no_regression(): + """val_wrong_min 默认 0 时不检查错题数,保持 Task 11 现有调用契约不破。""" + qs = [_q("v1-1", "v1"), _q("v2-1", "v2")] + assignment = {"v1": "trainval", "v2": "trainval"} + correctness = {"v1-1": True, "v2-1": True} # 全对但默认不触发护栏 + pools = split_by_video_assignment( + qs, assignment, correctness=correctness, val_ratio=1.0, seed=0 + ) + assert len(pools.validation) == 2 # 未抛异常,正常返回 + + +def test_pool_config_accepts_video_split_knobs(): + """PoolConfig 能接收视频级切分五个新旋钮字段(默认惰性,不破坏现有构造点)。""" + cfg = PoolConfig( + task_types=None, + seed=0, + baseline_run_id="infer_adhoc", + diag_size=200, + diag_correct_ratio=0.5, + val_size=30, + val_correct_ratio=0.5, + test_size=60, + eval_min_per_class=2, + train_ratio=0.667, + test_questions_dir=None, + n_trainval=100, + floor_k={"Counting Problem": 3}, + epsilon=0.1, + report_floor=27, + val_wrong_min=20, + ) + assert cfg.n_trainval == 100 + assert cfg.floor_k == {"Counting Problem": 3} + assert cfg.epsilon == 0.1 + assert cfg.report_floor == 27 + assert cfg.val_wrong_min == 20 + + +def test_pool_config_video_split_knobs_default_inert(): + """未传视频级切分字段时默认惰性(0 / 空 dict),不破坏 GlobalPoolStrategy 现有构造。""" + cfg = PoolConfig( + task_types=None, + seed=0, + baseline_run_id="run_1", + diag_size=200, + diag_correct_ratio=0.5, + val_size=30, + val_correct_ratio=0.5, + test_size=60, + eval_min_per_class=2, + train_ratio=0.667, + test_questions_dir=None, + ) + assert cfg.n_trainval == 0 + assert cfg.floor_k == {} + assert cfg.epsilon == 0.0 + assert cfg.report_floor == 0 + assert cfg.val_wrong_min == 0