# 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/` — 题目生成与池构建解耦
