"""async 块序贯验证编排 — CE-Gate 局部验证的唯一独立子编排器。 从 TRM4 core/harness/validate.py (626 行) 迁移,重大重构: - 同步 → async(run_inference 注入为 async callable) - _classify_quadrants → core.evolution.classify_quadrants 纯函数 - 配对逻辑 → 复用 core.evolution.pair_block + 本地证据行组装 - _load_run_rows / _candidate_correctness_from_db → 共享 log.query() - materialize_candidate_skill 保持同步(纯文件操作) 基线与候选在同一阶梯前缀上逐块配对,只数翻转(基线错→候选对 = W, 基线对→候选错 = L),每块结束调 gate_decision 做四出口判定。 基线侧逐题对错走 BaselineCache 内容寻址缓存,miss 才新鲜跑。 判定逻辑全部在 core/evolution/gate,本模块只负责推理编排与证据收集。 """ from __future__ import annotations import json import shutil import tempfile from dataclasses import dataclass, field from pathlib import Path 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 ( INFRA_STOP_REASONS, GateParams, GateVerdict, RejectedEdit, classify_quadrants, gate_decision, pair_block, ) # INFRA_STOP_REASONS 复用 core.evolution.diagnose 的单一定义(M-2):INFRA 故障 # stop_reason(推理侧基础设施错误,非模型答错)在诊断与 gate 两处必须同口径, # 避免各自维护副本致未来漂移。 if TYPE_CHECKING: from app.harness.inference import InferenceResult from app.harness.log import HarnessLog from core.types import GeneratedQuestion, QuestionUnit # gate_decision 的 decision → ValidationOutcome.stop_reason 映射 _STOP_REASON_BY_DECISION: dict[str, str] = { "accept_confirmed": "confirmed", "reject_directional": "directional", "reject_futility": "futility", "accept_provisional": "provisional", "reject_inertia": "inertia", } # --------------------------------------------------------------------------- # 注入协议 # --------------------------------------------------------------------------- @runtime_checkable class RunInferenceFn(Protocol): """注入的推理函数协议。 调用方(runner)负责绑定 llm、tool_dispatch_fn、prompt_builder、 log、concurrency、max_steps、skill_mode 等共享依赖。 validate 侧只传 questions、run_id、skills_dir 三个逐块变化的参数。 """ async def __call__( self, questions: list[GeneratedQuestion], *, run_id: str, skills_dir: Path, ) -> InferenceResult: ... # --------------------------------------------------------------------------- # 数据类型 # --------------------------------------------------------------------------- @dataclass(frozen=True) class InferenceRunConfig: """一次推理运行的配置三元组,把"如何跑推理"内聚成一组。 字段: concurrency: 推理并发度。 max_steps: 单题最大推理步数。 skill_mode: 推理 skill 模式("auto" / "manual" / "none")。 """ concurrency: int max_steps: int skill_mode: str @dataclass class ValidationOutcome: """CE-Gate 局部验证结果:三态动作 + e-process 证据(单元口径)+ 逐题溯源对错。 correctness 二轨语义:W/L、准确率、四象限均按 **unit 口径** 统计 (AR pair 双向 AND 折叠为一个单元,不被 P/Q 单题计分污染); candidate_correctness 独立保留 **逐题** 对错(只含已观测题,早停后是阶梯前缀 子集),accept 时由 runner 按 question_id 粒度增量合并进 state.correctness。 """ action: str # accept_confirmed | accept_provisional | reject accepted: bool stop_reason: str # confirmed | directional | futility | provisional | inertia e_value: float w: int l: int # noqa: E741 n_used: int delta_hat: float delta_shrunk: 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) stable_successes: list[str] = field(default_factory=list) candidate_correctness: dict[str, bool] = field(default_factory=dict) evidence_rows: list[dict] = field(default_factory=list) # gate_evidence 逐题行,runner 落库 @dataclass class Probation: """一个题型的在途试用账本(每题型至多一个)。 字段: task_type: 题型。 anchor_skills_version: 锚版本名(最近一个 CONFIRMED 的 skills 版本)—— 回滚时恢复该版本中本题型 skill 文件的内容。 target_file: 该题型解析后的 skill 文件名。 correctness_snapshot: 开账时该题型 val 题的对错快照(回滚时恢复)。 opened_step: 开账时的 global_step(观测用)。 pending_edits: 试用链上全部候选 edit 的黑名单素材(回滚时整链入黑名单)。 """ task_type: str anchor_skills_version: str target_file: str correctness_snapshot: dict[str, bool] opened_step: int pending_edits: list[RejectedEdit] = field(default_factory=list) # --------------------------------------------------------------------------- # 同步辅助函数 # --------------------------------------------------------------------------- def materialize_candidate_skill( workspace_dir: Path, base_skills_version: str, target_file: str, content: str, ) -> Path: """将候选 skill 正文物化为 workspace 专用临时目录下唯一命名的候选 skills 目录。 复制基线 skills 目录到 .cand_tmp/ 下的唯一命名临时目录,然后覆写 target_file。 构建失败时尽力清理已建临时目录再重抛原始异常。 参数: workspace_dir: Workspace 根目录。基线 skills 从 workspace_dir/skills/ 复制,临时候选落 workspace_dir/.cand_tmp/。 base_skills_version: 基线 skills 版本名。 target_file: 被替换的 skill 文件名。 content: 候选 skill 文件全文。 返回: 新建的临时候选目录绝对路径。 契约: 构建失败(OSError)时尽力清理已建临时目录再重抛原始异常; 清理本身失败记 warning。 """ cand_tmp_root = workspace_dir / ".cand_tmp" cand_tmp_root.mkdir(parents=True, exist_ok=True) cand_dir = Path(tempfile.mkdtemp(prefix=f"{base_skills_version}_cand_", dir=cand_tmp_root)) try: base_dir = workspace_dir / "skills" / base_skills_version shutil.copytree(base_dir, cand_dir, dirs_exist_ok=True) (cand_dir / target_file).write_text(content, encoding="utf-8") except OSError: try: shutil.rmtree(cand_dir) except OSError as cleanup_err: logger.warning("候选物化失败后清理临时目录也失败 {}: {}", cand_dir, cleanup_err) raise return cand_dir def _load_run_rows( log: HarnessLog, run_id: str, ) -> dict[str, dict[str, Any]]: """读取单个 run 的逐题预测行并规范化轨迹字段。 从 predictions 表读取指定 run 的题目级记录,补充 _correct 与规范化后的 steps 字段。保持同步(log.query)——仅在推理完成后调用。 参数: log: HarnessLog 共享实例(用 query 方法做只读 SELECT)。 run_id: 待读取的预测 run_id。 返回: 以 question_id 为键的行字典。每行至少包含 prediction、answer、 _correct、steps 等字段。 """ rows = log.query( "SELECT question_id, prediction, answer, stop_reason, steps_json " "FROM predictions WHERE run_id=?", (run_id,), ) normalized: dict[str, dict[str, Any]] = {} for row in rows: raw_steps = row.get("steps_json") parsed_steps: Any = raw_steps if isinstance(raw_steps, str): try: parsed_steps = json.loads(raw_steps) except json.JSONDecodeError: parsed_steps = [] steps = parsed_steps if isinstance(parsed_steps, list) else [] normalized[row["question_id"]] = { **row, "_correct": row.get("prediction") == row.get("answer"), "steps": steps, } return normalized def _infra_question_ids_from_db( log: HarnessLog, run_id: str, chunk: list[GeneratedQuestion], ) -> set[str]: """从 db 读取一个 run 中 stop_reason 属 INFRA 故障族的 question_id 集合。 参数: log: HarnessLog 共享实例。 run_id: 推理 run_id。 chunk: 题目列表。 返回: stop_reason ∈ {"error", "parse_error"} 的 question_id 集合。 """ rows = _load_run_rows(log, run_id) return { q.question_id for q in chunk if rows.get(q.question_id, {}).get("stop_reason") in INFRA_STOP_REASONS } def _count_infra_units(units: list[QuestionUnit], infra_qids: set[str]) -> int: """统计含 INFRA record 的 unit 数(一个 unit 任一题 INFRA 即计 1)。 使护栏分子与分母(r.total,unit 粒度)同口径:AR pair 一 unit 含两 record, 逐 record 计数会放大分子致 gate_guard_err 误触发,破坏 unit 粒度一致性 (核心算法保真 #5/#6)。 参数: units: 当前块的单元列表(single 或 AR pair)。 infra_qids: 本 run 中 stop_reason 属 INFRA 故障族的 question_id 集合。 返回: 含至少一题 INFRA 的 unit 数。 """ return sum(1 for u in units if any(q.question_id in infra_qids for q in u.questions)) def _candidate_correctness_from_db( log: HarnessLog, run_id: str, chunk: list[GeneratedQuestion], ) -> dict[str, bool]: """从 db 读取候选/基线 run 在指定题目上的逐题对错。 参数: log: HarnessLog 共享实例。 run_id: 推理 run_id。 chunk: 题目列表。 返回: question_id -> 是否答对的映射。缺行的题目记为 False。 """ rows = _load_run_rows(log, run_id) return {q.question_id: rows.get(q.question_id, {}).get("_correct", False) for q in chunk} # --------------------------------------------------------------------------- # 块级 async 函数 # --------------------------------------------------------------------------- async def _resolve_baseline_block( units: list[QuestionUnit], task_type: str, s_hash: str, prompts_version: str, baseline_cache: BaselineCache, base_skills_dir: Path, run_inference: RunInferenceFn, log: HarnessLog, run_id: str, ) -> tuple[dict[str, bool], list[QuestionUnit], int, int]: """基线侧处理一个块:缓存优先(unit 键),miss 的单元新鲜跑基线版本并回写缓存。 缓存以 unit_id 为键、存单元级对错(AR pair 双向 AND 折叠后一个布尔)。 miss 的单元展开为逐题送推理,读回逐题预测后经 unit_correctness_view 折叠成 单元级对错再写缓存(核心算法保真 #5)。逐题 predictions 仍逐题落库溯源。 INFRA 隔离(算法 #6):miss 单元内**任一题** stop_reason ∈ {error, parse_error} 即判定该单元为 INFRA 故障——**不写 BaselineCache**(否则瞬时故障永久污染基线 快照)、**不入 b_units**、并从返回的有效单元集中剔除,避免污染 W/L 翻转与配对。 命中缓存的单元恒为有效(此前已成功验证过)。 参数: units: 当前块的单元列表(single 或 AR pair)。 task_type: 当前验证题型(缓存键成分)。 s_hash: 基线侧生效 skill 的内容哈希(缓存键成分)。 prompts_version: 当前 prompts 版本(缓存键成分)。 baseline_cache: 基线侧单元级对错缓存(键含 unit_id)。 base_skills_dir: 基线 skills 版本目录。 run_inference: 注入的 async 推理函数。 log: HarnessLog 共享实例(推理后读预测)。 run_id: 本块基线 run_id。 返回: (b_units, valid_units, errors_inc, denom_inc):块内有效 unit_id -> 基线单元 对错、剔除 INFRA 后的有效单元列表、本块新增的 INFRA error 计数与推理题次 分母增量(全命中时为 0, 0)。 """ 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 infra_qids: set[str] = set() 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) infra_qids = _infra_question_ids_from_db(log, r_b.run_id, miss_questions) # 护栏分子与分母(r.total,unit 粒度)同口径:含 INFRA record 的 unit 计 1, # 避免 AR pair(一 unit 两 record)逐 record 计数放大分子致误触发;仍涵盖 # error + parse_error(_infra_question_ids_from_db 口径),parse_error 风暴不被绕过。 errors_inc = _count_infra_units(miss_units, infra_qids) denom_inc = r_b.total fresh_per_q = _candidate_correctness_from_db(log, r_b.run_id, miss_questions) fresh_units = unit_correctness_view(miss_units, fresh_per_q) # 只回写非 INFRA 单元;INFRA 单元不入缓存(不永久污染基线快照) for u in miss_units: if any(q.question_id in infra_qids for q in u.questions): continue baseline_cache.put(task_type, s_hash, prompts_version, u.unit_id, fresh_units[u.unit_id]) valid_units = [ u for u in units if not any(q.question_id in infra_qids for q in u.questions) ] b_units: dict[str, bool] = {} for u in valid_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, valid_units, errors_inc, denom_inc async def _run_candidate_block( units: list[QuestionUnit], cand_dir: Path, run_inference: RunInferenceFn, log: HarnessLog, run_id: str, ) -> tuple[dict[str, bool], int, int]: """候选侧处理一个块:单元展开为逐题全块新鲜跑候选版本并从 db 读逐题对错。 返回逐题对错映射(question_id -> bool),折叠为单元视图交由调用方完成, 逐题结果同时用于 candidate_correctness 溯源与二轨 correctness 合并。 参数: units: 当前块的单元列表。 cand_dir: 已物化的候选 skills 目录。 run_inference: 注入的 async 推理函数。 log: HarnessLog 共享实例(推理后读预测)。 run_id: 本块候选 run_id。 返回: (c_per_q, errors_inc, denom_inc):块内 question_id -> 候选对错。 """ 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) infra_qids = _infra_question_ids_from_db(log, r_c.run_id, questions) # 护栏分子与分母(r.total,unit 粒度)同口径:含 INFRA record 的 unit 计 1 # (见 _count_infra_units),涵盖 error + parse_error。 errors_inc = _count_infra_units(units, infra_qids) return c_per_q, errors_inc, r_c.total def _build_evidence_rows( units: list[QuestionUnit], b_units: dict[str, bool], c_units: dict[str, bool], task_type: str, block_idx: int, ) -> list[dict]: """组装一个块的 gate_evidence 单元级证据行。 证据行按 unit 口径(question_id 字段存 unit_id、correct 存单元级对错), 与 e-process 判定同粒度;逐题预测明细仍在 predictions 表逐题溯源。 e_value 留 None 待块判定后回填,stop_reason 留空串待终态回填。 参数: units: 当前块的单元列表。 b_units: 块内 unit_id -> 基线单元对错。 c_units: 块内 unit_id -> 候选单元对错。 task_type: 当前验证题型。 block_idx: 当前块序号。 返回: 单元级证据行列表。 """ return [ { "question_id": u.unit_id, "task_type": task_type, "block_idx": block_idx, "baseline_correct": b_units[u.unit_id], "candidate_correct": c_units[u.unit_id], "e_value": None, "stop_reason": "", } for u in units ] # --------------------------------------------------------------------------- # INFRA 护栏 # --------------------------------------------------------------------------- def _check_infra_guard(errors: int, infra_denom: int, gate_guard_err: float) -> None: """跨块累计 INFRA 错误率护栏:分母 >=10 且超阈值时 raise。 参数: errors: 两侧累计 error 计数。 infra_denom: 两侧累计推理题次分母。 gate_guard_err: 错误率阈值。 异常: RuntimeError: 错误率超阈值。 """ if infra_denom >= 10 and errors / infra_denom > gate_guard_err: raise RuntimeError(f"gate 推理累计错误率过高 {errors / infra_denom:.0%},中止本轮") # --------------------------------------------------------------------------- # 终态组装 # --------------------------------------------------------------------------- def _finalize_outcome( verdict: GateVerdict, w: int, l: int, # noqa: E741 n_used: int, 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: 累计基线已观测单元对错(unit_id -> bool)。 cand_obs: 累计候选已观测单元对错(unit_id -> bool)。 candidate_per_q: 累计候选逐题对错(question_id -> bool,溯源用)。 evidence_rows: 单元级证据行。 task_type: 验证题型(日志用)。 返回: ValidationOutcome。 """ action = { "accept_confirmed": "accept_confirmed", "accept_provisional": "accept_provisional", }.get(verdict.decision, "reject") stop_reason = _STOP_REASON_BY_DECISION[verdict.decision] # 只有终态单元的证据行才携带 stop_reason evidence_rows[-1]["stop_reason"] = stop_reason 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={}/{} 单元) {}", task_type, baseline_acc, candidate_acc, w, l, verdict.e_value, n_used, n_plan, "接受" if accepted else "回滚", ) return ValidationOutcome( action=action, accepted=accepted, stop_reason=stop_reason, e_value=verdict.e_value, w=w, l=l, n_used=n_used, delta_hat=verdict.delta_hat, delta_shrunk=verdict.delta_shrunk, baseline_acc=baseline_acc, candidate_acc=candidate_acc, improvements=quadrants.improvements, regressions=quadrants.regressions, persistent_fails=quadrants.persistent_fails, stable_successes=quadrants.stable_successes, candidate_correctness=candidate_per_q, evidence_rows=evidence_rows, ) # --------------------------------------------------------------------------- # 主编排 # --------------------------------------------------------------------------- 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, units: list[QuestionUnit], gate_params: GateParams, gate_block: int, gate_guard_err: float, baseline_cache: BaselineCache, prompts_version: str, run_inference: RunInferenceFn, log: HarnessLog, gate_run_prefix: str, ) -> ValidationOutcome: """块序贯循环主体:逐块基线(缓存优先)/候选按单元配对推理,块间 e-process 判定。 按 gate_block 切**单元**前缀(AR pair 整锁在同一块,不跨块拆分),每块先补齐 基线侧缓存 miss(新鲜跑基线版本并按 unit_id 写 BaselineCache),再全块跑候选, 折叠成单元视图后配对累计 W/L 调 gate_decision;非 continue 即早停。单元尽时 最后一块的判定即终态(n_remaining=0 走 provisional/inertia 分支),无循环外补判。 参数: workspace_dir: Workspace 根目录。 cand_dir: 已物化的候选 skills 目录。 base_skills_version: 基线 skills 版本名。 task_type: 当前验证题型。 base_skill_content: 基线侧生效 skill 全文(skill_hash 作缓存键成分)。 units: 已截断到 gate_n_max 的阶梯单元序(single 或 AR pair)。 gate_params: e-process 判据阈值组。 gate_block: 块大小(单位为**单元数**)。 gate_guard_err: 跨块累计 INFRA 错误率护栏(分母 >=10 才触发)。 baseline_cache: 基线侧单元级对错缓存(键含 unit_id)。 prompts_version: 当前 prompts 版本(缓存键成分)。 run_inference: 注入的 async 推理函数。 log: HarnessLog 共享实例。 gate_run_prefix: 块 run_id 前缀(含 "_gate_" 标记)。 返回: ValidationOutcome。 关键实现: INFRA 护栏跨块累计基线+候选两侧的 error 计数,分母(总推理题次,仍逐题计) >=10 且错误率超 gate_guard_err 时直接 raise,避免坏批次污染判定。 """ w = 0 l = 0 # noqa: E741 n_used = 0 n_excluded = 0 # 累计被 INFRA 隔离剔除的单元数(从阶梯分母扣除) errors = 0 infra_denom = 0 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 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, unit_chunk in enumerate(unit_chunks): # Phase 1: 基线侧(缓存优先,miss 新鲜跑,INFRA 单元剔除) b_units, valid_chunk, err_b, den_b = await _resolve_baseline_block( units=unit_chunk, task_type=task_type, s_hash=s_hash, prompts_version=prompts_version, baseline_cache=baseline_cache, base_skills_dir=base_skills_dir, run_inference=run_inference, log=log, run_id=f"{gate_run_prefix}_b{block_idx}_base", ) # 本块全 INFRA:无有效单元可配对——候选无需空跑,仅把基线侧错误计入护栏后 # 累计剔除数进入下一块(护栏仍能在整轮 INFRA 错误率超阈值时熔断)。 n_excluded += len(unit_chunk) - len(valid_chunk) if not valid_chunk: errors += err_b infra_denom += den_b _check_infra_guard(errors, infra_denom, gate_guard_err) continue # 候选侧只跑基线侧判定有效(非 INFRA)的单元,保证配对 unit_ids 两侧一致 c_per_q, err_c, den_c = await _run_candidate_block( units=valid_chunk, cand_dir=cand_dir, run_inference=run_inference, log=log, run_id=f"{gate_run_prefix}_b{block_idx}_cand", ) # Phase 2: INFRA 护栏(跨块累计,分母 >=10 才触发)——写缓存前置于此已由 # _resolve_baseline_block 保证 INFRA 单元不落缓存,此处仅做整轮错误率熔断。 errors += err_b + err_c infra_denom += den_b + den_c _check_infra_guard(errors, infra_denom, gate_guard_err) # Phase 3: 折叠成单元视图 + 配对 + 证据行 + 块间判定(均用有效单元) c_units = unit_correctness_view(valid_chunk, c_per_q) candidate_per_q.update(c_per_q) unit_ids = [u.unit_id for u in valid_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(valid_chunk, b_units, c_units, task_type, block_idx) w += pair_result.w l += pair_result.l # noqa: E741 n_used += len(valid_chunk) # 阶梯剩余按扣除 INFRA 后的有效分母计:n_remaining = (n_plan - n_excluded) - n_used verdict = gate_decision(w, l, n_used, (n_plan - n_excluded) - n_used, params=gate_params) for row in block_rows: row["e_value"] = verdict.e_value evidence_rows.extend(block_rows) if verdict.decision != "continue": break # verdict 仍为 None ⟺ 全部单元被 INFRA 排除(空 ladder 已在入口拒绝)。 # 明确失败,避免落到误导性的"空阶梯"断言而无法定位为 INFRA 原因。 if verdict is None: raise RuntimeError("gate 阶梯所有 unit 被判为 INFRA 排除,无法验证(检查推理基础设施)") # 最后一块判定即终态(n_remaining=0 → provisional/inertia) return _finalize_outcome( verdict=verdict, w=w, l=l, n_used=n_used, 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, ) async def validate_skill_local( workspace_dir: Path, base_skills_version: str, task_type: str, target_file: str, candidate_content: str, base_skill_content: str, ladder_items: list[GeneratedQuestion], gate_params: GateParams, gate_block: int, gate_n_max: int, gate_guard_err: float, baseline_cache: BaselineCache, prompts_version: str, run_inference: RunInferenceFn, log: HarnessLog, gate_run_prefix: str, ) -> ValidationOutcome: """块序贯配对验证:阶梯出题,基线/候选逐块配对,e-process 四出口早停。 参数: workspace_dir: workspace 根目录。 base_skills_version: 基线 skills 版本名(候选物化复制源)。 task_type: 待验证题型。 target_file: fallback 解析后该题型的真实生效 skill 文件名 (record.target_file,可能是共享 default-strategy.md); 候选物化写此文件,与 accept 路径同源。 candidate_content: 候选 skill 全文。 base_skill_content: 基线侧该题型解析后生效 skill 文件全文 (skill_hash(base_skill_content) 作 BaselineCache 键成分)。 ladder_items: 阶梯序题目列表(已排除本 step 案例包题)。 gate_params: e-process 判据阈值组。 gate_block: 块大小(单位为**单元数**,AR pair 整锁不跨块拆)。 gate_n_max: 单 gate 单元数上限(阶梯截断到此数量个单元)。 gate_guard_err: 跨块累计 INFRA 错误率护栏(分母 >=10 才触发)。 baseline_cache: 基线侧单元级对错缓存(键含 unit_id)。 prompts_version: 当前 prompts 版本(缓存键成分)。 run_inference: 注入的 async 推理函数(RunInferenceFn 协议)。 log: HarnessLog 共享实例(供 DB 回读逐题对错)。 gate_run_prefix: gate 内推理 run_id 前缀,必须含 "_gate_" (防泄露过滤靠它识别)。块 run_id = f"{prefix}_b{block_idx}_{arm}"。 返回: ValidationOutcome。单元级证据记入 outcome.evidence_rows 随结果返回, gate_evidence 落库由调用方(runner)负责。 """ if "_gate_" not in gate_run_prefix: raise ValueError(f"gate_run_prefix 必须含 '_gate_'(防泄露过滤依赖): {gate_run_prefix!r}") if not ladder_items: raise ValueError(f"task_type={task_type} 阶梯为空,无法验证") # 阶梯题序聚合为单元并按信息阶梯序截断到 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 ) try: return await _run_local_validation( workspace_dir=workspace_dir, cand_dir=cand_dir, base_skills_version=base_skills_version, task_type=task_type, base_skill_content=base_skill_content, units=units, gate_params=gate_params, gate_block=gate_block, gate_guard_err=gate_guard_err, baseline_cache=baseline_cache, prompts_version=prompts_version, run_inference=run_inference, log=log, gate_run_prefix=gate_run_prefix, ) finally: try: shutil.rmtree(cand_dir) except OSError as e: logger.warning("候选临时目录清理失败 {}: {}", cand_dir, e) # --------------------------------------------------------------------------- # 连续并发 gate:数据结构 + 前缀消费(algo #6 语义修订:块序贯 → 阶梯序前缀逐对序贯) # --------------------------------------------------------------------------- @dataclass(frozen=True) class GateSpec: """单题型 gate 验证规格(runner 装配阶段产物,调度器输入)。 字段: task_type: 题型。 target_file: 解析后生效 skill 文件名(候选物化写此文件)。 candidate_content: 候选 skill 全文。 base_skill_content: 基线侧生效 skill 全文(skill_hash 作缓存键)。 units: 阶梯序单元元组(已排除案例单元、截断 gate_n_max);元组,装配后 不可变,防 spec.units 与 run.slots 漂移。 gate_run_prefix: run_id 前缀,必须含 "_gate_"(防泄露过滤依赖)。 """ task_type: str target_file: str candidate_content: str base_skill_content: str units: tuple[QuestionUnit, ...] gate_run_prefix: str @dataclass class _UnitSlot: """单个阶梯单元的双臂到达状态。 base 为单元级对错(AR pair 已折叠);cand_per_q 为逐题对错(折叠交给消费时, 以复用 unit_correctness_view 并保留逐题溯源)。INFRA 标志与结果互斥。 """ unit: QuestionUnit base: bool | None = None cand_per_q: dict[str, bool] | None = None base_infra: bool = False cand_infra: bool = False def resolved(self) -> bool: """双臂均已出结果(含 INFRA 判定)。""" base_done = self.base is not None or self.base_infra cand_done = self.cand_per_q is not None or self.cand_infra return base_done and cand_done def excluded(self) -> bool: """任一臂 INFRA 即整单元剔除(不入配对)。""" return self.base_infra or self.cand_infra @dataclass class _GateRun: """单题型 gate 的运行时状态(计数器 + 前缀指针 + 证据)。""" spec: GateSpec slots: list[_UnitSlot] s_hash: str prefix_ptr: int = 0 w: int = 0 l: int = 0 # noqa: E741 n_used: int = 0 n_excluded: int = 0 errors: int = 0 infra_denom: int = 0 frozen: bool = False verdict: GateVerdict | None = None base_obs: dict[str, bool] = field(default_factory=dict) cand_obs: dict[str, bool] = field(default_factory=dict) candidate_per_q: dict[str, bool] = field(default_factory=dict) evidence_rows: list[dict] = field(default_factory=list) @classmethod def from_spec(cls, spec: GateSpec) -> _GateRun: """由规格构造初始状态(slots 与阶梯序一一对应)。""" return cls( spec=spec, slots=[_UnitSlot(unit=u) for u in spec.units], s_hash=skill_hash(spec.base_skill_content), ) def _advance_prefix(run: _GateRun, params: GateParams) -> None: """沿阶梯序消费"已配齐前缀",逐单元更新 (W,L) 并判定,过线即冻结。 统计合法性关键(设计 v3 §1 / Codex C1):严禁按到达序消费——base 臂缓存命中 瞬间返回、cand 臂必新鲜跑,两臂延迟不对称,若 cand 延迟与对错相关,早到翻转 对系统性偏向 W 型 → e-值虚高假接受。前缀消费把判定顺序钉回预声明阶梯序, anytime-valid 无条件成立;INFRA 单元视为"已解决(剔除)"不阻塞前缀。 契约:全部单元被剔除时 verdict 保持 None、frozen 保持 False,由调度编排层 (Task 3 的 validate_skills_concurrent)检测 verdict None 并 raise RuntimeError;本函数不负责该终态。 """ while not run.frozen and run.prefix_ptr < len(run.slots): slot = run.slots[run.prefix_ptr] if not slot.resolved(): return rank = run.prefix_ptr run.prefix_ptr += 1 if slot.excluded(): run.n_excluded += 1 # 剔除使 n_remaining 缩小,必须重判(Codex plan 审 C1):否则尾部全 INFRA # 时 verdict 停留在 "continue",绕过题尽第四出口且 _finalize_outcome # 查 stop_reason 映射 KeyError。n_used==0(纯前导 INFRA)时无证据可判,跳过。 if run.n_used > 0: n_remaining = (len(run.slots) - run.n_excluded) - run.n_used run.verdict = gate_decision(run.w, run.l, run.n_used, n_remaining, params=params) if run.verdict.decision != "continue": run.frozen = True continue uid = slot.unit.unit_id assert slot.base is not None and slot.cand_per_q is not None, ( f"slot 未配齐即被消费: unit={slot.unit.unit_id}" ) c_units = unit_correctness_view([slot.unit], slot.cand_per_q) pair_result = pair_block({uid: slot.base}, c_units, [uid]) run.candidate_per_q.update(slot.cand_per_q) for u, (b, c) in pair_result.observed.items(): run.base_obs[u] = b run.cand_obs[u] = c run.w += pair_result.w run.l += pair_result.l run.n_used += 1 n_remaining = (len(run.slots) - run.n_excluded) - run.n_used run.verdict = gate_decision(run.w, run.l, run.n_used, n_remaining, params=params) run.evidence_rows.append( { "question_id": uid, "task_type": run.spec.task_type, "ladder_rank": rank, "baseline_correct": slot.base, "candidate_correct": c_units[uid], "e_value": run.verdict.e_value, "stop_reason": "", } ) if run.verdict.decision != "continue": run.frozen = True