# Per-Category Pool Strategy 设计 > **状态**: 已批准 > **日期**: 2026-07-12 > **关联**: [app-harness-design](2026-07-07-app-harness-design.md)、[question-gen-v2-design](2026-07-11-question-gen-v2-design.md) ## 1. 动机 v2 题目生成管线为 12 个 task_type 各生成 30 题(共 360 题)。需要支持: | 需求 | 说明 | |------|------| | 按类增量 baseline | 类别生成完成后立即可跑 baseline inference,无需等全量完成 | | Per-category 分层划分 | 每类 30 题按 correctness 2:1 分为 20 train / 10 val | | 按类训练 | `--task-types` 控制训练作用域,快速验证单类别效果 | | 外部 test 池 | test 用 Video-MME 900 道真题中同类题目,不从生成题中抽取 | | 全局训练兼容 | 全类别联合训练时 system/tool 慢进化看全量 val 信号 | ## 2. 方案选型 | 方案 | 描述 | 取舍 | |------|------|------| | A. 最小改动 | 在 `build_pools` 内加 if 分支 | 改动小但两条路径耦合 | | B. 替换为纯 per-category | 删除全局模式 | 简洁但丢失 Video-MME 900 题训练能力 | | **C. Pool 工厂模式** ✓ | `PoolStrategy` Protocol + 具体策略 | 符合 Clean Architecture DIP/OCP;策略可独立测试替换 | **选择 C**:池分割是真正易变的接缝(已有两种策略,未来可扩展),满足 CLAUDE.md 抽象引入条件。 ## 3. 核心抽象 ### 3.1 PoolStrategy Protocol ```python # app/ports.py 新增(应用层端口,非 core 层——因为返回值 Pools 定义在 app/harness/) class PoolStrategy(Protocol): """池构建策略端口。""" def build( self, questions: list[GeneratedQuestion], correctness: dict[str, bool], config: PoolConfig, ) -> 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]]]: """增量构建新类别的 train/val 划分。 返回 {task_type: {"train": [qid, ...], "val": [qid, ...]}}。 仅 PerCategoryPoolStrategy 有实质实现;GlobalPoolStrategy 不支持增量,调用时抛出 NotImplementedError。 """ ... ``` > **层级决策**:`PoolStrategy` 放在 `app/ports.py` 而非 `core/protocols.py`,因为返回类型 `Pools` 定义在 `app/harness/pools.py`。若放入 core 会导致 core → app 的逆向依赖,违反 Clean Architecture 四层分层。 > > **注**:`PoolConfig` 的两组字段(Global 系列 / PerCategory 系列)由两个策略各取所需,未使用的字段被忽略。这避免了为每个策略定义专属 config 子类的过度抽象。 ### 3.2 PoolConfig ```python # core/types.py 新增 @dataclass(frozen=True) class PoolConfig: task_types: tuple[str, ...] | None # None = 全部类别 seed: int # 随机种子 baseline_run_id: str # 基线 run 标识(用于读 correctness、写入 pools 指纹) # GlobalStrategy 使用 diag_size: int diag_correct_ratio: float val_size: int val_correct_ratio: float test_size: int eval_min_per_class: int # PerCategoryStrategy 使用 train_ratio: float # train/(train+val),默认 2/3 test_questions_dir: Path | None # 外部 test 题源路径(与 RunConfig.test_questions 对应) ``` ### 3.3 两个具体策略 **GlobalPoolStrategy**:封装现有 `build_pools` 逻辑,test → val → diag 全局三分。 **PerCategoryPoolStrategy**: ``` 对每个 task_type (共 N 题,目标 train=20, val=10): correct 题 (Nc 个) → shuffle(seed) train_correct = floor(Nc * 20/N) # 按总量 20/10 比例分配 val_correct = Nc - train_correct wrong 题 (Nw 个) → shuffle(seed) train_wrong = 20 - train_correct # 确保 train 总量恰好 20 val_wrong = Nw - train_wrong assert train_correct + train_wrong == 20 assert val_correct + val_wrong == 10 diagnosis = 所有选中类别的 train 合并 validation = 所有选中类别的 val 合并 test = load_benchmark(test_questions_dir) 按 task_types 过滤 ``` **边界场景处理**: | 场景 | 行为 | |------|------| | 某类别全部 correct(0 wrong) | 退化为非分层 random 20/10,记录 WARNING | | 某类别全部 wrong(0 correct) | 同上,退化为非分层 random 20/10 | | 某类别 correctness 不完整(部分 qid 无 baseline) | fail-fast,列出缺失 qid | | Video-MME 中某 task_type 无题 | test 池该类别为空,记录 WARNING | ## 4. RunConfig 变更 | 新增字段 | 类型 | 默认值 | 归属 | |---------|------|--------|------| | `task_types` | `tuple[str, ...] \| None` | `None` | CLI 临时覆盖 | | `pool_split_mode` | `Literal["global", "per_category"]` | `"global"` | 科研 YAML | | `train_ratio` | `float` | `0.667` | 科研 YAML | | `test_questions` | `str` | `"benchmarks/Video-MME"` | 科研 YAML | `default.yaml` 新增: ```yaml harness: pool_split_mode: global train_ratio: 0.667 test_questions: "benchmarks/Video-MME" ``` ## 5. 增量 Baseline Inference ### 5.1 流程 ```bash # 类别 A、B 完成 → 跑 baseline python main.py harness --mode infer \ --run-id baseline_v2 --questions generated-v2-360 \ --task-types "Object Recognition" "Scene Understanding" # 类别 C 完成 → 追加到同一 run_id python main.py harness --mode infer \ --run-id baseline_v2 --questions generated-v2-360 \ --task-types "Action Reasoning" # promote 为 seed python main.py harness --mode promote \ --run-id baseline_v2 --seed baseline_v2_seed # 按类训练 python main.py harness --mode train --fresh \ --seed baseline_v2_seed --run-id train_obj_recog_01 \ --task-types "Object Recognition" --pool-split-mode per_category ``` ### 5.2 增量写入规则 | 表 | 唯一键 | 同 run_id 多次 infer 行为 | |---|--------|-------------------------| | `_runs` | `run_id` | upsert:更新 `updated_at`,保留原始 `created_at` | | `predictions` | `(run_id, question_id)` | INSERT;主键冲突报错(防重复推理) | | `traces` | `trace_id` | 正常 INSERT(UUID 天然唯一) | **重跑同类别**:若需重新推理某类别,先 DELETE 该类别的 predictions(`WHERE run_id=? AND question_id IN (?)`),再重新 infer。 **promote 读取规则**:`promote_to_seed` 读取指定 `run_id` 下全部 predictions,不区分追加批次。promote 前应确保所有目标类别均已完成 baseline。 ## 6. 池冻结格式 ### 6.1 per_category 格式 ```json { "split_mode": "per_category", "train_ratio": 0.667, "baseline_run_id": "baseline_v2", "categories": { "Object Recognition": { "train": ["qid_1", "qid_2", "..."], "val": ["qid_21", "..."] } }, "test_source": "benchmarks/Video-MME", "questions": [...] } ``` ### 6.2 增量更新 ```python def build_or_load_pools( config: RunConfig, strategy: PoolStrategy, db_path: Path, ) -> Pools: """构建或加载三池。 参数: config: 运行配置(含 task_types, pool_split_mode, baseline_run_id 等)。 strategy: 池构建策略实例。 db_path: harness.db 路径(用于读取 baseline correctness)。 流程: 1. 若 pools.json 存在 → 加载 a. 若 config.task_types 中有类别不在已冻结的 categories → 增量构建并追加 b. 若 pools.json 的 seed/train_ratio/baseline_run_id 与当前 config 不一致 → 报错 2. 若不存在 → 从 DB 加载 correctness → strategy.build() → 冻结 3. 按 config.task_types 从 categories 中组装 Pools 返回 """ ``` **冻结一致性校验**:加载已有 pools.json 时,若 `split_mode`、`seed`、`train_ratio`、`baseline_run_id` 与当前 config 不一致,直接报错并提示删除 pools.json 重建。已冻结类别的划分不可变,新类别可追加。 ### 6.3 向后兼容 旧格式 `pools.json`(无 `split_mode` 字段)自动识别为 global 模式。 ## 7. task_types 作用域贯穿 策略在池构建阶段完成所有过滤,下游无需感知 task_types: | 阶段 | 行为 | |------|------| | 池构建 | strategy 只处理指定类别 | | mini-batch | 消费 scoped 后的 diagnosis 池,无改动 | | per-skill gate | 池中只有指定类别的题,自然 scoped | | 慢进化 | 用 scoped 后的 val 池评估,照常进化 | | held-out eval | test 池已是 Video-MME 同类题 | ## 8. main.py train 接线 ```python # composition root 伪代码 elif config.mode == "train": strategy = (PerCategoryPoolStrategy() if config.pool_split_mode == "per_category" else GlobalPoolStrategy()) pools = build_or_load_pools(config, strategy) await runner.train(pools) ``` ## 9. 文件改动地图 | 文件 | 改动类型 | 内容 | |------|---------|------| | `app/ports.py` | 新增 | `PoolStrategy` Protocol(应用层端口,避免 core → app 逆向依赖) | | `core/types.py` | 新增 | `PoolConfig` dataclass | | `app/harness/pools.py` | 重构 | `GlobalPoolStrategy` + `PerCategoryPoolStrategy` + 增量逻辑 | | `app/harness/config.py` | 修改 | RunConfig 新增 4 字段 + 校验;`pool_split_mode` 用 Literal 类型 | | `config/default.yaml` | 修改 | 3 个新配置项 | | `main.py` | 修改 | `task_types` 纳入 `cli_overrides` + RunConfig;train 接线;strategy 组装 | | `app/harness/log.py` | 微调 | `_runs` 表 `INSERT OR IGNORE` 改为 `ON CONFLICT DO UPDATE`(增量 infer 更新时间戳) | | `tests/unit/test_pools.py` | 新增 | 两种策略单元测试 | | `tests/integration/test_pool_strategy.py` | 新增 | 端到端集成测试 | ### 不改动 - `app/harness/batching.py` — 消费 scoped 池,无需感知策略 - `core/evolution/` — 进化引擎与池来源解耦 - `app/harness/inference.py` — 推理逻辑不变 - `app/question_gen/` — 题目生成与池构建解耦