"""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)