feat: add video-split config knobs and reproducible script

This commit is contained in:
2026-07-15 12:54:25 -04:00
parent 43d7346526
commit 6a21d80313
6 changed files with 351 additions and 0 deletions
+149
View File
@@ -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 4run_baseline_diagnosis)——【LLM 重活,需单独跑一次】
# 对 baseline runinfer_adhocworkspaces/default/harness.db 内 236 道错题)
# 逐题跑诊断瀑布,把 defect/lapse/infra/degraded 投影为 tier 信号,逐行 upsert
# 到 harness.db 的 baseline_diagnosis 表(主键含 diag_fingerprint,断点续跑幂等)。
# 诊断依赖完整 harness 装配(GovernedLLMClient / 树数据 / 诊断 prompt),
# 不在本脚本内联,由诊断编排单独执行;本脚本前置校验诊断信号已落库,缺失即 fail loud。
#
# ② 冻结切分(Task 11build_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) ==="