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)。
This commit is contained in:
2026-07-15 07:31:03 -04:00
parent dee6bf4896
commit 4b6d1d8a50
6 changed files with 650 additions and 127 deletions
+135 -89
View File
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from loguru import logger
from app.harness.gate_ladder import BaselineCache, skill_hash
from app.harness.question_units import build_units, flatten_units, unit_correctness_view
from core.evolution import (
GateParams,
GateVerdict,
@@ -37,7 +38,7 @@ from core.evolution import (
if TYPE_CHECKING:
from app.harness.inference import InferenceResult
from app.harness.log import HarnessLog
from core.types import GeneratedQuestion
from core.types import GeneratedQuestion, QuestionUnit
# gate_decision 的 decision → ValidationOutcome.stop_reason 映射
@@ -95,10 +96,12 @@ class InferenceRunConfig:
@dataclass
class ValidationOutcome:
"""CE-Gate 局部验证结果:三态动作 + e-process 证据 + 已观测题逐题对错。
"""CE-Gate 局部验证结果:三态动作 + e-process 证据(单元口径)+ 逐题溯源对错。
correctness 二轨语义:candidate_correctness 只含已观测题(早停后是
阶梯前缀子集);accept 时由 runner 按题粒度增量合并进 state.correctness。
correctness 二轨语义:W/L、准确率、四象限均按 **unit 口径** 统计
AR pair 双向 AND 折叠为一个单元,不被 P/Q 单题计分污染);
candidate_correctness 独立保留 **逐题** 对错(只含已观测题,早停后是阶梯前缀
子集),accept 时由 runner 按 question_id 粒度增量合并进 state.correctness。
"""
action: str # accept_confirmed | accept_provisional | reject
@@ -110,8 +113,8 @@ class ValidationOutcome:
n_used: int
delta_hat: float
delta_shrunk: float
baseline_acc: float # 已观测上的基线准确率(观测口径)
candidate_acc: float # 已观测上的候选准确率(观测口径)
baseline_acc: float # 已观测单元上的基线准确率(unit 口径)
candidate_acc: float # 已观测单元上的候选准确率(unit 口径)
improvements: list[str] = field(default_factory=list)
regressions: list[str] = field(default_factory=list)
persistent_fails: list[str] = field(default_factory=list)
@@ -252,7 +255,7 @@ def _candidate_correctness_from_db(
async def _resolve_baseline_block(
chunk: list[GeneratedQuestion],
units: list[QuestionUnit],
task_type: str,
s_hash: str,
prompts_version: str,
@@ -262,102 +265,114 @@ async def _resolve_baseline_block(
log: HarnessLog,
run_id: str,
) -> tuple[dict[str, bool], int, int]:
"""基线侧处理一个块:缓存优先,miss 的新鲜跑基线版本并回写缓存。
"""基线侧处理一个块:缓存优先unit 键)miss 的单元新鲜跑基线版本并回写缓存。
缓存以 unit_id 为键、存单元级对错(AR pair 双向 AND 折叠后一个布尔)。
miss 的单元展开为逐题送推理,读回逐题预测后经 unit_correctness_view 折叠成
单元级对错再写缓存(核心算法保真 #5)。逐题 predictions 仍逐题落库溯源。
参数:
chunk: 当前块的题目列表
units: 当前块的单元列表(single 或 AR pair
task_type: 当前验证题型(缓存键成分)。
s_hash: 基线侧生效 skill 的内容哈希(缓存键成分)。
prompts_version: 当前 prompts 版本(缓存键成分)。
baseline_cache: 基线侧逐题对错缓存。
baseline_cache: 基线侧单元级对错缓存(键含 unit_id
base_skills_dir: 基线 skills 版本目录。
run_inference: 注入的 async 推理函数。
log: HarnessLog 共享实例(推理后读预测)。
run_id: 本块基线 run_id。
返回:
(b_map, errors_inc, denom_inc):块内 question_id -> 基线对错、
(b_units, errors_inc, denom_inc):块内 unit_id -> 基线单元对错、
本块新增的 INFRA error 计数与推理题次分母增量(全命中时为 0, 0)。
"""
misses = [
q
for q in chunk
if baseline_cache.get(task_type, s_hash, prompts_version, q.question_id) is None
miss_units = [
u
for u in units
if baseline_cache.get(task_type, s_hash, prompts_version, u.unit_id) is None
]
errors_inc = 0
denom_inc = 0
if misses:
r_b = await run_inference(misses, run_id=run_id, skills_dir=base_skills_dir)
if miss_units:
miss_questions = flatten_units(miss_units)
r_b = await run_inference(miss_questions, run_id=run_id, skills_dir=base_skills_dir)
errors_inc = r_b.stop_reason_counts.get("error", 0)
denom_inc = r_b.total
fresh = _candidate_correctness_from_db(log, r_b.run_id, misses)
for qid, correct in fresh.items():
baseline_cache.put(task_type, s_hash, prompts_version, qid, correct)
fresh_per_q = _candidate_correctness_from_db(log, r_b.run_id, miss_questions)
fresh_units = unit_correctness_view(miss_units, fresh_per_q)
for uid, correct in fresh_units.items():
baseline_cache.put(task_type, s_hash, prompts_version, uid, correct)
b_map: dict[str, bool] = {}
for q in chunk:
val = baseline_cache.get(task_type, s_hash, prompts_version, q.question_id)
assert val is not None, f"基线缓存补齐后仍有 miss: {q.question_id} run_id={run_id}"
b_map[q.question_id] = val
return b_map, errors_inc, denom_inc
b_units: dict[str, bool] = {}
for u in units:
val = baseline_cache.get(task_type, s_hash, prompts_version, u.unit_id)
assert val is not None, f"基线缓存补齐后仍有 miss: unit={u.unit_id} run_id={run_id}"
b_units[u.unit_id] = val
return b_units, errors_inc, denom_inc
async def _run_candidate_block(
chunk: list[GeneratedQuestion],
units: list[QuestionUnit],
cand_dir: Path,
run_inference: RunInferenceFn,
log: HarnessLog,
run_id: str,
) -> tuple[dict[str, bool], int, int]:
"""候选侧处理一个块:全块新鲜跑候选版本并从 db 读逐题对错。
"""候选侧处理一个块:单元展开为逐题全块新鲜跑候选版本并从 db 读逐题对错。
返回逐题对错映射(question_id -> bool),折叠为单元视图交由调用方完成,
逐题结果同时用于 candidate_correctness 溯源与二轨 correctness 合并。
参数:
chunk: 当前块的题目列表。
units: 当前块的单元列表。
cand_dir: 已物化的候选 skills 目录。
run_inference: 注入的 async 推理函数。
log: HarnessLog 共享实例(推理后读预测)。
run_id: 本块候选 run_id。
返回:
(c_map, errors_inc, denom_inc)。
(c_per_q, errors_inc, denom_inc):块内 question_id -> 候选对错
"""
r_c = await run_inference(chunk, run_id=run_id, skills_dir=cand_dir)
c_map = _candidate_correctness_from_db(log, r_c.run_id, chunk)
return c_map, r_c.stop_reason_counts.get("error", 0), r_c.total
questions = flatten_units(units)
r_c = await run_inference(questions, run_id=run_id, skills_dir=cand_dir)
c_per_q = _candidate_correctness_from_db(log, r_c.run_id, questions)
return c_per_q, r_c.stop_reason_counts.get("error", 0), r_c.total
def _build_evidence_rows(
chunk: list[GeneratedQuestion],
b_map: dict[str, bool],
c_map: dict[str, bool],
units: list[QuestionUnit],
b_units: dict[str, bool],
c_units: dict[str, bool],
task_type: str,
block_idx: int,
) -> list[dict]:
"""组装一个块的 gate_evidence 逐题证据行。
"""组装一个块的 gate_evidence 单元级证据行。
证据行按 unit 口径(question_id 字段存 unit_id、correct 存单元级对错),
与 e-process 判定同粒度;逐题预测明细仍在 predictions 表逐题溯源。
e_value 留 None 待块判定后回填,stop_reason 留空串待终态回填。
参数:
chunk: 当前块的题目列表。
b_map: 块内 question_id -> 基线对错。
c_map: 块内 question_id -> 候选对错。
units: 当前块的单元列表。
b_units: 块内 unit_id -> 基线单元对错。
c_units: 块内 unit_id -> 候选单元对错。
task_type: 当前验证题型。
block_idx: 当前块序号。
返回:
逐题证据行列表。
单元级证据行列表。
"""
return [
{
"question_id": q.question_id,
"question_id": u.unit_id,
"task_type": task_type,
"block_idx": block_idx,
"baseline_correct": b_map[q.question_id],
"candidate_correct": c_map[q.question_id],
"baseline_correct": b_units[u.unit_id],
"candidate_correct": c_units[u.unit_id],
"e_value": None,
"stop_reason": "",
}
for q in chunk
for u in units
]
@@ -394,20 +409,25 @@ def _finalize_outcome(
n_plan: int,
base_obs: dict[str, bool],
cand_obs: dict[str, bool],
candidate_per_q: dict[str, bool],
evidence_rows: list[dict],
task_type: str,
) -> ValidationOutcome:
"""将块循环终态判定组装为 ValidationOutcome。
四象限/准确率/W/L 均按单元口径(base_obs/cand_obs 为 unit_id -> bool),
candidate_correctness 独立保留逐题溯源(供 runner 二轨合并进 state.correctness)。
参数:
verdict: 最后一块的 gate 判定结果。
w: 累计 W(基线错→候选对翻转)。
l: 累计 L(基线对→候选错翻转)。
n_used: 已消费的阶梯数。
n_plan: 阶梯总数。
base_obs: 累计基线已观测对错
cand_obs: 累计候选已观测对错
evidence_rows: 逐题证据行
w: 累计 W(基线错→候选对单元翻转)。
l: 累计 L(基线对→候选错单元翻转)。
n_used: 已消费的阶梯单元数。
n_plan: 阶梯总单元数。
base_obs: 累计基线已观测单元对错(unit_id -> bool
cand_obs: 累计候选已观测单元对错(unit_id -> bool
candidate_per_q: 累计候选逐题对错(question_id -> bool,溯源用)
evidence_rows: 单元级证据行。
task_type: 验证题型(日志用)。
返回:
@@ -418,16 +438,16 @@ def _finalize_outcome(
"accept_provisional": "accept_provisional",
}.get(verdict.decision, "reject")
stop_reason = _STOP_REASON_BY_DECISION[verdict.decision]
# 只有终态的证据行才携带 stop_reason
# 只有终态单元的证据行才携带 stop_reason
evidence_rows[-1]["stop_reason"] = stop_reason
quadrants = classify_quadrants({qid: (base_obs[qid], cand_obs[qid]) for qid in base_obs})
quadrants = classify_quadrants({uid: (base_obs[uid], cand_obs[uid]) for uid in base_obs})
baseline_acc = sum(base_obs.values()) / len(base_obs)
candidate_acc = sum(cand_obs.values()) / len(cand_obs)
accepted = action != "reject"
logger.info(
"gate 局部验证[{}]: 基线{:.1%} → 候选{:.1%} (W={} L={} E={:.2f} n={}/{}) {}",
"gate 局部验证[{}]: 基线{:.1%} → 候选{:.1%} (W={} L={} E={:.2f} n={}/{} 单元) {}",
task_type,
baseline_acc,
candidate_acc,
@@ -455,7 +475,7 @@ def _finalize_outcome(
regressions=quadrants.regressions,
persistent_fails=quadrants.persistent_fails,
stable_successes=quadrants.stable_successes,
candidate_correctness=cand_obs,
candidate_correctness=candidate_per_q,
evidence_rows=evidence_rows,
)
@@ -465,13 +485,33 @@ def _finalize_outcome(
# ---------------------------------------------------------------------------
def _ladder_units(ladder_items: list[GeneratedQuestion]) -> list[QuestionUnit]:
"""把阶梯题序聚合为单元并保持信息阶梯顺序(按单元最早出现位置排序)。
build_units 会把 single 与 pair 分组重排(single 先、pair 后),破坏"难题优先"
的阶梯序;此处按单元内题目在 ladder 中的最早下标重排,恢复原阶梯优先级,
保证 AR pair 折叠不改变 e-process 的出题顺序(核心算法保真 #5)。非 AR 全 single
时排序为恒等(unit_id 等于 question_id、位置即原序),与迁移前逐题行为一致。
参数:
ladder_items: 阶梯出题序(可混含 single 与 AR pair 成员)。
返回:
按阶梯序排列的单元列表。
"""
units = build_units(ladder_items)
position = {q.question_id: i for i, q in enumerate(ladder_items)}
units.sort(key=lambda u: min(position[q.question_id] for q in u.questions))
return units
async def _run_local_validation(
workspace_dir: Path,
cand_dir: Path,
base_skills_version: str,
task_type: str,
base_skill_content: str,
plan: list[GeneratedQuestion],
units: list[QuestionUnit],
gate_params: GateParams,
gate_block: int,
gate_guard_err: float,
@@ -481,12 +521,12 @@ async def _run_local_validation(
log: HarnessLog,
gate_run_prefix: str,
) -> ValidationOutcome:
"""块序贯循环主体:逐块基线(缓存优先)/候选配对推理,块间 e-process 判定。
"""块序贯循环主体:逐块基线(缓存优先)/候选按单元配对推理,块间 e-process 判定。
按 gate_block 切阶梯前缀,每块先补齐基线侧缓存 miss(新鲜跑基线版本
并逐题写 BaselineCache),再全块跑候选,配对累计 W/L 后调 gate_decision
非 continue 即早停。题尽时最后一块的判定即终态(n_remaining=0 走
provisional/inertia 分支),无循环外补判。
按 gate_block 切**单元**前缀(AR pair 整锁在同一块,不跨块拆分),每块先补齐
基线侧缓存 miss(新鲜跑基线版本并按 unit_id 写 BaselineCache),再全块跑候选,
折叠成单元视图后配对累计 W/L 调 gate_decision;非 continue 即早停。单元尽时
最后一块的判定即终态(n_remaining=0 走 provisional/inertia 分支),无循环外补判。
参数:
workspace_dir: Workspace 根目录。
@@ -494,11 +534,11 @@ async def _run_local_validation(
base_skills_version: 基线 skills 版本名。
task_type: 当前验证题型。
base_skill_content: 基线侧生效 skill 全文(skill_hash 作缓存键成分)。
plan: 已截断到 gate_n_max 的阶梯出题序
units: 已截断到 gate_n_max 的阶梯单元序(single 或 AR pair
gate_params: e-process 判据阈值组。
gate_block: 块大小。
gate_block: 块大小(单位为**单元数**
gate_guard_err: 跨块累计 INFRA 错误率护栏(分母 >=10 才触发)。
baseline_cache: 基线侧逐题对错缓存。
baseline_cache: 基线侧单元级对错缓存(键含 unit_id
prompts_version: 当前 prompts 版本(缓存键成分)。
run_inference: 注入的 async 推理函数。
log: HarnessLog 共享实例。
@@ -508,8 +548,8 @@ async def _run_local_validation(
ValidationOutcome。
关键实现:
INFRA 护栏跨块累计基线+候选两侧的 error 计数,分母(总推理题次)>=10
且错误率超 gate_guard_err 时直接 raise,避免坏批次污染判定。
INFRA 护栏跨块累计基线+候选两侧的 error 计数,分母(总推理题次,仍逐题计
>=10 且错误率超 gate_guard_err 时直接 raise,避免坏批次污染判定。
"""
w = 0
l = 0 # noqa: E741
@@ -519,15 +559,17 @@ async def _run_local_validation(
evidence_rows: list[dict] = []
base_obs: dict[str, bool] = {}
cand_obs: dict[str, bool] = {}
candidate_per_q: dict[str, bool] = {}
s_hash = skill_hash(base_skill_content)
base_skills_dir = workspace_dir / "skills" / base_skills_version
chunks = [plan[i : i + gate_block] for i in range(0, len(plan), gate_block)]
unit_chunks = [units[i : i + gate_block] for i in range(0, len(units), gate_block)]
n_plan = len(units)
verdict: GateVerdict | None = None
for block_idx, chunk in enumerate(chunks):
for block_idx, unit_chunk in enumerate(unit_chunks):
# Phase 1: 基线侧(缓存优先,miss 新鲜跑)+ 候选侧(全块新鲜跑)
b_map, err_b, den_b = await _resolve_baseline_block(
chunk=chunk,
b_units, err_b, den_b = await _resolve_baseline_block(
units=unit_chunk,
task_type=task_type,
s_hash=s_hash,
prompts_version=prompts_version,
@@ -537,8 +579,8 @@ async def _run_local_validation(
log=log,
run_id=f"{gate_run_prefix}_b{block_idx}_base",
)
c_map, err_c, den_c = await _run_candidate_block(
chunk=chunk,
c_per_q, err_c, den_c = await _run_candidate_block(
units=unit_chunk,
cand_dir=cand_dir,
run_inference=run_inference,
log=log,
@@ -550,19 +592,21 @@ async def _run_local_validation(
infra_denom += den_b + den_c
_check_infra_guard(errors, infra_denom, gate_guard_err)
# Phase 3: 配对 + 证据行 + 块间判定
qids = [q.question_id for q in chunk]
pair_result = pair_block(b_map, c_map, qids)
for qid, (b, c) in pair_result.observed.items():
base_obs[qid] = b
cand_obs[qid] = c
# Phase 3: 折叠成单元视图 + 配对 + 证据行 + 块间判定
c_units = unit_correctness_view(unit_chunk, c_per_q)
candidate_per_q.update(c_per_q)
unit_ids = [u.unit_id for u in unit_chunk]
pair_result = pair_block(b_units, c_units, unit_ids)
for uid, (b, c) in pair_result.observed.items():
base_obs[uid] = b
cand_obs[uid] = c
block_rows = _build_evidence_rows(chunk, b_map, c_map, task_type, block_idx)
block_rows = _build_evidence_rows(unit_chunk, b_units, c_units, task_type, block_idx)
w += pair_result.w
l += pair_result.l # noqa: E741
n_used += len(chunk)
verdict = gate_decision(w, l, n_used, len(plan) - n_used, params=gate_params)
n_used += len(unit_chunk)
verdict = gate_decision(w, l, n_used, n_plan - n_used, params=gate_params)
for row in block_rows:
row["e_value"] = verdict.e_value
@@ -578,9 +622,10 @@ async def _run_local_validation(
w=w,
l=l,
n_used=n_used,
n_plan=len(plan),
n_plan=n_plan,
base_obs=base_obs,
cand_obs=cand_obs,
candidate_per_q=candidate_per_q,
evidence_rows=evidence_rows,
task_type=task_type,
)
@@ -618,10 +663,10 @@ async def validate_skill_local(
skill_hash(base_skill_content) 作 BaselineCache 键成分)。
ladder_items: 阶梯序题目列表(已排除本 step 案例包题)。
gate_params: e-process 判据阈值组。
gate_block: 块大小。
gate_n_max: 单 gate 数上限。
gate_block: 块大小(单位为**单元数**,AR pair 整锁不跨块拆)
gate_n_max: 单 gate 单元数上限(阶梯截断到此数量个单元)
gate_guard_err: 跨块累计 INFRA 错误率护栏(分母 >=10 才触发)。
baseline_cache: 基线侧逐题对错缓存。
baseline_cache: 基线侧单元级对错缓存(键含 unit_id
prompts_version: 当前 prompts 版本(缓存键成分)。
run_inference: 注入的 async 推理函数(RunInferenceFn 协议)。
log: HarnessLog 共享实例(供 DB 回读逐题对错)。
@@ -629,7 +674,7 @@ async def validate_skill_local(
(防泄露过滤靠它识别)。块 run_id = f"{prefix}_b{block_idx}_{arm}"
返回:
ValidationOutcome。逐题证据记入 outcome.evidence_rows 随结果返回,
ValidationOutcome。单元级证据记入 outcome.evidence_rows 随结果返回,
gate_evidence 落库由调用方(runner)负责。
"""
if "_gate_" not in gate_run_prefix:
@@ -637,7 +682,8 @@ async def validate_skill_local(
if not ladder_items:
raise ValueError(f"task_type={task_type} 阶梯为空,无法验证")
plan = ladder_items[:gate_n_max]
# 阶梯题序聚合为单元并按信息阶梯序截断到 gate_n_max 个单元(AR pair 整锁不拆)
units = _ladder_units(ladder_items)[:gate_n_max]
cand_dir = materialize_candidate_skill(
workspace_dir, base_skills_version, target_file, candidate_content
)
@@ -648,7 +694,7 @@ async def validate_skill_local(
base_skills_version=base_skills_version,
task_type=task_type,
base_skill_content=base_skill_content,
plan=plan,
units=units,
gate_params=gate_params,
gate_block=gate_block,
gate_guard_err=gate_guard_err,