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。
This commit is contained in:
+146
-63
@@ -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_id,AR 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(当前 = 2,unit 键)。旧版无
|
||||
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
|
||||
|
||||
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,66 @@ def skill_hash(content: str) -> str:
|
||||
|
||||
@dataclass
|
||||
class LadderEntry:
|
||||
"""阶梯单元:题目与其估计答对率。
|
||||
"""阶梯单元:题目单元与其估计答对率。
|
||||
|
||||
字段:
|
||||
question_id: 题目唯一标识。
|
||||
unit_id: 单元唯一标识(single 等于 question_id,AR pair 等于共享 pair_id)。
|
||||
p_hat: 估计答对率。冷启动为 Beta(1,1) 平滑的单次观测后验均值
|
||||
(错=1/3、对=2/3),此后经 gamma-EMA 更新。
|
||||
"""
|
||||
|
||||
question_id: str
|
||||
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(
|
||||
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_correct 折叠(任一成员错 → 单元错)。
|
||||
probe_quota: 从错 unit 中随机抽出插到梯尾的探针比例(防"解锁新能力"盲区)。
|
||||
seed: 洗牌种子,保证确定性重建。
|
||||
|
||||
返回:
|
||||
排序后的 LadderEntry 列表(p_hat 用 Beta(1,1) 平滑:错=1/3、对=2/3,
|
||||
与 warm 阶段 gamma-EMA / 信息量排序自然衔接)。
|
||||
排序后的 LadderEntry 列表(键=unit_id;p_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_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[GeneratedQuestion] = []
|
||||
interleaved: list[QuestionUnit] = []
|
||||
wi, ri = 0, 0
|
||||
while wi < len(wrong_main) or ri < len(right):
|
||||
for _ in range(2):
|
||||
@@ -97,10 +124,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_correct(u, correctness) 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 +162,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 +190,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_hat:p_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 +247,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 +323,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 变化、缓存自然 miss;prompts 版本变化同理。
|
||||
都使 skill_hash 变化、缓存自然 miss;prompts 版本变化同理。unit_id 维度
|
||||
使 single 题以自身 question_id、AR pair 以共享 pair_id 寻址,缓存单元级
|
||||
对错(pair 双向 AND 折叠后一个布尔)。
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
@@ -294,32 +377,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: 单元 id(single=question_id,AR 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 +410,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")
|
||||
|
||||
Reference in New Issue
Block a user