Files
Video-Tree-TRM5/app/harness/gate_ladder.py
T
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

419 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CE-Gate 信息量阶梯与基线缓存(unit 粒度,核心算法保真 #5)。
阶梯(每题型一条):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_" 过滤观测源),本迁移不改此过滤。
持久化门控:gate_pools.json 带 schema_version(当前 = 2unit 键)。旧版无
schema_versionv1、qid 键)加载时**直接报错**,拒绝静默混用 qid/unit 键。
BaselineCache:基线侧单元级对错缓存,键 = (task_type, skill_hash,
prompts_version, unit_id) 内容寻址、无显式失效。JSON 持久化到 workspace
供 resume 后合法复用已冻结阶梯上的新鲜 draw。
"""
from __future__ import annotations
import hashlib
import json
import os
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING
from loguru import logger
from app.harness.question_units import build_units
if TYPE_CHECKING:
from pathlib import Path
from core.types import GeneratedQuestion, QuestionUnit
# gate_pools.json 结构版本。v1(隐式、无此字段)为逐题 qid 键的存量格式;
# v2 起改为 unit_id 键。load 时严格校验,不匹配即报错(不静默迁移/混用)。
SCHEMA_VERSION = 2
def skill_hash(content: str) -> str:
"""对 skill 正文取 sha1 摘要,作缓存键的内容维度。
参数:
content: skill 文件全文(基线侧为解析后生效文件的正文)。
返回:
sha1 十六进制摘要。
"""
return hashlib.sha1(content.encode("utf-8")).hexdigest()
@dataclass
class LadderEntry:
"""阶梯单元:题目单元与其估计答对率。
字段:
unit_id: 单元唯一标识(single 等于 question_idAR pair 等于共享 pair_id)。
p_hat: 估计答对率。冷启动为 Beta(1,1) 平滑的单次观测后验均值
(错=1/3、对=2/3),此后经 gamma-EMA 更新。
"""
unit_id: str
p_hat: float
def _unit_correct(unit: QuestionUnit, correctness: dict[str, bool]) -> bool:
"""单元级正确性:AR pair 双向 AND,single 即单题;单元错 = 任一成员错。
参数:
unit: 目标单元。
correctness: question_id -> 是否答对(缺项按未答对处理,与迁移前
correctness.get(qid, False) 的默认语义一致,不改判定)。
返回:
单元内所有成员均答对时 True,否则 False。
"""
return all(correctness.get(q.question_id, False) for q in unit.questions)
def build_cold_entries(
units: list[QuestionUnit],
correctness: dict[str, bool],
probe_quota: float,
seed: int,
) -> list[LadderEntry]:
"""冷启动排序(unit 粒度):错 unit 高优先 2:1 交错 + 全错 unit 探针插尾。
参数:
units: 该题型的全部候选单元(已排除 test 池;AR pair 已折叠成单元)。
correctness: question_id -> 种子基线是否答对(900 题全量逐题对错)。
单元级对错由 _unit_correct 折叠(任一成员错 → 单元错)。
probe_quota: 从错 unit 中随机抽出插到梯尾的探针比例(防"解锁新能力"盲区)。
seed: 洗牌种子,保证确定性重建。
返回:
排序后的 LadderEntry 列表(键=unit_idp_hat 用 Beta(1,1) 平滑:错=1/3、
对=2/3,与 warm 阶段 gamma-EMA / 信息量排序自然衔接)。
关键实现细节:
与逐题版**同公式、同比例、同顺序**,仅把调度粒度从题换成单元:错 unit、
对 unit 各自固定种子洗牌 -> 按 probe_quota 从错 unit 抽探针 -> 剩余按
错错对 2:1 交错(一方耗尽后顺排另一方)-> 探针追加尾部。
"""
rng = random.Random(seed)
wrong = [u for u in units if not _unit_correct(u, correctness)]
right = [u for u in units if _unit_correct(u, correctness)]
rng.shuffle(wrong)
rng.shuffle(right)
n_probe = int(len(wrong) * probe_quota)
probes, wrong_main = wrong[:n_probe], wrong[n_probe:]
interleaved: list[QuestionUnit] = []
wi, ri = 0, 0
while wi < len(wrong_main) or ri < len(right):
for _ in range(2):
if wi < len(wrong_main):
interleaved.append(wrong_main[wi])
wi += 1
if ri < len(right):
interleaved.append(right[ri])
ri += 1
interleaved.extend(probes)
def _p0(u: QuestionUnit) -> float:
return 2 / 3 if _unit_correct(u, correctness) else 1 / 3
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]:
"""warm 排序:剔 p_hat 不在 [p_low, p_high] 的零信息题,按信息量 p_hat(1-p_hat) 降序。
参数:
entries: 待排序的阶梯单元。
p_low / p_high: p_hat 保留区间。
返回:
过滤并排序后的新列表(稳定排序,同信息量保持原相对序)。
"""
kept = [e for e in entries if p_low <= e.p_hat <= p_high]
return sorted(kept, key=lambda e: e.p_hat * (1 - e.p_hat), reverse=True)
@dataclass
class GatePools:
"""全部题型的阶梯容器,含构建种子与数据指纹(确定性重建凭据)。
字段:
entries: task_type -> 冷启动序 LadderEntry 列表(warm 排序在取用时做,
保持存储序稳定、避免每次更新重写全表顺序)。
seed: 冷启动洗牌种子。
fingerprint: 构建输入指纹(基线 run_id + 题集 hash 等),resume 校验用。
"""
entries: dict[str, list[LadderEntry]]
seed: int
fingerprint: str
def ladder_for(
self,
task_type: str,
exclude_units: set[str],
p_low: float,
p_high: float,
cold: bool,
) -> list[str]:
"""取该题型的 gate 出题序(unit_id 列表),排除本 step 进化案例包所在单元。
参数:
task_type: 目标题型。
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 信息量排序。
返回:
排除后的有序 unit_id 列表。
异常:
ValueError: 该题型无阶梯(冷启动构建缺失),或该题型阶梯为空。
"""
if task_type not in self.entries:
raise ValueError(f"task_type={task_type} 无阶梯,冷启动构建缺失该题型")
pool = self.entries[task_type]
if not pool:
raise ValueError(f"task_type={task_type} 阶梯为空,无可出题目")
ordered = pool if cold else order_ladder(pool, p_low, p_high)
return [e.unit_id for e in ordered if e.unit_id not in exclude_units]
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)。
参数:
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:
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),落 schema_version + unit_id 键。
参数:
path: 目标 JSON 路径。
"""
payload = {
"schema_version": SCHEMA_VERSION,
"seed": self.seed,
"fingerprint": self.fingerprint,
"entries": {
t: [{"unit_id": e.unit_id, "p_hat": e.p_hat} for e in es]
for t, es in self.entries.items()
},
}
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
@classmethod
def load(cls, path: Path) -> GatePools:
"""从 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["unit_id"], x["p_hat"]) for x in es]
for t, es in d["entries"].items()
},
seed=d["seed"],
fingerprint=d["fingerprint"],
)
def build_or_load_gate_pools(
workspace_dir: Path,
questions: list[GeneratedQuestion],
test_qids: set[str],
baseline_correctness: dict[str, bool],
task_types: list[str],
probe_quota: float,
seed: int,
baseline_run_id: str,
) -> GatePools:
"""gate 阶梯获取入口:gate_pools.json 存在且指纹一致则加载,否则冷启动构建。
参数:
workspace_dir: workspace 根目录(gate_pools.json 落其下)。
questions: benchmark 全量题(900 题)。
test_qids: held-out test 池题目 id(阶梯题源必须排除)。
baseline_correctness: 种子基线 900 题全量对错(从基线 run 的 db 读)。
task_types: 参与进化的题型列表。
probe_quota: 冷启动探针比例。
seed: 冷启动洗牌种子。
baseline_run_id: 指纹成分。
返回:
GatePools。
关键实现细节:
指纹 = sha1(baseline_run_id|全 qid|seed|probe_quota|task_types|test_qids)。
指纹不一致(题集/基线/参数变了)直接报错——FRESH 语义下不该发生,
防御性拒绝而非静默重建。
"""
joined = ",".join(sorted(q.question_id for q in questions))
fp_src = (
f"{baseline_run_id}|{joined}|{seed}|{probe_quota}"
f"|{','.join(sorted(task_types))}|{','.join(sorted(test_qids))}"
)
fingerprint = hashlib.sha1(fp_src.encode()).hexdigest()
path = workspace_dir / "gate_pools.json"
if path.exists():
pools = GatePools.load(path)
if pools.fingerprint != fingerprint:
raise RuntimeError(
f"gate_pools.json 指纹不一致(题集或基线变更),拒绝静默重建: {path}"
)
return pools
entries: dict[str, list[LadderEntry]] = {}
for t in task_types:
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
def _task_units_excluding_test(
questions: list[GeneratedQuestion], task_type: str, test_qids: set[str]
) -> list[QuestionUnit]:
"""取某题型的非 test 候选单元:先按 unit 折叠,再整体排除含 test 成员的单元。
先折叠后排除保证 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 版本变化同理。unit_id 维度
使 single 题以自身 question_id、AR pair 以共享 pair_id 寻址,缓存单元级
对错(pair 双向 AND 折叠后一个布尔)。
"""
def __init__(self, path: Path) -> None:
"""加载或初始化缓存文件。
参数:
path: 缓存 JSON 路径(workspace/baseline_cache.json)。
"""
self._path = path
self._store: dict[str, bool] = {}
if path.exists():
self._store = json.loads(path.read_text(encoding="utf-8"))
@staticmethod
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, unit_id: str) -> bool | None:
"""读缓存;未命中返回 None。
参数:
task_type: 题型。
s_hash: 基线侧生效 skill 文件的内容哈希。
prompts_version: 当前 prompts 版本。
unit_id: 单元 idsingle=question_idAR pair=pair_id)。
返回:
缓存的单元级对错;未命中 None。
"""
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, unit_id: str, correct: bool
) -> None:
"""写缓存并落盘(原子写,gate 频度低、全量重写成本可忽略)。
参数:
task_type / s_hash / prompts_version / unit_id: 缓存键四维。
correct: 基线侧该单元对错(AR pair 双向 AND 折叠后一个布尔)。
关键实现细节:
先盘后存:新条目先原子落盘(tmp 写 + os.replace)成功后才更新
内存,磁盘写失败时内存与磁盘一致(均无新条目),无分裂窗口。
"""
updated = {
**self._store,
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")
os.replace(tmp, self._path)
self._store = updated