Compare commits

..

400 Commits

Author SHA1 Message Date
iomgaa 7386e5af57 Merge remote-tracking branch 'origin/main' 2026-07-17 06:05:42 -04:00
iomgaa 0414948682 merge: question-gen v3 + preflight fixes + continuous concurrent gate 2026-07-17 06:02:39 -04:00
iomgaa 9bb09a44d4 feat: add Slurm gpu-sbatch training entry for 4090-server 2026-07-17 06:02:39 -04:00
iomgaa b0be1f1ae5 docs: gate speedup final review and delivery record 2026-07-17 05:38:44 -04:00
iomgaa 172b7a8ec4 merge: continuous concurrent gate speedup (algo #6 semantic revision, design v3) 2026-07-17 05:29:42 -04:00
iomgaa eb12006d38 fix: idempotent ladder_rank migration for legacy gate_evidence tables 2026-07-17 05:29:20 -04:00
iomgaa 1930ad32a4 chore: raise Redis cache TTL to 7 days 2026-07-17 04:52:34 -04:00
iomgaa 8958eee11b refactor: remove block-sequential gate path and gate_block knob (algo #6)
config/train_videomme.yaml 同时收录待入库的实验配置变更(run_id v2 /
concurrency 32 / batch_size 40)。tests/integration/test_v3_contract_e2e.py
的 run_id 断言按 Task 5 显式契约同步修正(原断言依赖旧隐式实例注入)。
2026-07-17 04:40:30 -04:00
iomgaa 0b839937df fix: predictions row carries arm run_id under shared gate_log; drain evolve gather on failure (algo #6) 2026-07-17 03:52:19 -04:00
iomgaa 23a64042fe feat: parallel evolve + continuous gate wiring in runner (algo #6) 2026-07-17 03:32:14 -04:00
iomgaa b3aba7c31d fix: escape all LIKE specials in step-row cleanup 2026-07-17 01:17:31 -04:00
iomgaa 16993ed362 style: consolidate inference stub, complete docstrings (algo #6) 2026-07-17 01:13:19 -04:00
iomgaa ea6bec5421 fix: clear gate-derived rows on step rerun (idempotency) 2026-07-17 00:57:53 -04:00
iomgaa 9e8a254fbb fix: gate orchestrator materialize leak + cancel-drain on abort (algo #6)
Codex 质量审 2 Critical:
- C001: 候选物化移入 try、成功一个登记一个,第 N 个题型物化失败时
  finally 仍清理前 N-1 个已建目录,不泄漏
- C002: gather 首异常(护栏 raise)后显式取消其余任务并排水,确保
  finally 删除候选目录时无在飞任务访问、事件循环无 pending task 警告;
  护栏中止整轮语义不变
- 新增 2 测试:部分物化失败清理 / 护栏 raise 取消收束不悬挂(wait_for 5s)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:45:47 -04:00
iomgaa e8b66f85ab feat: continuous concurrent gate orchestrator (algo #6)
- validate_skills_concurrent: 多题型全部臂共享题槽并发编排,发射序 =
  题型 round-robin × 阶梯序(base 先 cand 后),终态统一组装 outcome,
  verdict None(全 INFRA)保留 RuntimeError 语义
- gate_evidence 列 block_idx → ladder_rank(阶梯序号,0-based);旧块路径
  _build_evidence_rows 仅键名同步(值仍为块号)保持落库兼容
- 新增 3 项编排测试:乱序到达前缀有序性/双题型隔离/全 INFRA raise

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:36:01 -04:00
iomgaa c61a6dac84 docs: soften slot fairness claim, complete docstrings (algo #6) 2026-07-17 00:28:25 -04:00
iomgaa 30c1cf10c0 fix: gate slot cancel-safety + post-inference freeze discard (algo #6)
Codex 质量审 4 项:推理后二次冻结检查(τ 后 in-flight 结果整体丢弃)、
acquire 取消回滚(半持有 permit 自动归还)、BoundedSemaphore 防静默扩容、
补取消恢复与冻结丢弃两个回归测试。
2026-07-17 00:25:18 -04:00
iomgaa 232afd525b feat: gate unit-arm tasks with question-slot gate (algo #6) 2026-07-17 00:13:51 -04:00
iomgaa 0a8e1ad18b docs: complete four-part docstrings for gate prefix section (algo #6) 2026-07-17 00:06:35 -04:00
iomgaa 2ab8088c81 test: strengthen frozen-noop full-state assertion (algo #6) 2026-07-17 00:03:36 -04:00
iomgaa 1e92928d4e fix: lock all-INFRA contract, idempotency tests, tuple units (algo #6) 2026-07-17 00:00:56 -04:00
iomgaa 21c360bc87 feat: gate prefix-ordered consumption core (algo #6)
CE-Gate 语义修订获批:块序贯 → 阶梯序前缀逐对序贯。新增 GateSpec/_UnitSlot/
_GateRun 数据结构与 _advance_prefix 纯逻辑(乱序到达下统计严格按预声明阶梯序
消费,INFRA 剔除后重判防 continue 悬置,过线即冻结)。旧块路径共存,Task 6 删。
2026-07-16 23:44:32 -04:00
iomgaa b02db74237 docs: apply Codex plan review fixes (tail-INFRA rejudge, slot deadlock guard) 2026-07-16 23:30:56 -04:00
iomgaa 85ccad3b72 docs: implementation plan for continuous concurrent gate 2026-07-16 23:12:53 -04:00
iomgaa 0e8069af27 docs: gate speedup v3 - ladder-prefix consumption fixes arrival-order bias (Codex round 2) 2026-07-16 22:46:01 -04:00
iomgaa 53a8c4be02 docs: redesign gate speedup v2 (continuous concurrent gate, drop preload) 2026-07-16 22:24:28 -04:00
iomgaa 269e0d294c docs: design for gate speedup (preload BaselineCache + parallel arms) 2026-07-16 14:23:47 -04:00
iomgaa cc01d5ed62 fix: address Codex review of telemetry fix (mkdir degrade, close, degrade tests) 2026-07-16 08:53:50 -04:00
iomgaa 065c8ae1b9 fix: telemetry single persistent connection + lock (kill concurrent write lock) 2026-07-16 08:47:23 -04:00
iomgaa 766592d855 docs: design for telemetry SQLite concurrency fix (align HarnessLog pattern) 2026-07-16 08:47:23 -04:00
iomgaa 933de6251e chore: add FORCE flag to split script; align train config pool sizes to 0.4 split 2026-07-16 07:46:31 -04:00
iomgaa 5fdcd9b5aa feat: add Video-MME 900 training entry (config + self-contained script) 2026-07-16 07:34:14 -04:00
iomgaa a1c4e051e7 Merge branch 'feat/preflight-train-fixes': preflight training fixes (4 WPs, 21+ defects) 2026-07-16 07:25:50 -04:00
iomgaa 6a9b1ea188 docs: record preflight final review + integration fixes (I-3/I-4/M-2) 2026-07-16 07:21:10 -04:00
iomgaa 6911c83d66 refactor: share INFRA stop-reason set from core.evolution 2026-07-16 07:19:31 -04:00
iomgaa b3ba11c7a5 fix: count gate INFRA guard numerator by unit not record 2026-07-16 07:16:33 -04:00
iomgaa ee69721ea3 fix: filter frozen pools by requested task_types subset 2026-07-16 07:13:31 -04:00
iomgaa e1f08dcd3b fix: count units not questions in trainability pre-flight; fail-fast when all types filtered 2026-07-16 06:45:16 -04:00
iomgaa ef244c52bd chore: add trainable_min_units to train_ar30 config 2026-07-16 06:35:10 -04:00
iomgaa ed59c6091a test: accept cache_salt kwarg in AgentLoop mock LLM stub 2026-07-16 06:34:16 -04:00
iomgaa 9be3bdb8eb feat: inject run_id as cache_salt for per-epoch resampling (algo #10) 2026-07-16 06:33:09 -04:00
iomgaa 58a0203522 perf: dedup holdout four-way eval (baseline derive, best_hard memo) 2026-07-16 06:31:00 -04:00
iomgaa 77fd35830c fix: idempotent _run_step (DELETE stale) + checkpoint after gate save 2026-07-16 06:27:38 -04:00
iomgaa efbdeb1647 fix: route None/degraded diagnoses to lapse; abort on high degrade rate 2026-07-16 06:24:11 -04:00
iomgaa 65126feada fix: patch checks full target span + marker injection/integrity (algo #8) 2026-07-16 06:20:29 -04:00
iomgaa caea9150b3 feat: pre-flight filter of untrainable task types before gate 2026-07-16 06:16:02 -04:00
iomgaa c44f6010eb fix: early_stop patience counts epochs not steps 2026-07-16 06:09:11 -04:00
iomgaa 25918a73ff fix: normalize non-scalar prediction; harden predictions insert 2026-07-16 06:06:58 -04:00
iomgaa 7d02cded99 fix: wrap diagnosis run_log with StepsJsonRunLog (restore algo #7 traces) 2026-07-16 06:05:55 -04:00
iomgaa e1cac644c8 docs: address Codex review of WP3 plan (pools passthrough, holdout memo, DELETE table) 2026-07-16 06:03:17 -04:00
iomgaa 03337af8f8 fix: gate INFRA isolation edge cases (all-INFRA fail-loud, parse_error in guard) 2026-07-16 05:54:02 -04:00
iomgaa b307f51340 feat: --retry-uncertain re-diagnoses uncertain rows; fix docstring 2026-07-16 05:41:19 -04:00
iomgaa c68de849da fix: slow-update R2 dual_metric uses slow_candidate kind (not final) 2026-07-16 05:38:05 -04:00
iomgaa 1468a53b7a fix: read-only baseline queries skip _runs upsert (register_run flag) 2026-07-16 05:36:47 -04:00
iomgaa 96884dd149 fix: atomic writes for manifest/record_run/update_best (tmp+replace) 2026-07-16 05:34:45 -04:00
iomgaa d1516bf56b fix: isolate gate baseline-arm INFRA errors from BaselineCache (algo #6) 2026-07-16 05:33:21 -04:00
iomgaa 87908b23eb fix: half-open circuit admits single probe (no thundering herd) 2026-07-16 05:27:46 -04:00
iomgaa 1cbfa97b8d fix: cover httpx disconnect family in transient error set 2026-07-16 05:26:52 -04:00
iomgaa 200053fbdc fix: treat SSE stream without [DONE] as truncated (retry, no cache) 2026-07-16 05:26:21 -04:00
iomgaa 7cc6aa5b23 fix: reject REDIS_CACHE_TTL<=0 (kill implicit never-expire) 2026-07-16 05:25:36 -04:00
iomgaa 564c92a043 feat: add cache_salt dimension to LLM response cache 2026-07-16 05:23:58 -04:00
iomgaa c5c11ea81b docs: address Codex review of WP4 plan (salt key compat, store/protocol, INFRA path) 2026-07-16 05:21:18 -04:00
iomgaa 04bda25335 docs: update WP4 plan workspace anchors after WP2 line drift 2026-07-16 05:14:12 -04:00
iomgaa 8f349c5c62 fix: make frozen product backup unique and atomic on --force 2026-07-16 05:12:08 -04:00
iomgaa a0c7e043e8 fix: validate global frozen pools baseline_run_id + sha256 on load 2026-07-16 05:05:15 -04:00
iomgaa 4d60a545f6 feat: seed carries frozen pools.json into training workspace 2026-07-16 05:02:16 -04:00
iomgaa a39846af6e feat: guard frozen split products against silent overwrite (--force) 2026-07-16 05:00:42 -04:00
iomgaa 5bb8319220 feat: tier-aware diag/val split with val-power repair (design 5.1) 2026-07-16 04:58:41 -04:00
iomgaa 6432ffef9a feat: add dedupe_per_question to extract_run_db (canonical 902->900) 2026-07-16 04:54:19 -04:00
iomgaa 06c575f0a4 chore: bump video_split val_ratio 0.3->0.4 for terminal-eval power 2026-07-16 04:53:14 -04:00
iomgaa 4fd8a6bfef docs: fix WP2 plan GeneratedQuestion import + NULL qid note (Codex review) 2026-07-16 04:52:31 -04:00
iomgaa 232a56e826 fix: fail-loud on missing evolve/diagnose templates (no silent empty) 2026-07-16 04:43:22 -04:00
iomgaa 1108235818 refactor: drop dead prompt fields consolidate_system/span_eval_user 2026-07-16 04:42:26 -04:00
iomgaa d3639a94b8 feat: migrate 5 evolve/momentum templates from TRM4 (algo #8) 2026-07-16 04:41:02 -04:00
iomgaa 6cdf05d56b docs: address Codex review of WP1 plan (real loader tests, red-green order) 2026-07-16 04:38:51 -04:00
iomgaa 39a9210e4d docs: add 4 work-package implementation plans for preflight fixes 2026-07-16 04:32:47 -04:00
iomgaa a4c429b247 chore: snapshot in-progress question-gen work before preflight fixes 2026-07-16 04:12:21 -04:00
iomgaa 11a5545f57 docs: address Codex design review (coverage matrix, salt/gate pairing, tier termination) 2026-07-16 03:52:50 -04:00
iomgaa d7e965639e docs: add preflight training-fixes design (23 bugs + wiring + split tuning) 2026-07-16 03:44:31 -04:00
iomgaa 57f590b57e test: integration for diagnosis tree_data link + core dep direction 2026-07-15 23:05:27 -04:00
iomgaa 224bb25bb5 fix: load real tree_data for training-loop diagnosis (runner)
算法保真 §4.7#12(训练循环编排):仅将 _run_diagnosis 内 tree_data={} 替换为
按 batch question_ids 涉及 video 加载真实树注入 run_diagnosis,未改动三级嵌套/
慢更新/断点续训/gate 等编排逻辑。覆盖 question_ids=None 全量诊断与
question_ids=[...] batch 两条路径。
2026-07-15 23:02:17 -04:00
iomgaa 8dedd082ff fix: self-contained tree-inject test + fail-loud on missing question (review) 2026-07-15 22:48:37 -04:00
iomgaa 83056688cf fix: load real tree_data for offline diagnosis (video_split_cli) 2026-07-15 22:38:23 -04:00
iomgaa f1b6865861 fix: include question_id in diagnosis tree-coverage fail-loud message 2026-07-15 22:33:23 -04:00
iomgaa c5fff7f8b3 fix: fail loud when diagnosis video tree not covered (algo #7 input) 2026-07-15 22:25:30 -04:00
iomgaa d6c595c4a4 fix: fail-loud on node without time info; anchor test store path 2026-07-15 22:16:39 -04:00
iomgaa 05294412df test: cover missing-id and non-list roots; drop unreachable empty-nodes guard 2026-07-15 22:11:36 -04:00
iomgaa e6adaad8b3 feat: add tree.json flattener for diagnosis ground_truth 2026-07-15 22:05:50 -04:00
iomgaa 748a969e0e docs: implementation plan for diagnosis tree_data fix 2026-07-15 22:01:20 -04:00
iomgaa 63dfee1b9b docs: design for diagnosis tree_data broken-link fix (TRM4 regression) 2026-07-15 21:44:42 -04:00
iomgaa 266a1ab4bc fix: use conda run --no-capture-output for unbuffered real-time logs 2026-07-15 14:02:13 -04:00
iomgaa 94728c5220 chore: set video-split diagnosis concurrency to 12 (overridable) 2026-07-15 13:57:59 -04:00
iomgaa 26ab536fea chore: migrate diagnosis prompts from TRM4 (rename to TRM5 loader filenames) 2026-07-15 13:53:16 -04:00
iomgaa 8fef7ced42 fix: address whole-impl review (INFRA T0 rows, reproducible manifest, evolution_target report, dead config, canonical DRY)
C-1: persist_infra_t0_rows 补 INFRA/空预测错题的 T0 信号行(不进诊断故须单独落库),run_pipeline 加 Phase 0,dry-run 用假数据走通。
C-2: CLI 加 --generated-at,真实运行默认盖真实 UTC now,可显式固定以字节级复现 manifest。
I-1: coverage_report 增 evolution_target_distribution(T2 信号按 tool/skill/system 计数)。
I-2: 删除 PoolConfig 死字段 n_trainval/floor_k/epsilon/report_floor/val_wrong_min(grep 确认无消费者,视频级切分用独立 VideoSplitConfig/SplitBuildConfig/SelectConfig)。
I-3: 抽共享 load_canonical_predictions(db_path, run_id),CLI 与 build_split 共用;消除 canonical 取行 + correct 判定重复。
M-1: build_split docstring 注明 val_wrong_min-agnostic 契约(McNemar 护栏由 CLI 冻结后执行,Task 11 契约)。
2026-07-15 13:39:14 -04:00
iomgaa 02b8145b7f fix: fail loud on diag model fingerprint drift vs .env SEARCH_LLM_MODEL
Codex CHANGES_REQUESTED 复审:
- Critical:diag_fingerprint 用 config.diag.model,但 Phase 1 诊断 LLM 从 .env
  SEARCH_LLM_MODEL 构建,两者不一致会让信号以错误模型指纹落库,破坏可复现/
  resume/隔离。build_diagnosis_deps 新增 expected_model 参数,Phase 1 执行前
  fail loud 校验 config.model == settings.search_llm_model(附两值)。
- Minor:config/video_split.yaml diag.model 注释由 JUDGE_LLM_MODEL 更正为
  SEARCH_LLM_MODEL,与实现对齐。
- 补两个单测:模型不一致 fail loud + 缺凭证 fail loud。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:20:51 -04:00
iomgaa 2844732126 refactor: self-contained two-phase video-split CLI entry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:12:21 -04:00
iomgaa 6a21d80313 feat: add video-split config knobs and reproducible script 2026-07-15 12:54:25 -04:00
iomgaa 43d7346526 feat: wire end-to-end results-driven split with defensive asserts 2026-07-15 12:43:25 -04:00
iomgaa b497db97ba fix: guard floor phase against n_trainval budget + assert epsilon on output
Codex Task 8 审查修复:
- Important 1: _satisfy_floors 每步移入前检查预算,floor 需求超 n_trainval 时
  抛 InfeasibleSplitError(fail loud),保证 trainval 永不超额挤占 test;补预算超限测试。
- Important 2: 确定性测试末尾用 _epsilon_ok 断言产出 test 真满足 ε(两维偏差回归护栏),
  并断言 trainval <= n_trainval。
- Minor: fixture docstring 注明结构真实 / signal 二次构造,正式运行由真实诊断替换。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:38:33 -04:00
iomgaa c3187167c8 feat: add greedy joint-constrained split selector
两阶段贪心视频级切分:Floor 硬约束优先满足 + 多样性 submodular 覆盖最大化,
两阶段均带 ε 守护保 test 代表性;不可行 fail loud(InfeasibleSplitError),
多样性欠额记 loguru warning 不静默。derive_reportable_types 落地长尾报告门限。
确定性:Random(seed) 预洗牌打破等增益/等槽数平局,同 config 同 videos 同解。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:32:24 -04:00
iomgaa 84b0b30213 fix: dedup three-bucket projection + honest C3 error-handling note 2026-07-15 12:29:58 -04:00
iomgaa aa10485b9f fix: make pools.json freeze atomic + add split manifest 2026-07-15 12:28:48 -04:00
iomgaa fd907aab46 fix: extend correctness fail-fast to test-side pool questions (P5) 2026-07-15 12:22:11 -04:00
iomgaa 53989078a0 feat: add offline baseline diagnosis orchestration 2026-07-15 12:22:07 -04:00
iomgaa 3d8bd75372 feat: build all-video records with difficulty and signal overlay 2026-07-15 12:18:56 -04:00
iomgaa 20eea98cdd refactor: add video-atomic pool split (algo #5 gate input preserved) 2026-07-15 12:14:36 -04:00
iomgaa 9d19328cc9 feat: add signal tiering 2026-07-15 12:12:48 -04:00
iomgaa 75a2aac1c0 test: cover unknown error_type ValueError path 2026-07-15 12:10:24 -04:00
iomgaa 0a7ba724a9 feat: add evolution_target derivation and cell 2026-07-15 12:06:56 -04:00
iomgaa 3a8dd5c167 fix: add close/context-manager to diagnosis store; tighten import boundary 2026-07-15 12:06:29 -04:00
iomgaa 6b85fcbe1c feat: add baseline diagnosis signal store 2026-07-15 12:01:30 -04:00
iomgaa 01f2e7c7b9 feat: add steps_json-backed RunLog wrapper 2026-07-15 11:51:55 -04:00
iomgaa cf7f15d8bb feat: add steps_json to trace-row adapter 2026-07-15 11:47:00 -04:00
iomgaa 745b600b5d docs: register baseline_diagnosis and split_manifest schemas with baseline metrics 2026-07-15 11:42:38 -04:00
iomgaa cfa510a7e5 docs: add results-driven video-split implementation plan 2026-07-15 11:38:32 -04:00
iomgaa 312cee7be7 docs: add results-driven video-level split design 2026-07-15 11:17:45 -04:00
iomgaa f8ad8f0143 test: harden baseline_cache guard to per-call unit_id check 2026-07-15 10:13:33 -04:00
iomgaa a536a81b0d chore: apply ruff format to dead-path guard test 2026-07-15 10:07:19 -04:00
iomgaa 13234426b5 chore: guard perquestion revival + mark v2 tables deprecated 2026-07-15 10:06:13 -04:00
iomgaa e9349b55c0 test: add v3 pair-contract full-chain integration regression 2026-07-15 09:57:39 -04:00
iomgaa 58a84dc72b refactor: add CHECK constraints and drop redundant index in v3 tables 2026-07-15 09:47:00 -04:00
iomgaa 3cc8dc9105 refactor: assert warn sink, rename disk-pair sifter, trim WHAT docstrings
Capture loguru warning via project sink pattern and assert the dangling-orphan
warning is emitted; rename _sift_disk_pairs to _keep_complete_disk_pairs; drop
pure-WHAT docstrings on __init__/pending_orphans while keeping WHY notes.
2026-07-15 09:42:27 -04:00
iomgaa 5ecbac620c feat: add v3 question-gen observation tables to run_store 2026-07-15 09:33:40 -04:00
iomgaa 1ecb6ba25c fix: fail-loud on disk-corrupt pairs and preserve buffer on reject
read_accepted now distinguishes size==1 dangling orphans (warn+drop) from
structural corruption / role duplication (raise), and runs explicit binding
checks (video_id/task_type/flip_axis) that survive python -O. add() validates
before evicting the buffered partner so pending_orphans can recover it.
2026-07-15 09:31:42 -04:00
iomgaa bdcc93d7de feat: add pair atomic accepted-bank writer helper
PairPendingBuffer collects pair members by pair_id and emits units;
write_accepted does tmp+os.replace atomic write; read_accepted rebuilds
units and drops disk-dangling orphans. Pure helper; on_accept wiring is
Phase 2 (real write point: adversarial_filter.write_final_bank).
2026-07-15 09:22:41 -04:00
iomgaa 19911e18e0 test(harness): 黄金测试加非空护栏、补类型注解、端到端对照旧逻辑
回应 Codex 审查三项测试质量问题:
- C1: 每个 byte-identical 断言前加非空护栏(sum(len)>0 / len==samples>0),
  防空==空误通过。
- I1: TestBatchingByteIdentical._assert 补齐完整类型注解。
- I2: 端到端 checkpoint 用例增加 _reference_build_batches 对照,断言真实池划分
  →batching 与旧逐题逻辑逐字节一致(不只是 rebuilt==batches 自往返)。
2026-07-15 08:28:12 -04:00
iomgaa 6c6fb576ee feat(harness): checkpoint 存 unit_id 序列,断点续跑孪生对不拆
核心算法保真 #3(断点续跑):checkpoint 从逐题 question_id 改为存 unit_id
序列(孪生对折叠为单个 unit_id),恢复时 build_units + 按完整 unit 展开,
续跑后 pair 两成员同进同出、绝不被劈开。

- _batch_unit_ids/_batch_from_ids 对称折叠/展开,保序去重,纯非 AR 下
  unit_id==question_id、与旧逐题序列逐字节一致。
- momentum 采样抽取为 _sample_momentum_candidates 纯函数,docstring 显式
  记录 Phase 1 设计偏差:仅保证纯非 AR byte-identical,混格 momentum 不保证。
- 新增 test_checkpoint_pair(unit_id 落盘往返、pair 不拆)与
  test_non_ar_byte_identical(pools→batching→checkpoint→momentum 端到端黄金)。
2026-07-15 08:22:18 -04:00
iomgaa bd1f7a22a2 feat(harness): pools.json 序列化 pair 四字段防孤儿 single
_q_to_dict 写出 pair_id/question_role/flip_axis/unit_id,_dict_to_q 用 .get
兼容旧 workspace 的 pools.json 读回并回填(unit_id 缺省交 __post_init__)。
pools.json 是训练主回路读回题目处,此前漏写会让孪生对解冻后退化成孤儿
single、配对指标失真。categories 块沿用 per-qid 记录,Task 3 的 unit 原子
切分已保证两 pair 成员同池同 key,序列化不破坏该原子性。
2026-07-15 08:05:15 -04:00
iomgaa 273984674b feat(harness): 迁移 gate 信息量阶梯到 unit 粒度 + schema_version
核心算法保真#5(信息阶梯):gate_ladder.py 从逐题迁移到 unit 粒度,
只换键 question_id→unit_id,冷启动 2:1 错优先交错、gamma-EMA 公式、
Beta(1,1) 先验、反泄漏 _gate_ 过滤的公式/比例/顺序语义一字不改。

- LadderEntry 按 unit_id 键;AR pair 折叠为一个阶梯单元
- build_cold_entries 收单元列表,unit 错 = 任一成员错(双向 AND)折叠,
  2:1 交错 + probe 探针按 unit 抽,Beta 先验 p0 不变
- ladder_for 返回 unit_id 序、exclude 迁到 unit 口径(防半 pair 灌入
  触发下游 _ladder_units fail-fast)
- update_probs 先把逐题观测折叠成单元观测再按 unit_id 匹配更新,
  半观测单元跳过(防按 qid 匹配 pair 失效致 gamma-EMA 停摆)
- GatePools.save/load 加 schema_version=2;存量无版本/旧版本 json
  加载直接报错,拒绝静默混用 qid/unit 键
- BaselineCache 第四维键改名 unit_id(与 T7 validate 路径对齐)
- build_or_load_gate_pools 先折叠单元再排除 test(抽 helper 控复杂度 B)
- runner:_init_gate_pools 建 unit 索引;gate 验证 exclude/展开、
  _refresh_gate_ladder 折叠观测走 units_by_id

反泄漏 run_id 含 _gate_ 过滤不受影响(未改)。
测试:新增 test_gate_ladder_unit_migration.py(15 例覆盖 a-e),
既有 test_harness_gate_ladder.py 迁移到 unit API。全量 1363 passed。
2026-07-15 07:56:15 -04:00
iomgaa 7e97081779 test(harness): 补 _ladder_units 直测 + 澄清 gate 观测表 unit_id 口径注释
M1:quadrant_pair / gate_evidence 的 question_id 列注释与 write_* docstring
更正为承载 unit_id(single=question_id、pair=pair_id),提示逐题明细在
predictions 表溯源、按 pair_id join 真实 question 表会 join 不上。

M2:给 _ladder_units 补直接单测——纯非 AR 恒等(unit 序==原题序、
unit_id==question_id)、混格交错保持信息阶梯序(按单元最早出现下标重排、
pair 整锁)、且与 build_units 的 single-first 默认序显式区分(防阶梯序被污染)。
2026-07-15 07:40:36 -04:00
iomgaa 4b6d1d8a50 feat(harness): correctness 三口径 + gate 块按 unit 跑
进化引擎与 gate e-process 从 question_id 口径迁至 unit_id 口径,AR pair
双向 AND 折叠为单元、不被 P/Q 单题计分污染;逐题 predictions 仅作溯源。

- question_units: 新增 unit_correctness_view(units, per_q)->dict[unit_id,bool]
  作为逐题→单元折叠的唯一入口(复用 unit_correctness)。
- core/evolution/validate: pair_block/compute_accuracy 参数改 unit_ids、
  分母按单元数(键即 unit_id)。
- app/harness/validate(gate 实际执行路径):阶梯题序聚合为单元并保持信息
  阶梯序(_ladder_units),gate 块按单元切分(AR pair 整锁不跨块拆);
  baseline_cache 键含 unit_id、存单元级对错;候选逐题读回后折叠成单元视图;
  n_used/W/L/四象限/准确率均按单元计;证据行按 unit 口径,candidate_correctness
  独立保留逐题对错供 runner 二轨合并。
- runner: probation 结算按 unit 折叠计 W/L(_probation_unit_flips);quadrant
  四象限 id 承载 unit_id。

核心算法保真 #5(信息阶梯 e-process):本次仅迁移 correctness 口径,不改冷启动
2:1 / gamma-EMA / 反泄漏算法本身(gate_ladder 迁移见 Task 8)。
2026-07-15 07:31:03 -04:00
iomgaa dee6bf4896 test: assert specific missing question_id in T6 invariant error match 2026-07-15 07:09:54 -04:00
iomgaa 8a69a54078 refactor: fail-loud unit grading and stricter orphan pair drop
Address review: replace bare KeyError with a contextual ValueError invariant
check in _grade_unit (fail-loud, no catch/skip/fallback); tighten
_drop_orphan_pairs to require exactly one original + one mirror with no extra
illegal-role records (total==2); clarify InferenceResult docstring to unit-grained
semantics. Add tests for missing-prediction descriptive error and extra-illegal-role
pair drop.
2026-07-15 07:05:37 -04:00
iomgaa 61bfa0e633 style: apply ruff format to T6 inference aggregation 2026-07-15 06:58:51 -04:00
iomgaa 730caa7e9a feat: aggregate inference by question unit with pair AND
Reuse build_units/unit_correctness (pair contract single entry) in the
inference aggregation step: single questions count as one unit, AR pairs
collapse original+mirror into one unit scored by bidirectional AND. total/
correct/per_task_type are unit-grained; orphan pairs (missing one side) are
warned and dropped, not counted. Per-question predictions still land row by
row (traceability unchanged).
2026-07-15 06:57:48 -04:00
iomgaa 2429dad393 feat(batching): unit 粒度切分——pair 整锁 + 单元级分桶 + 非 AR 独立 rng
build_batches 改以 QuestionUnit 为原子调度单元:孪生对 2 题整锁进同一 batch、
按单元级正确性(双向 AND)落 correct/error 桶,不再因 P 对 Q 错被劈或被 FFD 拆箱。

- 非 AR(single)用 random.Random(seed) 复现旧逐题算法确切 draw 序列,AR(pair)
  用 _rng_ns(seed,"AR") SHA-256 派生独立流;二者 draw 流互不干扰,故 AR 折叠不改变
  非 AR 抽样/洗牌序列——纯非 AR 输入 build_batches 结果与引入 QuestionUnit 前逐字节一致。
- FFD 容量按 unit.size(pair 占 2),round-robin 遇碎片新开 bin 兜底而非报错。
- _select_mixed_by_task_type 分流各跑一次后合并,大类洗牌按 kind 拆分各用对应 rng。

新增黄金测试 test_batching_pair_lock.py 覆盖三条铁律(同 batch / 单元分桶 /
非 AR byte-identical + draw 流独立);既有 batching 测试全绿。
2026-07-15 06:46:28 -04:00
iomgaa c412698cff style(test): 修 loader unit-sampling 测试的 ruff 告警
TC003 将 Path 导入移入 TYPE_CHECKING 块(仅注解使用),
C408 将 base = dict(...) 改为字典字面量。行为不变。
2026-07-15 06:30:14 -04:00
iomgaa d6a3107e4e feat(question_gen): loader 按 unit 分层采样 + load_benchmark 读回 pair 字段
stratified_sample 先 build_units 聚合,以 QuestionUnit 为采样原子做
分层/去重/补足/rng.sample,返回前 flatten_units 展开为逐题列表;
size/correct_ratio/min_per_class 均按 unit 计数,单元正确性走成员 AND,
孪生对两题永不被劈开。纯 single 输入下 build_units 1:1 折叠、顺序不变,
rng 消耗与旧逐题实现字节级一致(新增回归测试守护)。

_backfill_per_class candidates 改按 unit 枚举去重;build_units/flatten_units
函数内延迟导入以规避 question_gen<->harness 循环依赖(沿用 adversarial_filter)。

load_benchmark 反序列化补 pair_id/question_role/flip_axis/unit_id 四字段,
用 .get 兼容旧 JSON(缺失退化为 single,unit_id 由 __post_init__ 回填)。

pools._sample_excluding 随之改为透传 flatten_units(candidates) 给已单元化的
stratified_sample(不再用 lone pair-original 代表),行为对 single-only 保持等价。
2026-07-15 06:27:41 -04:00
iomgaa ddb9a44f75 feat(pools): 三池切分以 unit 为原子,孪生对同池不被拆散
build_pools/_sample_excluding 与 PerCategoryPoolStrategy._split_one_category/
build_incremental 两条切分路径均改为以 QuestionUnit 为采样原子:progressive
exclusion 互斥集合与 train/val 分层划分都按 unit_id 计数(pair 计 1 个 unit),
命中单元整体展开,AR 孪生对两题永不落入不同池/split。

复用 app.harness.question_units 的 build_units/flatten_units,不重写分组逻辑。
single-only 输入下 unit 与 question 一一对应、rng 消耗量不变,采样与划分结果
与逐题口径完全一致;抽出 _unit_correct/_assert_correctness_complete 两个 helper
将 _split_one_category 复杂度压回基线以下。

新增 tests/unit/test_pools_pair_atomic.py 覆盖两条路径的 pair 原子性回归。
2026-07-15 06:09:14 -04:00
iomgaa 5ef5f2b8b7 refactor: extract _assemble_pair to lower build_units complexity to B
将 pair 分组的数量/角色校验与组装下沉到 _assemble_pair 私有 helper,
build_units 主体只做分组+派发,radon 复杂度从 C(11) 降到 A(4)。
fail-fast 语义、公共函数签名、稳定顺序均不变。
2026-07-15 05:54:08 -04:00
iomgaa bef46636fe feat: add question_units helper as pair contract entry point
build_units/flatten_units/validate_units/unit_correctness——pair 契约唯一入口。
build_units 对孤儿/超员/角色缺失重复 fail-fast raise ValueError(防 next 静默
StopIteration);unit_correctness 走 per_q[qid] KeyError 防静默兜底。
2026-07-15 05:52:30 -04:00
iomgaa 7ef9b99217 feat: add QuestionUnit entity and GeneratedQuestion pair fields
新增 QuestionUnit 契约实体(kind/unit_id/task_type/questions/unit_hash + size/from_single/from_pair),
作为 AR pair 孪生对贯穿评测/训练 harness 的最小调度单元。
GeneratedQuestion 追加 unit_id/pair_id/question_role/flip_axis 四字段,
__post_init__ 回填 unit_id,默认值保证现有非 AR single 题构造点行为不变。
2026-07-15 05:45:34 -04:00
iomgaa 0fe1c96393 docs: add question-gen v3 construction-paradigm design and phase1 plan
Complete v3 planning: construction-first paradigm (frame-perception grounded fact extraction + 4-family independent judges + 6-layer verification + QuestionUnit contract), adversarial audit, paradigm-shift finding, real-data spike validation, logging schema, and phase1 contract implementation plan.
2026-07-15 05:41:10 -04:00
iomgaa d9f7dee2df docs: revise question-gen v2 design per Codex review (pair-unit contract, AR-only gate, phasing) 2026-07-15 01:21:28 -04:00
iomgaa 7949f16906 docs: add question-gen v2 grounded-contrastive design 2026-07-15 01:10:00 -04:00
iomgaa fd96f906c2 docs: add question-gen v2 diagnosis and locked redesign direction 2026-07-15 00:50:29 -04:00
iomgaa 49b7be78ae fix: derive OUTPUT from config output_dir so CONFIG override counts correctly 2026-07-14 21:12:48 -04:00
iomgaa d84f9bf777 chore: harden generate_ar30.sh (drop no-op mock, add zero-gain early stop) 2026-07-14 21:08:00 -04:00
iomgaa b1c1bf7aac fix: isolate selector VLM exceptions at slot level (no batch crash) 2026-07-14 21:08:00 -04:00
iomgaa 441a0aa6c3 chore: format Phase B modules and register Phase B plan in wiki 2026-07-14 17:11:04 -04:00
iomgaa f36eb66c18 fix: tolerate backfill under-delivery, fix predict None-fill, drop dead session_id 2026-07-14 17:07:55 -04:00
iomgaa 1d222d9f18 feat: wire real agent runner, backfill assembly and adversarial-filter CLI 2026-07-14 16:58:32 -04:00
iomgaa 73d0bb9190 feat: add adversarial round loop with backfill iteration and difficulty report 2026-07-14 16:40:05 -04:00
iomgaa c1565a01c2 test: assert stage/verdict/pair_id and Q-refusal flip_skipped path 2026-07-14 16:33:09 -04:00
iomgaa 15aee0cfc1 feat: add flip gate reusing P prediction and mirror-Q agent run 2026-07-14 16:26:51 -04:00
iomgaa 8731e448fe fix: harden mirror material rebuild (empty/invalid frames, dedup, observability) 2026-07-14 16:16:38 -04:00
iomgaa 4af99b700b feat: add mirror question generation with canonical distinctness check 2026-07-14 16:08:13 -04:00
iomgaa d4e9852864 feat: add cheater gate with resume-safe survivor recovery 2026-07-14 16:00:14 -04:00
iomgaa 24ed7ca322 fix: harden canonical_answer_text and align decision-core signatures to spec 2026-07-14 15:55:36 -04:00
iomgaa c109f2257a feat: add pure decision core for adversarial filter (hash/fingerprint/canonical/flip) 2026-07-14 15:51:28 -04:00
iomgaa 334fbbc94d feat: add AdversarialFilterConfig for Phase B post-hoc filter layer 2026-07-14 15:47:21 -04:00
iomgaa ac115d96fb test: cover backfill params end-to-end (mutation isolation + seq_offset) 2026-07-14 15:44:43 -04:00
iomgaa 8b9e8aa19f feat: add optional backfill params to run_pipeline_v2 2026-07-14 15:39:57 -04:00
iomgaa d77cbc95eb feat: add adversarial_verdicts table with resume and terminal-verdict query
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:35:11 -04:00
iomgaa e41a2b0d08 test: guard non-AR flip isolation and AR flip-capable set 2026-07-14 15:30:18 -04:00
iomgaa f12dd7e559 feat: add supports_flip/flip_axis to SubPattern for Phase B flip gate 2026-07-14 15:27:21 -04:00
iomgaa 841112c6af docs: revise Phase B plan per Codex review (C1-C3,I1-I6,M1-M3) 2026-07-14 15:24:44 -04:00
iomgaa abc9c097d2 docs: add Phase B adversarial question-gen implementation plan 2026-07-14 15:05:36 -04:00
iomgaa 96e314c3a0 fix: mark item rejected on selector_error for consistent bookkeeping
selector_error 分支(捕获 ValueError/FileNotFoundError)此前只返回 reason,
未 mark_item_rejected,导致 Phase 3 已 record 的 pending attempt 行永远停在
pending;而 hard-fail 分支会标记 rejected。两条失败路径落库风格现统一为
mark_item_rejected(异常路径无 outcome/observation,故不写 selector_scores)。

补单测 test_apply_grounded_selector_marks_rejected_on_error 守卫该路径。
2026-07-14 14:49:41 -04:00
iomgaa 46ac848176 fix: converge non-numeric selector scores to ValueError 2026-07-14 14:47:21 -04:00
iomgaa eaab865d84 docs: register Phase A plan in research wiki 2026-07-14 14:41:53 -04:00
iomgaa e64c26e578 style: format distractor_selector 2026-07-14 14:41:21 -04:00
iomgaa 76f719018c feat: loosen multi_true gate to qualifier-scoped correctness 2026-07-14 14:33:22 -04:00
iomgaa b1f15ddb3a fix: keep cross_segment rule single-dimension, guard no-absent-events 2026-07-14 14:31:30 -04:00
iomgaa 58278c6de4 feat: enforce single-dimension counterfactual in AR distractor rules 2026-07-14 14:28:32 -04:00
iomgaa ea38e4717b refactor: extract grounded selector application into helper
将 Phase 3.5 的 grounded selector 逻辑抽成模块级 _apply_grounded_selector,
内部完成策略门控 / 异常捕获 / observation 落库 / hard-fail 拒绝,调用点仅剩
单一失败分支(selector_reason → 重出)。行为不变,测试全绿。

顺带移除 resample_video_interval 死子表达式(硬编码 1,(attempt-1)%1==0 恒真),
使 _process_one_slot 圈复杂度回落至 Task 6 前的 D(25)。_apply_grounded_selector
自身为 A(4),均满足质量门。
2026-07-14 14:22:30 -04:00
iomgaa b13eab0659 feat: wire grounded selector into AR slot processing
将 Task 5 的 grounded selector 织入 AR 出题路径(Phase 3.5,位于
record_item 与 postprocess 之间),仅在 strategy.uses_grounded_selector
为真时进入。observation 始终落库(含 hard-fail),硬失败走重出。
PipelineConfig 新增 candidate_pool_size/selector_delta_low/
selector_delta_high 三参,YAML 与 CLI seed override 同步。
2026-07-14 14:15:50 -04:00
iomgaa d0194f5840 fix: degrade distractor pool gracefully on malformed VLM response 2026-07-14 14:11:13 -04:00
iomgaa 8a54055d02 feat: add grounded distractor selector with visual scoring 2026-07-14 14:06:07 -04:00
iomgaa 207e834f30 feat: add selector_scores observation column to question_gen_items 2026-07-14 13:59:29 -04:00
iomgaa 3f984acc18 feat: add uses_grounded_selector strategy switch (AR only) 2026-07-14 13:53:17 -04:00
iomgaa 2608a3841f style: reformat sub_pattern round-trip tests 2026-07-14 13:51:51 -04:00
iomgaa e68e4b7d57 fix: restore sub_pattern when loading benchmark JSON 2026-07-14 13:51:05 -04:00
iomgaa 111c88488f style: format sub_pattern test file 2026-07-14 13:46:25 -04:00
iomgaa ae0a718f67 feat: thread and persist sub_pattern into accepted questions 2026-07-14 13:45:31 -04:00
iomgaa 25f2a845ff fix: run_gates must use current_tree after video resample 2026-07-14 13:40:46 -04:00
iomgaa 92714db370 docs: revise Phase A plan per Codex review (C1-C2,I1-I5,M1) 2026-07-14 13:38:35 -04:00
iomgaa 645585e784 docs: add Phase A grounded question-gen implementation plan 2026-07-14 13:28:43 -04:00
iomgaa ad0bfe3a51 docs: add grounded + adversarial question-gen design (Phase A/B) 2026-07-14 13:21:06 -04:00
iomgaa 84b52a0311 feat(pools): auto-supplement maintenance correct questions in PerCategoryPoolStrategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 10:41:46 -04:00
iomgaa 72befa2bd4 feat(pools): add batch_correct_ratio field to PoolConfig
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 10:34:28 -04:00
iomgaa 453cf62088 docs: add maintenance pool implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 10:22:52 -04:00
iomgaa 73c5d1e380 docs: add high-correctness warning to maintenance pool design
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 10:13:57 -04:00
iomgaa 89cad891de docs: add maintenance pool auto-supplement design
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 10:11:26 -04:00
iomgaa d7d7ce5bdc feat(question_gen): register ActionRecognitionStrategy, replace temp VISUAL binding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 06:51:50 -04:00
iomgaa d0a8019fe1 feat(question_gen): add ActionRecognitionStrategy with 6 SubPatterns
Self-contained strategy targeting 6 Agent failure modes in Action
Recognition: premature_evidence_anchoring, temporal_reasoning_failure,
semantic_rigidity, fine_grained_visual_action,
cross_segment_entity_tracking, evidence_gap_confabulation.

- L2 default sampling (upgrade from L3) with 3 patterns overriding to L1
- Weighted random SubPattern selection (0.20/0.20/0.15/0.15/0.15/0.15)
- Each SubPattern includes instruction, examples, distractor rules
- Satisfies TaskTypeStrategy Protocol without extending BaseTaskTypeStrategy
- 32 unit tests covering all properties, definitions, and selection behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 06:49:03 -04:00
iomgaa eaa58604b3 feat(question_gen): add Action Recognition specialized prompt template
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 06:45:09 -04:00
iomgaa b976c789d2 docs: add Plan B — ActionRecognitionStrategy implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 06:34:40 -04:00
iomgaa cd0805aad6 chore: fix N806 lint - rename _RESAMPLE_VIDEO_INTERVAL to lowercase
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 06:11:01 -04:00
iomgaa 7c7ad67d81 fix(pipeline): move extra_gates after standard gate pass check
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 06:08:52 -04:00
iomgaa 36c712defa feat(pipeline): replace QuestionFamilySpec with TaskTypeStrategy
- SlotAssignment: remove family field, strategy looked up at process time
- PipelineConfig: remove family_ratios field
- _assign_slots: remove family_ratios and rng params (pure deterministic)
- _process_one_slot: use get_strategy() for sampling, generation, gates
- Add sub_pattern support (level/constraint override, instruction injection)
- Add strategy.extra_gates() check after standard gates
- load_pipeline_config: stop reading family_ratios from YAML
- Update tools/generate_questions.py seed override and dry-run log
- Update all integration tests to match new API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 06:05:23 -04:00
iomgaa b9616de21e fix(tests): update test_generator_v2 to use new generator signatures
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 05:55:08 -04:00
iomgaa afa77173e3 refactor(question_gen): adapt generator/gates/store signatures for strategy
- generator_v2: _load_prompt_template takes template_name str instead of
  QuestionFamilySpec; _build_v2_prompt takes prompt_template + strategy_name
  + sub_pattern_instruction; generate_one_v2 takes discrete params
  (prompt_template, strategy_name, skill_target, sub_pattern_instruction)
- gates: _gate_leak_test and run_gates take leak_probe_template str
  instead of QuestionFamilySpec
- run_store: add sub_pattern column to DDL + idempotent migration;
  record_item accepts optional sub_pattern param
- Remove QuestionFamilySpec imports from generator_v2 and gates modules
- Update test call sites accordingly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 05:50:47 -04:00
iomgaa c49d0ff12f fix(sampler): validate level param rejects invalid values
Add ValueError guard at the top of sample_material_v2 for level not in
{1, 2, 3}, preventing silent fallthrough to L1 sampling. Add unit test
for the new validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 05:43:28 -04:00
iomgaa e2325b6535 refactor(sampler): replace family_spec param with level+constraint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 05:38:14 -04:00
iomgaa b6b6a48503 feat(question_gen): add TaskTypeStrategy Protocol and BaseTaskTypeStrategy
- TaskTypeStrategy Protocol: pipeline 的唯一接口,定义 task_type、
  sampling_level、sampling_constraint、prompt_template 等属性
- SubPattern frozen dataclass: 出题子模式,靶向特定失败机制
- BaseTaskTypeStrategy: 封装现有 QuestionFamilySpec 行为的默认策略,
  所有属性委托给绑定的 family
- _TASK_TYPE_TO_FAMILY: 消歧绑定表,12 个题型确定性绑定到 1 个 family
- register_strategy/get_strategy: 注册表 API,未注册题型自动创建
  BaseTaskTypeStrategy
- 13 个单元测试全部通过

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-14 05:24:29 -04:00
iomgaa 832838350a docs: add TaskTypeStrategy design + Plan A framework plan 2026-07-14 05:15:28 -04:00
iomgaa 9ee37a8534 fix(loader): use video_id from JSON data instead of filename
load_benchmark used the JSON filename stem as video_id, which broke
v2-360 questions (all 180 questions got video_id='accepted_questions').
Now uses qa['video_id'] when present, falls back to filename for
Video-MME format compatibility.
2026-07-14 01:02:55 -04:00
iomgaa dec7346da3 feat(harness): add Action Recognition training experiment
- PerCategoryPoolStrategy: filter test pool by task_types
- RunConfig: add run_holdout_eval toggle (default true)
- load_config: fix YAML task_types list-to-tuple conversion
- Runner: conditionally skip _holdout_four_way when disabled
- CLI: add --no-run-holdout-eval flag
- New config/train_action_recognition.yaml (3 epochs, per_category)
- New scripts/train_action_recognition.sh (baseline + seed + train)
2026-07-14 00:58:54 -04:00
iomgaa 37d4519905 chore: lint and format per-category pool strategy implementation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:58:10 -04:00
iomgaa 671db2f88c test(integration): add PerCategoryPoolStrategy end-to-end test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:56:42 -04:00
iomgaa 5563d0d675 feat(cli): wire train mode with PoolStrategy selection and task_types in RunConfig
- Add --pool-split-mode, --train-ratio, --test-questions CLI arguments
- Include task_types in cli_overrides (convert list to tuple for RunConfig)
- Wire train mode branch: select strategy based on pool_split_mode,
  resolve workspace paths, build pools, and call runner.train()
- Infer mode now reads task_types from config instead of raw args

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:56:18 -04:00
iomgaa c66a00c924 feat(harness): refactor build_or_load_pools to accept PoolStrategy + per_category freeze format
- save_pools: extended with split_mode and config params; per_category
  mode writes categories metadata (seed, train_ratio, test_source) for
  incremental append and consistency validation
- load_pools: compatible with both old format (no split_mode) and new
  format; extra metadata fields ignored during load
- build_or_load_pools: signature changed to (config, strategy, db_path);
  baseline_run_id read from seed.json (not config.run_id); per_category
  mode does consistency check on reload and supports incremental category
  append via strategy.build_incremental
- Added _to_pool_config, _read_baseline_run_id,
  _validate_per_category_consistency helpers
- Tests: TestPerCategorySaveLoad with 5 test cases covering roundtrip,
  missing config error, global split_mode field, legacy format compat,
  multi-type categories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:53:19 -04:00
iomgaa e5b07ac974 feat(harness): add task_types, pool_split_mode, train_ratio, test_questions to RunConfig
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:46:28 -04:00
iomgaa 73ae1f7143 fix(harness): change _runs INSERT OR IGNORE to ON CONFLICT DO UPDATE for incremental infer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:45:18 -04:00
iomgaa 8b48005a17 config: add pool_split_mode, train_ratio, test_questions to default.yaml
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:43:43 -04:00
iomgaa 21c6a53aed feat(harness): add PerCategoryPoolStrategy with correctness-stratified 2:1 split
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:41:48 -04:00
iomgaa ec4cbbdd44 style(core): move Path import to TYPE_CHECKING block in types.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 22:36:00 -04:00
iomgaa cd5c9c01fb feat(app): add PoolStrategy Protocol to application ports 2026-07-12 22:34:47 -04:00
iomgaa 0d0f275134 feat(core): add PoolConfig dataclass for pool strategy configuration 2026-07-12 22:33:49 -04:00
iomgaa 4fb7a61f8b fix(question_gen): resolve pipeline integration issues from final review
1. Apply postprocess shuffle result (pp.options, pp.answer) to final
   GeneratedQuestion output instead of using original candidate values.

2. Record dedup rejection in store via new mark_item_rejected() method,
   preventing items from staying as 'accepted' after dedup rejects them.

3. Add .flatten() to embed_fn outputs in _is_duplicate and embed_pool
   append to handle 2D (1,D) arrays from embedding implementations.

4. Validate exactly 4 options in _validate_parsed_fields (was >= 2),
   matching the A-D answer constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 00:14:12 -04:00
iomgaa eecb86e27a feat(question_gen): add generate-v2 CLI subcommand and experiment script
- Add generate-v2 subparser with --config, --store-dir, --db-path,
  --seed, and --dry-run arguments to tools/generate_questions.py
- Implement _run_generate_v2 async handler: config loading, video
  discovery, DI client construction, TreeIndex loading, pipeline
  invocation, and result persistence
- Add scripts/generate_questions_v2.sh following build_trees.sh
  conventions (source .env, conda run python path, MODE=mock support)
- Update app/question_gen/__init__.py to export full v2 public API:
  run_pipeline_v2, PipelineConfig, PipelineResult, QuestionFamilySpec,
  ALL_FAMILIES, CandidateQuestion, generate_one_v2, GateReport, run_gates
- Add QuestionGenStore.load_progress() for pipeline resumption
- Add integration tests for CLI help and dry-run behavior
- Update test_question_gen_api to match expanded __all__

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-12 00:04:42 -04:00
iomgaa f46e87258c refactor(question_gen): extract helpers to reduce pipeline_v2 CC below grade C
Extract _get_git_sha, _filter_pending_slots, and _run_heavy_sampling
from run_pipeline_v2. Reduces cyclomatic complexity from C(15) to B(6).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:57:35 -04:00
iomgaa 206c553143 feat(question_gen): add v2 pipeline with retry loop and heavy check
- PipelineConfig: YAML-driven configuration with family_ratios, retry,
  concurrency, dedup threshold, and heavy sampling rate
- _assign_slots: deterministic round-robin slot assignment across videos
  with per-family weighted random selection
- _process_one_slot: full retry loop (generate → postprocess → gates →
  dedup) with reject-reason feedback to VLM on retry
- _heavy_check_one: blind LLM agent trial-answer for difficulty_steps
- run_pipeline_v2: orchestration with semaphore-bounded concurrency,
  progress/resume support, and store integration
- is_duplicate: cosine similarity dedup against embedding pool

Tests: 11 integration tests covering slot assignment, retry behavior,
max-retries exhaustion, full pipeline flow, progress resume, heavy
sampling, and store record completeness.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:55:15 -04:00
iomgaa 6d6eb8e3a3 refactor(question_gen): extract _validate_parsed_fields to reduce CC
Split field validation logic out of _parse_v2_response into a dedicated
_validate_parsed_fields helper. This brings _parse_v2_response from CC=11
(grade C) down to CC=3 (grade A). The extracted validator is CC=9 (grade B).
No grade-C functions remain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:43:35 -04:00
iomgaa cf51d2de9d feat(question_gen): add v2 generator with per-family prompt templates
Implement generator_v2.py with:
- CandidateQuestion dataclass (canonical location)
- _load_prompt_template: loads per-family .md from store/prompts/
- _build_v2_prompt: constructs system+user messages with material context
- _parse_v2_response: JSON extraction, json_repair, field validation
- generate_one_v2: async VLM call orchestration with reject_reason support

Add 5 family-specific prompt templates:
- retrieval.md: factual recall from visible content
- reasoning.md: multi-hop inference across segments
- enumeration.md: counting/listing entities and actions
- visual.md: visual details requiring frame observation
- spatial.md: spatial relationships between objects/people

Tests: 11 unit tests covering prompt build, parse, and e2e generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:41:36 -04:00
iomgaa 9053233f99 fix(question_gen): gates reject_reason returns raw reason; short-circuit skips LLM
- GateReport.reject_reason now returns gate.reason directly (no [name] prefix)
- verbatim short-circuit sets other 3 gates to SKIP without calling LLM
- test_high_verbatim_shortcircuits asserts zero LLM calls and SKIP verdicts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:33:57 -04:00
iomgaa 9f739e831d refactor(question_gen): reduce cyclomatic complexity in sampler_v2
Extract shared _resolve_subtree helper to eliminate repeated tri-level
node resolution. Break _validate_sampling_constraints into focused
single-purpose helpers:
- _count_l3_descendants
- _has_frames
- _count_subtitles
- _resolve_subtree / _find_l3_parent

Extract _subtitles_from_l2_list and _frames_from_l2_list to simplify
collection functions.

Complexity improvements:
- _validate_sampling_constraints: D(23) -> B(8)
- _collect_subtitle_sentences: C(16) -> A(3)
- _collect_frame_paths: C(13) -> A(2)

All functions now grade B or better per radon cc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:33:48 -04:00
iomgaa f74711cd11 feat(question_gen): add v2 material sampler with family constraints
Implement sample_material_v2 module that samples tree nodes with
QuestionFamilySpec-aware constraint validation, providing richer
MaterialContext output (subtitles, cross-L2 context, frame paths).

Key components:
- AnchorContext/MaterialContext frozen dataclasses
- _validate_sampling_constraints: multi-level constraint checking
- _collect_subtitle_sentences: subtree subtitle extraction
- _collect_cross_l2_context: peer L2 event descriptions
- sample_material_v2: main entry with retry-on-constraint-violation

Tests: 11 unit tests covering normal sampling, used-node exclusion,
constraint violation retries, cross-L2 population, and subtitle
collection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:30:56 -04:00
iomgaa 271d1682c9 feat(question_gen): add lightweight 4-gate quality check
Implement 4 concurrent LLM-based quality gates for generated questions:
- key_verify: validates answer evidence in source material
- blind_answer: rejects questions answerable without video context
- multi_true: detects ambiguous multi-correct options
- leak_test: per-family shortcut detection (5 probe templates)

Includes run_gates orchestrator with verbatim_ratio short-circuit,
JSON response parsing with fallback, and 9 unit tests (all passing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:30:13 -04:00
iomgaa 9627ac9cf9 fix(question_gen): raise ValueError on UPDATE of missing rows
record_run_end, update_gates, and update_difficulty now check
cursor.rowcount after UPDATE+commit and raise ValueError if 0 rows
were affected. Prevents silent telemetry loss.

Adds three negative-path tests:
- test_record_run_end_missing_run_raises
- test_update_gates_missing_item_raises
- test_update_difficulty_missing_item_raises

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:23:48 -04:00
iomgaa 7abe92eb1c fix(question_gen): check_verbatim covers question_text + add missing blacklist patterns
- check_verbatim now computes n-gram overlap for BOTH question_text and
  correct_option vs source texts, returning max(question_ratio, option_ratio).
  Extracted _ngram_overlap_ratio helper for reuse.

- Added 4 missing blacklist patterns: 'this segment', 'this frame',
  'the current frame', 'frame summary'.

- Added 5 new test cases covering the above changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:23:37 -04:00
iomgaa 9525726133 fix(question_gen): rename finished_at to ended_at per schema spec
Aligns DDL column name with research-wiki/schemas/question-gen-runs.md
which specifies 'ended_at' (not 'finished_at').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:18:52 -04:00
iomgaa 74686dde68 refactor(question_gen): reduce check_forbidden_material complexity to A(3)
Extract _match_any helper and declarative _FORBIDDEN_MATERIAL_RULES table
to replace repetitive per-category for-loops. Reduces cyclomatic complexity
from C(11) to A(3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:16:34 -04:00
iomgaa 6a5424a618 feat(question_gen): add SQLite run store for generation telemetry
Implements QuestionGenStore with:
- Idempotent schema initialization (question_gen_runs + question_gen_items)
- Run lifecycle: record_run_start / record_run_end / get_run_stats
- Per-item recording: record_item / update_gates / update_difficulty
- GateReportLike Protocol for duck-type gate report compatibility
- WAL mode + foreign keys + check_same_thread=False

DDL aligns with research-wiki/schemas/question-gen-{runs,items}.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:15:53 -04:00
iomgaa c83d771923 feat(question_gen): add deterministic postprocess layer
Add app/question_gen/postprocess.py with zero-LLM deterministic
post-processing for generated questions:

- shuffle_options: deterministic option permutation with answer remapping
- check_referent_blacklist: detect self-referential language (this clip, etc.)
- check_verbatim: word-level n-gram overlap ratio measurement
- has_time_anchor: timestamp and temporal phrase detection
- check_forbidden_material: T1/T7 source material validation
- run_postprocess: orchestration returning PostprocessResult

Tests: 31 unit tests covering all functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:14:43 -04:00
iomgaa 75e6d8c550 feat(question_gen): add 5 question family specs with sampling constraints
Define QuestionFamilySpec, LeakTestProfile, SamplingConstraint dataclasses
and instantiate 5 families (RETRIEVAL/REASONING/ENUMERATION/VISUAL/SPATIAL)
targeting failure mechanisms M1-M5. Implement get_family_for_slot with
legal-type filtering + weighted random selection.

13 unit tests cover: full task-type coverage, skill_target uniqueness,
deterministic seeding, invalid input errors, and chi-square distribution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:14:22 -04:00
iomgaa 811ffa648b feat(types): extend GeneratedQuestion with skill_target & difficulty_steps
- Add skill_target (str | None) and difficulty_steps (int | None) fields
  to GeneratedQuestion dataclass with field(default=None)
- Update loader.py to pass new fields from JSON (backward-compatible)
- Update pools.py _q_to_dict/_dict_to_q for serialization compat
- Add question_gen_v2 config section to default.yaml
- Add comprehensive test coverage (7 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:05:38 -04:00
iomgaa 043d4aa46f docs(plans): add Spec-3 question-gen-v2 implementation plan
9 tasks: type extension → postprocess → families → sampler_v2 →
generator_v2 → gates → run_store → pipeline_v2 → CLI integration.
Includes structured-logging schemas/metrics and Codex review revisions.
2026-07-11 22:57:47 -04:00
iomgaa 8c9adfd3fa docs(wiki): record Spec-2 harness-eval finding (N/A — pure tooling addition) 2026-07-11 12:13:22 -04:00
iomgaa a51b3c4238 fix(tools): validate concurrency params to prevent zero-semaphore deadlock
Codex 最终审查发现 video_concurrency=0 或 TREE_BUILD_API_CONCURRENCY=0
会创建永不放行的 Semaphore 导致整批挂死。追加 >= 1 校验,fail-fast 报错。
2026-07-11 12:11:48 -04:00
iomgaa 0290005129 fix(scripts): align build_trees.sh with project shell conventions
Codex 审查发现两处偏离既有脚本惯例:
1. conda activate 改为 PYTHON=$(conda run ...) + ${PYTHON} 模式
2. 追加 source .env 确保 TREE_BUILD_API_CONCURRENCY 等工程配置可用
2026-07-11 12:07:14 -04:00
iomgaa 928ec8c37b feat(scripts): add batch tree build entry with smoke-test guide 2026-07-11 12:04:34 -04:00
iomgaa c5bf7e1a61 fix(tools): widen error isolation to cover full single-video pipeline
Codex 功能质量审查发现 _build_one 的 try/except 仅包裹 build_async,
构造器、SRT 解析、save_json、save_progress 抛异常会逃逸到 gather 中止整批。
将 try 边界扩大到覆盖完整单视频流程(构建→建树→保存→进度记录)。
2026-07-11 12:02:21 -04:00
iomgaa 56fe138a46 feat(tools): batch tree build orchestration with shared API semaphore 2026-07-11 11:56:36 -04:00
iomgaa 978ddef91b feat(tools): add build_trees skeleton with discovery helpers 2026-07-11 11:41:22 -04:00
iomgaa e9073bfdc2 feat(tree): expose build_async and accept injected API semaphore
Core algorithms #1/#2/#3 unchanged: only entry wrapping and semaphore
source switch (injected vs self-built); build logic untouched.
- rename _build_async to public build_async (body unchanged)
- __init__ accepts keyword-only api_semaphore for cross-video sharing
- default path (no injection) is verbatim-equivalent to previous code
2026-07-11 11:14:45 -04:00
iomgaa 25f5537974 docs(plans): add Spec-2 batch tree build implementation plan 2026-07-11 11:11:02 -04:00
iomgaa 1d2385291a docs(wiki): record Spec-1 harness-eval finding (all metrics pass) 2026-07-11 09:33:53 -04:00
iomgaa 40b156cf69 docs(wiki): record final Codex PASS for recipe redesign round 2026-07-11 09:08:27 -04:00
iomgaa ebbd112d50 fix(tree): move Iterable import into type-checking block 2026-07-11 09:05:04 -04:00
iomgaa a046862ca1 docs(wiki): record build-tree recipe redesign and diagnosis stage tags 2026-07-11 09:04:57 -04:00
iomgaa f0284199b8 refactor(tree): narrow _clean_join parameter type to Iterable 2026-07-11 09:04:25 -04:00
iomgaa 1c21e215e2 test(tree): cover L1 entity fields and block ordering in view_node 2026-07-11 09:02:03 -04:00
iomgaa a2c6a97621 refactor(tree): extract _clean_join to keep entity-fields complexity at B
质量门要求新增代码复杂度不超过 B 级:将 node_entity_fields 中重复的
"清洗-去重-拼接"逻辑抽为模块级纯函数 _clean_join,主体收敛为两次
调用。radon:node_entity_fields C(13) → B(6),_clean_join A(5)。

算法 #11(树环境语义搜索)数据访问层,行为不变(既有 4 测试未改全绿)。
2026-07-11 08:56:17 -04:00
iomgaa 4e0e05210d feat(search): append raw entity fields after view_node summary
view_node 按题两轮摘要(summarize_node)会吞掉 entities/visible_text
字段信号,Agent 站在证据节点上仍漏读实体(benchmark 错题 M1,案例
786-2、872-3、750-1)。dispatcher 侧在摘要后确定性追加 [实体]/[画面
文字] 原文区块,LLM 无法吞掉。

- TreeEnvironment.node_entity_fields:按层级取 card 实体字段原文,
  去空白、去重、分号拼接;空字段省键;未知节点抛 KeyError
- _handle_view_node Phase 2.5:摘要后、子节点概览前追加实体区块
- 附带 ruff format 修正 test_tree_environment.py 两处既有格式

算法 #11(树环境语义搜索)数据访问层扩展,不改搜索算法本身。
2026-07-11 08:53:56 -04:00
iomgaa 291a8108e1 test(agent): tighten type annotations in step-retry tests 2026-07-11 08:48:04 -04:00
iomgaa badfcce4cb fix(agent): validate non-empty retry delays; pin exhaustion semantics
核心算法 #10(Agent Loop):Codex 质量审查跟进,仅加固防御与
可观测,不改变循环语义。
- __init__ 校验 step_retry_delays 非空,空序列直接抛 ValueError
  (P5 fail-fast,避免首次可重试异常时 IndexError 掩盖原始 LLM
  异常、违背方法契约)
- 补耗尽语义窄测试(耗尽后原样抛出最后一次原始异常)与空序列
  构造校验测试
- run() 最终失败日志补异常类型名,便于回溯归因
2026-07-11 08:44:47 -04:00
iomgaa e3184c11f9 feat(agent): step-level retry for transient LLM errors (20s/40s backoff)
核心算法 #10(Agent Loop):仅加固异常路径的韧性兜底,不改变
解析协议、hook 时序与步数语义。benchmark 错题 796-3 显示一次
SSL BAD_RECORD_MAC 穿透 GovernedLLMClient 重试栈后废掉 13 步
已积累上下文;本次在 run() Phase 1 增加步级重试(默认 2 次,
20s/40s 退避),可重试异常限定 (TimeoutError, OSError),非可
重试异常照旧 fail-fast 整题终止,行为与现状一致(P5 显式异常)。
为满足 radon C 级复杂度约束,重试循环抽取为私有方法
_call_llm_with_step_retry,行为不变。
2026-07-11 08:37:35 -04:00
iomgaa cf529f2c8f test(agent): add docstring to empty-content boundary test 2026-07-11 08:33:36 -04:00
iomgaa 439dc29b3b fix(agent): reject argless action in normalization; add boundary test
核心算法 #10(Agent Loop):修复 Codex 质量审查 Critical——
_normalize_action 仅在除 tool 外至少存在一个平铺参数键时才收拢,
{"tool": "x"} 无参结构不再被静默升级为空 args 合法结构,照旧
返回 None 走 retry 追问路径。补边界测试 + 测试辅助方法类型注解
与中文 docstring。
2026-07-11 08:28:59 -04:00
iomgaa 6034d4172d test(question_gen): update __all__ assertion to include synthesizer exports 2026-07-11 08:21:06 -04:00
iomgaa a31b1fbf37 test(question_gen): drop tests for removed calibrate pairing validator 2026-07-11 08:19:45 -04:00
iomgaa 8d84d5e236 fix(agent): normalize fenced and flat-args LLM outputs in parser
核心算法 #10(Agent Loop):仅加固 _parse_response 解析路径——
剥除 ```json 围栏 + 收拢 action 平铺参数(deepseek 稳定输出变体,
案例 637-3/615-3 三连拒 0 步阵亡)。Thinking+JSON 协议、json_repair
兜底链、hook 时序与步数语义均未改动。
2026-07-11 08:16:28 -04:00
iomgaa 39c6352781 docs(plans): add Spec-1 implementation plan with Codex review fixes 2026-07-11 08:12:07 -04:00
iomgaa ef402c46a2 docs(designs): apply Codex review fixes to Spec-1/2/3 2026-07-11 07:54:48 -04:00
iomgaa a5666b4f16 docs(wiki): record tree-figure style unification and review fixes 2026-07-11 07:52:22 -04:00
iomgaa a79c2ec753 docs(wiki): add three-round analysis findings and Spec-1/2/3 designs 2026-07-11 07:45:51 -04:00
iomgaa 658e62054e feat(scripts): add generated-questions infer and calibrate entry 2026-07-11 07:45:42 -04:00
iomgaa da70eb6e23 refactor(question_gen): slim calibrate to compare two existing runs 2026-07-11 07:45:42 -04:00
iomgaa 307c64c388 fix(harness): infer mode honors CLI --questions over manifest 2026-07-11 07:45:33 -04:00
iomgaa 735471df3c fix(question_gen): Information Synopsis 去重阈值放宽至 0.90
该题型天然高相似(benchmark 均值 0.66,中位数 0.67),
通用阈值 0.75/0.85 导致几乎所有新题被误判重复。
按题型覆盖为 0.90,只拦截真正的复制品。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-10 08:55:47 -04:00
iomgaa 25c8d5ec42 fix(question_gen): 每次重试换视频避免同视频反复去重失败
Information Synopsis 163 道 benchmark 题,同一视频反复出题
必然相似。改为每次重试随机选不同视频。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-10 08:15:03 -04:00
iomgaa d0148e78ad docs(wiki): record main-figure final review verdict and caption notes 2026-07-10 01:40:04 -04:00
iomgaa 6a6aae081b docs(wiki): record main-figure final layout and panel decisions 2026-07-10 01:38:25 -04:00
iomgaa 69a4d17c0b config: LLM 总超时 120s→300s(5 分钟)
VLM 题目生成(图片+出题+4选项)输出长,120s 不够。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-10 01:11:40 -04:00
iomgaa 5b51f4bd0c fix(synthesizer): generate_one 捕获所有异常避免 VLM 超时穿透崩溃
except (ValueError, KeyError) → except Exception,
覆盖 StreamLivenessTimeout 等网络/超时异常。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-10 01:08:03 -04:00
iomgaa 8d11513e54 fix: 禁止日志缓存,确保所有日志立刻输出
- scripts/*.sh: PYTHONUNBUFFERED=1
- tools/generate_questions.py: loguru file sink enqueue=False
- main.py: loguru file sink enqueue=False

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 23:15:02 -04:00
iomgaa c191be16e2 fix(question_gen): L3 frame_path 拼接视频目录前缀
tree.json 中 frame_path 是相对路径(frames/xxx.jpg),
generate_questions 需要拼上 store/videos/{video_id}/ 前缀供 VLM 读取。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 23:10:18 -04:00
iomgaa 09b2773375 config(scripts): generate_questions 默认并发 8→16
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 23:07:57 -04:00
iomgaa c84de49dc4 feat(main): loguru 文件 sink 持久化运行日志
logs/run_YYYYMMDD_HHmmss.log,500MB 轮转,保留 30 天,
enqueue=True 异步写入不阻塞推理。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 22:56:34 -04:00
iomgaa 75016c4892 feat(scripts): generate_questions.sh 题目生成实验入口
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 22:53:28 -04:00
iomgaa 4ebdf0b0ba fix(main): InfraSettings 默认值同步 retry_base=20 retry_max=120
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 22:16:22 -04:00
iomgaa 1f83da9eb7 fix: embedding 改 cuda + OCR 超时 300s + 重试退避 20s 起 + jitter 防雷群
- config/default.yaml: embed.device cpu → cuda
- adapters/ocr.py: _TIMEOUT_S 15 → 300(5 分钟)
- adapters/llm.py: 指数退避加 ±50% jitter 避免 24 路同时重试
- .env: LLM_RETRY_BASE_DELAY 2→20s, LLM_RETRY_MAX_DELAY 30→120s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 22:14:57 -04:00
iomgaa 38a855b33e fix(scripts): 加 HF_HUB_OFFLINE 跳过在线检查
embedding 模型已有本地缓存,网络不通时避免连接 huggingface.co 失败。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 13:56:25 -04:00
iomgaa d6ae0d85cd fix: Redis 缓存构造修正 + TTL=0 永不过期支持
- main.py: 先创建 aioredis 客户端再传入 RedisResponseCache
- redis_cache.py: ttl_s=None 时不设过期时间
- .env: REDIS_CACHE_TTL=0(永不过期)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 13:52:33 -04:00
iomgaa 953fb7a456 feat(scripts): infer_video_mme.sh 推理实验入口
零参数全量 900 题、N_SAMPLES smoke test、TASK_TYPES 筛选、版本覆盖。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 13:40:31 -04:00
iomgaa cabe5be038 fix(main): assemble_mode 从 'default' 修正为 'ids_expand'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 13:21:05 -04:00
iomgaa f8f8b5b12d feat: main.py Composition Root(仅 infer 模式)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 12:27:13 -04:00
iomgaa ba400417e2 config: concurrency=24, max_steps=40, breaker_threshold=48
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 12:27:05 -04:00
iomgaa a7ca6d15ed feat(harness): InferenceDepsRouter per-video 路由器
按 video_id 懒加载 InferenceDeps 并缓存,路由 dispatch/prompt_builder:
- create_dispatch: 按 session_id 路由到对应视频的工具调度
- create_prompt_builder: 自动注册 qid→vid 映射并路由 prompt 构建
- 三元组 (video_id, skills_dir, prompts_dir) 缓存键

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 12:20:32 -04:00
iomgaa f21bf345a6 feat(runner): 注入 tool_dispatch_factory/prompt_builder_factory + fail-fast
Runner.__init__ 新增 2 个可选参数:
- tool_dispatch_factory: 工具调度工厂
- prompt_builder_factory: prompt 构建工厂

infer/eval/train 模式缺少工厂时 fail-fast 抛 ValueError。
_make_tool_dispatch_fn/_make_prompt_builder 优先使用注入工厂。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 12:20:25 -04:00
iomgaa 924160c779 feat(ports): 新增 ToolDispatchFactory/PromptBuilderFactory Protocol
4 个新 Protocol 类型:
- ToolDispatchFn: 工具调度函数签名
- ToolDispatchFactory: per-version 工具调度工厂
- PromptBuilderFn: Prompt 构建函数签名
- PromptBuilderFactory: per-version prompt 构建工厂

含 runtime_checkable isinstance 测试。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 12:20:16 -04:00
iomgaa e7be42570d feat(store): skills/v1 初始集 — TRM4 精简 + TRM5 card 字段注入
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 12:12:47 -04:00
iomgaa bb6dad7005 refactor(store): prompts 版本化目录重组 + skills/v1 骨架 + system.md L1 去 subtitle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 12:07:19 -04:00
iomgaa 16140cafcc fix(tree): from_dict L2 subtitle 旧格式兼容 fallback
旧格式 tree.json 无 card-level L2 subtitle 时,从已构造的 L3
children 聚合 subtitle,避免运行时加载静默丢失 L2 字幕。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 12:04:36 -04:00
iomgaa d3be9b1322 refactor(tree): subtitle 迁入 L3Card/L2Card + 建树管线修正
- L3Card/L2Card 新增 subtitle: str 字段(L1Card 不加)
- L3Node 移除 subtitle 字段(数据迁入 Card)
- assign_subtitles_voronoi 改写 Card.subtitle + L2 聚合
- _collect_card_strings 增加 skip_fields 排除 subtitle
- _node_full_text/_node_anchored_text 保持 字幕:/[sN] 语义
- get_subtitle 读 Card.subtitle(L2/L3)
- verify.py/synthesizer.py: l3.subtitle → l3.card.subtitle
- 迁移脚本 tools/migrate_subtitle_to_card.py(幂等,300 棵树已迁移)
- 9→6 个测试文件适配(3 个无需改动)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 11:57:41 -04:00
iomgaa c72b55508a fix(plan): Task 0 Codex 审查修复 — 锚标保持/双写消除/幂等迁移/遗漏文件
- [C4] 加入 verify.py/synthesizer.py 到变更清单
- [I3a] L2 subtitle 统一由 Voronoi+聚合处理,移除 _build_l2 双写
- [I3b] File Structure 修正为'保留 Phase 7'
- [I5] 迁移脚本幂等性修复
- [I6] 保持 [cN]/[sN] 双锚语义,_collect_card_strings 跳过 subtitle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 11:43:33 -04:00
iomgaa 160fb3bc7c docs(plan): main.py 推理入口实现计划 + Codex 审查修复
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 10:43:15 -04:00
iomgaa 457944bbfa fix(design): 修复 Codex 审查发现的 5 项问题
- C1: 13 个 skill(非 14)
- C2: factory 从 noop fallback 改为 fail-fast 校验
- I1: 新增 InfraSettings(BaseSettings) 工程配置模型
- I2: 显式 supersede 搜索模块设计的 prompt 路径约定
- I3: Protocol 返回类型精确化
- I4: dispatch 对缺失 session_id 显式 raise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 08:47:23 -04:00
iomgaa 5d0caa06e1 docs(design): main.py 推理入口 + 初始 Prompt 集设计
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 08:41:22 -04:00
iomgaa 09d337658b merge: feat/repair-concurrent — 赛题生成工具 + repair 管线完善
新增模块:
- app/question_gen/synthesizer.py — 题型映射、节点采样、prompt 构造、去重
- app/harness/factory.py — 推理依赖组装(可复用)
- tools/generate_questions.py — generate + calibrate CLI

修复:
- _VIDEO_MME_TASK_TYPE_COUNT 11→12
- repair/supplement 防御性修复
- repair/regenerator VLM 重生成器
2026-07-09 07:54:21 -04:00
iomgaa 45403b23b4 feat(repair): regenerator + supplement 防御修复 + 迁移脚本
- 新增 app/tree/repair/regenerator.py(VLM 重生成 + 级联修复)
- supplement.py: deduplicate_field str() 防御 + inject_value strip
- patch.py: ruff format 格式化
- repair_trees.sh: conda source 激活修复
- 新增 migrate_from_trm4.sh 迁移工具
- enhance/__init__.py → repair/__init__.py 重命名

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 07:53:59 -04:00
iomgaa ace0738546 docs(wiki): record main-figure composition rework and review outcomes 2026-07-09 07:50:24 -04:00
iomgaa f57ee45dc0 fix: Codex 全量审查修正
Critical:
- C1: assemble_mode 'plain' → 'ids'(合法枚举值)
- C2: question_id 加入 task_type slug 避免跨题型冲突

Important/Minor:
- generate_one 移除未用的 embed_fn/similarity_threshold 参数
- config.py 注释 11→12 同步
- 测试 question_id 断言更新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 07:40:27 -04:00
iomgaa fad8147d71 refactor(question_gen): __init__.py 追加 synthesizer re-export
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:49:05 -04:00
iomgaa 93c9be8bfa feat(tools): generate_questions.py calibrate 子命令
- Fisher exact test + effect size 组合判定(PASS/WARN/FAIL)
- 按 video_id 分组推理,避免跨视频树错用
- baseline 支持从 DB 读取或自动跑推理
- 对比表输出 + 退出码控制

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:47:52 -04:00
iomgaa 11f3c90200 feat(tools): generate_questions.py generate 子命令
- VLM 出题 + embedding 去重 + 断点续跑 + 并发控制
- 单线程汇总点保证去重原子性
- 18 个单元测试覆盖 progress/exemplar/pool rebuild/JSON append

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:42:50 -04:00
iomgaa 6e46d184b8 feat(harness): add factory.py — InferenceDeps dataclass + build_inference_deps
组装一次推理所需的全套依赖的工厂函数:
- TreeIndex 加载(FileNotFoundError if missing)
- TreeEnvironment 构建
- SkillRegistry 按需发现
- SearchToolDispatcher 装配
- PromptManager + prompt_builder 闭包

测试覆盖:正常路径、缺失树文件、skills 注入、frozen 不可变性。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:36:07 -04:00
iomgaa 5aa7cc48c5 feat(question_gen): is_duplicate + generate_one — 去重判定与单题生成编排
- is_duplicate: 余弦相似度去重,空池短路
- generate_one: 异步重试循环,不含去重(由调用方汇总点原子执行)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:32:39 -04:00
iomgaa 90f17e330e feat(question_gen): build_generation_prompt + parse_vlm_response
- prompt 组装:system(角色+题型+约束+few-shot) + user(card+字幕+干扰项)
- VLM 响应解析:JSON 直接 + markdown code block 回退,四选一 schema 校验

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:28:25 -04:00
iomgaa 40b04f886e fix(question_gen): sample_anchor Codex 审查修正
- C1/C2: Temporal Reasoning ≥3 L2 + Object Reasoning ≥2 L2 下限检查
- I1: L2 题型子帧不足时 ValueError
- I2: L3 过滤无 frame_path 的节点
- I3/I4: distractor_texts 扩展到整棵树范围
- I5-I7: 测试补强 Information Synopsis/Temporal Reasoning/Object Reasoning
- M1: Spatial Reasoning 测试断言 spatial_layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:24:30 -04:00
iomgaa a597a9f901 feat(question_gen): sample_anchor — 按题型层级采样锚节点
含 6 种层级分支:L3 单帧、L2 多帧、Temporal Perception 特例、
L1 全量/采样 L2、L1-L2 混合。时间排序 + used_node_ids 排除。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:18:25 -04:00
iomgaa 9eb9b86954 feat(question_gen): AnchorContext + 12 题型-层级映射常量
- AnchorContext frozen dataclass: 锚节点生成上下文(node_id, card_text, frame_paths, subtitle, distractor_texts)
- TaskTypeSpec frozen dataclass: 题型生成规格(level, needs_frames, frame_count, context_fields)
- TASK_TYPE_LEVEL_MAP: 12 种 Video-MME 题型 → 树层级 + 生成规格映射
- 11 项单元测试覆盖:映射完整性、值类型、层级合法性、frozen 不变性

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:10:58 -04:00
iomgaa eb15ab315e fix(config): _VIDEO_MME_TASK_TYPE_COUNT 11→12,Video-MME 实际有 12 种题型
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:08:05 -04:00
iomgaa 0b48b889e0 docs(wiki): 赛题生成工具设计 + 实现计划
design: synthesizer + factory + CLI 三模块架构
plan: 9 个 Task(前置修复 + synthesizer 4 步 + factory + CLI generate/calibrate + re-export)
calibrate: Fisher exact test 组合判定替代固定阈值

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:05:57 -04:00
iomgaa 8c383354c5 fix(detector): 移除 ongoing_actions/visible_entities 空字段检测 + 新增 repair sh
抽检确认这两个字段为空是合法内容状态(静物/黑帧/模糊帧),
VLM 重修也修不好,保留会导致断点续跑死循环。
L3 空字段检测缩减为 frame_summary + spatial_layout。
2026-07-09 00:54:27 -04:00
iomgaa 86573735aa docs(wiki): apply Codex review fixes to main-figure design 2026-07-09 00:46:08 -04:00
iomgaa 8cb1158a2d docs(wiki): paper main-figure design — self-evolving loop layout 2026-07-09 00:39:45 -04:00
iomgaa d6bcf41336 fix(repair): Codex 审查修正 — progress 完成判据加严 + load_progress 防御增强
1. 保存 progress 前重新 detect_issues,只有 empty_field 清零才记 finished
   (修复 L2/L1 空字段被检测但未修复仍写 finished 的问题)
2. load_progress 增加 AttributeError 捕获(防 JSON 非 dict 形态崩溃)
2026-07-09 00:30:23 -04:00
iomgaa 4612123ec4 style: ruff format telemetry + repair_trees, remove unused SRTEntry import 2026-07-09 00:25:36 -04:00
iomgaa 847def4a03 feat(repair): asyncio.Semaphore 并发 + 断点续跑 + CLI 参数
--concurrency 默认 16,--reaggregate-all 强制全量重聚合。
Semaphore 限视频并发数,视频内四步串行。progress 文件
asyncio.Lock + os.replace 原子写入。熔断阈值 max(.env, concurrency*2)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 00:23:40 -04:00
iomgaa afe80a8b32 feat(repair): 断点续跑 progress 文件管理
load_progress / save_progress(asyncio.Lock + os.replace 原子写入)
/ should_skip_video。支持并发安全的读改写和 --reaggregate-all 兜底。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 00:23:04 -04:00
iomgaa 8182cb86b1 feat(detector): 扩展空字段检测到 L2 event_description / L1 scene_summary
断点续跑判据需要 L2/L1 层的 empty_field 检测。零 LLM 成本。
2026-07-09 00:12:55 -04:00
iomgaa f733c13dd1 fix(llm): call_id 移入重试循环,每 attempt 独立
消除重试时遥测主键冲突的根因。每次 attempt 独立记录,
parent_call_id 不受影响(循环外固定),更利于事后诊断重试轨迹。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 00:12:38 -04:00
iomgaa 5a91f392f0 fix(telemetry): INSERT OR IGNORE + WAL + try/except 三层防御加固
根治遥测写入主键冲突(UNIQUE constraint)和并发写锁(database is locked)
导致的异常冒泡,遥测侧信道错误不再污染 LLM 重试链。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 00:11:54 -04:00
iomgaa dbc9d38cd7 plan(tree/repair): 三项改造实现计划(遥测加固+断点续跑+并发)
6 个 Task: telemetry 防御加固 → call_id 根因修复 → detector L2/L1 扩展
→ progress 管理 → 并发编排+CLI → lint+全量测试
2026-07-09 00:08:23 -04:00
iomgaa 8a49ef18e6 design(tree/repair): 建树修复管线熔断根因修复+断点续跑+并发改造设计
三事: (1)遥测防御性加固(INSERT OR IGNORE+WAL+busy_timeout+try/except不冒泡)
根治call_id重试复用导致的主键冲突污染重试与熔断器; (2)判据双轨
(detect重扫empty_field+progress文件)视频级断点续跑,首次用detect
自动接历史无需手补状态; (3)asyncio.Semaphore并发默认16,熔断阈值
max(5,concurrency*2),--concurrency走CLI避免与tree.concurrency串台
2026-07-08 23:52:48 -04:00
iomgaa 53286ac3ab refactor: remove deprecated retriever module
RecursiveRetriever was a failed approach, not carried into TRM5.
- delete app/retriever/ (empty placeholder)
- drop retriever + train blocks from config/default.yaml
- renumber fidelity checklist 13->12 items (drop #4, shift up)
- sync core-goal text, dir tree, module-interaction diagrams
  across CLAUDE.md, ARCHITECTURE.md, overview.md, README.md
- reference/ kept intact as historical code
2026-07-08 23:09:30 -04:00
iomgaa 91281cb995 merge: feat/app-harness — app/harness/ 训练循环编排层(14 文件, 算法保真 #6/#10/#13) 2026-07-07 20:35:04 -04:00
iomgaa 9a42bbcf85 style(harness): ruff format batching.py + log.py 2026-07-07 20:34:48 -04:00
iomgaa a668fc173c feat(harness): __init__.py public API + lint fixes 2026-07-07 13:45:48 -04:00
iomgaa d6e74f2734 feat(harness): runner.py — train loop orchestrator (#13 algorithm fidelity)
Three-level nesting (epoch -> step -> per-skill), slow update 10-step
sequence, checkpoint/resume, early stop, probation accept/reject/rollback.

Key TRM4->TRM5 changes:
- sync -> async (all inference/diagnosis/evolve/validate awaited)
- LLMClient.from_env -> injected LLMProvider (DI via constructor)
- Direct DB/file access -> module functions (workspace/store/log)
- _TrainState as train() local, explicit param passing to helpers

Module-level pure functions extracted for testability:
resume_plan, _guard_infra_failures, _apply_batch_correctness,
_compute_total_steps, _should_early_stop, _format_applied_edits,
_fallback_summary, _write_skip_report, _outcome_to_quadrant_pairs,
_build_comparison_pairs, _batch_from_ids, _snapshot_current_skills.

Tests: 34 unit tests covering 13a-13e sub-tasks.
Radon: all functions Grade B or better.
2026-07-07 13:43:20 -04:00
iomgaa 6baddcc17d feat(harness): validate.py — async 块序贯验证编排 + Probation 统一定义 2026-07-07 13:20:43 -04:00
iomgaa 8c9f7d2d37 feat(harness): checkpoint.py — TrainState 序列化 + 原子写 + 指纹校验 2026-07-07 13:13:37 -04:00
iomgaa 6b56fc8f98 feat(harness): inference.py — async run_inference + DI 2026-07-07 13:04:26 -04:00
iomgaa 49e272719e feat(harness): momentum.py — async 慢更新动量生成 2026-07-07 13:01:04 -04:00
iomgaa 767ac55787 feat(harness): observation.py — 五张观测表 + step/epoch 报告 2026-07-07 12:55:12 -04:00
iomgaa d7d8633dae feat(harness): gate_ladder.py — 信息阶梯 + BaselineCache (#6 算法保真) 2026-07-07 12:55:12 -04:00
iomgaa 48b423ef35 feat(harness): pools.py — 三池切分(test→validation→diagnosis) 2026-07-07 12:48:54 -04:00
iomgaa ad8fc176d0 feat(harness): batching.py — FFD + round-robin mini-batch (#10 算法保真) 2026-07-07 12:47:33 -04:00
iomgaa 461abf0329 feat(harness): workspace.py — Workspace lifecycle + VersionedSkillStore/PromptStore
- ResolvedPaths frozen dataclass: store_dir, videos_dir, questions_dir,
  skills_dir, prompts_dir, workspace_dir, db_path, analyses_dir, runs_dir
- init_workspace: create ws + copy seed weights from store
- init_workspace_from_seed: create from seed with fail-fast questions check
- load_manifest / resolve_paths: manifest I/O + path resolution
  (skills/prompts resolve to workspace, videos/questions to store)
- update_manifest: key whitelist validation
- record_run: idempotent history append + per-video wiki dirs
- read_best / update_best: best pointer independent of current
- list_video_ids: videos with tree.json
- archive_workspace: move to .archive/<name>-<ts>
- VersionedSkillStore: implements core/evolution/protocols.py::SkillStore
- VersionedPromptStore: implements core/evolution/protocols.py::PromptStore
- 21 tests all passing (incl. Protocol compliance checks)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 12:41:59 -04:00
iomgaa 6858da80c7 feat(harness): store.py — Store 版本操作 + Seed 管理
从 TRM4 core/workspace.py 拆出 Store + Seed 相关函数:
- _parse_version / list_versions / next_version / advance_version
- _write_meta / init_store
- init_seed / list_seeds / read_seed
- extract_run_db(保留原始 CREATE 语句重建主键约束)
- promote_to_seed(强校验版本一致 + 非 NULL + finally 清理)

26 个测试全部通过,radon 复杂度 A (2.83)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 12:36:36 -04:00
iomgaa fd76720dcf feat(harness): HarnessLog SQLite wrapper + RunLogImpl readonly port
- HarnessLog: TRM4 direct port with WAL mode, threading.Lock, INSERT OR
  IGNORE idempotent _runs, context manager (completed/failed), create_table
  with auto run_id+timestamp, insert/insert_many/execute/query/log_event
- RunLogImpl: implements core/evolution/protocols.py::RunLog Protocol with
  independent sqlite3.connect for read-only SELECT (no _runs pollution),
  asyncio.to_thread wrapping for async interface
- _read_table: shared readonly helper with optional question_ids filtering,
  graceful empty-list return for missing tables
- Tests: 17 cases covering thread safety, idempotent inserts, context
  manager status, WAL mode, protocol compliance, readonly isolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 12:29:29 -04:00
iomgaa b47f634e61 fix(harness): Codex functional review 修复 — 命名/集成测试/delta_min/promote 消息
- _apply_env_overrides → _apply_env_var_overrides,docstring 明确从 os.environ 读取
- 新增 TestLoadConfigRealYaml:用真实 config/default.yaml 验证嵌套 harness 解析
- 新增 test_delta_min_negative_rejected:覆盖 gate_delta_min >= 0 校验
- 恢复 promote 模式独立错误消息(从合并分支分离回 TRM4 原始提示)
- 77 个单元测试全部通过,radon 全部 Grade B 或更好

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 12:23:27 -04:00
iomgaa 535bd320b0 fix(harness): 补充 train 模式 run_id 校验 + 拆分函数保持 radon Grade B
- _validate_mode_deps: 恢复 train 非 resume/fresh 时必须提供 run_id 校验
- 提取 _validate_train_run_id 用 early return 展平条件,避免 radon Grade C
- 合并 promote run_id 检查到 diagnose/evolve/promote 统一检查
- 新增 4 个测试:train+run_id / train+resume / train+fresh / train+baseline
- radon cc -n C 无输出(全部 Grade B 或更好)
- 74 个单元测试全部通过

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 12:14:28 -04:00
iomgaa 5e57cf19b6 refactor(harness): 拆分校验函数降低 radon 圈复杂度至 Grade B
- _validate: 拆出 _validate_mode(mode 依赖校验)+ _validate_basic(标量/枚举校验)
- _validate_gate: 拆为 _validate_gate_thresholds(e 值/净胜/方向)+ _validate_gate_ladder(阶梯/块序贯)
- load_config: 提取 _apply_env_overrides 函数
- radon cc -n C 无输出(全部 Grade B 或更好)
- 70 个单元测试全部通过

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 12:01:46 -04:00
iomgaa ca02b5a91d feat(harness): RunConfig frozen dataclass + 四层校验 + YAML/CLI/.env 三层加载
- RunConfig: 46 字段 frozen dataclass,从 TRM4 core/harness/config.py 迁移
- 四层校验链:_validate → _validate_edit_budget + _validate_minibatch + _validate_gate
- 新增 .env 覆盖层:工程配置(workspace_dir, store_dir)可通过 HARNESS_* 环境变量注入
- 合并优先级:CLI > .env > YAML(CLAUDE.md §4.5)
- load_config 支持嵌套 harness 段和扁平 YAML 两种格式
- run_id 改为默认空字符串(CLI-only 字段,YAML 不提供)
- resume/fresh 互斥校验不在 config 层(移至 runner.py)
- 70 个单元测试全部通过

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 11:57:35 -04:00
iomgaa 63ad68f737 feat(evolution): export dataclass types from __init__.py 2026-07-07 11:48:13 -04:00
iomgaa 2db612e0bb docs: app/harness/ implementation plan — 15 tasks, algorithm fidelity #6/#10/#13, 120 TRM4 tips 2026-07-07 11:45:38 -04:00
iomgaa af8081e9ed docs: app/harness/ design — 14-module training loop orchestration layer 2026-07-07 11:28:29 -04:00
iomgaa 283ea27b51 merge: feat/tree-index — core/evolution/ extractable kernel + tree/search/question_gen modules 2026-07-07 10:29:54 -04:00
iomgaa 9ab6a89298 feat(evolution): __init__.py public API + ARCHITECTURE.md Protocol update 2026-07-07 10:28:14 -04:00
iomgaa 5c42e0309b feat(evolution): evolve.py per-target evolution — skill/system/tool (#9) 2026-07-07 10:26:58 -04:00
iomgaa 2c5b4c819c feat(evolution): evolve.py validation + helpers (#9) 2026-07-07 10:12:15 -04:00
iomgaa c6a81f8504 feat(evolution): diagnose.py aggregation + case packs + run_diagnosis (#8) 2026-07-07 10:04:48 -04:00
iomgaa fdbf9222ab feat(evolution): diagnose.py metrics + attribution (Stage 1, #8) 2026-07-07 09:55:22 -04:00
iomgaa ebd83810aa feat(evolution): validate.py — pure block validation decision functions (#7) 2026-07-07 09:47:45 -04:00
iomgaa 7f8ed56428 feat(evolution): patch.py — 补丁引擎移植(算法 #9),51 测试全通过
从 TRM4 core/harness/patch.py (427 行) 零修改移植到 core/evolution/patch.py。

包含:
- 7 个常量(APPENDIX/MOMENTUM marker + MAX_CHARS + HEADING)
- appendix 区:追加/提取/替换/边界检测,损坏态 ValueError
- momentum 区:替换/提取/边界检测,注入防护
- apply_patch_with_report:4 种 op(append/insert_after/replace/delete)
- 冻结区坐标判定(每条 edit 重算)、report 1-based index
- 51 个单元测试覆盖全部公共 API 及边缘场景

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 09:44:05 -04:00
iomgaa 57793fb0f8 feat(evolution): gate.py — CE-Gate e-process 纯函数 (#5 算法保真)
从 TRM4 core/harness/eprocess.py 逐行迁移,零逻辑变更:
- compute_e_value: 截断 Beta 混合 e 值(log 空间 + betainc 对称性)
- gate_decision: 四出口优先级链(confirmed→directional→futility→exhaustion→continue)
- probation_verdict: 试用期非对称双向结算
- 常量保真: _WALD_WIN=ln1.4, _WALD_LOSS=ln0.6, _SHRINK_PSEUDO=4

仅变更: import 路径 + GateParams/GateVerdict 移至 types.py + 中文 docstring

14 tests 覆盖: e 值数学、边界校验、四出口路径、试用期三分支

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 09:38:21 -04:00
iomgaa 47c2de71a2 feat(core/evolution): protocols.py + types.py 基础层 — 18 个 dataclass + 3 个 Protocol
TRM4 诊断/进化/门控数据类型迁移至 TRM5 Clean Architecture 内核。
逐字段比对 TRM4 的 eprocess.py、diagnose.py、evolve.py 保真迁移。

types.py (18 个 dataclass):
- Gate: GateParams, GateVerdict (frozen, 原样迁移)
- 诊断: SpanMetrics, SkillStepAdherence, QuestionMetrics,
  ErrorAttribution, CaseSample, SkillCasePack, SystemCasePack,
  ToolCasePack, DiagnosisResult (全部 frozen=True)
- 进化: EvolutionRecord (mutable), RejectedEdit (frozen),
  EvolutionResult (frozen, 移除 skills_version/prompts_version)
- 新增: PairResult, QuadrantClassification (块验证纯决策输出)
- 新增: DiagnosePrompts, EvolvePrompts (模板束, frozen)

protocols.py (3 个只读 Protocol):
- SkillStore, PromptStore (同步文件读取)
- RunLog (异步日志查询, 隔离 SQL)

变更理由:
- QuestionMetrics 由 TRM4 mutable 改为 frozen (一次性构造)
- ErrorAttribution 由 TRM4 mutable 改为 frozen (构造时填入全部字段)
- EvolutionResult 移除版本管理字段 (app/ 职责)

涉及算法: #5(CE-Gate), #8(诊断瀑布), #9(进化引擎)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 09:34:04 -04:00
iomgaa 8bbfbc9b8a docs: core/evolution/ implementation plan — 9 tasks, TDD, algorithm fidelity checks 2026-07-07 07:55:21 -04:00
iomgaa 2761ca3e82 docs: core/evolution/ extractable kernel design — gate, patch, diagnose, evolve 2026-07-07 07:39:24 -04:00
iomgaa dc19e9e007 docs: 同步 app/search/ 模块结构 + OCRProvider 签名到 ARCHITECTURE.md 2026-07-07 06:14:26 -04:00
iomgaa c42813992f feat(search): __init__.py 公开 API + 修复 OCR 测试 asyncio 兼容性 2026-07-07 06:13:25 -04:00
iomgaa 502681694b feat(search): PromptManager — 搜索 Agent 提示词加载与组装
从 TRM4 core/search/prompt.py 迁移。有意变更:
- 工具描述从 app.search.tools.get_tool_descriptions 获取
- format_user_prompt 参数显式化(question/options/l1_node_ids/task_type)

16 个单元测试覆盖 __init__、build_inference_prompt(auto/manual/none
三种 skill_mode)、format_user_prompt、load。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 06:11:13 -04:00
iomgaa 96e63a3e19 feat(search): 实现 SearchToolDispatcher 工具调度器 (Task 7)
- 新增 app/search/tools.py:
  - get_tool_descriptions() 工具描述文本(与 TRM4 一致)
  - SearchToolDispatcher 类实现 ToolDispatcher Protocol
  - dispatch() 按工具名路由: view_node / search_similar /
    observe_frame / submit_answer / read_skill
  - ValueError(未知工具)上抛,KeyError/FileNotFoundError 捕获返回错误文本
  - view_node: env.get_node_text + summarize_node + get_children_info + summarize_children
  - search_similar: env.search_similar + summarize_nodes_batch
  - observe_frame: env.resolve_frame_paths + get_subtitle + observe_frame + 字幕前置

- 修复 app/tree/environment.py get_children_info():
  - 原实现返回 _format_time_range (str) 导致 summarize_children 解包失败
  - 改为返回原始数值元组 via 新增 _node_time_range_raw 静态方法

- 新增 tests/unit/test_search_tools.py (14 tests):
  - get_tool_descriptions 含/不含 read_skill
  - 五种工具 dispatch 路由验证
  - 未知工具 ValueError + 节点不存在错误文本

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 06:07:27 -04:00
iomgaa 3fe077a249 style: format summarizer.py 2026-07-07 05:55:40 -04:00
iomgaa b8706adb33 style: format vision.py 2026-07-07 05:53:33 -04:00
iomgaa d83e720c4b feat(search): app/search/vision.py — 两轮 VLM 帧观察模块
从 TRM4 core/tree/vision.py 迁移 observe_frame,关键变更:
- VLM 调用走 VLMProvider.chat_with_images Protocol(images 传 Path)
- OCR 调用走 OCRProvider.transcribe_frames 异步 Protocol
- 遥测字段 session_id / parent_call_id 透传
- 帧文件存在性前置校验

12 个单元测试覆盖:两轮正常、仅提取、OCR 注入/失败降级/None、
VLM 提取失败、VLM 验证失败降级、帧缺失、stats 完整性、
分歧/弃权标记、遥测透传。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 05:52:54 -04:00
iomgaa 224c75ba9e feat(adapters): OCRProvider Protocol + MonkeyOCRClient 异步适配器
- app/ports.py: 新增 OCRProvider Protocol(runtime_checkable,与
  EmbeddingProvider 同级),定义 async transcribe_frames 端口
- adapters/ocr.py: 从 TRM4 core/tree/ocr.py 保真迁移 MonkeyOCRClient
  - assert → ValueError(P5 防御性校验)
  - 公开方法改 async(asyncio.to_thread 包装同步 HTTP)
  - 内部逻辑不变:多端点轮询、线程安全 Session、单帧降级、行去重
- tests/unit/test_ocr_adapter.py: 17 个测试覆盖 Protocol 合规、
  构造校验、健康检查、转录、降级、去重、轮询

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 05:47:55 -04:00
iomgaa 83e6475f90 feat(search): app/search/skills.py — 技能注册表与 frontmatter 解析
从 TRM4 core/search/skills.py 保真迁移。提供 parse_frontmatter、
strip_frontmatter、SkillRegistry、discover_skills 四个公共 API。
逻辑完全一致,仅调整导入路径并添加中文 docstring。

17 个单元测试全部通过,覆盖正常/异常/边界场景。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 05:46:25 -04:00
iomgaa f0a49b08e5 style: format environment.py 2026-07-07 05:46:25 -04:00
iomgaa 2bc5dc74c2 feat(tree): add get_node_text + get_children_info to TreeEnvironment
- get_node_text(node_id, anchor=False): returns raw text + optional
  anchor_map dict by parsing [cN]/[sN] prefixes from anchored text
- get_children_info(node_id): returns structured child list with
  id/time_range/summary (description truncated to 120 chars)
- Both methods reuse existing internal helpers (_node_full_text,
  _node_anchored_text, _get_children, _node_description,
  _format_time_range)
- 9 new test cases across TestGetNodeText and TestGetChildrenInfo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 05:45:48 -04:00
iomgaa 112876de2e chore: 复制 TRM4 v2 prompt 种子文件(9 个,字节级一致)
.gitignore 添加 !store/prompts/ 例外——prompt 是版本化资源需提交
2026-07-07 05:42:11 -04:00
iomgaa e36f7c63f3 docs(plan): 恢复被 wiki 工具覆盖的计划全文 2026-07-07 05:39:21 -04:00
iomgaa e20bfac48d docs(plan): app/search/ 搜索 Agent 装配层实现计划(10 Task) 2026-07-07 05:36:25 -04:00
iomgaa 961e85111b docs(design): 修复 Codex 审查发现 — 10 项修正
Critical: OCRProvider 移至 app/ports.py;新增 TreeEnvironment 结构化 API;
         observe_frame 补充字幕上下文拼接;search_similar 补充节点文本获取
Important: 遥测链路透传 session_id/parent_call_id;异常降级边界明确化;
           verify_vision/anchor/assemble_mode 改为必传;Prompt 路径逐文件列出;
           测试目录规范化到 tests/unit/
Minor: 依赖图补全 VLMProvider 连线
2026-07-07 05:29:52 -04:00
iomgaa bc18fc8635 docs(design): 补充 summarizer 模块 — view_node/search_similar 的 LLM 两轮摘要
自审发现 TRM4 三个搜索工具全有 question-conditioned 中间摘要层,
原设计遗漏了 summarizer.py。新增:
- §4.4 summarizer API(summarize_node / _children / _nodes_batch)
- anchor 锚模式完整保留
- 6 个额外 prompt 文件
- 更新交互流程图、错误处理、测试策略
2026-07-07 05:22:55 -04:00
iomgaa 5ab5adec51 docs(design): 搜索 Agent 装配层设计(app/search/)
方案 A 平铺模块:prompt.py / skills.py / tools.py / vision.py
新增 OCRProvider Protocol + adapters/ocr.py
Prompt 从 TRM4 store/prompts/v2/ 原封不动复制
2026-07-07 05:15:22 -04:00
iomgaa 253f36797b docs: 同步 question_gen 模块路径到 ARCHITECTURE.md 和 CLAUDE.md
DataLoader 代码位置 generator.py → loader.py;
目录树更新 question_gen/ 结构反映实际文件。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 04:51:26 -04:00
iomgaa f94c352d66 feat(question_gen): QuestionGenerator Protocol + 模块公开 API
app/ports.py 追加 QuestionGenerator Protocol(预留 LLM 出题接口)。
app/question_gen/__init__.py re-export load_benchmark 和 stratified_sample。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 04:48:21 -04:00
iomgaa c4d42eeca0 feat(question_gen): stratified_sample — 分层采样 + 题型保底
算法 100% 保真 TRM4: task_types 过滤、correctness.get(id, False) 语义、
对题在前返回顺序、min_per_class 遍历 pool 全部题型(含稀疏类)。
所有参数显式传入,无默认值。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 04:45:19 -04:00
iomgaa 8f5fbf8d2d feat(question_gen): load_benchmark — benchmark JSON 加载
从 JSON 目录 glob *.json 加载题目,stem 作 video_id。
legacy schema 无 difficulty 字段时赋 _LEGACY_DEFAULT_DIFFICULTY 常量。
options/source_nodes 转 tuple 配合 frozen dataclass。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 04:41:25 -04:00
iomgaa 9bdfd28c63 feat(core): 追加 GeneratedQuestion frozen dataclass
跨层共享类型,被 core/evolution/ 和 app/harness/、app/question_gen/ 使用。
frozen=True + tuple 字段确保不可变。无默认值(显式传入)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 04:36:56 -04:00
iomgaa 15fad65b1b feat(tree/repair): 检测器扩展 — visible_entities/ongoing_actions/spatial_layout 为空也触发修复 2026-07-07 03:15:42 -04:00
iomgaa faf6f1bd63 fix(tools): handle None card in flat tree conversion 2026-07-07 03:13:38 -04:00
iomgaa c61ceca538 test(tree): 建树模块端到端集成测试
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 02:44:28 -04:00
iomgaa 8cfdc841da feat(tree/repair): Q&A 反向补全 — 从 TRM4 supplement 迁移
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 02:37:50 -04:00
iomgaa 79761852df feat(tree): VideoTreeBuilder 保真 #1 #2 #3 + 复杂度重构
- 从 reference/video_tree_trm/video_tree_builder.py (994行) 迁移
- 保真算法 #1: L2 轴心建树策略 (asyncio.gather 链式并发)
- 保真算法 #2: VLM 批量帧描述 + JSON fallback (_L3_BATCH_SIZE=5)
- 保真算法 #3: 断点续跑 (progress.json + L1 中间 JSON)
- 新增: VLMProvider/LLMProvider Protocol 替代 LLMClient
- 新增: 结构化 JSON 输出 → L1Card/L2Card/L3Card
- 新增: L2 代表帧复用 L3 帧 (_sample_representative_frames)
- 新增: 字幕注入 + Voronoi 分配
- 重构: 提取 _load_resume_state/_assemble_roots 降低 _build_async 复杂度 D(21)→C(14)
- 44 个单元测试全部通过

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 02:17:50 -04:00
iomgaa af2aa751b9 feat(tree): 质量校验 — 交叉验证 entities/visible_text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 01:57:32 -04:00
iomgaa e87a744df9 feat(tree): 字幕模块 — SRT 解析 + 完整性检查 + Voronoi 分配
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 01:54:09 -04:00
iomgaa 3e348f5b21 feat(adapters): GovernedVLMClient — VLMProvider 最小可用实现
- 包装 GovernedLLMClient,注入 base64 图片到 OpenAI Vision API 格式
- 复用 LLM 治理栈全部能力(熔断、缓存、重试、遥测)
- 8 项单元测试覆盖协议满足、图片编码、注入逻辑、不可变性
2026-07-07 01:49:55 -04:00
iomgaa 6fd9522bf3 feat(adapters): EmbeddingProvider Protocol + local/remote 双后端实现
- app/ports.py: 添加 EmbeddingProvider Protocol(runtime_checkable,dim 属性 + embed 方法)
- adapters/embedding.py: 从参考代码迁移,拆分为 LocalEmbeddingProvider 和 RemoteEmbeddingProvider
  - Local: sentence-transformers 冻结推理,维度校验
  - Remote: OpenAI 兼容 API,L2 归一化,按 index 排序
  - 两者均提供 embed() 和 embed_tensor() 统一接口
- tests/unit/test_embedding_adapter.py: Protocol 满足性、形状校验、导入测试

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 01:45:09 -04:00
iomgaa 5ec8dd2f82 feat(tree): TreeConfig 配置 dataclass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 01:41:50 -04:00
iomgaa 1b2442b9c5 fix(tree): from_dict 增加 ID 唯一性校验
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 01:40:46 -04:00
iomgaa c4bc5e7cc6 refactor(tree): extract _embed_l2_subtree to reduce embed_all complexity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 01:37:09 -04:00
iomgaa fd58a3f02b feat(tree): TreeIndex 数据结构 — Card 体系 + 节点 + 序列化
- 新增三级 frozen Card dataclass: L3Card(6字段), L2Card(7字段), L1Card(7字段)
- 节点重构: L3Node/L2Node/L1Node 使用 Card 替代原始字符串字段
- 添加 @property 兼容层: description/summary 代理到 Card 字段
- L3Node 新增 subtitle 字段(字幕集成预留)
- JSON 序列化/反序列化支持 Card 结构 + embedding base64 编解码
- load_json 新增 ID 唯一性校验(重复 ID 抛 ValueError)
- 移除 pickle 序列化(仅保留 JSON)
- 日志从 log_msg 迁移到 loguru
- 17 个单元测试全部通过

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 01:35:10 -04:00
iomgaa d95b2a3142 docs(tree): add tree module vertical slice design and implementation plan 2026-07-07 01:27:36 -04:00
iomgaa adf14633e5 chore: lint 修复(TC001/TC003 类型导入优化)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-06 23:15:06 -04:00
370 changed files with 66141 additions and 1695 deletions
+9 -1
View File
@@ -22,6 +22,7 @@ Every project goes through this process. A todo list, a single-function utility,
You MUST create a task for each of these items and complete them in order:
1. **Explore project context** — check files, docs, recent commits
1.5. **Prior-version audit (mandatory for rewrites/refactors)** — if the task replaces or rewrites an existing module, list every behavior of the old version (including persistence, crash recovery, idempotency, resume) and confirm each is kept, replaced, or deliberately dropped. Undocumented implicit drops = bugs.
2. **Offer visual companion** (if topic will involve visual questions) — this is its own message, not combined with a clarifying question. See the Visual Companion section below.
3. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria
4. **Propose 2-3 approaches** — with trade-offs and your recommendation
@@ -92,7 +93,12 @@ digraph brainstorming {
- Once you believe you understand what you're building, present the design
- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced
- Ask after each section whether it looks right so far
- Cover: architecture, components, data flow, error handling, testing
- Cover: architecture, components, data flow, error handling, testing, **non-functional requirements** (see below)
- **Non-functional requirements (mandatory section):** Every design MUST explicitly address these four dimensions — even if the answer is "not applicable":
- **Persistence strategy:** When does data hit disk? How much is lost on crash? Overwrite or append?
- **Idempotency:** Is the same operation safe to repeat? Does it produce the same result?
- **Resume/checkpoint:** Can the process recover from interruption? How is progress persisted?
- **Atomicity:** Are writes atomic? Can a partial write corrupt data?
- Be ready to go back and clarify if something doesn't make sense
**Design for isolation and clarity:**
@@ -124,6 +130,8 @@ After writing the spec document, look at it with fresh eyes:
2. **Internal consistency:** Do any sections contradict each other? Does the architecture match the feature descriptions?
3. **Scope check:** Is this focused enough for a single implementation plan, or does it need decomposition?
4. **Ambiguity check:** Could any requirement be interpreted two different ways? If so, pick one and make it explicit.
5. **Non-functional coverage:** Does the design explicitly address persistence, idempotency, resume, and atomicity? If any dimension is missing, add it now — even if the answer is "not applicable."
6. **Prior-version regression check (rewrites only):** If this replaces an existing module, confirm every behavior from the prior-version audit (step 1.5) is accounted for in the design. Any gap = a spec bug.
Fix any issues inline. No need to re-review — just fix and move on.
+11 -7
View File
@@ -5,27 +5,27 @@ NO_PROXY=dashscope.aliyuncs.com,api.deepseek.com
# ── 搜索 Agent LLM ──
SEARCH_LLM_MODEL=deepseek-v4-pro
SEARCH_LLM_BASE_URL=https://newapi.iomgaa.online/v1
SEARCH_LLM_API_KEY=sk-lhDmxnhlnPd7ketQ3Z4uMRj4dCgnVpSJzdY2VTrjYpKFmCIV
SEARCH_LLM_API_KEY=sk-your-api-key-here
# ── 评估 Judge LLM ──
JUDGE_LLM_MODEL=deepseek-v4-pro
JUDGE_LLM_BASE_URL=https://newapi.iomgaa.online/v1
JUDGE_LLM_API_KEY=sk-lhDmxnhlnPd7ketQ3Z4uMRj4dCgnVpSJzdY2VTrjYpKFmCIV
JUDGE_LLM_API_KEY=sk-your-api-key-here
# ── 视觉模型(Qwen VL)──
VL_LLM_MODEL=qwen3.6-plus
VL_LLM_BASE_URL=https://newapi.iomgaa.online/v1
VL_LLM_API_KEY=sk-lhDmxnhlnPd7ketQ3Z4uMRj4dCgnVpSJzdY2VTrjYpKFmCIV
VL_LLM_API_KEY=sk-your-api-key-here
# ── 进化 LLM(Prompt 改写)──
EVOLVE_LLM_MODEL=deepseek-v4-pro
EVOLVE_LLM_BASE_URL=https://newapi.iomgaa.online/v1
EVOLVE_LLM_API_KEY=sk-lhDmxnhlnPd7ketQ3Z4uMRj4dCgnVpSJzdY2VTrjYpKFmCIV
EVOLVE_LLM_API_KEY=sk-your-api-key-here
# ── ASR 字幕生成(Groq Whisper)──
ASR_MODEL=whisper-large-v3
ASR_BASE_URL=https://api.groq.com/openai/v1
ASR_API_KEY=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
ASR_API_KEY=gsk_your-groq-api-key-here
# ── MonkeyOCR ──
MONKEY_OCR_URLS=http://10.77.0.20:7866,http://10.77.0.20:7867
@@ -41,9 +41,13 @@ REDIS_URL=redis://localhost:6379/0
LLM_TIMEOUT=120
LLM_MAX_RETRIES=3
LLM_RETRY_BASE_DELAY=2.0
LLM_CIRCUIT_BREAKER_THRESHOLD=5
LLM_CIRCUIT_BREAKER_THRESHOLD=48 # 实际阈值 = max(此值, concurrency*2)
LLM_CIRCUIT_BREAKER_COOLDOWN=60
LLM_TTFT_TIMEOUT=30
LLM_INTER_TOKEN_TIMEOUT=15
LLM_RETRY_MAX_DELAY=30.0
REDIS_CACHE_TTL=86400
# 正整数秒,禁止 0(0 会被拒绝启动);训练场景建议 >= 单次训练时长
REDIS_CACHE_TTL=604800
# 建树批量并行:全局 VLM/LLM 在途调用上限(Spec-2 工程配置)
TREE_BUILD_API_CONCURRENCY=16
+1
View File
@@ -182,6 +182,7 @@ pencil/
# 数据与实验产物(不提交)
store/
!store/prompts/
!store/skills/
workspaces/
results/
+17 -3
View File
@@ -96,9 +96,10 @@ MODE=mock N_SAMPLES=10 bash scripts/<experiment>.sh # smoke test
### Phase 1: 规划与设计 (Planning)
1. **需求探索**: 涉及创建新功能、新组件、修改行为时,**必须**先调用 `brainstorming` skill 进行需求探索与设计。无论用户的指令多么具体、改动多么简单,都不得跳过此步骤(除非用户显式说"跳过 brainstorming")。
2. **查阅规格 & 讨论**: 仔细阅读 `research-wiki/`(单一事实源)下对应的文档,了解项目最新情况。对于不理解的地方请与人类进行多轮讨论,确保理解人类的设计意图。
3. **日志方案设计**: 功能会产生运行时数据时,**必须**调用 `structured-logging` skill 设计日志方案
4. **撰写计划**: 正式编码前**必须**调用 `writing-plans` skill 撰写实现计划
5. **审核门控(差异化)**:
3. **前序版本对照(重写/重构时强制)**: 当任务涉及重写或重构已有模块时,**必须**列出前序版本的所有行为(包括持久化策略、崩溃恢复、幂等性、断点续跑等非功能性行为),逐一确认新版本是保留、替代、还是删除。未经确认的隐式删除 = bug
4. **日志方案设计**: 功能会产生运行时数据时**必须**调用 `structured-logging` skill 设计日志方案
5. **撰写计划**: 正式编码前,**必须**调用 `writing-plans` skill 撰写实现计划。
6. **审核门控(差异化)**:
- **designClaude 自审 → Codex 审 → 人类审**(保留人类门,批准后方可进入计划阶段)。
- **planClaude 自审 → Codex 审 → 直接执行**(无 plan 人类门);plan 经 Claude 自审 + Codex 审通过后直接进入 Phase 2 执行。
@@ -159,6 +160,19 @@ MODE=mock N_SAMPLES=10 bash scripts/<experiment>.sh # smoke test
- **功能修改**:
- **必须** 不考虑向后兼容,直接修改原文件。代码简洁性优先。
### 4.2.1 设计文档非功能性需求覆盖(强制)
> **教训来源**: v2 出题管线重写时未继承 v1 的逐题追加持久化策略,导致多次 run 的题目丢失。
设计文档**必须**显式覆盖以下非功能性维度(即使答案是"不适用"也要写明):
| 维度 | 必答问题 |
|------|---------|
| **持久化策略** | 数据何时落盘?崩溃时最多丢多少?是覆盖写还是追加? |
| **幂等性** | 同一操作重复执行是否安全?结果是否一致? |
| **断点续跑** | 中断后重启能否从断点恢复?进度如何持久化? |
| **原子性** | 写操作是否原子?部分写入是否会损坏数据? |
### 4.3 Git 工作流规范
- **Feature Branch**: 所有开发工作在 feature 分支上进行,**严禁**直接在 main/master 上修改。
- **增量提交**: 频繁提交,每个提交有明确的语义。
+183
View File
@@ -0,0 +1,183 @@
"""SqliteDiagnosisSignalStorebaseline 逐题诊断信号的 SQLite 持久化适配器。
实现 core/evolution/protocols.py::DiagnosisSignalStore 端口。信号行以
(question_id, baseline_run_id, diag_fingerprint) 为主键,INSERT OR REPLACE
保证逐题幂等 upsert;bool 字段以 0/1 存储,可空字段以 NULL 存储。表建在
harness.db,供离线诊断编排写入、视频级切分选择器读取。
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING
from core.evolution.types import DiagnosisSignalRow
if TYPE_CHECKING:
from types import TracebackType
# 表列顺序即 DiagnosisSignalRow 字段顺序(question_id..session_id),
# upsert 写入与 load 还原共用,避免手写列名两处漂移。
_COLUMNS: tuple[str, ...] = (
"question_id",
"video_id",
"baseline_run_id",
"diag_fingerprint",
"task_type",
"error_type",
"cause_category",
"tier",
"evolution_target",
"degraded",
"infra",
"session_id",
)
class SqliteDiagnosisSignalStore:
"""逐题诊断信号的 SQLite 存储实现。
构造时按需建表(CREATE TABLE IF NOT EXISTS),主键
(question_id, baseline_run_id, diag_fingerprint) 保证同键覆盖。
每次写操作单事务 commit,保证原子落盘与断点续跑。
参数:
db_path: SQLite 数据库文件路径(通常为 harness.db)。
"""
def __init__(self, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(db_path)
self._conn.row_factory = sqlite3.Row
self._init_table()
def _init_table(self) -> None:
"""创建 baseline_diagnosis 表(若不存在)。"""
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS baseline_diagnosis (
question_id TEXT NOT NULL,
video_id TEXT NOT NULL,
baseline_run_id TEXT NOT NULL,
diag_fingerprint TEXT NOT NULL,
task_type TEXT NOT NULL,
error_type TEXT,
cause_category TEXT,
tier TEXT NOT NULL,
evolution_target TEXT,
degraded INTEGER NOT NULL,
infra INTEGER NOT NULL,
session_id TEXT,
PRIMARY KEY (question_id, baseline_run_id, diag_fingerprint)
)
"""
)
self._conn.commit()
def upsert(self, row: DiagnosisSignalRow) -> None:
"""写入或覆盖单题诊断信号(按主键幂等,单事务)。
参数:
row: 待持久化的诊断信号行。bool 字段转 0/1None 存 NULL。
关键实现:
用 INSERT OR REPLACE 按主键覆盖,避免重复行;commit 保证原子。
"""
placeholders = ", ".join("?" for _ in _COLUMNS)
col_names = ", ".join(_COLUMNS)
values = (
row.question_id,
row.video_id,
row.baseline_run_id,
row.diag_fingerprint,
row.task_type,
row.error_type,
row.cause_category,
row.tier,
row.evolution_target,
int(row.degraded),
int(row.infra),
row.session_id,
)
self._conn.execute(
f"INSERT OR REPLACE INTO baseline_diagnosis ({col_names}) VALUES ({placeholders})",
values,
)
self._conn.commit()
def done_question_ids(
self,
baseline_run_id: str,
diag_fingerprint: str,
*,
retry_uncertain: bool = False,
) -> set[str]:
"""查询指定 run 与诊断指纹下已完成的 question_id 集合。
参数:
baseline_run_id: baseline run 标识。
diag_fingerprint: 诊断口径指纹。
retry_uncertain: True 时追加 `AND tier != 'uncertain'`,把 uncertain
(信号不可信降级)题排除出已完成集,令其被重新诊断;默认 False。
返回:
已落盘信号的 question_id 集合;无匹配时为空集,供断点续跑跳过。
"""
sql = (
"SELECT DISTINCT question_id FROM baseline_diagnosis"
" WHERE baseline_run_id = ? AND diag_fingerprint = ?"
)
if retry_uncertain:
sql += " AND tier != 'uncertain'"
cursor = self._conn.execute(sql, (baseline_run_id, diag_fingerprint))
return {r["question_id"] for r in cursor.fetchall()}
def load(self, baseline_run_id: str, diag_fingerprint: str) -> list[DiagnosisSignalRow]:
"""加载指定 run 与诊断指纹下的全部诊断信号行。
参数:
baseline_run_id: baseline run 标识。
diag_fingerprint: 诊断口径指纹。
返回:
还原后的 DiagnosisSignalRow 列表(0/1→boolNULL→None)。
"""
col_names = ", ".join(_COLUMNS)
cursor = self._conn.execute(
f"SELECT {col_names} FROM baseline_diagnosis"
" WHERE baseline_run_id = ? AND diag_fingerprint = ?",
(baseline_run_id, diag_fingerprint),
)
return [
DiagnosisSignalRow(
question_id=r["question_id"],
video_id=r["video_id"],
baseline_run_id=r["baseline_run_id"],
diag_fingerprint=r["diag_fingerprint"],
task_type=r["task_type"],
error_type=r["error_type"],
cause_category=r["cause_category"],
tier=r["tier"],
evolution_target=r["evolution_target"],
degraded=bool(r["degraded"]),
infra=bool(r["infra"]),
session_id=r["session_id"],
)
for r in cursor.fetchall()
]
def close(self) -> None:
"""关闭底层 SQLite 连接,释放文件描述符与锁。"""
self._conn.close()
def __enter__(self) -> SqliteDiagnosisSignalStore:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
+18 -3
View File
@@ -20,21 +20,32 @@ class CircuitBreaker:
self._cooldown_s = cooldown_s
self._fails: dict[str, int] = {}
self._open_until: dict[str, float] = {}
self._half_open_inflight: dict[str, bool] = {}
def is_open(self, source_name: str, now: float) -> bool:
"""判断指定源是否处于开路状态。
冷却截止时刻之前为开路;到期返回 False(放行一个试探,即半开)。
冷却截止时刻之前为开路;到期进入半开,**只放行一个探针**(其余仍被挡),
避免冷却到期瞬间惊群重连再次压垮上游。"检查+标记探针"在 asyncio 单线程内
同步执行,天然原子无竞态。
Args:
source_name: 被熔断的源标识。
now: 当前时刻(秒级时间戳),由调用方注入。
Returns:
True 表示开路(拒绝请求),False 表示关闭或半开放行
True 表示开路(拒绝请求),False 表示关闭或半开放行探针
"""
until = self._open_until.get(source_name)
return until is not None and now < until
if until is None:
return False
if now < until:
return True # 冷却中,全挡
# 冷却到期:half-open,只放行一个探针
if self._half_open_inflight.get(source_name):
return True # 已有探针在途,继续挡
self._half_open_inflight[source_name] = True
return False
def record_failure(self, source_name: str, now: float) -> None:
"""记录一次失败;累计达阈值则开路至 now + cooldown。
@@ -47,6 +58,8 @@ class CircuitBreaker:
self._fails[source_name] = count
if count >= self._fail_threshold:
self._open_until[source_name] = now + self._cooldown_s
# 探针失败清在途标记,使下一轮 cooldown 到期后可再放行探针
self._half_open_inflight.pop(source_name, None)
def force_open(self, source_name: str, now: float) -> None:
"""强制开路(用于 401/403 等不可恢复错误),一次即熔断。
@@ -59,6 +72,7 @@ class CircuitBreaker:
"""
self._fails[source_name] = self._fail_threshold
self._open_until[source_name] = now + self._cooldown_s
self._half_open_inflight.pop(source_name, None)
def record_success(self, source_name: str) -> None:
"""记录一次成功;清零失败计数与开路状态(关闭熔断器)。
@@ -68,3 +82,4 @@ class CircuitBreaker:
"""
self._fails.pop(source_name, None)
self._open_until.pop(source_name, None)
self._half_open_inflight.pop(source_name, None)
+28 -13
View File
@@ -179,7 +179,10 @@ def _is_transient_error(exc: Exception) -> bool:
返回:
True 表示可重试,False 表示不可重试。
"""
if isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout)):
# 两族基类覆盖断连族:TimeoutExceptionConnectTimeout/ReadTimeout/WriteTimeout/PoolTimeout
# 与 TransportErrorConnectError/ReadError/RemoteProtocolError 等)。
# 注意 HTTPStatusError 非 TransportError 子类,401/403 致命分支不受影响。
if isinstance(exc, (httpx.TimeoutException, httpx.TransportError)):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in _TRANSIENT_STATUS_CODES
@@ -274,6 +277,7 @@ class GovernedLLMClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
cache_salt: str | None = None,
) -> LLMResponse:
"""发起 LLM 调用,经四层治理栈:熔断 → 缓存 → 重试+流式 → 遥测。
@@ -281,6 +285,7 @@ class GovernedLLMClient:
messages: OpenAI 格式消息列表。
session_id: 会话 ID(传递到遥测)。
parent_call_id: 父调用 ID(传递到遥测)。
cache_salt: 可选缓存盐,透传到 Redis 缓存键(如跨 epoch 重采样)。
返回:
LLMResponse 统一响应。
@@ -295,12 +300,14 @@ class GovernedLLMClient:
if self._breaker.is_open(self._provider, time.monotonic()):
raise CircuitOpenError(f"熔断器已开启,拒绝调用 provider={self._provider}")
# ② call_id 生成
call_id = str(uuid4())
# ③ 缓存查询(cache 为 None 时跳过)
cached = await self._cache.get(self._model, messages) if self._cache is not None else None
# ② 缓存查询(cache 为 None 时跳过)— call_id 在缓存路径独立生成
cached = (
await self._cache.get(self._model, messages, cache_salt)
if self._cache is not None
else None
)
if cached is not None:
cache_call_id = str(uuid4())
response = LLMResponse(
content=cached.content,
thinking=cached.thinking,
@@ -312,10 +319,10 @@ class GovernedLLMClient:
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=True,
call_id=call_id,
call_id=cache_call_id,
)
await self._telemetry.record_llm_call(
call_id=call_id,
call_id=cache_call_id,
parent_call_id=parent_call_id,
session_id=session_id,
model_name=self._model,
@@ -333,9 +340,10 @@ class GovernedLLMClient:
)
return response
# 重试循环 + 流式消费
# 重试循环 + 流式消费(每次 attempt 独立 call_id
last_exc: Exception | None = None
for attempt in range(self._max_retries):
call_id = str(uuid4())
attempt_start = time.monotonic()
try:
content, thinking_text, ttft_ms, max_itoken_ms, usage = await self._call_streaming(
@@ -371,7 +379,7 @@ class GovernedLLMClient:
# ④ 写缓存(cache 为 None 时跳过)
if self._cache is not None:
await self._cache.set(self._model, messages, response)
await self._cache.set(self._model, messages, response, cache_salt)
# ⑤ 遥测
await self._telemetry.record_llm_call(
@@ -450,12 +458,15 @@ class GovernedLLMClient:
},
)
if attempt < self._max_retries - 1:
delay = min(
import random
base = min(
self._retry_base_delay_s * (2**attempt),
self._retry_max_delay_s,
)
if delay > 0:
await asyncio.sleep(delay)
jitter = base * random.uniform(0.5, 1.5)
if jitter > 0:
await asyncio.sleep(jitter)
continue
# 非瞬时、非致命 → 记遥测后直接抛出
@@ -570,6 +581,10 @@ class GovernedLLMClient:
else:
thinking_parts.append(text)
# 流耗尽但未收 [DONE] → 服务端截断,视为可重试的 SSE 异常(不写缓存/不当成功)
if not usage_sink.get("done"):
raise _SseAnomaly("truncated_no_done")
content = "".join(content_parts)
thinking = "".join(thinking_parts)
usage = usage_sink.get("usage", {})
+1 -1
View File
@@ -14,7 +14,7 @@ from pathlib import Path # noqa: TC003 — 运行时需要(方法签名 + ope
import requests
from loguru import logger
_TIMEOUT_S = 15
_TIMEOUT_S = 300
class MonkeyOCRClient:
+49 -12
View File
@@ -12,6 +12,26 @@ from loguru import logger
from core.types import LLMResponse
def _resolve_cache_ttl(ttl: int) -> int:
"""校验 Redis 缓存 TTL:必须为正整数(消灭 0=永不过期 的隐式语义)。
Args:
ttl: 待校验的 TTL 秒数。
Returns:
校验通过的正整数 TTL。
Raises:
ValueError: ttl <= 0。
"""
if ttl <= 0:
raise ValueError(
f"REDIS_CACHE_TTL 必须为正整数秒,实际 {ttl}"
"训练场景建议 >= 单次训练时长(如 86400)。"
)
return ttl
class RedisResponseCache:
"""基于 Redis 的 LLM 响应缓存。
@@ -22,43 +42,55 @@ class RedisResponseCache:
Args:
redis: 异步 Redis 客户端实例(duck-typed,需支持 get/set 方法)。
ttl_s: 缓存过期时间(秒)。
ttl_s: 缓存过期时间(秒)。None 表示永不过期。
"""
def __init__(self, redis: Any, ttl_s: int) -> None:
def __init__(self, redis: Any, ttl_s: int | None) -> None:
self._redis = redis
self._ttl_s = ttl_s
def _build_key(self, model: str, messages: list[dict[str, str]]) -> str:
def _build_key(
self,
model: str,
messages: list[dict[str, str]],
cache_salt: str | None = None,
) -> str:
"""构造 content-addressed 缓存键。
Args:
model: 模型名称。
messages: 消息列表。
cache_salt: 可选缓存盐(如跨 epoch 强制重采样)。仅当非 None 时才加入
键 payload,保证默认 None 时键结构与旧缓存一字节不差、旧键不失效。
Returns:
sha256 哈希字符串作为 Redis 键。
"""
payload = json.dumps(
{"model": model, "messages": messages},
sort_keys=True,
ensure_ascii=False,
)
key_obj: dict[str, Any] = {"model": model, "messages": messages}
if cache_salt is not None:
key_obj["salt"] = cache_salt
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return f"llm_cache:{digest}"
async def get(self, model: str, messages: list[dict[str, str]]) -> LLMResponse | None:
async def get(
self,
model: str,
messages: list[dict[str, str]],
cache_salt: str | None = None,
) -> LLMResponse | None:
"""从缓存读取 LLM 响应。
Args:
model: 模型名称。
messages: 消息列表。
cache_salt: 可选缓存盐,透传到键构造。
Returns:
缓存命中时返回 LLMResponse,未命中或 Redis 异常时返回 None。
"""
try:
key = self._build_key(model, messages)
key = self._build_key(model, messages, cache_salt)
raw = await self._redis.get(key)
except Exception:
logger.warning("Redis 缓存读取失败,降级为未命中")
@@ -75,6 +107,7 @@ class RedisResponseCache:
model: str,
messages: list[dict[str, str]],
response: LLMResponse,
cache_salt: str | None = None,
) -> None:
"""将 LLM 响应写入缓存。
@@ -82,10 +115,14 @@ class RedisResponseCache:
model: 模型名称。
messages: 消息列表。
response: 待缓存的 LLMResponse。
cache_salt: 可选缓存盐,透传到键构造。
"""
try:
key = self._build_key(model, messages)
key = self._build_key(model, messages, cache_salt)
value = json.dumps(dataclasses.asdict(response), ensure_ascii=False)
await self._redis.set(key, value, ex=self._ttl_s)
if self._ttl_s:
await self._redis.set(key, value, ex=self._ttl_s)
else:
await self._redis.set(key, value)
except Exception:
logger.warning("Redis 缓存写入失败,跳过缓存")
+80 -41
View File
@@ -1,17 +1,21 @@
"""SQLite 遥测记录器 — TelemetryRecorder Protocol 的生产实现。
通过 asyncio.to_thread 将 SQLite 同步写入桥接到异步接口,
确保事件循环不被阻塞。表在首次写入时懒初始化。
通过 asyncio.to_thread 将 SQLite 同步写入桥接到异步接口,确保事件循环不被阻塞。
构造时建单持久连接 + 建表(对齐 app/harness/log.py:HarnessLog 的并发写模式),
写入经进程内 threading.Lock 串行化,消除多连接并发写的 database is locked。
零丢失保证范围 = 单进程、单 recorder 实例(当前 main.py / video_split_cli 均单实例
注入)。同进程多个 recorder 指向同一 db 会退回跨连接竞争——本实现不支持该场景。
"""
from __future__ import annotations
import asyncio
import sqlite3
from typing import TYPE_CHECKING
import threading
from pathlib import Path
if TYPE_CHECKING:
from pathlib import Path
from loguru import logger
class SQLiteTelemetryRecorder:
@@ -45,7 +49,7 @@ class SQLiteTelemetryRecorder:
"""
_INSERT_SQL = """
INSERT INTO llm_calls (
INSERT OR IGNORE INTO llm_calls (
call_id, parent_call_id, session_id,
model_name, provider, messages, response, thinking,
prompt_tokens, completion_tokens, latency_ms,
@@ -55,16 +59,43 @@ class SQLiteTelemetryRecorder:
"""
def __init__(self, db_path: Path) -> None:
self._db_path = db_path
self._table_ready = False
"""建单持久连接 + 进程内 Lock(对齐 app/harness/log.py:HarnessLog 并发写模式)。
def _ensure_table(self, conn: sqlite3.Connection) -> None:
"""懒初始化:首次写入时创建 llm_calls 表。"""
if self._table_ready:
return
conn.execute(self._CREATE_TABLE_SQL)
conn.commit()
self._table_ready = True
把并发控制拉到进程内(threading.Lock 串行化写),消除"每次新连接并发写同一
db、靠 SQLite busy_timeout 跨连接协调"在高频下撑爆 timeout → database is locked
的根因。check_same_thread=Falserecord_llm_call 经 asyncio.to_thread 在线程池
不同线程调用,共享连接跨线程访问需此 flag,串行性由 self._lock 保证。
遥测哲学(P5):连接初始化失败降级不冒泡(self._conn=None,写入直接丢弃 warning),
绝不因遥测故障拖垮 LLM 调用 / 训练。
"""
self._db_path = db_path
self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None
# mkdir / connect / PRAGMA / 建表统一纳入降级边界:任一失败(OSError 含
# PermissionError、sqlite3.Error)都降级为 self._conn=None,绝不冒泡拖垮初始化。
try:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(self._CREATE_TABLE_SQL)
conn.commit()
self._conn = conn
except (OSError, sqlite3.Error) as exc:
logger.warning("遥测连接初始化失败(已降级,后续写入丢弃): {}", exc)
self._conn = None
def close(self) -> None:
"""幂等关闭持久连接(对齐 HarnessLog;进程退出前可选调以释放 fd)。
不调也不丢数据——每次 _write 已 commit 落 WAL,进程退出 OS 回收 fd、
WAL 已提交内容下次打开自动 checkpoint 恢复。
"""
with self._lock:
if self._conn is not None:
self._conn.close()
self._conn = None
def _write(
self,
@@ -85,33 +116,41 @@ class SQLiteTelemetryRecorder:
cache_hit: bool,
error: str | None,
) -> None:
"""同步写入一条 LLM 调用记录到 SQLite。"""
conn = sqlite3.connect(str(self._db_path))
"""同步写入一条 LLM 调用记录(单持久连接 + Lock 串行化,对齐 HarnessLog)。
三层防御:
1. INSERT OR IGNORE — call_id 主键冲突静默忽略(幂等)
2. 进程内 threading.Lock 串行化写 — 消除并发锁竞争(非依赖 SQLite busy_timeout
3. try/except sqlite3.Error — DB 错误降级不冒泡,遥测失败绝不拖垮 LLM 调用
"""
if self._conn is None:
logger.warning("遥测连接不可用(已降级),丢弃 call_id={}", call_id)
return
try:
self._ensure_table(conn)
conn.execute(
self._INSERT_SQL,
(
call_id,
parent_call_id,
session_id,
model_name,
provider,
messages,
response,
thinking,
prompt_tokens,
completion_tokens,
latency_ms,
ttft_ms,
max_inter_token_ms,
int(cache_hit),
error,
),
)
conn.commit()
finally:
conn.close()
with self._lock:
self._conn.execute(
self._INSERT_SQL,
(
call_id,
parent_call_id,
session_id,
model_name,
provider,
messages,
response,
thinking,
prompt_tokens,
completion_tokens,
latency_ms,
ttft_ms,
max_inter_token_ms,
int(cache_hit),
error,
),
)
self._conn.commit()
except sqlite3.Error as exc:
logger.warning("遥测写入失败(已降级),call_id={}: {}", call_id, exc)
async def record_llm_call(
self,
+3
View File
@@ -36,6 +36,7 @@ class GovernedVLMClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
cache_salt: str | None = None,
) -> LLMResponse:
"""图文调用:将图片编码为 base64 嵌入 messages,委托给 LLM 客户端。
@@ -45,6 +46,7 @@ class GovernedVLMClient:
images: 图片文件路径列表。
session_id: 会话 ID(遥测用)。
parent_call_id: 父调用 ID(遥测用)。
cache_salt: 可选缓存盐,透传到底层 LLM 缓存键。
返回:
LLMResponse。
@@ -54,6 +56,7 @@ class GovernedVLMClient:
vision_messages,
session_id=session_id,
parent_call_id=parent_call_id,
cache_salt=cache_salt,
)
@staticmethod
+266
View File
@@ -0,0 +1,266 @@
"""离线诊断编排:把 baseline run 的错题诊断投影为逐题信号行并断点续跑落库。
"结果驱动视频级切分"离线管线的诊断步。给定一批可诊断错题:
1. 算 remaining(跳过 store 已完成题)实现续跑幂等;
2. 对剩余错题调 core.evolution.diagnose.run_diagnosis(经 StepsJsonRunLog
包装内层 RunLog,兼容 traces 未落表的历史 run);
3. 把 error_attributions / infra / degraded 三类产物确定性投影为
DiagnosisSignalRowtier 由 split_selection.score_signal 判定);
4. run 末(Phase 3)逐行 store.upsert 落库——诊断在 Phase 2 全部跑完后才落库,
故崩溃丢本次 run 未落库的全部结果(不是"仅一行");靠 GovernedLLMClient 的
Redis 缓存缓解重跑时的 LLM 重烧,下次调用命中缓存直接续。
错误处理诚实标注(不谎称全传播):
- run_diagnosis 的 C1/C2 阶段(指标计算、错误归因)网络/API 失败经
GovernedLLMClient 重试栈后仍失败会向上抛出,本编排不捕获、不掩盖,
直接冒泡给调用方。
- 但 C3 阶段(defect/lapse judge)的调用整体包在 `except Exception` 内
core/evolution/diagnose.py:2186),故 C3 judge 的**全部异常(含网络/API
失败)都被吞并→warning→默认归为 lapse**,不会向上抛;judge 语义歧义同样
按此保护性 fallback 处理。本编排原样接受该判定,不二次兜底、也不谎称
C3 阶段网络失败会传播。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from loguru import logger
from app.harness.baseline_run_log import StepsJsonRunLog
from app.harness.split_selection import evolution_target_of, score_signal
from core.evolution.diagnose import run_diagnosis
from core.evolution.types import DiagnosisSignalRow
if TYPE_CHECKING:
from core.evolution.protocols import DiagnosisSignalStore
from core.evolution.types import DiagnosisResult
from core.types import GeneratedQuestion
@dataclass(frozen=True)
class DiagnosisDeps:
"""离线诊断编排的依赖束(一次编排的全部外部端口 + 运行参数)。
frozen 保证一次编排内依赖不可变;LLM/RunLog/SkillStore/prompts 走 Protocol
注入,便于测试替换成假实现。
属性:
run_log: 内层 RunLog 实现(提供 get_predictions/get_traces),
编排内部再用 StepsJsonRunLog 包装以兼容 traces 未落表的 run。
llm: LLM 调用端口(治理后的 GovernedLLMClient)。
skill_store: 技能文件读取端口。
prompts: 诊断模板束(DiagnosePrompts)。
tree_data: 树结构字典(多视频 {video_id: tree} 或单棵树),透传给 run_diagnosis。
concurrency: 诊断并发上限。
"""
run_log: Any
llm: Any
skill_store: Any
prompts: Any
tree_data: dict[str, Any]
concurrency: int
async def run_baseline_diagnosis(
*,
baseline_run_id: str,
diag_fingerprint: str,
wrong_ids: list[str],
questions: dict[str, GeneratedQuestion],
store: DiagnosisSignalStore,
deps: DiagnosisDeps,
retry_uncertain: bool = False,
) -> None:
"""对 baseline run 的错题跑离线诊断并把信号落库(断点续跑幂等)。
参数:
baseline_run_id: baseline run 标识(如 "infer_adhoc"),信号行主键之一。
diag_fingerprint: 诊断口径指纹,隔离不同诊断配置的信号,主键之一。
wrong_ids: 本次待诊断的可诊断错题 question_id 列表(保序)。
questions: question_id → GeneratedQuestion 映射,需覆盖 wrong_ids 全部题
及 run_diagnosis 返回的所有 infra/degraded 题(用于取 video_id/task_type)。
store: 诊断信号存储端口,upsert 落盘并提供 done_question_ids 续跑查询。
deps: 外部依赖束(见 DiagnosisDeps)。
retry_uncertain: True 时把已落 tier='uncertain'(信号不可信降级)的题也纳入
remaining 重新诊断,透传给 store.done_question_ids;默认 False。
返回:
None。副作用为把逐题 DiagnosisSignalRow 写入 store。
关键实现:
- remaining = wrong_ids 去除 store 已完成题;空则直接 return(续跑幂等,
重复调用零副作用)。
- run_diagnosis 只诊断 remaining,避免重复 LLM 调用浪费。
- 三类产物投影互斥落库:error_attributionsdefect/lapse)、infra_question_ids
T0)、degraded_question_idsuncertain)。
"""
# Phase 1: 算 remaining(续跑幂等)
done = store.done_question_ids(
baseline_run_id, diag_fingerprint, retry_uncertain=retry_uncertain
)
remaining = [qid for qid in wrong_ids if qid not in done]
if not remaining:
logger.info(
"离线诊断续跑:baseline={} fingerprint={} 无剩余错题(已完成 {} 题),跳过。",
baseline_run_id,
diag_fingerprint,
len(done),
)
return
logger.info(
"离线诊断开始:baseline={} fingerprint={} 剩余 {}/{} 题待诊断。",
baseline_run_id,
diag_fingerprint,
len(remaining),
len(wrong_ids),
)
# Phase 2: 对剩余错题跑诊断(StepsJsonRunLog 兼容 traces 未落表的历史 run)
result = await run_diagnosis(
baseline_run_id,
[questions[qid] for qid in remaining],
deps.tree_data,
deps.llm,
StepsJsonRunLog(deps.run_log),
deps.skill_store,
deps.prompts,
concurrency=deps.concurrency,
question_ids=list(remaining),
only_incorrect=True,
)
# Phase 3: 投影落库
counts = _project_and_persist(
result=result,
baseline_run_id=baseline_run_id,
diag_fingerprint=diag_fingerprint,
questions=questions,
store=store,
)
logger.info(
"离线诊断落库完成:baseline={} fingerprint={} "
"T2={} T1={} T0(infra)={} uncertain(degraded)={}{} 行。",
baseline_run_id,
diag_fingerprint,
counts["T2"],
counts["T1"],
counts["T0"],
counts["uncertain"],
sum(counts.values()),
)
def _project_and_persist(
*,
result: DiagnosisResult,
baseline_run_id: str,
diag_fingerprint: str,
questions: dict[str, GeneratedQuestion],
store: DiagnosisSignalStore,
) -> dict[str, int]:
"""把 DiagnosisResult 三类产物投影为信号行并逐行 upsert,返回各 tier 计数。
参数:
result: run_diagnosis 的返回,含 error_attributions/infra/degraded 三类产物。
baseline_run_id: 信号行主键之一。
diag_fingerprint: 信号行主键之一。
questions: question_id → GeneratedQuestion,用于取 video_id/task_type。
store: 诊断信号存储端口。
返回:
{tier: 行数} 计数字典(T2/T1/T0/uncertain),供上层日志与 manifest。
关键实现:
本函数在 run 末(Phase 3)逐行 upsert(单行单事务);诊断已在 Phase 2 全部
跑完,故本阶段中途崩溃丢本次 run 未落库的余下行。三桶**非互斥**:
同一 degraded 错题可能同时出现在 error_attributionsjudge 解析失败仍建
attribution)里,故按 **degraded > infra > attribution** 优先级去重——先落
degraded/infra,再在 attribution 循环跳过已落题,保证**每题恰写一行、
counts 恰计一次**(否则同 PK 覆盖会导致 counts 双计且分层错乱)。
"""
counts = {"T2": 0, "T1": 0, "T0": 0, "uncertain": 0}
# 优先级去重:degraded > infra > attribution。先记录高优先集合,
# attribution 循环遇到已落题即跳过,确保每题唯一落库。
persisted: set[str] = set()
# degraded_question_ids(最高优先):judge 解析失败降级 → uncertain,信号不可信排除出 T2
for qid in result.degraded_question_ids:
q = questions[qid]
store.upsert(
DiagnosisSignalRow(
question_id=qid,
video_id=q.video_id,
baseline_run_id=baseline_run_id,
diag_fingerprint=diag_fingerprint,
task_type=q.task_type,
error_type=None,
cause_category=None,
tier="uncertain",
evolution_target=None,
degraded=True,
infra=False,
session_id=None,
)
)
persisted.add(qid)
counts["uncertain"] += 1
# infra_question_ids:基础设施失败护栏排除 → T0,不参与训练主体
# (防御性跳过已落 degraded 题,虽 infra 通常已在诊断前过滤不重叠)
for qid in result.infra_question_ids:
if qid in persisted:
continue
q = questions[qid]
store.upsert(
DiagnosisSignalRow(
question_id=qid,
video_id=q.video_id,
baseline_run_id=baseline_run_id,
diag_fingerprint=diag_fingerprint,
task_type=q.task_type,
error_type=None,
cause_category=None,
tier="T0",
evolution_target=None,
degraded=False,
infra=True,
session_id=None,
)
)
persisted.add(qid)
counts["T0"] += 1
# error_attributions(最低优先):defect→T2 / lapse→T1 / 其它→uncertain(由 score_signal 判定)
# 跳过已作为 degraded/infra 落库的题,避免同 PK 覆盖与 counts 双计。
for ea in result.error_attributions:
if ea.question_id in persisted:
continue
q = questions[ea.question_id]
tier = score_signal(cause_category=ea.cause_category, infra=False, degraded=False).tier
# error_type 是 ErrorAttribution 必填字段(永远已知),确定性派生进化目标。
evolution_target = evolution_target_of(ea.error_type)
store.upsert(
DiagnosisSignalRow(
question_id=ea.question_id,
video_id=q.video_id,
baseline_run_id=baseline_run_id,
diag_fingerprint=diag_fingerprint,
task_type=q.task_type,
error_type=ea.error_type,
cause_category=ea.cause_category,
tier=tier,
evolution_target=evolution_target,
degraded=False,
infra=False,
session_id=None,
)
)
persisted.add(ea.question_id)
counts[tier] = counts.get(tier, 0) + 1
return counts
+71
View File
@@ -0,0 +1,71 @@
"""RunLog 包装器:traces 表空时从 predictions.steps_json 重建轨迹。
用于对 infer_adhoc 这类 traces 未落表、轨迹在 steps_json 的历史 run 跑离线诊断。
"""
from __future__ import annotations
from typing import Any
from app.harness.steps_json_traces import steps_json_to_trace_rows
class StepsJsonRunLog:
"""委托内层 RunLogget_traces 空表时回退 steps_json。
实现 core/evolution/protocols.py 的 RunLog Protocolduck-typing)。
对 traces 已落表的正常 run 完全透传;仅当底层 traces 为空时,
才从 predictions.steps_json 经 steps_json_to_trace_rows 重建轨迹行。
"""
def __init__(self, inner: Any) -> None:
"""构造包装器。
参数:
inner: 内层 RunLog 实现(如 app/harness/log.py::RunLogImpl),
需提供 get_predictions / get_traces 两个 async 方法。
"""
self._inner = inner
async def get_predictions(
self, run_id: str, *, question_ids: list[str] | None = None
) -> list[dict[str, Any]]:
"""透传内层预测查询。
参数:
run_id: 运行标识。
question_ids: 可选的题目 ID 过滤列表。
返回:
内层返回的预测记录字典列表,原样透传。
"""
return await self._inner.get_predictions(run_id, question_ids=question_ids)
async def get_traces(
self, run_id: str, *, question_ids: list[str] | None = None
) -> list[dict[str, Any]]:
"""查询轨迹;底层 traces 表空时从 steps_json 回退重建。
参数:
run_id: 运行标识。
question_ids: 可选的题目 ID 过滤列表。
返回:
轨迹行字典列表。
关键实现细节:
- 内层 traces 非空 → 原样返回,不触发回退(正常 run 路径)。
- 内层 traces 为空 → 拉取同一过滤条件下的 predictions
逐题经 steps_json_to_trace_rows 展开为轨迹行并拼接。
- steps_json 缺失时以空串传入,由下游确定性返回 []。
"""
inner_rows = await self._inner.get_traces(run_id, question_ids=question_ids)
if inner_rows:
return inner_rows
preds = await self._inner.get_predictions(run_id, question_ids=question_ids)
rows: list[dict[str, Any]] = []
for p in preds:
rows.extend(
steps_json_to_trace_rows(p["video_id"], p["question_id"], p.get("steps_json") or "")
)
return rows
+214 -103
View File
@@ -1,13 +1,37 @@
"""混合 mini-batch 切分:大类打散、小类整锁,供 runner 每 step 处理一个 batch。"""
"""混合 mini-batch 切分:以 QuestionUnit 为最小调度粒度,大类打散、小类整锁。
供 runner 每 step 处理一个 batch。孪生对(AR pair)作为 2 题单元整锁不拆、按单元级
正确性分桶;非 AR single 单元的抽样/洗牌 draw 流与"引入 QuestionUnit 前"的旧逐题算法
逐字节一致(AR 折叠不干扰非 AR draw 流)。
"""
from __future__ import annotations
import hashlib
import math
import random
from typing import TYPE_CHECKING
from app.harness.question_units import build_units, flatten_units, unit_correctness
if TYPE_CHECKING:
from core.types import GeneratedQuestion
from core.types import GeneratedQuestion, QuestionUnit
def _rng_ns(seed: int, ns: str) -> random.Random:
"""由 (seed, 命名空间) 稳定派生独立随机数发生器。
用 SHA-256 派生而非 Python 内置 ``hash()``——后者受 hash randomization 影响,
跨进程不可复现。不同命名空间的 draw 流互不干扰,使 AR 单元折叠不扰动非 AR 抽样。
参数:
seed: 实验随机种子。
ns: 命名空间标签(如 "AR")。
返回:
以 SHA-256(f"{ns}:{seed}") 前 8 字节为种子的 ``random.Random``。
"""
digest = hashlib.sha256(f"{ns}:{seed}".encode()).digest()
return random.Random(int.from_bytes(digest[:8], "big"))
def build_batches(
@@ -18,55 +42,59 @@ def build_batches(
seed: int,
correct_ratio: float = 0.0,
) -> tuple[list[list[GeneratedQuestion]], int]:
"""把诊断池里的题目切成多个混合 mini-batch。
"""把诊断池里的题目切成多个混合 mini-batch(以 QuestionUnit 为原子调度单元)
当 ``correct_ratio > 0`` 时,按题型为每组错题配比一定数量的正确题,使 batch
包含正误混合样本("动量"机制);``correct_ratio <= 0`` 时退化为纯错题模式。
single 题为 1 题单元,AR pair 孪生对为 2 题单元;同一 pair 的两题整锁进同一 batch
按单元级正确性(双向 AND)分桶。当 ``correct_ratio > 0`` 时,按题型为每组错误单元配比
一定数量的正确单元("动量"机制);``correct_ratio <= 0`` 时退化为纯错误单元模式。
参数:
items: 候选题目全集。
items: 候选题目全集(可混含 single 与孪生对成员)
correctness: question_id -> 基线是否答对。
batch_size: 单个 batch 的样本数上限(> 0)。
min_class_per_batch: 小类判定阈值——题目数 ≤ 此值的题型整组锁进单一
batch_size: 单个 batch 的题目数上限(> 0pair 占 2)。
min_class_per_batch: 小类判定阈值——单元题目数 ≤ 此值的题型整组锁进单一
batch> 0)。
seed: 随机种子,保证相同输入产出完全一致的切分。
correct_ratio: 正确题占比(0.0 ~ 1.0)。0.0 = 纯错0.5 = 错:正确题 = 1:1。
correct_ratio: 正确题占比(0.0 ~ 1.0)。0.0 = 纯错误单元0.5 = 错:正 = 1:1。
返回:
(非空 mini-batch 列表, selected_count);无错时返回 ([], 0)。
selected_count 是所有 batch 中题目总数。
(非空 mini-batch 列表, selected_count);无错误单元时返回 ([], 0)。
selected_count 是所有 batch 中题目(展开后)总数。
异常:
ValueError: batch_size 或 min_class_per_batch < 1, 或
min_class_per_batch >= batch_size(破坏小类整组装箱不超容的前提)。
关键实现细节:
装箱顺序为「先小类后大类」。小类整组用 first-fit-decreasing 装箱:按组大小
降序处理(同大小再按 task_type 排序保证确定性),每组放进第一个剩余容量足够
的 batch;若现有 batch 都装不下就新开一个空 batch——因小类组大小
≤ min_class_per_batch < batch_size,新空 batch 必能容纳,故小类装箱永不抛
ValueError,且保证整组不拆。再把大类样本(seed 确定性 shuffle 后)round-robin
分发到所有现存 batch 填充剩余容量。这样小类聚集于单 batch、大类散布多 batch
且与小类共箱,自然产生多类混合 batch(纯类切片会被 multiclass 断言拒绝)。
nb = ceil(总题数/batch_size) 是初始 batch 数下界估计而非硬上限:小类装箱可能
新开 bin 使实际 batch 数超过 nb。每次新开 bin 都意味着总容量随之增加,故总容量
恒 ≥ 总题数,大类 round-robin 跳过满箱后仍能放下全部样本,不会违反 batch_size
上限。题型按名称排序处理以保证跨运行确定性,不依赖 dict 遍历顺序。
非 ARsingle)与 ARpair)各用独立稳定派生的 rng:非 AR 用 ``random.Random(seed)``
(复现旧逐题算法的确切 draw 序列,保证纯非 AR 输入逐字节一致),AR 用
``_rng_ns(seed, "AR")``;二者 draw 流互不干扰,故加入/移除 pair 不改变非 AR 的
抽样/洗牌序列。抽样在合并前按流分别进行(``_select_mixed_by_task_type`` 各跑一次),
大类洗牌按单元 kind 拆分后各用对应流。装箱顺序「先小类后大类」:小类整组
first-fit-decreasing(容量按单元 ``size`` 计,pair 占 2)装入首个容得下的 batch
装不下新开 bin;大类洗牌后 round-robin 分发,遇碎片(size-2 单元放不进任一现存
batch 的剩余容量)新开 bin 兜底而非报错。最终每个 batch 展开回题目列表。
题型按名称排序处理以保证跨运行确定性。
"""
_validate_params(batch_size, min_class_per_batch)
rng = random.Random(seed)
grouped = _select_mixed_by_task_type(items, correctness, correct_ratio, rng)
total = sum(len(g) for g in grouped.values())
# 非 AR 复现旧版 random.Random(seed) 的确切序列以满足黄金 byte-identity
# AR 走独立命名空间派生流,二者互不干扰。
rng_nonar = random.Random(seed)
rng_ar = _rng_ns(seed, "AR")
grouped = _group_units_by_task_type(items, correctness, correct_ratio, rng_nonar, rng_ar)
total = sum(_group_load(g) for g in grouped.values())
if total == 0:
return [], 0
nb = max(1, math.ceil(total / batch_size))
batches: list[list[GeneratedQuestion]] = [[] for _ in range(nb)]
batches: list[list[QuestionUnit]] = [[] for _ in range(nb)]
small, large = _split_by_size(grouped, min_class_per_batch)
for group in _small_groups_decreasing(small):
_pack_small_class(batches, group, batch_size)
_distribute_large_classes(batches, large, batch_size, rng)
_distribute_large_classes(batches, large, batch_size, rng_nonar, rng_ar)
result = [b for b in batches if b]
result = [flatten_units(b) for b in batches if b]
selected_count = sum(len(b) for b in result)
return result, selected_count
@@ -74,7 +102,7 @@ def build_batches(
def _validate_params(batch_size: int, min_class_per_batch: int) -> None:
"""校验切分参数,非法值直接报错而非用默认值掩盖。
除各自 >= 1 外,强制 min_class_per_batch < batch_size:小类组大小
除各自 >= 1 外,强制 min_class_per_batch < batch_size:小类组题目总数
min_class_per_batch,唯有此前提成立才能保证小类整组放入单一 batch 而不超容;否则
_pack_small_class 新开的 bin 会装入超 batch_size 的整组,静默违反容量合约。此约束
与 config._validate_minibatch 一致,是 build_batches 对自身前提的防御性自校验(P5)。
@@ -91,52 +119,129 @@ def _validate_params(batch_size: int, min_class_per_batch: int) -> None:
)
def _split_by_size(
grouped: dict[str, list[GeneratedQuestion]],
min_class_per_batch: int,
) -> tuple[dict[str, list[GeneratedQuestion]], dict[str, list[GeneratedQuestion]]]:
"""按错题数把题型分为小类(≤ 阈值)与大类(> 阈值)两组。"""
small = {t: g for t, g in grouped.items() if len(g) <= min_class_per_batch}
large = {t: g for t, g in grouped.items() if len(g) > min_class_per_batch}
return small, large
def _select_mixed_by_task_type(
def _group_units_by_task_type(
items: list[GeneratedQuestion],
correctness: dict[str, bool],
correct_ratio: float,
rng: random.Random,
) -> dict[str, list[GeneratedQuestion]]:
"""按题型分组,为每组错题按比例采样正确题混入。
只对有错题的题型做混合——无错题的题型不进 batch,即使有正确题。
``correct_ratio <= 0`` 时退化为纯错题模式(向后兼容)。
rng_nonar: random.Random,
rng_ar: random.Random,
) -> dict[str, list[QuestionUnit]]:
"""把题目聚合为单元并按题型分组:非 AR 与 AR 各走独立 draw 流后合并。
参数:
items: 候选题目全集。
correctness: question_id -> 基线是否答对。
correct_ratio: 正确题占比0.0 ~ 1.0
rng: 随机数发生器,用于采样正确题
correct_ratio: 正确题占比。
rng_nonar: 非 ARsingle 单元)抽样用 rng
rng_ar: ARpair 单元)抽样用 rng。
返回:
task_type -> 该题型的混合题目列表(错题全部 + 按比例采样的正确题)。
task_type -> 混合后的单元列表(single 单元在前、pair 单元在后)。
"""
errors_by_type: dict[str, list[GeneratedQuestion]] = {}
correct_by_type: dict[str, list[GeneratedQuestion]] = {}
for q in items:
qid = q.question_id
if correctness.get(qid) is False:
errors_by_type.setdefault(q.task_type, []).append(q)
elif correctness.get(qid, False):
correct_by_type.setdefault(q.task_type, []).append(q)
units = build_units(items)
singles = [u for u in units if u.kind == "single"]
pairs = [u for u in units if u.kind == "pair"]
grouped_nonar = _select_mixed_by_task_type(singles, correctness, correct_ratio, rng_nonar)
grouped_ar = _select_mixed_by_task_type(pairs, correctness, correct_ratio, rng_ar)
return _merge_grouped(grouped_nonar, grouped_ar)
def _group_load(group: list[QuestionUnit]) -> int:
"""一组单元展开后的题目总数(single 计 1,pair 计 2),即占用的 batch 容量。"""
return sum(u.size for u in group)
def _batch_load(batch: list[QuestionUnit]) -> int:
"""一个 batch 内单元展开后的题目总数,用于容量判断。"""
return sum(u.size for u in batch)
def _merge_grouped(
grouped_nonar: dict[str, list[QuestionUnit]],
grouped_ar: dict[str, list[QuestionUnit]],
) -> dict[str, list[QuestionUnit]]:
"""按 task_type 合并非 AR 与 AR 两条流的分组(single 在前、pair 在后)。
参数:
grouped_nonar: 非 ARsingle 单元)分组。
grouped_ar: ARpair 单元)分组。
返回:
task_type -> 合并后的单元列表;每类 single 单元在前、pair 单元在后,顺序稳定。
"""
merged: dict[str, list[QuestionUnit]] = {}
for task_type in sorted({*grouped_nonar, *grouped_ar}):
merged[task_type] = grouped_nonar.get(task_type, []) + grouped_ar.get(task_type, [])
return merged
def _split_by_size(
grouped: dict[str, list[QuestionUnit]],
min_class_per_batch: int,
) -> tuple[dict[str, list[QuestionUnit]], dict[str, list[QuestionUnit]]]:
"""按题目总数(单元展开)把题型分为小类(≤ 阈值)与大类(> 阈值)两组。"""
small = {t: g for t, g in grouped.items() if _group_load(g) <= min_class_per_batch}
large = {t: g for t, g in grouped.items() if _group_load(g) > min_class_per_batch}
return small, large
def _classify_unit(unit: QuestionUnit, correctness: dict[str, bool]) -> str | None:
"""判定单元落入哪个桶:error / correct / None(未知,跳过)。
参数:
unit: 目标单元。
correctness: question_id -> 是否答对(缺键视为未知)。
返回:
"error"(单元级正确性为 False)、"correct"(双向 AND 为 True);单元内任一题
未知(correctness 缺该键)返回 None,与旧逐题算法把未知题排除在错/对两桶之外
的语义一致。
关键实现:
先探测是否有未知题(get 返回 None ⟺ 键缺失,因 correctness 值恒为 bool),
全部已知后交由 unit_correctness 计双向 AND(此时 KeyError 不可达)。
"""
if any(correctness.get(q.question_id) is None for q in unit.questions):
return None
return "correct" if unit_correctness(unit, correctness) else "error"
def _select_mixed_by_task_type(
units: list[QuestionUnit],
correctness: dict[str, bool],
correct_ratio: float,
rng: random.Random,
) -> dict[str, list[QuestionUnit]]:
"""按题型分组,为每组错误单元按比例采样正确单元混入(单元粒度)。
只对有错误单元的题型做混合——无错误单元的题型不进 batch,即使有正确单元。
``correct_ratio <= 0`` 时退化为纯错误单元模式。本函数只处理单一 draw 流(全 single
或全 pair),使非 AR 与 AR 的抽样互不干扰。
参数:
units: 同一流的候选单元(全 single 或全 pair)。
correctness: question_id -> 基线是否答对。
correct_ratio: 正确题占比(0.0 ~ 1.0)。
rng: 本流专用随机数发生器,用于采样正确单元。
返回:
task_type -> 该题型的混合单元列表(错误单元全部 + 按比例采样的正确单元)。
关键实现:
n_correct 按错误单元「题目总数」而非单元数计,与旧逐题语义对齐(纯 single 时
单元数 == 题目数,采样序列逐字节一致)。
"""
errors_by_type: dict[str, list[QuestionUnit]] = {}
correct_by_type: dict[str, list[QuestionUnit]] = {}
for unit in units:
bucket = _classify_unit(unit, correctness)
if bucket == "error":
errors_by_type.setdefault(unit.task_type, []).append(unit)
elif bucket == "correct":
correct_by_type.setdefault(unit.task_type, []).append(unit)
if correct_ratio <= 0:
return errors_by_type
# 为每个有错题的 task_type 混入正确题
grouped: dict[str, list[GeneratedQuestion]] = {}
grouped: dict[str, list[QuestionUnit]] = {}
for task_type in sorted(errors_by_type):
errs = errors_by_type[task_type]
n_correct = round(len(errs) * correct_ratio / (1 - correct_ratio))
n_err = _group_load(errs)
n_correct = round(n_err * correct_ratio / (1 - correct_ratio))
available = correct_by_type.get(task_type, [])
sampled = (
list(available) if len(available) <= n_correct else rng.sample(available, n_correct)
@@ -147,94 +252,100 @@ def _select_mixed_by_task_type(
def _small_groups_decreasing(
small: dict[str, list[GeneratedQuestion]],
) -> list[list[GeneratedQuestion]]:
"""按组大小降序、同大小按 task_type 升序排出小类组(first-fit-decreasing 顺序)。
small: dict[str, list[QuestionUnit]],
) -> list[list[QuestionUnit]]:
"""按组题目总数降序、同大小按 task_type 升序排出小类组(first-fit-decreasing 顺序)。
参数:
small: task_type -> 小类错题列表。
small: task_type -> 小类单元列表。
返回:
排好序的小类组列表;降序处理可降低碎片,确定性 tie-break 保证跨运行一致。
"""
return [small[t] for t in sorted(small, key=lambda t: (-len(small[t]), t))]
return [small[t] for t in sorted(small, key=lambda t: (-_group_load(small[t]), t))]
def _pack_small_class(
batches: list[list[GeneratedQuestion]],
group: list[GeneratedQuestion],
batches: list[list[QuestionUnit]],
group: list[QuestionUnit],
batch_size: int,
) -> None:
"""用 first-fit 把一个小类整组放入首个容得下的 batch,装不下则新开 bin(就地修改)。
因小类组大小 ≤ min_class_per_batch < batch_size,新开的空 batch 必能容纳整组,
故此函数永不抛 ValueError,且整组不拆。
因小类组题目总数 ≤ min_class_per_batch < batch_size,新开的空 batch 必能容纳整组,
故此函数永不抛 ValueError,且整组(含内部 pair 单元)不拆。
参数:
batches: 当前各 batch(就地追加,必要时 append 新空 batch)。
group: 待锁定的小类错题(整组不拆)。
batch_size: 单 batch 容量上限。
group: 待锁定的小类单元组(整组不拆)。
batch_size: 单 batch 题目容量上限。
"""
load = _group_load(group)
for b in batches:
if len(b) + len(group) <= batch_size:
if _batch_load(b) + load <= batch_size:
b.extend(group)
return
batches.append(list(group))
def _distribute_large_classes(
batches: list[list[GeneratedQuestion]],
large: dict[str, list[GeneratedQuestion]],
batches: list[list[QuestionUnit]],
large: dict[str, list[QuestionUnit]],
batch_size: int,
rng: random.Random,
rng_nonar: random.Random,
rng_ar: random.Random,
) -> None:
"""将各大类样本 shuffle 后 round-robin 分发到所有现存 batch(就地修改)。
"""将各大类单元洗牌后 round-robin 分发到所有现存 batch(就地修改)。
参数:
batches: 当前各 batch(含小类装箱可能新开的 bin,就地追加)。
large: task_type -> 大类错题列表。
batch_size: 单 batch 容量上限。
rng: 复用的随机数发生器,保证 shuffle 确定性
异常:
ValueError: 所有 batch 均满仍有样本未放置(总容量估算异常,合法输入不可达)。
large: task_type -> 大类单元列表。
batch_size: 单 batch 题目容量上限。
rng_nonar: 非 ARsingle 单元)洗牌用 rng
rng_ar: ARpair 单元)洗牌用 rng。
关键实现细节:
轮转范围是「所有现存 batch」而非固定 nb 个——小类装箱新开的 bin 也参与分发。
总容量 = 现存 batch 数 × batch_size,每次新开 bin 都同步抬高总容量,故总容量恒
≥ 总错题数,防御性 ValueError 在合法输入下不可达。全局指针在所有大类样本间持续
轮转(不为每类重置),满箱跳过,使大类充分散布并与已锁定的小类共箱。题型按名称
排序以保证分发顺序确定。
每组按单元 kind 拆成 single 子列与 pair 子列,分别用 rng_nonar / rng_ar 洗牌后
拼接(single 在前),使非 AR 洗牌 draw 流不受 pair 存在与否影响(纯 single 时
single 子列即整组,复现旧版单一 rng.shuffle 的序列)。全局指针在所有大类单元间
持续轮转,遇满箱跳过、遇碎片新开 bin。题型按名称排序以保证分发顺序确定。
"""
nb = len(batches)
pointer = 0
for task_type in sorted(large):
group = list(large[task_type])
rng.shuffle(group)
for q in group:
pointer = _place_round_robin(batches, q, pointer, batch_size, nb)
group = large[task_type]
singles = [u for u in group if u.kind == "single"]
pairs = [u for u in group if u.kind == "pair"]
rng_nonar.shuffle(singles)
rng_ar.shuffle(pairs)
for unit in singles + pairs:
pointer = _place_round_robin(batches, unit, pointer, batch_size)
def _place_round_robin(
batches: list[list[GeneratedQuestion]],
q: GeneratedQuestion,
batches: list[list[QuestionUnit]],
unit: QuestionUnit,
pointer: int,
batch_size: int,
nb: int,
) -> int:
"""从 pointer 起找第一个未满 batch 放入 q,返回下一次起始指针。
"""从 pointer 起找第一个容量够放 unit 的 batch 放入,返回下一次起始指针。
参数:
batches: 当前各 batch(就地追加)。
q: 待放置的样本
unit: 待放置的单元(占用 unit.size 个容量)
pointer: 本次轮转起始 batch 下标。
batch_size: 单 batch 容量上限。
nb: batch 总数。
batch_size: 单 batch 题目容量上限。
返回:
下一次轮转的起始指针(已前移一位)。
异常:
ValueError: 扫描一轮所有 batch 均满(总容量估算异常)。
关键实现:
单个单元容量 ≤ batch_size 是前提(pair 占 2,而 batch_size > min_class ≥ 1 ⇒
batch_size ≥ 2),故此处断言防御。扫描一轮所有现存 batch 都放不下(size-2 单元
遇满地碎片)时新开 bin 兜底而非报错——聚合容量足够但单箱剩余不足是合法碎片场景。
纯 single(size 1)永不触发新开分支,故与旧逐题 round-robin 逐字节一致。
"""
assert unit.size <= batch_size, f"单元 size={unit.size} 超过 batch_size={batch_size}"
nb = len(batches)
for offset in range(nb):
idx = (pointer + offset) % nb
if len(batches[idx]) < batch_size:
batches[idx].append(q)
if _batch_load(batches[idx]) + unit.size <= batch_size:
batches[idx].append(unit)
return (idx + 1) % nb
raise ValueError("所有 batch 均满仍有样本待放置, 总容量估算异常")
batches.append([unit])
return len(batches) % len(batches)
+634
View File
@@ -0,0 +1,634 @@
"""结果驱动视频级切分的顶层编排:诊断信号 → 冻结 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 json
import sqlite3
from collections import Counter, defaultdict
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: 视频组题级切分的洗牌种子。
val_wrong_min: validation 池最少错题数,切分时保证功效(不足则从 diag 换入
低 T2 错题组补足,耗尽 fail-loud)。
"""
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
val_wrong_min: 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 _unique_backup_path(path: Path, suffix: str) -> Path:
"""求 path 的唯一 .bak.<suffix> 备份路径,已存在则追加递增序号避免覆盖。
首选 ``<name>.bak.<suffix>``;若已存在,退化为 ``<name>.bak.<suffix>.2``、
``.3`` … 直到找到不存在的名字。保证连续 forced freeze(同 suffix 或都缺
manifest 用 'prev')不会静默覆盖此前保留的备份。
参数:
path: 待备份的原文件路径。
suffix: 备份后缀(旧 pools_sha256 前 8 位或 'prev')。
返回:
目录内唯一、尚不存在的备份路径。
"""
candidate = path.with_name(f"{path.name}.bak.{suffix}")
counter = 2
while candidate.exists():
candidate = path.with_name(f"{path.name}.bak.{suffix}.{counter}")
counter += 1
return candidate
def _guard_frozen_products(out_path: Path, manifest_path: Path, *, force: bool) -> None:
"""冻结前的覆盖保护:产物已存在时按 force 决定报错或备份。
参数:
out_path: 目标 pools.json 路径。
manifest_path: 目标 split_manifest.json 路径。
force: False 时已存在即 FileExistsErrorTrue 时把旧产物重命名为唯一的
.bak.<旧 pools_sha256 前 8 位或 'prev'>(同名已存在则追加递增序号)再放行。
异常:
FileExistsError: force=False 且产物已存在(防静默覆盖冻结锚点)。
OSError: 备份 rename 失败;已备份的文件先 rollback 回原名再抛出,保证
要么两文件都备份、要么都不动(原子性,不留半备份的不一致目录)。
"""
if not out_path.exists() and not manifest_path.exists():
return
if not force:
raise FileExistsError(
f"已存在冻结产物 {out_path}(或其 manifest)。重跑切分会覆盖训练依赖的"
"冻结锚点——确认要替换请加 --force(旧产物将备份为 .bak.*)。"
)
# 备份后缀取旧 manifest 的 pools_sha256 前 8 位,无则用 'prev'
suffix = "prev"
if manifest_path.exists():
try:
old = json.loads(manifest_path.read_text(encoding="utf-8"))
suffix = str(old.get("pools_sha256", "prev"))[:8] or "prev"
except (json.JSONDecodeError, OSError):
suffix = "prev"
# 先为每个存在的文件求唯一备份路径(互不冲突),再逐个 rename;
# 中途失败则把已备份的 rollback 回原名,保证原子性。
to_backup = [p for p in (out_path, manifest_path) if p.exists()]
done: list[tuple[Path, Path]] = [] # (备份路径, 原路径),供 rollback
try:
for p in to_backup:
dst = _unique_backup_path(p, suffix)
p.rename(dst)
done.append((dst, p))
except OSError:
for backup_path, original in reversed(done):
backup_path.rename(original)
raise
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,
force: bool = False,
) -> SplitBuildResult:
"""顶层编排结果驱动视频级切分,冻结 pools.json + manifest 并跑防御断言。
步骤:读 canonical 基线预测 → 读诊断信号 → 构建全视频画像 → 贪心选择 trainval /
test → 加载题库并以视频归属切三池 → 原子冻结 pools.json → 写溯源 manifest →
六条防御断言 fail-fast 校验。
契约(Task 11):val_wrong_min 前置到切分内保证功效——build_split 计算
wrong_tier_by_video 并连同 config.val_wrong_min 传入 split_by_video_assignment
切分时若 val 错题不足即从 diag 换入低 T2 错题组补足(耗尽 fail-loud)。CLI 的
check_mcnemar_power 作切分冻结后的冗余最终确认。
参数:
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 字符串),由调用方传入以保证可复现。
force: 覆盖保护开关。False(默认)时若 out_path/manifest_path 已存在即
FileExistsError(防静默覆盖训练依赖的冻结锚点);True 时先把旧产物备份为
.bak.* 再放行覆盖。
返回:
SplitBuildResult,含 pools / manifest / assignment,支持字典式访问。
异常:
AssertionError: 六条防御断言任一不满足(fail-fast,不静默)。
ValueError: 上游依赖校验失败(如 correctness 缺题、assignment 非法)。
"""
# Phase 1: canonical 基线预测 + 诊断信号。
preds = load_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}
tier_by_q = {row["question_id"]: row["tier"] for row in signal_rows}
wrong_tier_by_video: dict[str, int] = defaultdict(int)
for pred in preds:
if not pred["correct"] and tier_by_q.get(pred["question_id"]) == "T2":
wrong_tier_by_video[pred["video_id"]] += 1
pools = split_by_video_assignment(
questions,
assignment,
correctness,
config.val_ratio,
config.split_seed,
baseline_run_id=baseline_run_id,
val_wrong_min=config.val_wrong_min,
wrong_tier_by_video=dict(wrong_tier_by_video),
)
_guard_frozen_products(out_path, manifest_path, force=force)
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 load_canonical_predictions(db_path: Path, baseline_run_id: str) -> list[dict]:
"""从 harness.db 只读取指定 run 每题首行(ORDER BY rowid)为 canonical 预测。
共享口径 helperCLI(可诊断错题筛选 + INFRA T0 补录)与 build_split(切分)
共用同一"每 qid 取 rowid 最小首行 + 归一化 correct 判定"口径,消除两处重复实现。
同一 question_id 可能有多行(重跑 / 补测),canonical 口径取 rowid 最小的首行,
保证 distinct question 计数与对错判定确定。correct = 预测与答案各自归一
(strip → 大写 → 取首字母)后逐字符相等。
口径边界:旧 build_or_load_pools 的 legacy 池构建路径(app/harness/pools.py)是
另一条独立既有链路,不共用本 helper,两者刻意不统一(本次不动 legacy 路径)。
参数:
db_path: harness.db 路径(URI mode=ro 只读打开,绝不改动基线 db)。
baseline_run_id: 基线 run 标识。
返回:
canonical 预测行列表,每行含 question_id / video_id / task_type /
prediction / answer / stop_reason / correctbool)。按 rowid 升序去重,
每 qid 保留首行。
异常:
ValueError: 该 run 无任何预测行(fail-fast,不返回空切分)。
实现细节:
按 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, stop_reason "
"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"],
"prediction": row["prediction"],
"answer": row["answer"],
"stop_reason": row["stop_reason"],
"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 /
evolution_target_distribution。
实现细节:
evolution_target_distribution 只统计 T2 信号(可训练缺陷),按
tool / skill / system 计数,报告"哪层参数组拿到梯度"T0/T1/uncertain 行
evolution_target 恒为 None,不入该分布。
"""
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_distribution, evolution_target_distribution = _signal_distributions(signal_rows_raw)
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,
"evolution_target_distribution": evolution_target_distribution,
}
def _signal_distributions(
signal_rows_raw: list[DiagnosisSignalRow],
) -> tuple[dict[str, float], dict[str, int]]:
"""由诊断信号行算 tier 占比分布与 T2 进化目标计数分布。
参数:
signal_rows_raw: 诊断信号行。
返回:
(tier_distribution, evolution_target_distribution) 二元组:
- tier_distribution: {tier: 占比},无信号时为空 dict;
- evolution_target_distribution: 仅统计 T2(可训练缺陷)信号,按
tool / skill / system 计数,报告哪层参数组拿到梯度;T0/T1/uncertain 行
evolution_target 恒为 None,不入该分布。
"""
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 {}
)
evolution_target_distribution = dict(
Counter(
row.evolution_target
for row in signal_rows_raw
if row.tier == "T2" and row.evolution_target is not None
)
)
return tier_distribution, evolution_target_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}"
)
+5 -4
View File
@@ -41,6 +41,7 @@ _STRUCTURAL_KEYS = (
"diag_size",
"val_size",
"batch_correct_ratio",
"trainable_min_units",
)
_DECISION_KEYS = (
@@ -57,7 +58,6 @@ _DECISION_KEYS = (
"gate_delta_min",
"gate_lambda_dir",
"gate_e_rollback",
"gate_block",
"gate_n_max",
"gate_p_low",
"gate_p_high",
@@ -94,7 +94,7 @@ def serialize_state(state: Any) -> dict[str, Any]:
"eval_prev_run_id": state.eval_prev_run_id,
"baseline_skills_version": state.baseline_skills_version,
"baseline_prompts_version": state.baseline_prompts_version,
"steps_since_best_improved": state.steps_since_best_improved,
"epochs_since_best_improved": state.epochs_since_best_improved,
"epoch_start_skills": state.epoch_start_skills,
"changed_task_types_this_epoch": sorted(state.changed_task_types_this_epoch),
"rejected_buffer": {k: [asdict(x) for x in v] for k, v in state.rejected_buffer.items()},
@@ -145,7 +145,7 @@ def deserialize_state_fields(d: dict[str, Any]) -> dict[str, Any]:
"eval_prev_run_id": d["eval_prev_run_id"],
"baseline_skills_version": d["baseline_skills_version"],
"baseline_prompts_version": d["baseline_prompts_version"],
"steps_since_best_improved": d["steps_since_best_improved"],
"epochs_since_best_improved": d["epochs_since_best_improved"],
"epoch_start_skills": d["epoch_start_skills"],
"changed_task_types_this_epoch": set(d["changed_task_types_this_epoch"]),
"rejected_buffer": {
@@ -233,7 +233,8 @@ def write_checkpoint(
global_step: 全局 step 序号。
total_steps: 全局总 step 数。
version_snapshot: skills/prompts 版本快照。
epoch_batches: 本 epoch 的 batch 划分(question_id 列表的列表)。
epoch_batches: 本 epoch 的 batch 划分(unit_id 列表的列表,孪生对折叠为
单个 unit_id;纯非 AR 下 unit_id==question_id)。
config: 训练配置对象,用于计算 config_fingerprint。
关键实现细节:
+44 -21
View File
@@ -18,10 +18,11 @@ import yaml
_VALID_MODES = {"infer", "train", "diagnose", "evolve", "eval", "promote"}
_VALID_SKILL_MODES = {"auto", "manual", "none"}
_VALID_SKILL_UPDATE_MODES = {"patch", "rewrite"}
_VALID_POOL_SPLIT_MODES = {"global", "per_category"}
_PATH_FIELDS = {"workspace_dir", "store_dir"}
# Video-MME 的任务类型数量:验证池每类至少保底 eval_min_per_class 题,共 11 类。
_VIDEO_MME_TASK_TYPE_COUNT = 11
# Video-MME 的任务类型数量:验证池每类至少保底 eval_min_per_class 题,共 12 类。
_VIDEO_MME_TASK_TYPE_COUNT = 12
# .env 工程配置字段映射(环境变量名 → RunConfig 字段名)。
# 仅路径类工程配置走 .env,科研实验参数走 YAML。
@@ -59,6 +60,7 @@ class RunConfig:
batch_size: mini-batch 单批题目数。
min_class_per_batch: 单批中每个任务类型至少保留的题目数(< batch_size)。
eval_min_per_class: 验证池中每个任务类型至少保底的题目数。
trainable_min_units: 可训练性预检:每题型 diag+val 单元数下限,低于则剔除该题型。
early_stop_patience: 全局 best 连续未提升的容忍轮数,达到即早停。
test_size: held-out 测试池题目数。
use_slow_momentum: 是否启用快慢双速进化中的慢速 momentum 更新。
@@ -68,14 +70,13 @@ class RunConfig:
gate_delta_min: 最小点估计效应量下限(承接旧 margin 语义)。
gate_lambda_dir: Wald 方向拒绝的对数似然比阈值(必须为负)。
gate_e_rollback: 试用期对称回滚门(回滚 e 值门槛)。
gate_block: 块序贯验证的块大小(=推理并发度,块内跑满)。
gate_n_max: 单次 gate 消耗的题数上限。
gate_p_low: 信息量阶梯 p-hat 保留区间下界(剔除必错零信息题)。
gate_p_high: 信息量阶梯 p-hat 保留区间上界(剔除必对零信息题)。
gate_probe_quota: 冷启动探针集比例(全错题中插尾的比例)。
gate_gamma_decay: 逐题正确率估计 p-hat 的 EMA 衰减系数。
gate_cooldown_steps: 回滚后该题型跳过进化的冷却 step 数。
gate_guard_err: gate 内跨块累计 INFRA 错误率护栏。
gate_guard_err: gate 内累计 INFRA 错误率护栏。
skill_update_mode: skill 进化模式,"patch"(局部 edit/ "rewrite"(整篇重写)。
appendix_consolidate_threshold: appendix note 条数达此值触发 LLM consolidation。
run_id: diagnose/evolve 模式要分析的运行 ID,默认空字符串。
@@ -85,6 +86,11 @@ class RunConfig:
version: eval/promote 模式指定的 store 版本号(如 "v3")。
resume: train 模式是否从已有 checkpoint 续训。
fresh: train 模式是否从种子全新开始。
task_types: 限定参与的任务类型子集,None 表示全部。
pool_split_mode: 池划分策略,"global"(全局统一划分)/ "per_category"(按类别独立划分)。
train_ratio: 训练集占比,范围 (0, 1)。
test_questions: 测试题目集路径(相对路径)。
run_holdout_eval: 是否在 epoch 末执行 held-out 四向评估,默认 True。
"""
# ── 必填字段(无默认值,来自 YAML 或 CLI) ──
@@ -108,6 +114,7 @@ class RunConfig:
batch_size: int
min_class_per_batch: int
eval_min_per_class: int
trainable_min_units: int
early_stop_patience: int
test_size: int
use_slow_momentum: bool
@@ -117,7 +124,6 @@ class RunConfig:
gate_delta_min: float
gate_lambda_dir: float
gate_e_rollback: float
gate_block: int
gate_n_max: int
gate_p_low: float
gate_p_high: float
@@ -136,6 +142,11 @@ class RunConfig:
version: str = ""
resume: bool = False
fresh: bool = False
task_types: tuple[str, ...] | None = None
pool_split_mode: str = "global"
train_ratio: float = 0.667
test_questions: str = "benchmarks/Video-MME"
run_holdout_eval: bool = True
def _validate(config: RunConfig) -> None:
@@ -236,6 +247,13 @@ def _validate_basic(config: RunConfig) -> None:
f"appendix_consolidate_threshold 必须 >= 1"
f"实际: {config.appendix_consolidate_threshold}"
)
if config.pool_split_mode not in _VALID_POOL_SPLIT_MODES:
raise ValueError(
f"pool_split_mode 必须为 {_VALID_POOL_SPLIT_MODES} 之一,"
f"实际: {config.pool_split_mode!r}"
)
if not (0 < config.train_ratio < 1):
raise ValueError(f"train_ratio 必须在 (0, 1) 内,实际: {config.train_ratio}")
def _validate_edit_budget(config: RunConfig) -> None:
@@ -266,8 +284,9 @@ def _validate_minibatch(config: RunConfig) -> None:
ValueError: 任一约束被违反。
关键实现细节:
val_size 必须 >= eval_min_per_class * _VIDEO_MME_TASK_TYPE_COUNT,保证验证池
能为 Video-MME 的全部 11 个任务类型各保底 eval_min_per_class 题。
pool_split_mode != "per_category" 时,val_size 必须 >= eval_min_per_class *
_VIDEO_MME_TASK_TYPE_COUNT,保证验证池能为 Video-MME 的全部 12 个任务类型
各保底 eval_min_per_class 题。per_category 模式下跳过此硬编码 12 类保底检查。
"""
if config.batch_size <= 0:
raise ValueError(f"batch_size 必须 > 0,实际: {config.batch_size}")
@@ -278,14 +297,17 @@ def _validate_minibatch(config: RunConfig) -> None:
)
if config.eval_min_per_class < 1:
raise ValueError(f"eval_min_per_class 必须 >= 1,实际: {config.eval_min_per_class}")
floor = config.eval_min_per_class * _VIDEO_MME_TASK_TYPE_COUNT
if config.val_size < floor:
raise ValueError(
f"val_size 必须 >= eval_min_per_class * {_VIDEO_MME_TASK_TYPE_COUNT}"
f"(={floor})Video-MME 共 {_VIDEO_MME_TASK_TYPE_COUNT} 个任务类型,"
f"每类需 eval_min_per_class 题保底,故验证池下限为 {floor}"
f"实际: {config.val_size}"
)
if config.trainable_min_units < 1:
raise ValueError(f"trainable_min_units 必须 >= 1,实际: {config.trainable_min_units}")
if config.pool_split_mode != "per_category":
floor = config.eval_min_per_class * _VIDEO_MME_TASK_TYPE_COUNT
if config.val_size < floor:
raise ValueError(
f"val_size 必须 >= eval_min_per_class * {_VIDEO_MME_TASK_TYPE_COUNT}"
f"(={floor})Video-MME 共 {_VIDEO_MME_TASK_TYPE_COUNT} 个任务类型,"
f"每类需 eval_min_per_class 题保底,故验证池下限为 {floor}"
f"实际: {config.val_size}"
)
if config.early_stop_patience <= 0:
raise ValueError(f"early_stop_patience 必须 > 0,实际: {config.early_stop_patience}")
if config.test_size <= 0:
@@ -337,7 +359,7 @@ def _validate_gate_thresholds(config: RunConfig) -> None:
def _validate_gate_ladder(config: RunConfig) -> None:
"""校验 CE-Gate 信息量阶梯与块序贯参数。
"""校验 CE-Gate 信息量阶梯参数。
参数:
config: 待校验的配置实例。
@@ -345,11 +367,8 @@ def _validate_gate_ladder(config: RunConfig) -> None:
异常:
ValueError: 任一阶梯参数不合法。
"""
if config.gate_block <= 0 or config.gate_n_max < config.gate_block:
raise ValueError(
f"需 0 < gate_block <= gate_n_max"
f"实际: block={config.gate_block}, n_max={config.gate_n_max}"
)
if config.gate_n_max <= 0:
raise ValueError(f"需 gate_n_max > 0,实际: n_max={config.gate_n_max}")
if not (0 <= config.gate_p_low < config.gate_p_high <= 1):
raise ValueError(
f"需 0 <= gate_p_low < gate_p_high <= 1"
@@ -421,6 +440,10 @@ def load_config(
if field_name in yaml_data:
yaml_data[field_name] = Path(yaml_data[field_name])
# Phase 4b: 类型转换 — task_types list → tuple
if "task_types" in yaml_data and yaml_data["task_types"] is not None:
yaml_data["task_types"] = tuple(yaml_data["task_types"])
# Phase 5: 构造并校验
config = RunConfig(**{k: v for k, v in yaml_data.items() if k in valid_fields})
_validate(config)
+183
View File
@@ -0,0 +1,183 @@
"""按 video_id 懒加载 InferenceDeps 并路由 dispatch/prompt_builder。
每个视频的 TreeIndex、TreeEnvironment、SkillRegistry 等重量级对象
只在首次访问时构建并缓存,后续同视频的请求直接复用。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from loguru import logger
from app.harness.factory import InferenceDeps, build_inference_deps
if TYPE_CHECKING:
from pathlib import Path
from app.ports import EmbeddingProvider, OCRProvider
from core.protocols import LLMProvider, VLMProvider
from core.types import GeneratedQuestion
class InferenceDepsRouter:
"""按 video_id 懒加载 InferenceDeps 并路由工具调度和 prompt 构建。
职责:
1. 维护 question_id → video_id 的映射表(由 prompt_builder 自动注册)。
2. 按 (video_id, skills_dir, prompts_dir) 三元组缓存 InferenceDeps。
3. 提供 create_dispatch / create_prompt_builder 工厂方法,
返回的闭包符合 ToolDispatchFn / PromptBuilderFn Protocol。
参数:
store_dir: store 根目录。
embed_provider: 嵌入端口实例。
llm: LLM 端口实例。
vlm: VLM 端口实例。
ocr: OCR 端口实例(None 不启用)。
default_prompts_dir: 默认 prompt 文件目录。
default_skills_dir: 默认 skill 文件目录(None 则不加载 skill)。
skill_mode: skill 模式("auto"/"manual"/"none")。
verify_vision: observe_frame 是否执行验证轮。
anchor: view_node 是否启用行号锚模式。
assemble_mode: 锚模式装配形态。
"""
def __init__(
self,
*,
store_dir: Path,
embed_provider: EmbeddingProvider,
llm: LLMProvider,
vlm: VLMProvider,
ocr: OCRProvider | None,
default_prompts_dir: Path,
default_skills_dir: Path | None,
skill_mode: str,
verify_vision: bool,
anchor: bool,
assemble_mode: str,
) -> None:
self._store_dir = store_dir
self._embed = embed_provider
self._llm = llm
self._vlm = vlm
self._ocr = ocr
self._default_prompts_dir = default_prompts_dir
self._default_skills_dir = default_skills_dir
self._skill_mode = skill_mode
self._verify_vision = verify_vision
self._anchor = anchor
self._assemble_mode = assemble_mode
self._deps_cache: dict[tuple[str, str, str], InferenceDeps] = {}
self._qid_to_vid: dict[str, str] = {}
def create_dispatch(self, *, skills_dir: Path | None = None) -> Any:
"""创建工具调度闭包,按 session_id 路由到对应视频的 InferenceDeps。
参数:
skills_dir: skill 文件目录覆盖(None 使用默认值)。
返回:
符合 ToolDispatchFn 签名的 async 闭包。
"""
effective_skills = skills_dir or self._default_skills_dir
async def _dispatch(
tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str:
"""按 session_id 查找视频 → 获取缓存 deps → 委托执行。"""
session_id = context.get("session_id")
if not session_id or session_id not in self._qid_to_vid:
raise KeyError(
f"未注册的 session_id={session_id!r},已注册 {len(self._qid_to_vid)} 条映射"
)
video_id = self._qid_to_vid[session_id]
deps = self._ensure_deps(video_id, effective_skills, self._default_prompts_dir)
return await deps.tool_dispatch_fn(tool_name, args, context=context)
return _dispatch
def create_prompt_builder(
self,
*,
skills_dir: Path | None = None,
prompts_dir: Path | None = None,
) -> Any:
"""创建 prompt 构建闭包,自动注册 qid→vid 映射并路由到对应视频的 deps。
参数:
skills_dir: skill 文件目录覆盖(None 使用默认值)。
prompts_dir: prompt 文件目录覆盖(None 使用默认值)。
返回:
符合 PromptBuilderFn 签名的闭包。
"""
effective_skills = skills_dir or self._default_skills_dir
effective_prompts = prompts_dir or self._default_prompts_dir
def _builder(qa: GeneratedQuestion) -> tuple[str, str]:
"""注册 qid→vid 映射 → 获取缓存 deps → 委托构建 prompt。"""
self._qid_to_vid[qa.question_id] = qa.video_id
deps = self._ensure_deps(qa.video_id, effective_skills, effective_prompts)
return deps.prompt_builder(qa)
return _builder
def _ensure_deps(
self,
video_id: str,
skills_dir: Path | None,
prompts_dir: Path,
) -> InferenceDeps:
"""按 (video_id, skills_dir, prompts_dir) 三元组缓存 InferenceDeps。
参数:
video_id: 视频标识。
skills_dir: skill 文件目录。
prompts_dir: prompt 文件目录。
返回:
缓存命中或新建的 InferenceDeps 实例。
"""
key = (video_id, str(skills_dir), str(prompts_dir))
if key not in self._deps_cache:
self._deps_cache[key] = self._build_deps(video_id, skills_dir, prompts_dir)
logger.debug("InferenceDeps 已缓存: video_id={}", video_id)
return self._deps_cache[key]
def _build_deps(
self,
video_id: str,
skills_dir: Path | None,
prompts_dir: Path,
) -> InferenceDeps:
"""调用 build_inference_deps 构建 InferenceDeps 实例。
参数:
video_id: 视频标识。
skills_dir: skill 文件目录。
prompts_dir: prompt 文件目录。
返回:
新建的 InferenceDeps 实例。
"""
return build_inference_deps(
store_dir=self._store_dir,
video_id=video_id,
prompts_dir=prompts_dir,
skills_dir=skills_dir,
skill_mode=self._skill_mode,
embed_provider=self._embed,
llm=self._llm,
vlm=self._vlm,
ocr=self._ocr,
verify_vision=self._verify_vision,
anchor=self._anchor,
assemble_mode=self._assemble_mode,
)
def clear_cache(self) -> None:
"""清空 deps 缓存和 qid→vid 映射表。"""
self._deps_cache.clear()
self._qid_to_vid.clear()
+155
View File
@@ -0,0 +1,155 @@
"""推理依赖工厂 — 组装一次推理所需的全套依赖。
将 TreeIndex 加载、TreeEnvironment 构建、SkillRegistry 发现、
SearchToolDispatcher 装配、PromptManager 初始化等步骤封装为
单一工厂函数 ``build_inference_deps``,返回不可变的 ``InferenceDeps``。
调用方(runner / inference)只需传入配置参数,无需了解内部装配逻辑。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from loguru import logger
from app.search.prompt import PromptManager
from app.search.skills import discover_skills
from app.search.tools import SearchToolDispatcher
from app.tree.environment import TreeEnvironment
from app.tree.index import TreeIndex
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from app.ports import EmbeddingProvider, OCRProvider
from core.protocols import LLMProvider, VLMProvider
from core.types import GeneratedQuestion
@dataclass(frozen=True)
class InferenceDeps:
"""跑一次推理所需的全套依赖(不含 HarnessLog,其生命周期由调用方管理)。
属性:
llm: LLM 端口实例。
tool_dispatch_fn: SearchToolDispatcher.dispatch 的绑定方法。
prompt_builder: (GeneratedQuestion) -> (system_prompt, user_prompt)。
"""
llm: LLMProvider
tool_dispatch_fn: Callable[..., Any]
prompt_builder: Callable[[GeneratedQuestion], tuple[str, str]]
def build_inference_deps(
*,
store_dir: Path,
video_id: str,
prompts_dir: Path,
skills_dir: Path | None,
skill_mode: str,
embed_provider: EmbeddingProvider,
llm: LLMProvider,
vlm: VLMProvider,
ocr: OCRProvider | None,
verify_vision: bool,
anchor: bool,
assemble_mode: str,
) -> InferenceDeps:
"""组装一次推理所需的全套依赖。
参数:
store_dir: store 根目录(包含 videos/{video_id}/tree.json)。
video_id: 视频标识。
prompts_dir: prompt 文件目录。
skills_dir: skill 文件目录(None 则不加载 skill)。
skill_mode: skill 模式("auto"/"manual"/"none")。
embed_provider: 嵌入端口实例。
llm: LLM 端口实例。
vlm: VLM 端口实例。
ocr: OCR 端口实例(None 不启用)。
verify_vision: observe_frame 是否执行验证轮。
anchor: view_node 是否启用行号锚模式。
assemble_mode: 锚模式装配形态。
返回:
InferenceDeps 实例。
异常:
FileNotFoundError: tree.json 不存在。
"""
# Phase 1: 加载 TreeIndex
tree_path = store_dir / "videos" / video_id / "tree.json"
if not tree_path.exists():
raise FileNotFoundError(f"树索引文件不存在: {tree_path}")
tree_index = TreeIndex.load_json(str(tree_path))
logger.info("已加载 TreeIndex: video_id={}, L1 节点数={}", video_id, len(tree_index.roots))
# Phase 2: 构建 TreeEnvironment
frames_dir = store_dir / "videos" / video_id / "frames"
env = TreeEnvironment(index=tree_index, frames_dir=frames_dir)
# Phase 3: 构建 SkillRegistry
skills = None
always_skills_text = ""
task_skill_map: dict[str, str] = {}
catalog_text = ""
if skills_dir is not None:
always_skills_text, task_skill_map, catalog_text, skills = discover_skills(skills_dir)
logger.info(
"已发现 skills: always={} 字符, task_map={}",
len(always_skills_text),
len(task_skill_map),
)
# Phase 4: 构建 SearchToolDispatcher
dispatcher = SearchToolDispatcher(
env,
tool_llm=llm,
vlm=vlm,
ocr=ocr,
prompts_dir=prompts_dir,
skills=skills,
embed_fn=embed_provider.embed,
verify_vision=verify_vision,
anchor=anchor,
assemble_mode=assemble_mode,
)
# Phase 5: 构建 PromptManager + _prompt_builder 闭包
pm = PromptManager(prompts_dir)
l1_ids = [root.id for root in tree_index.roots]
def _prompt_builder(qa: GeneratedQuestion) -> tuple[str, str]:
"""为单条题目生成 (system_prompt, user_prompt)。
参数:
qa: 生成的题目实例。
返回:
(system_prompt, user_prompt) 二元组。
"""
system = pm.build_inference_prompt(
skill_mode,
qa.task_type,
always_skills_text,
task_skill_map,
catalog_text,
)
user = pm.format_user_prompt(
qa.question,
list(qa.options),
l1_ids,
qa.task_type,
)
return system, user
logger.info("InferenceDeps 组装完成: video_id={}, skill_mode={}", video_id, skill_mode)
return InferenceDeps(
llm=llm,
tool_dispatch_fn=dispatcher.dispatch,
prompt_builder=_prompt_builder,
)
+132 -63
View File
@@ -1,13 +1,18 @@
"""CE-Gate 信息量阶梯与基线缓存。
"""CE-Gate 信息量阶梯与基线缓存unit 粒度,核心算法保真 #5
阶梯(每题型一条):gate 的出题顺序表。冷启动(FRESH)用种子基线对错
两档粗排(错题高优先 2:1 交错 + 全错题 probe_quota 探针插尾);
epoch >=1 用非 gate run 观测做 gamma-EMA 更新 p_hat,按信息量 p_hat(1-p_hat) 降序、
剔 p_hat 不在 [p_low, p_high]。防泄露铁律:gate 内 rollout 永不回流 p_hat
(调用方以 run_id 含 "_gate_" 过滤观测源)
阶梯(每题型一条):gate 的出题顺序表,键为 **unit_id**single 题 unit_id
等于 question_idAR pair 折叠为一个单元、unit_id 等于共享 pair_id)。冷启动
(FRESH)用种子基线的**单元级**对错两档粗排(错 unit 高优先 2:1 交错 + 全错
unit 的 probe_quota 探针插尾);epoch >=1 用非 gate run 观测**折叠成单元观测**后做
gamma-EMA 更新 p_hat,按信息量 p_hat(1-p_hat) 降序、剔 p_hat 不在 [p_low, p_high]
单元错 = 该单元任一成员错(AR pair 双向 AND)。防泄露铁律:gate 内 rollout 永不
回流 p_hat(调用方以 run_id 含 "_gate_" 过滤观测源),本迁移不改此过滤。
BaselineCache:基线侧逐题对错缓存,键 = (task_type, skill_hash,
prompts_version, qid) 内容寻址、无显式失效。JSON 持久化到 workspace
持久化门控:gate_pools.json 带 schema_version(当前 = 2unit 键)。旧版无
schema_version(v1、qid 键)加载时**直接报错**,拒绝静默混用 qid/unit 键。
BaselineCache:基线侧单元级对错缓存,键 = (task_type, skill_hash,
prompts_version, unit_id) 内容寻址、无显式失效。JSON 持久化到 workspace
供 resume 后合法复用已冻结阶梯上的新鲜 draw。
"""
@@ -22,10 +27,16 @@ from typing import TYPE_CHECKING
from loguru import logger
from app.harness.question_units import build_units, unit_correctness
if TYPE_CHECKING:
from pathlib import Path
from core.types import GeneratedQuestion
from core.types import GeneratedQuestion, QuestionUnit
# gate_pools.json 结构版本。v1(隐式、无此字段)为逐题 qid 键的存量格式;
# v2 起改为 unit_id 键。load 时严格校验,不匹配即报错(不静默迁移/混用)。
SCHEMA_VERSION = 2
def skill_hash(content: str) -> str:
@@ -42,50 +53,52 @@ def skill_hash(content: str) -> str:
@dataclass
class LadderEntry:
"""阶梯单元:题目与其估计答对率。
"""阶梯单元:题目单元与其估计答对率。
字段:
question_id: 题目唯一标识
unit_id: 单元唯一标识(single 等于 question_idAR pair 等于共享 pair_id
p_hat: 估计答对率。冷启动为 Beta(1,1) 平滑的单次观测后验均值
(错=1/3、对=2/3),此后经 gamma-EMA 更新。
"""
question_id: str
unit_id: str
p_hat: float
def build_cold_entries(
questions: list[GeneratedQuestion],
units: list[QuestionUnit],
correctness: dict[str, bool],
probe_quota: float,
seed: int,
) -> list[LadderEntry]:
"""冷启动排序:错题高优先 2:1 交错 + 全错题 probe_quota 探针插尾。
"""冷启动排序unit 粒度):错 unit 高优先 2:1 交错 + 全错 unit 探针插尾。
参数:
questions: 该题型的全部候选(已排除 test 池)。
correctness: question_id -> 种子基线是否答对(900 题全量对错)。
probe_quota: 从错题中随机抽出插到梯尾的探针比例(防"解锁新能力"盲区)。
units: 该题型的全部候选单元(已排除 test 池AR pair 已折叠成单元)。
correctness: question_id -> 种子基线是否答对(900 题全量逐题对错)。
单元级对错由 unit_correctness(strict=False) 折叠(任一成员错 → 单元错)。
probe_quota: 从错 unit 中随机抽出插到梯尾的探针比例(防"解锁新能力"盲区)。
seed: 洗牌种子,保证确定性重建。
返回:
排序后的 LadderEntry 列表(p_hat 用 Beta(1,1) 平滑:错=1/3、对=2/3
与 warm 阶段 gamma-EMA / 信息量排序自然衔接)。
排序后的 LadderEntry 列表(键=unit_idp_hat 用 Beta(1,1) 平滑:错=1/3、
对=2/3与 warm 阶段 gamma-EMA / 信息量排序自然衔接)。
关键实现细节:
错题、对题各自固定种子洗牌 -> 抽探针 -> 剩余按 错错对 2:1 交错
(一方耗尽后顺排另一方)-> 探针追加尾部。
与逐题版**同公式、同比例、同顺序**,仅把调度粒度从题换成单元:错 unit、
对 unit 各自固定种子洗牌 -> 按 probe_quota 从错 unit 抽探针 -> 剩余按
错错对 2:1 交错(一方耗尽后顺排另一方)-> 探针追加尾部。
"""
rng = random.Random(seed)
wrong = [q for q in questions if not correctness.get(q.question_id, False)]
right = [q for q in questions if correctness.get(q.question_id, False)]
wrong = [u for u in units if not unit_correctness(u, correctness, strict=False)]
right = [u for u in units if unit_correctness(u, correctness, strict=False)]
rng.shuffle(wrong)
rng.shuffle(right)
n_probe = int(len(wrong) * probe_quota)
probes, wrong_main = wrong[:n_probe], wrong[n_probe:]
interleaved: list[GeneratedQuestion] = []
interleaved: list[QuestionUnit] = []
wi, ri = 0, 0
while wi < len(wrong_main) or ri < len(right):
for _ in range(2):
@@ -97,10 +110,10 @@ def build_cold_entries(
ri += 1
interleaved.extend(probes)
def _p0(q: GeneratedQuestion) -> float:
return 2 / 3 if correctness.get(q.question_id, False) else 1 / 3
def _p0(u: QuestionUnit) -> float:
return 2 / 3 if unit_correctness(u, correctness, strict=False) else 1 / 3
return [LadderEntry(q.question_id, _p0(q)) for q in interleaved]
return [LadderEntry(u.unit_id, _p0(u)) for u in interleaved]
def order_ladder(entries: list[LadderEntry], p_low: float, p_high: float) -> list[LadderEntry]:
@@ -135,23 +148,24 @@ class GatePools:
def ladder_for(
self,
task_type: str,
exclude_qids: set[str],
exclude_units: set[str],
p_low: float,
p_high: float,
cold: bool,
) -> list[str]:
"""取该题型的 gate 出题序(qid 列表),排除本 step 进化案例包
"""取该题型的 gate 出题序(unit_id 列表),排除本 step 进化案例包所在单元
参数:
task_type: 目标题型。
exclude_qids: 本 step 案例包(failure/success cases)的题目 id
防止在"刚学的那道题"上自测。
exclude_units: 本 step 案例包(failure/success cases所在单元
unit_id防止在"刚学的那道题"上自测。按 **unit** 排除:命中单元
整体剔除,避免只排 AR pair 半个成员而向 gate 池灌入半个 pair。
p_low / p_high: warm 阶段的 p_hat 保留区间。
cold: True 表示尚无 epoch 级观测(epoch 1),用冷启动存储序;
False 走 order_ladder 信息量排序。
返回:
排除后的有序 question_id 列表。
排除后的有序 unit_id 列表。
异常:
ValueError: 该题型无阶梯(冷启动构建缺失),或该题型阶梯为空。
@@ -162,33 +176,54 @@ class GatePools:
if not pool:
raise ValueError(f"task_type={task_type} 阶梯为空,无可出题目")
ordered = pool if cold else order_ladder(pool, p_low, p_high)
return [e.question_id for e in ordered if e.question_id not in exclude_qids]
return [e.unit_id for e in ordered if e.unit_id not in exclude_units]
def update_probs(self, observations: dict[str, bool], gamma: float) -> None:
"""gamma-EMA 更新 p_hatp_hat <- gamma * p_hat + (1-gamma) * obs。只更新有新观测的题。
def update_probs(
self,
per_q_observations: dict[str, bool],
units_by_id: dict[str, QuestionUnit],
gamma: float,
) -> None:
"""gamma-EMA 更新 p_hat:先把逐题观测折叠成单元观测,再按 unit_id 匹配更新。
p_hat <- gamma * p_hat + (1-gamma) * unit_obs。只更新"整个单元都被观测到"
的单元;单元观测 = 成员逐题对错的 AND(任一成员错 → 单元错)。折叠是必需的:
AR pair 的 unit_id 是 pair_id,若直接按 unit_id 去逐题观测里匹配将永不命中、
导致 gamma-EMA 停摆(核心算法保真 #5)。
参数:
observations: question_id -> 本 epoch 非 gate run 的最新对错。
per_q_observations: question_id -> 本 epoch 非 gate run 的最新逐题对错。
调用方必须已按 run_id 过滤掉 gate 内 rollout(防泄露铁律)。
units_by_id: unit_id -> QuestionUnit,用于把逐题观测折叠成单元观测。
gamma: EMA 衰减系数。
关键实现细节:
单元只有在其**全部**成员都出现在 per_q_observations 时才更新;半观测
(AR pair 只见一半)跳过,避免用不完整证据污染 p_hat。
"""
for entries in self.entries.values():
for e in entries:
if e.question_id in observations:
obs = 1.0 if observations[e.question_id] else 0.0
e.p_hat = gamma * e.p_hat + (1 - gamma) * obs
unit = units_by_id.get(e.unit_id)
if unit is None:
continue
if not all(q.question_id in per_q_observations for q in unit.questions):
continue
unit_correct = all(per_q_observations[q.question_id] for q in unit.questions)
obs = 1.0 if unit_correct else 0.0
e.p_hat = gamma * e.p_hat + (1 - gamma) * obs
def save(self, path: Path) -> None:
"""原子写 gate_pools.json.tmp 再 replace)。
"""原子写 gate_pools.json.tmp 再 replace,落 schema_version + unit_id 键
参数:
path: 目标 JSON 路径。
"""
payload = {
"schema_version": SCHEMA_VERSION,
"seed": self.seed,
"fingerprint": self.fingerprint,
"entries": {
t: [{"question_id": e.question_id, "p_hat": e.p_hat} for e in es]
t: [{"unit_id": e.unit_id, "p_hat": e.p_hat} for e in es]
for t, es in self.entries.items()
},
}
@@ -198,18 +233,28 @@ class GatePools:
@classmethod
def load(cls, path: Path) -> GatePools:
"""从 gate_pools.json 恢复。
"""从 gate_pools.json 恢复schema_version 不匹配直接报错(不静默混用)
参数:
path: gate_pools.json 路径。
返回:
复活的 GatePools。
异常:
RuntimeError: 缺 schema_version(存量 v1、qid 键)或版本不等于
SCHEMA_VERSION——拒绝把 qid 键当 unit 键静默复用,须 FRESH 重建。
"""
d = json.loads(path.read_text(encoding="utf-8"))
version = d.get("schema_version")
if version != SCHEMA_VERSION:
raise RuntimeError(
f"gate_pools.json schema_version={version!r} 与当前 {SCHEMA_VERSION} 不符"
f"(存量 qid 键池不可当 unit 键复用),请删除后 FRESH 重建: {path}"
)
return cls(
entries={
t: [LadderEntry(x["question_id"], x["p_hat"]) for x in es]
t: [LadderEntry(x["unit_id"], x["p_hat"]) for x in es]
for t, es in d["entries"].items()
},
seed=d["seed"],
@@ -264,22 +309,46 @@ def build_or_load_gate_pools(
entries: dict[str, list[LadderEntry]] = {}
for t in task_types:
pool = [q for q in questions if q.task_type == t and q.question_id not in test_qids]
if not pool:
raise ValueError(f"task_type={t} 无非 test ,无法建阶梯")
entries[t] = build_cold_entries(pool, baseline_correctness, probe_quota, seed)
logger.info("gate 阶梯[{}]: {} (冷启动)", t, len(entries[t]))
units = _task_units_excluding_test(questions, t, test_qids)
if not units:
raise ValueError(f"task_type={t} 无非 test 单元,无法建阶梯")
entries[t] = build_cold_entries(units, baseline_correctness, probe_quota, seed)
logger.info("gate 阶梯[{}]: {} 单元(冷启动)", t, len(entries[t]))
pools = GatePools(entries=entries, seed=seed, fingerprint=fingerprint)
pools.save(path)
return pools
class BaselineCache:
"""基线侧逐题对错缓存(内容寻址,JSON 持久化)。
def _task_units_excluding_test(
questions: list[GeneratedQuestion], task_type: str, test_qids: set[str]
) -> list[QuestionUnit]:
"""取某题型的非 test 候选单元:先按 unit 折叠,再整体排除含 test 成员的单元。
键 = (task_type, skill_hash, prompts_version, qid):任何影响该题型
先折叠后排除保证 AR pair 不被拆半(否则半个 pair 交给下游会触发 build_units 的
孤儿 fail-fast);single 单元等价于逐题排除(核心算法保真 #5)。
参数:
questions: benchmark 全量题。
task_type: 目标题型。
test_qids: held-out test 池题目 id。
返回:
该题型下不含任何 test 成员的候选单元列表。
"""
pool = [q for q in questions if q.task_type == task_type]
return [
u for u in build_units(pool) if all(q.question_id not in test_qids for q in u.questions)
]
class BaselineCache:
"""基线侧单元级对错缓存(内容寻址,JSON 持久化)。
键 = (task_type, skill_hash, prompts_version, unit_id):任何影响该题型
有效 skill 的变化(含共享 default-strategy.md 被他类 accept 改写)
都使 skill_hash 变化、缓存自然 missprompts 版本变化同理。
都使 skill_hash 变化、缓存自然 missprompts 版本变化同理。unit_id 维度
使 single 题以自身 question_id、AR pair 以共享 pair_id 寻址,缓存单元级
对错(pair 双向 AND 折叠后一个布尔)。
"""
def __init__(self, path: Path) -> None:
@@ -294,32 +363,32 @@ class BaselineCache:
self._store = json.loads(path.read_text(encoding="utf-8"))
@staticmethod
def _key(task_type: str, s_hash: str, prompts_version: str, qid: str) -> str:
"""拼缓存键(四维内容寻址)。"""
return f"{task_type}|{s_hash}|{prompts_version}|{qid}"
def _key(task_type: str, s_hash: str, prompts_version: str, unit_id: str) -> str:
"""拼缓存键(四维内容寻址,第四维为 unit_id)。"""
return f"{task_type}|{s_hash}|{prompts_version}|{unit_id}"
def get(self, task_type: str, s_hash: str, prompts_version: str, qid: str) -> bool | None:
def get(self, task_type: str, s_hash: str, prompts_version: str, unit_id: str) -> bool | None:
"""读缓存;未命中返回 None。
参数:
task_type: 题型。
s_hash: 基线侧生效 skill 文件的内容哈希。
prompts_version: 当前 prompts 版本。
qid: 题目 id。
unit_id: 单元 idsingle=question_idAR pair=pair_id
返回:
缓存的对错;未命中 None。
缓存的单元级对错;未命中 None。
"""
return self._store.get(self._key(task_type, s_hash, prompts_version, qid))
return self._store.get(self._key(task_type, s_hash, prompts_version, unit_id))
def put(
self, task_type: str, s_hash: str, prompts_version: str, qid: str, correct: bool
self, task_type: str, s_hash: str, prompts_version: str, unit_id: str, correct: bool
) -> None:
"""写缓存并落盘(原子写,gate 频度低、全量重写成本可忽略)。
参数:
task_type / s_hash / prompts_version / qid: 缓存键四维。
correct: 基线侧该题对错
task_type / s_hash / prompts_version / unit_id: 缓存键四维。
correct: 基线侧该单元对错(AR pair 双向 AND 折叠后一个布尔)
关键实现细节:
先盘后存:新条目先原子落盘(tmp 写 + os.replace)成功后才更新
@@ -327,7 +396,7 @@ class BaselineCache:
"""
updated = {
**self._store,
self._key(task_type, s_hash, prompts_version, qid): correct,
self._key(task_type, s_hash, prompts_version, unit_id): correct,
}
tmp = self._path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(updated, ensure_ascii=False), encoding="utf-8")
+185 -36
View File
@@ -14,12 +14,14 @@ from __future__ import annotations
import asyncio
import json
import sqlite3
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from loguru import logger
from app.harness.question_units import build_units, unit_correctness
from core.agent.loop import AgentLoop
if TYPE_CHECKING:
@@ -28,22 +30,22 @@ if TYPE_CHECKING:
from app.harness.log import HarnessLog
from core.agent.types import LoopResult
from core.protocols import LLMProvider
from core.types import GeneratedQuestion
from core.types import GeneratedQuestion, QuestionUnit
@dataclass(frozen=True)
class InferenceResult:
"""推理聚合结果。
"""推理聚合结果(正确率按 unit 粒度)
属性:
run_id: 运行标识。
accuracy: 总正确率
total: 总题数
correct: 正确题数
per_task_type: 按题型分组的指标 {task_type: {accuracy, total, correct}}。
steps_mean: 平均步数。
token_usage: token 总用量 {prompt_tokens, completion_tokens}。
stop_reason_counts: 终止原因计数 {reason: count}。
accuracy: unit 级正确率(correct / total
total: unit 总数(single 数 + pair 数,孤儿 pair 已剔除不计入)
correct: 正确 unit 数(single 单题正确;pair 走 original/mirror 双向 AND
per_task_type: 按题型分组的 unit 级指标 {task_type: {accuracy, total, correct}}。
steps_mean: 平均步数record 粒度,逐题溯源)
token_usage: token 总用量 {prompt_tokens, completion_tokens}record 粒度)
stop_reason_counts: 终止原因计数 {reason: count}record 粒度)
"""
run_id: str
@@ -161,6 +163,24 @@ def _to_text_field(value: Any) -> str:
return json.dumps(value, ensure_ascii=False)
def _normalize_prediction(answer: object) -> str | None:
"""归一化 prediction 落库值。
LLM 提交的 answer 有时是 list/dict(如 {'answer': ['B']}),sqlite 无法绑定
非标量类型直接入库会抛 ProgrammingError 击穿整轮 gather。None 保留(INFRA 空
预测语义,供正确率判定天然计错);str 原样;其余 JSON 序列化为文本。
参数:
answer: LoopResult.result 中的 answer 原始值(可能是 None/str/list/dict)。
返回:
None(保留空预测语义)或可直接入库的字符串。
"""
if answer is None or isinstance(answer, str):
return answer
return _to_text_field(answer)
def _zero_result(run_id: str) -> InferenceResult:
"""空记录时的零值 InferenceResult。
@@ -182,23 +202,25 @@ def _zero_result(run_id: str) -> InferenceResult:
)
def _group_by_task_type(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""按 task_type 分组聚合正确率指标。
def _group_by_task_type(graded: list[tuple[QuestionUnit, bool]]) -> dict[str, dict[str, Any]]:
"""按 task_type 分组聚合 unit 级正确率指标。
pair 单元整体计 1 个 unit,归入其 task_typesingle 单元计 1 个 unit。
参数:
records: 预测记录列表。
graded: (单元, 该单元是否整体正确) 元组列表。
返回:
{task_type: {accuracy, total, correct}} 映射。
{task_type: {accuracy, total, correct}} 映射unit 粒度)
"""
task_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for r in records:
task_groups[r["task_type"]].append(r)
task_groups: dict[str, list[bool]] = defaultdict(list)
for unit, is_correct in graded:
task_groups[unit.task_type].append(is_correct)
per_task_type: dict[str, dict[str, Any]] = {}
for task_type, group in task_groups.items():
t_total = len(group)
t_correct = sum(1 for r in group if r["prediction"] == r["answer"])
for task_type, verdicts in task_groups.items():
t_total = len(verdicts)
t_correct = sum(verdicts)
per_task_type[task_type] = {
"accuracy": t_correct / t_total,
"total": t_total,
@@ -207,35 +229,143 @@ def _group_by_task_type(records: list[dict[str, Any]]) -> dict[str, dict[str, An
return per_task_type
def _aggregate_results(records: list[dict[str, Any]], run_id: str) -> InferenceResult:
"""从内存 records 聚合推理指标
def _is_valid_pair(group: list[GeneratedQuestion]) -> bool:
"""判定同一 pair_id 分组是否为合法孪生对(恰好 1 original + 1 mirror,无多余)
TRM4 从 DB 回读 predictions 表聚合;TRM5 改为从内存直接聚合,
避免 DB 回读的同步开销和额外依赖
要求分组总数恰为 2 且角色齐备唯一;有额外非法 role 记录(total>2)或角色
缺失/重复均视为非法,交由调用方剔除,防非法记录混入 build_units
参数:
records: _run_single_question 返回的 record 列表。
group: 归属同一 pair_id 的题目列表。
返回:
合法孪生对为 True,否则 False。
"""
if len(group) != 2:
return False
originals = sum(1 for q in group if q.question_role == "pair_original")
mirrors = sum(1 for q in group if q.question_role == "pair_mirror")
return originals == 1 and mirrors == 1
def _drop_orphan_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
"""剔除收不齐 2 条 / 角色非法的孤儿 pair,告警不静默。
每条题目均会各答一次并逐题落库;能否合成 pair 单元仅取决于 questions
是否同时含该 pair_id 的 original + mirror(且无多余非法记录)。非法者告警并
整对剔除,使后续 build_units 只面对合法孪生对(不触发 fail-fast),孤儿 unit
不计入 total(对齐设计 §8 聚合入口的"告警 + 剔除")。
参数:
questions: 待聚合的题目列表(可混含 single 与孪生对成员)。
返回:
可安全交给 build_units 的题目列表(single 全保留,pair 仅保留合法成对者)。
"""
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
singles: list[GeneratedQuestion] = []
for q in questions:
if q.pair_id:
by_pair[q.pair_id].append(q)
else:
singles.append(q)
kept_pairs: list[GeneratedQuestion] = []
for pair_id, group in by_pair.items():
if _is_valid_pair(group):
kept_pairs.extend(group)
else:
logger.warning(
"孤儿 pair {}:非法配对(total={}),剔除该 unit 不计入 total",
pair_id,
len(group),
)
return singles + kept_pairs
def _per_question_correctness(records: list[dict[str, Any]]) -> dict[str, bool]:
"""由逐题 record 构造 question_id → 该题作答是否正确 的映射。
prediction 为 None(作答异常)时与 answer 不相等 → False,天然计错。
参数:
records: _run_single_question 返回的逐题 record 列表。
返回:
{question_id: prediction == answer} 映射,供 unit_correctness 取值。
"""
return {r["question_id"]: r["prediction"] == r["answer"] for r in records}
def _grade_unit(unit: QuestionUnit, per_q: dict[str, bool]) -> bool:
"""判定单元整体正确性,缺 prediction 时 fail-loud(带上下文)。
_drop_orphan_pairs 已剔除孤儿/非法配对,正常情况下 unit 内每题都应有对应
record;若仍缺失说明聚合不变量被破坏(如 records 与 questions 不同源)。此处
显式抛带上下文的 ValueErrorfail-loud,不 catch/不跳过/不兜底),而非放任
unit_correctness 抛裸 KeyError 丢失定位信息。
参数:
unit: 待判定单元。
per_q: question_id → 该题是否作答正确 的映射。
返回:
单元整体是否正确(single 即单题正确;pair 走双向 AND)。
异常:
ValueError: unit 内某 question_id 不在 per_q 中(聚合不变量被破坏)。
"""
missing = [q.question_id for q in unit.questions if q.question_id not in per_q]
if missing:
raise ValueError(
f"unit {unit.unit_id} 的 question {missing} 缺 prediction"
"_drop_orphan_pairs 后不应发生,聚合不变量被破坏)"
)
return unit_correctness(unit, per_q)
def _aggregate_results(
records: list[dict[str, Any]],
questions: list[GeneratedQuestion],
run_id: str,
) -> InferenceResult:
"""从内存 records + 题目列表按 unit 粒度聚合推理指标。
逐题 record 保留逐题溯源(token/steps/stop_reason 诊断仍按 record 汇总);
正确率则按 unit 粒度计:single 计 1AR pair 经 build_units 收齐 original +
mirror 后走 unit_correctness 的双向 AND 判定,整对计 1 个 unit。孤儿 pair
在 _drop_orphan_pairs 中告警 + 剔除,不计入 total。
参数:
records: _run_single_question 返回的逐题 record 列表。
questions: 与 records 对应的题目列表(提供 pair_id/question_role 元数据)。
run_id: 当前运行标识。
返回:
InferenceResult 冻结实例。
InferenceResult 冻结实例total/correct/per_task_type 为 unit 粒度)
"""
total = len(records)
if total == 0:
if not records:
return _zero_result(run_id)
correct = sum(1 for r in records if r["prediction"] == r["answer"])
per_q = _per_question_correctness(records)
units = build_units(_drop_orphan_pairs(questions))
graded = [(unit, _grade_unit(unit, per_q)) for unit in units]
total = len(graded)
correct = sum(1 for _, is_correct in graded if is_correct)
stop_counts: dict[str, int] = defaultdict(int)
for r in records:
stop_counts[r["stop_reason"]] += 1
n_records = len(records)
return InferenceResult(
run_id=run_id,
accuracy=correct / total,
accuracy=correct / total if total else 0.0,
total=total,
correct=correct,
per_task_type=_group_by_task_type(records),
steps_mean=sum(r["steps_used"] for r in records) / total,
per_task_type=_group_by_task_type(graded),
steps_mean=sum(r["steps_used"] for r in records) / n_records,
token_usage={
"prompt_tokens": sum(r["prompt_tokens"] for r in records),
"completion_tokens": sum(r["completion_tokens"] for r in records),
@@ -258,6 +388,7 @@ async def _run_single_question(
log: HarnessLog,
max_steps: int,
plugins: list[object],
run_id: str,
) -> dict[str, Any]:
"""执行单道题目的 Agent 推理。
@@ -272,11 +403,17 @@ async def _run_single_question(
log: HarnessLog 实例(线程安全)。
max_steps: AgentLoop 最大步数。
plugins: pluggy 插件列表。
run_id: 运行标识,用作 cache_salt——run_id 含 _e{epoch} 天然跨 epoch 重采样、
同 epoch 续跑命中缓存(算法 #10 透传)。
返回:
预测结果字典(含 video_id, question_id, prediction, answer 等)。
"""
# run_id 必须显式入 record:HarnessLog.insert 缺省用**实例** run_id 填充,
# 连续并发 gate 共享单一 gate_log(实例 run_id 为 step 级)时,各臂行必须
# 落自己的臂 run_id,否则 validate 回读 _load_run_rows(臂 run_id) 为空。
record: dict[str, Any] = {
"run_id": run_id,
"video_id": qa.video_id,
"question_id": qa.question_id,
"task_type": qa.task_type,
@@ -301,6 +438,7 @@ async def _run_single_question(
dispatcher,
plugins=plugins,
session_id=qa.question_id,
cache_salt=run_id,
)
result_dict = loop_result.result if isinstance(loop_result.result, dict) else {}
@@ -308,7 +446,7 @@ async def _run_single_question(
reasoning = _to_text_field(result_dict.get("reasoning", ""))
record.update(
{
"prediction": result_dict.get("answer"),
"prediction": _normalize_prediction(result_dict.get("answer")),
"evidence": evidence,
"reasoning": reasoning,
"steps_used": loop_result.steps_used,
@@ -331,8 +469,18 @@ async def _run_single_question(
except Exception:
logger.exception("[{}] QA {} 执行异常", qa.video_id, qa.question_id)
# prediction 必落库(try 外,无论成败)
await asyncio.to_thread(log.insert, "predictions", record)
# prediction 必落库(try 外,无论成败);绑定异常降级为最小 error 行,不击穿 gather
try:
await asyncio.to_thread(log.insert, "predictions", record)
except (sqlite3.InterfaceError, sqlite3.ProgrammingError):
logger.exception("[{}] QA {} 落库绑定异常,降级为 error 行", qa.video_id, qa.question_id)
record["prediction"] = None
record["stop_reason"] = "error"
await asyncio.to_thread(
log.insert,
"predictions",
{k: v for k, v in record.items() if isinstance(v, (str, int, float, type(None)))},
)
return record
@@ -399,7 +547,7 @@ async def run_inference(
if not questions:
logger.info("题目列表为空,返回零值 InferenceResult")
return _aggregate_results([], run_id)
return _aggregate_results([], [], run_id)
sem = asyncio.Semaphore(concurrency)
total_count = len(questions)
@@ -418,6 +566,7 @@ async def run_inference(
log=log,
max_steps=max_steps,
plugins=plugins,
run_id=run_id,
)
logger.info(
"[{}/{}] {} QA {} 完成 (stop={})",
@@ -431,7 +580,7 @@ async def run_inference(
results = await asyncio.gather(*[_bounded(i, qa) for i, qa in enumerate(questions)])
inference_result = _aggregate_results(list(results), run_id)
inference_result = _aggregate_results(list(results), questions, run_id)
logger.info(
"推理完成: accuracy={:.2%} ({}/{})",
inference_result.accuracy,
+37 -16
View File
@@ -1,8 +1,9 @@
"""HarnessLogSQLite 薄包装 + RunLogImpl 只读查询端口。
HarnessLog 提供统一的结构化日志接口,从 TRM4 直搬,保留全部线程安全与幂等语义。
同 run_id 重复创建时通过 ON CONFLICT DO UPDATE 更新 started_at/config/status。
RunLogImpl 实现 core/evolution/protocols.py::RunLog Protocol,用独立连接做只读 SELECT,
不经 HarnessLog 生命周期(不触发 _runs INSERT OR IGNORE),避免污染运行状态。
不经 HarnessLog 生命周期(不触发 _runs upsert),避免污染运行状态。
"""
from __future__ import annotations
@@ -41,7 +42,7 @@ class HarnessLog:
关键设计:
- WAL 模式 + threading.Lock 保证共享连接下并发安全。
- INSERT OR IGNORE INTO _runs 保证幂等(同 run_id 多次创建不报错)
- ON CONFLICT DO UPDATE 保证幂等(同 run_id 多次创建不报错)并更新 started_at/config/status
- query 也持锁:共享连接(check_same_thread=False)下并发 SELECT + INSERT
在同一连接上 execute 会损坏游标状态,故读也须串行化。
- context manager 语义:正常退出 completed,异常退出 failed。
@@ -51,6 +52,9 @@ class HarnessLog:
run_id: 本次运行的唯一标识。
git_sha: 代码版本,默认自动获取。
config_snapshot: 本次运行的配置快照。
register_run: 是否注册运行(upsert _runs + 退出时同步 status)。默认 True
只读查询已有 run(如基线预测回读)时传 False,避免把该 run 的
started_at/config/status 改写、把基线元数据污染成本次进程的运行状态。
"""
def __init__(
@@ -59,23 +63,35 @@ class HarnessLog:
run_id: str,
git_sha: str | None = None,
config_snapshot: dict[str, Any] | None = None,
*,
register_run: bool = True,
) -> None:
self._run_id = run_id
self._register_run = register_run
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# 单持久连接 + 进程内 Lock 串行化写:把并发控制拉到进程内,消除多连接争
# SQLite 写锁。同款模式复用于 adapters/telemetry.py:SQLiteTelemetryRecorder。
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._lock = threading.Lock()
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._init_fixed_tables()
resolved_sha = git_sha or _get_git_sha()
config_json = json.dumps(config_snapshot, ensure_ascii=False) if config_snapshot else None
self._conn.execute(
"INSERT OR IGNORE INTO _runs"
" (run_id, git_sha, started_at, config, status)"
" VALUES (?, ?, ?, ?, ?)",
(run_id, resolved_sha, _now_iso(), config_json, "running"),
)
self._conn.commit()
if register_run:
resolved_sha = git_sha or _get_git_sha()
config_json = (
json.dumps(config_snapshot, ensure_ascii=False) if config_snapshot else None
)
self._conn.execute(
"INSERT INTO _runs"
" (run_id, git_sha, started_at, config, status)"
" VALUES (?, ?, ?, ?, ?)"
" ON CONFLICT(run_id) DO UPDATE SET"
" started_at=excluded.started_at,"
" config=excluded.config,"
" status=excluded.status",
(run_id, resolved_sha, _now_iso(), config_json, "running"),
)
self._conn.commit()
def _init_fixed_tables(self) -> None:
"""创建 _runs 和 _events 固定表。"""
@@ -212,13 +228,18 @@ class HarnessLog:
参数:
status: 最终状态,"completed""failed"
关键实现:
register_run=False(只读打开)时跳过 status 更新,仅关闭连接,
避免只读回读把已有 run 的 finished_at/status 改写。
"""
with self._lock:
self._conn.execute(
"UPDATE _runs SET finished_at = ?, status = ? WHERE run_id = ?",
(_now_iso(), status, self._run_id),
)
self._conn.commit()
if self._register_run:
self._conn.execute(
"UPDATE _runs SET finished_at = ?, status = ? WHERE run_id = ?",
(_now_iso(), status, self._run_id),
)
self._conn.commit()
self._conn.close()
def __enter__(self) -> HarnessLog:
+28 -8
View File
@@ -87,6 +87,8 @@ _HOLDOUT_COLS: dict[str, str] = {
_QUADRANT_COLS: dict[str, str] = {
"epoch": "INTEGER",
"step": "INTEGER",
# question_id 列承载 unit_idsingle=question_idpair=pair_id);
# 逐题明细在 predictions 表溯源,按 pair_id join 真实 question 表会 join 不上。
"question_id": "TEXT",
"task_type": "TEXT",
"prev_correct": "INTEGER",
@@ -98,8 +100,10 @@ _GATE_EVIDENCE_COLS: dict[str, str] = {
"epoch": "INTEGER",
"step": "INTEGER",
"task_type": "TEXT",
# question_id 列承载 unit_idsingle=question_idpair=pair_id);
# 逐题明细在 predictions 表溯源,按 pair_id join 真实 question 表会 join 不上。
"question_id": "TEXT",
"block_idx": "INTEGER",
"ladder_rank": "INTEGER",
"baseline_correct": "INTEGER",
"candidate_correct": "INTEGER",
"e_value": "REAL",
@@ -131,7 +135,8 @@ def write_dual_metric(
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
version_kind: baseline / best_hard / best_mixed / final
version_kind: baseline / best_hard / best_mixed / final / slow_candidate
slow_candidate = 慢更新 R2 可能被 revert 的候选,不占 epoch 终值 final 口径)。
skills_version / prompts_version: 评估的资源版本。
pool: val / test。
hard_acc: hard 准确率。
@@ -275,7 +280,7 @@ def write_quadrant_pairs(
step: int,
pairs: list[dict[str, Any]],
) -> None:
"""落 quadrant_pair 多行:fast gate 后逐题四象限(prev/curr 翻转 + category)落库。
"""落 quadrant_pair 多行:fast gate 后按 **unit** 四象限(prev/curr 翻转 + category)落库。
参数:
db_path: SQLite 路径。
@@ -283,7 +288,10 @@ def write_quadrant_pairs(
epoch: 轮次(1-based)。
step: epoch 内 step 序号(0-based)。
pairs: 每条含 question_id/task_type/prev_correct/curr_correct/category
prev_correct/curr_correct 为 bool,写库前转 0/1。
question_id 字段承载 **unit_id**single=question_idpair=pair_id
与 gate e-process 同粒度)——逐题明细在 predictions 表溯源,按 pair_id
join 真实 question 表会 join 不上;prev_correct/curr_correct 为 bool
写库前转 0/1。
关键实现:
用 insert_many 批量落库;pairs 为空时只建表不插入(fast gate 无翻转的极端情况)。
@@ -326,16 +334,22 @@ def write_gate_evidence(
step: int,
rows: list[dict[str, Any]],
) -> None:
"""落 gate_evidence 逐题行:CE-Gate 每次决策的可回放审计记录。
"""落 gate_evidence 单元行:CE-Gate 每次决策的可回放审计记录unit 口径)
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 该 gate 所属的轮次(1-based)。
step: epoch 内 step 序号(0-based)。
rows: 每一行,含 question_id/task_type/block_idx/baseline_correct/
candidate_correct/e_value(该题所在块判定后的累计 e 值)/
stop_reason(仅最后一题携带最终 stop_reason,其余空串)。
rows: 每 **单元** 一行,含 question_id/task_type/ladder_rank(阶梯序号,
0-based/baseline_correct/
candidate_correct/e_value(该单元判定后的累计 e 值)/
stop_reason(仅最后一单元携带最终 stop_reason,其余空串)。
question_id 字段承载 **unit_id**single=question_idpair=pair_id)——
逐题明细在 predictions 表溯源,按 pair_id join 真实 question 表会 join 不上。
返回:
无。
关键实现:
逐行 insert(非 insert_many),保证每行独立事务。
@@ -344,6 +358,12 @@ def write_gate_evidence(
with HarnessLog(db_path, run_id) as log:
log.create_table("gate_evidence", _GATE_EVIDENCE_COLS)
# 幂等迁移(对齐 question_gen/run_store 先例):块序贯时代的旧表只有
# block_idx 列,CREATE TABLE IF NOT EXISTS 不补列,直接插 ladder_rank
# 会 OperationalError——为旧 workspace 复用补列,新表恒为 no-op。
cols = {r["name"] for r in log.query("PRAGMA table_info(gate_evidence)")}
if "ladder_rank" not in cols:
log.execute("ALTER TABLE gate_evidence ADD COLUMN ladder_rank INTEGER")
for row in rows:
log.insert("gate_evidence", {"epoch": epoch, "step": step, **row})
+987 -64
View File
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
"""QuestionUnit 组装/展开/校验/单元正确性——pair 契约的唯一入口。
pool 构建、批处理、推理、评测(Task 3+)均通过本模块聚合/展开孪生对,
保证 AR pair 的"两题作为整体调度"契约只在一处实现、fail-fast 暴露非法配对。
"""
from collections import defaultdict
from core.types import GeneratedQuestion, QuestionUnit
def _assemble_pair(pair_id: str, group: list[GeneratedQuestion]) -> QuestionUnit:
"""校验单个 pair 分组的数量/角色并组装为 pair 单元(fail-fast)。
参数:
pair_id: 该分组共享的孪生对标识。
group: 归属同一 pair_id 的题目列表。
返回:
kind="pair" 的 QuestionUnit。
关键实现:
- 分组必须恰好 2 条,否则视为孤儿/超员,raise ValueError。
- 显式检查 pair_original / pair_mirror 角色齐备且唯一,缺失或重复
直接 raise ValueError(防 next(...) 静默 StopIteration)。
- 合法孪生对交由 QuestionUnit.from_pair 做 video_id/task_type/flip_axis
一致性断言。
"""
if len(group) != 2:
raise ValueError(f"pair {pair_id} 数量={len(group)}≠2(孤儿或超员)")
originals = [q for q in group if q.question_role == "pair_original"]
mirrors = [q for q in group if q.question_role == "pair_mirror"]
if len(originals) != 1 or len(mirrors) != 1:
raise ValueError(
f"pair {pair_id} 角色非法:original={len(originals)} mirror={len(mirrors)}"
"需各恰好 1 条"
)
return QuestionUnit.from_pair(originals[0], mirrors[0])
def build_units(questions: list[GeneratedQuestion]) -> list[QuestionUnit]:
"""将扁平题目列表聚合为单元列表:single 单封、pair 按 pair_id 成对聚合。
参数:
questions: 待聚合的题目列表,可混含 single 与孪生对成员。
返回:
单元列表,先 single 后 pair,顺序稳定(single 保留输入顺序,
pair 按首次出现的 pair_id 顺序)。
关键实现:
pair_id 为空 → single 单元;非空 → 归入对应 pair 桶。各 pair 桶的
数量/角色校验与组装下沉到 _assemble_pairfail-fast),本体只做分组
与派发。
"""
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
singles: list[QuestionUnit] = []
for q in questions:
if q.pair_id:
by_pair[q.pair_id].append(q)
else:
singles.append(QuestionUnit.from_single(q))
pairs = [_assemble_pair(pid, qs) for pid, qs in by_pair.items()]
return singles + pairs
def flatten_units(units: list[QuestionUnit]) -> list[GeneratedQuestion]:
"""将单元列表无损展开回扁平题目列表。
参数:
units: 单元列表。
返回:
展开后的题目列表,保持单元顺序及单元内题目顺序。
"""
return [q for u in units for q in u.questions]
def validate_units(units: list[QuestionUnit]) -> list[QuestionUnit]:
"""校验单元列表结构合法性,通过则原样返回(便于链式调用)。
参数:
units: 待校验单元列表。
返回:
校验通过的原单元列表。
关键实现:
pair 单元必须恰好含 2 题,否则 raise ValueErrorsingle 单元无需额外
校验(构造时即为 1 题)。用于消费方在使用前做一道防御闸门。
"""
for u in units:
if u.kind == "pair" and u.size != 2:
raise ValueError(f"unit {u.unit_id} pair 不成对(size={u.size}")
return units
def unit_correctness(unit: QuestionUnit, per_q: dict[str, bool], *, strict: bool = True) -> bool:
"""计算单元级正确性:AR pair 走双向 AND,single 即单题正确性。
参数:
unit: 目标单元。
per_q: 题目 question_id → 该题是否作答正确的映射。
strict: 缺键策略。True(默认)时以 per_q[q.question_id] 取值,缺任一题
触发 KeyError(防静默兜底,强制上游先补齐全部单题结果);False 时以
per_q.get(q.question_id, False) 取值,缺键计 False(宽松口径,供池
构建 / gate 冷启动 / 采样等"缺基线对错即视为未答对"的调用点复用)。
返回:
单元内所有题目均正确时为 True,否则 False。
关键实现:
pool 构建(pools)、gate 冷启动(gate_ladder)、分层采样(loader)三处
原各自持有的 loose 版 _unit_correct 副本统一收敛到本函数 strict=False 分支,
消除重复逻辑与 missing-key 策略分叉。
"""
if strict:
return all(per_q[q.question_id] for q in unit.questions)
return all(per_q.get(q.question_id, False) for q in unit.questions)
def unit_correctness_view(
units: list[QuestionUnit], per_q: dict[str, bool], *, strict: bool = True
) -> dict[str, bool]:
"""把逐题对错折叠成单元级视图:unit_id → 单元是否整体正确。
进化引擎(gate e-process / quadrant / probation / pair_block / compute_accuracy
统一消费此单元视图,保证 AR pair 双向 AND、非 AR single 单题,混格池中
孪生对折叠为一个单元、不被 P/Q 单题计分污染(核心算法保真 #5)。
参数:
units: 目标单元列表(single 或 pair)。
per_q: 题目 question_id → 该题是否作答正确(唯一逐题溯源来源)。
strict: 缺键策略,透传给 unit_correctness。True(默认)缺任一题 raise
KeyErrorFalse 缺键计 False(宽松口径)。
返回:
unit_id → 单元级正确性。single 的 unit_id 等于其 question_id
pair 的 unit_id 等于共享 pair_id。
关键实现:
逐单元复用 unit_correctnessstrict 透传),默认 strict 禁静默兜底、
强制上游先补齐全部单题结果。
"""
return {u.unit_id: unit_correctness(u, per_q, strict=strict) for u in units}
+752 -195
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
"""结果驱动视频级切分的冻结溯源 manifest。
冻结的 pools.json 是切分产物;manifest 记录产出这份切分的关键输入
baseline_run_id、诊断指纹、随机种子、配置)与 pools.json 的内容指纹
pools_sha256),供后续 build_split 写溯源、以及复现校验时比对。
"""
from __future__ import annotations
import hashlib
from typing import TYPE_CHECKING
from app.harness.pools import _atomic_write_json
if TYPE_CHECKING:
from pathlib import Path
def write_manifest(
path: Path,
*,
baseline_run_id: str,
diag_fingerprint: str,
seed: int,
config: dict,
pools_json_text: str,
coverage_report: dict,
generated_at: str,
) -> dict:
"""写切分冻结溯源 manifest(原子写),返回写入的 dict。
pools_sha256 = sha256(pools_json_text),供复现时校验冻结的 pools.json 内容
是否与本次切分一致。generated_at 由调用方传入(库内不用 datetime.now),
以保证相同输入产出相同 manifest,可复现。
参数:
path: manifest 目标 JSON 文件路径。
baseline_run_id: 产出本次切分所依据的基线 run 标识。
diag_fingerprint: 诊断结果指纹(决定 train/val 归属的输入)。
seed: 切分使用的随机种子。
config: 切分相关配置快照(如 train_ratio 等)。
pools_json_text: 冻结的 pools.json 完整文本,用于计算内容指纹。
coverage_report: 各类别 train/val 覆盖统计报告。
generated_at: 生成时间戳(ISO 字符串),由调用方传入。
返回:
写入 manifest 的 dict(与落盘内容一致)。
"""
manifest = {
"baseline_run_id": baseline_run_id,
"diag_fingerprint": diag_fingerprint,
"seed": seed,
"config": config,
"pools_sha256": hashlib.sha256(pools_json_text.encode("utf-8")).hexdigest(),
"coverage_report": coverage_report,
"generated_at": generated_at,
}
_atomic_write_json(path, manifest)
return manifest
+551
View File
@@ -0,0 +1,551 @@
"""视频级切分选择:signal 分层、视频聚合、贪心联合约束选择(纯函数)。
结果驱动切分管线的核心:把诊断信号投影为多样性格子,供贪心选择器最大化覆盖。
本模块起步定义 evolution_target 派生与多样性格子;后续追加 score_signal /
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",
"reasoning_failure": "skill",
"mixed": "system",
}
def evolution_target_of(error_type: str) -> str:
"""由 error_type 确定性派生进化目标(tool/skill/system)。
这是报告用的派生标注,非独立多样性轴(多样性主格子=task_type×error_type)。
参数:
error_type: 诊断瀑布归因的错误类别(extraction/search/reasoning/mixed_failure)。
返回:
进化目标字符串 tool / skill / system。
异常:
ValueError: error_type 不在已知集合内(不静默兜底)。
"""
if error_type not in _EVOLUTION_TARGET:
raise ValueError(f"未知 error_type: {error_type}")
return _EVOLUTION_TARGET[error_type]
def cell_of(task_type: str, error_type: str) -> tuple[str, str]:
"""构造多样性主格子 = (task_type, error_type)。
参数:
task_type: 题型(12 类之一)。
error_type: 错误类别(4 类之一)。
返回:
(task_type, error_type) 二元组,作为覆盖计数的格子键。
"""
return (task_type, error_type)
@dataclass(frozen=True)
class SignalLabel:
"""诊断信号分层标签(DiagnosisResult 的确定性投影)。
字段:
tier: 信号层级,取值 T0 / T1 / T2 / uncertain(判据见 score_signal)。
"""
tier: str
def score_signal(*, cause_category: str | None, infra: bool, degraded: bool) -> SignalLabel:
"""把诊断产物投影为信号分层 tier(不发明新分类,是确定性投影)。
分层优先级顺序固定(用早返回表达,不用魔法权重):
先判 INFRA,再判 degraded,然后 defect / lapse,最后兜底 uncertain。
各层判据来源:
T0 — infra=True,即诊断 INFRA 排除(stop_reason ∈ {error, parse_error}),
基础设施失败先于一切判定,排除出可训练主体。
uncertain — degraded=Truejudge 解析失败)或 cause_category 落不到
defect/lapse 上(如为 None),信号不可信,排除出 T2。
T2 — cause_category == "defect",可训练核心,进多样性覆盖与训练主体。
T1 — cause_category == "lapse",低信号(含无解题),接受但不作训练主体。
参数:
cause_category: 诊断的缺陷归因("defect" / "lapse" / None)。
infra: 是否被 INFRA 护栏排除(基础设施失败)。
degraded: judge 是否解析失败导致诊断降级。
返回:
SignalLabel,其 tier 字段为上述四层之一。
实现细节:
关键字参数强制传入,防止 infra / degraded 两个 bool 位置混淆。
"""
if infra:
return SignalLabel(tier="T0")
if degraded:
return SignalLabel(tier="uncertain")
if cause_category == "defect":
return SignalLabel(tier="T2")
if cause_category == "lapse":
return SignalLabel(tier="T1")
return SignalLabel(tier="uncertain")
@dataclass(frozen=True)
class VideoRecord:
"""全视频画像单元(贪心选择器 Task 8 的输入单元)。
覆盖全部视频(含全对、零诊断信号的视频),既承载 test 代表性所需的难度/题型画像,
也叠加 T2 可训练缺陷的多样性格子,供选择器算覆盖与补集。
字段:
video_id: 视频唯一标识。
type_set: 该视频所有题的 task_type 集合(去重,画像用)。
n_correct: 该视频答对题数。
difficulty: 难度画像桶 = 错题数 = 题数 - n_correct。
cells: 仅 tier=="T2" 信号行投影的 (task_type, error_type) 主格子并集(去重)。
wrong_by_type: 各 task_type 的 T2 计数,供选择器 floor 约束(普通 dict)。
实现细节:
frozen 生成的 __hash__ 会遍历各字段;wrong_by_type 为不可哈希 dict
故显式标注 hash=False 将其排除出哈希,避免 VideoRecord 入 set/dict 键时报错,
仍保留其参与相等性比较。
"""
video_id: str
type_set: frozenset[str]
n_correct: int
difficulty: int
cells: frozenset[tuple[str, str]]
wrong_by_type: dict[str, int] = field(hash=False)
def build_video_records(preds: list[dict], signal_rows: list[dict]) -> list[VideoRecord]:
"""由全量 predictions 与诊断信号行构建全视频 VideoRecord 列表。
先按 video_id 聚合全部 predictions(覆盖全对、零信号视频),再叠加仅 tier=="T2"
的诊断信号为多样性格子与 wrong_by_type 计数。非 T2 信号行(T0/T1/uncertain
不计入格子与计数。
参数:
preds: 全量预测行,每行含 video_id / question_id / task_type / correct。
每视频含其全部题(不限于错题),correct 为布尔答对标记。
signal_rows: 诊断信号行,每行含 question_id / task_type / error_type / tier。
诊断只覆盖错题子集,正确题无对应信号行属正常,不视为错误。
返回:
全部视频的 VideoRecord 列表,按视频在 preds 中首次出现顺序排列。
无任何 T2 信号的视频其 cells 为空 frozenset、wrong_by_type 为空 dict。
实现细节:
signal_rows 的 question_id 若不在 preds 中则忽略(诊断可能滞后于当前预测集,
非数据损坏),不 fail-fast;缺失必需键则按 KeyError 直接暴露(不静默兜底)。
异常:
KeyError: preds 或 signal_rows 行缺少必需键(校验前置,防脏数据静默通过)。
"""
# Phase 1: 按 video_id 聚合 preds(保持首次出现顺序)。
signal_by_qid = {row["question_id"]: row for row in signal_rows}
aggregates: dict[str, dict] = {}
for pred in preds:
video_id = pred["video_id"]
bucket = aggregates.setdefault(
video_id, {"types": set(), "question_ids": [], "n_correct": 0}
)
bucket["types"].add(pred["task_type"])
bucket["question_ids"].append(pred["question_id"])
if pred["correct"]:
bucket["n_correct"] += 1
# Phase 2: 逐视频叠加 T2 信号为格子与 wrong_by_type。
records: list[VideoRecord] = []
for video_id, bucket in aggregates.items():
cells: set[tuple[str, str]] = set()
wrong_by_type: dict[str, int] = {}
for question_id in bucket["question_ids"]:
row = signal_by_qid.get(question_id)
if row is None or row["tier"] != "T2":
continue
task_type = row["task_type"]
cells.add(cell_of(task_type, row["error_type"]))
wrong_by_type[task_type] = wrong_by_type.get(task_type, 0) + 1
n_questions = len(bucket["question_ids"])
records.append(
VideoRecord(
video_id=video_id,
type_set=frozenset(bucket["types"]),
n_correct=bucket["n_correct"],
difficulty=n_questions - bucket["n_correct"],
cells=frozenset(cells),
wrong_by_type=wrong_by_type,
)
)
return records
@dataclass(frozen=True)
class SelectConfig:
"""贪心联合约束选择器的实验配置(科研配置,随实验扫动)。
字段:
n_trainval: trainval 目标视频数(多样性阶段的填充上限)。
floor_k: 各高信号 task_type 的 T2 defect 数下限(硬约束,floor 阶段满足)。
epsilon: test 相对全局的最大允许分布偏差(题型占比 / 难度画像两维,逐桶)。
reportable_types: 参与 ε 题型代表性校验的 task_type 集(长尾类型不入约束)。
seed: 预洗牌随机种子,仅用于打破等增益平局,保证同 config 同 videos 同解。
实现细节:
floor_k / reportable_types 为不可哈希容器,标 hash=False 排除出自动 __hash__
避免 frozen dataclass 被哈希时报错(本类不作为字典键,仅承载配置)。
"""
n_trainval: int
floor_k: dict[str, int] = field(hash=False)
epsilon: float
reportable_types: frozenset[str] | set[str] = field(hash=False)
seed: int
@dataclass(frozen=True)
class SplitAssignment:
"""视频级切分归属结果(交给 split_by_video_assignment 做题级切分)。
字段:
trainval: 进入 trainval 的 video_id 元组(按选择顺序,确定性)。
test: 补集视频的 video_id 元组(按 videos 原始顺序)。
"""
trainval: tuple[str, ...]
test: tuple[str, ...]
class InfeasibleSplitError(Exception):
"""floor 硬约束与 ε 守护死锁、无法在不破坏 test 代表性下满足 floor 时抛出。
fail loud(P5):不静默兜底、不随机塞题,直接暴露不可行并报告未达标类型。
"""
def derive_reportable_types(total_by_type: dict[str, int], report_floor: int) -> set[str]:
"""派生可 per-type 报告的 task_type 集(长尾处理:题数 ≥ report_floor 才报告)。
参数:
total_by_type: 各 task_type 的总题数(或代理承载数)。
report_floor: 报告门限,低于此的类型并入长尾、不单独报告也不入 ε 约束。
返回:
总题数 ≥ report_floor 的 task_type 集合。
"""
return {task_type for task_type, total in total_by_type.items() if total >= report_floor}
def _type_membership_fraction(records: list[VideoRecord], keys: set[str]) -> dict[str, float]:
"""计算各 task_type 在给定视频集中的承载占比(含该题型的视频数 / 总视频数)。
参数:
records: 视频记录子集(非空,调用方保证)。
keys: 需计算占比的 task_type 键集。
返回:
{task_type: 占比},占比 ∈ [0, 1]。
"""
total = len(records)
return {key: sum(1 for r in records if key in r.type_set) / total for key in keys}
def _difficulty_fraction(records: list[VideoRecord], buckets: set[int]) -> dict[int, float]:
"""计算各难度桶在给定视频集中的占比(难度 = 错题数)。
参数:
records: 视频记录子集(非空,调用方保证)。
buckets: 需计算占比的难度桶键集。
返回:
{难度桶: 占比},占比 ∈ [0, 1]。
"""
total = len(records)
return {bucket: sum(1 for r in records if r.difficulty == bucket) / total for bucket in buckets}
def _max_deviation(global_dist: dict, subset_dist: dict, keys: set) -> float:
"""逐键取全局与子集分布的最大绝对偏差(键集为空时约定为 0.0)。
参数:
global_dist: 全局分布(键 → 占比)。
subset_dist: 子集分布(键 → 占比)。
keys: 参与比较的键集。
返回:
逐键 |global - subset| 的最大值;keys 为空返回 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 _epsilon_ok(
test_video_records: list[VideoRecord],
videos_all: list[VideoRecord],
config: SelectConfig,
) -> bool:
"""校验 test 子集相对全局在题型占比与难度画像两维的偏差是否均 ≤ epsilon。
test 越简单则 headline 越虚高,故 test 必须保持代表性:逐 reportable 题型、逐难度桶
比较 test 与全局占比,任一维超 epsilon 即判不合格。
参数:
test_video_records: 候选 test 子集(trainval 补集)。
videos_all: 全部视频(全局分布基准)。
config: 选择配置,提供 epsilon 与 reportable_types。
返回:
两维最大偏差均 ≤ epsilon 为 Truetest 为空视为不合格返回 False。
"""
if not test_video_records:
return False
type_keys = set(config.reportable_types)
global_type = _type_membership_fraction(videos_all, type_keys)
subset_type = _type_membership_fraction(test_video_records, type_keys)
if _max_deviation(global_type, subset_type, type_keys) > config.epsilon:
return False
diff_keys = {r.difficulty for r in videos_all}
global_diff = _difficulty_fraction(videos_all, diff_keys)
subset_diff = _difficulty_fraction(test_video_records, diff_keys)
return _max_deviation(global_diff, subset_diff, diff_keys) <= config.epsilon
def _current_wrong_counts(selected: list[VideoRecord]) -> dict[str, int]:
"""聚合已选 trainval 视频的 T2 defect 计数(供 floor 达标判定)。
参数:
selected: 当前已进入 trainval 的视频记录。
返回:
{task_type: T2 defect 累计数}。
"""
counts: dict[str, int] = {}
for video in selected:
for task_type, wrong in video.wrong_by_type.items():
counts[task_type] = counts.get(task_type, 0) + wrong
return counts
def _unmet_floors(selected: list[VideoRecord], floor_k: dict[str, int]) -> dict[str, int]:
"""计算尚未达标的 floor 类型及其缺口(已达标类型不返回)。
参数:
selected: 当前已进入 trainval 的视频记录。
floor_k: 各高信号 task_type 的 defect 下限。
返回:
{task_type: 缺口数},仅含 current < floor 的类型;全达标返回空 dict。
"""
counts = _current_wrong_counts(selected)
return {
task_type: floor - counts.get(task_type, 0)
for task_type, floor in floor_k.items()
if counts.get(task_type, 0) < floor
}
def _floor_fill_count(video: VideoRecord, deficits: dict[str, int]) -> int:
"""计算某视频能填补的 floor 缺口槽数(逐类型取 min(defect, 缺口) 求和)。
参数:
video: 候选视频记录。
deficits: 各未达标类型的缺口。
返回:
该视频实际可填的槽数总和(0 表示对当前缺口无贡献)。
"""
return sum(
min(video.wrong_by_type.get(task_type, 0), deficit)
for task_type, deficit in deficits.items()
)
def _marginal_gain(video: VideoRecord, current_cells: set[tuple[str, str]]) -> int:
"""计算把某视频移入 trainval 的边际覆盖增益(新开的 T2 格子数)。
参数:
video: 候选视频记录。
current_cells: 当前 trainval 的 T2 格子并集。
返回:
video.cells 相对 current_cells 的新增格子数(去重)。
"""
return len(video.cells - current_cells)
def _prospective_test(pool: list[VideoRecord], candidate: VideoRecord) -> list[VideoRecord]:
"""构造"把候选移入 trainval 后"的 test 子集 = 当前剩余池去掉候选。
参数:
pool: 当前尚未进入 trainval 的视频(即当前 test 补集)。
candidate: 拟移入 trainval 的候选视频。
返回:
pool 去掉 candidate 后的视频列表。
"""
return [r for r in pool if r.video_id != candidate.video_id]
def _satisfy_floors(
selected: list[VideoRecord],
pool: list[VideoRecord],
videos_all: list[VideoRecord],
config: SelectConfig,
) -> None:
"""Floor 阶段:硬约束优先,逐步移入能填 floor 槽且不破 ε 的视频(就地改 selected/pool)。
每轮取未达标类型的缺口,候选 = 能填 ≥1 槽 且 移入后 test 仍满足 ε 的视频;候选为空即
死锁抛 InfeasibleSplitError;否则选填槽最多者(等槽数按预洗牌顺序取首个,确定性)。
n_trainval 是硬预算:floor 需求超出预算(尚有缺口却已达 n_trainval)也判不可行 fail loud
保证返回的 trainval 永不超过 n_trainval(不因硬约束悄悄超额、挤占 test)。
参数:
selected: 当前 trainval(就地追加)。
pool: 当前剩余池 = test 补集(就地移除)。
videos_all: 全部视频(ε 全局基准)。
config: 选择配置。
异常:
InfeasibleSplitError: 存在未达标类型但无候选可在不破 ε 下填补,
或 floor 需求超过 n_trainval 预算。
"""
while True:
deficits = _unmet_floors(selected, config.floor_k)
if not deficits:
return
if len(selected) >= config.n_trainval:
raise InfeasibleSplitError(
f"floor 需求超过 n_trainval={config.n_trainval} 预算,剩余缺口: {dict(deficits)}"
)
candidates = [
video
for video in pool
if _floor_fill_count(video, deficits) > 0
and _epsilon_ok(_prospective_test(pool, video), videos_all, config)
]
if not candidates:
raise InfeasibleSplitError(
f"floor 无法在 ε≤{config.epsilon} 下满足,未达标类型缺口: {dict(deficits)}"
)
pick = max(candidates, key=lambda video: _floor_fill_count(video, deficits))
selected.append(pick)
pool.remove(pick)
def _maximize_diversity(
selected: list[VideoRecord],
pool: list[VideoRecord],
videos_all: list[VideoRecord],
config: SelectConfig,
) -> None:
"""多样性阶段:submodular 贪心,按边际覆盖增益降序填至 n_trainval(就地改 selected/pool)。
每轮对剩余视频算新开格子数,按 -增益稳定排序(等增益按预洗牌顺序),取第一个移入后 test
仍满足 ε 的视频;若无任一视频可加而不破 ε,则停并记 warning(欠额,不静默不报错)。
参数:
selected: 当前 trainval(就地追加)。
pool: 当前剩余池 = test 补集(就地移除)。
videos_all: 全部视频(ε 全局基准)。
config: 选择配置。
"""
while len(selected) < config.n_trainval:
if not pool:
logger.warning(
"多样性阶段剩余池耗尽,trainval 欠额: {}/{}", len(selected), config.n_trainval
)
return
current_cells = set().union(*(v.cells for v in selected)) if selected else set()
ranked = sorted(pool, key=lambda video: -_marginal_gain(video, current_cells))
pick = next(
(
video
for video in ranked
if _epsilon_ok(_prospective_test(pool, video), videos_all, config)
),
None,
)
if pick is None:
logger.warning(
"多样性阶段 ε 守护阻断全部候选,trainval 欠额: {}/{}",
len(selected),
config.n_trainval,
)
return
selected.append(pick)
pool.remove(pick)
def select_split(videos: list[VideoRecord], *, config: SelectConfig) -> SplitAssignment:
"""贪心联合约束视频级切分:floor 硬约束先满足、多样性覆盖后最大化、ε 守护 test 代表性。
核心洞察:全数据集错题总数固定,越把信号塞 trainval、test 越简单、headline 越虚高,
故 test 必须保持代表性(ε 约束),trainval 只靠 floor + 多样性覆盖富集,不从 test 偷难题。
两阶段贪心(均带 ε 守护):先 Floor 阶段满足各高信号类型 defect 下限(不可行 fail loud),
再多样性阶段按边际覆盖增益填至 n_trainval(欠额记 warning)。test = trainval 补集。
确定性:入场用 random.Random(seed) 对视频列表做一次预洗牌,此后 max / 稳定排序仅取首个,
seed 只打破等增益 / 等槽数平局;同 config 同 videos → 同结果。
参数:
videos: 全部视频记录(Task 7 build_video_records 产物)。
config: 选择配置(关键字传入,含 n_trainval / floor_k / epsilon / reportable_types / seed)。
返回:
SplitAssignmenttrainval 按选择顺序、test 按 videos 原始顺序。
异常:
InfeasibleSplitError: videos 为空、floor 与 ε 死锁无法满足、
或 floor 需求超过 n_trainval 预算。
"""
if not videos:
raise InfeasibleSplitError("videos 为空,无法执行切分")
rng = random.Random(config.seed)
pool = list(videos)
rng.shuffle(pool)
selected: list[VideoRecord] = []
_satisfy_floors(selected, pool, videos, config)
_maximize_diversity(selected, pool, videos, config)
trainval_ids = {video.video_id for video in selected}
trainval = tuple(video.video_id for video in selected)
test = tuple(video.video_id for video in videos if video.video_id not in trainval_ids)
return SplitAssignment(trainval=trainval, test=test)
+51
View File
@@ -0,0 +1,51 @@
"""把 predictions.steps_json 转成 RunLog.get_traces 的行形。
infer_adhoc 的 traces 表为空,轨迹存于 steps_json{thought, tool_call, tool_output})。
诊断管线经 get_traces 消费轨迹,故需此确定性转换适配。
"""
from __future__ import annotations
import json
from typing import Any
def steps_json_to_trace_rows(
video_id: str, question_id: str, steps_json: str
) -> list[dict[str, Any]]:
"""将单题 steps_json 解析为 trace 行列表(step 从 0 递增)。
参数:
video_id: 视频 ID。
question_id: 题 ID。
steps_json: predictions.steps_json 原文(JSON 数组字符串)。
返回:
行字典列表,字段对齐 traces 表 schema;空/空数组返回 []。
关键实现细节:
- 工具名读 tool_call.toolinfer_adhoc 真实字段),对极少数历史
数据的 name 做 back-compat 回退。
- steps_json 非 JSON 数组时直接报错,不做兜底掩盖。
"""
if not steps_json or not steps_json.strip():
return []
steps = json.loads(steps_json)
if not isinstance(steps, list):
raise ValueError(f"steps_json 非数组: {question_id}")
rows: list[dict[str, Any]] = []
for i, s in enumerate(steps):
call = s.get("tool_call") or {}
rows.append(
{
"video_id": video_id,
"question_id": question_id,
"step": i,
# infer_adhoc 用 "tool"back-compat 兼容极少数 "name"
"tool_name": call.get("tool", call.get("name")),
"tool_args": call.get("args", {}),
"tool_output": s.get("tool_output"),
"thought": s.get("thought"),
}
)
return rows
+28 -4
View File
@@ -190,6 +190,9 @@ def init_seed(
baseline_run_id: str,
parent: str | None,
description: str,
*,
pools_json: Path | None = None,
split_manifest: Path | None = None,
) -> Path:
"""在 store/seeds/<name> 写一个种子:权重 + baseline.db + seed.json。
@@ -202,6 +205,10 @@ def init_seed(
baseline_run_id: 全量记录的 run_idfresh 时注入 build_pools。
parent: 来源(initial 为 None)。
description: 人类可读说明。
pools_json: 可选,冻结切分 pools.json 源路径;提供时拷入 seed 目录,
供 fresh 训练时携带冻结切分进 workspace(见 init_workspace_from_seed)。
split_manifest: 可选,冻结切分 split_manifest.json 源路径;提供时拷入 seed
目录,供加载时校验 pools.json 内容指纹(pools_sha256)。
返回:
种子目录路径。
@@ -216,6 +223,10 @@ def init_seed(
shutil.copytree(skills_dir, seed_dir / "skills")
shutil.copytree(prompts_dir, seed_dir / "prompts")
shutil.copy2(baseline_db, seed_dir / "baseline.db")
if pools_json is not None:
shutil.copy2(pools_json, seed_dir / "pools.json")
if split_manifest is not None:
shutil.copy2(split_manifest, seed_dir / "split_manifest.json")
(seed_dir / "seed.json").write_text(
json.dumps(
{
@@ -266,7 +277,9 @@ def read_seed(store_dir: Path, name: str) -> dict:
return json.loads(seed_json.read_text())
def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None:
def extract_run_db(
src_db: Path, dst_db: Path, run_id: str, *, dedupe_per_question: bool = False
) -> None:
"""从 src_db 抽出某 run_id 的 _runs + predictions 行,写一个最小 db(种子 baseline.db)。
用源表的**原始 CREATE 语句**重建目标表,保留主键/列类型/约束——
@@ -277,6 +290,9 @@ def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None:
src_db: 源 harness.db。
dst_db: 目标 db(不得已存在)。
run_id: 要抽取的 run。
dedupe_per_question: True 时 predictions 表每 question_id 仅保留 rowid 最小
的首行(对齐 canonical「每 question_id 取第一行 ORDER BY rowid」口径,
902→900)。_runs 表不受影响。
异常:
RuntimeError: 源中无该表或无该 run 的行。
@@ -294,9 +310,17 @@ def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None:
dst.execute(create_sql[0])
cols = [r[1] for r in src.execute(f"PRAGMA table_info({table})")]
col_sql = ", ".join(cols)
rows = src.execute(
f"SELECT {col_sql} FROM {table} WHERE run_id=?", (run_id,)
).fetchall()
if table == "predictions" and dedupe_per_question:
rows = src.execute(
f"SELECT {col_sql} FROM {table} WHERE run_id=? "
"AND rowid IN (SELECT MIN(rowid) FROM predictions "
"WHERE run_id=? GROUP BY question_id)",
(run_id, run_id),
).fetchall()
else:
rows = src.execute(
f"SELECT {col_sql} FROM {table} WHERE run_id=?", (run_id,)
).fetchall()
if not rows:
raise RuntimeError(f"{table} 中无 run_id={run_id} 的行")
ph = ", ".join("?" * len(cols))
+89
View File
@@ -0,0 +1,89 @@
"""诊断侧树读取适配:把嵌套 tree.json 展平成诊断消费的扁平 nodes dict。
诊断编排(core/evolution/diagnose.py)期望 tree_data 形如
{"nodes": {node_id: {card, level, time_range}}},但 TRM5 建树产物
store/videos/<vid>/tree.json 是嵌套 {"metadata","roots":[...]}。本模块递归展平,
接通 TRM4→TRM5 迁移时断掉的 ground_truth 加载环。
不走 TreeIndex 对象层:仅 L1Node 有 to_dictapp/tree/index.py:260),L2/L3 为其内部闭包,
且 to_dict 输出无 level、L3 用 timestamp 无 time_range。直接遍历 json 更省且零改建树模块。
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
def load_tree_nodes(store_dir: Path, video_id: str) -> dict[str, Any]:
"""加载单视频 tree.json 并展平成扁平 nodes dict。
参数:
store_dir: store 根目录(含 videos/<video_id>/tree.json)。
video_id: 视频标识。
返回:
{"nodes": {node_id: {"card": dict, "level": int, "time_range": list}}}。
异常:
FileNotFoundError: tree.json 不存在(沿用 factory.py fail-loud 先例)。
ValueError: roots 非 list 或为空、节点缺 id、或节点既无 time_range 又无 timestamp。
关键实现:
level 由遍历深度赋值(root=1/child=2/孙=3),不解析 node_id——node_id 累积式
..._L1_..._L2_..._L3_)用正则首匹配会把 L2/L3 误判成 1。
L3 无 time_range,用 timestamp 合成 [t, t]。
"""
tree_path = store_dir / "videos" / video_id / "tree.json"
if not tree_path.exists():
raise FileNotFoundError(f"树索引文件不存在: {tree_path}(诊断需真实树,P5 fail loud")
tree = json.loads(tree_path.read_text(encoding="utf-8"))
roots = tree.get("roots")
if not isinstance(roots, list) or not roots:
raise ValueError(f"树无有效 roots: {tree_path}")
nodes: dict[str, Any] = {}
def _walk(node: dict[str, Any], level: int) -> None:
node_id = node.get("id")
if not isinstance(node_id, str) or not node_id:
raise ValueError(f"节点缺 id: {tree_path}")
time_range = node.get("time_range")
if time_range is None:
ts = node.get("timestamp")
if ts is None:
raise ValueError(
f"节点既无 time_range 又无 timestamp(树损坏): {node_id} in {tree_path}"
)
time_range = [ts, ts]
nodes[node_id] = {
"card": node.get("card", {}),
"level": level,
"time_range": time_range,
}
for child in node.get("children", []) or []:
_walk(child, level + 1)
for root in roots:
_walk(root, 1)
return {"nodes": nodes}
def load_tree_data_for_videos(store_dir: Path, video_ids: list[str]) -> dict[str, Any]:
"""按一组 video_id 去重加载展平树,供诊断按 video 注入。
参数:
store_dir: store 根目录。
video_ids: 视频标识列表(可含重复,内部按首次出现顺序去重)。
返回:
{video_id: {"nodes": {...}}}。
异常:
同 load_tree_nodes(任一视频树缺失/无效即 fail-loud)。
"""
return {vid: load_tree_nodes(store_dir, vid) for vid in dict.fromkeys(video_ids)}
+607 -339
View File
File diff suppressed because it is too large Load Diff
+824
View File
@@ -0,0 +1,824 @@
"""结果驱动视频级切分的自包含两阶段 CLI 入口。
把整条离线管线的编排从 shell 搬进 Python:一次调用内联串起
Phase 1 离线诊断(run_baseline_diagnosisLLM 重活,断点续跑幂等)→
Phase 2 冻结切分(build_split,纯 code-controlled,产出 pools.json + manifest)→
McNemar 功效护栏(validation 池错题数达阈校验)。
复现锚点约定(C-2):
- pools.json 的内容(+ seed + diag_fingerprint)是切分的**复现锚点**——相同输入
产出字节级相同的 pools.json 与 pools_sha256。
- manifest 的 generated_at 是**溯源元数据**,非复现锚点:真实运行默认盖真实 UTC
now(记录本次切分何时产出),但可用 `--generated-at <ISO>` 显式固定,以对
manifest 做字节级复现比对。write_manifest 库内不调 datetime.now,时间戳一律由
本 CLI 传入。
设计要点:
- 诊断口径指纹 = (诊断 prompt 版本, 模型名, git 短 SHA) 三分量合成,隔离不同
诊断配置的信号;换 prompt / 模型 / 代码实现即换指纹,旧信号不被覆盖。
- 真实依赖组装参考 app/harness/runner.py::_run_diagnosisGovernedLLMClient
(search llm, thinking=True) + RunLogImpl(harness.db) + VersionedSkillStore +
DiagnosePrompts(项目根 prompts/) + tree_data 按 wrong_ids 涉及 video 预加载
store/videos/<vid>/tree.json 展平)。
- 缺 .env / config 关键项一律 fail loud(P5),绝不静默兜底。
- `--dry-run` 用假 deps 跑通两阶段 wiring 不真调 LLM,打印将执行的步骤 + 指纹,
用于校验装配正确性(对齐 CLAUDE.md §2.5 smoke test)。
编排函数(run_pipeline)通过依赖注入接收 DiagnosisDeps / signal_store / wrong_ids /
questions / canonical_preds,便于单测用假实现替换、不触真实 LLM 与 harness.db。
其中 canonical_preds 供 Phase 0 补 INFRA / 空预测错题的 T0 信号(这些题不进诊断)。
"""
from __future__ import annotations
import argparse
import asyncio
import datetime
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
from loguru import logger
from app.harness.baseline_diagnosis import DiagnosisDeps, run_baseline_diagnosis
from app.harness.build_split import (
SplitBuildConfig,
SplitBuildResult,
build_split,
load_canonical_predictions,
)
from app.harness.split_selection import diag_fingerprint
from app.question_gen.loader import load_benchmark
from core.evolution.types import DiagnosisSignalRow
if TYPE_CHECKING:
from app.harness.pools import Pools
from core.evolution.protocols import DiagnosisSignalStore
from core.types import GeneratedQuestion
# 与 core.evolution.diagnose._INFRA_STOP_REASONS 对齐:执行/解析层失败排除出可诊断错题。
_INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})
# 工程路径默认值(少变;可经 CLI 单次覆盖)。诊断信号表建在 harness.db。
_DEFAULT_HARNESS_DB = Path("workspaces/default/harness.db")
_DEFAULT_QUESTIONS_DIR = Path("store/questions/benchmarks/Video-MME")
_DEFAULT_OUT_DIR = Path("workspaces/video-split")
_DEFAULT_STORE_DIR = Path("store") # tree.json 在 store/videos/<vid>/
# ---------------------------------------------------------------------------
# 配置解析(fail loud
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class VideoSplitConfig:
"""结果驱动视频级切分的科研旋钮快照(从 config/video_split.yaml 解析)。
字段:
baseline_run_id: 基线 run 标识(错题诊断与切分依据)。
n_trainval: trainval 目标视频数(多样性阶段填充上限)。
epsilon: test 相对全局最大允许分布偏差(题型 / 难度两维)。
report_floor: per-type 报告门限,题数 ≥ 此值的 task_type 才入 ε 约束。
val_wrong_min: validation 池最少错题数(McNemar 功效阈;0=不检查)。
val_ratio: validation 占 trainval 视频组总数的比例。
seed: 贪心选择器预洗牌 + 视频组题级切分种子。
floor_k: 各高信号 task_type 的 T2 defect 下限(硬约束)。
prompt_version: 诊断 prompt 版本标识(指纹分量)。
model: 执行诊断的模型名(指纹分量)。
"""
baseline_run_id: str
n_trainval: int
epsilon: float
report_floor: int
val_wrong_min: int
val_ratio: float
seed: int
floor_k: dict[str, int]
prompt_version: str
model: str
def _require(section: dict[str, Any], keys: tuple[str, ...], where: str) -> None:
"""校验 section 含全部必填键,缺任一即 fail loud(P5,不静默兜底)。
参数:
section: 待校验的配置子字典。
keys: 必填键元组。
where: 出错信息中标注的段名(如 "video_split")。
异常:
SystemExit: 存在缺失键。
"""
missing = [k for k in keys if k not in section]
if missing:
raise SystemExit(f"config {where} 段缺关键项 {missing},无法运行(P5 fail loud")
def parse_config(raw: dict[str, Any]) -> VideoSplitConfig:
"""把 yaml 原始字典解析为 VideoSplitConfig,缺关键项 fail loud。
参数:
raw: yaml.safe_load 的顶层字典,需含 video_split / diag 两段。
返回:
VideoSplitConfig 冻结快照。
异常:
SystemExit: 缺 video_split / diag 段或段内关键项。
"""
if "video_split" not in raw or "diag" not in raw:
raise SystemExit("config 缺 video_split / diag 段,无法运行(P5 fail loud")
vs = raw["video_split"]
dg = raw["diag"]
_require(
vs,
(
"baseline_run_id",
"n_trainval",
"epsilon",
"report_floor",
"val_wrong_min",
"val_ratio",
"seed",
"floor_k",
),
"video_split",
)
_require(dg, ("prompt_version", "model"), "diag")
return VideoSplitConfig(
baseline_run_id=vs["baseline_run_id"],
n_trainval=vs["n_trainval"],
epsilon=vs["epsilon"],
report_floor=vs["report_floor"],
val_wrong_min=vs["val_wrong_min"],
val_ratio=vs["val_ratio"],
seed=vs["seed"],
floor_k=dict(vs["floor_k"]),
prompt_version=dg["prompt_version"],
model=dg["model"],
)
def load_config(config_path: Path) -> VideoSplitConfig:
"""读取并解析 video_split yaml 配置文件(缺文件 / 关键项 fail loud)。
参数:
config_path: yaml 配置路径。
返回:
VideoSplitConfig。
异常:
SystemExit: 文件不存在或缺关键项。
"""
if not config_path.exists():
raise SystemExit(f"config 文件不存在: {config_path}P5 fail loud")
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
return parse_config(raw)
def git_short_sha() -> str:
"""取当前 git 短 SHA 作为诊断口径指纹的代码分量(诊断代码变则指纹变)。
返回:
git rev-parse --short HEAD 输出(去空白)。
异常:
SystemExit: 非 git 仓库或 git 不可用(fail loud,指纹不可缺分量)。
"""
try:
out = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
raise SystemExit(f"无法获取 git 短 SHA 作为诊断代码版本: {exc}P5 fail loud") from exc
sha = out.stdout.strip()
if not sha:
raise SystemExit("git rev-parse --short HEAD 返回空,诊断指纹缺代码分量(P5 fail loud")
return sha
# ---------------------------------------------------------------------------
# 真实依赖组装(参考 runner.py::_run_diagnosis
# ---------------------------------------------------------------------------
class _DiagLLMSettings:
"""诊断 LLM 的工程配置(从 .env 读取 search llm 凭证 + 韧性旋钮)。
仅承载诊断所需字段(搜索 LLM = 诊断 judge),不复用 main.InfraSettings 以免
构造整套适配器(embed / vlm)的重活;缺关键凭证 fail loud。
"""
def __init__(self) -> None:
from pydantic_settings import BaseSettings, SettingsConfigDict
class _Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
search_llm_model: str = ""
search_llm_base_url: str = ""
search_llm_api_key: str = ""
redis_url: str = ""
redis_cache_ttl: int = 86400
llm_timeout: float = 300.0
llm_max_retries: int = 3
llm_retry_base_delay: float = 20.0
llm_retry_max_delay: float = 120.0
llm_circuit_breaker_threshold: int = 48
llm_circuit_breaker_cooldown: float = 60.0
llm_ttft_timeout: float = 30.0
llm_inter_token_timeout: float = 15.0
self._s = _Settings()
def __getattr__(self, name: str) -> Any:
return getattr(self._s, name)
def _build_redis_cache(settings: Any) -> Any | None:
"""按 .env redis_url 构建响应缓存(不可用则降级 None,与 main 一致)。"""
if not settings.redis_url:
return None
from adapters.redis_cache import RedisResponseCache, _resolve_cache_ttl
# 配置校验 fail-loud(不属于 Redis 连接故障,不得被下方降级 except 吞掉)
ttl_s = _resolve_cache_ttl(settings.redis_cache_ttl)
try:
import redis.asyncio as aioredis
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
return RedisResponseCache(redis=redis_client, ttl_s=ttl_s)
except Exception:
logger.warning("Redis 缓存不可用,诊断降级为无缓存模式")
return None
def build_diagnosis_deps(
*,
harness_db: Path,
store_dir: Path,
video_ids: list[str],
concurrency: int,
expected_model: str,
) -> DiagnosisDeps:
"""组装 Phase 1 诊断的真实依赖束(GovernedLLMClient + RunLogImpl + prompts)。
与 runner.py::_run_diagnosis 对齐:search LLMthinking=True)作诊断 judge
RunLogImpl 只读读取 harness.db 的 predictions/tracesVersionedSkillStore 读技能,
DiagnosePrompts 从项目根 prompts/ 加载,tree_data 按 video_ids 从
store/videos/<vid>/tree.json 展平预加载(诊断需真实树,缺失即 fail-loud)。
参数:
harness_db: harness.db 路径(诊断读预测 + 信号落库同库)。
store_dir: store 根目录(含 videos/<vid>/tree.json)。
video_ids: wrong_ids 涉及的 video 标识列表(可含重复,内部去重加载树)。
concurrency: 诊断并发上限。
expected_model: config.diag.model(诊断口径指纹的模型分量)。必须与 .env
SEARCH_LLM_MODEL 一致——指纹里的 model 与实际诊断所用 model 不一致会让
信号以错误模型指纹落库,破坏可复现 / resume / 口径隔离,故此处 fail loud。
返回:
DiagnosisDeps 冻结依赖束。
异常:
SystemExit: .env 缺 search LLM 凭证(model / base_url / api_key 任一为空),
或 config.diag.model 与 .env SEARCH_LLM_MODEL 不一致(指纹漂移防护)。
FileNotFoundError / ValueError: video_ids 中任一 video 的 tree.json 缺失或
无效(load_tree_data_for_videos fail-loud,诊断需真实树)。
"""
from adapters.breaker import CircuitBreaker
from adapters.llm import GovernedLLMClient
from adapters.telemetry import SQLiteTelemetryRecorder
from app.harness.log import RunLogImpl
from app.harness.workspace import VersionedSkillStore
settings = _DiagLLMSettings()
if not (
settings.search_llm_model and settings.search_llm_base_url and settings.search_llm_api_key
):
raise SystemExit(
"诊断 LLM 凭证缺失:.env 需配置 SEARCH_LLM_MODEL / SEARCH_LLM_BASE_URL / "
"SEARCH_LLM_API_KEYP5 fail loud,不静默兜底)"
)
if expected_model != settings.search_llm_model:
raise SystemExit(
"诊断模型指纹漂移:config.diag.model="
f"{expected_model!r} 与 .env SEARCH_LLM_MODEL={settings.search_llm_model!r} "
"不一致;指纹里的 model 必须等于实际诊断所用 modelP5 fail loud"
"请对齐 config/video_split.yaml diag.model 与 .env SEARCH_LLM_MODEL"
)
telemetry_db = Path("logs/telemetry.db")
telemetry_db.parent.mkdir(parents=True, exist_ok=True)
telemetry = SQLiteTelemetryRecorder(telemetry_db)
breaker = CircuitBreaker(
fail_threshold=max(settings.llm_circuit_breaker_threshold, 1),
cooldown_s=settings.llm_circuit_breaker_cooldown,
)
llm = GovernedLLMClient(
model=settings.search_llm_model,
base_url=settings.search_llm_base_url,
api_key=settings.search_llm_api_key,
provider=settings.search_llm_model.split("-")[0],
thinking=True,
breaker=breaker,
cache=_build_redis_cache(settings),
telemetry=telemetry,
timeout_s=settings.llm_timeout,
ttft_timeout_s=settings.llm_ttft_timeout,
inter_token_timeout_s=settings.llm_inter_token_timeout,
max_retries=settings.llm_max_retries,
retry_base_delay_s=settings.llm_retry_base_delay,
retry_max_delay_s=settings.llm_retry_max_delay,
)
from app.harness.tree_nodes import load_tree_data_for_videos
return DiagnosisDeps(
run_log=RunLogImpl(str(harness_db)),
llm=llm,
skill_store=VersionedSkillStore(_diagnosis_skills_dir()),
prompts=_load_diagnose_prompts(),
tree_data=load_tree_data_for_videos(store_dir, video_ids),
concurrency=concurrency,
)
def _diagnosis_skills_dir() -> Path:
"""诊断用技能目录:种子 store 的当前技能版本(诊断读技能遵从判定)。
诊断只读技能内容判断"是否遵从技能",用 store 种子 v1 即可(与基线 run 一致)。
"""
return Path("store/skills/v1")
def _load_diagnose_prompts() -> Any:
"""加载诊断模板束(从项目根 prompts/ 读取;与 runner._load_diagnose_prompts 一致)。"""
from core.evolution.types import DiagnosePrompts
def _read(name: str) -> str:
p = Path("prompts") / name
if not p.exists():
raise FileNotFoundError(f"缺进化/诊断模板: {p}(请从 TRM4 迁移或检查 prompts/")
return p.read_text(encoding="utf-8")
return DiagnosePrompts(
defect_vs_lapse=_read("defect_vs_lapse.md"),
reasoning_sub=_read("reasoning_sub.md"),
span_eval_system=_read("span_eval_system.md"),
missed_nodes=_read("missed_nodes.md"),
skill_adherence=_read("skill_adherence.md"),
confirmation_bias=_read("confirmation_bias.md"),
evidence_sufficiency=_read("evidence_sufficiency.md"),
)
def select_diagnosable_wrong_ids(preds: list[dict]) -> list[str]:
"""从 canonical 预测筛出可诊断错题 question_id(保序)。
可诊断错题判据:预测非空 且 stop_reason 非 INFRAerror / parse_error)且
归一后预测 != 答案。INFRA / 空预测错题不进 wrong_ids——它们改由
persist_infra_t0_rows 直接落 T0run_diagnosis 内部也会二次排除同类题)。
参数:
preds: load_canonical_predictions 产出的 canonical 预测行(已按 qid 去重)。
返回:
可诊断错题 question_id 列表(保 preds 顺序)。
"""
wrong_ids: list[str] = []
for pred in preds:
prediction = (pred["prediction"] or "").strip()
if not prediction or pred["stop_reason"] in _INFRA_STOP_REASONS:
continue
if not pred["correct"]:
wrong_ids.append(pred["question_id"])
return wrong_ids
def persist_infra_t0_rows(
store: DiagnosisSignalStore,
preds: list[dict],
baseline_run_id: str,
diag_fingerprint: str,
) -> int:
"""把非正确且 INFRA / 空预测的错题以 T0 信号行 upsert 落库(幂等)。
这些题(stop_reason ∈ {error, parse_error} 或预测为空)从不进入 run_diagnosis
(筛选时被前置排除),故其 T0 信号必须在此单独补齐——否则 signal store 缺这些行,
tier 分布 / manifest 不完整(计划要求 4 个 INFRA 空预测错题 → T0)。
投影口径与 baseline_diagnosis 的 INFRA 投影一致:infra=True、tier="T0"
error_type / cause_category / evolution_target 均 None、degraded=False
video_id / task_type 从 canonical 预测取。store.upsert 按主键
(question_id, baseline_run_id, diag_fingerprint) 幂等,重复调用零副作用。
参数:
store: 诊断信号存储端口(与诊断落库同一 store)。
preds: load_canonical_predictions 产出的 canonical 预测行。
baseline_run_id: 基线 run 标识(信号行主键之一)。
diag_fingerprint: 诊断口径指纹(信号行主键之一)。
返回:
落库的 T0 行数(供日志)。
"""
count = 0
for pred in preds:
prediction = (pred["prediction"] or "").strip()
is_infra_or_empty = pred["stop_reason"] in _INFRA_STOP_REASONS or not prediction
if pred["correct"] or not is_infra_or_empty:
continue
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=None,
cause_category=None,
tier="T0",
evolution_target=None,
degraded=False,
infra=True,
session_id=None,
)
)
count += 1
return count
def load_questions_by_id(questions_dir: Path) -> dict[str, GeneratedQuestion]:
"""加载 benchmark 全部题并建 question_id → GeneratedQuestion 映射。
覆盖 wrong_ids 与 run_diagnosis 返回的全部 infra/degraded 题(取 video_id/task_type)。
参数:
questions_dir: benchmark 题库目录。
返回:
question_id → GeneratedQuestion 映射。
"""
return {q.question_id: q for q in load_benchmark(questions_dir)}
# ---------------------------------------------------------------------------
# McNemar 功效护栏
# ---------------------------------------------------------------------------
def check_mcnemar_power(pools: Pools, val_wrong_min: int) -> int:
"""校验 validation 池错题数达 McNemar 功效阈,不足即 fail loud。
val_wrong_min 已前置到 build_split 内的切分保证功效(不足即从 diag 换入低 T2
错题组补足,耗尽 fail-loud);本函数作切分冻结后的冗余最终确认:val 错题数 < 阈
→ 验证信号不足以支撑可靠比较。
参数:
pools: 冻结三池(含 validation 与 correctness)。
val_wrong_min: 最少错题数阈(0 = 不检查)。
返回:
validation 池实际错题数(供日志)。
异常:
SystemExit: val_wrong_min > 0 且 val 错题数 < 阈(P5 fail loud,不静默放行)。
"""
val_wrong = sum(1 for q in pools.validation if not pools.correctness[q.question_id])
if val_wrong_min > 0 and val_wrong < val_wrong_min:
raise SystemExit(
f"validation 池错题数 {val_wrong} < val_wrong_min={val_wrong_min}"
"验证信号不足以支撑可靠比较(McNemar 检验功效不够)。"
"请放大 val_ratio / 调整旋钮后重跑,勿静默放行。"
)
return val_wrong
# ---------------------------------------------------------------------------
# 两阶段编排(依赖注入,便于单测)
# ---------------------------------------------------------------------------
async def run_pipeline(
*,
config: VideoSplitConfig,
fingerprint: str,
diagnosis_deps: DiagnosisDeps,
signal_store: DiagnosisSignalStore,
wrong_ids: list[str],
questions: dict[str, GeneratedQuestion],
canonical_preds: list[dict],
harness_db: Path,
questions_dir: Path,
out_dir: Path,
generated_at: str,
force: bool = False,
retry_uncertain: bool = False,
) -> SplitBuildResult:
"""内联三阶段:Phase 0 INFRA T0 补录 → Phase 1 诊断 → Phase 2 冻结切分 → McNemar 护栏。
参数:
config: 科研旋钮快照。
fingerprint: 诊断口径指纹(已合成,作诊断信号主键之一)。
diagnosis_deps: Phase 1 诊断依赖束(真实或假实现)。
signal_store: 诊断信号存储端口(Phase 0/1 写、Phase 2 读)。
wrong_ids: 待诊断的可诊断错题 question_id 列表。
questions: question_id → GeneratedQuestion 映射。
canonical_preds: canonical 预测行(Phase 0 从中筛 INFRA / 空预测错题补 T0)。
harness_db: harness.db 路径(Phase 2 读 canonical 预测)。
questions_dir: benchmark 题库目录(Phase 2 加载题库切池)。
out_dir: 冻结产物目录(pools.json + split_manifest.json)。
generated_at: 生成时间戳(ISO 字符串,由调用方传入;见模块 C-2 复现锚点约定)。
force: 覆盖已存在冻结产物开关,透传给 build_split(False 时已存在即报错)。
retry_uncertain: 透传给 run_baseline_diagnosis,令已落 uncertain 题被重新诊断。
返回:
SplitBuildResult(冻结三池 + manifest + assignment)。
"""
# Phase 0: INFRA / 空预测错题补 T0(这些题不进诊断,须单独落库保证 tier 分布/manifest 完整)。
n_t0 = persist_infra_t0_rows(signal_store, canonical_preds, config.baseline_run_id, fingerprint)
logger.info("Phase 0 INFRA T0 补录:落库 {} 行(INFRA / 空预测错题不进诊断)", n_t0)
# Phase 1: 离线诊断(断点续跑幂等:done_question_ids 已完成题跳过)。
logger.info(
"Phase 1 离线诊断:baseline={} 待诊断错题 {}", config.baseline_run_id, len(wrong_ids)
)
await run_baseline_diagnosis(
baseline_run_id=config.baseline_run_id,
diag_fingerprint=fingerprint,
wrong_ids=wrong_ids,
questions=questions,
store=signal_store,
deps=diagnosis_deps,
retry_uncertain=retry_uncertain,
)
# Phase 2: 冻结切分(读诊断信号 → 贪心选择 → 视频组原子切三池 → 冻结 + 六条断言)。
out_dir.mkdir(parents=True, exist_ok=True)
logger.info("Phase 2 冻结切分:out={}", out_dir)
result = build_split(
db_path=harness_db,
baseline_run_id=config.baseline_run_id,
signal_store=signal_store,
diag_fingerprint=fingerprint,
questions_dir=questions_dir,
config=SplitBuildConfig(
n_trainval=config.n_trainval,
floor_k=config.floor_k,
epsilon=config.epsilon,
report_floor=config.report_floor,
select_seed=config.seed,
val_ratio=config.val_ratio,
split_seed=config.seed,
val_wrong_min=config.val_wrong_min,
),
out_path=out_dir / "pools.json",
manifest_path=out_dir / "split_manifest.json",
generated_at=generated_at,
force=force,
)
# McNemar 功效护栏(build_split 契约外的 capstone 层校验)。
val_wrong = check_mcnemar_power(result.pools, config.val_wrong_min)
logger.info(
"切分冻结完成:pools={} manifest={} val错题={}/{}(阈)",
out_dir / "pools.json",
out_dir / "split_manifest.json",
val_wrong,
config.val_wrong_min,
)
return result
# ---------------------------------------------------------------------------
# 真实执行 / dry-run 入口
# ---------------------------------------------------------------------------
def _resolve_paths(args: argparse.Namespace) -> tuple[Path, Path, Path, Path]:
"""解析 harness_db / questions_dir / out_dir / store_dirCLI 覆盖默认工程路径)。"""
harness_db = args.harness_db or _DEFAULT_HARNESS_DB
questions_dir = args.questions_dir or _DEFAULT_QUESTIONS_DIR
out_dir = args.out_dir or _DEFAULT_OUT_DIR
store_dir = args.store_dir or _DEFAULT_STORE_DIR
return harness_db, questions_dir, out_dir, store_dir
def _execute_real(config: VideoSplitConfig, fingerprint: str, args: argparse.Namespace) -> None:
"""真实执行两阶段管线:组装真实 deps、读错题、跑诊断 + 冻结切分。"""
harness_db, questions_dir, out_dir, store_dir = _resolve_paths(args)
if not harness_db.exists():
raise SystemExit(f"harness.db 不存在: {harness_db}P5 fail loud")
canonical_preds = load_canonical_predictions(harness_db, config.baseline_run_id)
wrong_ids = select_diagnosable_wrong_ids(canonical_preds)
questions = load_questions_by_id(questions_dir)
video_ids: list[str] = []
for qid in wrong_ids:
q = questions.get(qid)
if q is None:
raise SystemExit(
f"wrong_id {qid!r} 不在 questions_dir 题库中"
"baseline predictions 与题库不匹配,P5 fail loud"
)
video_ids.append(q.video_id)
deps = build_diagnosis_deps(
harness_db=harness_db,
store_dir=store_dir,
video_ids=video_ids,
concurrency=args.concurrency,
expected_model=config.model,
)
# generated_at:默认盖真实 UTC now(溯源用),--generated-at 可显式固定以复现(C-2)。
generated_at = args.generated_at or datetime.datetime.now(datetime.UTC).isoformat()
from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore
store = SqliteDiagnosisSignalStore(str(harness_db))
try:
asyncio.run(
run_pipeline(
config=config,
fingerprint=fingerprint,
diagnosis_deps=deps,
signal_store=store,
wrong_ids=wrong_ids,
questions=questions,
canonical_preds=canonical_preds,
harness_db=harness_db,
questions_dir=questions_dir,
out_dir=out_dir,
generated_at=generated_at,
force=args.force,
retry_uncertain=args.retry_uncertain,
)
)
finally:
store.close()
class _DryRunLLM:
"""dry-run 假 LLM:被真实调用即报错,保证不真调 LLM。"""
async def complete(self, *args: Any, **kwargs: Any) -> Any:
raise AssertionError("dry-run 不应真调 LLM.complete")
class _DryRunLog:
"""dry-run 假 RunLogpredictions/traces 均返回空,诊断不真正执行。"""
async def get_predictions(self, run_id: str, *, question_ids: list[str] | None = None) -> list:
return []
async def get_traces(self, run_id: str, *, question_ids: list[str] | None = None) -> list:
return []
def _execute_dry_run(config: VideoSplitConfig, fingerprint: str, args: argparse.Namespace) -> None:
"""dry-run:用假 deps 跑通 Phase 1 wiring(空错题 → 诊断早返回),打印步骤 + 指纹。
Phase 2 build_split 需真实诊断信号方能冻结,dry-run 不真实冻结,仅打印其计划;
Phase 1 用空 wrong_ids 走 run_baseline_diagnosis 早返回路径,验证装配可调用而不触 LLM。
"""
harness_db, questions_dir, out_dir, _store_dir = _resolve_paths(args)
logger.info("=== dry-run:校验两阶段装配(不真调 LLM / 不冻结产物)===")
logger.info(
"诊断口径指纹 diag_fingerprint={} (prompt={} model={})",
fingerprint,
config.prompt_version,
config.model,
)
logger.info(
"解析路径:harness_db={} questions_dir={} out_dir={}", harness_db, questions_dir, out_dir
)
logger.info(
"旋钮:n_trainval={} epsilon={} report_floor={} val_ratio={} seed={} "
"val_wrong_min={} floor_k={}",
config.n_trainval,
config.epsilon,
config.report_floor,
config.val_ratio,
config.seed,
config.val_wrong_min,
config.floor_k,
)
fake_deps = DiagnosisDeps(
run_log=_DryRunLog(),
llm=_DryRunLLM(),
skill_store=object(),
prompts=object(),
tree_data={},
concurrency=args.concurrency,
)
from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore
dry_db = out_dir / "_dry_run_signals.db"
dry_db.parent.mkdir(parents=True, exist_ok=True)
store = SqliteDiagnosisSignalStore(str(dry_db))
try:
# Phase 0 装配:用一条假 INFRA 空预测走通 persist_infra_t0_rows(不触 LLM)。
fake_infra_preds = [
{
"question_id": "_dry_infra",
"video_id": "_dry_v",
"task_type": "Counting Problem",
"prediction": "",
"answer": "A",
"stop_reason": "error",
"correct": False,
}
]
n_t0 = persist_infra_t0_rows(store, fake_infra_preds, config.baseline_run_id, fingerprint)
logger.info("Phase 0 装配 OKpersist_infra_t0_rows 落 {} 行 INFRA T0(假数据)", n_t0)
logger.info("Phase 1 装配 OKrun_baseline_diagnosis 以空错题走早返回路径(不触 LLM)")
asyncio.run(
run_baseline_diagnosis(
baseline_run_id=config.baseline_run_id,
diag_fingerprint=fingerprint,
wrong_ids=[],
questions={},
store=store,
deps=fake_deps,
)
)
finally:
store.close()
dry_db.unlink(missing_ok=True)
logger.info(
"Phase 2 装配 OK:真实执行将调 build_split 冻结 pools.json + manifestdry-run 跳过)"
)
logger.info("=== dry-run 通过:两阶段装配可调用,指纹已算出 ===")
def build_arg_parser() -> argparse.ArgumentParser:
"""构建 CLI 参数解析器。"""
parser = argparse.ArgumentParser(description="结果驱动视频级切分两阶段 CLI(诊断 → 冻结切分)")
parser.add_argument("--config", type=Path, default=Path("config/video_split.yaml"))
parser.add_argument("--dry-run", action="store_true", dest="dry_run")
parser.add_argument("--gpu", type=str, default=None, help="可选:设置 CUDA_VISIBLE_DEVICES")
parser.add_argument("--concurrency", type=int, default=8, help="诊断并发上限")
parser.add_argument("--harness-db", type=Path, default=None, dest="harness_db")
parser.add_argument("--questions-dir", type=Path, default=None, dest="questions_dir")
parser.add_argument("--out-dir", type=Path, default=None, dest="out_dir")
parser.add_argument("--store-dir", type=Path, default=None, dest="store_dir")
parser.add_argument(
"--generated-at",
type=str,
default=None,
dest="generated_at",
help=(
"manifest generated_at 时间戳(ISO 字符串);默认盖真实 UTC now(溯源元数据)。"
"复现锚点是 pools.json 内容 + seed + fingerprintgenerated_at 可显式传入以"
"对 manifest 做字节级复现比对。"
),
)
parser.add_argument(
"--force",
action="store_true",
help="覆盖已存在的冻结 pools.json/manifest(旧产物备份为 .bak.*",
)
parser.add_argument(
"--retry-uncertain",
action="store_true",
dest="retry_uncertain",
help="把已落 tier='uncertain'(信号不可信降级)的题重新诊断,而非当作已完成跳过",
)
return parser
def main(argv: list[str] | None = None) -> None:
"""CLI 入口:解析参数 → 载配置 → 算指纹 → dry-run 或真实两阶段执行。
参数:
argv: 可选参数列表(默认 sys.argv[1:]),便于测试注入。
"""
from dotenv import load_dotenv
load_dotenv()
args = build_arg_parser().parse_args(argv)
if args.gpu is not None:
os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu
logger.info("CUDA_VISIBLE_DEVICES={}", args.gpu)
config = load_config(args.config)
fingerprint = diag_fingerprint(config.prompt_version, config.model, git_short_sha())
if args.dry_run:
_execute_dry_run(config, fingerprint, args)
return
_execute_real(config, fingerprint, args)
if __name__ == "__main__":
main()
+26 -6
View File
@@ -64,6 +64,21 @@ def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _atomic_write_json(path: Path, data: dict) -> None:
"""原子写 JSONtmp + os.replace(对齐 checkpoint.py 范式,防半截损坏)。
先写同目录临时文件,再 os.replace 原子替换目标;替换阶段崩溃不会留下半截
JSON,原文件保持完好。
参数:
path: 目标 JSON 文件路径。
data: 待序列化的字典。
"""
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
# ---------------------------------------------------------------------------
# Workspace 核心函数
# ---------------------------------------------------------------------------
@@ -108,7 +123,7 @@ def _scaffold_workspace(
},
"history": [],
}
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
_atomic_write_json(workspace_dir / "manifest.json", manifest)
def init_workspace(
@@ -196,6 +211,13 @@ def init_workspace_from_seed(
shutil.copytree(seed_dir / "prompts", workspace_dir / "prompts" / "v1")
shutil.copy2(seed_dir / "baseline.db", workspace_dir / "harness.db")
seed_pools = seed_dir / "pools.json"
if seed_pools.exists():
shutil.copy2(seed_pools, workspace_dir / "pools.json")
seed_manifest = seed_dir / "split_manifest.json"
if seed_manifest.exists():
shutil.copy2(seed_manifest, workspace_dir / "split_manifest.json")
logger.info("Workspace 从种子 '{}' 初始化完成: {}", seed_name, workspace_dir)
return meta["baseline_run_id"]
@@ -279,7 +301,7 @@ def update_manifest(workspace_dir: Path, **version_updates: str) -> None:
raise KeyError(f"无效的 manifest current 字段: {invalid}")
manifest = load_manifest(workspace_dir)
manifest["current"].update(version_updates)
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
_atomic_write_json(workspace_dir / "manifest.json", manifest)
def record_run(workspace_dir: Path, run_id: str) -> Path:
@@ -308,9 +330,7 @@ def record_run(workspace_dir: Path, run_id: str) -> Path:
"questions": current["questions"],
}
)
(workspace_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2)
)
_atomic_write_json(workspace_dir / "manifest.json", manifest)
run_dir = workspace_dir / "runs" / run_id
# exist_ok:同 run_id 重跑时 run 目录已存在不应崩溃
@@ -362,7 +382,7 @@ def update_best(
"run_id": run_id,
"epoch": epoch,
}
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
_atomic_write_json(workspace_dir / "manifest.json", manifest)
logger.info("Best 已更新: val_acc={}, run={}, epoch={}", val_acc, run_id, epoch)
+99 -2
View File
@@ -3,13 +3,14 @@
from __future__ import annotations
from pathlib import Path # noqa: TC003 — runtime_checkable Protocol 需运行时可见
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
import numpy as np
from app.harness.pools import Pools
from app.tree.index import TreeIndex
from core.types import GeneratedQuestion
from core.types import GeneratedQuestion, PoolConfig
@runtime_checkable
@@ -74,3 +75,99 @@ class OCRProvider(Protocol):
"""
async def transcribe_frames(self, frame_paths: list[Path]) -> str: ...
@runtime_checkable
class ToolDispatchFn(Protocol):
"""工具调度函数签名。
参数:
tool_name: 工具名称。
args: 工具参数字典。
context: 上下文字典(包含 session_id)。
返回:
工具执行结果文本。
"""
async def __call__(
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str: ...
@runtime_checkable
class ToolDispatchFactory(Protocol):
"""per-version 工具调度工厂。
通过注入 skills_dir 生产对应版本的 ToolDispatchFn。
参数:
skills_dir: skill 文件目录(None 则不加载 skill)。
返回:
ToolDispatchFn 实例。
"""
def __call__(self, *, skills_dir: Path | None = None) -> ToolDispatchFn: ...
@runtime_checkable
class PromptBuilderFn(Protocol):
"""Prompt 构建函数签名。
参数:
qa: 待构建 prompt 的题目。
返回:
(system_prompt, user_prompt) 二元组。
"""
def __call__(self, qa: GeneratedQuestion) -> tuple[str, str]: ...
@runtime_checkable
class PromptBuilderFactory(Protocol):
"""per-version prompt 构建工厂。
通过注入 skills_dir 和 prompts_dir 生产对应版本的 PromptBuilderFn。
参数:
skills_dir: skill 文件目录(None 则不加载 skill)。
prompts_dir: prompt 文件目录(None 则使用默认目录)。
返回:
PromptBuilderFn 实例。
"""
def __call__(
self,
*,
skills_dir: Path | None = None,
prompts_dir: Path | None = None,
) -> PromptBuilderFn: ...
@runtime_checkable
class PoolStrategy(Protocol):
"""池构建策略端口。
应用层端口(非 core 层),因为返回类型 Pools 定义在 app/harness/pools.py。
两个具体策略(GlobalPoolStrategy / PerCategoryPoolStrategy)实现此接口。
"""
def build(
self,
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
config: PoolConfig,
*,
db_path: Path | None = None,
) -> Pools: ...
def build_incremental(
self,
new_task_types: list[str],
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
config: PoolConfig,
) -> dict[str, dict[str, list[str]]]: ...
+30 -2
View File
@@ -1,5 +1,33 @@
"""出题模块 — benchmark 加载分层采样。"""
"""出题模块 — benchmark 加载分层采样、赛题合成与 v2 出题管线"""
from app.question_gen.families import ALL_FAMILIES, QuestionFamilySpec
from app.question_gen.gates import GateReport, run_gates
from app.question_gen.generator_v2 import CandidateQuestion, generate_one_v2
from app.question_gen.loader import load_benchmark, stratified_sample
from app.question_gen.pipeline_v2 import PipelineConfig, PipelineResult, run_pipeline_v2
from app.question_gen.synthesizer import (
TASK_TYPE_LEVEL_MAP,
AnchorContext,
generate_one,
sample_anchor,
)
__all__ = ["load_benchmark", "stratified_sample"]
__all__ = [
# v1 接口
"load_benchmark",
"stratified_sample",
"TASK_TYPE_LEVEL_MAP",
"AnchorContext",
"generate_one",
"sample_anchor",
# v2 接口
"run_pipeline_v2",
"PipelineConfig",
"PipelineResult",
"QuestionFamilySpec",
"ALL_FAMILIES",
"CandidateQuestion",
"generate_one_v2",
"GateReport",
"run_gates",
]
+59
View File
@@ -0,0 +1,59 @@
"""Phase B 对抗过滤层配置 — filter 层配置(非 strategy 属性)。
设计: research-wiki/designs/2026-07-14-adversarial-question-gen-phaseB-design.md §8
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import yaml
if TYPE_CHECKING:
from pathlib import Path
@dataclass(frozen=True)
class AdversarialFilterConfig:
"""后置对抗过滤配置。
属性:
filter_task_types: 被过滤的题型仅这些走 agent 默认仅 AR
adversarial_max_rounds: 补生成迭代上限
adversarial_agent_max_steps: agent 试答步数上限
difficulty_warn_threshold: 批次 agent 正确率告警阈值
"""
filter_task_types: tuple[str, ...] = ("Action Recognition",)
adversarial_max_rounds: int = 5
adversarial_agent_max_steps: int = 40
difficulty_warn_threshold: float = 0.85
def load_adversarial_config(config_path: Path) -> AdversarialFilterConfig:
"""从 YAML 的 adversarial_filter 区段加载配置,缺段/缺键用默认值。
参数:
config_path: YAML 配置文件路径
返回:
AdversarialFilterConfig 实例
"""
with open(config_path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
section = raw.get("adversarial_filter", {}) or {}
default = AdversarialFilterConfig()
types = section.get("filter_task_types")
return AdversarialFilterConfig(
filter_task_types=tuple(types) if types else default.filter_task_types,
adversarial_max_rounds=int(
section.get("adversarial_max_rounds", default.adversarial_max_rounds)
),
adversarial_agent_max_steps=int(
section.get("adversarial_agent_max_steps", default.adversarial_agent_max_steps)
),
difficulty_warn_threshold=float(
section.get("difficulty_warn_threshold", default.difficulty_warn_threshold)
),
)
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
"""Grounded 干扰项 selector — 候选池 + VLM 视觉打分 + 区间选择(仅 AR 路径)。
把干扰项从"VLM 主观写得像"下沉到机制层VLM 生成 N 个候选干扰项再对
候选 + 正解逐一打"视觉可信度" [正解分-δ_high, 正解分-δ_low] 区间
3 grounded near-miss从机制上消灭 Easy-Options Bias
设计: research-wiki/designs/2026-07-14-grounded-question-gen-phaseA-design.md §3
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from json_repair import repair_json
from loguru import logger
if TYPE_CHECKING:
from app.question_gen.sampler_v2 import MaterialContext
from core.protocols import VLMProvider
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"
@dataclass(frozen=True)
class SelectorConfig:
"""selector 科研参数。
属性:
candidate_pool_size: 首轮候选干扰项数 N
delta_low: 干扰项视觉分与正解的最小差上界太近=真歧义
delta_high: 干扰项视觉分与正解的最大差下界太低=负空间
max_delta_relax: δ_high 放宽次数上限退火
delta_relax_step: 每次放宽 δ_high 的增量
"""
candidate_pool_size: int
delta_low: float
delta_high: float
max_delta_relax: int = 2
delta_relax_step: float = 0.1
@dataclass(frozen=True)
class SelectorOutcome:
"""selector 产出。observation 始终存在(含 hard-fail),供 run_store 落库。
属性:
observation: 打分观测 dictcorrect_score/chosen/pool_size/anneal_rounds/hard_fail
options: 重组四选项A=正解hard-fail 时为 None
answer: 正解字母 "A"hard-fail 时为 None
"""
observation: dict[str, object]
options: tuple[str, ...] | None = None
answer: str | None = None
@property
def hard_fail(self) -> bool:
"""是否硬失败(凑不齐 3 个 grounded 干扰项)。"""
return self.options is None
def _select_in_interval(
correct_score: float,
candidates: list[str],
candidate_scores: list[float],
delta_low: float,
delta_high: float,
) -> list[str] | None:
"""从候选中选 3 个视觉分落 [correct-δ_high, correct-δ_low] 区间的干扰项。
落区间者按分数降序取前 3分数越高越接近正解=越难不足 3 个返回 None
参数:
correct_score: 正解视觉可信度分
candidates: 候选干扰项文本列表
candidate_scores: candidates 对齐的视觉分列表
delta_low: 最小差上界 = correct - delta_low
delta_high: 最大差下界 = correct - delta_high
返回:
选中的 3 个候选文本降序 None不足 3
"""
upper = correct_score - delta_low
lower = correct_score - delta_high
eligible = [
(c, s) for c, s in zip(candidates, candidate_scores, strict=True) if lower <= s <= upper
]
if len(eligible) < 3:
return None
eligible.sort(key=lambda cs: cs[1], reverse=True)
return [c for c, _ in eligible[:3]]
def _load_prompt(name: str) -> str:
path = _PROMPTS_DIR / name
if not path.exists():
msg = f"Prompt 模板不存在: {path}"
raise FileNotFoundError(msg)
return path.read_text(encoding="utf-8")
def _material_context_block(question: str, correct_text: str, material: MaterialContext) -> str:
parts = [f"## Question\n{question}", f"## Correct Answer\n{correct_text}"]
if material.subtitle_sentences:
parts.append("## Subtitles")
parts.extend(f" - {s}" for s in material.subtitle_sentences)
if getattr(material, "cross_l2_texts", None):
parts.append("## Cross-Segment Context")
parts.extend(f" - {t}" for t in material.cross_l2_texts)
return "\n".join(parts)
def _parse_json_object(raw: str) -> dict:
content = raw.strip()
if "```" in content:
for part in content.split("```"):
stripped = part.strip()
if stripped.startswith("json"):
stripped = stripped[4:].strip()
if stripped.startswith("{"):
content = stripped
break
data = json.loads(repair_json(content, return_objects=False))
if not isinstance(data, dict):
msg = f"selector 响应顶层非 JSON 对象: {type(data).__name__}"
raise ValueError(msg)
return data
async def _generate_pool(
vlm: VLMProvider,
question: str,
correct_text: str,
material: MaterialContext,
n: int,
*,
session_id: str,
) -> list[str]:
"""VLM 生成 n 个候选干扰项文本。"""
system = _load_prompt("ar_distractor_pool.md")
user = (
_material_context_block(question, correct_text, material)
+ f"\n## N\nGenerate exactly {n} distractors."
)
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
resp = await vlm.chat_with_images(messages, list(material.frame_paths), session_id=session_id)
# graceful 降级:解析爆炸时返回空池,让退火/hard_fail 接管(区别于契约违反的打分校验)
try:
data = _parse_json_object(resp.content)
except (ValueError, json.JSONDecodeError) as exc:
logger.warning("grounded selector 候选池响应解析失败,降级为空池: {}", exc)
return []
raw = data.get("distractors", [])
if not isinstance(raw, list):
return []
# 防御:去空、去重、剔除与正解字面相同者
seen: set[str] = set()
out: list[str] = []
for item in raw:
text = str(item).strip()
if not text or text == correct_text.strip() or text in seen:
continue
seen.add(text)
out.append(text)
return out
async def _score_options(
vlm: VLMProvider,
question: str,
options: list[str],
material: MaterialContext,
*,
session_id: str,
) -> list[float]:
"""VLM 对 options(首个为正解)逐一打视觉可信度分 [0,1],返回对齐分数列表。"""
system = _load_prompt("ar_distractor_score.md")
numbered = "\n".join(f"{i}. {opt}" for i, opt in enumerate(options, 1))
user = f"## Question\n{question}\n\n## Candidates\n{numbered}"
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
resp = await vlm.chat_with_images(messages, list(material.frame_paths), session_id=session_id)
data = _parse_json_object(resp.content)
scores_raw = data.get("scores", [])
if not isinstance(scores_raw, list) or len(scores_raw) != len(options):
msg = f"打分数量({len(scores_raw) if isinstance(scores_raw, list) else 'NA'}) != 选项数({len(options)})"
raise ValueError(msg)
try:
return [max(0.0, min(1.0, float(s))) for s in scores_raw]
except (TypeError, ValueError) as e:
msg = f"打分含非数值元素: {e}"
raise ValueError(msg) from e
async def build_grounded_options(
vlm: VLMProvider,
question: str,
correct_text: str,
material: MaterialContext,
config: SelectorConfig,
*,
session_id: str,
) -> SelectorOutcome:
"""生成候选池 → 视觉打分 → 区间选 3 干扰项 → 重组四选项。
退火凑不齐 3 个时按序 最多追加 N 个新候选去重后实际增量可能更少再打分 逐步放宽
δ_high纯重选不再调 VLM 仍不足则 hard_fail调用方走重出
参数:
vlm: VLM 端口
question: 题干
correct_text: 正解文本无字母前缀
material: 采样素材提供 frame_paths / subtitles
config: selector 科研参数
session_id: 遥测会话 ID
返回:
SelectorOutcome成功时 options=A 正解+3 grounded 干扰项hard_fail
options=None observation 始终存在供落库
"""
candidates = await _generate_pool(
vlm, question, correct_text, material, config.candidate_pool_size, session_id=session_id
)
# options[0] 恒为正解
scored = await _score_options(
vlm, question, [correct_text, *candidates], material, session_id=session_id
)
correct_score, cand_scores = scored[0], scored[1:]
anneal_rounds = 0
chosen = _select_in_interval(
correct_score, candidates, cand_scores, config.delta_low, config.delta_high
)
# 退火 1: 最多追加 N 个新候选(去重后实际增量可能更少),仅对新增候选打分,正解分保持首轮值
if chosen is None:
anneal_rounds += 1
more = await _generate_pool(
vlm, question, correct_text, material, config.candidate_pool_size, session_id=session_id
)
more = [m for m in more if m not in candidates]
if more:
more_scores = await _score_options(
vlm, question, [correct_text, *more], material, session_id=session_id
)
candidates = candidates + more
cand_scores = cand_scores + more_scores[1:]
chosen = _select_in_interval(
correct_score, candidates, cand_scores, config.delta_low, config.delta_high
)
# 退火 2: 放宽 δ_high(下界下移,纳入更低分候选),δ_low 不动
relax = 0
delta_high = config.delta_high
while chosen is None and relax < config.max_delta_relax:
relax += 1
anneal_rounds += 1
delta_high = delta_high + config.delta_relax_step
chosen = _select_in_interval(
correct_score, candidates, cand_scores, config.delta_low, delta_high
)
hard_fail = chosen is None
observation: dict[str, object] = {
"correct_score": correct_score,
"chosen": [cand_scores[candidates.index(c)] for c in (chosen or [])],
"pool_size": len(candidates),
"anneal_rounds": anneal_rounds,
"delta_high_final": delta_high,
"hard_fail": hard_fail,
}
if hard_fail:
logger.warning(
"grounded selector 硬失败: correct={:.3f}, pool={}, anneal={}",
correct_score,
len(candidates),
anneal_rounds,
)
# observation 仍返回,供 pipeline 落 selector_scores(设计 §3.3 退化观测)
return SelectorOutcome(observation=observation)
options = (
f"A. {correct_text}",
f"B. {chosen[0]}",
f"C. {chosen[1]}",
f"D. {chosen[2]}",
)
return SelectorOutcome(observation=observation, options=options, answer="A")
+252
View File
@@ -0,0 +1,252 @@
"""题族规格声明 — 定义 5 大问题家族及其采样、泄漏检测、提示模板约束。
每个 QuestionFamilySpec 对应一种失败机制skill_target M1M5
get_family_for_slot 在出题时按权重分配
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import random
@dataclass(frozen=True)
class LeakTestProfile:
"""泄漏测试配置 — 定义快捷答题捷径类型与通过阈值。
Attributes:
shortcut_type: 捷径类型标识
probe_template: store/prompts/question_gen/ 下的探测模板文件名
pass_threshold: 通过阈值01低于此值视为存在泄漏
"""
shortcut_type: str
probe_template: str
pass_threshold: float
@dataclass(frozen=True)
class SamplingConstraint:
"""采样约束 — 对树节点的最低要求。
Attributes:
min_subtitles: 最少字幕段数
min_l3_nodes: 最少 L3 节点数
require_frames: 是否要求帧图像可用
cross_l2_span: 是否要求跨 L2 段采样
"""
min_subtitles: int
min_l3_nodes: int
require_frames: bool
cross_l2_span: bool
@dataclass(frozen=True)
class QuestionFamilySpec:
"""问题家族规格 — 一个家族的完整声明。
Attributes:
name: 家族标识名 "RETRIEVAL"
skill_target: 目标失败机制编号M1M5
sampling: 采样约束
legal_task_types: 该家族合法的任务类型集合frozenset
leak_profile: 泄漏测试配置
prompt_template: 出题 prompt 模板文件名
"""
name: str
skill_target: str
sampling: SamplingConstraint
legal_task_types: frozenset[str]
leak_profile: LeakTestProfile
prompt_template: str
# ---------------------------------------------------------------------------
# 5 大家族实例
# ---------------------------------------------------------------------------
RETRIEVAL_FAMILY = QuestionFamilySpec(
name="RETRIEVAL",
skill_target="M1",
sampling=SamplingConstraint(
min_subtitles=2,
min_l3_nodes=3,
require_frames=False,
cross_l2_span=False,
),
legal_task_types=frozenset(
[
"Object Recognition",
"Object Reasoning",
"Action Recognition",
"Attribute Perception",
"OCR Problems",
]
),
leak_profile=LeakTestProfile(
shortcut_type="temporal_proximity",
probe_template="gate_leak_retrieval.md",
pass_threshold=0.6,
),
prompt_template="retrieval.md",
)
REASONING_FAMILY = QuestionFamilySpec(
name="REASONING",
skill_target="M2",
sampling=SamplingConstraint(
min_subtitles=3,
min_l3_nodes=4,
require_frames=False,
cross_l2_span=True,
),
legal_task_types=frozenset(
[
"Action Reasoning",
"Object Reasoning",
"Information Synopsis",
]
),
leak_profile=LeakTestProfile(
shortcut_type="frequency",
probe_template="gate_leak_reasoning.md",
pass_threshold=0.5,
),
prompt_template="reasoning.md",
)
ENUMERATION_FAMILY = QuestionFamilySpec(
name="ENUMERATION",
skill_target="M3",
sampling=SamplingConstraint(
min_subtitles=2,
min_l3_nodes=5,
require_frames=False,
cross_l2_span=False,
),
legal_task_types=frozenset(
[
"Counting Problem",
"Temporal Reasoning",
"Temporal Perception",
"Information Synopsis",
]
),
leak_profile=LeakTestProfile(
shortcut_type="option_length",
probe_template="gate_leak_enumeration.md",
pass_threshold=0.6,
),
prompt_template="enumeration.md",
)
VISUAL_FAMILY = QuestionFamilySpec(
name="VISUAL",
skill_target="M4",
sampling=SamplingConstraint(
min_subtitles=0,
min_l3_nodes=3,
require_frames=True,
cross_l2_span=False,
),
legal_task_types=frozenset(
[
"Attribute Perception",
"Counting Problem",
"OCR Problems",
"Action Recognition",
]
),
leak_profile=LeakTestProfile(
shortcut_type="visual_salience",
probe_template="gate_leak_visual.md",
pass_threshold=0.5,
),
prompt_template="visual.md",
)
SPATIAL_FAMILY = QuestionFamilySpec(
name="SPATIAL",
skill_target="M5",
sampling=SamplingConstraint(
min_subtitles=0,
min_l3_nodes=3,
require_frames=True,
cross_l2_span=False,
),
legal_task_types=frozenset(
[
"Spatial Perception",
"Spatial Reasoning",
]
),
leak_profile=LeakTestProfile(
shortcut_type="spatial_default",
probe_template="gate_leak_spatial.md",
pass_threshold=0.5,
),
prompt_template="spatial.md",
)
ALL_FAMILIES: tuple[QuestionFamilySpec, ...] = (
RETRIEVAL_FAMILY,
REASONING_FAMILY,
ENUMERATION_FAMILY,
VISUAL_FAMILY,
SPATIAL_FAMILY,
)
# 按 name 索引,用于 get_family_for_slot 快速查找
_FAMILY_BY_NAME: dict[str, QuestionFamilySpec] = {f.name: f for f in ALL_FAMILIES}
def get_family_for_slot(
task_type: str,
family_ratios: dict[str, float],
rng: random.Random,
) -> QuestionFamilySpec:
"""根据任务类型和家族权重比例,随机选择一个合法的问题家族。
Args:
task_type: 任务类型字符串必须是 12 种合法类型之一
family_ratios: 家族名称到权重的映射 {"RETRIEVAL": 0.30, ...}
rng: 随机数生成器实例确保可复现
Returns:
被选中的 QuestionFamilySpec
Raises:
ValueError: task_type 不在任何家族的 legal_task_types
ValueError: 给定 task_type 下没有合法家族所有合法族权重为 0 或不在 ratios
"""
# 检查 task_type 是否被任一家族接受
all_legal_types: set[str] = set()
for family in ALL_FAMILIES:
all_legal_types.update(family.legal_task_types)
if task_type not in all_legal_types:
msg = f"task_type '{task_type}' 不在任何家族的合法类型中"
raise ValueError(msg)
# 过滤出接受该 task_type 且在 ratios 中有正权重的家族
candidates: list[QuestionFamilySpec] = []
weights: list[float] = []
for family_name, weight in family_ratios.items():
family = _FAMILY_BY_NAME.get(family_name)
if family is None:
continue
if task_type in family.legal_task_types and weight > 0:
candidates.append(family)
weights.append(weight)
if not candidates:
msg = f"task_type '{task_type}' 下没有合法家族可选(检查 family_ratios"
raise ValueError(msg)
# 归一化权重 + 加权随机选择
chosen = rng.choices(candidates, weights=weights, k=1)[0]
return chosen
+511
View File
@@ -0,0 +1,511 @@
"""轻量四门质量检查 — 对生成题目执行 4 个独立的 LLM 质量门控。
四门
1. key_verify: 验证答案在来源素材中有证据支撑
2. blind_answer: 无上下文时 LLM 能否答对若答对 题目泄漏
3. multi_true: 检测是否有多个选项可被视为正确
4. leak_test: 按策略特定模板探测答题捷径
设计要点
- run_gates 先做 verbatim_ratio 前置短路> 0.5 直接 FAIL key_verify
- 四门并发执行asyncio.gather
- 每门加载 store/prompts/question_gen/ 下对应模板构造 messages LLM
- LLM 返回 JSON {"verdict": "pass"|"fail", "reason": "..."}解析失败视为 FAIL
- session_id 必须透传至每次 LLM 调用遥测关联
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
from app.question_gen.postprocess import PostprocessResult
from app.tree.index import TreeIndex
from core.protocols import LLMProvider, VLMProvider
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"
_VERBATIM_THRESHOLD = 0.5
# ---------------------------------------------------------------------------
# CandidateQuestionTask 5 尚未实现,本地定义,后续迁移至 generator_v2.py
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class CandidateQuestion:
"""候选题目 — 出题管线生成、待门控审核的题目数据。
属性:
question_id: 题目唯一标识
video_id: 所属视频标识
task_type: 题型 "Action Reasoning"
skill_target: 目标失败机制编号M1-M5
question: 题目文本
options: 选项元组 ("A. ...", "B. ...", "C. ...", "D. ...")
answer: 正确答案字母 "A"
source_nodes: 来源节点 ID 元组
difficulty: 难度等级
subtitle_sentences: 字幕句子元组
frame_paths: 帧图片路径元组
"""
question_id: str
video_id: str
task_type: str
skill_target: str
question: str
options: tuple[str, ...]
answer: str
source_nodes: tuple[str, ...]
difficulty: str
subtitle_sentences: tuple[str, ...] = field(default_factory=tuple)
frame_paths: tuple[str, ...] = field(default_factory=tuple)
# ---------------------------------------------------------------------------
# 门控结果类型
# ---------------------------------------------------------------------------
class GateVerdict(Enum):
"""门控判决枚举。"""
PASS = "pass"
FAIL = "fail"
SKIP = "skip"
@dataclass(frozen=True)
class GateResult:
"""单门判决结果。
属性:
verdict: 门控判决PASS / FAIL / SKIP
reason: 判决原因说明
raw_response: LLM 原始返回内容
"""
verdict: GateVerdict
reason: str
raw_response: str
@dataclass(frozen=True)
class GateReport:
"""四门判决汇总报告。
属性:
key_verify: 关键验证门结果
blind_answer: 盲答门结果
multi_true: 多正确门结果
leak_test: 泄漏测试门结果
"""
key_verify: GateResult
blind_answer: GateResult
multi_true: GateResult
leak_test: GateResult
@property
def passed(self) -> bool:
"""全门 PASS 或 SKIP 时视为通过。"""
for gate in (self.key_verify, self.blind_answer, self.multi_true, self.leak_test):
if gate.verdict == GateVerdict.FAIL:
return False
return True
@property
def reject_reason(self) -> str | None:
"""首个 FAIL 门的 reason,全部通过返回 None。"""
for gate in (self.key_verify, self.blind_answer, self.multi_true, self.leak_test):
if gate.verdict == GateVerdict.FAIL:
return gate.reason
return None
# ---------------------------------------------------------------------------
# 内部工具函数
# ---------------------------------------------------------------------------
def _load_prompt_template(filename: str) -> str:
"""加载 store/prompts/question_gen/ 下的模板文件。
参数:
filename: 模板文件名 "gate_key_verify.md"
返回:
模板内容字符串
异常:
FileNotFoundError: 模板文件不存在
"""
path = _PROMPTS_DIR / filename
if not path.exists():
msg = f"门控模板文件不存在: {path}"
raise FileNotFoundError(msg)
return path.read_text(encoding="utf-8")
def _resolve_source_text(candidate: CandidateQuestion, tree: TreeIndex) -> str:
"""从树中解析候选题的来源节点文本。
遍历树的所有层级匹配 source_nodes 中的 ID提取对应描述文本
参数:
candidate: 候选题目
tree: 视频树索引
返回:
拼接的来源节点描述文本
"""
texts: list[str] = []
target_ids = set(candidate.source_nodes)
for l1 in tree.roots:
if l1.id in target_ids:
texts.append(f"[L1 {l1.id}] {l1.card.scene_summary}")
for l2 in l1.children:
if l2.id in target_ids:
texts.append(f"[L2 {l2.id}] {l2.card.event_description}")
for l3 in l2.children:
if l3.id in target_ids:
texts.append(f"[L3 {l3.id}] {l3.card.frame_summary}")
if not texts:
logger.warning(
"未找到来源节点: candidate={}, source_nodes={}",
candidate.question_id,
candidate.source_nodes,
)
return "(no source material found)"
return "\n".join(texts)
def _format_options(options: tuple[str, ...]) -> str:
"""将选项元组格式化为可读字符串。"""
return "\n".join(options)
def _parse_gate_response(raw_content: str) -> tuple[GateVerdict, str]:
"""解析 LLM 返回的门控 JSON 响应。
期望格式: {"verdict": "pass"|"fail", "reason": "..."}
解析失败时返回 FAIL + parse_error
参数:
raw_content: LLM 返回的原始文本
返回:
(verdict, reason) 元组
"""
try:
# 尝试从文本中提取 JSON(可能被 markdown 代码块包裹)
content = raw_content.strip()
if "```" in content:
# 提取代码块中的内容
parts = content.split("```")
for part in parts:
stripped = part.strip()
if stripped.startswith("json"):
stripped = stripped[4:].strip()
if stripped.startswith("{"):
content = stripped
break
data = json.loads(content)
verdict_str = data.get("verdict", "").lower()
reason = data.get("reason", "")
if verdict_str == "pass":
return GateVerdict.PASS, reason
elif verdict_str == "fail":
return GateVerdict.FAIL, reason
else:
return GateVerdict.FAIL, f"parse_error: invalid verdict '{verdict_str}'"
except (json.JSONDecodeError, AttributeError, TypeError) as e:
return GateVerdict.FAIL, f"parse_error: {e}"
# ---------------------------------------------------------------------------
# 四门实现
# ---------------------------------------------------------------------------
def _resolve_source_frames(candidate: CandidateQuestion, tree: TreeIndex) -> list[str]:
"""从树中收集候选题来源节点关联的帧路径。
参数:
candidate: 候选题目
tree: 视频树索引
返回:
去重后的帧路径列表最多 10 避免 VLM 输入过长
"""
frames: list[str] = []
target_ids = set(candidate.source_nodes)
for l1 in tree.roots:
for l2 in l1.children:
if l2.id in target_ids:
for l3 in l2.children:
if l3.frame_path:
frames.append(l3.frame_path)
for l3 in l2.children:
if l3.id in target_ids and l3.frame_path:
frames.append(l3.frame_path)
# 也使用候选题自带的帧路径
frames.extend(candidate.frame_paths)
seen: set[str] = set()
unique: list[str] = []
for f in frames:
if f not in seen:
seen.add(f)
unique.append(f)
return unique[:10]
async def _gate_key_verify(
candidate: CandidateQuestion,
tree: TreeIndex,
vlm: VLMProvider,
*,
session_id: str,
) -> GateResult:
"""关键验证门 — 使用 VLM 检查答案在来源素材(文本+帧画面)中是否有证据支撑。
参数:
candidate: 候选题目
tree: 视频树索引
vlm: VLM 图文调用端口同时看文本和帧画面
session_id: 会话 ID遥测关联
返回:
GateResult 实例
"""
source_text = _resolve_source_text(candidate, tree)
frames = _resolve_source_frames(candidate, tree)
template = _load_prompt_template("gate_key_verify.md")
prompt = template.format(
source_text=source_text,
question=candidate.question,
options=_format_options(candidate.options),
answer=candidate.answer,
)
if frames:
response = await vlm.chat_with_images(
[{"role": "user", "content": prompt}],
images=frames,
session_id=session_id,
)
else:
# 无帧时降级为纯文本(不应常见)
logger.warning("key_verify 无可用帧,降级纯文本: {}", candidate.question_id)
response = await vlm.chat_with_images(
[{"role": "user", "content": prompt}],
images=[],
session_id=session_id,
)
verdict, reason = _parse_gate_response(response.content)
return GateResult(verdict=verdict, reason=reason, raw_response=response.content)
async def _gate_blind_answer(
candidate: CandidateQuestion,
llm: LLMProvider,
*,
session_id: str,
) -> GateResult:
"""盲答门 — 无上下文时 LLM 能否答对(答对 → 题目泄漏)。
参数:
candidate: 候选题目
llm: LLM 调用端口
session_id: 会话 ID遥测关联
返回:
GateResult 实例
"""
template = _load_prompt_template("gate_blind_answer.md")
prompt = template.format(
question=candidate.question,
options=_format_options(candidate.options),
)
response = await llm.chat(
[{"role": "user", "content": prompt}],
session_id=session_id,
)
verdict, reason = _parse_gate_response(response.content)
return GateResult(verdict=verdict, reason=reason, raw_response=response.content)
async def _gate_multi_true(
candidate: CandidateQuestion,
tree: TreeIndex,
llm: LLMProvider,
*,
session_id: str,
) -> GateResult:
"""多正确门 — 检测是否有多个选项可被视为正确。
参数:
candidate: 候选题目
tree: 视频树索引
llm: LLM 调用端口
session_id: 会话 ID遥测关联
返回:
GateResult 实例
"""
source_text = _resolve_source_text(candidate, tree)
template = _load_prompt_template("gate_multi_true.md")
prompt = template.format(
source_text=source_text,
question=candidate.question,
options=_format_options(candidate.options),
)
response = await llm.chat(
[{"role": "user", "content": prompt}],
session_id=session_id,
)
verdict, reason = _parse_gate_response(response.content)
return GateResult(verdict=verdict, reason=reason, raw_response=response.content)
async def _gate_leak_test(
candidate: CandidateQuestion,
leak_probe_template: str,
llm: LLMProvider,
*,
session_id: str,
) -> GateResult:
"""泄漏测试门 — 按策略特定模板探测答题捷径。
参数:
candidate: 候选题目
leak_probe_template: 泄漏探测模板文件名store/prompts/question_gen/
llm: LLM 调用端口
session_id: 会话 ID遥测关联
返回:
GateResult 实例
"""
template = _load_prompt_template(leak_probe_template)
prompt = template.format(
question=candidate.question,
options=_format_options(candidate.options),
answer=candidate.answer,
)
response = await llm.chat(
[{"role": "user", "content": prompt}],
session_id=session_id,
)
verdict, reason = _parse_gate_response(response.content)
return GateResult(verdict=verdict, reason=reason, raw_response=response.content)
# ---------------------------------------------------------------------------
# 编排入口
# ---------------------------------------------------------------------------
async def run_gates(
candidate: CandidateQuestion,
tree: TreeIndex,
llm: LLMProvider,
leak_probe_template: str,
postprocess: PostprocessResult,
*,
vlm: VLMProvider | None = None,
session_id: str,
) -> GateReport:
"""编排四门并发执行,返回汇总报告。
前置规则verbatim_ratio > 0.5 时直接短路 key_verify FAIL不调 LLM
参数:
candidate: 候选题目
tree: 视频树索引
llm: LLM 调用端口
leak_probe_template: 泄漏探测模板文件名store/prompts/question_gen/
postprocess: 后处理结果 verbatim_ratio
vlm: VLM 图文调用端口key_verify 使用None 时降级为 LLM
session_id: 会话 ID遥测关联
返回:
GateReport 四门汇总
"""
# Phase 1: verbatim 前置短路 — 不调 LLM,直接返回
if postprocess.verbatim_ratio > _VERBATIM_THRESHOLD:
logger.info(
"verbatim_ratio={:.3f} > {:.1f}key_verify 短路 FAIL,其余三门 SKIP",
postprocess.verbatim_ratio,
_VERBATIM_THRESHOLD,
)
skip_result = GateResult(
verdict=GateVerdict.SKIP,
reason="skipped due to verbatim short-circuit",
raw_response="",
)
return GateReport(
key_verify=GateResult(
verdict=GateVerdict.FAIL,
reason=f"verbatim_ratio={postprocess.verbatim_ratio:.3f} exceeds threshold {_VERBATIM_THRESHOLD}",
raw_response="",
),
blind_answer=skip_result,
multi_true=skip_result,
leak_test=skip_result,
)
# Phase 2: 四门并发执行(key_verify 使用 VLM 看帧+文本)
key_verify_provider = vlm if vlm is not None else llm
key_result, blind_result, multi_result, leak_result = await asyncio.gather(
_gate_key_verify(candidate, tree, key_verify_provider, session_id=session_id),
_gate_blind_answer(candidate, llm, session_id=session_id),
_gate_multi_true(candidate, tree, llm, session_id=session_id),
_gate_leak_test(candidate, leak_probe_template, llm, session_id=session_id),
)
report = GateReport(
key_verify=key_result,
blind_answer=blind_result,
multi_true=multi_result,
leak_test=leak_result,
)
if report.passed:
logger.debug("四门全部通过: question_id={}", candidate.question_id)
else:
logger.info(
"门控拒绝: question_id={}, reason={}",
candidate.question_id,
report.reject_reason,
)
return report
+461
View File
@@ -0,0 +1,461 @@
"""v2 生成器 — 基于策略特化 prompt 模板的单题 VLM 出题模块。
使用 VLMProvider 接口调用视觉语言模型结合 per-strategy prompt 模板
MaterialContext 素材上下文生成一道四选一候选题
典型调用路径::
candidate = await generate_one_v2(
vlm=vlm_client,
tree=tree_index,
material=material_ctx,
task_type="Action Reasoning",
seq=1,
video_id="vid_001",
prompt_template="retrieval.md",
strategy_name="RETRIEVAL",
skill_target="M1",
session_id="sess_001",
)
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from json_repair import repair_json
from loguru import logger
if TYPE_CHECKING:
from app.question_gen.sampler_v2 import MaterialContext
from app.tree.index import TreeIndex
from core.protocols import VLMProvider
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"
_VALID_ANSWERS = frozenset({"A", "B", "C", "D"})
_VALID_DIFFICULTIES = frozenset({"easy", "medium", "hard"})
# ---------------------------------------------------------------------------
# CandidateQuestion(规范定义位置)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class CandidateQuestion:
"""候选题目 — 出题管线生成、待门控审核的题目数据。
属性:
question_id: 题目唯一标识格式 "{video_id}_{task_type}_{seq:04d}"
video_id: 所属视频标识
task_type: 题型 "Action Reasoning"
skill_target: 目标失败机制编号M1-M5
question: 题目文本
options: 选项元组 ("A. ...", "B. ...", "C. ...", "D. ...")
answer: 正确答案字母 "A"
source_nodes: 来源节点 ID 元组
difficulty: 难度等级easy/medium/hard
subtitle_sentences: 验证材料 字幕句子元组
frame_paths: 验证材料 帧图片路径元组
"""
question_id: str
video_id: str
task_type: str
skill_target: str
question: str
options: tuple[str, ...]
answer: str
source_nodes: tuple[str, ...]
difficulty: str
subtitle_sentences: tuple[str, ...] = field(default_factory=tuple)
frame_paths: tuple[str, ...] = field(default_factory=tuple)
# ---------------------------------------------------------------------------
# Prompt 模板加载
# ---------------------------------------------------------------------------
def _load_prompt_template(template_name: str) -> str:
"""加载 prompt 模板文件。
参数:
template_name: store/prompts/question_gen/ 下的模板文件名
返回:
模板内容字符串
异常:
FileNotFoundError: 模板文件不存在
"""
path = _PROMPTS_DIR / template_name
if not path.exists():
msg = f"Prompt 模板文件不存在: {path}"
raise FileNotFoundError(msg)
return path.read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Prompt 构建
# ---------------------------------------------------------------------------
def _build_v2_prompt(
prompt_template: str,
strategy_name: str,
material: MaterialContext,
task_type: str,
seq: int,
*,
reject_reason: str | None = None,
sub_pattern_instruction: str | None = None,
) -> tuple[list[dict[str, str]], list[str]]:
"""构建 VLM 出题调用的 messages 和帧路径列表。
参数:
prompt_template: prompt 模板文件名store/prompts/question_gen/
strategy_name: 策略名称 "RETRIEVAL"
material: 采样素材上下文
task_type: 任务类型字符串
seq: 当前序号
reject_reason: 上一次被门控拒绝的原因用于引导 VLM 避免相同错误
sub_pattern_instruction: 子模式特殊指令如有
返回:
二元组:
- messages: 适配 VLMProvider message 列表system + user
- frame_paths: 需发送给 VLM 的帧路径列表
"""
# Phase 1: 加载策略模板作为 system prompt
template_content = _load_prompt_template(prompt_template)
system_message = template_content
# Phase 2: 构建 user prompt — 聚合素材信息
user_parts: list[str] = []
user_parts.append(f"## Task Type: {task_type}")
user_parts.append(f"## Question Family: {strategy_name}")
user_parts.append(f"## Sequence: #{seq}")
# 字幕素材
if material.subtitle_sentences:
user_parts.append("\n## Subtitle Content:")
for i, sent in enumerate(material.subtitle_sentences, 1):
user_parts.append(f" {i}. {sent}")
# 跨 L2 上下文
if material.cross_l2_texts:
user_parts.append("\n## Cross-Segment Context:")
for text in material.cross_l2_texts:
user_parts.append(f" - {text}")
# 帧路径提示(VLM 会接收实际图像,此处仅作文本参考)
if material.frame_paths:
user_parts.append(f"\n## Visual Frames: {len(material.frame_paths)} frames attached.")
# 拒绝原因注入
if reject_reason is not None:
user_parts.append(
f"\n## IMPORTANT - Previous Attempt Rejected:\n"
f"Your previous question was rejected for the following reason:\n"
f'"{reject_reason}"\n'
f"Please generate a NEW question that avoids this issue."
)
# 子模式特殊指令注入
if sub_pattern_instruction is not None:
user_parts.append(f"\n## Special Focus:\n{sub_pattern_instruction}")
# 输出格式指令
user_parts.append(
"\n## Output Format:\n"
"Respond with ONLY a JSON object in this exact format:\n"
"```json\n"
"{\n"
' "question": "Your question text here",\n'
' "options": ["A. ...", "B. ...", "C. ...", "D. ..."],\n'
' "answer": "A",\n'
' "difficulty": "easy|medium|hard"\n'
"}\n"
"```"
)
user_content = "\n".join(user_parts)
messages = [
{"role": "system", "content": system_message},
{"role": "user", "content": user_content},
]
# Phase 3: 帧路径
frame_paths = list(material.frame_paths)
return messages, frame_paths
# ---------------------------------------------------------------------------
# 响应解析
# ---------------------------------------------------------------------------
def _extract_json_from_text(raw: str) -> str:
"""从可能被 markdown 代码块包裹的文本中提取 JSON 部分。
参数:
raw: VLM 原始返回文本
返回:
清理后的 JSON 字符串
"""
content = raw.strip()
if "```" in content:
parts = content.split("```")
for part in parts:
stripped = part.strip()
if stripped.startswith("json"):
stripped = stripped[4:].strip()
if stripped.startswith("{"):
return stripped
return content
@dataclass(frozen=True)
class _ValidatedFields:
"""字段校验通过后的中间结构。"""
question: str
options: tuple[str, ...]
answer: str
difficulty: str
def _validate_parsed_fields(data: dict) -> _ValidatedFields:
"""校验 VLM 响应 JSON 的必填字段并规范化。
参数:
data: 已解析的 JSON 字典
返回:
_ValidatedFields 实例字段已规范化
异常:
ValueError: 缺少必填字段或字段值非法
"""
missing = [f for f in ("question", "options", "answer", "difficulty") if f not in data]
if missing:
msg = f"VLM 响应缺少必填字段: {', '.join(missing)}"
raise ValueError(msg)
question_text = str(data["question"])
options_raw = data["options"]
answer = str(data["answer"]).strip().upper()
difficulty = str(data["difficulty"]).strip().lower()
# 校验 optionsanswer 约束为 A-D,因此必须恰好 4 个选项)
if not isinstance(options_raw, list) or len(options_raw) != 4:
msg = f"options 字段必须恰好包含 4 个选项,实际数量: {len(options_raw) if isinstance(options_raw, list) else type(options_raw).__name__}"
raise ValueError(msg)
# 校验 answer
if answer not in _VALID_ANSWERS:
msg = f"answer 字段值 '{answer}' 非法,必须为 A/B/C/D 之一"
raise ValueError(msg)
# 校验 difficulty(宽容处理:非法值回退为 medium)
if difficulty not in _VALID_DIFFICULTIES:
logger.warning(
"difficulty '{}' 不在预设范围 {},回退为 'medium'",
difficulty,
_VALID_DIFFICULTIES,
)
difficulty = "medium"
return _ValidatedFields(
question=question_text,
options=tuple(str(o) for o in options_raw),
answer=answer,
difficulty=difficulty,
)
def _parse_v2_response(
raw: str,
video_id: str,
task_type: str,
skill_target: str,
seq: int,
source_nodes: tuple[str, ...],
) -> CandidateQuestion:
"""解析 VLM 返回的 JSON 响应,构造 CandidateQuestion。
流程
1. 提取 JSON处理 markdown 包裹
2. json_repair 修复常见格式错误
3. 校验必填字段委托 _validate_parsed_fields
4. 构造 CandidateQuestion 实例
参数:
raw: VLM 原始返回文本
video_id: 视频标识
task_type: 任务类型
skill_target: 目标技能编号
seq: 当前序号
source_nodes: 来源节点 ID 元组
返回:
CandidateQuestion 实例
异常:
ValueError: JSON 解析失败或缺少必填字段或字段值非法
"""
# Phase 1: 提取 + 修复 JSON
json_text = _extract_json_from_text(raw)
repaired = repair_json(json_text, return_objects=False)
# Phase 2: 解析为字典
try:
data = json.loads(repaired)
except json.JSONDecodeError as e:
msg = f"VLM 响应 JSON 解析失败: {e}. 原始文本: {raw[:200]}"
raise ValueError(msg) from e
if not isinstance(data, dict):
msg = f"VLM 响应顶层不是 JSON 对象: type={type(data).__name__}"
raise ValueError(msg)
# Phase 3: 字段校验
fields = _validate_parsed_fields(data)
# Phase 4: 构造 CandidateQuestion
question_id = f"{video_id}_{task_type}_{seq:04d}"
return CandidateQuestion(
question_id=question_id,
video_id=video_id,
task_type=task_type,
skill_target=skill_target,
question=fields.question,
options=fields.options,
answer=fields.answer,
source_nodes=source_nodes,
difficulty=fields.difficulty,
)
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
async def generate_one_v2(
vlm: VLMProvider,
tree: TreeIndex,
material: MaterialContext,
task_type: str,
seq: int,
*,
video_id: str,
prompt_template: str,
strategy_name: str,
skill_target: str,
reject_reason: str | None = None,
sub_pattern_instruction: str | None = None,
session_id: str,
) -> CandidateQuestion:
"""调用 VLM 生成一道候选题目。
流程
1. 构建 per-strategy prompt + 帧路径
2. 调用 VLMProvider.chat_with_images
3. 解析响应为 CandidateQuestion
4. 附加素材验证信息subtitle_sentencesframe_paths
参数:
vlm: VLM 调用端口
tree: 视频树索引当前未直接使用预留后续扩展
material: 采样素材上下文
task_type: 任务类型字符串
seq: 当前序号
video_id: 视频标识
prompt_template: prompt 模板文件名
strategy_name: 策略名称 "RETRIEVAL"
skill_target: 目标失败机制编号M1-M5
reject_reason: 上一次被门控拒绝的原因
sub_pattern_instruction: 子模式特殊指令如有
session_id: 会话 ID遥测关联
返回:
CandidateQuestion 实例包含验证材料
异常:
ValueError: VLM 响应解析失败
FileNotFoundError: prompt 模板不存在
"""
# Phase 1: 构建 prompt
messages, frame_paths = _build_v2_prompt(
prompt_template=prompt_template,
strategy_name=strategy_name,
material=material,
task_type=task_type,
seq=seq,
reject_reason=reject_reason,
sub_pattern_instruction=sub_pattern_instruction,
)
# Phase 2: 调用 VLM
logger.debug(
"generate_one_v2: strategy={}, task_type={}, seq={}, frames={}",
strategy_name,
task_type,
seq,
len(frame_paths),
)
response = await vlm.chat_with_images(
messages,
frame_paths,
session_id=session_id,
)
# Phase 3: 解析响应
candidate = _parse_v2_response(
raw=response.content,
video_id=video_id,
task_type=task_type,
skill_target=skill_target,
seq=seq,
source_nodes=material.source_nodes,
)
# Phase 4: 附加验证材料(构造新实例,因 frozen=True
candidate = CandidateQuestion(
question_id=candidate.question_id,
video_id=candidate.video_id,
task_type=candidate.task_type,
skill_target=candidate.skill_target,
question=candidate.question,
options=candidate.options,
answer=candidate.answer,
source_nodes=candidate.source_nodes,
difficulty=candidate.difficulty,
subtitle_sentences=tuple(material.subtitle_sentences),
frame_paths=tuple(material.frame_paths),
)
logger.debug(
"generate_one_v2 完成: question_id={}, difficulty={}",
candidate.question_id,
candidate.difficulty,
)
return candidate
+83 -45
View File
@@ -15,14 +15,23 @@ from core.types import GeneratedQuestion
if TYPE_CHECKING:
from pathlib import Path
from core.types import QuestionUnit
_LEGACY_DEFAULT_DIFFICULTY = "medium"
def load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]:
"""从 benchmark JSON 目录加载题目列表。
每个 JSON 文件以文件名不含扩展名作为 video_id
文件内容为题目数组
video_id 优先使用题目 JSON 中的 ``video_id`` 字段若缺失则回退到
文件不含扩展名Video-MME benchmark 按视频拆文件文件名即
video_idv2 生成题把多视频题目合并在单个 JSON 每条记录自带
``video_id``两种格式均兼容
pair 契约字段``pair_id`` / ``question_role`` / ``flip_axis`` / ``unit_id``
``.get`` 读取 benchmark 无这些键时退化为 single``question_role``
默认 "single"``unit_id`` 留空由 __post_init__ 回填为 question_id
保证历史题库可无缝加载
参数:
questions_dir: 包含 *.json 文件的目录路径
@@ -32,20 +41,30 @@ def load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]:
"""
results: list[GeneratedQuestion] = []
for path in sorted(questions_dir.glob("*.json")):
video_id = path.stem
fallback_video_id = path.stem
with open(path, encoding="utf-8") as f:
qa_list: list[dict] = json.load(f)
for qa in qa_list:
results.append(
GeneratedQuestion(
question_id=qa["question_id"],
video_id=video_id,
video_id=qa.get("video_id", fallback_video_id),
task_type=qa["task_type"],
question=qa["question"],
options=tuple(qa["options"]),
answer=qa["answer"],
source_nodes=tuple(qa.get("source_nodes", ())),
difficulty=qa.get("difficulty", _LEGACY_DEFAULT_DIFFICULTY),
family=qa.get("family"),
skill_target=qa.get("skill_target"),
difficulty_steps=qa.get("difficulty_steps"),
sub_pattern=qa.get("sub_pattern"),
# pair 契约字段:旧 benchmark 无这些键时按 single 默认兜底,
# unit_id 留空交由 GeneratedQuestion.__post_init__ 回填。
pair_id=qa.get("pair_id"),
question_role=qa.get("question_role", "single"),
flip_axis=qa.get("flip_axis"),
unit_id=qa.get("unit_id", ""),
)
)
return results
@@ -60,62 +79,83 @@ def stratified_sample(
seed: int,
min_per_class: int | None,
) -> list[GeneratedQuestion]:
"""按题型过滤后采样 size 道题,可选按对错比例分层并按题型保底。
"""按题型过滤后采样 size 个单元,可选按对错比例分层并按题型保底。
参数:
questions: 候选题目全集
correctness: question_id -> 基线是否答对
size: 采样总量
correct_ratio: 采样中"基线答对"的占比None 表示自然分布
questions: 候选题目全集single 与孪生对成员可混含
correctness: question_id -> 基线是否答对单元级正确性取成员 AND
size: 采样单元总量single 1pair 1
correct_ratio: 采样中"基线答对"单元的占比None 表示自然分布
task_types: 限定题型None 表示不限
seed: 随机种子保证可复现
min_per_class: 每个题型补足到的下限None 表示不补足
min_per_class: 每个题型补足到的单元下限None 表示不补足
返回:
采样后的题目列表
采样后的题目列表pair 单元展开为原始的两道题
异常:
ValueError: 自然分布时池不足 size或分层时某层题目不足
ValueError: 自然分布时单元池不足 size或分层时某层单元不足
关键实现:
**QuestionUnit 为采样原子**single 1pair 1size /
correct_ratio / min_per_class 均按 unit 计数孪生对两题永不被劈开
采样完成后 flatten_units 展开回逐题列表 single 输入时 build_units
与题目一一对应顺序不变rng 消耗与旧逐题实现完全一致字节级回归
build_units / flatten_units 采用函数内延迟导入loader question_gen
question_units harness harness 包初始化会反向 import question_gen
模块级导入将触发循环依赖沿用 adversarial_filter 的既有做法
"""
from app.harness.question_units import build_units, flatten_units
rng = random.Random(seed)
pool = [q for q in questions if task_types is None or q.task_type in task_types]
units = build_units(questions)
pool = [u for u in units if task_types is None or u.task_type in task_types]
if correct_ratio is None:
if len(pool) < size:
raise ValueError(f"自然分布采样不足: 需 {size} , 实有 {len(pool)} ")
raise ValueError(f"自然分布采样不足: 需 {size} 个单元, 实有 {len(pool)} ")
sampled = rng.sample(pool, size)
else:
sampled = _ratio_stratified_sample(pool, correctness, size, correct_ratio, rng)
if min_per_class is not None:
sampled = _backfill_per_class(sampled, pool, min_per_class, rng)
return sampled
return flatten_units(sampled)
def _ratio_stratified_sample(
pool: list[GeneratedQuestion],
pool: list[QuestionUnit],
correctness: dict[str, bool],
size: int,
correct_ratio: float,
rng: random.Random,
) -> list[GeneratedQuestion]:
"""按对错比例分层采样:对占 correct_ratio,其余为错
) -> list[QuestionUnit]:
"""按对错比例分层采样:对单元占 correct_ratio,其余为错单元
参数:
pool: 题型过滤后的候选
pool: 题型过滤后的候选单元
correctness: question_id -> 基线是否答对
size: 采样总量
correct_ratio: 占比
size: 采样单元总量
correct_ratio: 单元占比
rng: 随机数发生器
返回:
采样后的题目列表在前在后
采样后的单元列表单元在前单元在后
异常:
ValueError: 题或错题层不足
ValueError: 单元或错单元层不足
关键实现:
unit_correctness 采用函数内延迟导入loader question_gen
question_units harness模块级导入将触发循环依赖沿用 build_units /
flatten_units 的既有做法 strict=False 保持"缺基线对错即视为未答对"
loose 语义不变
"""
correct = [q for q in pool if correctness.get(q.question_id, False)]
wrong = [q for q in pool if not correctness.get(q.question_id, False)]
from app.harness.question_units import unit_correctness
correct = [u for u in pool if unit_correctness(u, correctness, strict=False)]
wrong = [u for u in pool if not unit_correctness(u, correctness, strict=False)]
n_correct = round(size * correct_ratio)
n_wrong = size - n_correct
if len(correct) < n_correct or len(wrong) < n_wrong:
@@ -126,42 +166,40 @@ def _ratio_stratified_sample(
def _backfill_per_class(
sampled: list[GeneratedQuestion],
pool: list[GeneratedQuestion],
sampled: list[QuestionUnit],
pool: list[QuestionUnit],
min_per_class: int,
rng: random.Random,
) -> list[GeneratedQuestion]:
"""对候选池中出现的每个题型,将采样结果补足到 min_per_class
) -> list[QuestionUnit]:
"""对候选池中出现的每个题型,将采样单元补足到 min_per_class
遍历对象是候选池 pool 里出现的全部题型非仅 sampled 命中的
保证任意稀疏题型都能拿到足额样本
保证任意稀疏题型都能拿到足额样本补足以 unit 为原子孪生对整进整出
参数:
sampled: 主采样结果不修改返回新列表
pool: 候选全集补足来源 + 题型枚举来源
min_per_class: 每个题型的下限
sampled: 主采样结果单元不修改返回新列表
pool: 候选单元全集补足来源 + 题型枚举来源
min_per_class: 每个题型的单元下限
rng: 随机数发生器
返回:
补足后的题目列表
补足后的单元列表
"""
selected_ids = {q.question_id for q in sampled}
selected_ids = {u.unit_id for u in sampled}
result = list(sampled)
counts: dict[str, int] = {}
for q in sampled:
counts[q.task_type] = counts.get(q.task_type, 0) + 1
for u in sampled:
counts[u.task_type] = counts.get(u.task_type, 0) + 1
ordered_task_types: dict[str, None] = {}
for q in pool:
ordered_task_types.setdefault(q.task_type, None)
for u in pool:
ordered_task_types.setdefault(u.task_type, None)
for task_type in ordered_task_types:
deficit = min_per_class - counts.get(task_type, 0)
if deficit <= 0:
continue
candidates = [
q for q in pool if q.task_type == task_type and q.question_id not in selected_ids
]
candidates = [u for u in pool if u.task_type == task_type and u.unit_id not in selected_ids]
take = rng.sample(candidates, min(deficit, len(candidates)))
for q in take:
selected_ids.add(q.question_id)
result.append(q)
for u in take:
selected_ids.add(u.unit_id)
result.append(u)
return result
+196
View File
@@ -0,0 +1,196 @@
"""accepted 题库的 pair 原子成对落盘 helper(纯件,不依赖 pipeline)。
三件可复用纯件供 Phase 2 pipeline on_accept 回调复用``PairPendingBuffer``
pair_id 收齐才 emit 单元``write_accepted``tmp + os.replace 原子写
``read_accepted``聚合成单元并剔除磁盘悬挂孤儿
wiring 归属本模块**只是纯件**不接任何生成侧回调真正的 on_accept wiring
** Phase 2**当前项目真实 accepted 写入点是 ``adversarial_filter.write_final_bank``
Phase 2 待重建的旧代码本模块沿用其 tmp + os.replace 模式但不 import/不改动它
unit_hash 校验``QuestionUnit.unit_hash`` 目前恒为 ""填充是 Phase 2 的事
``GeneratedQuestion`` 不携带 unit_hash 字段故把"unit_hash 不一致→拒"落地为**
pair_id 两成员的绑定一致性校验**video_id / task_type / flip_axis不一致即
fail-fast raise语义等价只有 payload 绑定一致的孪生对才允许聚合
"""
from __future__ import annotations
import json
import os
from collections import defaultdict
from typing import TYPE_CHECKING
from loguru import logger
from core.types import GeneratedQuestion, QuestionUnit
if TYPE_CHECKING:
from pathlib import Path
def _check_pair_binding(first: GeneratedQuestion, second: GeneratedQuestion) -> None:
"""校验同 pair_id 两成员的绑定一致性,不一致 fail-fastunit_hash 语义代偿)。
参数 first/second 为先后到达的孪生对成员video_id / task_type / flip_axis 任一
不一致即 raise ValueError绑定不一致的两条题目不构成同一 payload 的孪生对
拒绝聚合而非静默兜底
"""
mismatches: list[str] = []
if first.video_id != second.video_id:
mismatches.append(f"video_id: {first.video_id} != {second.video_id}")
if first.task_type != second.task_type:
mismatches.append(f"task_type: {first.task_type} != {second.task_type}")
if first.flip_axis != second.flip_axis:
mismatches.append(f"flip_axis: {first.flip_axis} != {second.flip_axis}")
if mismatches:
raise ValueError(
f"pair {first.pair_id} 两成员绑定不一致(" + "".join(mismatches) + "),拒绝聚合"
)
class PairPendingBuffer:
"""按 pair_id 收齐孪生对才 emit 单元的有状态缓冲器。
喂题接口 ``add`` 逐条消费题目single 立即 emit ``QuestionUnit.from_single``
pair 成员先缓存等同一 pair_id original+mirror 都到齐才 emit 一个 pair 单元
复用 ``QuestionUnit.from_pair`` fail-fast 校验批次末尾用
``pending_orphans`` 检测"只落 P 未落 Q"的悬挂项
"""
def __init__(self) -> None:
"""pending 以 pair_id 索引首个到达的孪生对成员,等伙伴到齐再 emit。"""
self._pending: dict[str, GeneratedQuestion] = {}
def add(self, q: GeneratedQuestion) -> QuestionUnit | None:
"""喂入一条题目 q,返回本次凑齐的单元或 None(pair 尚未配齐)。
single 立即返回 kind="single" 单元pair 首个成员缓存并返回 None第二个
成员到齐后返回 kind="pair" 单元 pair_id 两成员绑定不一致或角色非法
两个 original raise ValueError
关键实现:配齐后**先校验成功组装出 unit 才从 pending 删除**
``_check_pair_binding`` / ``_order_pair`` raise首成员仍留在 pending调用方
``pending_orphans`` 可取回被拒的悬挂成员绑定校验用显式 ValueError不依赖
会被 ``-O`` 剥除的 assert
"""
if not q.pair_id:
return QuestionUnit.from_single(q)
partner = self._pending.get(q.pair_id)
if partner is None:
self._pending[q.pair_id] = q
return None
_check_pair_binding(partner, q)
original, mirror = _order_pair(partner, q)
unit = QuestionUnit.from_pair(original, mirror)
del self._pending[q.pair_id]
return unit
def pending_orphans(self) -> list[GeneratedQuestion]:
"""返回仍未配齐的悬挂成员,供调用方在批次末尾检测"只落 P 未落 Q""""
return list(self._pending.values())
def _order_pair(
a: GeneratedQuestion, b: GeneratedQuestion
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
"""按 question_role 把两成员 a/b 定序为 (original, mirror)。
两成员不构成恰好 1 original + 1 mirror角色缺失或重复 raise ValueError
next(...) 静默 StopIteration
"""
originals = [q for q in (a, b) if q.question_role == "pair_original"]
mirrors = [q for q in (a, b) if q.question_role == "pair_mirror"]
if len(originals) != 1 or len(mirrors) != 1:
raise ValueError(
f"pair {a.pair_id} 角色非法:original={len(originals)} mirror={len(mirrors)}"
"需各恰好 1 条"
)
return originals[0], mirrors[0]
def _validate_disk_pair(pair_id: str, group: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
"""三态判定磁盘上同 pair_id 分组,区分悬挂孤儿(drop)与结构损坏(raise)。
- 恰好 1 original + 1 mirror合法孪生对额外做 ``_check_pair_binding`` 显式绑定
校验video_id/task_type/flip_axis-O 下仍生效返回其两题
- size==1只落 P 未落 Q业务上合法的悬挂孤儿warn + drop返回 []
- 其余size>2 超员 size==2 角色重复/缺角色数据损坏/外部篡改 P5
fail-loudraise ValueError pair_id 与成员构成绝不静默吞
"""
originals = sum(1 for q in group if q.question_role == "pair_original")
mirrors = sum(1 for q in group if q.question_role == "pair_mirror")
if len(group) == 2 and originals == 1 and mirrors == 1:
_check_pair_binding(group[0], group[1])
return group
if len(group) == 1:
logger.warning("磁盘悬挂孤儿 pair {}:仅 1 成员(缺伙伴),warn+drop 该 unit", pair_id)
return []
raise ValueError(
f"磁盘 pair {pair_id} 结构损坏:成员数={len(group)}"
f"original={originals} mirror={mirrors}),需恰好 1 original + 1 mirror"
)
def _keep_complete_disk_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
"""筛选磁盘读回的题目:single 全保留、pair 按 ``_validate_disk_pair`` 三态处理。
悬挂孤儿 warn+drop结构损坏/绑定不一致 raise ValueError合法成对保留后交给
build_unitssingle 全保留
"""
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
singles: list[GeneratedQuestion] = []
for q in questions:
if q.pair_id:
by_pair[q.pair_id].append(q)
else:
singles.append(q)
kept_pairs = [q for pid, grp in by_pair.items() for q in _validate_disk_pair(pid, grp)]
return singles + kept_pairs
def write_accepted(path: Path, units: list[QuestionUnit]) -> None:
"""把 units 全量原子写到 pathtmp + os.replace),孪生对两题相邻落盘。
parent 不存在则自动创建任一 pair 单元结构非法size2落盘前 fail-fast raise
关键实现:沿用 ``write_final_bank`` 的原子写模式先写同目录 ``.tmp``
``os.replace`` 覆盖保证读到的 JSON 恒完整序列化复用 T9 pools.py
``_q_to_dict``唯一 GeneratedQuestiondict schema pair 四字段函数内
import 规避 app.question_genapp.harness 循环依赖
"""
from app.harness.pools import _q_to_dict
from app.harness.question_units import flatten_units, validate_units
validate_units(units)
records = [_q_to_dict(q) for q in flatten_units(units)]
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(str(tmp), str(path))
logger.info("accepted 题库全量原子写: {} 单元 / {} 题 → {}", len(units), len(records), path)
def read_accepted(path: Path) -> list[QuestionUnit]:
"""读回 path 的 accepted JSON → 聚合为单元列表。
磁盘是外部输入 P5 全量校验后再用``_keep_complete_disk_pairs`` pair 分组三态处理
"只落 P 未落 Q"的悬挂孤儿 warn+drop不进结果 raise结构损坏超员/
角色重复或绑定不一致video_id/task_type/flip_axis显式 raise ValueError
依赖 build_units 内会被 ``-O`` 剥除的 assertsingle 全保留
关键实现:反序列化复用 T9 pools.py ``_dict_to_q``pair 四字段 .get 兼容
函数内 import 规避循环依赖sift ``build_units`` 聚合``validate_units`` 二次
防御闸门
"""
from app.harness.pools import _dict_to_q
from app.harness.question_units import build_units, validate_units
raw = json.loads(path.read_text(encoding="utf-8"))
questions = [_dict_to_q(d) for d in raw]
kept = _keep_complete_disk_pairs(questions)
return validate_units(build_units(kept))
File diff suppressed because it is too large Load Diff
+381
View File
@@ -0,0 +1,381 @@
"""确定性后处理层 — 零 LLM 的选项洗牌、指代黑名单、逐字重复率、时间锚点、素材禁区检测。
所有函数均为纯函数给定输入必定产出相同输出
用于 pipeline 出题后门控前的确定性质量检查
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
import random
# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class PostprocessResult:
"""后处理结果汇总。
属性:
options: 洗牌后的选项元组
answer: 重映射后的答案字母
referent_violations: 指代黑名单违规描述列表
verbatim_ratio: 正确选项与来源素材的逐字重复率 [0.0, 1.0]
has_time_anchor: 题目是否包含时间锚点
"""
options: tuple[str, ...]
answer: str
referent_violations: list[str]
verbatim_ratio: float
has_time_anchor: bool
# ---------------------------------------------------------------------------
# 指代黑名单(预编译正则)
# ---------------------------------------------------------------------------
_BLACKLIST_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"this\s+clip", re.IGNORECASE), "this clip"),
(re.compile(r"the\s+video", re.IGNORECASE), "the video"),
(re.compile(r"this\s+video", re.IGNORECASE), "this video"),
(re.compile(r"this\s+scene", re.IGNORECASE), "this scene"),
(re.compile(r"the\s+clip", re.IGNORECASE), "the clip"),
(re.compile(r"this\s+footage", re.IGNORECASE), "this footage"),
(re.compile(r"the\s+footage", re.IGNORECASE), "the footage"),
(re.compile(r"this\s+segment", re.IGNORECASE), "this segment"),
(re.compile(r"this\s+frame", re.IGNORECASE), "this frame"),
(re.compile(r"the\s+current\s+frame", re.IGNORECASE), "the current frame"),
(re.compile(r"frame\s+summary", re.IGNORECASE), "frame summary"),
(re.compile(r"上面的片段", re.IGNORECASE), "上面的片段"),
(re.compile(r"这段视频", re.IGNORECASE), "这段视频"),
(re.compile(r"该视频", re.IGNORECASE), "该视频"),
(re.compile(r"这个片段", re.IGNORECASE), "这个片段"),
(re.compile(r"视频中", re.IGNORECASE), "视频中"),
]
# ---------------------------------------------------------------------------
# 时间锚点正则与短语
# ---------------------------------------------------------------------------
_TIMESTAMP_PATTERN = re.compile(r"\d{1,2}:\d{2}")
_TIME_ANCHOR_PHRASES: list[re.Pattern[str]] = [
re.compile(r"at\s+the\s+beginning", re.IGNORECASE),
re.compile(r"at\s+the\s+end", re.IGNORECASE),
re.compile(r"in\s+the\s+beginning", re.IGNORECASE),
re.compile(r"at\s+the\s+start", re.IGNORECASE),
re.compile(r"开头", re.IGNORECASE),
re.compile(r"结尾", re.IGNORECASE),
re.compile(r"末尾", re.IGNORECASE),
re.compile(r"片头", re.IGNORECASE),
re.compile(r"片尾", re.IGNORECASE),
]
# ---------------------------------------------------------------------------
# T1 / T7 素材禁区正则
# ---------------------------------------------------------------------------
# T1: 瞬时动作
_T1_INSTANT_ACTION_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"瞬间|一闪而过|转瞬即逝|一瞬间|刹那", re.IGNORECASE),
re.compile(r"flash|instant|split\s*second|blink", re.IGNORECASE),
]
# T1: 记分牌时序
_T1_SCOREBOARD_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"记分牌|比分.*变|比分.*更新|得分.*变化", re.IGNORECASE),
re.compile(r"scoreboard|score\s*(changed|updated|went)", re.IGNORECASE),
]
# T1: 无对白因果
_T1_NO_DIALOGUE_CAUSAL_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"无对白.*因果|因果.*无法.*判断", re.IGNORECASE),
re.compile(r"no\s+dialogue.*caus|cannot.*determin.*caus", re.IGNORECASE),
]
# T7: 选项重复
_T7_OPTION_REPETITION_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"重复选项|选项.*相同|选项.*重复|完全相同", re.IGNORECASE),
re.compile(r"duplicate\s+option|identical\s+option|same\s+option", re.IGNORECASE),
]
# T7: 计数边界口径含糊
_T7_COUNTING_AMBIGUITY_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"大约.*数量|数量不确定|难以.*计数|不确定.*几|约.*个", re.IGNORECASE),
re.compile(r"approximate.*count|uncertain.*number|hard\s+to\s+count", re.IGNORECASE),
]
# ---------------------------------------------------------------------------
# 公开函数
# ---------------------------------------------------------------------------
def shuffle_options(
options: tuple[str, ...],
answer: str,
rng: random.Random,
) -> tuple[tuple[str, ...], str]:
"""确定性洗牌选项并重映射答案字母。
通过 index permutation 打乱选项顺序为每个选项重新分配 A/B/C/D 前缀
并将答案字母映射到正确选项的新位置
参数:
options: 原始选项元组格式为 ("A. text1", "B. text2", ...)
answer: 原始答案字母"A"/"B"/"C"/"D"
rng: 可控随机数生成器保证确定性
返回:
(新选项元组, 新答案字母) 选项文本不变仅前缀和顺序改变
"""
# Phase 1: 提取纯文本(去掉 "X. " 前缀)
texts = [opt[3:] for opt in options]
# Phase 2: 确定原正确选项的文本
correct_idx = ord(answer) - ord("A")
correct_text = texts[correct_idx]
# Phase 3: 生成随机排列
indices = list(range(len(texts)))
rng.shuffle(indices)
# Phase 4: 按排列重组,分配新前缀
new_options: list[str] = []
new_answer = ""
for new_pos, old_idx in enumerate(indices):
letter = chr(ord("A") + new_pos)
new_options.append(f"{letter}. {texts[old_idx]}")
if texts[old_idx] == correct_text:
new_answer = letter
return tuple(new_options), new_answer
def check_referent_blacklist(question_text: str) -> list[str]:
"""检测题目文本中的自指代语言。
benchmark 题目中自指代 "this clip""这段视频"会泄露视频上下文
使题目脱离视频后无法独立理解
参数:
question_text: 题目文本
返回:
违规描述列表空列表表示通过
"""
violations: list[str] = []
for pattern, label in _BLACKLIST_PATTERNS:
if pattern.search(question_text):
violations.append(f"检测到自指代: '{label}'")
return violations
def _ngram_overlap_ratio(
text_words: list[str],
source_ngrams: set[tuple[str, ...]],
window: int,
) -> float:
"""计算单段文本与来源 n-gram 集合的重叠率。
参数:
text_words: 待比较文本的分词列表已小写化
source_ngrams: 来源素材的 n-gram 集合
window: n-gram 窗口大小
返回:
重叠率 [0.0, 1.0]词数不足窗口大小时返回 0.0
"""
if len(text_words) < window:
return 0.0
text_ngrams: set[tuple[str, ...]] = {
tuple(text_words[i : i + window]) for i in range(len(text_words) - window + 1)
}
if not text_ngrams:
return 0.0
overlap = text_ngrams & source_ngrams
return len(overlap) / len(text_ngrams)
def check_verbatim(
question_text: str,
correct_option: str,
source_texts: list[str],
window: int = 6,
) -> float:
"""计算题目文本和正确选项与来源素材的最大逐字重复率。
使用滑动窗口 n-gram 集合交集方法分别从题目文本和选项文本提取 n-gram
各自与来源文本的 n-gram 集合求交集返回两者中较大的重叠比例
参数:
question_text: 题目文本
correct_option: 正确选项文本 "X. " 前缀
source_texts: 来源素材文本列表
window: n-gram 窗口大小
返回:
最大重复率 [0.0, 1.0]0.0 表示无重叠1.0 表示完全复制
"""
# Phase 1: 构造来源文本的 n-gram 集合
source_ngrams: set[tuple[str, ...]] = set()
for source in source_texts:
words = source.lower().split()
for i in range(len(words) - window + 1):
source_ngrams.add(tuple(words[i : i + window]))
if not source_ngrams:
return 0.0
# Phase 2: 计算题目文本的重叠率
question_words = question_text.lower().split()
question_ratio = _ngram_overlap_ratio(question_words, source_ngrams, window)
# Phase 3: 提取选项纯文本(去掉可能的 "X. " 前缀)
option_text = correct_option
if len(option_text) >= 3 and option_text[1] == "." and option_text[2] == " ":
option_text = option_text[3:]
option_words = option_text.lower().split()
option_ratio = _ngram_overlap_ratio(option_words, source_ngrams, window)
# Phase 4: 返回两者中较大的重叠率
return max(question_ratio, option_ratio)
def has_time_anchor(question_text: str) -> bool:
"""检测题目中是否包含时间锚点。
时间锚点包括数字时间戳 "01:30"和时间短语 "at the beginning""开头"
参数:
question_text: 题目文本
返回:
True 表示包含时间锚点
"""
# Phase 1: 检查数字时间戳
if _TIMESTAMP_PATTERN.search(question_text):
return True
# Phase 2: 检查时间短语
return any(pattern.search(question_text) for pattern in _TIME_ANCHOR_PHRASES)
def _match_any(text: str, patterns: list[re.Pattern[str]]) -> bool:
"""判断文本是否匹配任一正则模式。
参数:
text: 待检测文本
patterns: 预编译正则列表
返回:
True 表示至少命中一条模式
"""
return any(p.search(text) for p in patterns)
# 素材禁区规则表:(模式列表, 违规描述)
_FORBIDDEN_MATERIAL_RULES: list[tuple[list[re.Pattern[str]], str]] = [
(_T1_INSTANT_ACTION_PATTERNS, "T1 违规: 素材包含瞬时动作描述,不适合出题"),
(_T1_SCOREBOARD_PATTERNS, "T1 违规: 素材包含记分牌时序信息,不适合出题"),
(_T1_NO_DIALOGUE_CAUSAL_PATTERNS, "T1 违规: 素材缺乏对白因果线索,不适合出题"),
(_T7_OPTION_REPETITION_PATTERNS, "T7 违规: 素材暗示可能产生重复选项"),
(_T7_COUNTING_AMBIGUITY_PATTERNS, "T7 违规: 素材包含计数边界含糊描述"),
]
def check_forbidden_material(source_nodes_text: str, task_type: str) -> list[str]:
"""出题禁区:检测 T1 类素材和 T7 噪声模式。
T1 类素材不适合出题的内容:
- 瞬时动作画面一闪而过无法稳定观察
- 记分牌时序依赖数字变化的时序信息
- 无对白因果缺乏语言线索的因果推理
T7 噪声模式选项质量问题:
- 选项重复多个选项表述相同
- 计数边界口径含糊数量描述不确定
参数:
source_nodes_text: 来源节点的拼接文本
task_type: 题型名称用于上下文感知检测
返回:
违规描述列表空列表表示通过
"""
return [
message
for patterns, message in _FORBIDDEN_MATERIAL_RULES
if _match_any(source_nodes_text, patterns)
]
def run_postprocess(
question_text: str,
options: tuple[str, ...],
answer: str,
source_texts: list[str],
rng: random.Random,
) -> PostprocessResult:
"""编排全部后处理检查,返回汇总结果。
执行流程:
1. 选项洗牌确定性
2. 指代黑名单检测
3. 逐字重复率计算基于洗牌后的正确选项
4. 时间锚点检测
参数:
question_text: 题目文本
options: 原始选项元组"A. text", "B. text", ...
answer: 原始答案字母
source_texts: 来源素材文本列表
rng: 可控随机数生成器
返回:
PostprocessResult 汇总实例
"""
# Phase 1: 选项洗牌
shuffled_options, new_answer = shuffle_options(options, answer, rng)
# Phase 2: 指代黑名单
referent_violations = check_referent_blacklist(question_text)
# Phase 3: 逐字重复率(用洗牌后正确选项的文本)
correct_idx = ord(new_answer) - ord("A")
correct_option_text = shuffled_options[correct_idx]
verbatim_ratio = check_verbatim(question_text, correct_option_text, source_texts)
# Phase 4: 时间锚点
time_anchor = has_time_anchor(question_text)
logger.debug(
"后处理完成: referent_violations={}, verbatim_ratio={:.3f}, has_time_anchor={}",
len(referent_violations),
verbatim_ratio,
time_anchor,
)
return PostprocessResult(
options=shuffled_options,
answer=new_answer,
referent_violations=referent_violations,
verbatim_ratio=verbatim_ratio,
has_time_anchor=time_anchor,
)
File diff suppressed because it is too large Load Diff
+597
View File
@@ -0,0 +1,597 @@
"""v2 素材采样器 — 基于采样约束的树节点采样与上下文收集。
v1 synthesizer 的基础上引入 SamplingConstraint 约束验证
为每次出题提供更丰富的素材上下文字幕 L2 上下文帧路径
典型调用路径::
material = sample_material_v2(
tree=tree_index,
task_type="Action Reasoning",
used_node_ids=already_used,
rng=rng,
level=2,
constraint=my_constraint,
)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
import random
from app.question_gen.families import SamplingConstraint
from app.tree.index import L1Node, L2Node, TreeIndex
# ---------------------------------------------------------------------------
# 题型 → 采样层级映射
# ---------------------------------------------------------------------------
_TASK_TYPE_TO_LEVEL: dict[str, int] = {
# Level 3(细粒度帧级)
"Action Recognition": 3,
"Object Recognition": 3,
"Attribute Perception": 3,
"OCR Problems": 3,
# Level 2(片段/事件级)
"Action Reasoning": 2,
"Object Reasoning": 2,
"Information Synopsis": 2,
"Counting Problem": 2,
# Level 1(段落/场景级)
"Temporal Reasoning": 1,
"Temporal Perception": 1,
"Spatial Reasoning": 1,
"Spatial Perception": 1,
}
# ---------------------------------------------------------------------------
# 数据类型
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class AnchorContext:
"""采样锚点上下文。
属性:
node_id: 锚节点 ID
level: 锚节点所在层级1/2/3
l2_id: 锚节点所属的 L2 节点 ID若自身为 L2 则等于 node_id
若为 L1 则取其第一个 L2 子节点 ID
"""
node_id: str
level: int
l2_id: str
@dataclass(frozen=True)
class MaterialContext:
"""采样素材上下文 — 出题所需的全部素材打包。
属性:
anchor: 采样锚点信息
source_nodes: 参与采样的节点 ID 元组
subtitle_sentences: 锚节点子树中收集的字幕句列表
frame_paths: 锚节点子树中可用的帧路径列表
cross_l2_texts: L2 段的上下文文本列表 cross_l2_span 时填充
"""
anchor: AnchorContext
source_nodes: tuple[str, ...]
subtitle_sentences: list[str]
frame_paths: list[str]
cross_l2_texts: list[str]
# ---------------------------------------------------------------------------
# 内部索引辅助
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _ResolvedSubtree:
"""节点解析结果 — 将任意层级 node_id 统一解析为 L2 节点列表 + 父 L1。
属性:
l2_nodes: 与目标节点关联的 L2 节点列表
parent_l1: L1 节点用于跨 L2 判断
"""
l2_nodes: list[L2Node]
parent_l1: L1Node | None
def _find_l2_node(tree: TreeIndex, l2_id: str) -> tuple[L2Node, L1Node] | None:
"""按 ID 定位 L2 节点及其父 L1。
参数:
tree: 三层树索引
l2_id: L2 节点 ID
返回:
(L2Node, 父L1Node) 元组未找到返回 None
"""
for l1 in tree.roots:
for l2 in l1.children:
if l2.id == l2_id:
return (l2, l1)
return None
def _find_l1_node(tree: TreeIndex, l1_id: str) -> L1Node | None:
"""按 ID 定位 L1 节点。
参数:
tree: 三层树索引
l1_id: L1 节点 ID
返回:
L1Node未找到返回 None
"""
for l1 in tree.roots:
if l1.id == l1_id:
return l1
return None
def _find_l3_parent(tree: TreeIndex, l3_id: str) -> tuple[L2Node, L1Node] | None:
"""按 L3 ID 定位其所属的 L2 和 L1 节点。
参数:
tree: 三层树索引
l3_id: L3 节点 ID
返回:
(L2Node, L1Node) 元组未找到返回 None
"""
for l1 in tree.roots:
for l2 in l1.children:
for l3 in l2.children:
if l3.id == l3_id:
return (l2, l1)
return None
def _resolve_subtree(tree: TreeIndex, node_id: str) -> _ResolvedSubtree | None:
"""将任意层级节点 ID 解析为关联的 L2 节点列表和父 L1。
参数:
tree: 三层树索引
node_id: 任意层级的节点 ID
返回:
_ResolvedSubtree 实例节点不存在时返回 None
"""
# 尝试作为 L2
result = _find_l2_node(tree, node_id)
if result is not None:
l2_node, parent_l1 = result
return _ResolvedSubtree(l2_nodes=[l2_node], parent_l1=parent_l1)
# 尝试作为 L1
l1_node = _find_l1_node(tree, node_id)
if l1_node is not None:
return _ResolvedSubtree(l2_nodes=list(l1_node.children), parent_l1=l1_node)
# 尝试作为 L3
l3_result = _find_l3_parent(tree, node_id)
if l3_result is not None:
l2_node, parent_l1 = l3_result
return _ResolvedSubtree(l2_nodes=[l2_node], parent_l1=parent_l1)
return None
# ---------------------------------------------------------------------------
# 约束检查辅助(单一职责)
# ---------------------------------------------------------------------------
def _count_l3_descendants(l2_nodes: list[L2Node]) -> int:
"""统计 L2 节点列表下的 L3 总数。
参数:
l2_nodes: L2 节点列表
返回:
L3 节点总数
"""
return sum(len(l2.children) for l2 in l2_nodes)
def _has_frames(l2_nodes: list[L2Node]) -> bool:
"""检查 L2 节点列表的子树中是否有可用帧。
参数:
l2_nodes: L2 节点列表
返回:
True 表示至少有一个 L3 frame_path
"""
return any(l3.frame_path for l2 in l2_nodes for l3 in l2.children)
def _count_subtitles(l2_nodes: list[L2Node]) -> int:
"""统计 L2 节点列表中全部字幕数(L2 + L3)。
参数:
l2_nodes: L2 节点列表
返回:
非空字幕总数
"""
count = 0
for l2 in l2_nodes:
if l2.card.subtitle:
count += 1
count += sum(1 for l3 in l2.children if l3.card.subtitle)
return count
# ---------------------------------------------------------------------------
# 公开辅助函数
# ---------------------------------------------------------------------------
def _validate_sampling_constraints(
tree: TreeIndex, node_id: str, constraint: SamplingConstraint
) -> bool:
"""校验指定节点是否满足采样约束。
根据节点层级自动判断检查范围
- L2 节点检查其子 L3 的帧/字幕数量
- L1 节点检查其下全部 L2/L3 的帧/字幕总数
- L3 节点检查其所属 L2 的子树
参数:
tree: 三层树索引
node_id: 待检查节点 ID
constraint: 采样约束条件
返回:
True 表示满足所有约束False 表示至少一项不满足
"""
resolved = _resolve_subtree(tree, node_id)
if resolved is None:
return False
if _count_l3_descendants(resolved.l2_nodes) < constraint.min_l3_nodes:
return False
if constraint.require_frames and not _has_frames(resolved.l2_nodes):
return False
if _count_subtitles(resolved.l2_nodes) < constraint.min_subtitles:
return False
return not (
constraint.cross_l2_span
and (resolved.parent_l1 is None or len(resolved.parent_l1.children) < 2)
)
def _subtitles_from_l2_list(l2_nodes: list[L2Node]) -> list[str]:
"""从 L2 节点列表收集全部非空字幕。
参数:
l2_nodes: L2 节点列表
返回:
非空字幕字符串列表
"""
sentences: list[str] = []
for l2 in l2_nodes:
if l2.card.subtitle:
sentences.append(l2.card.subtitle)
for l3 in l2.children:
if l3.card.subtitle:
sentences.append(l3.card.subtitle)
return sentences
def _collect_subtitle_sentences(tree: TreeIndex, node_ids: tuple[str, ...]) -> list[str]:
"""从指定节点集合中收集字幕句。
遍历每个 node_id 对应的子树提取非空字幕
L2 节点提取自身 + L3 字幕 L1 提取下属全部
参数:
tree: 三层树索引
node_ids: 待收集字幕的节点 ID 元组
返回:
非空字幕句列表
"""
sentences: list[str] = []
for nid in node_ids:
resolved = _resolve_subtree(tree, nid)
if resolved is not None:
sentences.extend(_subtitles_from_l2_list(resolved.l2_nodes))
return sentences
def _collect_cross_l2_context(tree: TreeIndex, anchor_l2_id: str, max_peers: int = 3) -> list[str]:
"""收集锚 L2 的同级 L2 节点描述文本(跨 L2 上下文)。
找到锚 L2 所属的 L1 父节点取该父节点下除锚 L2 之外的其他 L2 描述
参数:
tree: 三层树索引
anchor_l2_id: L2 节点 ID
max_peers: 最多返回的同级 L2 描述数量
返回:
同级 L2 event_description 列表最多 max_peers
"""
result = _find_l2_node(tree, anchor_l2_id)
if result is None:
return []
_, parent_l1 = result
peers: list[str] = []
for l2 in parent_l1.children:
if l2.id != anchor_l2_id:
peers.append(l2.card.event_description)
if len(peers) >= max_peers:
break
return peers
# ---------------------------------------------------------------------------
# 层级采样策略
# ---------------------------------------------------------------------------
def _sample_l3_node(
tree: TreeIndex,
used_node_ids: set[str],
rng: random.Random,
) -> tuple[str, str] | None:
"""随机采样一个未使用的 L3 节点,返回 (l3_id, 所属l2_id)。
参数:
tree: 三层树索引
used_node_ids: 已用节点 ID 集合
rng: 随机数生成器
返回:
(l3_id, l2_id) 元组无候选返回 None
"""
candidates: list[tuple[str, str]] = []
for l1 in tree.roots:
for l2 in l1.children:
for l3 in l2.children:
if l3.id not in used_node_ids:
candidates.append((l3.id, l2.id))
if not candidates:
return None
return rng.choice(candidates)
def _sample_l2_node(
tree: TreeIndex,
used_node_ids: set[str],
rng: random.Random,
) -> tuple[str, str] | None:
"""随机采样一个未使用的 L2 节点,返回 (l2_id, l2_id)。
参数:
tree: 三层树索引
used_node_ids: 已用节点 ID 集合
rng: 随机数生成器
返回:
(l2_id, l2_id) 元组无候选返回 None
"""
candidates: list[str] = []
for l1 in tree.roots:
for l2 in l1.children:
if l2.id not in used_node_ids:
candidates.append(l2.id)
if not candidates:
return None
chosen = rng.choice(candidates)
return (chosen, chosen)
def _sample_l1_node(
tree: TreeIndex,
used_node_ids: set[str],
rng: random.Random,
) -> tuple[str, str] | None:
"""随机采样一个未使用的 L1 节点,返回 (l1_id, 首个子l2_id)。
参数:
tree: 三层树索引
used_node_ids: 已用节点 ID 集合
rng: 随机数生成器
返回:
(l1_id, first_l2_id) 元组无候选返回 None
"""
candidates: list[tuple[str, str]] = []
for l1 in tree.roots:
if l1.id not in used_node_ids and l1.children:
candidates.append((l1.id, l1.children[0].id))
if not candidates:
return None
return rng.choice(candidates)
def _frames_from_l2_list(l2_nodes: list[L2Node]) -> list[str]:
"""从 L2 节点列表收集全部可用帧路径。
参数:
l2_nodes: L2 节点列表
返回:
帧路径字符串列表
"""
return [l3.frame_path for l2 in l2_nodes for l3 in l2.children if l3.frame_path]
def _collect_frame_paths(tree: TreeIndex, node_id: str) -> list[str]:
"""收集节点子树下的所有可用帧路径。
参数:
tree: 三层树索引
node_id: 目标节点 ID
返回:
帧路径列表
"""
resolved = _resolve_subtree(tree, node_id)
if resolved is None:
return []
return _frames_from_l2_list(resolved.l2_nodes)
def _collect_source_nodes(tree: TreeIndex, node_id: str) -> tuple[str, ...]:
"""收集节点子树涉及的全部节点 ID(包含自身)。
参数:
tree: 三层树索引
node_id: 目标节点 ID
返回:
相关节点 ID 元组
"""
ids: list[str] = [node_id]
# L2 节点:加入子 L3
result = _find_l2_node(tree, node_id)
if result is not None:
l2_node, _ = result
for l3 in l2_node.children:
ids.append(l3.id)
return tuple(ids)
# L1 节点:加入子 L2 + L3
l1_node = _find_l1_node(tree, node_id)
if l1_node is not None:
for l2 in l1_node.children:
ids.append(l2.id)
for l3 in l2.children:
ids.append(l3.id)
return tuple(ids)
# L3 节点:仅自身
return tuple(ids)
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def sample_material_v2(
tree: TreeIndex,
task_type: str,
used_node_ids: set[str],
rng: random.Random,
*,
level: int,
constraint: SamplingConstraint,
max_attempts: int = 10,
) -> MaterialContext:
"""基于采样约束从视频树中采样素材上下文。
采样流程
1. 按指定 level 确定采样层级
2. 随机选取候选节点排除 used_node_ids
3. 验证 SamplingConstraint 约束
4. 约束不满足则重试最多 max_attempts
5. 收集字幕帧路径 L2 上下文
参数:
tree: 三层树索引
task_type: 任务类型字符串
used_node_ids: 本轮已用节点 ID 集合
rng: 可控随机数生成器
level: 采样层级1/2/3
constraint: 采样约束条件
max_attempts: 最大尝试次数
返回:
MaterialContext 实例
异常:
ValueError: level 不在 {1, 2, 3}
RuntimeError: 耗尽 max_attempts 次尝试仍无法满足约束
"""
if level not in (1, 2, 3):
msg = f"level 必须为 1、2 或 3,收到: {level}"
raise ValueError(msg)
for attempt in range(max_attempts):
# Phase 1: 按层级采样候选节点
if level == 3:
sampled = _sample_l3_node(tree, used_node_ids, rng)
elif level == 2:
sampled = _sample_l2_node(tree, used_node_ids, rng)
else:
sampled = _sample_l1_node(tree, used_node_ids, rng)
if sampled is None:
logger.debug(
"sample_material_v2 尝试 {}/{}: 无可用候选节点 (level={})",
attempt + 1,
max_attempts,
level,
)
continue
node_id, l2_id = sampled
# Phase 2: 验证约束
if not _validate_sampling_constraints(tree, node_id, constraint):
logger.debug(
"sample_material_v2 尝试 {}/{}: 约束违反 (node={})",
attempt + 1,
max_attempts,
node_id,
)
continue
# Phase 3: 构造 AnchorContext
anchor = AnchorContext(node_id=node_id, level=level, l2_id=l2_id)
# Phase 4: 收集素材
source_nodes = _collect_source_nodes(tree, node_id)
subtitle_sentences = _collect_subtitle_sentences(tree, (node_id,))
frame_paths = _collect_frame_paths(tree, node_id)
# Phase 5: 跨 L2 上下文(仅 cross_l2_span 时收集)
cross_l2_texts: list[str] = []
if constraint.cross_l2_span:
cross_l2_texts = _collect_cross_l2_context(tree, l2_id)
logger.debug(
"sample_material_v2 成功: node={}, level={}, attempt={}/{}",
node_id,
level,
attempt + 1,
max_attempts,
)
return MaterialContext(
anchor=anchor,
source_nodes=source_nodes,
subtitle_sentences=subtitle_sentences,
frame_paths=frame_paths,
cross_l2_texts=cross_l2_texts,
)
raise RuntimeError(
f"sample_material_v2: 耗尽 max_attempts={max_attempts} 次尝试,"
f"无法为 task_type='{task_type}' (level={level}) 满足采样约束"
)
+262
View File
@@ -0,0 +1,262 @@
"""题型出题策略 — Clean Architecture 的 Strategy 层。
将出题管线从 5 个粗粒度 QuestionFamilySpec 替换为 12 个题型级别的
TaskTypeStrategyBaseTaskTypeStrategy 封装现有 family 行为
特化策略 ActionRecognitionStrategy自包含
典型用法::
strategy = get_strategy("Action Reasoning")
level = strategy.sampling_level
constraint = strategy.sampling_constraint
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from app.question_gen.families import (
ENUMERATION_FAMILY,
REASONING_FAMILY,
RETRIEVAL_FAMILY,
SPATIAL_FAMILY,
VISUAL_FAMILY,
QuestionFamilySpec,
SamplingConstraint,
)
from app.question_gen.sampler_v2 import _TASK_TYPE_TO_LEVEL
if TYPE_CHECKING:
import random
# ---------------------------------------------------------------------------
# SubPattern
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SubPattern:
"""出题子模式 — 靶向特定失败机制。
属性:
name: 子模式标识名
weight: 选择权重按比例分配
sampling_level_override: 覆盖策略默认采样层级None = 不覆盖
constraint_override: 覆盖策略默认采样约束None = 不覆盖
instruction: 核心出题指令1-3 句话
positive_examples: VME 原题 few-shot 示范
negative_examples: 反面示例
distractor_rules: 干扰项构造规则
supports_flip: 是否支持配对翻转门Phase B 默认 False
flip_axis: 翻转轴"before/after" | "first/last"None 表示不翻转
"""
name: str
weight: float
sampling_level_override: int | None
constraint_override: SamplingConstraint | None
instruction: str
positive_examples: list[dict] = field(default_factory=list)
negative_examples: list[dict] = field(default_factory=list)
distractor_rules: str = ""
supports_flip: bool = False
flip_axis: str | None = None
# ---------------------------------------------------------------------------
# Protocol
# ---------------------------------------------------------------------------
@runtime_checkable
class TaskTypeStrategy(Protocol):
"""题型出题策略 — pipeline 的唯一接口。"""
@property
def task_type(self) -> str: ...
@property
def sampling_level(self) -> int: ...
@property
def sampling_constraint(self) -> SamplingConstraint: ...
@property
def prompt_template(self) -> str: ...
@property
def strategy_name(self) -> str: ...
@property
def skill_target(self) -> str: ...
@property
def leak_probe_template(self) -> str: ...
@property
def uses_grounded_selector(self) -> bool: ...
def select_sub_pattern(self, rng: random.Random) -> SubPattern | None: ...
def build_prompt_context(self, material: Any, sub_pattern: SubPattern | None) -> dict: ...
def extra_gates(self, candidate: Any) -> list: ...
# ---------------------------------------------------------------------------
# BaseTaskTypeStrategy
# ---------------------------------------------------------------------------
# 消歧绑定表:多归属题型确定性绑定到一个 family
_TASK_TYPE_TO_FAMILY: dict[str, QuestionFamilySpec] = {
"Action Recognition": VISUAL_FAMILY, # fallback — 注册表中已被 ActionRecognitionStrategy 替换
"Object Recognition": RETRIEVAL_FAMILY,
"Object Reasoning": REASONING_FAMILY,
"Action Reasoning": REASONING_FAMILY,
"Attribute Perception": VISUAL_FAMILY,
"OCR Problems": VISUAL_FAMILY,
"Counting Problem": ENUMERATION_FAMILY,
"Information Synopsis": REASONING_FAMILY,
"Temporal Reasoning": ENUMERATION_FAMILY,
"Temporal Perception": ENUMERATION_FAMILY,
"Spatial Reasoning": SPATIAL_FAMILY,
"Spatial Perception": SPATIAL_FAMILY,
}
class BaseTaskTypeStrategy:
"""封装现有 QuestionFamilySpec 行为的默认策略。
所有属性委托给绑定的 family确保未特化题型的行为不变
参数:
task_type: 题型名
family: 绑定的 QuestionFamilySpec
"""
def __init__(self, task_type: str, family: QuestionFamilySpec) -> None:
self._task_type = task_type
self._family = family
@property
def task_type(self) -> str:
"""返回题型名。"""
return self._task_type
@property
def sampling_level(self) -> int:
"""返回采样层级(从 _TASK_TYPE_TO_LEVEL 查询)。"""
return _TASK_TYPE_TO_LEVEL[self._task_type]
@property
def sampling_constraint(self) -> SamplingConstraint:
"""返回采样约束(委托给绑定的 family)。"""
return self._family.sampling
@property
def prompt_template(self) -> str:
"""返回 prompt 模板文件名(委托给绑定的 family)。"""
return self._family.prompt_template
@property
def strategy_name(self) -> str:
"""返回策略名(即 family.name)。"""
return self._family.name
@property
def skill_target(self) -> str:
"""返回目标失败机制编号(委托给绑定的 family)。"""
return self._family.skill_target
@property
def leak_probe_template(self) -> str:
"""返回泄漏探测模板文件名(委托给绑定的 family.leak_profile)。"""
return self._family.leak_profile.probe_template
@property
def uses_grounded_selector(self) -> bool:
"""默认不启用 grounded selector11 类题型走原路径)。"""
return False
def select_sub_pattern(self, rng: random.Random) -> SubPattern | None:
"""BaseTaskTypeStrategy 无子模式。"""
return None
def build_prompt_context(self, material: Any, sub_pattern: SubPattern | None) -> dict:
"""返回基础 prompt 上下文(family_name + prompt_template)。"""
return {
"family_name": self._family.name,
"prompt_template": self._family.prompt_template,
}
def extra_gates(self, candidate: Any) -> list:
"""BaseTaskTypeStrategy 无额外 gate。"""
return []
# ---------------------------------------------------------------------------
# 注册表
# ---------------------------------------------------------------------------
_STRATEGY_REGISTRY: dict[str, TaskTypeStrategy] = {}
def register_strategy(strategy: TaskTypeStrategy) -> None:
"""注册一个题型策略。同一 task_type 重复注册会覆盖。
参数:
strategy: 实现 TaskTypeStrategy 接口的策略实例
"""
_STRATEGY_REGISTRY[strategy.task_type] = strategy
_BUILTIN_REGISTERED = False
def get_strategy(task_type: str) -> TaskTypeStrategy:
"""获取题型策略。未注册的自动创建 BaseTaskTypeStrategy。
首次调用时延迟注册内建特化策略避免循环导入
参数:
task_type: 题型名
返回:
TaskTypeStrategy 实例
异常:
KeyError: task_type 不在消歧绑定表和注册表中
"""
global _BUILTIN_REGISTERED # noqa: PLW0603
if not _BUILTIN_REGISTERED:
_BUILTIN_REGISTERED = True
_register_builtin_strategies()
if task_type in _STRATEGY_REGISTRY:
return _STRATEGY_REGISTRY[task_type]
return _build_default_strategy(task_type)
def _build_default_strategy(task_type: str) -> BaseTaskTypeStrategy:
"""根据消歧绑定表自动构造 BaseTaskTypeStrategy。
参数:
task_type: 题型名
返回:
BaseTaskTypeStrategy 实例
异常:
KeyError: task_type 不在消歧绑定表中
"""
family = _TASK_TYPE_TO_FAMILY[task_type]
return BaseTaskTypeStrategy(task_type=task_type, family=family)
def _register_builtin_strategies() -> None:
"""注册内建的特化策略。由 get_strategy 首次调用时延迟执行。"""
from app.question_gen.strategy_action_recognition import ActionRecognitionStrategy
register_strategy(ActionRecognitionStrategy())
@@ -0,0 +1,347 @@
"""Action Recognition 特化出题策略。
靶向 Agent 在动作识别类题目上的 6 种典型失败机制通过加权随机
SubPattern 选择为 VLM 出题提供聚焦指令
BaseTaskTypeStrategy 完全自包含不依赖 QuestionFamilySpec
采样从 L3 提升至 L2跨段推理需要更广视野3 SubPattern
进一步覆盖至 L1 以测试全局追踪能力
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from app.question_gen.families import SamplingConstraint
from app.question_gen.strategy import SubPattern
if TYPE_CHECKING:
import random
# ---------------------------------------------------------------------------
# 6 个 SubPattern 定义
# ---------------------------------------------------------------------------
_PREMATURE_EVIDENCE_ANCHORING = SubPattern(
name="premature_evidence_anchoring",
weight=0.20,
sampling_level_override=1,
constraint_override=None,
instruction=(
"设计一道需要考察视频中完整证据链的动作识别题。"
"正确答案的关键证据出现在视频后半段或跨多个片段,"
"但视频前段包含一个看似合理的局部匹配——"
"Agent 若仅凭首条匹配停止搜索就会出错。"
),
positive_examples=[
{
"question": "视频中厨师最终采用了哪种烹饪方式?",
"answer": "B. 蒸",
"why": "厨师先演示了炒(前段),但最终菜品使用蒸制(后段),"
"锚定首段证据的 Agent 会错选''",
},
],
negative_examples=[
{
"question": "视频开头厨师在做什么?",
"why": "答案仅需前段信息,不会触发过早锚定失败。",
},
],
distractor_rules=(
"将视频前段真实出现的局部匹配动作设为强干扰项——它真实发生,"
"仅在【时点】这一单一维度上与正解不同(前段 vs 最终结论段)。"
"其余干扰项亦须是视频中真实发生的动作,严禁缺席事件。"
),
)
_TEMPORAL_REASONING_FAILURE = SubPattern(
name="temporal_reasoning_failure",
weight=0.20,
sampling_level_override=1,
constraint_override=None,
instruction=(
"设计一道要求正确排序或定位第 N 次出现的动作识别题。"
"题目需要 Agent 追踪事件的时间先后顺序,"
"或准确识别某动作在视频中第几次出现。"
"打乱时序或错误计数即会答错。"
),
positive_examples=[
{
"question": "运动员第三次尝试起跳前做了什么准备动作?",
"answer": "C. 深蹲热身",
"why": "需要准确定位'第三次'起跳而非其他次,"
"时序推理失败的 Agent 会混淆不同次尝试的准备动作。",
},
],
negative_examples=[
{
"question": "运动员在视频中做了什么?",
"why": "不涉及时序排序或计数,Agent 无需追踪顺序。",
},
],
distractor_rules=(
"干扰项必须是视频中真实发生的事件,仅在【事件时序/顺序】这一单一维度上与正解不同——"
"即同一组真实事件的错误排列或错误的第 N 次定位。"
"严禁使用视频中未出现的缺席事件作为干扰项。"
),
supports_flip=True,
flip_axis="before/after",
)
_SEMANTIC_RIGIDITY = SubPattern(
name="semantic_rigidity",
weight=0.15,
sampling_level_override=None,
constraint_override=None,
instruction=(
"设计一道动作识别题,正确选项使用与视频原始描述不同的同义表达。"
"Agent 需理解语义等价而非依赖字面匹配——"
"例如视频字幕说'奔跑',正确选项写作'快速移动'"
),
positive_examples=[
{
"question": "工人对墙面进行了什么操作?",
"answer": "A. 涂覆保护层",
"why": "视频中字幕描述为'刷漆',正确答案改写为'涂覆保护层'"
"依赖字面匹配的 Agent 会因找不到完全一致的表述而错选。",
},
],
negative_examples=[
{
"question": "工人在刷漆吗?",
"why": "选项直接复用视频原文,不考察语义理解。",
},
],
distractor_rules=(
"干扰项须基于视频真实内容,仅在【表述/语义】这一单一维度上做文章:"
"保留一个复用视频原始字幕字面、但在题干限定下语义为假的选项作为陷阱,"
"其余选项描述真实动作的不同同义表述。严禁凭空编造缺席动作。"
),
)
_FINE_GRAINED_VISUAL_ACTION = SubPattern(
name="fine_grained_visual_action",
weight=0.15,
sampling_level_override=None,
constraint_override=None,
instruction=(
"设计一道需要区分细粒度动作方式的识别题。"
"题目聚焦于 HOW(怎么做)而非 WHAT(做什么),"
"如区分'搅拌''翻炒'''''等视觉上相似但方式不同的动作。"
),
positive_examples=[
{
"question": "维修人员是如何拆卸螺丝的?",
"answer": "D. 用扳手逆时针旋转",
"why": "需要区分拆卸的具体方式(扳手 vs 螺丝刀、顺时针 vs 逆时针),"
"粗粒度识别只能判断'在拆螺丝',无法区分方式。",
},
],
negative_examples=[
{
"question": "维修人员在做什么?",
"why": "只需粗粒度动作识别('拆螺丝'),不考察具体方式。",
},
],
distractor_rules=(
"四个选项须是【同一大类动作】的不同执行方式,全部为视频中真实可见的做法,"
"仅在【执行方式】这一单一维度上不同(如顺/逆时针、扳手/螺丝刀)。"
"严禁使用明显不相关或视频中未出现的动作作为干扰项。"
),
)
_CROSS_SEGMENT_ENTITY_TRACKING = SubPattern(
name="cross_segment_entity_tracking",
weight=0.15,
sampling_level_override=1,
constraint_override=None,
instruction=(
"设计一道需要跨视频段落追踪同一实体动作的识别题。"
"目标实体在不同片段中外观、称呼或上下文发生变化,"
"Agent 需要将多段信息合并才能正确回答关于该实体的动作问题。"
),
positive_examples=[
{
"question": "穿红色外套的人在视频中总共完成了哪些动作?",
"answer": "B. 先讲解、后示范、最后总结",
"why": "该人物在前段穿外套讲解,中段脱外套示范,后段重新穿上总结,"
"无法跨段追踪的 Agent 会遗漏某段动作。",
},
{
"question": "主持人在节目不同环节中分别做了什么?",
"answer": "A. 开场介绍、采访嘉宾、总结点评",
"why": "主持人在不同场景切换中持续出现,需要跨段聚合。",
},
],
negative_examples=[
{
"question": "视频第一个片段中的人在做什么?",
"why": "仅需单段信息,不考察跨段追踪。",
},
],
distractor_rules=(
"干扰项须是视频中【另一真实实体】在相应片段真实做过的动作,"
"仅在【动作主体】这一单一维度上与正解不同。"
"严禁编造任何实体未做过的缺席动作。"
),
supports_flip=True,
flip_axis="first/last",
)
_EVIDENCE_GAP_CONFABULATION = SubPattern(
name="evidence_gap_confabulation",
weight=0.15,
sampling_level_override=None,
constraint_override=None,
instruction=(
"设计一道动作识别题,视频中存在证据空缺(如遮挡、跳切、画外音)。"
"正确答案承认信息不足或基于间接证据推断,"
"而非凭空编造因果链。Agent 若虚构缺失证据即会出错。"
),
positive_examples=[
{
"question": "画面切走后,演讲者下一步做了什么?",
"answer": "C. 无法从视频中直接确定",
"why": "画面跳切导致该动作无直接视觉证据,"
"倾向于虚构的 Agent 会编造一个看似合理的动作。",
},
],
negative_examples=[
{
"question": "画面中演讲者正在做什么?",
"why": "动作在画面中可见,不存在证据空缺。",
},
],
distractor_rules=(
"正解仅陈述视频中可观测的事实或诚实承认证据不足;"
"干扰项在【因果完整性】这一单一维度上越界——补上一段视频未展示的因果链,"
"但其前提元素仍取自视频真实内容(诱导 Agent 顺势编造),而非完全凭空的缺席事件。"
),
)
AR_SUB_PATTERNS: tuple[SubPattern, ...] = (
_PREMATURE_EVIDENCE_ANCHORING,
_TEMPORAL_REASONING_FAILURE,
_SEMANTIC_RIGIDITY,
_FINE_GRAINED_VISUAL_ACTION,
_CROSS_SEGMENT_ENTITY_TRACKING,
_EVIDENCE_GAP_CONFABULATION,
)
# 预计算:名称列表 + 权重列表(避免每次 select 重新构建)
_AR_PATTERN_NAMES: list[str] = [sp.name for sp in AR_SUB_PATTERNS]
_AR_PATTERN_WEIGHTS: list[float] = [sp.weight for sp in AR_SUB_PATTERNS]
_AR_PATTERN_BY_NAME: dict[str, SubPattern] = {sp.name: sp for sp in AR_SUB_PATTERNS}
# ---------------------------------------------------------------------------
# ActionRecognitionStrategy
# ---------------------------------------------------------------------------
_SAMPLING_CONSTRAINT = SamplingConstraint(
min_subtitles=3,
min_l3_nodes=5,
require_frames=True,
cross_l2_span=True,
)
class ActionRecognitionStrategy:
"""Action Recognition 特化出题策略。
自包含实现不依赖 BaseTaskTypeStrategy QuestionFamilySpec
靶向 6 种典型失败机制通过加权随机 SubPattern 引导 VLM 出题
属性:
task_type: 题型名 "Action Recognition"
strategy_name: 策略标识 "ACTION_RECOGNITION"
skill_target: 目标失败机制 "M1_AR"
sampling_level: 采样层级 2L2 L3 升级以获得跨段视野
sampling_constraint: 采样约束min_subtitles=3, min_l3_nodes=5, require_frames, cross_l2_span
prompt_template: 出题模板 "action_recognition.md"
leak_probe_template: 泄漏探测模板 "gate_leak_retrieval.md"
"""
@property
def task_type(self) -> str:
"""返回题型名。"""
return "Action Recognition"
@property
def strategy_name(self) -> str:
"""返回策略标识名。"""
return "ACTION_RECOGNITION"
@property
def skill_target(self) -> str:
"""返回目标失败机制编号。"""
return "M1_AR"
@property
def sampling_level(self) -> int:
"""返回采样层级(L2)。"""
return 2
@property
def sampling_constraint(self) -> SamplingConstraint:
"""返回采样约束。"""
return _SAMPLING_CONSTRAINT
@property
def prompt_template(self) -> str:
"""返回出题 prompt 模板文件名。"""
return "action_recognition.md"
@property
def leak_probe_template(self) -> str:
"""返回泄漏探测模板文件名。"""
return "gate_leak_retrieval.md"
@property
def uses_grounded_selector(self) -> bool:
"""AR 启用候选池 + VLM 视觉打分 selector。"""
return True
def select_sub_pattern(self, rng: random.Random) -> SubPattern:
"""按权重随机选择一个 SubPattern。
参数:
rng: 随机数生成器确保可复现
返回:
选中的 SubPattern 实例永不为 None
"""
chosen_name = rng.choices(
_AR_PATTERN_NAMES,
weights=_AR_PATTERN_WEIGHTS,
k=1,
)[0]
return _AR_PATTERN_BY_NAME[chosen_name]
def build_prompt_context(self, material: Any, sub_pattern: SubPattern | None) -> dict:
"""构建 prompt 上下文字典。
参数:
material: 采样素材当前未使用留给管线扩展
sub_pattern: 选中的子模式
返回:
包含 family_name, prompt_template, sub_pattern 的字典
"""
return {
"family_name": self.strategy_name,
"prompt_template": self.prompt_template,
"sub_pattern": sub_pattern.name if sub_pattern is not None else None,
}
def extra_gates(self, candidate: Any) -> list:
"""返回额外门控列表(当前为空)。
参数:
candidate: 候选题目
返回:
空列表
"""
return []
+756
View File
@@ -0,0 +1,756 @@
"""赛题合成核心逻辑 — 节点采样、prompt 构造、VLM 响应解析、去重。
纯函数为主异步编排仅 generate_one
通过 DI 接收 VLMProvider / EmbeddingProvider import adapters/
"""
from __future__ import annotations
import contextlib
import json
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from loguru import logger
if TYPE_CHECKING:
import random
from collections.abc import Callable
from app.tree.index import L2Node, L3Node, TreeIndex
from core.protocols import VLMProvider
from core.types import GeneratedQuestion
@dataclass(frozen=True)
class AnchorContext:
"""锚节点上下文——生成单道题所需的全部素材。
属性:
node_id: 锚节点 ID
card_text: 锚节点 card 序列化文本
frame_paths: 帧图片路径列表
subtitle: 对应字幕可空
distractor_texts: 同视频其他节点摘要 VLM 生成干扰项
"""
node_id: str
card_text: str
frame_paths: list[str]
subtitle: str
distractor_texts: list[str]
@dataclass(frozen=True)
class TaskTypeSpec:
"""题型的生成规格。
属性:
level: 锚定层级"L3" / "L2" / "L1" / "L1-L2"
needs_frames: 是否必须提供帧图
frame_count: 帧数范围描述 "1", "2-3", "0-1"
context_fields: 需要提取的 card 字段元组
"""
level: str
needs_frames: bool
frame_count: str
context_fields: tuple[str, ...]
# ---------------------------------------------------------------------------
# 12 种 Video-MME 题型 → 树层级 + 生成规格映射
# ---------------------------------------------------------------------------
TASK_TYPE_LEVEL_MAP: dict[str, TaskTypeSpec] = {
# --- L3 单帧题型 ---
"Object Recognition": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
"Attribute Perception": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
"OCR Problems": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
"Spatial Reasoning": TaskTypeSpec("L3", True, "1", ("frame_summary", "spatial_layout")),
"Spatial Perception": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
# --- L2 多帧 / 事件级题型 ---
"Action Recognition": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
"Action Reasoning": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
"Counting Problem": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
"Temporal Perception": TaskTypeSpec("L2", False, "0-1", ("event_description", "time_range")),
# --- L1 / 跨层级题型 ---
"Temporal Reasoning": TaskTypeSpec("L1", True, "per-L2", ("scene_summary",)),
"Information Synopsis": TaskTypeSpec("L1", True, "per-L2", ("scene_summary",)),
"Object Reasoning": TaskTypeSpec("L1-L2", True, "per-L2", ("event_description",)),
}
# ---------------------------------------------------------------------------
# 内部辅助函数
# ---------------------------------------------------------------------------
def _serialize_l3_card(l3: L3Node, context_fields: tuple[str, ...]) -> str:
"""将 L3 节点 card 按 context_fields 序列化为可读文本。
参数:
l3: L3 节点
context_fields: 需提取的字段名元组
返回:
多行 "field: value" 格式的文本
"""
parts: list[str] = []
for fld in context_fields:
val = getattr(l3.card, fld, None)
if val is not None:
parts.append(f"{fld}: {val}")
return "\n".join(parts)
def _l2_time_range_str(l2: L2Node) -> str:
"""将 L2 的 time_range 格式化为可读字符串。
参数:
l2: L2 节点
返回:
"time_range: (start, end)" 格式 "time_range: unknown"
"""
if l2.time_range is not None:
return f"time_range: ({l2.time_range[0]:.2f}, {l2.time_range[1]:.2f})"
return "time_range: unknown"
def _representative_frame(l2: L2Node) -> str | None:
"""取 L2 的代表帧路径——第一个有 frame_path 的 L3 子节点。
参数:
l2: L2 节点
返回:
帧路径字符串 None无可用帧时
"""
for l3 in l2.children:
if l3.frame_path:
return l3.frame_path
return None
def _sort_l2_by_time(l2_nodes: list[L2Node]) -> list[L2Node]:
"""按 time_range 升序排列 L2 节点(None 排末尾)。
参数:
l2_nodes: 待排序的 L2 节点列表
返回:
排序后的新列表不修改原列表
"""
return sorted(
l2_nodes,
key=lambda n: n.time_range[0] if n.time_range is not None else float("inf"),
)
# ---------------------------------------------------------------------------
# 各层级采样策略
# ---------------------------------------------------------------------------
def _sample_l3(
tree: TreeIndex,
task_type: str,
spec: TaskTypeSpec,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""L3 层级锚节点采样。
收集全部 L3 节点排除已用节点随机选取一个
参数:
tree: 三层树索引
task_type: 题型名称
spec: 题型规格
used_node_ids: 已用节点 ID 集合
rng: 随机数生成器
返回:
AnchorContext 实例
异常:
ValueError: 候选 L3 节点不足
"""
# Phase 1: 收集所有 L3 候选(必须有 frame_path
candidates: list[tuple[L3Node, L2Node]] = []
for root in tree.roots:
for l2 in root.children:
for l3 in l2.children:
if l3.id not in used_node_ids and l3.frame_path:
candidates.append((l3, l2))
if not candidates:
raise ValueError(f"锚节点不足: {task_type} 无可用 L3 节点(需具备 frame_path")
# Phase 2: 随机选取
chosen_l3, parent_l2 = rng.choice(candidates)
# Phase 3: 构造上下文(frame_path 已在候选过滤中保证非 None)
card_text = _serialize_l3_card(chosen_l3, spec.context_fields)
frame_paths = [chosen_l3.frame_path] # type: ignore[list-item]
subtitle = chosen_l3.card.subtitle or ""
# Phase 4: 干扰项——整棵树中其他 L3 的 frame_summary
distractor_texts = [
l3.card.frame_summary
for root in tree.roots
for l2 in root.children
for l3 in l2.children
if l3.id != chosen_l3.id
]
return AnchorContext(
node_id=chosen_l3.id,
card_text=card_text,
frame_paths=frame_paths,
subtitle=subtitle,
distractor_texts=distractor_texts,
)
def _sample_l2(
tree: TreeIndex,
task_type: str,
spec: TaskTypeSpec,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""L2 层级锚节点采样(含 Temporal Perception 特殊处理)。
普通 L2 题型随机选 1 L2 2-3 个子 L3
Temporal Perception0-1 card_text 必含 time_range
参数:
tree: 三层树索引
task_type: 题型名称
spec: 题型规格
used_node_ids: 已用节点 ID 集合
rng: 随机数生成器
返回:
AnchorContext 实例
异常:
ValueError: 候选 L2 节点不足
"""
# Phase 1: 收集所有 L2 候选
all_l2: list[L2Node] = []
for root in tree.roots:
for l2 in root.children:
if l2.id not in used_node_ids:
all_l2.append(l2)
if not all_l2:
raise ValueError(f"锚节点不足: {task_type} 无可用 L2 节点")
# Phase 2: 随机选取
chosen_l2 = rng.choice(all_l2)
is_temporal_perception = task_type == "Temporal Perception"
# Phase 3: 帧路径
if is_temporal_perception:
# 0-1 帧:有子节点则取 1 帧,否则 0 帧
frame_paths: list[str] = []
if chosen_l2.children:
first_frame = chosen_l2.children[0].frame_path
if first_frame:
frame_paths = [first_frame]
else:
# 普通 L2:随机采样 2-3 个 L3 帧
children_with_frames = [l3 for l3 in chosen_l2.children if l3.frame_path]
if len(children_with_frames) < 2:
raise ValueError(
f"锚节点不足: {task_type} 需要 >=2 个子帧,"
f"{chosen_l2.id} 仅有 {len(children_with_frames)} 个可用帧"
)
n_frames = min(rng.randint(2, 3), len(children_with_frames))
sampled = rng.sample(children_with_frames, n_frames)
frame_paths = [l3.frame_path for l3 in sampled if l3.frame_path]
# Phase 4: card_text
card_text = f"event_description: {chosen_l2.card.event_description}"
if is_temporal_perception:
card_text += f"\n{_l2_time_range_str(chosen_l2)}"
# Phase 5: 字幕(优先使用 L2 自身字幕,否则取首个子 L3 字幕)
subtitle = chosen_l2.card.subtitle or ""
if not subtitle and chosen_l2.children and chosen_l2.children[0].card.subtitle:
subtitle = chosen_l2.children[0].card.subtitle
# Phase 6: 干扰项——整棵树中其他 L2 的 event_description
distractor_texts = [
l2.card.event_description
for root in tree.roots
for l2 in root.children
if l2.id != chosen_l2.id
]
return AnchorContext(
node_id=chosen_l2.id,
card_text=card_text,
frame_paths=frame_paths,
subtitle=subtitle,
distractor_texts=distractor_texts,
)
def _sample_l1(
tree: TreeIndex,
task_type: str,
spec: TaskTypeSpec,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""L1 层级锚节点采样(Temporal Reasoning / Information Synopsis)。
Information Synopsis使用目标 L1 下全部 L2 子节点
Temporal Reasoning严格要求 >=3 L2 子节点不足则抛 ValueError
L2 time_range 升序排列每个 L2 取一帧代表
参数:
tree: 三层树索引
task_type: 题型名称
spec: 题型规格
used_node_ids: 已用节点 ID 集合
rng: 随机数生成器
返回:
AnchorContext 实例
异常:
ValueError: 候选 L1 节点不足 Temporal Reasoning L2 子节点 <3
"""
# Phase 1: 收集可用 L1
candidates = [r for r in tree.roots if r.id not in used_node_ids]
if not candidates:
raise ValueError(f"锚节点不足: {task_type} 无可用 L1 节点")
# Phase 2: 随机选取
chosen_l1 = rng.choice(candidates)
# Phase 3: 选定 L2 子集
if task_type == "Information Synopsis":
# 必须使用全部 L2
selected_l2 = list(chosen_l1.children)
else:
# Temporal Reasoning:严格要求 >=3 个 L2
if len(chosen_l1.children) < 3:
raise ValueError(
f"锚节点不足: {task_type} 需要 >=3 个 L2 子节点,"
f"{chosen_l1.id} 仅有 {len(chosen_l1.children)}"
)
if len(chosen_l1.children) == 3:
selected_l2 = list(chosen_l1.children)
else:
selected_l2 = rng.sample(chosen_l1.children, rng.randint(3, len(chosen_l1.children)))
# Phase 4: 按 time_range 升序排列
selected_l2 = _sort_l2_by_time(selected_l2)
# Phase 5: card_text(场景摘要)
card_text = f"scene_summary: {chosen_l1.card.scene_summary}"
# Phase 6: 帧路径——每个 L2 取一帧代表
frame_paths: list[str] = []
for l2 in selected_l2:
rep = _representative_frame(l2)
if rep:
frame_paths.append(rep)
# Phase 7: 字幕(L1 无字幕)
subtitle = ""
# Phase 8: 干扰项——其他 L1 的 scene_summary
distractor_texts = [r.card.scene_summary for r in tree.roots if r.id != chosen_l1.id]
return AnchorContext(
node_id=chosen_l1.id,
card_text=card_text,
frame_paths=frame_paths,
subtitle=subtitle,
distractor_texts=distractor_texts,
)
def _sample_l1_l2(
tree: TreeIndex,
task_type: str,
spec: TaskTypeSpec,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""L1-L2 跨层级锚节点采样(Object Reasoning)。
从全部 L2 中随机选 2-3 time_range 排序
card_text 为各 L2 event_description 拼接
参数:
tree: 三层树索引
task_type: 题型名称
spec: 题型规格
used_node_ids: 已用节点 ID 集合
rng: 随机数生成器
返回:
AnchorContext 实例
异常:
ValueError: 候选 L2 节点不足
"""
# Phase 1: 收集全部 L2
all_l2: list[L2Node] = []
for root in tree.roots:
for l2 in root.children:
if l2.id not in used_node_ids:
all_l2.append(l2)
if len(all_l2) < 2:
raise ValueError(
f"锚节点不足: {task_type} 需要 >=2 个 L2 节点,但仅有 {len(all_l2)} 个可用"
)
# Phase 2: 随机选 2-3 个
n_pick = min(rng.randint(2, 3), len(all_l2))
selected = rng.sample(all_l2, n_pick)
# Phase 3: 按 time_range 升序排列
selected = _sort_l2_by_time(selected)
# Phase 4: card_text = 各 L2 event_description 拼接
card_text = "\n".join(f"event_description: {l2.card.event_description}" for l2 in selected)
# Phase 5: 帧路径——每个 L2 取一帧代表
frame_paths: list[str] = []
for l2 in selected:
rep = _representative_frame(l2)
if rep:
frame_paths.append(rep)
# Phase 6: 字幕
subtitle = ""
# Phase 7: 干扰项——未被选中的 L2 的 event_description
selected_ids = {l2.id for l2 in selected}
distractor_texts = [l2.card.event_description for l2 in all_l2 if l2.id not in selected_ids]
# 使用第一个被选中节点的 ID 作为锚节点 ID
anchor_id = selected[0].id
return AnchorContext(
node_id=anchor_id,
card_text=card_text,
frame_paths=frame_paths,
subtitle=subtitle,
distractor_texts=distractor_texts,
)
# ---------------------------------------------------------------------------
# 公开接口
# ---------------------------------------------------------------------------
def sample_anchor(
tree: TreeIndex,
task_type: str,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""根据题型从视频树中采样锚节点及上下文素材。
依据 TASK_TYPE_LEVEL_MAP 中的层级规格分发到对应的层级采样策略
每种层级有不同的帧选取card 序列化和干扰项收集逻辑
参数:
tree: 三层树索引
task_type: 12 Video-MME 题型之一
used_node_ids: 本轮已用节点 ID 集合避免重复采样
rng: 可控随机数生成器保证可复现
返回:
AnchorContext 实例包含锚节点 IDcard 文本帧路径字幕和干扰项
异常:
KeyError: task_type 不在 TASK_TYPE_LEVEL_MAP
ValueError: 候选节点不足全部被 used_node_ids 排除
"""
spec = TASK_TYPE_LEVEL_MAP[task_type]
if spec.level == "L3":
return _sample_l3(tree, task_type, spec, used_node_ids, rng)
elif spec.level == "L2":
return _sample_l2(tree, task_type, spec, used_node_ids, rng)
elif spec.level == "L1":
return _sample_l1(tree, task_type, spec, used_node_ids, rng)
elif spec.level == "L1-L2":
return _sample_l1_l2(tree, task_type, spec, used_node_ids, rng)
else:
raise ValueError(f"未知层级: {spec.level}")
# ---------------------------------------------------------------------------
# Prompt 构造与 VLM 响应解析
# ---------------------------------------------------------------------------
_VALID_ANSWERS = frozenset({"A", "B", "C", "D"})
def build_generation_prompt(
task_type: str,
anchor: AnchorContext,
exemplars: list[GeneratedQuestion],
) -> tuple[list[dict[str, str]], list[str]]:
"""组装 VLM 出题 prompt。
构造 OpenAI 格式的 messages 列表和帧图片路径列表
VLMProvider.chat_with_images 直接消费
参数:
task_type: 题型名称 "Object Recognition"
anchor: 锚节点上下文card_text, subtitle, distractor_texts, frame_paths
exemplars: 少样本示例列表可为空
返回:
(messages, image_paths) messages OpenAI 格式消息列表
image_paths 为帧图片路径列表直接喂给 VLMProvider.chat_with_images
"""
# Phase 1: 构造 system message
system_parts: list[str] = [
"你是一个视频理解题目生成器。",
f"题型: {task_type}",
"约束:",
"- 题目必须基于提供的节点内容",
"- 干扰选项应来自其他节点的信息",
"- 生成风格应与示例保持一致",
'- 以 JSON 格式返回: {"question": "...", "options": ["A. ...", "B. ...", "C. ...", "D. ..."], "answer": "A/B/C/D"}',
]
# Phase 2: 加入 few-shot 示例
if exemplars:
system_parts.append("\n示例:")
for i, ex in enumerate(exemplars, 1):
system_parts.append(f" 示例 {i}:")
system_parts.append(f" question: {ex.question}")
system_parts.append(f" options: {list(ex.options)}")
system_parts.append(f" answer: {ex.answer}")
system_content = "\n".join(system_parts)
# Phase 3: 构造 user message
user_parts: list[str] = [f"节点内容:\n{anchor.card_text}"]
if anchor.subtitle:
user_parts.append(f"\n字幕:\n{anchor.subtitle}")
if anchor.distractor_texts:
user_parts.append("\n干扰项来源节点摘要:")
for dt in anchor.distractor_texts:
user_parts.append(f"- {dt}")
user_content = "\n".join(user_parts)
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
]
return messages, list(anchor.frame_paths)
def parse_vlm_response(
raw: str,
video_id: str,
task_type: str,
seq: int,
) -> dict:
"""解析 VLM 返回的 JSON → 部分字段字典。
尝试直接解析 JSON若失败 markdown 代码块中提取后重试
校验必需字段选项数量和答案合法性
参数:
raw: VLM 原始返回文本
video_id: 所属视频标识
task_type: 题型名称用于错误消息
seq: 序列号用于生成 question_id
返回:
{"question_id": "gen-{video_id}-{seq:03d}", "question": ..., "options": [...], "answer": ...}
调用方generate_one补齐 source_nodes/difficulty 后构造 GeneratedQuestion
异常:
ValueError: JSON 解析失败缺必需字段options 4 answer 不在 A-D
"""
# Phase 1: 尝试直接解析 JSON
data = None
with contextlib.suppress(json.JSONDecodeError):
data = json.loads(raw)
# Phase 2: 从 markdown 代码块提取 JSON
if data is None:
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", raw, re.DOTALL)
if match:
with contextlib.suppress(json.JSONDecodeError):
data = json.loads(match.group(1))
if data is None:
raise ValueError(f"VLM 返回无法解析为 JSON: {raw[:200]}")
# Phase 3: 校验必需字段
required = ("question", "options", "answer")
missing = [f for f in required if f not in data]
if missing:
raise ValueError(f"VLM 返回缺少必需字段 {missing}: {raw[:200]}")
# Phase 4: options 必须恰好 4 项
options = data["options"]
if not isinstance(options, list) or len(options) != 4:
raise ValueError(
f"options 必须恰好 4 项,实际 {len(options) if isinstance(options, list) else type(options).__name__}: {raw[:200]}"
)
# Phase 5: answer 必须是 A-D
answer = data["answer"]
if answer not in _VALID_ANSWERS:
raise ValueError(f"answer 必须是 A/B/C/D 之一,实际 '{answer}': {raw[:200]}")
return {
"question_id": f"gen-{video_id}-{task_type.lower().replace(' ', '_')}-{seq:03d}",
"question": data["question"],
"options": list(options),
"answer": answer,
}
# ---------------------------------------------------------------------------
# Embedding 去重
# ---------------------------------------------------------------------------
def is_duplicate(
question_text: str,
pool_embeddings: np.ndarray,
embed_fn: Callable[[str | list[str]], np.ndarray],
threshold: float,
) -> bool:
"""embedding 去重判定。
参数:
question_text: 待检查的题目文本
pool_embeddings: 已有题目的 embedding 矩阵 [N, D]L2 归一化
embed_fn: 文本嵌入函数返回 [N, D] ndarrayL2 归一化
threshold: 余弦相似度阈值
返回:
True 表示与池中某题重复空池永远返回 False
"""
if pool_embeddings.shape[0] == 0:
return False
query = embed_fn(question_text) # [1, D]
query = query.squeeze(0) # [D]
similarities = pool_embeddings @ query # [N]
return bool(np.max(similarities) >= threshold)
# ---------------------------------------------------------------------------
# 单题生成
# ---------------------------------------------------------------------------
async def generate_one(
vlm: VLMProvider,
tree: TreeIndex,
video_id: str,
task_type: str,
seq: int,
*,
exemplars: list[GeneratedQuestion],
used_node_ids: set[str],
max_retries: int,
rng: random.Random,
session_id: str,
) -> GeneratedQuestion | None:
"""生成单道候选题(不含去重——去重在调用方汇总点原子执行)。
循环最多 max_retries 次尝试生成每次尝试
1. 采样锚节点
2. 构造 prompt
3. 调用 VLM
4. 解析响应
5. 构造 GeneratedQuestion
返回 None 表示耗尽重试
参数:
vlm: VLM 调用端口
tree: 三层树索引
video_id: 所属视频标识
task_type: 12 Video-MME 题型之一
seq: 序列号用于生成 question_id
exemplars: 少样本示例列表
used_node_ids: 已用节点 ID 集合
max_retries: 最大重试次数
rng: 可控随机数生成器
session_id: 会话 ID传递给 VLM 遥测
返回:
GeneratedQuestion 实例 None耗尽重试
"""
from core.types import GeneratedQuestion as _GeneratedQuestion
for attempt in range(max_retries):
try:
# Phase 1: 采样锚节点
anchor = sample_anchor(tree, task_type, used_node_ids, rng)
# Phase 2: 构造 prompt
messages, images = build_generation_prompt(task_type, anchor, exemplars)
# Phase 3: 调用 VLM
response = await vlm.chat_with_images(
messages,
images,
session_id=session_id,
)
# Phase 4: 解析响应
parsed = parse_vlm_response(response.content, video_id, task_type, seq)
# Phase 5: 构造 GeneratedQuestion
return _GeneratedQuestion(
question_id=parsed["question_id"],
video_id=video_id,
task_type=task_type,
question=parsed["question"],
options=tuple(parsed["options"]),
answer=parsed["answer"],
source_nodes=(anchor.node_id,),
difficulty="medium",
)
except Exception as exc:
logger.warning(
"generate_one 尝试 {}/{} 失败 ({}): {}",
attempt + 1,
max_retries,
task_type,
exc,
)
continue
logger.warning(
"generate_one 耗尽 {} 次重试 (video={}, task_type={})",
max_retries,
video_id,
task_type,
)
return None
+4
View File
@@ -201,6 +201,10 @@ class SearchToolDispatcher:
summary,
]
# Phase 2.5: 确定性追加实体/画面文字原文(防按题摘要吞噬,Spec-1 B)
for label, text in self._env.node_entity_fields(node_id).items():
parts.append(f"[{label}] {text}")
# Phase 3: 子节点概览
children_info = self._env.get_children_info(node_id)
if children_info:
+91 -16
View File
@@ -19,7 +19,7 @@ from loguru import logger
from app.tree.index import L1Node, L2Node, L3Node, TreeIndex
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Iterable
# 节点联合类型(内部使用)
AnyNode = L1Node | L2Node | L3Node
@@ -64,26 +64,61 @@ def _node_description(node: AnyNode) -> str:
return node.card.frame_summary
def _collect_card_strings(node: AnyNode) -> list[str]:
def _collect_card_strings(
node: AnyNode,
skip_fields: frozenset[str] = frozenset(),
) -> list[str]:
"""从节点 card 中递归收集所有非空字符串字段。
参数:
node: 树节点实例
skip_fields: 需要跳过的 dataclass 字段名集合 subtitle
因为它需要单独添加"字幕:"标签和 [sN] 锚标
返回:
字符串列表每个非空字段值一项含内嵌换行的按行拆分
"""
result: list[str] = []
_collect_from_obj(node.card, result)
_collect_from_obj(node.card, result, skip_fields=skip_fields)
return result
def _collect_from_obj(obj: object, out: list[str]) -> None:
# subtitle 字段在 _node_full_text / _node_anchored_text 中单独处理
_SUBTITLE_SKIP: frozenset[str] = frozenset({"subtitle"})
# 各层级 card 的实体字段名(B 修复:dispatcher 追加原文用)
_ENTITY_FIELDS_BY_LEVEL: dict[str, tuple[str, ...]] = {
"L1": ("key_entities",),
"L2": ("entities",),
"L3": ("visible_entities",),
}
def _clean_join(values: Iterable[object] | None) -> str:
"""过滤非空字符串、去重(保序)、分号拼接;无有效值返回空串。
参数:
values: 待清洗的值集合通常为 list[str]容忍 None / 混入非字符串
返回:
"a; b; c" 形式的拼接串无有效值时为空串
"""
cleaned = [v.strip() for v in (values or []) if isinstance(v, str) and v.strip()]
return "; ".join(dict.fromkeys(cleaned))
def _collect_from_obj(
obj: object,
out: list[str],
*,
skip_fields: frozenset[str] = frozenset(),
) -> None:
"""递归收集任意嵌套结构中的非空字符串。
参数:
obj: dict / list / str / 其他
out: 收集结果列表原地修改
skip_fields: 需要跳过的 dataclass 字段名集合
"""
if isinstance(obj, str):
stripped = obj.strip()
@@ -91,14 +126,16 @@ def _collect_from_obj(obj: object, out: list[str]) -> None:
out.append(stripped)
elif isinstance(obj, dict):
for v in obj.values():
_collect_from_obj(v, out)
_collect_from_obj(v, out, skip_fields=skip_fields)
elif isinstance(obj, (list, tuple)):
for item in obj:
_collect_from_obj(item, out)
_collect_from_obj(item, out, skip_fields=skip_fields)
elif hasattr(obj, "__dataclass_fields__"):
# frozen dataclassCard 类型)
for field_name in obj.__dataclass_fields__:
_collect_from_obj(getattr(obj, field_name), out)
if field_name in skip_fields:
continue
_collect_from_obj(getattr(obj, field_name), out, skip_fields=skip_fields)
class TreeEnvironment:
@@ -197,6 +234,39 @@ class TreeEnvironment:
return "\n".join(parts)
def node_entity_fields(self, node_id: str) -> dict[str, str]:
"""返回节点 card 的实体/画面文字字段原文。
dispatcher 在按题摘要后确定性追加防止 LLM 摘要吞掉
entities/visible_text 信号benchmark 错题 M1 恶化因素
参数:
node_id: 节点 ID
返回:
{"实体": "...", "画面文字": "..."}空字段不含对应键
异常:
KeyError: 节点不存在
"""
node = self._id_to_node.get(node_id)
if node is None:
raise KeyError(f"节点不存在: {node_id}")
level = _node_level(node)
entity_values: list[str] = []
for field_name in _ENTITY_FIELDS_BY_LEVEL[level]:
entity_values.extend(getattr(node.card, field_name) or [])
out: dict[str, str] = {}
entities_text = _clean_join(entity_values)
if entities_text:
out["实体"] = entities_text
visible_text = _clean_join(getattr(node.card, "visible_text", None))
if visible_text:
out["画面文字"] = visible_text
return out
def search_similar(
self,
query: str,
@@ -367,17 +437,19 @@ class TreeEnvironment:
def get_subtitle(self, node_id: str) -> str:
"""返回节点字幕文本。
L2/L3 节点从 card.subtitle 读取L1 节点不含字幕
参数:
node_id: 节点 ID
返回:
字幕文本无字幕或节点不存在时返回空字符串
字幕文本无字幕L1 节点或节点不存在时返回空字符串
"""
node = self._id_to_node.get(node_id)
if node is None:
return ""
if isinstance(node, L3Node):
return node.subtitle or ""
if isinstance(node, (L2Node, L3Node)):
return node.card.subtitle or ""
return ""
def resolve_frame_paths(self, node_ids: list[str]) -> list[Path]:
@@ -448,22 +520,25 @@ class TreeEnvironment:
def _node_full_text(self, node: AnyNode) -> str:
"""获取节点完整文本(card 所有字段 + subtitle)。
subtitle card.subtitle 读取 L2/L3 节点附加"字幕:"标签
参数:
node: 树节点
返回:
拼接后的全文本
"""
card_strings = _collect_card_strings(node)
card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP)
text = "\n".join(card_strings)
if isinstance(node, L3Node) and node.subtitle:
text += f"\n字幕: {node.subtitle}"
if isinstance(node, (L2Node, L3Node)) and node.card.subtitle:
text += f"\n字幕: {node.card.subtitle}"
return text
def _node_anchored_text(self, node: AnyNode) -> str:
"""获取带行号锚的节点文本。
card 字符串逐行编 [c1]..[cN]字幕逐行编 [s1]..[sM]
字幕从 card.subtitle 读取 L2/L3 节点产生 [sN] 锚标
参数:
node: 树节点
@@ -471,15 +546,15 @@ class TreeEnvironment:
返回:
带锚文本
"""
card_strings = _collect_card_strings(node)
card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP)
# 拆分内嵌换行,确保一锚一行
card_lines: list[str] = []
for s in card_strings:
card_lines.extend(ln for ln in s.splitlines() if ln.strip())
sub_lines: list[str] = []
if isinstance(node, L3Node) and node.subtitle:
sub_lines = [ln for ln in node.subtitle.splitlines() if ln.strip()]
if isinstance(node, (L2Node, L3Node)) and node.card.subtitle:
sub_lines = [ln for ln in node.card.subtitle.splitlines() if ln.strip()]
anchored: list[str] = []
for i, line in enumerate(card_lines, 1):
+16 -4
View File
@@ -20,6 +20,7 @@
from __future__ import annotations
import base64
import dataclasses
import json
from dataclasses import dataclass, field
from datetime import datetime
@@ -82,6 +83,7 @@ class L3Card:
visible_text: 画面中可见的文字列表
spatial_layout: 空间布局描述
visual_attributes: 视觉属性字典如光照色调等
subtitle: 字幕文本Voronoi 分配后填充默认空
"""
frame_summary: str
@@ -90,6 +92,7 @@ class L3Card:
visible_text: list[str]
spatial_layout: str
visual_attributes: dict[str, Any]
subtitle: str = ""
@dataclass(frozen=True)
@@ -106,6 +109,7 @@ class L2Card:
visible_text: 片段中可见的文字列表
spatial_relations: 空间关系描述
state_changes: 状态变化描述可选
subtitle: L3 字幕聚合文本Voronoi 分配后填充默认空
"""
event_description: str
@@ -115,6 +119,7 @@ class L2Card:
visible_text: list[str]
spatial_relations: str
state_changes: str | None
subtitle: str = ""
@dataclass(frozen=True)
@@ -183,7 +188,6 @@ class L3Node:
embedding: 文本嵌入向量形状 [D]float32
timestamp: 对应的时间戳可选
frame_path: 关联的帧图像路径可选仅视频模态
subtitle: 该帧对应的字幕文本可选
"""
id: str
@@ -191,7 +195,6 @@ class L3Node:
embedding: np.ndarray | None = None
timestamp: float | None = None
frame_path: str | None = None
subtitle: str | None = None
@property
def description(self) -> str:
@@ -274,10 +277,10 @@ class L1Node:
"visible_text": n.card.visible_text,
"spatial_layout": n.card.spatial_layout,
"visual_attributes": n.card.visual_attributes,
"subtitle": n.card.subtitle,
},
"timestamp": n.timestamp,
"frame_path": n.frame_path,
"subtitle": n.subtitle,
}
if include_embedding:
d["embedding"] = _embed_to_str(n.embedding)
@@ -294,6 +297,7 @@ class L1Node:
"visible_text": n.card.visible_text,
"spatial_relations": n.card.spatial_relations,
"state_changes": n.card.state_changes,
"subtitle": n.card.subtitle,
},
"time_range": list(n.time_range) if n.time_range else None,
"children": [l3_to_dict(c) for c in n.children],
@@ -334,6 +338,8 @@ class L1Node:
for l2d in d.get("children", []):
l3_nodes: list[L3Node] = []
for l3d in l2d.get("children", []):
# 向后兼容:旧格式 subtitle 在节点级,新格式在 card 内
l3_subtitle = l3d["card"].get("subtitle", "") or l3d.get("subtitle", "") or ""
l3_card = L3Card(
frame_summary=l3d["card"]["frame_summary"],
visible_entities=l3d["card"]["visible_entities"],
@@ -341,6 +347,7 @@ class L1Node:
visible_text=l3d["card"]["visible_text"],
spatial_layout=l3d["card"]["spatial_layout"],
visual_attributes=l3d["card"]["visual_attributes"],
subtitle=l3_subtitle,
)
l3_nodes.append(
L3Node(
@@ -349,7 +356,6 @@ class L1Node:
embedding=_embed_from_str(l3d.get("embedding")),
timestamp=l3d.get("timestamp"),
frame_path=l3d.get("frame_path"),
subtitle=l3d.get("subtitle"),
)
)
l2_card = L2Card(
@@ -360,7 +366,13 @@ class L1Node:
visible_text=l2d["card"]["visible_text"],
spatial_relations=l2d["card"]["spatial_relations"],
state_changes=l2d["card"]["state_changes"],
subtitle=l2d["card"].get("subtitle", ""),
)
# 旧格式兼容:L2 无 card subtitle 时从 L3 children 聚合
if not l2_card.subtitle:
l3_subs = [n.card.subtitle for n in l3_nodes if n.card.subtitle]
if l3_subs:
l2_card = dataclasses.replace(l2_card, subtitle="\n".join(l3_subs))
tr2 = l2d.get("time_range")
l2_nodes.append(
L2Node(
+27 -6
View File
@@ -40,10 +40,13 @@ def detect_issues(
"""扫描树,返回所有问题节点列表。
检查项:
- L3: card 必填字段为空frame_summary / visible_entities / ongoing_actions / spatial_layout
- L3: card 必填字段为空frame_summary / spatial_layout
- : visible_entities / ongoing_actions 为空是合法状态静物/黑帧不纳入检测
- L3: frame_path 对应文件不存在需提供 frames_dir
- L2: event_description 为空
- L2/L1: children 列表为空
- L2: 相邻 clips 时间范围不连续gap > 1
- L1: scene_summary 为空
参数:
index: 待检测的 TreeIndex
@@ -55,6 +58,17 @@ def detect_issues(
issues: list[NodeIssue] = []
for l1 in index.roots:
# L1: scene_summary 不为空
if not l1.card.scene_summary:
issues.append(
NodeIssue(
node_id=l1.id,
level=1,
issue_type="empty_field",
details="L1 节点字段为空: scene_summary",
)
)
# L1: children 不为空
if not l1.children:
issues.append(
@@ -71,6 +85,17 @@ def detect_issues(
_check_time_gaps(l1.children, issues)
for l2 in l1.children:
# L2: event_description 不为空
if not l2.card.event_description:
issues.append(
NodeIssue(
node_id=l2.id,
level=2,
issue_type="empty_field",
details="L2 节点字段为空: event_description",
)
)
# L2: children 不为空
if not l2.children:
issues.append(
@@ -84,14 +109,10 @@ def detect_issues(
continue
for l3 in l2.children:
# L3: 必填字段不为空
# L3: 核心必填字段不为空visible_entities/ongoing_actions 为空是合法状态)
empty_fields: list[str] = []
if not l3.card.frame_summary:
empty_fields.append("frame_summary")
if not l3.card.visible_entities:
empty_fields.append("visible_entities")
if not l3.card.ongoing_actions:
empty_fields.append("ongoing_actions")
if not l3.card.spatial_layout:
empty_fields.append("spatial_layout")
if empty_fields:
+513
View File
@@ -0,0 +1,513 @@
"""树修复重生成器:VLM 重新描述问题节点 + 底向上级联。
底向上修复流程:
1. 收集需修复的 L3 节点 VLM 重新描述帧
2. 收集受影响的 L2 LLM L3 children 聚合
3. 收集受影响的 L1 LLM L2 children 聚合
仅处理 issue_type == "empty_field" level == 3 的问题节点
帧文件不存在时跳过该节点不中断整体修复流程
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from loguru import logger
from app.tree.index import (
L1Card,
L1Node,
L2Card,
L2Node,
L3Card,
L3Node,
TreeIndex,
)
from app.tree.subtitle import extract_subtitle_for_range
if TYPE_CHECKING:
from pathlib import Path
from app.tree.repair.detector import NodeIssue
from app.tree.subtitle import SRTEntry
from core.protocols import LLMProvider, VLMProvider
# ---------------------------------------------------------------------------
# Prompt 常量(与 VideoTreeBuilder 保持一致风格)
# ---------------------------------------------------------------------------
_L3_REPAIR_PROMPT = (
'该片段的整体内容: "{l2_description}"\n'
"用一到两句话描述这帧画面的具体内容。"
"重点关注: 动作、物体变化、文字信息、人物表情。\n"
"{subtitle_block}"
"返回 JSON 对象,包含以下字段:\n"
"- frame_summary: 画面描述\n"
"- visible_entities: 可见实体列表\n"
"- ongoing_actions: 动作列表\n"
"- visible_text: 可见文字列表\n"
"- spatial_layout: 空间布局\n"
'- visual_attributes: {{"lighting": "...", "dominant_colors": [...], "camera_angle": "..."}}\n'
"只返回 JSON 对象,不要其他内容。"
)
_L2_REGEN_PROMPT = (
"以下是一个视频片段中各帧的描述:\n{l3_texts}\n"
"用1-2句话描述该片段的核心内容。\n"
"返回 JSON 对象,包含以下字段:\n"
"- event_description: 1-2句片段描述\n"
"- entities: 可见实体列表\n"
"- actions: 动作列表\n"
"- action_subjects: 动作主体列表\n"
"- visible_text: 画面中可见文字列表\n"
"- spatial_relations: 空间关系描述\n"
"- state_changes: 状态变化描述(无则 null)\n"
"只返回 JSON 对象,不要其他内容。"
)
_L1_REGEN_PROMPT = (
"以下是一个视频段落中各片段的描述:\n{l2_texts}\n"
"用2-3句话总结该段落的整体内容,涵盖所有片段的主题。\n"
"返回 JSON 对象,包含以下字段:\n"
"- scene_summary: 2-3句段落摘要\n"
"- main_setting: 主要场景\n"
"- key_entities: 关键实体列表\n"
"- main_actions: 主要动作列表\n"
"- topic_keywords: 主题关键词列表\n"
"- visible_text: 出现的文字列表\n"
"- temporal_flow: 时间流向描述\n"
"只返回 JSON 对象,不要其他内容。"
)
# ---------------------------------------------------------------------------
# 统计数据类
# ---------------------------------------------------------------------------
@dataclass
class RepairStats:
"""修复统计信息。
属性:
l3_repaired: 修复的 L3 节点数
l2_regenerated: 重生成的 L2 节点数
l1_regenerated: 重生成的 L1 节点数
"""
l3_repaired: int = 0
l2_regenerated: int = 0
l1_regenerated: int = 0
# ---------------------------------------------------------------------------
# JSON 解析辅助(复用 VideoTreeBuilder 的解析逻辑)
# ---------------------------------------------------------------------------
def _extract_json(raw: str) -> Any:
"""从 VLM/LLM 原始输出中提取 JSON(处理 markdown 代码块包裹)。
参数:
raw: 原始返回字符串
返回:
解析后的 Python 对象dict/list解析失败返回 None
"""
raw = raw.strip()
# Phase 1: 尝试提取 markdown 代码块中的 JSON
code_match = re.search(
r"```(?:json)?\s*([\[{].*?[\]}])\s*```",
raw,
re.DOTALL,
)
if code_match:
raw = code_match.group(1)
# Phase 2: 直接解析
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
# Phase 3: 尝试提取裸 JSON 对象/数组
json_match = re.search(r"[\[{].*[\]}]", raw, re.DOTALL)
if json_match:
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
pass
return None
def _parse_l3_card(raw: str) -> L3Card | None:
"""解析 VLM 输出为 L3Card。解析失败返回 None。
参数:
raw: VLM 原始返回字符串
返回:
L3Card 实例或 None解析失败时
"""
data = _extract_json(raw)
if isinstance(data, dict):
try:
return L3Card(
frame_summary=str(data["frame_summary"]),
visible_entities=list(data["visible_entities"]),
ongoing_actions=list(data["ongoing_actions"]),
visible_text=list(data["visible_text"]),
spatial_layout=str(data["spatial_layout"]),
visual_attributes=dict(data["visual_attributes"]),
)
except (KeyError, TypeError, ValueError):
pass
return None
def _parse_l2_card(raw: str) -> L2Card | None:
"""解析 LLM 输出为 L2Card。解析失败返回 None。
参数:
raw: LLM 原始返回字符串
返回:
L2Card 实例或 None解析失败时
"""
data = _extract_json(raw)
if isinstance(data, dict):
try:
state_changes = data.get("state_changes")
if state_changes is not None:
state_changes = str(state_changes)
return L2Card(
event_description=str(data["event_description"]),
entities=list(data["entities"]),
actions=list(data["actions"]),
action_subjects=list(data["action_subjects"]),
visible_text=list(data["visible_text"]),
spatial_relations=str(data["spatial_relations"]),
state_changes=state_changes,
)
except (KeyError, TypeError, ValueError):
pass
return None
def _parse_l1_card(raw: str) -> L1Card | None:
"""解析 LLM 输出为 L1Card。解析失败返回 None。
参数:
raw: LLM 原始返回字符串
返回:
L1Card 实例或 None解析失败时
"""
data = _extract_json(raw)
if isinstance(data, dict):
try:
return L1Card(
scene_summary=str(data["scene_summary"]),
main_setting=str(data["main_setting"]),
key_entities=list(data["key_entities"]),
main_actions=list(data["main_actions"]),
topic_keywords=list(data["topic_keywords"]),
visible_text=list(data["visible_text"]),
temporal_flow=str(data["temporal_flow"]),
)
except (KeyError, TypeError, ValueError):
pass
return None
# ---------------------------------------------------------------------------
# 节点查找辅助
# ---------------------------------------------------------------------------
def _build_node_lookup(
index: TreeIndex,
) -> tuple[
dict[str, L3Node],
dict[str, L2Node],
dict[str, L1Node],
dict[str, L2Node],
dict[str, L1Node],
]:
"""构建节点 ID 到节点的查找表 + 子节点到父节点的映射。
参数:
index: 树索引
返回:
(l3_by_id, l2_by_id, l1_by_id, l3_parent_l2, l2_parent_l1)
- l3_by_id: L3 节点 ID L3Node
- l2_by_id: L2 节点 ID L2Node
- l1_by_id: L1 节点 ID L1Node
- l3_parent_l2: L3 节点 ID 其父 L2Node
- l2_parent_l1: L2 节点 ID 其父 L1Node
"""
l3_by_id: dict[str, L3Node] = {}
l2_by_id: dict[str, L2Node] = {}
l1_by_id: dict[str, L1Node] = {}
l3_parent_l2: dict[str, L2Node] = {}
l2_parent_l1: dict[str, L1Node] = {}
for l1 in index.roots:
l1_by_id[l1.id] = l1
for l2 in l1.children:
l2_by_id[l2.id] = l2
l2_parent_l1[l2.id] = l1
for l3 in l2.children:
l3_by_id[l3.id] = l3
l3_parent_l2[l3.id] = l2
return l3_by_id, l2_by_id, l1_by_id, l3_parent_l2, l2_parent_l1
# ---------------------------------------------------------------------------
# 字幕辅助
# ---------------------------------------------------------------------------
def _build_subtitle_block(
srt_entries: list[SRTEntry] | None,
timestamp: float | None,
) -> str:
"""构建字幕注入文本块。
参数:
srt_entries: SRT 字幕条目列表
timestamp: 帧时间戳
返回:
字幕文本块字符串无匹配时返回空字符串
"""
if not srt_entries or timestamp is None:
return ""
window = 2.0
start = max(0.0, timestamp - window)
end = timestamp + window
text = extract_subtitle_for_range(srt_entries, (start, end))
if not text:
return ""
return f"字幕信息:\n{text}\n"
# ---------------------------------------------------------------------------
# 主修复函数
# ---------------------------------------------------------------------------
async def repair_tree(
index: TreeIndex,
issues: list[NodeIssue],
vlm: VLMProvider,
llm: LLMProvider,
frames_dir: Path,
srt_entries: list[SRTEntry] | None = None,
) -> RepairStats:
"""修复有问题的节点,底向上级联。
流程:
1. 收集需修复的 L3 节点 VLM 重新描述帧
2. 收集受影响的 L2 LLM L3 children 聚合
3. 收集受影响的 L1 LLM L2 children 聚合
参数:
index: 待修复的 TreeIndex原地修改
issues: detect_issues() 返回的问题列表
vlm: VLM 调用端口
llm: LLM 调用端口
frames_dir: 帧文件根目录
srt_entries: 字幕条目列表可选
返回:
RepairStats 统计
"""
stats = RepairStats()
if not issues:
logger.info("无修复任务,跳过")
return stats
# 构建查找表
l3_by_id, l2_by_id, l1_by_id, l3_parent_l2, l2_parent_l1 = _build_node_lookup(index)
# Step 1: 修复 L3 节点(仅处理 empty_field + level 3
l3_issues = [
issue for issue in issues if issue.issue_type == "empty_field" and issue.level == 3
]
affected_l2_ids: set[str] = set()
for issue in l3_issues:
l3_node = l3_by_id.get(issue.node_id)
if l3_node is None:
logger.warning(
"L3 节点 ID 未在树中找到,跳过",
node_id=issue.node_id,
)
continue
# 查找帧文件
if l3_node.frame_path is None:
logger.warning(
"L3 节点无 frame_path,跳过",
node_id=issue.node_id,
)
continue
frame_file = frames_dir / l3_node.frame_path
if not frame_file.exists():
logger.warning(
"L3 帧文件不存在,跳过修复",
node_id=issue.node_id,
frame_path=str(frame_file),
)
continue
# 获取 L2 父节点描述作为上下文
parent_l2 = l3_parent_l2.get(issue.node_id)
l2_description = parent_l2.card.event_description if parent_l2 else ""
# 构建字幕块
subtitle_block = _build_subtitle_block(srt_entries, l3_node.timestamp)
# VLM 重新描述帧
prompt = _L3_REPAIR_PROMPT.format(
l2_description=l2_description,
subtitle_block=subtitle_block,
)
messages = [{"role": "user", "content": prompt}]
try:
response = await vlm.chat_with_images(messages, [str(frame_file)])
except Exception as exc:
logger.warning(
"L3 修复 VLM 调用失败,跳过: {}",
exc,
node_id=issue.node_id,
)
continue
new_card = _parse_l3_card(response.content)
if new_card is None:
logger.warning(
"L3 修复 VLM 输出解析失败,跳过",
node_id=issue.node_id,
raw_preview=response.content[:200],
)
continue
# 原地替换 cardL3Node.card 不是 frozen dataclass 的限制字段)
l3_node.card = new_card
stats.l3_repaired += 1
# 标记受影响的 L2 父节点
if parent_l2 is not None:
affected_l2_ids.add(parent_l2.id)
logger.debug(
"L3 节点修复完成",
node_id=issue.node_id,
frame_summary=new_card.frame_summary[:50],
)
# Step 2: 重生成受影响的 L2 节点
affected_l1_ids: set[str] = set()
for l2_id in affected_l2_ids:
l2_node = l2_by_id.get(l2_id)
if l2_node is None:
continue
# 从 L3 children 聚合描述
l3_texts = "\n".join(f"- {l3.card.frame_summary}" for l3 in l2_node.children)
prompt = _L2_REGEN_PROMPT.format(l3_texts=l3_texts)
messages = [{"role": "user", "content": prompt}]
try:
response = await llm.chat(messages)
except Exception as exc:
logger.warning(
"L2 重生成 LLM 调用失败,跳过: {}",
exc,
l2_id=l2_id,
)
continue
new_card = _parse_l2_card(response.content)
if new_card is None:
logger.warning(
"L2 重生成 LLM 输出解析失败,跳过",
l2_id=l2_id,
raw_preview=response.content[:200],
)
continue
l2_node.card = new_card
stats.l2_regenerated += 1
# 标记受影响的 L1 父节点
parent_l1 = l2_parent_l1.get(l2_id)
if parent_l1 is not None:
affected_l1_ids.add(parent_l1.id)
logger.debug(
"L2 节点重生成完成",
l2_id=l2_id,
event_description=new_card.event_description[:50],
)
# Step 3: 重生成受影响的 L1 节点
for l1_id in affected_l1_ids:
l1_node = l1_by_id.get(l1_id)
if l1_node is None:
continue
# 从 L2 children 聚合描述
l2_texts = "\n".join(f"- {l2.card.event_description}" for l2 in l1_node.children)
prompt = _L1_REGEN_PROMPT.format(l2_texts=l2_texts)
messages = [{"role": "user", "content": prompt}]
try:
response = await llm.chat(messages)
except Exception as exc:
logger.warning(
"L1 重生成 LLM 调用失败,跳过: {}",
exc,
l1_id=l1_id,
)
continue
new_card = _parse_l1_card(response.content)
if new_card is None:
logger.warning(
"L1 重生成 LLM 输出解析失败,跳过",
l1_id=l1_id,
raw_preview=response.content[:200],
)
continue
l1_node.card = new_card
stats.l1_regenerated += 1
logger.debug(
"L1 节点重生成完成",
l1_id=l1_id,
scene_summary=new_card.scene_summary[:50],
)
logger.info(
"树修复完成",
l3_repaired=stats.l3_repaired,
l2_regenerated=stats.l2_regenerated,
l1_regenerated=stats.l1_regenerated,
)
return stats
+4 -3
View File
@@ -84,10 +84,11 @@ def deduplicate_field(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for v in values:
key = v.strip().lower()
s = str(v).strip()
key = s.lower()
if key and key not in seen:
seen.add(key)
result.append(v)
result.append(s)
return result
@@ -278,7 +279,7 @@ def apply_injections(index: TreeIndex, injections: list[dict[str, Any]]) -> Supp
stats.facts_skipped += 1
continue
inject_value = instr.get("inject_value", "")
inject_value = str(instr.get("inject_value", "")).strip()
if not inject_value:
stats.facts_skipped += 1
continue
+14 -2
View File
@@ -13,6 +13,7 @@
from __future__ import annotations
import dataclasses
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
@@ -254,7 +255,8 @@ def assign_subtitles_voronoi(
entries: 已解析的 SRTEntry 列表
副作用:
直接修改每个 L3Node.subtitle 字段
通过 dataclasses.replace 替换 L3Node.card L2Node.card
将字幕写入 card.subtitle 字段
迁移来源:
TRM3 tools/generate_subtitles.py compute_effective_ranges + assign_subtitles
@@ -299,7 +301,17 @@ def assign_subtitles_voronoi(
right = (ts + next_ts) / 2.0
subtitle_text = extract_subtitle_for_range(entries, (left, right))
l3.subtitle = subtitle_text if subtitle_text else None
l3.card = dataclasses.replace(
l3.card,
subtitle=subtitle_text or "",
)
# L2 字幕聚合:拼接所有 L3 子节点的字幕
l3_subtitles = [l3.card.subtitle for l3 in l2.children if l3.card.subtitle]
l2.card = dataclasses.replace(
l2.card,
subtitle="\n".join(l3_subtitles),
)
logger.debug(
"Voronoi 字幕分配完成: {} 个 L1 节点, {} 条字幕条目",
+5 -4
View File
@@ -97,8 +97,8 @@ def _collect_l3_text(l2_node: L2Node) -> str:
for l3 in l2_node.children:
parts.append(l3.card.frame_summary)
parts.extend(l3.card.visible_text)
if l3.subtitle:
parts.append(l3.subtitle)
if l3.card.subtitle:
parts.append(l3.card.subtitle)
return "\n".join(parts)
@@ -139,8 +139,8 @@ def _collect_descendant_text_corpus(l1_node: L1Node) -> str:
for l3 in l2.children:
parts.append(l3.card.frame_summary)
parts.extend(l3.card.visible_text)
if l3.subtitle:
parts.append(l3.subtitle)
if l3.card.subtitle:
parts.append(l3.card.subtitle)
return "\n".join(parts)
@@ -221,6 +221,7 @@ def _verify_l2(l2: L2Node, stats: VerifyStats) -> None:
visible_text=kept_vt,
spatial_relations=old_card.spatial_relations,
state_changes=old_card.state_changes,
subtitle=old_card.subtitle,
)
+20 -9
View File
@@ -13,8 +13,8 @@
并发模型异步版::
build() asyncio.run(_build_async())
_build_async():
build() asyncio.run(build_async())
build_async():
asyncio.Semaphore(concurrency) 控制最大 VLM/LLM 并发数
L1 段并发构建段内 L2 clip 各启动 _chain 协程
提取全部 L3 采样 L2 代表帧 L2 VLM L3 VLM
@@ -156,8 +156,8 @@ class VideoTreeBuilder:
转化为三层 TreeIndex
并发架构:
build() 为同步壳内部调用 asyncio.run(_build_async())
_build_async() 使用 asyncio.Semaphore(concurrency) 控制并发 VLM/LLM 数量
build() 为同步壳内部调用 asyncio.run(build_async())
build_async() 使用 asyncio.Semaphore(concurrency) 控制并发 VLM/LLM 数量
所有 VLM 调用通过 VLMProvider 的异步接口发起零线程阻塞
所有 LLM 调用通过 LLMProvider 的异步接口发起L1 摘要
ffmpeg 提帧在独立 ThreadPoolExecutor 中并行不阻塞事件循环
@@ -166,6 +166,7 @@ class VideoTreeBuilder:
_vlm: VLM 图文调用端口
_llm: LLM 文本调用端口L1 摘要
_config: 树构建配置
_api_semaphore: 外部注入的全局 API 并发信号量None build_async 自建
_ffmpeg_pool: ffmpeg 专用线程池max_workers=_FFMPEG_MAX_WORKERS
"""
@@ -174,6 +175,8 @@ class VideoTreeBuilder:
vlm: VLMProvider,
llm: LLMProvider,
config: TreeConfig,
*,
api_semaphore: asyncio.Semaphore | None = None,
) -> None:
"""初始化视频树构建器。
@@ -183,10 +186,14 @@ class VideoTreeBuilder:
config: 树构建配置TreeConfig关键字段
l1_segment_duration, l2_clip_duration, l3_fps,
l2_representative_frames, cache_dir, concurrency
api_semaphore: 外部注入的全局 VLM/LLM 并发信号量批量建树时跨视频共享
None build_async 内部按 config.concurrency 自建
单视频行为零变化
"""
self._vlm = vlm
self._llm = llm
self._config = config
self._api_semaphore = api_semaphore
self._ffmpeg_pool = ThreadPoolExecutor(max_workers=_FFMPEG_MAX_WORKERS)
self._cache_root = Path(self._config.cache_dir)
self._session_id: str = ""
@@ -296,13 +303,13 @@ class VideoTreeBuilder:
返回:
三层 TreeIndex 对象
"""
return asyncio.run(self._build_async(video_path, srt_entries))
return asyncio.run(self.build_async(video_path, srt_entries))
# ------------------------------------------------------------------
# 核心异步构建逻辑(保真算法 #1:L2→L3 链式触发)
# ------------------------------------------------------------------
async def _build_async(
async def build_async(
self,
video_path: str,
srt_entries: list[SRTEntry] | None = None,
@@ -353,8 +360,12 @@ class VideoTreeBuilder:
# Phase 1.1: 读取已有进度(保真算法 #3:断点续跑)
finished_l1_ids = self._load_resume_state(source_id, total_l1)
# 创建 VLM/LLM 并发控制信号量
vlm_sem = asyncio.Semaphore(self._config.concurrency)
# 创建 VLM/LLM 并发控制信号量(外部注入时跨视频全局共享,Spec-2)
vlm_sem = (
self._api_semaphore
if self._api_semaphore is not None
else asyncio.Semaphore(self._config.concurrency)
)
# Phase 2-5: 按 L1 段并发,段内 L2→L3 链式触发(保真算法 #1)
async def _build_segment(
@@ -480,7 +491,7 @@ class VideoTreeBuilder:
)
index = TreeIndex(metadata=metadata, roots=l1_nodes)
# Phase 7: 字幕 Voronoi 分配(可选)
# Phase 7: 字幕 Voronoi 分配到 L3/L2 Card.subtitle(可选)
if srt_entries:
assign_subtitles_voronoi(index, srt_entries)
logger.info("字幕 Voronoi 分配完成", n_entries=len(srt_entries))
+30 -4
View File
@@ -20,15 +20,15 @@ embed:
backend: "local"
model_name: "BAAI/bge-base-zh-v1.5"
embed_dim: 768
device: "cpu"
device: "cuda"
# ── Harness 自进化循环 ──
harness:
workspace_dir: "workspaces/default"
store_dir: store
mode: infer
concurrency: 12
max_steps: 15
concurrency: 24
max_steps: 40
skill_mode: auto
n_samples: 0
questions: "benchmarks/Video-MME"
@@ -42,7 +42,6 @@ harness:
gate_delta_min: 0.02
gate_lambda_dir: -0.642
gate_e_rollback: 10.0
gate_block: 8
gate_n_max: 40
gate_p_low: 0.05
gate_p_high: 0.95
@@ -67,5 +66,32 @@ harness:
batch_correct_ratio: 0.5
momentum_samples: 20
eval_min_per_class: 2
trainable_min_units: 8
early_stop_patience: 8
use_slow_momentum: true
# 池构建策略
pool_split_mode: global # global | per_category
train_ratio: 0.667 # per_category 模式下 train/(train+val) 比例
test_questions: "benchmarks/Video-MME" # test 池的题目来源
# ── 出题管线 v2 ──
question_gen_v2:
family_ratios:
retrieval: 0.30
reasoning: 0.25
enumeration: 0.20
visual: 0.15
spatial: 0.10
gate:
blind_answer_model: "gpt-4.1-mini"
leak_test_model: "gpt-4.1-mini"
key_verify_model: "gpt-4.1-mini"
multi_true_model: "gpt-4.1-mini"
dedup_threshold: 0.85
retry_limit: 3
heavy_sample_rate: 0.15
heavy_agent_model: "gpt-4.1-mini"
output_dir: "store/questions/generated-v2"
per_type: 20 # 12 类 x 20 = 240 题(设计 §3 硬约束)
concurrency: 4
seed: 42
+80
View File
@@ -0,0 +1,80 @@
# 补生 Video-MME 6 类缺失题型(180 题)
# 原 360 题中有 6 类错误类型已归档,此配置只生成缺失的 6 类。
# ── 建树模块 ──(与 question_gen_360.yaml 一致)
tree:
max_paragraphs_per_l2: 5
l1_segment_duration: 600.0
l2_clip_duration: 60.0
l3_fps: 0.5
l2_representative_frames: 6
cache_dir: "cache/trees"
concurrency: 16
subtitle_inject: true
srt_window_sec: 5.0
# ── Embedding ──
embed:
backend: "local"
model_name: "BAAI/bge-base-zh-v1.5"
embed_dim: 768
device: "cuda"
# ── Harness ──(占位,出题不使用)
harness:
workspace_dir: "workspaces/default"
store_dir: store
mode: infer
concurrency: 24
max_steps: 40
skill_mode: auto
n_samples: 0
questions: "benchmarks/Video-MME"
skills_version: v1
prompts_version: v1
epochs: 1
gate_e_confirm: 20.0
gate_e_provisional: 3.0
gate_w_net_min: 2
gate_delta_min: 0.02
gate_lambda_dir: -0.642
gate_e_rollback: 10.0
gate_n_max: 40
gate_p_low: 0.05
gate_p_high: 0.95
gate_probe_quota: 0.2
gate_gamma_decay: 0.9
gate_cooldown_steps: 2
gate_guard_err: 0.10
edit_budget_start: 5
edit_budget_end: 2
skill_update_mode: patch
appendix_consolidate_threshold: 6
diag_size: 200
diag_correct_ratio: 0.5
val_size: 30
val_correct_ratio: 0.5
test_size: 60
batch_size: 15
min_class_per_batch: 2
batch_correct_ratio: 0.5
momentum_samples: 20
eval_min_per_class: 2
early_stop_patience: 8
use_slow_momentum: true
# ── 出题管线 v2 ──
question_gen_v2:
family_ratios:
retrieval: 0.30
reasoning: 0.25
enumeration: 0.20
visual: 0.15
spatial: 0.10
dedup_threshold: 0.85
retry_limit: 10
heavy_sample_rate: 0.15
output_dir: "store/questions/generated-v2-180补"
per_type: 30 # 6 类 x 30 = 180 题
concurrency: 24
seed: 43 # 不同于原始 seed=42,避免生成相同题目
+86
View File
@@ -0,0 +1,86 @@
# config/default.yaml
# 科研实验配置默认值来源(会在实验中反复扫动/对比的参数)。
# 工程配置(少变、敏感)由 .env / pydantic-settings 管理,不在此文件。
# 优先级: CLI args > 此文件。CLI 仅用于单次临时覆盖。
# ── 建树模块 ──
tree:
max_paragraphs_per_l2: 5
l1_segment_duration: 600.0 # L1 段时长(秒)
l2_clip_duration: 60.0 # L2 clip 时长(秒)
l3_fps: 0.5 # L3 帧提取频率(帧/秒)
l2_representative_frames: 6 # L2 VLM 描述用的代表帧数
cache_dir: "cache/trees"
concurrency: 16 # asyncio Semaphore 上限
subtitle_inject: true # 建树时是否注入 SRT 字幕
srt_window_sec: 5.0 # 字幕匹配时间窗口(前后各 N 秒)
# ── Embedding ──
embed:
backend: "local"
model_name: "BAAI/bge-base-zh-v1.5"
embed_dim: 768
device: "cuda"
# ── Harness 自进化循环 ──
harness:
workspace_dir: "workspaces/default"
store_dir: store
mode: infer
concurrency: 24
max_steps: 40
skill_mode: auto
n_samples: 0
questions: "benchmarks/Video-MME"
skills_version: v1
prompts_version: v1
epochs: 1
# CE-Gate 参数
gate_e_confirm: 20.0
gate_e_provisional: 3.0
gate_w_net_min: 2
gate_delta_min: 0.02
gate_lambda_dir: -0.642
gate_e_rollback: 10.0
gate_n_max: 40
gate_p_low: 0.05
gate_p_high: 0.95
gate_probe_quota: 0.2
gate_gamma_decay: 0.9
gate_cooldown_steps: 2
gate_guard_err: 0.10
# 进化参数
edit_budget_start: 5
edit_budget_end: 2
skill_update_mode: patch
appendix_consolidate_threshold: 6
# 数据池
diag_size: 200
diag_correct_ratio: 0.5
val_size: 30
val_correct_ratio: 0.5
test_size: 60
# mini-batch
batch_size: 15
min_class_per_batch: 2
batch_correct_ratio: 0.5
momentum_samples: 20
eval_min_per_class: 2
early_stop_patience: 8
use_slow_momentum: true
# ── 出题管线 v2 ──
question_gen_v2:
family_ratios:
retrieval: 0.30
reasoning: 0.25
enumeration: 0.20
visual: 0.15
spatial: 0.10
dedup_threshold: 0.85
retry_limit: 10
heavy_sample_rate: 0.15
output_dir: "store/questions/generated-v2-360"
per_type: 30 # 12 类 x 30 = 360 题
concurrency: 24
seed: 42
+21
View File
@@ -0,0 +1,21 @@
# AR 专项出题配置:使用 ActionRecognitionStrategy + 6 SubPattern 靶向生成
# 目标:生成 30 道 Action Recognition 题
question_gen_v2:
per_type: 30 # 只跑 AR 一类,30 题
retry_limit: 15 # AR 约束更严,给更多重试机会
heavy_sample_rate: 0.0 # 不需要重量抽检
dedup_threshold: 0.85
concurrency: 8 # AR 需要帧,适度并发
seed: 2024
output_dir: "store/questions/generated-ar30"
candidate_pool_size: 24 # grounded selector 首轮候选干扰项数 N
selector_delta_low: 0.05 # 干扰项视觉分与正解的最小差(区间上界)
selector_delta_high: 0.35 # 干扰项视觉分与正解的最大差(区间下界)
# Phase B 后置对抗过滤层配置(filter 层,非 strategy 属性)
adversarial_filter:
filter_task_types: [Action Recognition] # 仅这些题型走 agent 门(路径隔离开关)
adversarial_max_rounds: 5 # 补生成迭代上限
adversarial_agent_max_steps: 40 # agent 试答步数上限
difficulty_warn_threshold: 0.85 # 批次 agent 正确率告警阈值
+63
View File
@@ -0,0 +1,63 @@
# config/train_action_recognition.yaml
# Action Recognition 单题型首次训练实验
# 设计文档: research-wiki/designs/2026-07-14-action-recognition-training-design.md
harness:
workspace_dir: "workspaces/train-action-recognition"
store_dir: store
mode: train
run_id: train_ar_v1
concurrency: 24
max_steps: 40
skill_mode: auto
n_samples: 0
questions: "generated-v2-360"
skills_version: v1
prompts_version: v1
epochs: 3
# CE-Gate 参数(沿用 default.yaml
gate_e_confirm: 20.0
gate_e_provisional: 3.0
gate_w_net_min: 2
gate_delta_min: 0.02
gate_lambda_dir: -0.642
gate_e_rollback: 10.0
gate_n_max: 40
gate_p_low: 0.05
gate_p_high: 0.95
gate_probe_quota: 0.2
gate_gamma_decay: 0.9
gate_cooldown_steps: 2
gate_guard_err: 0.10
# 进化参数
edit_budget_start: 5
edit_budget_end: 2
skill_update_mode: patch
appendix_consolidate_threshold: 6
# 池配置 — per_category 单题型
pool_split_mode: per_category
task_types:
- "Action Recognition"
train_ratio: 0.667
test_questions: "benchmarks/Video-MME"
run_holdout_eval: false
# mini-batch
batch_size: 10
min_class_per_batch: 2
batch_correct_ratio: 0.5
momentum_samples: 20
eval_min_per_class: 2
trainable_min_units: 8
early_stop_patience: 4
test_size: 63
diag_size: 20
diag_correct_ratio: 0.5
val_size: 10
val_correct_ratio: 0.5
use_slow_momentum: true
embed:
backend: "local"
model_name: "BAAI/bge-base-zh-v1.5"
embed_dim: 768
device: "cuda"
+64
View File
@@ -0,0 +1,64 @@
# config/train_ar30.yaml
# Action Recognition 训练 — 基于 SubPattern 靶向生成的 30 题
# 对比基线: v2-360 的 AR 题(100% 单帧,训练无效)
# 本次: AR30 题(6 种失败子模式靶向,跨段时序)
harness:
workspace_dir: "workspaces/train-ar30"
store_dir: store
mode: train
run_id: train_ar30_v1
concurrency: 24
max_steps: 40
skill_mode: auto
n_samples: 0
questions: "generated-ar30"
skills_version: v1
prompts_version: v1
epochs: 3
# CE-Gate 参数(沿用 default.yaml
gate_e_confirm: 20.0
gate_e_provisional: 3.0
gate_w_net_min: 2
gate_delta_min: 0.02
gate_lambda_dir: -0.642
gate_e_rollback: 10.0
gate_n_max: 40
gate_p_low: 0.05
gate_p_high: 0.95
gate_probe_quota: 0.2
gate_gamma_decay: 0.9
gate_cooldown_steps: 2
gate_guard_err: 0.10
# 进化参数
edit_budget_start: 5
edit_budget_end: 2
skill_update_mode: patch
appendix_consolidate_threshold: 6
# 池配置 — per_category 单题型
pool_split_mode: per_category
task_types:
- "Action Recognition"
train_ratio: 0.667
test_questions: "benchmarks/Video-MME"
run_holdout_eval: false
# mini-batch
batch_size: 10
min_class_per_batch: 2
batch_correct_ratio: 0.5
momentum_samples: 20
eval_min_per_class: 2
trainable_min_units: 8
early_stop_patience: 4
test_size: 63
diag_size: 20
diag_correct_ratio: 0.5
val_size: 10
val_correct_ratio: 0.5
use_slow_momentum: true
embed:
backend: "local"
model_name: "BAAI/bge-base-zh-v1.5"
embed_dim: 768
device: "cuda"
+69
View File
@@ -0,0 +1,69 @@
# config/train_videomme.yaml
# Video-MME 900 题自进化训练 —— 消费 video-split 冻结切分(global 三池)
#
# 数据来源: workspaces/video-split/pools.jsontier 感知 diag/val + val 功效修复)
# 经 adhoc-baseline seed 携带进 workspaceWP2 接线)。
# 训练前置: .env REDIS_CACHE_TTL 须为正整数(WP4 fail-loud);见
# research-wiki/reviews/2026-07-16-preflight-final-review.md runbook。
harness:
workspace_dir: "workspaces/train-videomme"
store_dir: store
mode: train
run_id: train_videomme_v2
concurrency: 32
max_steps: 40
skill_mode: auto
n_samples: 0
questions: "benchmarks/Video-MME" # gate 指纹依赖加载全 900 题
skills_version: v1
prompts_version: v1
epochs: 3
# CE-Gate 参数(沿用 default.yaml
gate_e_confirm: 20.0
gate_e_provisional: 3.0
gate_w_net_min: 2
gate_delta_min: 0.02
gate_lambda_dir: -0.642
gate_e_rollback: 10.0
gate_n_max: 40
gate_p_low: 0.05
gate_p_high: 0.95
gate_probe_quota: 0.2
gate_gamma_decay: 0.9
gate_cooldown_steps: 2
gate_guard_err: 0.10
# 进化参数
edit_budget_start: 5
edit_budget_end: 2
skill_update_mode: patch
appendix_consolidate_threshold: 6
# 池配置 —— global 冻结切分(diag/val/test 尺寸由 pools.json 冻结,以下采样旋钮加载时忽略)
# 实际冻结切分(0.4+tierfingerprint d456ef4f: diag=180 val=120 test=600
pool_split_mode: global
diag_size: 180
diag_correct_ratio: 0.5
val_size: 120
val_correct_ratio: 0.5
test_size: 600
test_questions: "benchmarks/Video-MME"
# 可训练性预检(WP3):val 单元 < eval_min_per_class 或 非test单元 < trainable_min_units 的题型剔除
eval_min_per_class: 2
trainable_min_units: 8
# mini-batch —— 对齐 TRM4 正式实验 batch=40sh --batch-size 40 覆盖 yaml 15 的最终生效值):
# 8 可训题型 × 每型约 5 题/step,保住题型级诊断信号;同时 steps/epoch 180/40≈5
# 进化/gate 验证轮数比 batch=10 少 4 倍。
batch_size: 40
min_class_per_batch: 2
batch_correct_ratio: 0.5
momentum_samples: 20
early_stop_patience: 2 # epoch 粒度(WP3):连续 2 epoch 无 best 刷新即停
use_slow_momentum: true
run_holdout_eval: true # 逐 epoch test 四向评估(WP3 去重版:baseline 推导 + 版本备忘录)
# 全 12 题型(不指定 task_types 子集,避免 I-4 语义偏差;微型类由预检自动剔除)
embed:
backend: "local"
model_name: "BAAI/bge-base-zh-v1.5"
embed_dim: 768
device: "cuda"
+33
View File
@@ -0,0 +1,33 @@
# 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.4 # validation 占 trainval 视频组总数的比例(0.3→0.4 提升整包终审功效,WP2)
seed: 7 # 贪心选择器预洗牌 + 视频组题级切分种子(打破等增益 / 等槽平局)
floor_k: # 各 task_type 的 T2 defect 下限(硬约束)—— 均衡覆盖全 11 类,标定于 1cb1c203 真实 T2 分布
Object Reasoning: 5 # T2=25
Information Synopsis: 5 # T2=13
Action Reasoning: 5 # T2=11
Counting Problem: 5 # T2=10
Temporal Reasoning: 2 # T2=5
Object Recognition: 2 # T2=5
Action Recognition: 2 # T2=5
Attribute Perception: 1 # T2=3
Temporal Perception: 1 # T2=2
OCR Problems: 1 # T2=2
Spatial Perception: 1 # T2=1
diag: # 诊断口径指纹三分量(隔离不同诊断配置的信号,参与主键)
prompt_version: diagnose_v1 # 诊断 prompt 版本标识(换 prompt 即换指纹,旧记录不被覆盖)
model: deepseek-v4-pro # 执行诊断的模型名(必须与 .env SEARCH_LLM_MODEL 一致,CLI 会 fail loud 校验)
# code_version 由 build_video_split.sh 注入 git 短 SHA,不写死在此(随代码变动)
+108 -9
View File
@@ -17,7 +17,9 @@ TRM4 → TRM5 有意变更(非简化):
from __future__ import annotations
import asyncio
import json
import re
from typing import TYPE_CHECKING, Any
import pluggy
@@ -31,6 +33,9 @@ if TYPE_CHECKING:
from core.protocols import LLMProvider
from core.types import LLMResponse
# deepseek 等模型稳定输出变体:```json 围栏包裹 JSON 体
_CODE_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*\n?|\n?\s*```\s*$")
async def _call_hook(hook: Any, **kwargs: Any) -> list[Any]:
"""调用 pluggy hook 并 await 异步返回值。
@@ -67,6 +72,13 @@ class AgentLoop:
llm: LLMProvider 实例Protocol 类型化提供 async chat 方法
max_steps: 最大有效步数每次成功工具调用计一步
max_retries: JSON 解析连续失败的最大容忍次数
step_retries: LLM 瞬时异常的步级重试次数不含首次调用
step_retry_delays: 步级重试的退避秒数序列超出部分取末值
不得为空空序列构造时抛 ValueErrorfail-fast
retryable_exceptions: 可重试异常元组默认 (TimeoutError, OSError)
ssl.SSLError/ConnectionError 均为 OSError 子类覆盖穿透
GovernedLLMClient 内部重试栈的瞬时异常openai API 类异常由
治理层负责core 不依赖 SDK
"""
def __init__(
@@ -74,10 +86,19 @@ class AgentLoop:
llm: LLMProvider,
max_steps: int,
max_retries: int = 3,
*,
step_retries: int = 2,
step_retry_delays: tuple[float, ...] = (20.0, 40.0),
retryable_exceptions: tuple[type[BaseException], ...] = (TimeoutError, OSError),
) -> None:
if not step_retry_delays:
raise ValueError("step_retry_delays 不得为空")
self._llm = llm
self._max_steps = max_steps
self._max_retries = max_retries
self._step_retries = step_retries
self._step_retry_delays = step_retry_delays
self._retryable_exceptions = retryable_exceptions
async def run(
self,
@@ -87,6 +108,7 @@ class AgentLoop:
plugins: list[object] | None = None,
*,
session_id: str | None = None,
cache_salt: str | None = None,
) -> LoopResult:
"""执行 Thinking+JSON 推理循环。
@@ -96,6 +118,7 @@ class AgentLoop:
tool_dispatcher: 工具调度器ToolDispatcher Protocol 实例
plugins: pluggy 插件列表
session_id: 会话 ID透传给 LLMProvider
cache_salt: 缓存盐透传给 LLMProvider如训练用 run_id epoch 重采样
返回:
LoopResult 实例包含推理步骤token 用量终止原因
@@ -114,11 +137,13 @@ class AgentLoop:
while step_count < self._max_steps:
await _call_hook(pm.hook.before_step, iteration=iteration, messages=messages)
# Phase 1: LLM 调用
# Phase 1: LLM 调用(步级重试:防穿透 GovernedLLMClient 的瞬时异常)
try:
response = await self._call_llm(messages, token_usage, session_id=session_id)
response = await self._call_llm_with_step_retry(
messages, token_usage, session_id=session_id, cache_salt=cache_salt
)
except Exception as e:
logger.error("LLM API 调用失败: {}", e)
logger.error("LLM API 调用失败{}: {}", type(e).__name__, e)
result = LoopResult(
steps=steps,
steps_used=step_count,
@@ -240,14 +265,21 @@ class AgentLoop:
pm.register(plugin)
return pm
async def _call_llm(
async def _call_llm_with_step_retry(
self,
messages: list[dict[str, Any]],
token_usage: dict[str, int],
*,
session_id: str | None = None,
cache_salt: str | None = None,
) -> LLMResponse:
"""调用 LLMProvider 并累加 token 使用量
"""带步级重试的 LLM 调用,兜底穿透治理层重试栈的瞬时异常
可重试异常self._retryable_exceptions self._step_retry_delays
退避后重发重试预算self._step_retries耗尽后抛出最后一次异常
非可重试异常直接穿透fail-fastasyncio.CancelledError 继承
BaseException天然不被捕获取消信号照常传播失败尝试的 error
遥测由 GovernedLLMClient 内部负责此处仅 loguru 记录
参数:
messages: 消息历史
@@ -256,8 +288,52 @@ class AgentLoop:
返回:
LLMResponse 实例
异常:
重试耗尽或非可重试时原样抛出底层异常
"""
response = await self._llm.chat(messages, session_id=session_id)
step_attempt = 0
while True:
try:
return await self._call_llm(
messages, token_usage, session_id=session_id, cache_salt=cache_salt
)
except self._retryable_exceptions as e:
step_attempt += 1
if step_attempt > self._step_retries:
raise
delay = self._step_retry_delays[
min(step_attempt - 1, len(self._step_retry_delays) - 1)
]
logger.warning(
"LLM 瞬时异常,步级重试 {}/{}{}s 后重发): {}",
step_attempt,
self._step_retries,
delay,
e,
)
await asyncio.sleep(delay)
async def _call_llm(
self,
messages: list[dict[str, Any]],
token_usage: dict[str, int],
*,
session_id: str | None = None,
cache_salt: str | None = None,
) -> LLMResponse:
"""调用 LLMProvider 并累加 token 使用量。
参数:
messages: 消息历史
token_usage: 可变字典就地累加
session_id: 会话 ID透传给 LLMProvider
cache_salt: 缓存盐透传给 LLMProvider epoch 重采样
返回:
LLMResponse 实例
"""
response = await self._llm.chat(messages, session_id=session_id, cache_salt=cache_salt)
token_usage["prompt_tokens"] += response.prompt_tokens
token_usage["completion_tokens"] += response.completion_tokens
return response
@@ -267,7 +343,8 @@ class AgentLoop:
) -> tuple[str, dict, dict, str, dict, str] | None:
"""从 LLMResponse 中提取结构化决策数据。
解析流程: content repair_json json.loads 校验 action/tool/args
解析流程: content 剥除 ```json 围栏 repair_json json.loads
收拢 action 平铺参数 校验 action/tool/args
参数:
response: LLMResponse 实例
@@ -282,7 +359,7 @@ class AgentLoop:
if not content.strip():
return None
repaired = repair_json(content)
repaired = repair_json(_CODE_FENCE_RE.sub("", content).strip())
try:
data = json.loads(repaired)
except (json.JSONDecodeError, ValueError):
@@ -291,7 +368,7 @@ class AgentLoop:
if not isinstance(data, dict) or "action" not in data:
return None
action = data["action"]
action = self._normalize_action(data["action"])
if not isinstance(action, dict) or "tool" not in action or "args" not in action:
return None
@@ -299,6 +376,28 @@ class AgentLoop:
plan = data.get("plan", {})
return thought, reflect, plan, content, action, response.call_id
@staticmethod
def _normalize_action(action: Any) -> Any:
"""收拢 deepseek 变体的 action 平铺参数。
deepseek 等模型稳定输出变体: 工具参数平铺在 action args
嵌套确定性收拢为标准 {"tool": ..., "args": {...}} 结构
仅当除 tool 外至少存在一个平铺参数键时才收拢无参结构
{"tool": "x"}原样返回交由调用方校验拒绝避免把缺参
错误静默升级为空 args 合法结构标准嵌套与非法结构同样原样返回
参数:
action: LLM 输出解析出的 action 字段任意类型
返回:
归一化后的 action仅带平铺参数的变体被改写其余原样返回
"""
if isinstance(action, dict) and "tool" in action and "args" not in action:
flat_args = {k: v for k, v in action.items() if k != "tool"}
if flat_args:
return {"tool": action["tool"], "args": flat_args}
return action
async def _execute_tool(
self,
dispatcher: ToolDispatcher,
+2 -1
View File
@@ -4,7 +4,7 @@
只依赖 Protocol 接口和标准库可搬到无 adapters 的环境用假实现原样运行
"""
from core.evolution.diagnose import run_diagnosis
from core.evolution.diagnose import INFRA_STOP_REASONS, run_diagnosis
from core.evolution.evolve import (
edit_budget_at,
evolve_single_skill,
@@ -44,6 +44,7 @@ from core.evolution.types import (
from core.evolution.validate import classify_quadrants, compute_accuracy, pair_block
__all__ = [
"INFRA_STOP_REASONS",
"CaseSample",
"DiagnosePrompts",
"DiagnosisResult",
+19 -8
View File
@@ -53,7 +53,7 @@ if TYPE_CHECKING:
_SPAN_EVAL_TOOLS: frozenset[str] = frozenset({"view_node", "search_similar", "observe_frame"})
"""span 级评估涵盖的工具集合。"""
_INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})
INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})
"""执行/解析层失败导致排除的 stop_reason 集合。"""
@@ -1489,12 +1489,15 @@ def _build_skill_case_packs(
if qm.correct:
continue
attr = attribution_map.get(qm.question_id)
if attr is not None and attr.cause_category == "lapse":
if attr.lapse_note and attr.lapse_note.strip():
# 仅明确 defect 且非 degraded 才进正文进化路径;
# lapse / cause_category=None(判别失败)/ degradedjudge 解析失败)一律保守走 lapse,
# 不以降级或未判定信号驱动错误进化。
is_defect = attr is not None and attr.cause_category == "defect" and not qm.degraded
if not is_defect:
if attr is not None and attr.lapse_note and attr.lapse_note.strip():
lapse_notes.append(attr.lapse_note)
continue
et = attr.error_type if attr else "mixed"
wrong_by_error[et].append(qm)
wrong_by_error[attr.error_type].append(qm)
# 单条 fallback
n_body_failures = sum(len(group) for group in wrong_by_error.values())
@@ -2004,7 +2007,7 @@ def _count_infra_excluded(
qids = [
row["question_id"]
for row in prediction_rows
if row.get("stop_reason") in _INFRA_STOP_REASONS
if row.get("stop_reason") in INFRA_STOP_REASONS
]
return len(qids), qids
@@ -2080,7 +2083,7 @@ async def run_diagnosis(
for row in all_predictions:
stop_reason = row.get("stop_reason")
if stop_reason in _INFRA_STOP_REASONS:
if stop_reason in INFRA_STOP_REASONS:
continue
if task_type_filter and row.get("task_type") not in task_type_filter:
continue
@@ -2142,7 +2145,15 @@ async def run_diagnosis(
key = (prediction.get("video_id", ""), prediction.get("question_id", ""))
traces = traces_by_question.get(key, [])
vid = prediction.get("video_id", "")
td = tree_data_by_video.get(vid, {})
if vid not in tree_data_by_video:
qid = prediction.get("question_id", "")
# P5 fail-loud:诊断需真实树,调用方须为每个诊断视频加载 tree_data;
# 静默回退空树会让 ground_truth 恒空、error_type 归因坍缩(本次修复的根因)。
raise ValueError(
f"诊断视频树未覆盖: video_id={vid!r} question_id={qid!r} 不在注入的 tree_data 中"
"(调用方须为每个诊断视频加载树,P5 fail loud"
)
td = tree_data_by_video[vid]
skill_content = skill_cache.get(prediction.get("task_type", ""), "")
try:
+40
View File
@@ -19,6 +19,8 @@ from loguru import logger
from core.evolution.patch import (
APPENDIX_END,
APPENDIX_START,
MOMENTUM_END,
MOMENTUM_START,
append_to_appendix,
apply_patch_with_report,
extract_appendix_notes,
@@ -293,10 +295,39 @@ def _tool_protected_spans(text: str) -> list[str]:
# =========================================================================
def _check_marker_integrity(evolved: str) -> list[str]:
"""校验 evolved 中冻结区 marker 的完整性(成对、至多一对、START 先于 END)。
进化写入可能破坏 appendix/momentum marker 配对破坏后 append_to_appendix /
replace_momentum 等下游会静默误拼或抛错此处集中拦截任一 marker 对违反
START ==END 各至多一对START END 即整体 reject
参数:
evolved: 改写后的全文
返回:
错误信息列表空列表表示 marker 完整
"""
errors: list[str] = []
for name, start_m, end_m in (
("APPENDIX", APPENDIX_START, APPENDIX_END),
("MOMENTUM", MOMENTUM_START, MOMENTUM_END),
):
s = evolved.count(start_m)
e = evolved.count(end_m)
if s != e:
errors.append(f"{name} marker 不配对:START={s} END={e}")
elif s > 1:
errors.append(f"{name} marker 出现多对({s}),至多一对")
elif s == 1 and evolved.index(start_m) > evolved.index(end_m):
errors.append(f"{name} marker 顺序错误:END 出现在 START 之前")
return errors
def validate_skill(original: str, evolved: str) -> ValidationResult:
"""校验 Skill 改写结果。
检查项: frontmatter 三字段保留name / description / task_type
marker 完整性appendix/momentum 成对且至多一对顺序正确
长度比在 [0.3, 2.0]代码块闭合
参数:
@@ -305,6 +336,11 @@ def validate_skill(original: str, evolved: str) -> ValidationResult:
返回:
ValidationResult 实例
关键实现细节:
marker 完整性先于长度校验长度校验经 _strip_protected_regions 调用
momentum_region_bounds对损坏 marker 会抛 ValueError marker 破坏时先
返回失败避免异常穿透且明确 reject 该候选
"""
errors: list[str] = []
orig_fm = _parse_frontmatter(original)
@@ -319,6 +355,10 @@ def validate_skill(original: str, evolved: str) -> ValidationResult:
errors.append(
f"frontmatter 字段 {key} 被修改: {orig_fm.get(key)!r}{evol_fm.get(key)!r}"
)
marker_errors = _check_marker_integrity(evolved)
if marker_errors:
errors.extend(marker_errors)
return ValidationResult(passed=False, errors=errors)
errors.extend(_check_length(original, evolved))
errors.extend(_check_code_blocks(evolved))
return ValidationResult(passed=len(errors) == 0, errors=errors)
+47 -19
View File
@@ -15,9 +15,7 @@ APPENDIX_MAX_CHARS = 2000 # appendix 区软上限(守设计「长度上限+wa
MOMENTUM_START = "<!-- MOMENTUM_START -->"
MOMENTUM_END = "<!-- MOMENTUM_END -->"
MOMENTUM_MAX_CHARS = 2000 # momentum 区软上限(与 appendix 一致:超限 warning 不截断)
MOMENTUM_HEADING = (
"## 动量指导(每轮重写,勿手改)" # replace_momentum 写入的固定标题行
)
MOMENTUM_HEADING = "## 动量指导(每轮重写,勿手改)" # replace_momentum 写入的固定标题行
def momentum_region_bounds(text: str) -> tuple[int, int] | None:
@@ -284,9 +282,34 @@ def _protected_ranges(content: str, spans: list[str]) -> list[tuple[int, int]]:
return ranges
def _in_ranges(pos: int, ranges: list[tuple[int, int]]) -> bool:
"""判断位置 pos 是否落在任意冻结区间内。"""
return any(start <= pos < end for start, end in ranges)
def _span_overlaps_ranges(pos: int, length: int, ranges: list[tuple[int, int]]) -> bool:
"""判断 [pos, pos+length) 是否与任一冻结区间相交(不止起点)。
起点落在正文末端伸入冻结区的 target 也须拦截否则 replace/delete 会连带
改动冻结区如破坏 appendix/momentum marker
参数:
pos: target 在正文中的起点
length: target 长度
ranges: 冻结区间 [start, end) 列表
返回:
与任一区间相交返回 True
"""
end = pos + length
return any(start < end and pos < r_end for start, r_end in ranges)
# 冻结区 marker 字面量:LLM 生成的 edit 不得注入这些字面量,否则破坏 marker 配对
_MARKER_LITERALS = (APPENDIX_START, APPENDIX_END, MOMENTUM_START, MOMENTUM_END)
def _edit_injects_marker(edit: dict) -> bool:
"""判断 edit 的 target/content 是否含冻结区 marker 字面量(注入拦截)。"""
for key in ("target", "content"):
value = edit.get(key)
if isinstance(value, str) and any(m in value for m in _MARKER_LITERALS):
return True
return False
def _append_at(content: str, ranges: list[tuple[int, int]]) -> int:
@@ -303,9 +326,7 @@ def _insert_at(content: str, at: int, payload: str) -> str:
return head + "\n\n" + payload + "\n"
def _do_append(
content: str, payload: str, ranges: list[tuple[int, int]]
) -> tuple[str, str]:
def _do_append(content: str, payload: str, ranges: list[tuple[int, int]]) -> tuple[str, str]:
"""执行 append 操作,返回更新后内容与状态字符串。"""
return _insert_at(content, _append_at(content, ranges), payload), "applied_append"
@@ -321,8 +342,8 @@ def _do_insert_after(
_insert_at(content, _append_at(content, ranges), payload),
"applied_insert_after_fallback",
)
if _in_ranges(pos, ranges):
logger.warning("insert_after 目标冻结区,跳过 target={}", target[:80])
if _span_overlaps_ranges(pos, len(target), ranges):
logger.warning("insert_after 目标跨入冻结区,跳过 target={}", target[:80])
return content, "skipped_protected"
at = pos + len(target)
nl = content.find("\n", at)
@@ -344,16 +365,14 @@ def _do_replace_delete(
if pos == -1:
logger.warning("{} 锚点缺失,跳过 target={}", op, target[:80])
return content, "skipped_target_not_found"
if _in_ranges(pos, ranges):
logger.warning("{} 目标冻结区,跳过 target={}", op, target[:80])
if _span_overlaps_ranges(pos, len(target), ranges):
logger.warning("{} 目标跨入冻结区,跳过 target={}", op, target[:80])
return content, "skipped_protected"
new_content = content.replace(target, payload if op == "replace" else "", 1)
return new_content, "applied_" + op
def _apply_one(
content: str, edit: dict, ranges: list[tuple[int, int]]
) -> tuple[str, dict]:
def _apply_one(content: str, edit: dict, ranges: list[tuple[int, int]]) -> tuple[str, dict]:
"""应用单条 edit,返回 (更新后内容, 状态报告)。"""
if not isinstance(edit, dict):
return content, {
@@ -382,9 +401,7 @@ def _apply_one(
return content, report
if op in ("replace", "delete"):
content, report["status"] = _do_replace_delete(
op, content, target, payload, ranges
)
content, report["status"] = _do_replace_delete(op, content, target, payload, ranges)
return content, report
logger.warning("未知 op,跳过: {}", op)
@@ -411,6 +428,17 @@ def apply_patch_with_report(
reports: list[dict] = []
for i, edit in enumerate(edits, 1):
try:
if isinstance(edit, dict) and _edit_injects_marker(edit):
logger.warning("edit 含冻结区 marker 字面量,拒绝该 edit index={}", i)
report = {
"op": str(edit.get("op", "")),
"target": str(edit.get("target", "") or "")[:200],
"content_preview": str(edit.get("content", "") or "")[:200],
"status": "skipped_marker_injection",
}
report["index"] = i
reports.append(report)
continue
ranges = _protected_ranges(content, spans)
content, report = _apply_one(content, edit, ranges)
except (KeyError, TypeError, ValueError, AttributeError) as exc:
+59 -5
View File
@@ -1,13 +1,18 @@
"""core/evolution/ 子包的只读 Protocol 定义。
"""core/evolution/ 子包的持久化 Protocol 定义。
三个 Protocol 为只读core/ 返回结果 dataclass写入由 app/ 持久化
SkillStore / PromptStore 同步文件读取量小且快RunLog 为异步
隔离 SQLite 查询core/ 不写 SQL
SkillStore / PromptStore / RunLog 为只读core/ 返回结果 dataclass
读取由 app/ 落盘的资源SkillStore / PromptStore 同步文件读取量小且快
RunLog 异步隔离 SQLite 查询core/ 不写 SQL
DiagnosisSignalStore 兼具读写逐题 upsert 诊断信号并支持断点续跑查询
同样隔离 SQLite 实现app/core 不写裸 SQL
"""
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from core.evolution.types import DiagnosisSignalRow
@runtime_checkable
@@ -104,3 +109,52 @@ class RunLog(Protocol):
轨迹记录字典列表
"""
...
@runtime_checkable
class DiagnosisSignalStore(Protocol):
"""逐题诊断信号存储端口。
隔离 SQLite 实现细节app/core 不写裸 SQL逐题 upsert 落盘
支持断点续跑done_question_ids 查已完成集合
"""
def upsert(self, row: DiagnosisSignalRow) -> None:
"""写入或覆盖单题诊断信号(按主键幂等)。
参数:
row: 待持久化的诊断信号行
"""
...
def done_question_ids(
self,
baseline_run_id: str,
diag_fingerprint: str,
*,
retry_uncertain: bool = False,
) -> set[str]:
"""查询指定 run 与诊断指纹下已完成的 question_id 集合。
参数:
baseline_run_id: baseline run 标识
diag_fingerprint: 诊断口径指纹
retry_uncertain: True 时把 tier='uncertain'信号不可信降级题视为
未完成令其被重新诊断默认 Falseuncertain 也算完成不重诊
返回:
已落盘信号的 question_id 集合用于断点续跑跳过
"""
...
def load(self, baseline_run_id: str, diag_fingerprint: str) -> list[DiagnosisSignalRow]:
"""加载指定 run 与诊断指纹下的全部诊断信号行。
参数:
baseline_run_id: baseline run 标识
diag_fingerprint: 诊断口径指纹
返回:
还原后的 DiagnosisSignalRow 列表
"""
...
+38 -4
View File
@@ -300,6 +300,44 @@ class DiagnosisResult:
degraded_question_ids: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class DiagnosisSignalRow:
"""单题诊断信号行,即 baseline run 逐题诊断的持久化单元。
供后续视频级切分选择器消费 (question_id, baseline_run_id,
diag_fingerprint) 唯一确定逐题 upsert 支持断点续跑
属性:
question_id: 题目唯一标识
video_id: 对应视频唯一标识
baseline_run_id: 产出该信号的 baseline run 标识
diag_fingerprint: 诊断口径指纹隔离不同诊断配置的信号
task_type: 题目任务类型
error_type: 错误类别extraction/search/reasoning/mixed
T0/uncertain 行为 None
cause_category: 病因类别defect/lapse不适用为 None
tier: 诊断分层T0/T1/T2/uncertain
evolution_target: 进化目标tool/skill/system
error_type None 时亦为 None
degraded: 是否为降级信号judge 解析失败时生成
infra: 是否为 INFRA 护栏排除行
session_id: 关联的会话标识不适用为 None
"""
question_id: str
video_id: str
baseline_run_id: str
diag_fingerprint: str
task_type: str
error_type: str | None
cause_category: str | None
tier: str
evolution_target: str | None
degraded: bool
infra: bool
session_id: str | None
# =========================================================================
# 3. 进化类型
# =========================================================================
@@ -446,7 +484,6 @@ class DiagnosePrompts:
defect_vs_lapse: defect/lapse 病因判别模板
reasoning_sub: 推理失败子分类模板
span_eval_system: span 评估系统提示模板
span_eval_user: span 评估用户提示模板
missed_nodes: 遗漏节点检测模板
skill_adherence: 技能遵循判定模板
confirmation_bias: 确认偏误检测模板
@@ -456,7 +493,6 @@ class DiagnosePrompts:
defect_vs_lapse: str
reasoning_sub: str
span_eval_system: str
span_eval_user: str
missed_nodes: str
skill_adherence: str
confirmation_bias: str
@@ -474,11 +510,9 @@ class EvolvePrompts:
evolve_system: System Prompt 进化提示模板
evolve_tool: Tool Prompt 进化提示模板
evolve_rank: 编辑排序提示模板
consolidate_system: appendix 压缩系统提示
"""
evolve_skill: str
evolve_system: str
evolve_tool: str
evolve_rank: str
consolidate_system: str
+20 -17
View File
@@ -1,9 +1,12 @@
"""core/evolution/validate.py — 块验证纯决策函数。
算法 #7(块顺序验证)的局部实现:pair_block 逐题比对基线与候选、
算法 #7(块顺序验证)的局部实现:pair_block 按 unit 比对基线与候选、
classify_quadrants 四象限分类compute_accuracy 纯算术准确率
三个函数均为纯函数无副作用无外部依赖
三个函数均为纯函数无副作用无外部依赖输入的对错映射均为 **unit 口径**
unit_id 单元级正确性AR pair 已在上游经 unit_correctness_view 双向 AND
折叠保证 e-process W/L 与准确率分母按单元计不被 P/Q 单题计分污染
核心算法保真 #5:信息阶梯口径从 question_id 迁至 unit_id)。
"""
from core.evolution.types import PairResult, QuadrantClassification
@@ -12,24 +15,24 @@ from core.evolution.types import PairResult, QuadrantClassification
def pair_block(
baseline: dict[str, bool],
candidate: dict[str, bool],
question_ids: list[str],
unit_ids: list[str],
) -> PairResult:
"""逐题比对基线与候选对错,统计翻转。
"""按单元比对基线与候选对错,统计翻转。
参数:
baseline: 基线臂每题正确性映射
candidate: 候选臂每题正确性映射
question_ids: 参与比对的题目 ID 列表
baseline: 基线臂单元级正确性映射unit_id bool
candidate: 候选臂单元级正确性映射unit_id bool
unit_ids: 参与比对的单元 ID 列表AR pair 折叠后为单一 unit_id
返回:
PairResult包含 w基线错候选对翻转数l基线对候选错翻转数
observed (基线, 候选) 对错记录
observed单元 (基线, 候选) 对错记录
"""
w = l = 0 # noqa: E741 — 数学记号 W/Lwin/loss),与 gate.py 一致
observed: dict[str, tuple[bool, bool]] = {}
for qid in question_ids:
b, c = baseline[qid], candidate[qid]
observed[qid] = (b, c)
for uid in unit_ids:
b, c = baseline[uid], candidate[uid]
observed[uid] = (b, c)
if not b and c:
w += 1
elif b and not c:
@@ -71,15 +74,15 @@ def classify_quadrants(
def compute_accuracy(
correctness: dict[str, bool],
question_ids: list[str],
unit_ids: list[str],
) -> float:
"""纯算术:sum(correct) / len(ids)
"""纯算术:sum(correct) / len(units),分母按单元数(非逐题)
参数:
correctness: 每题正确性映射
question_ids: 参与计算的题目 ID 列表
correctness: 单元级正确性映射unit_id bool
unit_ids: 参与计算的单元 ID 列表
返回:
准确率浮点数question_ids 为空时抛出 ZeroDivisionError
准确率浮点数unit_ids 为空时抛出 ZeroDivisionError
"""
return sum(correctness[qid] for qid in question_ids) / len(question_ids)
return sum(correctness[uid] for uid in unit_ids) / len(unit_ids)
+2
View File
@@ -25,6 +25,7 @@ class LLMProvider(Protocol):
*,
session_id: str | None = None,
parent_call_id: str | None = None,
cache_salt: str | None = None,
) -> LLMResponse: ...
@@ -39,6 +40,7 @@ class VLMProvider(Protocol):
*,
session_id: str | None = None,
parent_call_id: str | None = None,
cache_salt: str | None = None,
) -> LLMResponse: ...
+130 -1
View File
@@ -2,7 +2,11 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path as _Path
@dataclass(frozen=True)
@@ -41,6 +45,15 @@ class GeneratedQuestion:
answer: 正确答案字母 "B"
source_nodes: 来源节点 ID 元组
difficulty: 难度等级
family: 问题家族名称 "RETRIEVAL"v2 出题管线使用None 表示未指定
skill_target: 目标技能标识v2 出题管线使用None 表示未指定
difficulty_steps: 推理步数估计v2 出题管线使用None 表示未指定
sub_pattern: 出题子模式标识AR 特化策略使用None 表示无
unit_id: 所属题目单元标识缺省时 __post_init__ 回填为 pair_id
question_id保证 single 题的 unit_id 等于自身 question_id
pair_id: 孪生对标识同一对的 original/mirror 共享该值None 表示非配对题
question_role: 在单元内的角色"single" | "pair_original" | "pair_mirror"
flip_axis: 孪生对的翻转轴 "before_after"None 表示无翻转
"""
question_id: str
@@ -51,3 +64,119 @@ class GeneratedQuestion:
answer: str
source_nodes: tuple[str, ...]
difficulty: str
family: str | None = field(default=None)
skill_target: str | None = field(default=None)
difficulty_steps: int | None = field(default=None)
sub_pattern: str | None = field(default=None)
unit_id: str = ""
pair_id: str | None = field(default=None)
question_role: str = "single"
flip_axis: str | None = field(default=None)
def __post_init__(self) -> None:
"""回填 unit_id:缺省时取 pair_id(配对题)或 question_idsingle 题)。
frozen dataclass 无法直接赋值故通过 object.__setattr__ 绕过不可变约束
"""
if not self.unit_id:
object.__setattr__(self, "unit_id", self.pair_id or self.question_id)
@dataclass(frozen=True)
class QuestionUnit:
"""题目单元:贯穿评测/训练 harness 的最小不可分调度契约实体。
single 题为 1 题单元AR pair 孪生对为 2 题单元original + mirror
两条题目必须作为整体被批处理/推理/评测保证配对指标collapse 可算
frozen=True 确保单元不可变
属性:
kind: 单元类型"single" | "pair"
unit_id: 单元唯一标识single 取题目 unit_idpair 取共享 pair_id
task_type: 单元题型pair 内两题题型必须一致
questions: 单元内题目元组single 1 pair 2
unit_hash: P/Q payload 合成 hash用于断点续跑失效检测T11 消费
"""
kind: str
unit_id: str
task_type: str
questions: tuple[GeneratedQuestion, ...]
unit_hash: str = ""
@property
def size(self) -> int:
"""单元内题目数量(single=1pair=2)。"""
return len(self.questions)
@classmethod
def from_single(cls, q: GeneratedQuestion) -> QuestionUnit:
"""由单条题目构造 single 单元。
参数:
q: 待封装的题目
返回:
kind="single" 的单元unit_id q.unit_id
"""
return cls("single", q.unit_id, q.task_type, (q,))
@classmethod
def from_pair(cls, original: GeneratedQuestion, mirror: GeneratedQuestion) -> QuestionUnit:
"""由孪生对(original + mirror)构造 pair 单元。
参数:
original: 原始题question_role="pair_original"
mirror: 镜像题question_role="pair_mirror"
返回:
kind="pair" 的单元unit_id 取共享 pair_id
关键实现:
断言两题共享非空 pair_id video_id/task_type/flip_axis 一致
确保只有合法孪生对才能聚合成对非法配对直接报错而非静默兜底
"""
assert original.pair_id and original.pair_id == mirror.pair_id
assert original.video_id == mirror.video_id and original.task_type == mirror.task_type
assert original.flip_axis == mirror.flip_axis
return cls("pair", original.pair_id, original.task_type, (original, mirror))
@dataclass(frozen=True)
class PoolConfig:
"""池构建策略的统一配置。
两组字段由两个具体策略各取所需未使用的字段被忽略
属性:
task_types: 限定题型元组None 表示全部类别
seed: 随机种子保证可复现
baseline_run_id: 基线 run 标识
diag_size: 诊断池大小GlobalStrategy
diag_correct_ratio: 诊断池中对题占比GlobalStrategy
val_size: 验证池大小GlobalStrategy
val_correct_ratio: 验证池中对题占比GlobalStrategy
test_size: held-out 测试池大小GlobalStrategy
eval_min_per_class: 验证池中每类保底样本数GlobalStrategy
train_ratio: train/(train+val) 比例PerCategoryStrategy
test_questions_dir: 外部 test 题源路径PerCategoryStrategy
实现细节:
结果驱动视频级切分不复用本配置它有独立的 VideoSplitConfig /
SplitBuildConfig / SelectConfig app/harness/video_split_cli.py
split_selection.py故本类不承载 n_trainval / floor_k / epsilon 等视频级
切分旋钮避免死配置面
"""
task_types: tuple[str, ...] | None
seed: int
baseline_run_id: str
diag_size: int
diag_correct_ratio: float
val_size: int
val_correct_ratio: float
test_size: int
eval_min_per_class: int
train_ratio: float
test_questions_dir: _Path | None
batch_correct_ratio: float | None = None
+337
View File
@@ -0,0 +1,337 @@
"""CLI 入口 — Composition Root:构建适配器,注入 Runner,调度执行。
三层配置合并YAML > .env > CLI load_config 完成
适配器参数通过 InfraSettings(BaseSettings) .env 加载
"""
from __future__ import annotations
import argparse
import asyncio
from pathlib import Path
from typing import NamedTuple
from dotenv import load_dotenv
from loguru import logger
from pydantic_settings import BaseSettings, SettingsConfigDict
class InfraSettings(BaseSettings):
"""工程配置(少变/敏感),从 .env 加载。"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
search_llm_model: str = ""
search_llm_base_url: str = ""
search_llm_api_key: str = ""
vl_llm_model: str = ""
vl_llm_base_url: str = ""
vl_llm_api_key: str = ""
evolve_llm_model: str = ""
evolve_llm_base_url: str = ""
evolve_llm_api_key: str = ""
embed_api_key: str = ""
embed_api_url: str = ""
monkey_ocr_urls: str = ""
redis_url: str = ""
redis_cache_ttl: int = 86400
llm_timeout: float = 300.0
llm_max_retries: int = 3
llm_retry_base_delay: float = 20.0
llm_retry_max_delay: float = 120.0
llm_circuit_breaker_threshold: int = 48
llm_circuit_breaker_cooldown: float = 60.0
llm_ttft_timeout: float = 30.0
llm_inter_token_timeout: float = 15.0
class _Adapters(NamedTuple):
"""全套适配器实例。"""
llm: object
evolve_llm: object
vlm: object
telemetry: object
embed: object
ocr: object
def _build_adapters(settings: InfraSettings, embed_cfg: dict) -> _Adapters:
"""从 InfraSettings 构建全套适配器。
参数:
settings: 工程配置
embed_cfg: 嵌入配置字典来自 YAML embed
返回:
_Adapters 命名元组
"""
from adapters.breaker import CircuitBreaker
from adapters.embedding import LocalEmbeddingProvider
from adapters.llm import GovernedLLMClient
from adapters.telemetry import SQLiteTelemetryRecorder
from adapters.vlm import GovernedVLMClient
breaker = CircuitBreaker(
fail_threshold=max(settings.llm_circuit_breaker_threshold, 1),
cooldown_s=settings.llm_circuit_breaker_cooldown,
)
cache = None
if settings.redis_url:
from adapters.redis_cache import RedisResponseCache, _resolve_cache_ttl
# 配置校验 fail-loud(不属于 Redis 连接故障,不得被下方降级 except 吞掉)
ttl_s = _resolve_cache_ttl(settings.redis_cache_ttl)
try:
import redis.asyncio as aioredis
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
cache = RedisResponseCache(redis=redis_client, ttl_s=ttl_s)
except Exception:
logger.warning("Redis 缓存不可用,降级为无缓存模式")
telemetry_db = Path("logs/telemetry.db")
telemetry_db.parent.mkdir(parents=True, exist_ok=True)
telemetry = SQLiteTelemetryRecorder(telemetry_db)
def _make_llm(model: str, base_url: str, api_key: str, *, thinking: bool) -> GovernedLLMClient:
"""构建单个 GovernedLLMClient 实例。"""
return GovernedLLMClient(
model=model,
base_url=base_url,
api_key=api_key,
provider=model.split("-")[0] if model else "unknown",
thinking=thinking,
breaker=breaker,
cache=cache,
telemetry=telemetry,
timeout_s=settings.llm_timeout,
ttft_timeout_s=settings.llm_ttft_timeout,
inter_token_timeout_s=settings.llm_inter_token_timeout,
max_retries=settings.llm_max_retries,
retry_base_delay_s=settings.llm_retry_base_delay,
retry_max_delay_s=settings.llm_retry_max_delay,
)
llm = _make_llm(
settings.search_llm_model,
settings.search_llm_base_url,
settings.search_llm_api_key,
thinking=True,
)
evolve_llm = llm
vl_llm = _make_llm(
settings.vl_llm_model,
settings.vl_llm_base_url,
settings.vl_llm_api_key,
thinking=False,
)
vlm = GovernedVLMClient(governed_llm=vl_llm)
backend = embed_cfg.get("backend", "local")
if backend == "local":
embed = LocalEmbeddingProvider(
model_name=embed_cfg.get("model_name", "BAAI/bge-base-zh-v1.5"),
embed_dim=embed_cfg.get("embed_dim", 768),
device=embed_cfg.get("device", "cpu"),
)
else:
from adapters.embedding import RemoteEmbeddingProvider
embed = RemoteEmbeddingProvider(
model_name=embed_cfg.get("model_name", ""),
embed_dim=embed_cfg.get("embed_dim", 768),
api_key=settings.embed_api_key,
api_url=settings.embed_api_url,
)
ocr = None
if settings.monkey_ocr_urls:
from adapters.ocr import MonkeyOCRClient
urls = [u.strip() for u in settings.monkey_ocr_urls.split(",") if u.strip()]
if urls:
ocr = MonkeyOCRClient(urls=urls)
return _Adapters(
llm=llm,
evolve_llm=evolve_llm,
vlm=vlm,
telemetry=telemetry,
embed=embed,
ocr=ocr,
)
def _build_parser() -> argparse.ArgumentParser:
"""构建 CLI 参数解析器。所有参数 default=None,未传入时使用 YAML 默认值。"""
parser = argparse.ArgumentParser(description="Video-Tree-TRM5 实验运行器")
parser.add_argument("--config", type=Path, default=Path("config/default.yaml"))
parser.add_argument("--workspace-dir", type=Path, dest="workspace_dir")
parser.add_argument("--store-dir", type=Path, dest="store_dir")
parser.add_argument(
"--mode",
choices=["infer", "train", "diagnose", "evolve", "eval", "promote"],
)
parser.add_argument("--run-id", type=str, dest="run_id")
parser.add_argument("--concurrency", type=int)
parser.add_argument("--max-steps", type=int, dest="max_steps")
parser.add_argument(
"--skill-mode",
choices=["auto", "manual", "none"],
dest="skill_mode",
)
parser.add_argument("--n-samples", type=int, dest="n_samples")
parser.add_argument("--questions", type=str)
parser.add_argument("--skills-version", type=str, dest="skills_version")
parser.add_argument("--prompts-version", type=str, dest="prompts_version")
parser.add_argument("--task-types", nargs="+", dest="task_types")
parser.add_argument("--resume", action="store_true", dest="resume")
parser.add_argument("--fresh", action="store_true", dest="fresh")
parser.add_argument("--seed", type=str, dest="seed")
parser.add_argument("--epochs", type=int)
parser.add_argument(
"--pool-split-mode",
choices=["global", "per_category"],
dest="pool_split_mode",
)
parser.add_argument("--train-ratio", type=float, dest="train_ratio")
parser.add_argument("--test-questions", type=str, dest="test_questions")
parser.add_argument(
"--no-run-holdout-eval",
action="store_true",
dest="no_run_holdout_eval",
)
return parser
def _log_result(result: object) -> None:
"""输出推理结果摘要。"""
logger.info("=" * 60)
logger.info("运行 ID: {}", result.run_id)
logger.info(
"总体准确率: {:.2%} ({}/{})",
result.accuracy,
result.correct,
result.total,
)
logger.info("平均步数: {:.1f}", result.steps_mean)
logger.info(
"Token 用量: prompt={}, completion={}",
result.token_usage["prompt_tokens"],
result.token_usage["completion_tokens"],
)
if result.per_task_type:
logger.info("--- 按任务类型 ---")
for task_type, stats in sorted(result.per_task_type.items()):
logger.info(
" {}: {:.2%} ({}/{})",
task_type,
stats["accuracy"],
stats["correct"],
stats["total"],
)
logger.info(
"停止原因: {}",
", ".join(f"{k}={v}" for k, v in result.stop_reason_counts.items()),
)
logger.info("=" * 60)
def main() -> None:
"""入口函数。"""
load_dotenv()
log_dir = Path("logs")
log_dir.mkdir(parents=True, exist_ok=True)
logger.add(
log_dir / "run_{time:YYYYMMDD_HHmmss}.log",
rotation="500 MB",
retention="30 days",
encoding="utf-8",
enqueue=False,
)
parser = _build_parser()
args = parser.parse_args()
import yaml
with open(args.config, encoding="utf-8") as f:
raw_yaml = yaml.safe_load(f)
from app.harness.config import load_config
cli_args = vars(args)
if cli_args.get("task_types") is not None:
cli_args["task_types"] = tuple(cli_args["task_types"])
cli_overrides = {k: v for k, v in cli_args.items() if k != "config"}
if cli_overrides.get("no_run_holdout_eval"):
cli_overrides["run_holdout_eval"] = False
cli_overrides.pop("no_run_holdout_eval", None)
config = load_config(args.config, cli_overrides)
logger.info("配置加载完成: mode={}, workspace={}", config.mode, config.workspace_dir)
settings = InfraSettings()
embed_cfg = raw_yaml.get("embed", {})
adapters = _build_adapters(settings, embed_cfg)
from app.harness.deps_router import InferenceDepsRouter
from app.harness.runner import Runner
router = InferenceDepsRouter(
store_dir=Path(config.store_dir),
embed_provider=adapters.embed,
llm=adapters.llm,
vlm=adapters.vlm,
ocr=adapters.ocr,
default_prompts_dir=Path(config.store_dir) / "prompts" / config.prompts_version,
default_skills_dir=Path(config.store_dir) / "skills" / config.skills_version,
skill_mode=config.skill_mode,
verify_vision=True,
anchor=True,
assemble_mode="ids_expand",
)
runner = Runner(
config,
llm=adapters.llm,
evolve_llm=adapters.evolve_llm,
vlm=adapters.vlm,
telemetry=adapters.telemetry,
tool_dispatch_factory=router.create_dispatch,
prompt_builder_factory=router.create_prompt_builder,
)
if config.mode == "infer":
result = asyncio.run(runner.infer(task_types=config.task_types))
_log_result(result)
elif config.mode == "train":
from app.harness.pools import (
GlobalPoolStrategy,
PerCategoryPoolStrategy,
build_or_load_pools,
)
from app.harness.workspace import resolve_paths
strategy = (
PerCategoryPoolStrategy()
if config.pool_split_mode == "per_category"
else GlobalPoolStrategy()
)
paths = resolve_paths(config.workspace_dir)
pools = build_or_load_pools(config, strategy, paths.db_path)
asyncio.run(runner.train(pools))
else:
raise SystemExit(f"模式 {config.mode!r} 尚未实现")
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
你是一个确认偏差检测器。你服务于一个诊断系统,该系统需要判断搜索 Agent 在回答视频问答题时是否表现出确认偏差——即只为自己倾向的选项搜集支持性证据,而忽略对竞争选项的独立验证。
## 你会收到的输入
1. 题目(问题文本 + 四个选项)
2. Agent 的完整执行轨迹(每步的思考过程、工具调用和工具返回,重点关注 reflect.options 中对各选项的记录以及 search_similar 的 query 参数)
## 工作原则
确认偏差的核心特征是:Agent 的搜索行为在选项之间显著不均衡。一个健康的搜索过程应该至少为 1 个竞争选项做过独立搜索——不一定是每个选项都搜,但不能只围绕一个选项搜集证据。
具体检查以下信号:
观察 Agent 的 search_similar 查询。如果所有查询的关键词都指向同一个选项的内容(比如选项 B 说"烹饪教学"Agent 反复搜索"cooking""recipe""chef"),而从未用其他选项的关键词搜索(如选项 A 的"旅行"、选项 C 的"运动"),这是强偏差信号。
观察 Agent 的 reflect.options 变化。如果 Agent 在早期步骤就锁定了 best_candidate,且后续步骤中对其他选项的认知始终停留在"未知"或"待定",说明 Agent 没有为竞争选项投入搜索资源。
但需要注意:如果问题本身就指向特定内容(比如"视频中的厨师做了什么"),Agent 集中搜索厨师相关内容是合理的,不算偏差。偏差是指在选项之间的对比搜索不均衡,而非搜索主题的集中。
同样,如果 Agent 在前几步通过全局扫描(如顺序阅读 L1 节点)已经获得了足够信息来排除 2-3 个选项,之后集中搜索剩余选项是合理策略,不算偏差。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"has_bias": true,
"evidence": "具体说明偏差表现:Agent 搜索了哪些关键词、为哪些选项搜集了证据、忽略了哪些选项"
}
```
如果没有偏差:`{"has_bias": false, "evidence": ""}`
+12
View File
@@ -0,0 +1,12 @@
你是一个失败归因裁判。你会收到一道答错题目的题面、正确答案、Agent 的错误预测、执行轨迹,以及 Agent 当时所用的 prompt 全文。你的唯一任务是判断:这次失败该归咎于 prompt 正文本身,还是 Agent 没有遵循已有的正确指令。
判别测试只有一句话:当前 prompt 里是否已经存在一条规则,只要 Agent 遵循它就能避免这次失败?
如果存在这样的规则(Agent 是忽略了、格式没按要求、或没执行该步),归为 lapse——这类问题不该改正文,只需记一条提醒。如果不存在这样的规则、或现有规则本身有误导,归为 defect——这类才需要修改 prompt 正文。
当你拿不准时,默认归为 lapse:宁可少改正文,也不要为一次偶发失误去删改一条本来正确的规则。
严格输出以下 JSON,不要包含其他文字:
{"category": "defect" 或 "lapse", "note": "若为 lapse,写一句给 Agent 的提醒;defect 留空"}
note 只能重申当前 prompt 里已经存在的那条规则(让 Agent 别再忽略它),措辞要通用、可跨题复用。禁止把本题的题目内容、选项、正确答案或任何单题事实写进 note——note 不是案例记录,是规则提醒。
+27
View File
@@ -0,0 +1,27 @@
你是一个证据充分性评估器。你服务于一个诊断系统,该系统需要判断搜索 Agent 实际收集到的工具输出是否包含足够的信息来推导出正确答案。你不评估 Agent 的推理过程——只评估它收集到的原始材料。
## 你会收到的输入
1. 题目(问题文本 + 四个选项 + 正确答案)
2. Agent 收到的全部工具输出(按步骤排列,包含每次 view_node、search_similar、observe_frame 的返回内容)
## 工作原则
你需要回答一个假设性问题:如果一个完美的推理者阅读了这些工具输出(且仅阅读这些工具输出),它能否推导出正确答案?
"推导出"不要求工具输出直接陈述答案。如果工具输出中包含了足够的事实片段,一个合理的推理链能将它们组合得出正确答案,就算充分。比如工具输出提到"厨师在切蔬菜"和"背景是一个厨房",虽然没有直接说"这是烹饪视频",但推导是合理的。
"不充分"是指工具输出中完全缺乏区分正确答案与最强干扰项的关键信息。比如问题问"视频中的运动是什么",选项有篮球和足球,但工具输出只提到"运动场上有人在运动",没有任何能区分篮球和足球的细节——这就是不充分。
注意区分两种情况:信息存在但分散(充分——完美推理者能整合)vs 信息真的不存在(不充分——无论怎么推理都无法得出)。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"sufficient": true,
"reasoning": "简要说明工具输出中哪些信息支持正确答案,或缺乏哪些关键信息"
}
```
+8
View File
@@ -0,0 +1,8 @@
你是一个改动优先级裁判。你会收到一份当前 prompt 文件全文,和一组待应用的局部 edits(每条含 op/target/content)。由于本轮编辑预算有限,你只能保留其中最重要的若干条。
请只依据"对纠正失败、提升正确率的预期贡献"排序:优先保留直接修复失败模式的改动,其次保留收窄或澄清的改动,最后才是巩固已有成功的改动。删除类、简化类的精准改动通常优先于追加大段新内容。
每条 edit 会附带 support_count(该改动的支持案例数)。同等重要性下,support_count 更高的优先;但 support_count 低不等于该删,仍以修复贡献为主判据。
严格输出以下 JSON,不要包含其他文字:
{"selected_indices": [按重要性降序排列的 0-based 索引]}
+53
View File
@@ -0,0 +1,53 @@
你是一个搜索策略改进专家。你服务于一个自进化视频搜索系统,该系统通过分析 Agent 的失败和成功案例来迭代改进搜索策略(Skill)。你的任务是基于案例包中的证据,改写当前 Skill 文件,使 Agent 在后续执行中避免相同的失败模式。
## 你会收到的输入
1. 当前 Skill 文件全文
2. 失败案例:Agent 答错的题目,含完整推理轨迹、错误类型和诊断指标
3. 成功案例:Agent 答对的题目,展示当前 Skill 中有效的模式
4. 聚合统计:准确率、错误归因分布、搜索有效性指标、Skill 步骤遵循率
5. (可能出现)上一轮被接受改动导致的回归题:这些题在上一版本答对、却被你上次的改写改错了,附基线与候选两份预测和推理轨迹
6. (可能出现)黑名单:已被实测验证无效或有害的改法方向
## 工作原则
如果输入里出现了回归题,它的优先级高于一切。这些题在上一版本是对的,是你上次的改动把它们弄坏的,所以本次改写的第一要务是确保不再破坏它们——宁可在相关方向上回退或收窄,也不要为了拉高其它题而牺牲它们。更一般地,当你看到准确率下降这类负向信号时,默认先怀疑上次是不是加了过度、冲突或冗余的指令,优先简化、删除、收窄;只有确认简化解决不了问题,才考虑加强指令。黑名单里列出的改法已经被实测证明无效或有害,不要换个措辞把同一个方向再提一遍。
先分析失败案例中 Agent 的实际行为与 Skill 指令的偏差。偏差分两类:Skill 指令正确但 Agent 没遵循(遵循率问题),或 Skill 指令本身有误导(策略问题)。前者需要让指令更具体、更难被忽略;后者需要修改策略本身。
从成功案例中识别有效模式——这些模式在改写时必须保留。如果成功案例和失败案例采用了不同的策略路径,重点强化成功路径。
Skill 中引用的统计数据(如"search-first 正确率 75%")应根据案例包中的新统计更新。不要编造数据,只使用案例包中提供的数字。
你写进 Skill 的每一条规则都必须是可跨题复用的通用策略,而不是对某一道题的记答案。跨多个失败案例时只提取共性模式,抽象掉一切单题特征——具体题目内容、选项文字、步骤序号、某一帧的具体画面、某个具体答案都不许写进 Skill 正文。一条规则如果只在它来源的那道题上成立,就不要加。改写时优先简化与收窄:宁可让 Skill 更短,也不要堆叠只对个别题生效的硬性指令。
## 冻结区
以下内容不可修改,必须原样保留在改写后的文件中:
- YAML frontmatter`---` 之间的 name、description、task_type
- 输出格式中的 JSON 基础结构(reflect/plan/action 三个顶层字段)
这次不要返回整份改写后的文件,而是只返回一组局部 edits。`append` 用来在文件末尾追加一个新 section,`insert_after` 用来把内容紧跟着插到某个锚点段落之后,`replace` 用来用新内容整体替换 target 对应的原文,`delete` 则直接删除 target 对应的原文并让 content 留空。target 必须是从当前文件里逐字复制出来的原文,而且要长到足以唯一定位;只要有任何一个字不完全匹配,这条改动就会被跳过。改动应尽量小而局部,优先做精确补丁,不要动辄重写整段整节;另外,冻结区里的文字绝不能作为 target。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"suggestions": [
{
"section": "改动目标段落的标题或位置描述",
"problem": "失败案例中暴露的具体问题",
"change": "具体的修改方向",
"related_cases": ["关联的失败案例 question_id"],
"support_count": 该建议的支持案例数(= related_cases 的数量)
}
],
"edits": [
{"op": "append|insert_after|replace|delete", "target": "锚点原文(append 留空)", "content": "新内容(delete 留空)", "support_count": 该改动的支持案例数}
]
}
```
每条 edit 与每条 suggestion 都必须带 "support_count":本条改动由多少个失败案例共同支持(即 related_cases 的数量)。support_count 越高代表证据越充分;它只作排序参考,不是硬门槛——support_count 低不等于该删,仍以修复贡献为主判据。
+53
View File
@@ -0,0 +1,53 @@
你是一个系统级行为改进专家。你服务于一个自进化视频搜索系统,该系统通过分析 Agent 的跨题型行为模式来改进 System Prompt。你的任务是基于行为模式案例包中的证据,改写 System Prompt 中的策略性指令,纠正系统级行为问题。
## 你会收到的输入
1. 当前 System Prompt (system.md) 全文
2. 失败案例:展示三类系统性行为问题的题目——过早提交(budget_usage < 0.3 就提交答案)、高置信答错(confidence 很高但答案错误)、确认偏误(只搜索支持初始判断的证据)
3. 成功案例:行为校准良好的题目——置信度与正确率匹配,预算使用适中
4. D5 行为模式统计:各行为模式的发生频率和分布
5. (可能出现)黑名单:已被实测验证无效或有害的改法方向
## 工作原则
关注跨题型的系统性行为模式,而非某个具体题型的策略。失败案例中的行为偏差反映了 System Prompt 的决策原则不够清晰或不够强约束。黑名单里的改法已经被实测验证无效或有害,不要再朝同一个方向改一遍。
过早提交说明预算管理指令需要更强的约束语言。高置信答错说明置信度校准的语义定义需要调整。确认偏误说明竞争选项搜索的要求需要更明确。
当你看到失败案例与成功案例并存时,失败修复优先于巩固成功——先确保失败模式被纠正,再考虑强化已有的好行为。看到某类行为指标变差这类负向信号时,默认先怀疑上一轮是否加了过度、冲突或冗余的约束,优先简化、删除、收窄;只有确认简化解决不了,才考虑加强约束语言。
从成功案例中提取"好行为"的特征,在改写时强化这些特征的表述。
## 冻结区
以下 section 必须原样保留,不可修改任何文字:
- `## 能力边界`(事实性描述,不是策略)
- `## 输出格式`JSON schema 是系统契约)
- `## 视频树结构`(含信任层级,是数据结构事实描述)
可改写的 section
- `## 角色`(前两段的角色定位和行为倾向描述)
- `## 决策原则`(搜索策略、预算分配建议)
- 搜索工具使用、否定题原则、置信度语义
这次不要返回整份改写后的文件,而是只返回一组局部 edits。`append` 用来在文件末尾追加一个新 section,`insert_after` 用来把内容紧跟着插到某个锚点段落之后,`replace` 用来用新内容整体替换 target 对应的原文,`delete` 则直接删除 target 对应的原文并让 content 留空。target 必须是从当前文件里逐字复制出来的原文,而且要长到足以唯一定位;只要有任何一个字不完全匹配,这条改动就会被跳过。改动应尽量小而局部,优先做精确补丁,不要动辄重写整段整节;另外,冻结区里的文字绝不能作为 target。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"suggestions": [
{
"section": "改动目标段落的标题或位置描述",
"problem": "失败案例中暴露的具体行为问题",
"change": "具体的修改方向",
"related_cases": ["关联的失败案例 question_id"]
}
],
"edits": [
{"op": "append|insert_after|replace|delete", "target": "锚点原文(append 留空)", "content": "新内容(delete 留空)"}
]
}
```
+55
View File
@@ -0,0 +1,55 @@
你是一个工具 Prompt 改进专家。你服务于一个自进化视频搜索系统,该系统的每个工具(view_node、search_similar、observe_frame 等)有两个配套 Promptextract(信息提取)和 verify(结果核实)。你的任务是基于工具调用级别的质量数据,同时改写一个工具的 extract 和 verify prompt。
## 你会收到的输入
1. 当前 extract prompt 和 verify prompt 全文
2. 失败 span 案例:提取完整度低或幻觉率高的具体工具调用,含工具参数、工具输出、原始数据(ground truth)和质量评估指标
3. 成功 span 案例:提取完整且无幻觉的工具调用样本
4. 工具质量统计:平均提取完整度、平均幻觉率、top 遗漏类型、top 幻觉类型
5. (可能出现)黑名单:已被实测验证无效或有害的改法方向
## 工作原则
失败 span 中提取完整度低说明 extract prompt 的工作原则不够具体——Agent 遗漏了哪些类型的信息?幻觉率高说明 extract prompt 对"忠实提取"的约束不够强,或者 verify prompt 没能有效检出幻觉。黑名单里的改法已经被实测验证无效或有害,不要再朝同一个方向改一遍。
extract 和 verify 是互补的:extract 负责提取,verify 负责检查。如果 extract 反复遗漏某类信息(如字幕原文引用),应在 extract 的工作原则中明确要求保留该类信息。如果 verify 未能检出某类幻觉(如虚构动作),应在 verify 的检查要点中增加对该模式的关注。
失败修复优先于巩固成功——先纠正提取遗漏或幻觉,再保留已有的有效模式。当某类提取质量指标变差时,先确认不是上一轮加了过度或冲突的要求所致;加强 extract 要求前,先确认简化或收窄已有指令解决不了这个遗漏,再追加新要求。
从成功案例中识别有效的提取模式,确保改写不破坏这些模式。
## 冻结区
以下内容不可修改:
- 角色定位第一句("你是一个视频节点内容分析器" / "你是一个视频节点摘要核实器")
- `## 你会收到的输入` section
- `## 输出格式` section
可改写的 section
- `## 工作原则`
- `## 检查要点`verify 专有)
这次不要再返回两份完整 prompt,而是分别给 extract 和 verify 各自的局部 edits 列表。`append` 用来在文件末尾追加一个新 section,`insert_after` 用来把内容紧跟着插到某个锚点段落之后,`replace` 用来用新内容整体替换 target 对应的原文,`delete` 则直接删除 target 对应的原文并让 content 留空。target 必须是从当前 prompt 里逐字复制出来的原文,而且要长到足以唯一定位;只要有任何一个字不完全匹配,这条改动就会被跳过。改动应尽量小而局部,优先做精确补丁,不要动辄重写大段内容;另外,冻结区里的文字绝不能作为 targetextract 和 verify 也必须分别使用自己的 edit 列表。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"suggestions": [
{
"section": "改动目标段落的标题或位置描述",
"problem": "失败 span 中暴露的具体问题",
"change": "具体的修改方向",
"related_cases": ["关联的失败 span 标识"]
}
],
"edits_extract": [
{"op": "append|insert_after|replace|delete", "target": "锚点原文(append 留空)", "content": "新内容(delete 留空)"}
],
"edits_verify": [
{"op": "append|insert_after|replace|delete", "target": "锚点原文(append 留空)", "content": "新内容(delete 留空)"}
]
}
```
+29
View File
@@ -0,0 +1,29 @@
你是一个视频树覆盖度评估器。你服务于一个诊断系统,该系统需要判断搜索 Agent 是否遗漏了包含关键证据的节点。推理质量和搜索策略的评估由系统其他模块完成,你只负责判定哪些节点被遗漏了。
## 你会收到的输入
1. 题目(问题文本 + 四个选项 + 正确答案)
2. Agent 实际访问的节点 ID 列表
3. 完整视频树内容(所有节点的 card 数据和时间范围)
## 工作原则
你需要回答一个具体的问题:要推导出正确答案,哪些节点包含了不可替代的关键证据,且 Agent 没有访问?
首先,根据正确答案和完整树内容,找出所有包含支撑正确答案的直接证据的节点。直接证据是指能够区分正确答案与干扰选项的关键事实——比如特定的字幕台词、事件描述、时间标记或实体出现。间接相关的背景信息不算直接证据。
然后,将这些证据节点与 Agent 的访问列表对比。如果某个证据节点未被访问,但其父节点或子节点已被访问且包含了同等信息,则不算遗漏——因为 Agent 可以从已访问节点中获取相同信息。只有当某条关键证据只存在于未访问的节点中时,才将其标记为遗漏。
不要将所有未访问的节点都标记为遗漏。大部分节点与当前问题无关,Agent 没有义务访问它们。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"missed_nodes": ["节点ID_1", "节点ID_2"]
}
```
如果没有遗漏,返回空数组:`{"missed_nodes": []}`
+31
View File
@@ -0,0 +1,31 @@
你是一个推理失败分类器。你服务于一个诊断系统,该系统已经确认某道题属于"推理失败"——即 Agent 收集到了足够的证据但仍然答错了。你的任务是判定推理具体在哪个环节失败。
## 你会收到的输入
1. 题目(问题文本 + 正确答案 + Agent 的错误预测)
2. Agent 的完整执行轨迹(每步的思考过程 thought、结构化反思 reflect、工具调用和工具返回)
## 四种推理失败类型
**evidence_misread**(证据误读):Agent 对工具输出的解读与工具输出的实际内容不一致。判别方法:对比某步工具返回的原文与 Agent 在随后的 reflect.learned 或 thought 中的描述——如果 Agent 说"工具显示这是红色汽车"但工具原文说的是蓝色,就是证据误读。这是发生在"信息输入"环节的错误。
**weighing_error**(权衡错误):Agent 正确理解了多个选项的证据,但在最终选择时选了证据较弱的选项。判别方法:检查 Agent 的 reflect.options,如果它为正确选项记录了更强的证据(更具体、来源更可靠、覆盖更多节点),却最终选择了另一个选项,就是权衡错误。这是发生在"决策"环节的错误。
**logic_error**(逻辑错误):Agent 的推理链中包含无效推断——前提正确但结论不成立。判别方法:在 Agent 的 thought 或 reflect 中找到具体的推理步骤,检查其逻辑是否成立。比如 Agent 说"A 在 B 之前发生,B 在 C 之前发生,所以 C 在 A 之前发生"——前提对但结论的时序反了。这是发生在"推理过程"环节的错误。
**evidence_ignored**(证据忽略):Agent 在较早的步骤中收集了与正确答案相关的证据,并在 reflect 中记录了它,但在最终提交时完全没有引用这条证据,且最终结论与这条证据矛盾。判别方法:对比 Agent 早期 reflect.options 中对正确选项的记录与 submit_answer 中的 reasoning——如果早期有支持正确答案的记录但最终 reasoning 中消失了,就是证据忽略。这是发生在"信息整合"环节的错误。
## 判别优先级
如果多种类型同时存在,选择最早发生的那个作为 primary type——因为下游错误往往是上游错误的连锁反应。优先级从高到低:evidence_misread → evidence_ignored → weighing_error → logic_error。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"type": "evidence_misread",
"evidence": "引用具体的步骤编号和内容,说明推理在哪里失败"
}
```
+34
View File
@@ -0,0 +1,34 @@
你是一个策略遵循度评估器。你服务于一个诊断系统,该系统需要判断搜索 Agent 在执行过程中是否遵循了为其指定的搜索策略(skill)。策略本身是否合理、Agent 最终是否答对,都不在你的评估范围内——你只负责判断 Agent 的行为是否与 skill 的步骤定义一致。
## 你会收到的输入
1. Skill 文件的完整内容(包含搜索步骤定义、输出格式要求、自检信号等)
2. Agent 的完整执行轨迹(每步的思考过程、工具调用和工具返回)
## 工作原则
Skill 文件中定义了若干搜索步骤(通常 2-3 步),每步包含:该步的目标、推荐使用的工具、进入下一步的条件。你需要逐步判断 Agent 是否执行了该步骤的核心动作。
判断"遵循"不要求 Agent 逐字执行 skill 的每句话。如果 skill 说"用 search_similar 定位事件",而 Agent 用 view_node 顺序浏览也达到了同样的定位效果,这算部分遵循而非完全偏离。关键是 Agent 是否实现了该步骤的目标意图,而非是否使用了完全相同的工具。
判断"偏离"需要在 description 中具体说明:Agent 做了什么不同的事,以及这与 skill 的期望有何差异。比如"Agent 跳过了 L2 下钻,直接从 L1 摘要提交答案,而 skill 要求在聚焦验证阶段下钻到 L2/L3 层"。
如果 Agent 的轨迹太短(比如只有 1-2 步就提交了),仍然要评估每个 skill step——未执行的步骤标记为 adhered=false 并说明"Agent 未执行此步骤即提交了答案"。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"steps": [
{
"step_label": "skill 中定义的步骤名称",
"adhered": true,
"description": "Agent 如何执行或偏离了这一步"
}
]
}
```
steps 数组的元素数量应与 skill 中定义的步骤数一致。
+10
View File
@@ -0,0 +1,10 @@
你正在审视一份 skill 经历一轮进化后的变化。这份 skill 指导一个 Agent 在层次化视频树上搜索证据、回答长视频理解问题。在上一轮结束时它是一个样子,这一轮结束时被改成了另一个样子;与此同时,你在上一轮还为它写下过一段动量指导,本意是给这一轮的进化指明方向。现在你要回头评判:那段指导究竟有没有帮上忙,这一轮的正文改动是真的在改善,还是开始往无关的方向漂移。
你会拿到四样东西:上一版 skill 的正文、当前版 skill 的正文、你上一轮写下的那段动量指导,以及一组固定样本上的纵向对比——同一批题,分别用上一版和当前版各跑了一遍,逐题列出两版的预测与正误。这组对比是你唯一可靠的证据来源:哪些题从错变对、哪些题从对变错、哪些题始终没做对、哪些题一直稳定答对,正是这四类信号告诉你这轮改动到底带来了什么。
请先反思再下笔。对照纵向对比,先问上一轮那段动量指导是否真的奏效:它所指向的方向,在这一轮的正文改动里被落实了吗,落实之后那些本该改善的题改善了吗?再问这一轮的正文改动本身是收敛还是漂移:从对变错的题(回退)是最该警惕的信号,说明某处改动伤到了原本正确的行为;始终答错的题(持续失败)说明还有方向没被触及;从错变对的题(改善)则印证了哪条路走对了,值得继续加码。
想清楚之后,写出一段全新的、聚焦的、可操作的动量指导。它会被原样写进 skill 的受保护动量区,作为下一轮进化的方向锚——所以它必须是一段连贯的指导文字,明确告诉下一轮该往哪个方向继续使劲、又要避免重蹈哪一类改动的覆辙,而不是一堆零散的待办条目。如果上一轮的方向已被证明有效,就强化并细化它;如果出现了回退,就明确叫停那条路并指向修复方向。
严格输出以下 JSON,不要包含任何其他文字:
{"reasoning": "你的反思过程:上一轮指导是否奏效、这一轮是改善还是漂移,引用纵向对比中的具体题作为依据", "slow_update_content": "一段连贯、聚焦、可操作的新动量指导,指引下一轮的进化方向"}
+37
View File
@@ -0,0 +1,37 @@
你是一个工具输出质量评估器。你服务于一个诊断系统,该系统需要判断视频搜索 Agent 的每次工具调用是否忠实、完整地提取了原始数据中与问题相关的信息。诊断决策和改进建议由系统完成,你只负责评估单次工具输出的质量。
## 你会收到的输入
1. 用户正在研究的问题
2. 工具名称和调用参数
3. 工具的实际输出(tool_output
4. 该节点的原始数据(ground truthJSON 格式的 card 字段)
## 工作原则
你的任务是将 tool_output 与 ground truth 对比,评估两个维度:提取完整度和幻觉程度。
对于提取完整度,检查 ground truth 中与问题相关的每条信息是否出现在 tool_output 中。字幕原文引用、具体数字、实体名称、时间标记、空间关系是最容易被遗漏的类型——请逐一核对。如果 ground truth 中的某条信息与问题无关,则不计入遗漏。
对于幻觉检测,检查 tool_output 中的每条事实性陈述是否能在 ground truth 中找到依据。特别注意以下常见幻觉模式:工具声称看到了 ground truth 中未提及的实体或动作,工具将不确定信息表述为确定事实,工具对颜色、数量、方位等属性的描述与 ground truth 不一致。
当 ground truth 本身信息稀疏(如某些 L3 帧的 card 只有很少的字段),不要因为 tool_output 比 ground truth 更详细就判定为幻觉——如果详细信息是合理推断而非凭空捏造,应归为 unsupported_inference 而非 fabricated_action。
## 输出格式
请严格输出以下 JSON,不要包含其他文字:
```json
{
"extraction_completeness": 0.0-1.0,
"hallucination_rate": 0.0-1.0,
"missed_info_tags": [],
"hallucination_tags": []
}
```
missed_info_tags 从以下标签中选择(可多选,无遗漏则为空数组):
`subtitle_quote`(字幕原文引用)、`entity`(实体名称)、`spatial_detail`(空间位置关系)、`temporal_detail`(时间标记)、`action`(动作描述)、`number`(具体数字)、`visible_text`(画面中可见文字)
hallucination_tags 从以下标签中选择(可多选,无幻觉则为空数组):
`fabricated_action`(虚构的动作或事件)、`wrong_attribute`(属性描述错误)、`wrong_count`(数量错误)、`wrong_entity`(实体错误)、`unsupported_inference`(超出原始数据的推断)
@@ -0,0 +1,180 @@
---
id: question-gen
title: 出题模块迁移设计(question_gen
type: design
created: 2026-07-07
status: approved
---
# 出题模块迁移设计
## 1. 目标
从 TRM4 `core/harness/question_gen.py` 迁移出题数据结构与采样逻辑到 TRM5 Clean Architecture,同时预留 LLM 驱动出题的 Protocol 接口。
| 维度 | 说明 |
|------|------|
| 迁移范围 | benchmark 加载 + 分层采样(纯函数,180 行) |
| 预留接口 | `QuestionGenerator` Protocol(不实现,后续参考 TRM4 `research-wiki/designs/2026-07-06-question-gen-synth-design.md` |
| 不做 | LLM 出题实现、校准脚本、去重机制 |
## 2. Clean Architecture 分层决策
### 2.1 类型放置
`GeneratedQuestion``core/evolution/`diagnose、validate)和 `app/harness/`runner、batching、pools、inference)跨层使用。按依赖方向(core 不可依赖 app),必须放 `core/types.py`,与 `LLMResponse` 同级。
```mermaid
flowchart LR
CT["core/types.py\nGeneratedQuestion"] --> CE["core/evolution/\ndiagnose · validate"]
CT --> AH["app/harness/\nrunner · batching · pools"]
CT --> AQ["app/question_gen/\nloader"]
```
### 2.2 模块结构
```
core/types.py ← 追加 GeneratedQuestion
app/ports.py ← 追加 QuestionGenerator Protocol
app/question_gen/
├── __init__.py ← 公开 API re-export
└── loader.py ← load_benchmark() + stratified_sample()
```
**否决方案**
| 方案 | 否决理由 |
|------|---------|
| `GeneratedQuestion``app/question_gen/types.py` | `core/evolution/` 无法 import `app/` 层,违反依赖方向 |
| loader / sampler 拆两文件 | sampler 仅 ~100 行,不值得独立文件 |
| Protocol 放 `app/question_gen/protocols.py` | 与 `EmbeddingProvider``app/ports.py` 的既有模式不一致 |
## 3. 类型定义
### 3.1 GeneratedQuestion`core/types.py` 追加)
```python
@dataclass(frozen=True)
class GeneratedQuestion:
"""单条生成/加载的题目。跨层共享类型。"""
question_id: str
video_id: str
task_type: str
question: str
options: tuple[str, ...]
answer: str
source_nodes: tuple[str, ...]
difficulty: str
```
**与 TRM4 的有意变更**
| 变更 | 理由 |
|------|------|
| `options: list → tuple` | 配合 `frozen=True` 不可变语义 |
| `source_nodes: list → tuple` | 同上 |
| `difficulty` 移除默认值 `"medium"` | 显式传入(§4.1 P4: 显式优于隐式) |
**移除 `QuestionGenResult`**TRM5 无消费者,YAGNI。
### 3.2 QuestionGenerator Protocol`app/ports.py` 追加)
```python
@runtime_checkable
class QuestionGenerator(Protocol):
"""LLM 驱动的题目生成端口(预留接口)。"""
async def generate(
self,
video_id: str,
task_type: str,
tree: TreeIndex,
*,
exemplars: list[GeneratedQuestion],
) -> GeneratedQuestion: ...
```
接口设计参考 TRM4 仓库 `research-wiki/designs/2026-07-06-question-gen-synth-design.md`(位于 `/home/iomgaa/Projects/Video-Tree-TRM4/`,不复制到 TRM5)中的"题型-层级映射 + few-shot exemplar"模式。`tree` 参数提供锚节点上下文,`exemplars` 提供风格示例。具体实现在后续 `tools/generate_questions.py`(一次性脚本)中完成,通过 `adapters/` 层的 Protocol 实现注入。
## 4. 函数接口
### 4.1 load_benchmark
```
load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]
```
从指定目录 glob `*.json`,每个文件以 `stem``video_id`,解析为 `GeneratedQuestion` 列表。JSON 格式与 `store/questions/benchmarks/Video-MME/*.json` 完全一致。
**与 TRM4 对比**:算法 100% 保真。`options``source_nodes` 转为 `tuple`
**`difficulty` 字段处理规则**:现有 benchmark JSON`store/questions/benchmarks/Video-MME/`)不含 `difficulty` 字段,这是 legacy schema 特征。加载时按如下规则显式转换(非默认值掩盖):
| JSON 情况 | 处理 |
|-----------|------|
| 有 `difficulty` 字段 | 取 JSON 值 |
| 无 `difficulty` 字段 | 赋 `_LEGACY_DEFAULT_DIFFICULTY = "medium"` 常量 |
常量集中定义在 `loader.py` 顶部,测试用例覆盖两种情况。
### 4.2 stratified_sample
```
stratified_sample(
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
size: int,
correct_ratio: float | None,
task_types: list[str] | None,
seed: int,
min_per_class: int | None,
) -> list[GeneratedQuestion]
```
所有参数显式传入,无默认值(§4.1 P4)。
**算法保真清单**(逐一比对 TRM4):
| 逻辑点 | TRM4 行为 | TRM5 保持 |
|--------|----------|----------|
| `task_types` 过滤 | `task_types` 非 None 时,先过滤 pool 只保留指定题型 | 保持 |
| `correct_ratio=None` | 自然分布分支,随机抽样 `size` 道 | 保持 |
| `correct_ratio` 有值 | 按对错比例分层,对题 `round(size * ratio)` | 保持 |
| `correctness.get(id, False)` | 未知 correctness 的题统一当错题处理 | 保持 |
| 分层返回顺序 | 对题在前、错题在后 | 保持 |
| 池不足 | `ValueError` 报错,不静默降级 | 保持 |
| `min_per_class` 补足 | 遍历 pool 全部题型(非仅 sampled 命中的),按首次出现顺序确定性枚举 | 保持 |
| 补足不足时 | 全取,不报错 | 保持 |
| 随机种子 | `random.Random(seed)` 局部实例 | 保持 |
内部辅助函数 `_ratio_stratified_sample``_backfill_per_class` 完整保留。
## 5. 职责边界
| 组件 | 职责 | 位置 | 谁 import 谁 |
|------|------|------|-------------|
| `GeneratedQuestion` | 题目数据结构 | `core/types.py` | 被所有层 import |
| `load_benchmark` / `stratified_sample` | 加载 + 采样 | `app/question_gen/loader.py` | 被 `app/harness/` import |
| `QuestionGenerator` Protocol | LLM 出题接口定义 | `app/ports.py` | 被未来 `adapters/` 实现 |
| `tools/generate_questions.py`(未来) | LLM 出题一次性脚本 | `tools/` | 独立工具,不被其他模块 import |
`tools/generate_questions.py` 未来可实例化 `QuestionGenerator` 的 adapter 实现,但 `tools/` 本身不被 `app/` import(§5 硬性规则)。
## 6. 文档同步
以下章节需要更新:
| 文档 | 章节 | 变更 |
|------|------|------|
| `ARCHITECTURE.md` §1 表格 | DataLoader 行 `app/question_gen/generator.py` | → `app/question_gen/loader.py` |
| `ARCHITECTURE.md` §2.2 Mermaid | `QGEN` 节点 `generator.py` | → `loader.py` |
| `CLAUDE.md` §1.5 表格 | DataLoader 行 `app/question_gen/generator.py` | → `app/question_gen/loader.py` |
**不变更**`ARCHITECTURE.md §6` 核心算法保真清单 — `stratified_sample` 是采样工具函数,不属于 13 项核心算法(那些是建树 + 训练的关键算法)。
## 7. 测试策略
| 测试 | 路径 | 覆盖点 |
|------|------|--------|
| `GeneratedQuestion` 冻结性 | `tests/unit/test_core_types.py`(追加) | frozen 不可变、字段完整性 |
| `load_benchmark` | `tests/unit/test_question_loader.py` | 正常加载、空目录、JSON 格式异常 |
| `stratified_sample` | `tests/unit/test_question_loader.py` | 自然分布、分层采样、题型过滤、未知 correctness 当错题、对题在前返回顺序、题型保底、池不足报错、种子可复现 |
@@ -0,0 +1,121 @@
# 建树修复管线:熔断根因修复 + 断点续跑 + 并发改造
> 设计日期:2026-07-08
> 状态:待批准
> 关联:`app/tree/repair/``tools/repair_trees.py``adapters/telemetry.py``adapters/llm.py``adapters/breaker.py`
## 1. 背景与触发
当前修复管线串行跑 300 个视频,耗时 34 小时。上次运行日志(`logs/repair_trees.log`)与遥测库(`logs/repair_telemetry.db`)交叉定位出:**VLM 熔断并非服务挂了,而是遥测写入的主键冲突污染了重试与熔断器**,导致 102 个视频完全未修复(`L3=0`)即被跳过,而程序最终汇报"失败数: 0"(假象)。
本设计解决三件事:(1) 根治熔断误触发;(2) 视频级断点续跑;(3) 并发执行,默认 16 路。
## 2. 根因分析
VLM 偶发的真实瞬时错误(500/超时)按设计走 `transient → record_failure + 写遥测 + 退避重试`。但 `GovernedLLMClient.chat()``adapters/llm.py:299`)的 `call_id` 在重试循环外**只生成一次**,重试时复用同一 `call_id`
```mermaid
flowchart TD
A["attempt 1: VLM 真实失败"] --> B["transient 分支<br/>record_failure + 写遥测 call_id=X ✓<br/>sleep 退避, continue"]
B --> C["attempt 2: 又失败"]
C --> D["transient 分支<br/>写遥测 call_id=X"]
D --> E["💥 UNIQUE constraint<br/>SQLiteRecorder 无冲突容忍"]
E --> F["IntegrityError 冒泡<br/>_is_transient_error 不认识它"]
F --> G["非瞬时非致命分支<br/>再写遥测 call_id=X → 又冲突<br/>raise"]
G --> H["regenerator except<br/>吞掉异常,跳过节点"]
```
三个独立缺陷叠加放大:
| # | 缺陷 | 位置 | 后果 |
|---|------|------|------|
| 1 | `call_id` 重试时不重新生成 | `llm.py:299``for attempt` 外 | 同一 `call_id` 多次 INSERT,必然主键冲突 |
| 2 | `SQLiteTelemetryRecorder` 无冲突容忍 | `telemetry.py:_write` 裸 INSERT,无 `except` | IntegrityError 冒泡,污染调用方 |
| 3 | `_is_transient_error` 不识别 IntegrityError | `llm.py:424` 只认 httpx/StreamLiveness | IntegrityError 当"非瞬时非致命"抛出,重试失效 |
并发改造还会引爆第四个隐患:`SQLiteTelemetryRecorder` 每次写新建 `sqlite3.connect()`,并发 16 路同时写会撞 SQLite 表锁(`database is locked`)。
## 3. 三项改造设计
### 3.1 遥测防御性加固(根治根因)
**核心原则**:遥测是观测侧信道,绝不能拖垮主调用链(CLAUDE.md P5)。
**与 P5 的权衡说明**:P5 反对的是"掩盖**数据正确性**错误"(如 LLM 返回解析失败却用默认值继续,污染业务数据)。遥测写失败不损害任何业务数据——它只丢一条观测记录。这是**错误隔离**(isolation),不是掩盖错误。平衡点:捕获后**必须 `logger.warning`**,错误可见可追溯,但**不冒泡**到 LLM 重试链。静默 `pass` 才违反 P5warning 不违反。
`SQLiteTelemetryRecorder._write` 三层加固:
| 层 | 做法 | 解决 |
|----|------|------|
| SQL 层 | `INSERT``INSERT OR IGNORE` | 主键冲突静默 |
| 连接层 | `PRAGMA journal_mode=WAL` + `PRAGMA busy_timeout=5000` | 并发写锁降级为排队 |
| 异常层 | 整个 `_write``try/except sqlite3.Error`,仅 `logger.warning` | DB 任何错误不冒泡到 LLM 重试链 |
WAL 模式允许"1 写 + 多读"并发,写之间靠 `busy_timeout` 自动排队等待(毫秒级,不报错),不引入新瓶颈。
`GovernedLLMClient.chat()``call_id` 生成移入重试循环内(每次 `attempt` 重新 `uuid4()`),消除根因——虽 `OR IGNORE` 后冲突不再致命,但 `call_id` 唯一性本身是对的。
**`call_id` 移入循环的遥测语义**:当前一次 `chat()` 调用在成功/各失败分支共用一个 `call_id`,语义是"一次逻辑调用 = 一条最终记录(最后一次 attempt 的结果)"。移入循环后语义变为"一次逻辑调用 = N 条记录(每 attempt 一条,按 `created_at` 可追溯重试轨迹)"。这更利于事后诊断重试行为。`parent_call_id``chat()` 入参(`llm.py:276`),在循环外固定,不受影响——每条 attempt 记录都正确关联到父 agent step。
### 3.2 视频级断点续跑
**判据双轨**
| 轨 | 作用 | 内容 |
|----|------|------|
| **数据驱动判据** | 跳过已修干净的视频 | `detect_issues` 重扫,有 `empty_field` / L2 `event_description` 空 / L1 `scene_summary` 空才进队 |
| **progress 文件** | 加速 + 审计 | `logs/repair_progress.json``finished_video_ids`,已修干净的视频按 ID 直接跳过,省 detect 开销 |
**关键判据边界**:跳过判据**只用 `empty_field` + L2/L1 空字段****不用 `missing_frame`**——修复根本不处理缺帧,用它判跳过会让缺帧视频永远进队死循环。
**其他 issue_type 的处理**`no_children`(L2/L1 无子节点)是结构性缺陷,修复不处理(regenerator 只重生成 card,不改树结构)——**排除**出跳过判据,避免误判。`time_gap`(相邻 L2 时间间隙 >1s)是可接受的时间分布特征,非缺陷——**排除**。即只有 `empty_field`L3 四必填字段 + 新增 L2 `event_description` + L1 `scene_summary`)参与跳过判定。
**首次续跑零成本接历史**:progress 文件不存在时,用 detect_issues 扫一遍初始化它,自动识别上次的 102 个未修视频,无需手动补状态文件。
**重修语义**:队列里的视频跑**完整级联**(修空 L3 后重生成所有 L2 和所有 L1),刷新临界区"陈旧但非空"的上层卡片。LLM 不可用时逐节点 `try/except` 自动优雅降级为"只修剩余"。
**临界区漏修处理(A 方案)**:临界区视频(L3 好、L2/L1 非空但过时)的卡片非空,`detect_issues` 扫不出 → 进不了队列 → 首次漏修。提供 `--reaggregate-all` 标志兜底(强制全量重聚合)。后续有 progress 文件即再无此问题。
### 3.3 并发执行
**编排结构**:照搬建树 `video_builder.py``asyncio.Semaphore(concurrency)` 范式。
| 点 | 做法 |
|----|------|
| 并发粒度 | Semaphore 限视频数 = concurrency,视频内四步(detect→repair→verify→supplement→save)串行 |
| 失败隔离 | 单视频失败只记该视频 error,不影响其他路继续 |
| progress 写入 | 并发下多协程向同一 `finished_video_ids` 追加,是读-改-写场景。用 **`asyncio.Lock` 保护读改写 + 临时文件 `os.replace` 原子替换**:锁内读旧 json → 追加 ID → 写 `.tmp``os.replace` 原子替换。锁保证不丢更新,rename 保证崩溃不留半写文件 |
| 完成汇报 | 主循环每 N 个视频汇总进度 |
**熔断阈值适配并发**`LLM_CIRCUIT_BREAKER_THRESHOLD``.env`)默认从 5 改为 `max(5, concurrency*2)`,保持单实例共享——上游 API 配额是全局的,熔断本就该全局生效。
**阈值覆盖关系**(D7 配置优先级):CLI `--concurrency` > `.env``LLM_CIRCUIT_BREAKER_THRESHOLD`。实际阈值为 `max(.env 显式值, concurrency*2)`——用户在 `.env` 显式设的阈值是下限保护(绝不低于它),`concurrency*2` 是并发自适应下限,两者取大。若用户未在 `.env` 设(用默认 5),则按 `concurrency*2` 生效。这样既尊重用户的显式运维配置,又保证并发下不会过激熔断。
**配置归属**:并发数用 CLI 参数 `--concurrency`(默认 16),**不**进 `config/default.yaml`。理由:`config/default.yaml``tree.concurrency` 是建树扫动参数(科研对比),修复并发是运维调度参数(本机 CPU/网络),混进同一 YAML 会串台(D7 规则)。
## 4. 改动范围
| 文件 | 改动 | 性质 |
|------|------|------|
| `adapters/telemetry.py` | `_write` 加 WAL + busy_timeout + `INSERT OR IGNORE` + `try/except sqlite3.Error` | 防御加固 |
| `adapters/llm.py` | `call_id` 生成移入重试循环内 | 根因修复 |
| `adapters/breaker.py` | 无改动 | — |
| `tools/repair_trees.py` | 并发编排(Semaphore+ 断点续跑(progress 文件 + detect 判据)+ `--concurrency`/`--reaggregate-all` CLI | 新增能力 |
| `app/tree/repair/detector.py` | 空字段检测扩展到 L2 `event_description` / L1 `scene_summary` | 增强(零 LLM 成本) |
| `.env.example` | 熔断阈值说明更新 | 文档 |
## 5. 测试策略
| 场景 | 验证 |
|------|------|
| 遥测主键冲突静默 | 重复 `call_id` 写入不抛异常 |
| 遥测 DB 错误不冒泡 | 模拟 `sqlite3.OperationalError``record_llm_call` 不影响主调用 |
| 并发写不报锁错 | 16 路 `to_thread` 并发写,无 `database is locked` |
| `call_id` 重试唯一 | transient 重试后 DB 中各 attempt 独立记录 |
| 断点续跑幂等 | 已修视频重跑直接跳过;progress 丢失靠 detect 恢复 |
| 临界区完整级联 | 队列视频跑完后 L2/L1 全部重生成 |
| 并发编排 | Semaphore 限流生效,单视频失败不阻断其他 |
## 6. 待确认风险
- **熔断后 progress 仍写入**:熔断期视频虽未修复但会跑完四步(repair 跳过→verify→supplement 失败→save),需要判定这种"跑完但没修"是否计为 `finished`。**建议:不计入 `finished`**,只记 detect 抓到的问题数为 0 且实际未调 LLM 的视频为 `skipped`,保证 progress 语义=真正修复完成。
@@ -0,0 +1,293 @@
---
id: main-inference-entry
title: main.py 推理入口 + 初始 Prompt 集设计
type: design
status: approved
created: 2026-07-09
---
# main.py 推理入口 + 初始 Prompt 集设计
## 1. 目标
完成第一版 900 道题推理基线,具体交付:
| 交付物 | 说明 |
|--------|------|
| `main.py` | CLI Composition Root,本次仅实现 infer 模式 |
| `app/harness/deps_router.py` | 按 video_id 懒加载 InferenceDeps 的路由器 |
| `app/ports.py` 扩展 | 新增 `ToolDispatchFactory` / `PromptBuilderFactory` Protocol |
| `app/harness/runner.py` 小改 | `__init__` 注入 factory,替换 noop 占位 |
| `store/skills/v1/` | 13 个 skill(从 TRM4 v1 精简 + 注入 TRM5 card 字段) |
| `store/prompts/v1/` | 目录重组(从 `store/prompts/` 扁平结构迁入) |
| 配置变更 | concurrency=24, max_steps=40, breaker_threshold=48 |
## 2. 架构
### 2.1 依赖流(Clean Architecture Composition Root
```
main.py (Composition Root — 最外层)
├─ 构建 adapters ──────────────────────────────┐
│ CircuitBreaker │
│ RedisResponseCache (可选,降级为 None) │ adapters/
│ SQLiteTelemetryRecorder │
│ GovernedLLMClient (search) │
│ GovernedLLMClient (evolve, 本次传同一实例) │
│ GovernedVLMClient │
│ LocalEmbeddingProvider │
│ MonkeyOCRClient (可选) │
│ │
├─ 构建 InferenceDepsRouter ───────────────────┤
│ 接收: store_dir, embed, llm, vlm, ocr │ app/harness/deps_router.py
│ 复用: factory.build_inference_deps() │
│ │
├─ 构建 Runner ────────────────────────────────┤
│ 注入: config, llm, evolve_llm, vlm, │ app/harness/runner.py
│ telemetry, │
│ tool_dispatch_factory, │
│ prompt_builder_factory │
│ │
└─ asyncio.run(runner.infer()) ────────────────┘
```
依赖只向内:`main.py``adapters/` + `app/``core/`
### 2.2 InferenceDepsRouter
**位置**`app/harness/deps_router.py`
**职责**:将 Runner 的全局统一 dispatch/prompt_builder 接口路由到 per-video 的 `InferenceDeps`
```
prompt_builder(qa) ← 先调用,注册 qid→vid 映射
AgentLoop.run()
dispatch(tool, args, ctx) ← 后调用,通过 ctx["session_id"] 查映射
```
核心伪代码:
```python
class InferenceDepsRouter:
_deps_cache: dict[tuple, InferenceDeps] # (vid, skills_dir, prompts_dir) → deps
_qid_to_vid: dict[str, str] # question_id → video_id
def create_dispatch(self, *, skills_dir=None):
async def _dispatch(tool_name, args, *, context):
vid = self._qid_to_vid[context["session_id"]]
deps = self._ensure_deps(vid, skills_dir, ...)
return await deps.tool_dispatch_fn(tool_name, args, context=context)
return _dispatch
def create_prompt_builder(self, *, skills_dir=None, prompts_dir=None):
def _builder(qa):
self._qid_to_vid[qa.question_id] = qa.video_id # 注册映射
deps = self._ensure_deps(qa.video_id, skills_dir, prompts_dir)
return deps.prompt_builder(qa)
return _builder
def _ensure_deps(self, video_id, skills_dir, prompts_dir):
key = (video_id, str(skills_dir or ""), str(prompts_dir or ""))
if key not in self._deps_cache:
self._deps_cache[key] = build_inference_deps(...)
return self._deps_cache[key]
```
**时序保证**`run_inference()` 中每道题先调 `prompt_builder(qa)` 构建 prompt,再启动 AgentLoop(调 dispatch)。映射注册总先于使用。
### 2.3 Runner 改动
`__init__` 新增 2 个可选参数,按 mode 做 fail-fast 校验:
```python
def __init__(self, config, *, llm, evolve_llm, vlm, telemetry,
tool_dispatch_factory=None,
prompt_builder_factory=None):
# mode in {"infer", "eval", "train"} 时 factory 为 None → 立即 ValueError
if config.mode in {"infer", "eval", "train"}:
if tool_dispatch_factory is None or prompt_builder_factory is None:
raise ValueError(
f"mode={config.mode} 需要 tool_dispatch_factory 和 prompt_builder_factory"
)
```
`_make_tool_dispatch_fn` / `_make_prompt_builder` 优先用注入值。测试场景可传 mock factory。
### 2.4 Protocol 定义
`app/ports.py` 新增精确类型的 Protocol
```python
class ToolDispatchFn(Protocol):
"""工具调度函数签名。"""
async def __call__(
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str: ...
class ToolDispatchFactory(Protocol):
"""per-version 工具调度工厂。"""
def __call__(self, *, skills_dir: Path | None = None) -> ToolDispatchFn: ...
class PromptBuilderFn(Protocol):
"""Prompt 构建函数签名。"""
def __call__(self, qa: GeneratedQuestion) -> tuple[str, str]: ...
class PromptBuilderFactory(Protocol):
"""per-version prompt 构建工厂。"""
def __call__(self, *, skills_dir: Path | None = None,
prompts_dir: Path | None = None) -> PromptBuilderFn: ...
```
### 2.5 InferenceDepsRouter dispatch 防御
Router 的 dispatch 函数对缺失/未知 `session_id` 显式 raise 带诊断信息的 `KeyError`
```python
async def _dispatch(tool_name, args, *, context):
session_id = context.get("session_id")
if not session_id or session_id not in self._qid_to_vid:
raise KeyError(
f"未注册的 session_id={session_id!r}"
f"已注册 {len(self._qid_to_vid)} 条映射"
)
...
```
### 2.6 main.py 结构
使用 argparse(复用 `RunConfig` + `load_config()` 的 YAML/CLI 三层合并逻辑)。
适配器参数通过 `InfraSettings(BaseSettings)``.env` 加载(遵循 CLAUDE.md §4.5 pydantic-settings 规范),禁止 main.py 直接散读环境变量:
```python
class InfraSettings(BaseSettings):
"""工程配置(少变/敏感),从 .env 加载。"""
search_llm_model: str
search_llm_base_url: str
search_llm_api_key: str
vl_llm_model: str
# ... 其余 LLM/VLM/Redis/OCR/timeout/breaker 字段
model_config = SettingsConfigDict(env_file=".env")
def _build_adapters(settings: InfraSettings) -> _Adapters:
# 从 settings 注入构建全套 adapters
...
def _log_result(result): # 输出推理结果摘要
def main(): # Composition Rootload_config → settings → adapters → router → runner
```
本次只实现 `mode == "infer"` 分支,其余模式 `raise SystemExit("尚未实现")`
> **Supersede 声明**:本设计的 `store/prompts/v1/` 版本化目录结构替代了 `2026-07-07-search-module-design.md``store/prompts/` 扁平结构的约定。`PromptManager(prompts_dir)` 今后总接收具体版本目录(`store/prompts/v1` 或 workspace 内 `prompts/vN`),不再接收 `store/prompts`
## 3. 初始 Prompt 集
### 3.1 设计原则
```
进化能学到的 → v1 留框架、去细节(给进化留空间)
进化学不到的 → v1 用人类版(否则永远缺失)
```
依据 TRM4 进化历史(v1→v40):skills 进化 39 轮(主力),system prompt 仅 1 轮,tool prompts 未变。
### 3.2 三类 Prompt 处理
| 类别 | 是否进化 | v1 来源 | 处理 |
|------|---------|---------|------|
| system.md | 微量 | TRM5 现有版本 | 保留(已含 card 字段说明,是基础设施) |
| skills/v1/ (13个) | 主力 | TRM4 v1 精简 + TRM5 注入 | 见 §3.3 |
| extract/verify (8个) | 不变 | TRM5 现有版本 | 保留(已适配 card 结构) |
### 3.3 Skills v1 内容策略
每个 skill 文件的改造规则:
| 内容层次 | 进化能学到? | v1 处理 |
|----------|------------|---------|
| YAML frontmattertask_type | 否 | 保留原版 |
| Step 标题 + 一句话基本意图 | 否 | 保留原版 |
| reflect/plan/action JSON schema | 否 | 保留原版 |
| **TRM5 card 字段索引** | **否** | **新增**(注入各层字段名及适用场景) |
| 数据驱动统计 | 是 | 移除 |
| 步骤间精确转换条件 | 是 | 移除 |
| 详细操作性规则 | 是 | 移除 |
| 大多数自检信号 | 是 | 移除(保留最基本 1 条) |
| 特定失败模式陷阱 | 是 | 移除(保留 1-2 条通用警告) |
**card 字段索引表**(注入每个 skill):
| 层级 | 字段 | 适用场景 |
|------|------|---------|
| L1 | scene_summary | 整体概况 |
| L1 | key_entities | 查找人物/物体 |
| L1 | main_actions | 主要动作 |
| L1 | temporal_flow | 时间线概览 |
| L1 | topic_keywords | 主题定位 |
| L2 | event_description | 事件因果 |
| L2 | entities / actions | 实体和动作细节 |
| L2 | state_changes | 状态转变 |
| L2 | spatial_relations | 空间关系变化 |
| L3 | frame_summary | 精确视觉证据 |
| L3 | visible_entities | 具体物体确认 |
| L3 | ongoing_actions | 正在发生的动作 |
| L3 | spatial_layout | 精确空间位置 |
| L3 | visual_attributes | 光照、色调、机位 |
| 全层 | visible_text | 画面文字(OCR |
| 全层 | subtitle | 字幕转写 |
### 3.4 store/ 目录重组
```
store/
prompts/
v1/ ← 新建(从 store/prompts/*.md 移入)
system.md
observe_frame_extract.md
observe_frame_verify.md
search_similar_extract.md
search_similar_verify.md
view_node_extract.md
view_node_verify.md
view_node_children_extract.md
view_node_children_verify.md
skills/
v1/ ← 新建(13 个文件)
default-strategy.md
action-reasoning.md
action-recognition.md
attribute-perception.md
counting-problem.md
information-synopsis.md
object-reasoning.md
object-recognition.md
ocr-problems.md
spatial-perception.md
spatial-reasoning.md
temporal-perception.md
temporal-reasoning.md
```
## 4. 配置变更
| 参数 | 原值 | 新值 | 位置 |
|------|------|------|------|
| concurrency | 12 | 24 | config/default.yaml |
| max_steps | 15 | 40 | config/default.yaml |
| breaker_threshold | 5 | 48 | .env |
熔断器行为:所有 24 并发 agent 共享同一 CircuitBreaker 实例,`record_success()` 归零计数。48 = 连续 2 波全失败才触发开路,间歇抖动不会误触。
## 5. 拒绝的方案
| 方案 | 拒绝原因 |
|------|---------|
| Runner 子类覆盖 `_make_*` | Template Method 反模式,违反依赖反转 |
| 绕过 Runner 直接调 `run_inference` | 不复用 workspace 管理、日志、报告逻辑 |
| 照搬 TRM4 v1 skills | 进化空间不足,且缺少 TRM5 card 字段知识 |
| 极简骨架 skills | 基线质量过低,可能不收敛 |
| typer CLI | 需重写 `RunConfig` 的 YAML/CLI 合并逻辑,ROI 不值 |
@@ -0,0 +1,197 @@
# 论文主图设计:Self-Evolving Search Agent 推理训练闭环
**日期** 2026-07-09 · **状态** 已获用户批准(口头) · **产出物** Figma 图(文件 `xnLGUkZottqnr4dsEt9fGq` Page 1 空白区)
## 1. 目标与定位
- **用途**:论文主图(目标会议存在文档分歧:CLAUDE.md=AAAI 2026ARCHITECTURE.md 与记忆=EMNLP 2026,以用户最终决定为准,不影响本图设计),展示推理训练部分的自进化闭环;不含建树与新题生成(建树已有独立图,位于同一 Figma 文件)。
- **核心叙事**:搜索 Agent 通过 推理→诊断→进化→门控 闭环自我改进;**Frozen LLM, trainable harness**——被"训练"的不是模型权重,而是版本化的 Skills+Prompts。
- **差异化**:AVP/DVD 等相关工作画的是推理期内环(agent 怎么搜视频);本图内环只是一个面板,**训练期外环是主角**。
- **审稿人一句话记忆点**:这是一个不动模型权重的 PyTorch 式训练循环。
## 2. 已确认的关键决策
| 决策点 | 结论 |
|---|---|
| 构图 | 水平流水线 + 底部参数回流闭环(方案 A) |
| 版式 | 双栏跨页宽图,画布 2400×1050 px(≈2.3:1,缩印 180mm |
| 信息密度 | 四机制全部可见(Agent 内环 / 诊断瀑布 / patch 引擎 / CE-Gate+信息阶梯),去工程化(无熔断/缓存/遥测) |
| PyTorch 类比 | 底部独立双行对照条,与上方区域逐段对齐 |
| 示例贯穿 | 延续建树图同一 Video-MME 天文台视频,问题/诊断/patch 文本典型化设计 |
| 迭代维度 | Store 处 v1…vN 卡片堆叠 + ×N epochs 循环标记暗示,不加独立时间轴 |
| 绘制位置 | 与建树图同文件(素材直接复用),Page 1 空白区 y≥1800,不动现有图层 |
| 语言 | 图内文字全英文 |
## 3. 布局
> 下方 ASCII 草图为中文说明稿,仅示意区域关系;最终图层文字一律采用 §4/§5 的英文术语。
```
┌──────────────────────────────────────────────────────────────────┐
│ [输入] ┌──────────────┐ ┌───────────┐ ┌──────────┐ ┌──────┐ │
│ Q+缩略图→│ ① INFERENCE │→ │② DIAGNOSE │→ │③ EVOLVE │→ │④ CE- │ │
│ │ 树环境+内环 │ │ 归因瀑布 │ │ patch引擎 │ │ GATE │ │
│ └──────↑───────┘ └───────────┘ └──────────┘ └──┬───┘ │
│ │ read ┌─────────────────┐ accept│ │
│ └─────────────────│⑤ Skills+Prompts │←──────┘ │
│ │ Store v1…vN ▤▤ │ reject→保基线
│ └─────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ DataLoader│forward()│backward()│optimizer.step()│grad clip│nn.Parameter│
└──────────────────────────────────────────────────────────────────┘
```
**数据流勘误记录**CE-Gate 位于 Evolve 之后(进化产出候选 → gate 用 e-process 验证候选 vs 基线 → accept 才写入 Store),信息阶梯为 gate 供给高信息量题序,而非控制输入难度。此顺序已与 `core/evolution/gate.py``app/harness/gate_ladder.py` 核实。
## 4. 五区域内容规格
术语均与代码核实一致(来源见 §7)。
### 4.1 输入区
- 问题卡(Q: *"What happens right after the dome opens?"* 措辞绘制时可打磨)+ 天文台视频缩略图 + 迷你树 icon,标注 "hierarchical video tree (Fig. 2)" 衔接建树图。
### 4.2 ① INFERENCEagent-controlled,图上唯一画成循环的面板)
- 树环境迷你版:L1/L2/L3 三层色带(绿/蓝/黄,复用建树图配色与缩略图)。
- Agent 内环轨迹:Thought → `search_similar``view_node``observe_frame``submit_answer`;侧边 `read_skill` 箭头(来自 ⑤,工具名与 `app/search/tools.py:56-58` 一致)。
- 示例结局:answer ✗(答错),输出 trace 流向 ②。
- 面板角标:*agent-controlled*;其余面板角标 *code-controlled*
### 4.3 ② DIAGNOSEcode-controlled
- 归因瀑布级联:`extraction failure → search failure → reasoning failure`(+ mixed 兜底),画成三级下落台阶。
- 二分岔:**defect**(改 skill 正文)vs **lapse**(记 appendix 提醒)。
- D1D5 压缩为一排五个小 chipattribution / tool quality / search behavior / skill compliance / decision patterns。
- 示例:该题归因 `search failure` → 判 **defect**
### 4.4 ③ EVOLVE
- patch 流水线:candidate edits → **rank-and-clip** → apply patch。
- 侧边锁条带:**protected spans**appendix / momentum 区带锁图标,不可改写)。
- 示例 patch 片段:*"+ verify event boundary via L2 card before observe_frame"*。
- momentum 机制不单独出现(用户确认),仅隐含于锁条带。
### 4.5 ④ Validation · CE-GATE
- 面板标题 **Validation · CE-Gate**:块顺序验证(`validate.py` 配对翻转 W/L)作为输入喂 e-process——图上画为"candidate vs baseline 配对小图 → e 曲线"。
- e-process 小曲线:e 值随题数爬升,越过 `e_confirm` 虚线。
- 四出口:**accept (confirmed) / accept (provisional) / reject / continue**(代码中三种 reject 在图上合并,用户确认)。
- 侧挂小组件:信息阶梯(2:1 交错题序图标,标注 *info-max question ladder*),尺寸压小避免抢焦点。
- 视觉层级:accept 主路径线最粗;reject/continue 细灰次级线。
### 4.6 ⑤ Skills+Prompts Store
- v1…vN 卡片堆叠(复用建树图 Event Card 堆叠画法)+ 版本号 badge。
- accept 箭头写入 v(N+1);read 箭头回流至 ①,构成大闭环;循环标记 **×N epochs**。
## 5. PyTorch 对照条(最底部)
浅灰底横带,等宽字体,与上方区域逐段对齐:
| 上方区域 | 对照文字 |
|---|---|
| 输入 | `DataLoader` |
| ① | `model.forward()` |
| ② | `loss.backward()` |
| ③ | `optimizer.step()` |
| ④ | `grad clipping (validate)`(对应 CLAUDE.md 类比表中"进化 validation = grad clipping";④ 面板同时含 validate 配对翻转与 CE-Gate 判定) |
| ⑤ | `nn.Parameter` |
条带一侧放记忆点标语:*Frozen LLM, trainable harness*。
## 6. 视觉规范与素材复用
| 元素 | 方案 |
|---|---|
| 面板样式 | 白底、细虚线外框、顶部居中标题(沿用建树图) |
| ① | 淡绿系 · ② 淡橙红系(新增,饱和度对齐现有 pastel) · ③ 淡紫系(复用 VLM 紫) · ④ 淡蓝系 · ⑤ 白卡+badge |
| 直接复用 | 视频缩略图(candidate_a_t*)、L1/L2/L3 badge、Scene/Event/Frame Card 组件、VLM 紫块、箭头/chevron 样式 |
| 字体 | 与建树图一致(Inter);标题 24px / 正文 16-18px / 标注最小 15px 灰(2400px 画布缩印 180mm 后 15px ≈ 1.1mm12px 过小已弃用) |
| 图层组织 | 顶层 Frame 命名 `Main Figure — Self-Evolving Loop`,五区域各一个子 Group,便于后续人工微调 |
## 7. 术语出处(代码核实)
| 图上术语 | 来源 |
|---|---|
| gate 四出口 accept_confirmed / accept_provisional / reject×3 / continue | `core/evolution/gate.py:57-110` |
| 归因瀑布 extraction/search/reasoning/mixeddefect vs lapse | `core/evolution/diagnose.py:910-997` |
| D1-D5 五维聚合 | `core/evolution/diagnose.py:1095-1307`D2-D5)、`1551-1563` + `2230` + `2293-2296`D1 attribution distribution |
| 信息阶梯冷启动 2:1、信息量排序 | `app/harness/gate_ladder.py:57-117` |
| 块顺序验证配对翻转 W/L | `core/evolution/validate.py:12-69` |
| rank-and-clip、protected spans、appendix/momentum 区 | `core/evolution/evolve.py:186-594``core/evolution/patch.py:11-56` |
| agent 工具五件套(含 `read_skill` | `app/search/tools.py:33-73` |
| 版本目录 Store `store/skills/v{N}` / workspace 本地拷贝 | `app/harness/store.py:28-136``app/harness/workspace.py:103-108,150-152` |
## 8. 验收标准
1. 图在 Figma 中为独立顶层 Frame,可整体导出 PNG/SVG,缩印 180mm 宽时最小文字(12px 标注)仍可辨认。
2. 五区域 + 对照条齐全,闭环箭头(⑤→① read、④→⑤ accept)无歧义。
3. 全部术语与 §7 代码核实结果一致;无熔断/缓存/遥测等工程元素。
4. 风格与同文件建树图肉眼一致(配色、字体、面板语言、卡片组件)。
5. 不改动/移动建树图的任何现有图层。
## 9. 构图重构记录(2026-07-09 定稿后追加)
用户验收反馈:内容正确但"下半部空、无主线重点"。经方案比选(用户选 A),实施:
| 改动 | 内容 |
|---|---|
| 显式循环主干 | 面板间 chevron → 4px 黑色实心三角箭头(forward 主线);④→⑤ write 与 ⑤→① read 回流均为 4px 绿色实线带,⟳ ×N epochs 置于带上;黑/绿双色对应 forward / parameter-update 语义 |
| 底部压缩 | Store 2080px 全宽行 → 680×170 紧凑块(右缘对齐 ④,write 直指 v5);对照条 130→84px;画布 1050→**940**2.55:1 |
| 填充 | motto 24px 斜体移至左下空区;read 带起点加绿色圆点锚记 |
逐模块精修均经 Claude 自审 + Codex 独立审双 PASSQuestion/①/②/③/④/⑤+对照条/整图重构共 8 轮审核)。Codex 抓到的实质问题:④ 的 W/L 翻转数与 e 曲线出口统计不自洽(修正为序列省略号 + W=8·L=0E=56.78>e_confirm=20)、ladder 色块数与题数不符、read 线易误读为边框。
## 10. 定稿修订记录(2026-07-10,本节覆盖 §2/§3 的布局规格)
逐模块精修完成后的第二轮构图重构与最终决策(均经用户逐项拍板):
| 决策 | 内容与理由 |
|---|---|
| 环形布局 | 画布 2400×940 → **1608×960**;顶行 Question→①→②→③(黑色 forward 箭头),③ 底部下沉入 ④,底行 ④→⑤ 右→左(绿色 write),⑤ 顶部 read 回流 ①;顺时针一圈 = 一个 epoch,上下缝压缩至 36px |
| ④ 表义化 | 横版 840×280,右→左三段与底行流向一致:入口小票 `v6 · candidate`(③ 箭头落点)→ 配对复测表(v5·baseline 上 / v6·candidate 下,翻转列淡绿高亮,`W = 8 · L = 0` + 小注 `(4 of 8 flips shown)`)→ e-process 曲线越 `e_confirm = 20`(端点 `E = 56.78`,与 gate.py 公式验算一致)→ 绿色放行单卡片(与 ② 红色缺陷工单对仗)+ 虚线幽灵票 `else: no confirmed write`(if/else 结构;虚线沿用 ① 的"未采用路径"编码) |
| 出口简化 | gate 四出口在图上压成 accept(confirmed) + else 两出口;else 措辞避免暗示"非 confirmed 全是 reject"Codex I2 |
| PyTorch 对照 | 底部全宽对照条 → Question 下方**纵向对照卡**248×280 浅灰圆角块,Q/①…⑤ ↔ DataLoader/forward/backward/step/grad clipping/nn.Parameter 六行) |
| ⑤ Store | 408×280 与 ④ 同高:v1..v5 灰卡 + **v6 ✦ 绿卡**write 落点),motto 移入面板底部;版本叙事闭合:本轮 read v5 → 产出 v6 候选 → 放行写入 → `read (next epoch)` 读 v6Codex I3 时序标注) |
| 模型标签统一 | ① Agent / ③ LLM / 建树图 VLM 全部统一为 **Agent**(同紫块同图标;紫=冻结预训练模型,与绿=可训练资源对仗)。备注风险:建树图批量帧描述严格说非 agentic,正文表述需圆(如 captioning agent |
| 删减 | 面板间游离标签(trace + ✗ / defect)、⑤ 的 nn.Parameter 小标签、大 ⟳ 字符、全宽对照条均删除;`⟳ × N epochs` 合并为单行小字嵌缝 |
审核记录:④⑤+布局经 Codex 独立审(无 CriticalI1/I2/I3/M3 已采纳落实);整图终审 **PASS**(无 Critical/图内必改项)。终审遗留三项归入 **figure caption 与投稿检查**,不改图:
1. caption 需说明"紫块 Agent = 冻结预训练模型组件,仅 ① 为 agent-controlled,训练对象是 harness"(消解 ③ 同款 Agent 块的误读,兼答 motto 与标签并存问题);
2. 投稿前打印 180mm 宽样张,验证代码字体图例、`e_confirm = 20``(4 of 8 flips shown)` 等 14px 小字的缩印可读性。
## 11. 建树图风格统一记录(2026-07-10)
主图定稿后,用户认定主图风格更优,建树图向其看齐(内容结构不大改)。改动与决策:
| 决策 | 内容 |
|---|---|
| 视觉词汇表统一 | 两图共用同一套编码:虚线=堆叠/次要(不得用作焦点强调)、红色只留给错误、层级色 L1绿/L2蓝/L3黄贯穿;据此 Scene 2/Event 2 焦点框由灰虚线改层级色 3px 实线,Event i 红虚线框与 Event Card 红描边改 L2 蓝,Frame Card 描边补齐 L3 黄 |
| 标题与结构 | 三区块标题 ① Long Video / ② Multi-scale Temporal Decomposition / ③ Build TreeInter Bold 24 + 编号居中);chevron 换主图同款 4px 黑箭头;全部游离节点收入顶层 Frame(1715×441);修正 3 处 "leval" 拼写与标题缺空格、删重复标题 |
| Build Tree 补位 | 用户删除冗余 E1/E2 块后,空区放**空心骨架树**(白底+层级色描边,caption "tree skeleton (from ②) → attach a card to every node");三个 Agent 块加建树顺序徽章 ❶Event(L2)→❷Frame(L3)→❸Scene(L1),与算法#1 "L2 先行→L3 向下→L1 向上"一致 |
| 两树同构 | 主图 Question 面板迷你树按同一对称几何重画(实心 24/20/16);空心=待填充骨架(建树图)、实心=成品(主图),唯一差别维度即语义 |
| 模型标签 | 建树图 VLM 紫块随主图统一为 Agent(用户决策;正文 caption 需说明紫块=冻结模型组件) |
审核记录:Codex 独立审出 1 CriticalL3 展开帧时间戳越出 Event 2 区间,已改 0:21:30/0:21:32+ 2 Important(竖排来源标注弱化为 16px 灰、"The Event Card of L2 corresponding to L3" 改 "parent event card (L2)"+ 1 MinorFrame Card 黄描边),全部落实。遗留投稿检查项:❶❷❸ 徽章含义入 caption、180mm 样张验证小字。
### 11.1 ③ Build Tree 二次重构:配方表(2026-07-11,覆盖上文"Build Tree 补位"与"两树同构"两行)
同门反馈引发讨论后,用户裁定 ③ 的核心信息是**每类 Card 由什么输入生成**(建树流程而非建树结果),推翻骨架树方案。定稿设计与决策:
| 决策 | 内容 |
|---|---|
| 配方表结构 | 三行 = 三类卡的生成配方,**行序 = 建树顺序**(❶ Event→❷ Frame→❸ Scene,即 L2→L3→L1),顺序歧义消除;行区上方图例 `1 → 2 → 3 = construction order` 解释徽章 |
| 输入模态编码 | **缩略图(边框层级色)= 图像输入;迷你 Event Card = 文本输入**。三行配方:两张蓝框帧 / 黄框帧 + 迷你卡 / 三张迷你卡堆叠 + ⋯(text only)——一眼读出模态递变:纯图 → 图+文 → 纯文 |
| 迷你 Event Card | 58×56,淡蓝底 + 14px "Event" 标题 + 字段线 + 点列,与大卡视觉基因一致(经两轮迭代:无标题字形认不出、被拒调用残留一代旧卡致"重影",均已清理) |
| 树的去留 | 大树/骨架树全部移除;两图迷你树同构(主图 Question 面板对称重画)保留 |
| 内容纠错 | 借重画撞出两处原图错误并修正:Scene Card 字段 "frame summary"→"scene summary"(对照 `scene_summary`)、两卡 "visible test"→"visible text"(对照 `visible_text` |
| 主图 ② 阶段标签 | 三层注释:灰 `replay & collect evidence`(顶部)、斜体 `defect or lapse?`(过渡箭头旁)、红 `verdict`(贴红票左上缘,红色随工单类目);补齐同门指出的流程过渡感 |
| 工具图例回退 | ① 工具名就近标注经试验后观感不佳,回退为底部图例(用户决定) |
| 备份 | 重构前两图整体克隆锁定:Tree 备份 `2173:2`、Main 备份 `2173:276`BACKUP 2026-07-10 |
本轮 Codex 终审 **PASS**(无 Critical/Important):配方三行与 `video_builder.py` 逐行核对一致(L2=chat_with_images(rep_frames)、L3=帧+l2_description、L1=l2_texts 纯文本聚合),字段名全部对应。Minor 归入投稿检查项:
1. caption 补一句 "cards show selected fields"L2 卡代码中还有 action_subjects/visible_text/spatial_relations,图内为节选);
2. `verdict` 红字在极小缩印下有被误读为错误标注的微小风险,如需更稳可改深灰或加冒号(当前保持用户拍板的红色随工单方案)。
## 12. 被拒绝的备选方案
| 方案 | 拒绝原因 |
|---|---|
| B 上下双层 S 形回路 | PyTorch 对照条无法与面板逐段对齐,退化为角标 |
| C 中心辐射环形 | 2.3:1 宽幅下横向空间浪费大,机制细节难展开,与建树图直线叙事不一致 |
| 独立进化时间轴 | 占版面,与对照条拥挤;由 Store 版本堆叠 + ×N epochs 替代 |
@@ -0,0 +1,477 @@
---
id: question-gen-synth
title: 赛题生成工具设计(Question Generation Synthesis
type: design
created: 2026-07-09
status: draft
---
# 赛题生成工具设计
## 1. 目标与动机
让视频树自行生成与 Video-MME 原始赛题风格、难度近似的四选一选择题,用于自进化训练循环的 DataLoader。原始 900 道 benchmark 题保留为 held-out 最终评测集,避免"直接拿答案调"的审稿质疑。
**角色定位**:生成题 = 训练集,原始题 = 测试集。进化循环的改进效果最终由原始 benchmark 验证泛化能力。
**训练 vs 论文评测的区分**:训练循环全程使用生成题(三池切分——诊断池/验证池/test 池——均来自生成题),论文报告的 held-out 泛化指标是训练结束后,用最终 best 版本对原始 benchmark 全量 900 题单独跑推理得到的结果。两步分离,Runner 代码无需改动。
## 2. 模块结构与职责边界
### 2.1 文件布局
```
app/question_gen/
├── __init__.py ← 已有:re-export loader API
├── loader.py ← 已有:load_benchmark + stratified_sample
└── synthesizer.py ← 新增①:出题核心逻辑
app/harness/
└── factory.py ← 新增②:推理依赖组装(wiring)
tools/generate_questions.py ← 新增③:CLI 壳(generate + calibrate
```
### 2.2 职责切分
| 模块 | 职责 | 消费者 |
|------|------|--------|
| `synthesizer.py` | 题型-层级映射、锚节点采样、prompt 构造(few-shot)、embedding 去重、单题生成编排 | `tools/generate_questions.py` |
| `factory.py` | 给定 store 路径 + config → 组装 LLM/VLM/Embedding/SearchToolDispatcher/PromptManager 全套推理依赖 | `tools/generate_questions.py`(校准)、未来 `main.py`、Runner |
| `tools/generate_questions.py` | CLI 参数解析、并发编排(Semaphore)、进度日志、JSON 输出 | 用户直接运行 |
### 2.3 依赖方向
```mermaid
flowchart LR
TOOLS["tools/generate_questions.py"] --> SYN["app/question_gen/synthesizer"]
TOOLS --> FAC["app/harness/factory"]
TOOLS --> ADP["adapters/*"]
FAC --> SEARCH["app/search/*"]
FAC --> ENV["app/tree/environment"]
FAC --> ADP
SYN --> PROTO["core/protocols (VLMProvider, EmbeddingProvider via DI)"]
SYN --> TYPES["core/types (GeneratedQuestion)"]
SYN --> IDX["app/tree/index (TreeIndex)"]
```
全部合规——外层→内层,`core/` 不依赖任何外层。
### 2.4 与 QuestionGenerator Protocol 的关系
`app/ports.py` 已预留 `QuestionGenerator` Protocol。本设计**不实现该 Protocol**——出题是一次性离线工具而非运行时能力,Runner 不需要运行时出题。`synthesizer.py` 的函数式接口(`generate_one` 等纯函数 + async 编排)比 Protocol class 更适合工具脚本场景。`QuestionGenerator` Protocol 保留但标记为"预留,当前无实现",不删除——若未来需要运行时出题可基于 synthesizer 的纯函数包装实现。
### 2.5 方案选择与否决
| 方案 | 否决理由 |
|------|---------|
| A: 单体脚本(全部逻辑放 `tools/`) | 业务逻辑(题型映射、采样、prompt、去重)混在 CLI 编排中,不可独立测试;不匹配 repair 管线的 app/ + tools/ 分层惯例 |
| B: Protocol 实现 + 脚本编排(`adapters/` 实现 `QuestionGenerator`) | adapter 层语义是外部服务接口,出题逻辑是应用层业务规则,放 adapter 层语义不匹配 |
| **C: app/ 业务逻辑 + tools/ CLI 壳(采用)** | 与 repair 管线结构一致,Clean Architecture 依赖方向合规,业务逻辑可独立测试 |
## 3. synthesizer.py 核心设计
### 3.1 题型-层级映射
模块级常量,沿用 TRM4 设计文档的映射表:
| 锚定层级 | 题型 | 帧图 | 文本上下文 | 帧数 |
|---------|------|------|-----------|------|
| L3 | Object Recognition | 必须 | frame_summary | 1 |
| L3 | Attribute Perception | 必须 | frame_summary | 1 |
| L3 | OCR Problems | 必须 | frame_summary | 1 |
| L3 | Spatial Reasoning | 必须 | frame_summary + spatial_layout | 1 |
| L3 | Spatial Perception | 必须 | frame_summary | 1 |
| L2 | Action Recognition | 必须 | 事件 card | 2-3(子帧均匀采样) |
| L2 | Action Reasoning | 必须 | 事件 card | 2-3 |
| L2 | Counting Problem | 必须 | 事件 card | 2-3 |
| L2 | Temporal Perception | 可选 | 事件 card + time_range | 0-1 |
| L1 | Temporal Reasoning | 必须 | 根 card + 多个 L2 card(≥3 | 每 L2 取 1 张代表帧 |
| L1 | Information Synopsis | 必须 | 根 card + 全部 L2 card | 每 L2 取 1 张代表帧 |
| L1-L2 | Object Reasoning | 必须 | 2-3 个 L2 card | 每 L2 取 1 张代表帧 |
节点采样:每道题从全部视频树中随机选一棵,在对应层级随机选一个锚节点。同视频同题型不重复。L1 题型使用多个 L2 子节点联合输入时,按时间顺序组织节点,保持叙事连贯性。
### 3.2 AnchorContext 数据结构
```python
@dataclass(frozen=True)
class AnchorContext:
"""锚节点上下文——生成单道题所需的全部素材。"""
node_id: str # 锚节点 ID
card_text: str # 锚节点 card 序列化文本
frame_paths: list[str] # 帧图片路径
subtitle: str # 对应字幕(可空)
distractor_texts: list[str] # 同视频其他节点摘要(供 VLM 生成干扰项)
```
### 3.3 核心函数签名
```python
# 纯函数:从树中采样锚节点 + 帧 + 上下文
def sample_anchor(
tree: TreeIndex,
task_type: str,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext
# 纯函数:组装 VLM promptsystem + user,含 few-shot exemplar
def build_generation_prompt(
task_type: str,
anchor: AnchorContext,
exemplars: list[GeneratedQuestion],
) -> tuple[list[dict], list[str]]
# 返回:(messages, image_paths) — 直接喂给 VLMProvider
# 纯函数:解析 VLM 返回的 JSON → 部分字段字典
# source_nodes 和 difficulty 由 generate_one 在 parse 后用 anchor 信息补齐
def parse_vlm_response(
raw: str,
video_id: str,
task_type: str,
seq: int,
) -> dict
# 返回:{"question_id", "question", "options", "answer"} 字典
# 调用方补齐 source_nodes/difficulty 后构造 GeneratedQuestion
# 纯函数:embedding 去重判定
def is_duplicate(
question_text: str,
pool_embeddings: np.ndarray,
embed_fn: Callable[[str | list[str]], np.ndarray],
threshold: float,
) -> bool
# 异步编排:生成单道题(含重试 + 去重循环)
async def generate_one(
vlm: VLMProvider,
embed_fn: Callable[[str | list[str]], np.ndarray],
tree: TreeIndex,
video_id: str,
task_type: str,
seq: int,
*,
exemplars: list[GeneratedQuestion],
pool_embeddings: np.ndarray,
used_node_ids: set[str],
max_retries: int,
similarity_threshold: float,
rng: random.Random,
session_id: str,
) -> GeneratedQuestion | None
```
**设计要点**
- 纯函数(sample_anchor、build_generation_prompt、parse_vlm_response、is_duplicate)可独立单测,不需要 VLM
- `generate_one` 是唯一异步函数,接收 `VLMProvider` 通过 DI
- 干扰项来自 `AnchorContext.distractor_texts`——同视频其他节点的真实信息
### 3.4 few-shot exemplar 选择
生成 prompt 包含 2-3 道同题型的原始 benchmark 题作示例,对齐风格和难度。
选择策略:
- 每题型取 `min(3, 该题型 benchmark 总量)`
- 按 seed 随机采样 + 跨视频去重(避免 exemplar 全来自同一视频)
- exemplar 是只读引用,不从 benchmark 评测集中移除
### 3.5 prompt 结构
```
System: 视频理解题目生成器,根据视频树节点内容和帧图生成 {task_type} 四选一题。
[2-3 道该题型原始 benchmark 题作示例]
约束:
- 问题必须基于给定节点内容,不能靠常识推断
- 干扰项来自同视频其他节点的真实信息(非凭空捏造)
- 难度和问法风格与示例一致
User: [锚节点 card + 字幕 + 帧图] + [同视频其他节点摘要,供干扰项素材]
```
### 3.6 去重机制
`EmbeddingProvider`nomic-embed-text-v1.5)对 question 文本做 embedding,余弦相似度检查:
| 检查对 | 阈值 | 处理 |
|--------|------|------|
| 生成题 vs 原始 benchmark 同题型题 | ≥ similarity_threshold | 丢弃,换节点重试 |
| 生成题 vs 已生成的同题型题 | ≥ similarity_threshold | 丢弃,换节点重试 |
维护 embedding 池(原始题 + 已通过的生成题),每生成一道新题即时查重。单题最多重试 `max_retries` 次。某题型连续耗尽重试配额时,脚本报错退出并输出已完成/未完成的题型统计,不静默少题。
**并发去重安全**embedding 池的"检查 + 添加"必须是原子操作。并发 `generate_one` 任务成功后,通过单线程汇总点(asyncio.Queue 或 await 后顺序提交)更新 embedding 池 + 写 JSON + 更新 progress,避免竞态导致相似题同时通过。
## 4. factory.py 推理依赖组装
### 4.1 解决的问题
目前 `Runner._make_tool_dispatch_fn()``_make_prompt_builder()` 都是 `raise NotImplementedError`,设计为"由 main.py 注入"。组装逻辑涉及 adapter 实例化 + app 组件串联,应提取为可复用的 factory 函数,避免在每个调用方(tools/ 脚本、未来 main.py)重复 wiring。
### 4.2 核心接口
```python
@dataclass(frozen=True)
class InferenceDeps:
"""跑一次推理所需的全套依赖(不含 HarnessLog,其生命周期由调用方管理)。"""
llm: LLMProvider
tool_dispatch_fn: Callable # SearchToolDispatcher.dispatch
prompt_builder: Callable # PromptManager 的偏函数
def build_inference_deps(
*,
store_dir: Path,
video_id: str,
prompts_dir: Path,
skills_dir: Path | None,
skill_mode: str,
embed_provider: EmbeddingProvider,
llm: LLMProvider,
vlm: VLMProvider,
ocr: OCRProvider | None,
verify_vision: bool,
anchor: bool,
assemble_mode: str,
) -> InferenceDeps
```
### 4.3 内部流程
```
build_inference_deps()
├── 加载 TreeIndexstore_dir/videos/{video_id}/tree.json
├── 构建 TreeEnvironmentindex=tree, frames_dir=videos/{video_id}/frames
├── 构建 SkillRegistryskills_dir,可选)
├── 构建 SearchToolDispatcherenv, tool_llm, vlm, ocr, prompts_dir,
│ skills, embed_fn, verify_vision, anchor, assemble_mode
├── 构建 PromptManagerprompts_dir)→ 偏函数化 prompt_builder(绑定 skill_mode
└── 返回 InferenceDeps
```
注意:`HarnessLog` 不放入 `InferenceDeps`——其生命周期由调用方通过 `with HarnessLog(...) as log` 管理,作为参数传给 `run_inference`
### 4.4 消费者
| 消费者 | 用法 |
|--------|------|
| `tools/generate_questions.py` calibrate | 按 video_id 分组题目,对每组调 `build_inference_deps` 构建对应视频树的依赖 → 分组 `run_inference` |
| 未来 `main.py --mode infer` | CLI 参数映射到 factory 参数 |
| `Runner` | `_make_tool_dispatch_fn` / `_make_prompt_builder` 改为委托 factory |
### 4.5 设计约束
- factory 只做**组装**,不持有状态——每次调用返回独立的 `InferenceDeps`
- adapter 实例(LLM/VLM/Embedding)由调用方创建并传入,factory 不管 adapter 生命周期
- 调用方自由决定 adapter 的复用策略(共享 vs 按需创建)
## 5. tools/generate_questions.py CLI 设计
### 5.1 子命令
```bash
# 生成
python tools/generate_questions.py generate \
--store-dir store \
--output-dir store/questions/generated/Video-MME \
--per-type 20 \
--similarity-threshold 0.85 \
--max-retries 3 \
--concurrency 8 \
--seed 42
# 校准(生成题 vs benchmark 基线对比)
python tools/generate_questions.py calibrate \
--generated-dir store/questions/generated/Video-MME \
--benchmark-dir store/questions/benchmarks/Video-MME \
--store-dir store \
--db-path results/calibrate.db \
--prompts-dir store/prompts \
--concurrency 4 \
--max-steps 15 \
--skill-mode auto \
--tolerance 0.10 \
--alpha 0.05 \
--baseline-db <可选,已有基线 DB 路径> \
--baseline-run-id <可选,已有基线 run_id>
```
除 baseline 复用参数外均必传,无默认值(CLAUDE.md §4.5)。`--baseline-db` + `--baseline-run-id` 可选但必须成对出现:有则从 DB 读 benchmark 基线,无则自动跑一次 benchmark 推理。
### 5.2 generate 流程
```
1. 加载 300 棵树的 video_id 列表
2. 加载 benchmark 题目(作为 few-shot exemplar 来源)
3. 初始化 embedding 池(benchmark 题 question text → embedding
4. 实例化 GovernedVLMClient + EmbeddingProvider
5. 检查断点续跑文件(progress.json
6. 对 12 题型 × per_type
├── 跳过已完成的(断点续跑)
├── 随机选视频 + 锚节点(同视频同题型不重复)
├── asyncio.Semaphore(concurrency) 并发调 generate_one
├── 成功 → 加入 embedding 池 + 追加到结果 + 更新 progress
└── 连续耗尽重试 → 报错退出,输出已完成/未完成统计
7. 按 video_id 分组写入 JSON
8. 全部完成后删除 progress.json
```
### 5.3 calibrate 流程
```
1. load_benchmark 加载生成题和 benchmark 题
2. 获取 benchmark 基线:
├── 有 --baseline-db + --baseline-run-id → 从 DB 读 per_task_type accuracy
└── 没有 → 按 video_id 分组 benchmark 题 → 每组 build_inference_deps
→ 分组 run_inference → 汇总存 DB
3. 按 video_id 分组生成题 → 每组 build_inference_deps → 分组 run_inference
(每组使用对应视频的 TreeEnvironment,避免跨视频树错用)
4. 汇总两组 per_task_type accuracy,对比(Fisher exact test
5. 输出对比表 + 判定结果
6. 存在 FAIL → 退出码 1
```
### 5.4 tools/ 脚本职责边界
脚本**只做**argparse、adapter 实例化(读 `.env`)、Semaphore 并发、进度日志(loguru)、JSON 写入、calibrate 时调 factory + run_inference。
脚本**不做**:prompt 构造、节点采样、去重判定(synthesizer.py)、依赖组装逻辑(factory.py)。
## 6. 校准统计方法
### 6.1 问题
benchmark 题型分布极不均匀(Spatial Perception 仅 3 道 vs Object Reasoning 240 道),固定 10% 阈值对小样本题型会产生误判——单题翻转即 33% 波动。
### 6.2 组合判定:Fisher exact test + effect size
`scipy.stats.fisher_exact` 对每个题型构造 2×2 列联表:
| | 答对 | 答错 |
|--|------|------|
| Benchmark | a | b |
| Generated | c | d |
判定规则:
| \|Δ\| > tolerance | p < α | 判定 | 含义 |
|---|---|---|---|
| ✗ | — | **PASS** | 差异在容忍范围内 |
| ✓ | ✓ | **FAIL** | 差异大且统计显著——生成题难度确实偏了 |
| ✓ | ✗ | **WARN** | 差异大但样本不足以确认——可能是噪声 |
### 6.3 优势
- 不需要 ad-hoc 的 `min_calibrate_size` 参数
- 小样本题型自动降级为 WARN——Fisher test 的 p-value 天然反映样本量不足
- CLI 只需两个语义清晰的统计参数:`--tolerance 0.10` + `--alpha 0.05`
- 退出码只看是否存在 FAIL(WARN 不阻塞)
### 6.4 检测灵敏度与 per_type 的关系
| per_type | 可检出的最小差异(大样本 benchmark 侧) |
|----------|---------------------------------------|
| 20 | ~30%(仅极大差异) |
| 50 | ~15%(中等差异) |
用户可根据需要的检测灵敏度选择 `--per-type`
### 6.5 输出格式
```
题型 | bench | gen | Δ | p-value | 判定
-------------------|--------|--------|---------|---------|--------
Spatial Perception | 66.7% | 40.0% | -26.7% | 0.590 | ⚠ WARN
Action Reasoning | 72.2% | 68.0% | -4.2% | 0.712 | ✓ PASS
Object Reasoning | 60.0% | 30.0% | -30.0% | 0.016 | ✗ FAIL
```
## 7. 断点续跑
生成 240 道题可能中断(VLM 故障、手动 Ctrl-C),沿用项目已有的 progress.json 模式:
```json
{
"completed": {
"Action Reasoning": ["gen-xyz-001", "gen-xyz-002"],
"Object Recognition": ["gen-abc-001"]
},
"output_dir": "store/questions/generated/Video-MME"
}
```
- 启动时检查 `{output_dir}/progress.json`,跳过已完成的题
- **恢复 embedding 池**:从已写出的 `{output_dir}/*.json` 重建已生成题的 embedding + `used_node_ids`,避免续跑后产生重复题
- 每道题写入 JSON 后立即更新 progress
- 全部完成后删除 progress.json
## 8. 输出格式
输出路径:`store/questions/generated/Video-MME/{video_id}.json`
```json
[
{
"question_id": "gen-{video_id}-{seq}",
"task_type": "Action Reasoning",
"question": "...",
"options": ["A. ...", "B. ...", "C. ...", "D. ..."],
"answer": "B",
"source_nodes": ["L1_000_L2_003"],
"difficulty": "medium"
}
]
```
与 loader schema 兼容(额外 `source_nodes`/`difficulty` 字段用于溯源),`load_benchmark` 零改动直接加载。
**训练集成**`--questions generated/Video-MME`
## 9. 受影响的既有接口
| 接口 | 影响 | 适配 |
|------|------|------|
| `load_benchmark` | 无 | 输出与 loader schema 兼容(额外 source_nodes/difficulty 字段用于溯源) |
| `RunConfig.questions` | 无 | 传 `generated/Video-MME` |
| `build_or_load_pools` | 无 | 三池均来自生成题 |
| `Runner._make_tool_dispatch_fn` | 改造 | 委托 factory.py |
| `Runner._make_prompt_builder` | 改造 | 委托 factory.py |
| `_VIDEO_MME_TASK_TYPE_COUNT` | **前置修复** | 从 11 改为 12`app/harness/config.py:24`),影响验证池保底下限 |
## 10. 测试策略
### 10.1 synthesizer.py
| 测试 | 覆盖点 |
|------|--------|
| `test_sample_anchor` | 各层级题型正确采锚、同视频同题型不重复、树节点不足时报错 |
| `test_build_generation_prompt` | messages 结构正确、exemplar 注入、图片路径列表、干扰项素材包含 |
| `test_parse_vlm_response` | 正常解析、格式异常(缺字段/非法 JSON)报错 |
| `test_is_duplicate` | 相似度 ≥ 阈值判重、< 阈值通过、空池不判重 |
| `test_generate_one` | mock VLMProvider,验证重试+去重循环、耗尽重试返回 None |
### 10.2 factory.py
| 测试 | 覆盖点 |
|------|--------|
| `test_build_inference_deps` | fake LLM/VLM/Embedding 验证返回各字段非 None、类型正确 |
| `test_missing_tree_file` | tree.json 不存在时报错 |
### 10.3 tools/generate_questions.py(集成级)
| 测试 | 覆盖点 |
|------|--------|
| `test_generate_smoke` | mock VLM + 1 棵真实树 + per_type=1,验证 JSON 输出格式 |
| `test_progress_resume` | 中断后重启,跳过已完成题 |
| `test_calibrate_pass_fail` | mock 两组 accuracy,验证 Fisher + tolerance 组合判定 |
真实 VLM 调用的 integration test 不在此次范围——依赖外部服务,不适合 CI。
## 11. 实现约束
- 完整类型注解 + 中文 DocstringCLAUDE.md §4.2
- 禁用 `print()`,使用 loguruCLAUDE.md §4.2
- 脚本放 `tools/`,不被其他模块 importCLAUDE.md §5
- 并发模式:`asyncio.Semaphore`CLI `--concurrency` 指定(沿用项目既有模式)
- 所有 VLM 调用经过 `GovernedLLMClient` 治理栈(CLAUDE.md §4.9
@@ -0,0 +1,70 @@
# Spec-1Agent 执行环境修复(解析容错 + 步级重试 + 摘要附实体)
- **日期**: 2026-07-11
- **状态**: 已批准(用户确认,步级重试退避改为 20s/40s)
- **依据**: `research-wiki/findings/2026-07-11-benchmark-failure-taxonomy.md` §四(T85 题)与 §二 M1view_node 摘要吞 entities
- **系列**: Spec-1/2/3 三件套之一,见 [2026-07-11-batch-tree-build-design.md]、[2026-07-11-question-gen-v2-design.md]
## 1. 问题
| # | 缺陷 | 证据 | 影响 |
|---|------|------|------|
| A1 | `_parse_response``core/agent/loop.py:265-300`)只接受 `action.args` 嵌套结构;deepseek 稳定输出变体(args 平铺 + ```json 围栏)解析三连拒 → 0 步阵亡 | 637-3、615-3 | 整题报废,且 json_repair 修不了结构错位 |
| A2 | LLM 调用异常(`loop.py:118-129`)直接整题终止,无步级重试 | 796-3SSL BAD_RECORD_MAC 废掉 13 步上下文) | 一次网络抖动损失全部已积累推理 |
| B | `summarize_node``app/search/summarizer.py`)两轮按题摘要后 entities/visible_text 不保证幸存 | 786-2、872-3、750-1Agent 站在证据节点上漏读实体) | M1 负证据幻觉的恶化因素 |
## 2. 设计
### A1 解析容错(结构归一化层)
在现有 `repair_json → json.loads → 校验` 之后、返回 None 之前,增加确定性归一化:
1. **围栏剥除**repair_json 前先剥 ```json / ``` 围栏(正则,幂等)。
2. **args 收拢**:若 `action` 为 dict 且含 `tool` 但缺 `args`,把 action 下除 `tool` 外的所有平铺键收拢为 `args` 嵌套。
3. 归一化成功 → 照常执行;失败 → 走现有 retry 追问路径(行为不变)。
纯函数实现,用 637-3/615-3 的真实坏输出作单测样本。
### A2 步级重试
`_call_llm` 异常处理改为步级重试循环:
| 参数 | 值 | 说明 |
|------|-----|------|
| 重试次数 | 2 | 第 3 次失败才整题终止(stop_reason=error 不变) |
| 退避 | 20s / 40s | 用户指定 |
| 可重试异常 | 显式类型元组,默认 `(TimeoutError, OSError)``ssl.SSLError`/`ConnectionError` 均为 OSError 子类,覆盖实测穿透案例);作为构造参数可注入扩展 | 遵循 CLAUDE.md P5(不做全 Exception 兜底);core/ 不依赖 openai SDK——API 类异常由 GovernedLLMClient 内部重试栈负责;`asyncio.CancelledError` 绝不吞;未知异常 fail-fast 整题终止(现状行为) |
| 上下文 | 原样保留 | messages 不回滚,重试即重发 |
| 遥测 | 失败尝试的 error 记录由 `GovernedLLMClient` 内部负责(已有);AgentLoop 侧只以 loguru 记录步级重试事件(不注入 TelemetryRecorder | AgentLoop 无遥测端口,不越层补写 |
### B 摘要附带实体原文
**dispatcher 侧**`SearchToolDispatcher._handle_view_node`)由确定性代码(非 LLM)在摘要结果末尾追加节点 card 的字段原文:
```
[实体] <entities + visible_entities 原文>
[画面文字] <visible_text 原文>
```
- **调用链改动**Codex 审查修正):`summarize_node` 只收 `raw_text: str`、无结构化 card 访问,且 `_node_full_text` 递归收值不保留字段名——因此在 `TreeEnvironment` 新增结构化字段提取方法(如 `node_entity_fields(node_id) -> dict[str, str]`),dispatcher 调用它并把区块拼接到 summarize_node 返回值之后;summarize_node 本体不改
- 摘要后追加 → LLM 无法吞掉;字段为空则不加对应区块
- 对 anchor / 非 anchor 两种模式一致生效
## 3. 不做什么(YAGNI
- 不改 GovernedLLMClient 的内部重试栈(已有四层治理)
- 不改判分协议、不动 prompt 版本化内容
- 不做异常分型重试策略(统一兜底已覆盖已知案例)
## 4. 验证
1. 单测:坏输出样本(围栏/平铺/两者叠加)归一化正确;空 content、缺 tool 仍拒
2. 单测:步级重试计数与退避(mock LLM 抛错)
3. 单测:summarize_node 追加区块(有/无实体字段两种节点)
4. 集成:抽 10 道 T2/T8 错题重跑(637-3、615-3、786-2、872-3、750-1 必含),对比修复前后
5. `make test` 全绿 + 覆盖率不降
## 5. 被否方案
- **prompt 层要求 LLM 修正输出格式**:治标,deepseek 变体是稳定行为,代码归一化是确定性修复
- **重试时区分异常类型**(仅网络类重试):已知案例全是穿透型异常,分型收益低且易漏
@@ -0,0 +1,93 @@
# Spec-2:建树批量并行入口
- **日期**: 2026-07-11
- **状态**: 已批准(用户确认两层参数推荐方案)
- **系列**: Spec-1/2/3 三件套之一,见 [2026-07-11-agent-runtime-fixes-design.md]、[2026-07-11-question-gen-v2-design.md]
## 1. 问题
TRM5 只有单视频建树(`app/tree/video_builder.py`,内部 Semaphore(16) 限 VLM/LLM 调用)与修复/迁移工具,**没有多视频批量构建入口**——批量建树只能视频间串行,非 API 阶段(ffmpeg 帧提取、图像编码、IO)与 API 阶段无法跨视频重叠,太慢。
## 2. 并发语义调研结论(项目惯例)
| 位置 | 并行单元 | 惯例 |
|------|---------|------|
| `app/harness/inference.py` | 题目 | 一个 `asyncio.Semaphore` + `gather`,任务级 |
| `tools/repair_trees.py` | 视频 | 视频级 Semaphore + gather + progress.json + 熔断阈值随并发缩放 |
| `app/tree/video_builder.py` | API 调用 | Semaphore 作为参数在协程链中显式传递 |
建树是唯一任务内部本身有大并发的场景 → **视频级与 API 级信号量必须分开**,否则 16×16=256 API 并发打爆端点与熔断器。
## 3. 设计
### 入口形态(遵循项目结构规范)
- `tools/build_trees.py`:独立工具(不被其他模块 import),复刻 `repair_trees.py` 的编排模式
- `scripts/build_trees.sh`:自包含实验记录,写死参数、零参数复现(GPU 卡号除外)
### 两层并发参数
```mermaid
graph LR
subgraph tools/build_trees.py
V[视频级 Semaphore<br/>video_concurrency=16] --> B1[VideoTreeBuilder 视频A]
V --> B2[VideoTreeBuilder 视频B]
V --> B3[...]
end
B1 --> API[全局共享 Semaphore<br/>api_concurrency=16]
B2 --> API
B3 --> API
API --> E[VLM/LLM 端点]
```
| 参数 | 默认 | 语义 |
|------|------|------|
| `--video-concurrency` | 16 | 同时在建的视频数;吞吐提升来自非 API 阶段跨视频重叠 |
| `--api-concurrency` | 16 | 全局在途 VLM/LLM 调用上限,跨所有视频共享**一个** Semaphore 实例——端点压力与今天单视频建树完全一致 |
熔断阈值按 repair_trees 惯例缩放:`max(cfg_threshold, api_concurrency * 2)`
**配置归属(D7 规则,Codex 审查补充)**:
| 参数 | 归属 | 理由 |
|------|------|------|
| `api_concurrency` | 工程配置 `.env``TREE_BUILD_API_CONCURRENCY=16`) | 端点保护参数,少变、随部署环境定 |
| `video_concurrency` | sh 脚本写死(默认 16)+ CLI 单次覆盖 | 单机吞吐参数,随硬件观测调整,不进科研 YAML(不会被实验扫动) |
### builder 改动(唯一的存量修改)
两处(Codex 审查修正后):
1. **公开异步入口**:现有 `build()` 是同步壳(内部 `asyncio.run(self._build_async(...))`),在异步批量编排里调用会触发"事件循环嵌套"运行时错误。将 `_build_async` 提升为公开 `build_async()` 供批量工具调用;同步 `build()` 保留原样(内部改为调 `build_async`),单视频调用方零影响。
2. **Semaphore 注入**:内部 Semaphore 改为构造器可选参数(不传则自建,行为零变化)。builder 内部协程链本就显式传递 Semaphore,改动面极小。
### 断点续跑
- 视频级:`progress.json`(复用 repair_trees 的 `save_progress` 模式);tree.json 存在且完整性校验通过的视频自动跳过
- 视频内:现有段级恢复(核心算法 #3)不动
### 输入输出
- 输入:`--videos-dir`(视频文件 + 可选同名 SRT
- 输出:`store/videos/<video_id>/tree.json`;帧持久化沿用现有 cache 机制
## 4. 风险与观测
- 16 路并行 ffmpeg/cv2 解码可能压满 CPU/磁盘 → 实现时输出速率日志(视频/分钟,复刻 repair_trees),观测后再调 video_concurrency
- 日志遵循"禁止缓存、立即输出"CLAUDE.md §2.1
## 5. 不做什么(YAGNI
- 不做分布式/多机;不做动态并发自适应
- 不改单视频建树算法(核心算法 #1/#2/#3 保真,仅信号量注入)
## 6. 验证
1. 单测:Semaphore 注入后单视频行为不变(默认自建路径)
2. 集成:3-4 个短视频小批量构建,验证跨视频并行、progress 跳过、全局 API 信号量生效(遥测里在途调用数 ≤ api_concurrency
3. 中断-恢复测试:构建中 Ctrl+C 后重跑,已完成视频跳过、未完成视频从段级断点续跑
## 7. 被否方案
- **单一视频级 Semaphorerepair_trees 原样照搬)**:建树内部并发大,总 API 并发 = 视频数 × 内部并发,不可控
- **仅共享全局 API Semaphore、视频数不限**:任意多视频同时提帧会压垮磁盘 IO/CPU
@@ -0,0 +1,142 @@
# Spec-3:出题管线 v2(失败机理靶向 + 逐题质量门)
- **日期**: 2026-07-11
- **状态**: 已批准(用户确认:双标签体系、轻量档全量 + 重量档 15% 抽检)
- **依据**: 三轮分析——`findings/2026-07-11-question-gen-calibration-analysis.md`(生成题缺陷与根因)、`findings/2026-07-11-benchmark-failure-taxonomy.md`(242 错题机理分类与 11 种题型规格)
- **系列**: Spec-1/2/3 三件套之一;Spec-1 修好的推理环境是本 spec 重量抽检的前置
## 1. 目标重定义
出题目标从"难度与 benchmark 一致"改为:**覆盖已证实的失败机理(M1-M5)+ 逐题质量门**。
| 决策 | 内容 |
|------|------|
| calibrate 降级 | 仅作观测性报表,不再是验收门 |
| 评分协议不变 | 标准四选一按字母判分;harness/推理侧零改动。题型规格中"附证据节点 id"等要求降级为**构造时验证材料**,存题目元数据供 diagnose 分析 |
| 双标签体系 | 主标签 `task_type`Video-MME 12 类,进化循环 mini-batch/gate/skills/diagnose 零改动)+ 附加字段 `skill_target`(M1-M5/题族,仅用于出题配比、质检、覆盖率统计) |
| 质检两档 | 轻量四门全量逐题;重量档(盲 Agent 全树试答)15% 抽检 + 难度标签 |
## 2. 流水线架构
重构 `app/question_gen/synthesizer.py` + `tools/generate_questions.py` generate 子命令:
```mermaid
graph LR
S[采样器<br/>按题族选素材] --> G[生成器<br/>题族 prompt 模板<br/>双标签输出]
G --> P[确定性后处理<br/>shuffle+答案重映射<br/>指代黑名单<br/>verbatim 检测]
P --> Q{轻量四门 全量}
Q -->|拒| R[带拒因重出<br/>同 slot 最多 3 次]
R --> G
Q -->|过| H[重量抽检 15%<br/>盲 Agent 试答→difficulty_steps]
H --> W[入库 generated-v2]
```
### 2.1 五题族(11 种题型的落地归并)
每族一个采样器 + 一个 prompt 模板,题型(shape)作为模板参数:
| 题族 | 覆盖题型 | 采样约束 | 治什么 | 默认配比 |
|------|---------|---------|--------|---------|
| 检索族 | 证据埋深/反"不存在"、ASR 实体对齐、属性辨析 | 证据取自 entities/visible_entities/visible_text/长字幕单句;配镜像题(真不存在) | M1 负证据幻觉(最大杠杆) | 30% |
| 推理族 | 转述还原、NOTA 校准对 | 从字幕单句做一步语义变换(蕴含/虚拟语气/序数映射);NOTA 成对生成(半正解半陷阱) | M2 字面匹配 | 25% |
| 枚举族 | 多实例锚定、序数枚举、覆盖率权重 | **source_nodes ≥ 3 跨 L1**;同类事件 ≥2 次;首个表面匹配必须是错的 | M3 锚定/盘点 | 20% |
| 视觉族 | 状态演化多帧、实例消歧数字 | 证据仅在帧内、不在任何 card 文本;多时刻真实读数做干扰项 | M4 视觉验证 | 15% |
| 空间族 | 参照系空间 | spatial_layout 字段 + 时间锚;摄像机/被摄者双参照系 | T5 空间 | 10% |
**题族 × task_type 兼容矩阵**(Codex 审查补充;✓=合法组合,采样器按此分配双标签,保证 12 类各 20 题的硬约束可满足):
| 题族 | 合法 task_type |
|------|---------------|
| 检索族 | Object Recognition、Object Reasoning、Action Recognition、Attribute Perception、OCR Problems |
| 推理族 | Action Reasoning、Object Reasoning、Information Synopsis |
| 枚举族 | Counting Problem、Temporal Reasoning、Temporal Perception、Information Synopsis |
| 视觉族 | Attribute Perception、Counting Problem、OCR Problems、Action Recognition |
| 空间族 | Spatial Perception、Spatial Reasoning |
每个 task_type 至少落入一个题族;Spatial 两类仅由空间族供给。**"不出 T1 类素材题"是素材形态禁用**(不采瞬时动作/记分牌瞬时数值/无对白因果类素材),不删除任何 Video-MME task_type。
### 2.2 确定性后处理(零 LLM 成本,通用硬约束)
1. **选项 shuffle** + 答案字母重映射(修复 v1 答案 57% 在 A 的偏斜)
2. **指代黑名单**(正则):禁 this segment / this frame / this clip / the current frame / the scene / frame summary 等;题干必须含 L1 time_range 时间锚("between 10:52 and 21:44"式)或全局限定语("in the entire video"
3. **verbatim 检测**:题干+正确项 vs 源节点文本的 n-gram 重合门(检索族豁免正确项检测——其证据本来就在文本,见 2.3)
4. **出题禁区**:不出 T1 类素材题(瞬时动作/记分牌时序/无对白因果);不复刻 T7 噪声模式(选项重复、口径含糊的计数边界)
### 2.3 轻量四门(全量逐题,约 4 次单轮 LLM 调用/题)
| 门 | 判定 | 杀什么 |
|----|------|--------|
| 键验证 | 拿出题依据(source_nodes 原文)判标注答案是否成立 | 标注幻觉(v1 CP 组 2 例无出处) |
| 盲答测试 | 不给任何视频信息裸答,答对即拒 | 常识可解题、干扰项秒排题 |
| 多真测试 | 拿全树素材判是否 >1 选项可为真 | 歧义多解题(v1 SR 组 5/8 |
| 泄漏测试 | **按题族条件化**(见下) | 一跳检索捷径 |
**泄漏门的题族捷径画像**("信息不对称化"的落地):
| 题族 | 捷径画像(该捷径必须失败才放行) |
|------|--------------------------------|
| 检索族 | top-5 语义搜索片段裸答必须失败(全树 card 文本裸答**允许**成功——考的是检索深度) |
| 视觉族 | 全树 card 文本裸答必须失败(逼 observe_frame |
| 推理/枚举/空间族 | 锚点节点文本裸答必须失败(需变换/跨节点) |
### 2.4 重出循环与重量抽检
- 拒题 → 拒因回填到生成 prompt → 同 slot 重出,最多 3 次;3 次仍拒则该 slot 换素材重采样
- 通过四门的题按 15% 抽样跑盲 Agent 全树试答(复用 Spec-1 修复后的推理管线):验证真实可答性,产出 `difficulty_steps` 难度标签
## 3. 数据与版本
| 项 | 决策 |
|----|------|
| 旧 240 题 | 原地保留 `store/questions/generated/`infer_gen240 run 引用它,保可复现) |
| 新题集 | `store/questions/generated-v2/`,布局与 v1 一致:`{video_id}.json` 平铺(无子目录)。CLI 示例:生成 `python tools/generate_questions.py generate --output-dir store/questions/generated-v2 ...`;推理 `python main.py --mode infer --questions generated-v2 --run-id gen240v2` |
| 题目元数据新增 | `skill_target``source_nodes`(已有)、`gate_report`(四门判定)、`difficulty_steps`(抽检题) |
**元数据承载方式(Codex 审查修正)**:现有 `GeneratedQuestion``core/types.py`)为固定 8 字段,`load_benchmark` 丢弃未知 JSON 字段,池快照只存固定字段。约定:
| 字段 | 承载 | 进训练链路 |
|------|------|-----------|
| `skill_target``difficulty_steps` | 扩展 `GeneratedQuestion` 为可选字段(默认 None,benchmark 题不受影响);loader/pools 同步保留 | 是(diagnose 可按 skill_target 聚合报表) |
| `gate_report` | 只存在于题目 JSON(溯源用)与生成期 SQLiteloader **不加载**(体积大且训练不需要) | 否 |
| 规模 | 默认 240 题;**task_type 均匀(20/类)为硬约束**(进化循环分层需要),**族配比为软目标(±5%)**——采样器按"族 × task_type 兼容矩阵"(如枚举族→Counting/Temporal 类)分配每题的双标签;YAML 可扫 |
## 4. 配置归属(D7 规则)
- **科研配置**per-experiment YAML):族配比、门阈值(n-gram 窗口、多真判定温度)、抽检率、重出上限、规模
- **工程配置**`.env`):LLM/VLM 端点、超时、熔断——沿用现有
## 5. 运行时数据与治理
- 每题的门判定记录(哪门拒、拒因文本、重出轮次、最终状态)落 SQLite。表结构在设计批准后走 `structured-logging` skill 单独设计(本 spec 只约定:记录必须逐题可追溯、可聚合出各门拦截率报表)
- **质量门 LLM 调用治理(Codex 审查补充)**:轻量四门与生成器的全部 LLM/VLM 调用必须经 `GovernedLLMClient`/`GovernedVLMClient` + `TelemetryRecorder`CLAUDE.md §4.8/§4.9),严禁裸调 SDK;门执行器通过依赖注入接收客户端实例,session_id 用生成批次 id、parent_call_id 链接到题目生成调用
## 5.5 核心接口概要(Codex 审查补充,完整签名留给 plan)
| 类型/函数 | 职责 |
|----------|------|
| `QuestionFamilySpec` | 题族声明:采样约束、prompt 模板、泄漏门捷径画像、合法 task_type 集合 |
| `CandidateQuestion` | 生成器输出:题面 + 双标签 + source_nodes + 构造验证材料(未过门) |
| `GateReport` | 四门判定结果:每门 pass/reject + 拒因文本 |
| `run_gates(candidate, deps) -> GateReport` | 门执行器(依赖注入 LLM/树环境) |
| `generate_one` 迁移 | v1 签名(只收 task_type)废弃,v2 收 `(family_spec, task_type, slot_seed)` 返回 `CandidateQuestion` |
## 6. 不做什么(YAGNI
- 不改判分协议、不改 harness/推理侧、不改 mini-batch/gate/diagnose 的 task_type 分组
- 不做对抗式加难迭代(留到自进化循环跑通后,须固定出题对手版本)
- 不做 T1 树增强(用户已确认本次不做)
- 不引入树外信息源(原始视频重新抽帧出题)——视觉族用现有帧缓存即可
## 7. 验证
1. Smoke12 个视频 × 每族 2-3 题,验证四门拦截率、重出收敛(≤3 轮)、双标签与元数据完整性
2. 全量:生成 240 题 → `bash scripts/infer_generated.sh`(指向 generated-v2)→ calibrate 报表观测
3. 回归断言:新题集答案位置分布均匀(卡方检验);指代黑名单零命中;泄漏门画像全部通过
4. 单测:后处理层纯函数(shuffle 重映射、黑名单、n-gram 门)
## 8. 被否方案
- **纯 prompt 补丁**:第二轮设计层分析证明 7 类缺陷中 5 类会换形复发(信息闭环是结构问题)
- **失败机理作为主标签**:进化循环全链改造,收益不明确;先以元数据形式观察其价值
- **重量档全量**:240 题 ≈ 一次完整推理实验(4-5 小时),轻量四门已拦截三轮发现的全部缺陷类型
- **难度一致性作为验收门**:三轮分析证明分数一致性是坏代理指标(假难/假易双向失真)

Some files were not shown because too many files have changed in this diff Show More