Compare commits

..

10 Commits

Author SHA1 Message Date
iomgaa f1dea4f68f merge: feat/app-harness — app/harness/ 训练循环编排层(14 文件, 算法保真 #6/#10/#13) 2026-07-07 20:35:04 -04:00
iomgaa 7a00bc1a28 style(harness): ruff format batching.py + log.py 2026-07-07 20:34:48 -04:00
iomgaa be0e89401e feat(harness): __init__.py public API + lint fixes 2026-07-07 13:45:48 -04:00
iomgaa 2296134f73 feat(harness): runner.py — train loop orchestrator (#13 algorithm fidelity)
Three-level nesting (epoch -> step -> per-skill), slow update 10-step
sequence, checkpoint/resume, early stop, probation accept/reject/rollback.

Key TRM4->TRM5 changes:
- sync -> async (all inference/diagnosis/evolve/validate awaited)
- LLMClient.from_env -> injected LLMProvider (DI via constructor)
- Direct DB/file access -> module functions (workspace/store/log)
- _TrainState as train() local, explicit param passing to helpers

Module-level pure functions extracted for testability:
resume_plan, _guard_infra_failures, _apply_batch_correctness,
_compute_total_steps, _should_early_stop, _format_applied_edits,
_fallback_summary, _write_skip_report, _outcome_to_quadrant_pairs,
_build_comparison_pairs, _batch_from_ids, _snapshot_current_skills.

Tests: 34 unit tests covering 13a-13e sub-tasks.
Radon: all functions Grade B or better.
2026-07-07 13:43:20 -04:00
iomgaa 7bc6fc752c feat(harness): validate.py — async 块序贯验证编排 + Probation 统一定义 2026-07-07 13:20:43 -04:00
iomgaa a6b816db94 feat(harness): checkpoint.py — TrainState 序列化 + 原子写 + 指纹校验 2026-07-07 13:13:37 -04:00
iomgaa d7f1bdeea6 feat(harness): inference.py — async run_inference + DI 2026-07-07 13:04:26 -04:00
iomgaa 886a444d1d feat(harness): momentum.py — async 慢更新动量生成 2026-07-07 13:01:04 -04:00
iomgaa a550d39e1c feat(harness): observation.py — 五张观测表 + step/epoch 报告 2026-07-07 12:55:12 -04:00
iomgaa bd4e438c6c feat(harness): gate_ladder.py — 信息阶梯 + BaselineCache (#6 算法保真) 2026-07-07 12:55:12 -04:00
17 changed files with 7839 additions and 24 deletions
+36
View File
@@ -0,0 +1,36 @@
"""app/harness/ — 训练循环编排层。
组合 core/evolution/(决策内核)+ core/agent/AgentLoop+ adapters/LLM/VLM/telemetry),
实现自进化闭环的训练循环三级嵌套、块序贯验证、快慢双速进化、checkpoint/resume。
"""
from app.harness.config import RunConfig, load_config
from app.harness.inference import InferenceResult, run_inference
from app.harness.log import HarnessLog, RunLogImpl
from app.harness.pools import Pools, build_or_load_pools, build_pools, load_pools, save_pools
from app.harness.runner import Runner
from app.harness.workspace import (
ResolvedPaths,
VersionedPromptStore,
VersionedSkillStore,
resolve_paths,
)
__all__ = [
"HarnessLog",
"InferenceResult",
"Pools",
"ResolvedPaths",
"RunConfig",
"RunLogImpl",
"Runner",
"VersionedPromptStore",
"VersionedSkillStore",
"build_or_load_pools",
"build_pools",
"load_config",
"load_pools",
"resolve_paths",
"run_inference",
"save_pools",
]
+1 -3
View File
@@ -139,9 +139,7 @@ def _select_mixed_by_task_type(
n_correct = round(len(errs) * correct_ratio / (1 - correct_ratio))
available = correct_by_type.get(task_type, [])
sampled = (
list(available)
if len(available) <= n_correct
else rng.sample(available, n_correct)
list(available) if len(available) <= n_correct else rng.sample(available, n_correct)
)
grouped[task_type] = errs + sampled
+274
View File
@@ -0,0 +1,274 @@
"""step 级续训 checkpoint_TrainState 可持久化字段的序列化 / 反序列化。
_TrainState 的累加包均为扁平纯数据 dataclass,经 dataclasses.asdict 序列化为
纯 JSON dict;反序列化时用 Cls(**d) 还原,其中 SystemCasePack 含嵌套 CaseSample
列表、Probation 含嵌套 RejectedEdit 列表,需逐个重建。
不持久化的字段:gate_pools / baseline_cache(各自文件级自持久化,resume 时按
指纹重载)、best_*(从 manifest best 指针读)、global_step(存 progress 块,
由 train 单独赋值)。gate_epoch_observed 持久化:warm p-hat 在 gate_pools.json
幸存,观测开关须随行,否则 resume 后阶梯排序回退冷启动序。
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict
from typing import TYPE_CHECKING, Any
from app.harness.validate import Probation
from core.evolution.types import (
CaseSample,
RejectedEdit,
SystemCasePack,
ToolCasePack,
)
if TYPE_CHECKING:
from pathlib import Path
CHECKPOINT_SCHEMA_VERSION = 1
# ---------------------------------------------------------------------------
# 结构性 / 决策性指纹键
# ---------------------------------------------------------------------------
_STRUCTURAL_KEYS = (
"batch_size",
"min_class_per_batch",
"epochs",
"diag_size",
"val_size",
"batch_correct_ratio",
)
_DECISION_KEYS = (
"edit_budget_start",
"edit_budget_end",
"early_stop_patience",
"use_slow_momentum",
"skill_update_mode",
"appendix_consolidate_threshold",
"momentum_samples",
"gate_e_confirm",
"gate_e_provisional",
"gate_w_net_min",
"gate_delta_min",
"gate_lambda_dir",
"gate_e_rollback",
"gate_block",
"gate_n_max",
"gate_p_low",
"gate_p_high",
"gate_probe_quota",
"gate_gamma_decay",
"gate_cooldown_steps",
"gate_guard_err",
)
# ---------------------------------------------------------------------------
# 序列化 / 反序列化
# ---------------------------------------------------------------------------
def serialize_state(state: Any) -> dict[str, Any]:
"""把 _TrainState 的可持久化字段转为纯 JSON dict。
参数:
state: _TrainState 实例(duck-typed,仅需含可持久化字段)。
返回:
纯 JSON 可序列化的 dict,不含 gate_pools / baseline_cache /
best_* / global_step。
关键实现细节:
- changed_task_types_this_epoch 是 setJSON 无 set,故 sorted 成有序列表。
- dataclass 均经 asdict 递归转 dict(含 SystemCasePack 嵌套 CaseSample、
Probation 嵌套 RejectedEdit)。
"""
return {
"correctness": state.correctness,
"eval_prev_acc": state.eval_prev_acc,
"eval_prev_run_id": state.eval_prev_run_id,
"baseline_skills_version": state.baseline_skills_version,
"baseline_prompts_version": state.baseline_prompts_version,
"steps_since_best_improved": state.steps_since_best_improved,
"epoch_start_skills": state.epoch_start_skills,
"changed_task_types_this_epoch": sorted(state.changed_task_types_this_epoch),
"rejected_buffer": {k: [asdict(x) for x in v] for k, v in state.rejected_buffer.items()},
"system_packs": [asdict(x) for x in state.system_packs],
"tool_packs": [asdict(x) for x in state.tool_packs],
"probations": {t: asdict(p) for t, p in state.probations.items()},
"gate_cooldown": state.gate_cooldown,
"gate_epoch_observed": state.gate_epoch_observed,
}
def _restore_system_pack(d: dict[str, Any]) -> SystemCasePack:
"""还原 SystemCasePack,含嵌套 CaseSample 列表。
参数:
d: asdict(SystemCasePack) 产出的 dict。
返回:
复活的 SystemCasePackfailure_cases / success_cases 重建为 CaseSample 实例。
"""
return SystemCasePack(
stats=d["stats"],
failure_cases=[CaseSample(**c) for c in d["failure_cases"]],
success_cases=[CaseSample(**c) for c in d["success_cases"]],
)
def deserialize_state_fields(d: dict[str, Any]) -> dict[str, Any]:
"""把序列化 dict 还原为可填入 _TrainState 的字段字典(dataclass 复活)。
参数:
d: serialize_state 产出并经 JSON 往返的 dict。
返回:
字段名 -> 值的 dict,可直接铺到 _TrainState;其中各 dataclass 已复活、
changed_task_types_this_epoch 还原为 set。
关键实现细节:
- RejectedEdit / ToolCasePack 字段均为标量/dict/list[dict]Cls(**d) 直接构造。
- SystemCasePack 含嵌套 CaseSample,交由 _restore_system_pack 重建。
- Probation 含嵌套 RejectedEdit 列表(pending_edits),先重建内层再构造外层。
- 直接取 d[...] 不用 .get 兜底:serialize 后的 checkpoint 必带全部键,
缺键即 checkpoint 损坏,应硬失败(P5 不掩盖)。
"""
return {
"correctness": d["correctness"],
"eval_prev_acc": d["eval_prev_acc"],
"eval_prev_run_id": d["eval_prev_run_id"],
"baseline_skills_version": d["baseline_skills_version"],
"baseline_prompts_version": d["baseline_prompts_version"],
"steps_since_best_improved": d["steps_since_best_improved"],
"epoch_start_skills": d["epoch_start_skills"],
"changed_task_types_this_epoch": set(d["changed_task_types_this_epoch"]),
"rejected_buffer": {
k: [RejectedEdit(**x) for x in v] for k, v in d["rejected_buffer"].items()
},
"system_packs": [_restore_system_pack(x) for x in d["system_packs"]],
"tool_packs": [ToolCasePack(**x) for x in d["tool_packs"]],
"probations": {
t: Probation(
**{
**d_p,
"pending_edits": [RejectedEdit(**x) for x in d_p["pending_edits"]],
}
)
for t, d_p in d["probations"].items()
},
"gate_cooldown": d["gate_cooldown"],
"gate_epoch_observed": d["gate_epoch_observed"],
}
# ---------------------------------------------------------------------------
# 配置指纹
# ---------------------------------------------------------------------------
def compute_fingerprint(config: Any) -> dict[str, Any]:
"""采集影响训练轨迹的配置项(结构性 + 决策性)。
参数:
config: 训练配置对象(duck-typed,需含 _STRUCTURAL_KEYS + _DECISION_KEYS 属性)。
返回:
指纹 dict,键为配置项名,值为对应配置值。
"""
return {k: getattr(config, k) for k in _STRUCTURAL_KEYS + _DECISION_KEYS}
def check_fingerprint(saved: dict[str, Any], config: Any) -> tuple[list[str], list[str]]:
"""比对保存的指纹与当前配置。返回 (结构性不一致项, 决策性不一致项)。
参数:
saved: checkpoint 中保存的 config_fingerprint。
config: 当前训练配置对象。
返回:
(structural, decision) 两个不一致项名列表。
关键实现细节:
结构性不一致(batch_size/min_class_per_batch/epochs/diag_size/val_size/
batch_correct_ratio)→ 调用方应拒绝 resume;决策性不一致 → 仅告警放行。
"""
cur = compute_fingerprint(config)
structural = [k for k in _STRUCTURAL_KEYS if saved.get(k) != cur[k]]
decision = [k for k in _DECISION_KEYS if saved.get(k) != cur[k]]
return structural, decision
# ---------------------------------------------------------------------------
# 读写 checkpoint
# ---------------------------------------------------------------------------
def write_checkpoint(
workspace_dir: Path,
*,
state: Any,
epoch: int,
step_completed: int,
phase: str,
global_step: int,
total_steps: int,
version_snapshot: dict[str, str],
epoch_batches: list[list[str]],
config: Any,
) -> None:
"""原子写 checkpoint.json.tmp 再 os.replace)。
参数:
workspace_dir: workspace 目录,checkpoint.json 写入其下。
state: _TrainState 实例,交由 serialize_state 序列化。
epoch: 当前 epoch 序号。
step_completed: 本 epoch 内已完成的 step 数。
phase: 续训阶段标识(如 "in_epoch")。
global_step: 全局 step 序号。
total_steps: 全局总 step 数。
version_snapshot: skills/prompts 版本快照。
epoch_batches: 本 epoch 的 batch 划分(question_id 列表的列表)。
config: 训练配置对象,用于计算 config_fingerprint。
关键实现细节:
先写 checkpoint.json.tmp 再 os.replace,保证 checkpoint 不被写一半的中断破坏。
"""
payload = {
"schema_version": CHECKPOINT_SCHEMA_VERSION,
"progress": {
"epoch": epoch,
"step_completed": step_completed,
"phase": phase,
"global_step": global_step,
"total_steps": total_steps,
},
"version_snapshot": version_snapshot,
"epoch_batches": epoch_batches,
"config_fingerprint": compute_fingerprint(config),
"state": serialize_state(state),
}
path = workspace_dir / "checkpoint.json"
tmp = path.with_name("checkpoint.json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
os.replace(tmp, path)
def load_checkpoint(workspace_dir: Path) -> dict[str, Any] | None:
"""读 checkpoint.json,不存在返回 None。
参数:
workspace_dir: workspace 目录。
返回:
checkpoint payload dictcheckpoint.json 不存在时返回 None。
"""
path = workspace_dir / "checkpoint.json"
if not path.exists():
return None
return json.loads(path.read_text())
+335
View File
@@ -0,0 +1,335 @@
"""CE-Gate 信息量阶梯与基线缓存。
阶梯(每题型一条):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_" 过滤观测源)。
BaselineCache:基线侧逐题对错缓存,键 = (task_type, skill_hash,
prompts_version, qid) 内容寻址、无显式失效。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
if TYPE_CHECKING:
from pathlib import Path
from core.types import GeneratedQuestion
def skill_hash(content: str) -> str:
"""对 skill 正文取 sha1 摘要,作缓存键的内容维度。
参数:
content: skill 文件全文(基线侧为解析后生效文件的正文)。
返回:
sha1 十六进制摘要。
"""
return hashlib.sha1(content.encode("utf-8")).hexdigest()
@dataclass
class LadderEntry:
"""阶梯单元:题目与其估计答对率。
字段:
question_id: 题目唯一标识。
p_hat: 估计答对率。冷启动为 Beta(1,1) 平滑的单次观测后验均值
(错=1/3、对=2/3),此后经 gamma-EMA 更新。
"""
question_id: str
p_hat: float
def build_cold_entries(
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
probe_quota: float,
seed: int,
) -> list[LadderEntry]:
"""冷启动排序:错题高优先 2:1 交错 + 全错题 probe_quota 探针插尾。
参数:
questions: 该题型的全部候选题(已排除 test 池)。
correctness: question_id -> 种子基线是否答对(900 题全量对错)。
probe_quota: 从错题中随机抽出插到梯尾的探针比例(防"解锁新能力"盲区)。
seed: 洗牌种子,保证确定性重建。
返回:
排序后的 LadderEntry 列表(p_hat 用 Beta(1,1) 平滑:错=1/3、对=2/3
与 warm 阶段 gamma-EMA / 信息量排序自然衔接)。
关键实现细节:
错题、对题各自固定种子洗牌 -> 抽探针 -> 剩余按 错错对 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)]
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] = []
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(q: GeneratedQuestion) -> float:
return 2 / 3 if correctness.get(q.question_id, False) else 1 / 3
return [LadderEntry(q.question_id, _p0(q)) for q 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_qids: set[str],
p_low: float,
p_high: float,
cold: bool,
) -> list[str]:
"""取该题型的 gate 出题序(qid 列表),排除本 step 进化案例包题。
参数:
task_type: 目标题型。
exclude_qids: 本 step 案例包(failure/success cases)的题目 id
防止在"刚学的那道题"上自测。
p_low / p_high: warm 阶段的 p_hat 保留区间。
cold: True 表示尚无 epoch 级观测(epoch 1),用冷启动存储序;
False 走 order_ladder 信息量排序。
返回:
排除后的有序 question_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.question_id for e in ordered if e.question_id not in exclude_qids]
def update_probs(self, observations: dict[str, bool], gamma: float) -> None:
"""gamma-EMA 更新 p_hatp_hat <- gamma * p_hat + (1-gamma) * obs。只更新有新观测的题。
参数:
observations: question_id -> 本 epoch 非 gate run 的最新对错。
调用方必须已按 run_id 过滤掉 gate 内 rollout(防泄露铁律)。
gamma: EMA 衰减系数。
"""
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
def save(self, path: Path) -> None:
"""原子写 gate_pools.json.tmp 再 replace)。
参数:
path: 目标 JSON 路径。
"""
payload = {
"seed": self.seed,
"fingerprint": self.fingerprint,
"entries": {
t: [{"question_id": e.question_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 恢复。
参数:
path: gate_pools.json 路径。
返回:
复活的 GatePools。
"""
d = json.loads(path.read_text(encoding="utf-8"))
return cls(
entries={
t: [LadderEntry(x["question_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:
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]))
pools = GatePools(entries=entries, seed=seed, fingerprint=fingerprint)
pools.save(path)
return pools
class BaselineCache:
"""基线侧逐题对错缓存(内容寻址,JSON 持久化)。
键 = (task_type, skill_hash, prompts_version, qid):任何影响该题型
有效 skill 的变化(含共享 default-strategy.md 被他类 accept 改写)
都使 skill_hash 变化、缓存自然 missprompts 版本变化同理。
"""
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, qid: str) -> str:
"""拼缓存键(四维内容寻址)。"""
return f"{task_type}|{s_hash}|{prompts_version}|{qid}"
def get(self, task_type: str, s_hash: str, prompts_version: str, qid: str) -> bool | None:
"""读缓存;未命中返回 None。
参数:
task_type: 题型。
s_hash: 基线侧生效 skill 文件的内容哈希。
prompts_version: 当前 prompts 版本。
qid: 题目 id。
返回:
缓存的对错;未命中 None。
"""
return self._store.get(self._key(task_type, s_hash, prompts_version, qid))
def put(
self, task_type: str, s_hash: str, prompts_version: str, qid: str, correct: bool
) -> None:
"""写缓存并落盘(原子写,gate 频度低、全量重写成本可忽略)。
参数:
task_type / s_hash / prompts_version / qid: 缓存键四维。
correct: 基线侧该题对错。
关键实现细节:
先盘后存:新条目先原子落盘(tmp 写 + os.replace)成功后才更新
内存,磁盘写失败时内存与磁盘一致(均无新条目),无分裂窗口。
"""
updated = {
**self._store,
self._key(task_type, s_hash, prompts_version, qid): 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
+441
View File
@@ -0,0 +1,441 @@
"""async 推理编排 — 训练循环的 forward()。
从 TRM4 core/harness/inference.py (~560 行) 迁移,重大重构:
- 同步 ThreadPoolExecutor → asyncio.Semaphore + asyncio.gather
- LLMClient.from_env() 每题构造 → llm: LLMProvider 注入共享
- SentenceTransformer/OCR 内部构造 → 调用方通过 tool_dispatch_fn 注入
- run_id 必传,空串 → ValueError
- _aggregate_results 从内存 results 聚合(非 DB 回读)
- record_run 由调用方(Runner)负责
- prompt 构建由调用方注入 prompt_builder
"""
from __future__ import annotations
import asyncio
import json
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from loguru import logger
from core.agent.loop import AgentLoop
if TYPE_CHECKING:
from collections.abc import Callable
from app.harness.log import HarnessLog
from core.agent.types import LoopResult
from core.protocols import LLMProvider
from core.types import GeneratedQuestion
@dataclass(frozen=True)
class InferenceResult:
"""推理聚合结果。
属性:
run_id: 运行标识。
accuracy: 总正确率。
total: 总题数。
correct: 正确题数。
per_task_type: 按题型分组的指标 {task_type: {accuracy, total, correct}}。
steps_mean: 平均步数。
token_usage: token 总用量 {prompt_tokens, completion_tokens}。
stop_reason_counts: 终止原因计数 {reason: count}。
"""
run_id: str
accuracy: float
total: int
correct: int
per_task_type: dict[str, dict]
steps_mean: float
token_usage: dict[str, int]
stop_reason_counts: dict[str, int]
# ---------------------------------------------------------------------------
# 表 Schema 定义(5 张表,保留 TRM4 全部 schema
# ---------------------------------------------------------------------------
PREDICTIONS_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"task_type": "TEXT",
"prediction": "TEXT",
"answer": "TEXT",
"evidence": "TEXT",
"reasoning": "TEXT",
"steps_used": "INTEGER",
"prompt_tokens": "INTEGER",
"completion_tokens": "INTEGER",
"stop_reason": "TEXT",
"steps_json": "JSON",
}
TRACES_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"step": "INTEGER",
"tool_name": "TEXT",
"tool_args": "JSON",
"tool_output": "TEXT",
"thought": "TEXT",
}
VALIDATION_FLAGS_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"has_l3_visit": "INTEGER",
"l1_count": "INTEGER",
"l2_count": "INTEGER",
"l3_count": "INTEGER",
}
ANCHOR_CHECK_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"step": "INTEGER",
"n_assertions": "INTEGER",
"n_anchored": "INTEGER",
"n_illegal": "INTEGER",
"n_expanded": "INTEGER",
"n_trunc": "INTEGER",
"output_chars": "INTEGER",
}
OF_HEALTH_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"step": "INTEGER",
"ocr_injected": "INTEGER",
"ocr_chars": "INTEGER",
"ocr_failed": "INTEGER",
"discrepancy": "INTEGER",
"abstain": "INTEGER",
}
# ---------------------------------------------------------------------------
# 内部工具
# ---------------------------------------------------------------------------
class _DispatcherAdapter:
"""将裸 async callable 包装为 ToolDispatcher Protocol 实例。
AgentLoop 要求 ToolDispatcher(有 dispatch 方法),而 run_inference
接收的 tool_dispatch_fn 是裸 async callable。此适配器桥接两者。
参数:
fn: async def (tool_name, args, *, context) -> str。
"""
def __init__(self, fn: Callable[..., Any]) -> None:
self._fn = fn
async def dispatch(
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str:
"""转发工具调用给被包装的 callable。"""
return await self._fn(tool_name, args, context=context)
def _to_text_field(value: Any) -> str:
"""把 prediction 的 evidence/reasoning 归一为可入库的文本。
LLM 有时把这些字段返回成 list 或 dict(而非字符串)。sqlite 无法绑定
非标量类型,直接入库会抛 ProgrammingError 致该题丢失预测行、进而触发
rollout 完整性护栏中止整轮。凡非 str 一律 JSON 序列化为文本。
参数:
value: evidence/reasoning 原始值(可能是 str/list/dict)。
返回:
可直接入库的字符串。
"""
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
def _zero_result(run_id: str) -> InferenceResult:
"""空记录时的零值 InferenceResult。
参数:
run_id: 运行标识。
返回:
全零的 InferenceResult。
"""
return InferenceResult(
run_id=run_id,
accuracy=0.0,
total=0,
correct=0,
per_task_type={},
steps_mean=0.0,
token_usage={"prompt_tokens": 0, "completion_tokens": 0},
stop_reason_counts={},
)
def _group_by_task_type(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""按 task_type 分组聚合正确率指标。
参数:
records: 预测记录列表。
返回:
{task_type: {accuracy, total, correct}} 映射。
"""
task_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for r in records:
task_groups[r["task_type"]].append(r)
per_task_type: dict[str, dict[str, Any]] = {}
for task_type, group in task_groups.items():
t_total = len(group)
t_correct = sum(1 for r in group if r["prediction"] == r["answer"])
per_task_type[task_type] = {
"accuracy": t_correct / t_total,
"total": t_total,
"correct": t_correct,
}
return per_task_type
def _aggregate_results(records: list[dict[str, Any]], run_id: str) -> InferenceResult:
"""从内存 records 聚合推理指标。
TRM4 从 DB 回读 predictions 表聚合;TRM5 改为从内存直接聚合,
避免 DB 回读的同步开销和额外依赖。
参数:
records: _run_single_question 返回的 record 列表。
run_id: 当前运行标识。
返回:
InferenceResult 冻结实例。
"""
total = len(records)
if total == 0:
return _zero_result(run_id)
correct = sum(1 for r in records if r["prediction"] == r["answer"])
stop_counts: dict[str, int] = defaultdict(int)
for r in records:
stop_counts[r["stop_reason"]] += 1
return InferenceResult(
run_id=run_id,
accuracy=correct / total,
total=total,
correct=correct,
per_task_type=_group_by_task_type(records),
steps_mean=sum(r["steps_used"] for r in records) / total,
token_usage={
"prompt_tokens": sum(r["prompt_tokens"] for r in records),
"completion_tokens": sum(r["completion_tokens"] for r in records),
},
stop_reason_counts=dict(stop_counts),
)
# ---------------------------------------------------------------------------
# 单题推理
# ---------------------------------------------------------------------------
async def _run_single_question(
qa: GeneratedQuestion,
*,
llm: LLMProvider,
tool_dispatch_fn: Callable[..., Any],
prompt_builder: Callable[[GeneratedQuestion], tuple[str, str]],
log: HarnessLog,
max_steps: int,
plugins: list[object],
) -> dict[str, Any]:
"""执行单道题目的 Agent 推理。
悲观默认值:record 初始 stop_reason="error",成功后覆盖。
prediction 必落库:log.insert 在 try/except 之后(无论成败)。
参数:
qa: 待推理的题目。
llm: LLMProvider 共享实例。
tool_dispatch_fn: async 工具调度函数 (tool_name, args, *, context) -> str。
prompt_builder: (GeneratedQuestion) -> (system_prompt, user_prompt)。
log: HarnessLog 实例(线程安全)。
max_steps: AgentLoop 最大步数。
plugins: pluggy 插件列表。
返回:
预测结果字典(含 video_id, question_id, prediction, answer 等)。
"""
record: dict[str, Any] = {
"video_id": qa.video_id,
"question_id": qa.question_id,
"task_type": qa.task_type,
"prediction": None,
"answer": qa.answer,
"evidence": "",
"reasoning": "",
"steps_used": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"stop_reason": "error", # 悲观默认
"steps_json": "[]",
}
try:
system_prompt, user_prompt = prompt_builder(qa)
dispatcher = _DispatcherAdapter(tool_dispatch_fn)
loop = AgentLoop(llm, max_steps=max_steps)
loop_result: LoopResult = await loop.run(
system_prompt,
user_prompt,
dispatcher,
plugins=plugins,
session_id=qa.question_id,
)
result_dict = loop_result.result if isinstance(loop_result.result, dict) else {}
evidence = _to_text_field(result_dict.get("evidence", ""))
reasoning = _to_text_field(result_dict.get("reasoning", ""))
record.update(
{
"prediction": result_dict.get("answer"),
"evidence": evidence,
"reasoning": reasoning,
"steps_used": loop_result.steps_used,
"prompt_tokens": loop_result.token_usage["prompt_tokens"],
"completion_tokens": loop_result.token_usage["completion_tokens"],
"stop_reason": loop_result.stop_reason,
"steps_json": json.dumps(
[
{
"thought": s.thought,
"tool_call": s.tool_call,
"tool_output": s.tool_output,
}
for s in loop_result.steps
],
ensure_ascii=False,
),
}
)
except Exception:
logger.exception("[{}] QA {} 执行异常", qa.video_id, qa.question_id)
# prediction 必落库(try 外,无论成败)
await asyncio.to_thread(log.insert, "predictions", record)
return record
# ---------------------------------------------------------------------------
# 建表
# ---------------------------------------------------------------------------
def _ensure_tables(log: HarnessLog) -> None:
"""创建推理所需的 5 张表。
参数:
log: HarnessLog 实例。
"""
log.create_table("predictions", PREDICTIONS_SCHEMA)
log.create_table("traces", TRACES_SCHEMA)
log.create_table("validation_flags", VALIDATION_FLAGS_SCHEMA)
log.create_table("anchor_check", ANCHOR_CHECK_SCHEMA)
log.create_table("observe_frame_health", OF_HEALTH_SCHEMA)
# ---------------------------------------------------------------------------
# 公共入口
# ---------------------------------------------------------------------------
async def run_inference(
questions: list[GeneratedQuestion],
*,
llm: LLMProvider,
tool_dispatch_fn: Callable[..., Any],
prompt_builder: Callable[[GeneratedQuestion], tuple[str, str]],
log: HarnessLog,
run_id: str,
concurrency: int,
max_steps: int,
skill_mode: str,
plugins_factory: Callable[[str, str], list[object]] | None = None,
) -> InferenceResult:
"""在视频树上执行 Agent 推理,对应训练循环的 forward()。
参数:
questions: 待推理的题目列表。
llm: LLMProvider 共享实例(依赖注入)。
tool_dispatch_fn: async 工具调度函数 (tool_name, args, *, context) -> str。
prompt_builder: prompt 构建函数 (GeneratedQuestion) -> (system_prompt, user_prompt)。
log: HarnessLog 实例(由调用方管理生命周期)。
run_id: 运行标识(必传,空串 → ValueError)。
concurrency: 最大并发数(asyncio.Semaphore 控制)。
max_steps: AgentLoop 单题最大步数。
skill_mode: "auto" / "manual" / "none"(传递给调用方的 prompt/plugin 构建逻辑)。
plugins_factory: 可选的插件工厂 (video_id, question_id) -> plugins 列表。
返回:
InferenceResult(含 accuracy、per_task_type 等聚合指标)。
异常:
ValueError: run_id 为空串或纯空白。
"""
if not run_id or not run_id.strip():
raise ValueError("run_id 不得为空串或纯空白")
_ensure_tables(log)
if not questions:
logger.info("题目列表为空,返回零值 InferenceResult")
return _aggregate_results([], run_id)
sem = asyncio.Semaphore(concurrency)
total_count = len(questions)
async def _bounded(index: int, qa: GeneratedQuestion) -> dict[str, Any]:
"""信号量限流的单题推理包装。"""
async with sem:
plugins = (
plugins_factory(qa.video_id, qa.question_id) if plugins_factory is not None else []
)
result = await _run_single_question(
qa,
llm=llm,
tool_dispatch_fn=tool_dispatch_fn,
prompt_builder=prompt_builder,
log=log,
max_steps=max_steps,
plugins=plugins,
)
logger.info(
"[{}/{}] {} QA {} 完成 (stop={})",
index + 1,
total_count,
qa.video_id,
qa.question_id,
result["stop_reason"],
)
return result
results = await asyncio.gather(*[_bounded(i, qa) for i, qa in enumerate(questions)])
inference_result = _aggregate_results(list(results), run_id)
logger.info(
"推理完成: accuracy={:.2%} ({}/{})",
inference_result.accuracy,
inference_result.correct,
inference_result.total,
)
return inference_result
+7 -21
View File
@@ -68,9 +68,7 @@ class HarnessLog:
self._conn.execute("PRAGMA journal_mode=WAL")
self._init_fixed_tables()
resolved_sha = git_sha or _get_git_sha()
config_json = (
json.dumps(config_snapshot, ensure_ascii=False) if config_snapshot else None
)
config_json = json.dumps(config_snapshot, ensure_ascii=False) if config_snapshot else None
self._conn.execute(
"INSERT OR IGNORE INTO _runs"
" (run_id, git_sha, started_at, config, status)"
@@ -143,18 +141,14 @@ class HarnessLog:
col_names = ", ".join(cols)
values = [enriched[c] for c in cols]
if mode == "upsert":
sql = (
f"INSERT OR REPLACE INTO {table} ({col_names}) VALUES ({placeholders})"
)
sql = f"INSERT OR REPLACE INTO {table} ({col_names}) VALUES ({placeholders})"
else:
sql = f"INSERT INTO {table} ({col_names}) VALUES ({placeholders})"
with self._lock:
self._conn.execute(sql, values)
self._conn.commit()
def insert_many(
self, table: str, records: list[dict[str, Any]], mode: str = "append"
) -> None:
def insert_many(self, table: str, records: list[dict[str, Any]], mode: str = "append") -> None:
"""批量插入多条记录。
参数:
@@ -192,9 +186,7 @@ class HarnessLog:
with self._lock:
cursor = self._conn.execute(sql, params)
columns = [desc[0] for desc in cursor.description]
return [
dict(zip(columns, row, strict=True)) for row in cursor.fetchall()
]
return [dict(zip(columns, row, strict=True)) for row in cursor.fetchall()]
def log_event(self, event_type: str, payload: dict[str, Any]) -> None:
"""向 _events 表写入一条事件。
@@ -205,8 +197,7 @@ class HarnessLog:
"""
with self._lock:
self._conn.execute(
"INSERT INTO _events (run_id, timestamp, event_type, payload)"
" VALUES (?, ?, ?, ?)",
"INSERT INTO _events (run_id, timestamp, event_type, payload) VALUES (?, ?, ?, ?)",
(
self._run_id,
_now_iso(),
@@ -280,15 +271,10 @@ def _read_table(
if question_ids is not None:
placeholders = ", ".join(["?"] * len(question_ids))
sql = (
f"SELECT * FROM {table}"
f" WHERE run_id = ? AND question_id IN ({placeholders})"
)
sql = f"SELECT * FROM {table} WHERE run_id = ? AND question_id IN ({placeholders})"
rows = conn.execute(sql, (run_id, *question_ids)).fetchall()
else:
rows = conn.execute(
f"SELECT * FROM {table} WHERE run_id = ?", (run_id,)
).fetchall()
rows = conn.execute(f"SELECT * FROM {table} WHERE run_id = ?", (run_id,)).fetchall()
return [dict(r) for r in rows]
finally:
+178
View File
@@ -0,0 +1,178 @@
"""慢更新动量生成 — epoch 末为单个 skill 产出新的动量指导。
对标 SkillOpt 的 slow_update 机制:拿上一 epoch 末与当前 epoch 末两版 skill
在固定样本上各跑一遍得到纵向对比(comparison_pairs),反思上一轮动量指导是否奏效、
本轮正文改动是改善还是漂移,据此重写动量指导。新指导经 patch 引擎的 replace_momentum
写回 skill 的 momentum 受保护区,作为下一轮进化的方向锚。
从 TRM4 core/harness/momentum.py156 行)迁移 + async 化。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from loguru import logger
from core.evolution.diagnose import extract_json_from_response
if TYPE_CHECKING:
from pathlib import Path
from core.protocols import LLMProvider
# =========================================================================
# 四类纵向对比类别名(单一真源)
# =========================================================================
IMPROVED = "improved" # 错→对
REGRESSED = "regressed" # 对→错
PERSISTENT_FAIL = "persistent_fail" # 错→错
STABLE_SUCCESS = "stable_success" # 对→对
# 类别名 → 展示标题,列表顺序即展示顺序。
# 回退(REGRESSED)刻意排在改善(IMPROVED)之前——它是最该警惕的伤害信号。
_CATEGORY_LABELS: tuple[tuple[str, str], ...] = (
(REGRESSED, "从对变错(回退,最高优先级)"),
(PERSISTENT_FAIL, "始终答错(持续失败)"),
(IMPROVED, "从错变对(改善)"),
(STABLE_SUCCESS, "始终答对(稳定成功)"),
)
# =========================================================================
# 辅助函数
# =========================================================================
def _categorize_pair(pair: dict[str, Any]) -> str:
"""按两版正误派生纵向对比类别。
用键值的真值(bool(...))表示该题在两版上各自的正误:缺 correct_prev/
correct_curr 键时直接抛 KeyError 向上传播——这是上游数据损坏(不是裁判语义
歧义),静默当 False 会伪造 persistent_fail 证据、污染动量指导,故不掩盖。
参数:
pair: 单个纵向对比对,须含 correct_prev/correct_curr 两键。
返回:
四个类别命名常量之一:IMPROVED(错→对)/REGRESSED(对→错)/
PERSISTENT_FAIL(错→错)/STABLE_SUCCESS(对→对)。
异常:
KeyError: 缺 correct_prev 或 correct_curr 键时。
"""
correct_prev = bool(pair["correct_prev"])
correct_curr = bool(pair["correct_curr"])
if not correct_prev and correct_curr:
return IMPROVED
if correct_prev and not correct_curr:
return REGRESSED
if not correct_prev and not correct_curr:
return PERSISTENT_FAIL
return STABLE_SUCCESS
def _format_comparison_pairs(comparison_pairs: list[dict[str, Any]]) -> str:
"""将纵向对比对格式化为裁判可读文本,按 _CATEGORY_LABELS 分组与排序。
参数:
comparison_pairs: 每个 dict 含 question/prev_prediction/curr_prediction/
correct_prev/correct_curr 字段,描述一道固定样本上两版的成对结果。
返回:
可读的纵向对比文本;空列表返回占位说明。
异常:
KeyError: 任一 pair 缺 correct_prev/correct_curr 键时;不掩盖的理由见
_categorize_pair docstring。
"""
if not comparison_pairs:
return "(本轮无可用纵向对比样本)"
grouped: dict[str, list[dict[str, Any]]] = {key: [] for key, _ in _CATEGORY_LABELS}
for pair in comparison_pairs:
grouped[_categorize_pair(pair)].append(pair)
lines: list[str] = [f"固定样本总数:{len(comparison_pairs)}"]
for key, label in _CATEGORY_LABELS:
entries = grouped[key]
lines.append(f"\n### {label}{len(entries)} 题)")
if not entries:
lines.append("(无)")
continue
for pair in entries:
lines.append(
f"- 题目:{pair.get('question', '')}\n"
f" 上版预测:{pair.get('prev_prediction', '')} | "
f"当前版预测:{pair.get('curr_prediction', '')}"
)
return "\n".join(lines)
# =========================================================================
# 入口
# =========================================================================
async def run_slow_momentum(
llm: LLMProvider,
diagnose_prompts_dir: Path,
skill_content: str,
prev_skill: str,
prev_guidance: str,
comparison_pairs: list[dict[str, Any]],
) -> str:
"""为单个 skill 生成新的慢更新动量指导。
参数:
llm: LLM 端口(async chat)。
diagnose_prompts_dir: 诊断 prompt 目录(根 prompts/slow_momentum.md 在此)。
skill_content: 当前版 skill 正文。
prev_skill: 上一版 skill 正文。
prev_guidance: 上一轮写下的动量指导。
comparison_pairs: 固定样本上两版 rollout 的成对结果(含 question/
prev_prediction/curr_prediction/correct_prev/correct_curr)。
返回:
新的动量指导文本;解析失败时保留 prev_guidance。
关键实现细节:
- _format_comparison_pairs 刻意置于 try 块之外(prompt 构造阶段):它对每个
pair 取 correct_prev/correct_curr,缺键抛 KeyError 直接向上传播,不被下方
针对裁判语义歧义的 except ValueError 吞掉。
- 解析失败保留上轮指导:extract_json_from_response 抛 ValueError、缺
slow_update_content 字段、或该字段非 str,均视为语义解析失败,返回
prev_guidance(判不准时保守保留上轮指导,对标 diagnose 的保护性 fallback)。
- P5 边界:仅捕 ValueError 这一语义歧义;llm.chat 的基础设施失败
(网络/API 异常)刻意不捕,向上传播,绝不用默认值掩盖。
"""
system_prompt = (diagnose_prompts_dir / "slow_momentum.md").read_text(encoding="utf-8")
# _format_comparison_pairs 刻意置于下方 try 块之外(prompt 构造阶段):它对每个
# pair 取 correct_prev/correct_curr,缺键抛 KeyError 直接向上传播,不被下方针对
# 裁判语义歧义的 except ValueError 吞掉。异常类型选 KeyError(非 ValueError),
# 即便位置疏忽落入 try 也不会被误吞。
user_prompt = (
f"## 上一版 skill 正文\n{prev_skill}\n\n"
f"## 当前版 skill 正文\n{skill_content}\n\n"
f"## 上一轮的动量指导\n{prev_guidance}\n\n"
f"## 固定样本纵向对比(上版 vs 当前版)\n"
f"{_format_comparison_pairs(comparison_pairs)}"
)
response = await llm.chat(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
)
raw = response.content
try:
parsed = extract_json_from_response(raw)
new_guidance = parsed.get("slow_update_content")
if not isinstance(new_guidance, str):
raise ValueError("slow_update_content 字段缺失或非字符串")
except ValueError:
logger.warning("慢更新动量解析失败,保留上轮动量指导")
return prev_guidance
return new_guidance
+459
View File
@@ -0,0 +1,459 @@
"""五张观测表的落库写入与回读 + step/epoch 报告文件输出。
合并 TRM4 的 metric_log.py(五表)和 loop_report.py(报告)。
五张表均经 structured-logging 定义,DDL 与之逐列一致:
dual_metric_eval / shadow_gate / holdout_eval / quadrant_pair / gate_evidence。
公共契约(守 P5):soft/mixed 为 Noneinvalid,无 span / 诊断失败)时存 NULL**绝不存 0**——
SQLite 对 dict 中 None 值写入即 NULL,分析时按 NULL 跳过。每个写函数内幂等建表
``HarnessLog.create_table`` 用 CREATE TABLE IF NOT EXISTS),run_id/timestamp 列由
HarnessLog 自动补。
报告函数输出 JSON 到 workspace 的 analyses/ 目录,供人工审查诊断 prompt 与进化 prompt。
"""
from __future__ import annotations
import json
import sqlite3
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
def _read_table(db_path: str, table: str, run_id: str) -> list[dict[str, Any]]:
"""纯读某表指定 run 的全部行——不经 HarnessLog 生命周期,避免回读污染 _runs 运行状态。
HarnessLog.__enter__/__exit__ 会对 run_id 做 INSERT OR IGNORE 并在退出时标 completed
回读指标绝不应改运行状态,故 read_* 一律走本只读连接(仅 SELECT)。
参数:
db_path: SQLite 路径。
table: 表名(内部固定常量,非外部输入,无注入风险)。
run_id: 过滤的 run ID。
返回:
行 dict 列表;表尚未建(没写过)视为无数据返 []。
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
exists = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone()
if exists is None:
return []
rows = conn.execute(f"SELECT * FROM {table} WHERE run_id=?", (run_id,)).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
# ---------------------------------------------------------------------------
# 列定义严格对齐 research-wiki/schemas/*.mdrun_id/timestamp 由 create_table 自动补)
# ---------------------------------------------------------------------------
_DUAL_COLS: dict[str, str] = {
"epoch": "INTEGER",
"version_kind": "TEXT",
"skills_version": "TEXT",
"prompts_version": "TEXT",
"pool": "TEXT",
"hard_acc": "REAL",
"soft_score": "REAL",
"mixed_score": "REAL",
}
_SHADOW_COLS: dict[str, str] = {
"epoch": "INTEGER",
"candidate_version": "TEXT",
"hard_acc": "REAL",
"soft_score": "REAL",
"mixed_score": "REAL",
"is_mixed_best": "INTEGER",
}
_HOLDOUT_COLS: dict[str, str] = {
"epoch": "INTEGER",
"version_kind": "TEXT",
"hard_acc": "REAL",
"soft_score": "REAL",
"mixed_score": "REAL",
"per_task_type_json": "TEXT",
}
_QUADRANT_COLS: dict[str, str] = {
"epoch": "INTEGER",
"step": "INTEGER",
"question_id": "TEXT",
"task_type": "TEXT",
"prev_correct": "INTEGER",
"curr_correct": "INTEGER",
"category": "TEXT",
}
_GATE_EVIDENCE_COLS: dict[str, str] = {
"epoch": "INTEGER",
"step": "INTEGER",
"task_type": "TEXT",
"question_id": "TEXT",
"block_idx": "INTEGER",
"baseline_correct": "INTEGER",
"candidate_correct": "INTEGER",
"e_value": "REAL",
"stop_reason": "TEXT",
}
# ---------------------------------------------------------------------------
# dual_metric_eval
# ---------------------------------------------------------------------------
def write_dual_metric(
db_path: str,
*,
run_id: str,
epoch: int,
version_kind: str,
skills_version: str,
prompts_version: str,
pool: str,
hard_acc: float,
soft_score: float | None,
mixed_score: float | None,
) -> None:
"""落 dual_metric_eval 一行:epoch 末关键版本的 hard+soft+mixed 双轨度量。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
version_kind: baseline / best_hard / best_mixed / final。
skills_version / prompts_version: 评估的资源版本。
pool: val / test。
hard_acc: hard 准确率。
soft_score: soft 连续分;invalid 传 None -> 存 NULL。
mixed_score: 0.5*hard+0.5*softsoft 缺失传 None -> 存 NULL。
"""
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("dual_metric_eval", _DUAL_COLS)
log.insert(
"dual_metric_eval",
{
"epoch": epoch,
"version_kind": version_kind,
"skills_version": skills_version,
"prompts_version": prompts_version,
"pool": pool,
"hard_acc": hard_acc,
"soft_score": soft_score,
"mixed_score": mixed_score,
},
)
def read_dual_metric(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 dual_metric_eval 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "dual_metric_eval", run_id)
# ---------------------------------------------------------------------------
# shadow_gate
# ---------------------------------------------------------------------------
def write_shadow_gate(
db_path: str,
*,
run_id: str,
epoch: int,
candidate_version: str,
hard_acc: float,
soft_score: float | None,
mixed_score: float | None,
is_mixed_best: bool,
) -> None:
"""落 shadow_gate 一行:mixed 影子 best 候选的 hard/soft/mixed 及是否 argmax 选中。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
candidate_version: 候选版本标识(如 skills/vX+prompts/vY)。
hard_acc: hard 准确率。
soft_score: soft 连续分;invalid 传 None -> 存 NULL(该版本不进 argmax)。
mixed_score: 0.5*hard+0.5*softsoft 缺失传 None -> 存 NULL。
is_mixed_best: 是否本 epoch mixed argmax 选中(存 1/0)。
"""
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("shadow_gate", _SHADOW_COLS)
log.insert(
"shadow_gate",
{
"epoch": epoch,
"candidate_version": candidate_version,
"hard_acc": hard_acc,
"soft_score": soft_score,
"mixed_score": mixed_score,
"is_mixed_best": int(is_mixed_best),
},
)
def read_shadow_gate(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 shadow_gate 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "shadow_gate", run_id)
# ---------------------------------------------------------------------------
# holdout_eval
# ---------------------------------------------------------------------------
def write_holdout_eval(
db_path: str,
*,
run_id: str,
epoch: int,
version_kind: str,
hard_acc: float,
soft_score: float | None,
mixed_score: float | None,
per_task_type_json: str,
) -> None:
"""落 holdout_eval 一行:四向 held-out 在 test 池的 hard+soft+mixed 及按题型细分。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
version_kind: baseline / best_hard / best_mixed / final。
hard_acc: hard 准确率。
soft_score: soft 连续分;invalid 传 None -> 存 NULL。
mixed_score: 0.5*hard+0.5*softsoft 缺失传 None -> 存 NULL。
per_task_type_json: 按 task_type 的 {accuracy,total,correct} JSON 串。
"""
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("holdout_eval", _HOLDOUT_COLS)
log.insert(
"holdout_eval",
{
"epoch": epoch,
"version_kind": version_kind,
"hard_acc": hard_acc,
"soft_score": soft_score,
"mixed_score": mixed_score,
"per_task_type_json": per_task_type_json,
},
)
def read_holdout_eval(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 holdout_eval 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "holdout_eval", run_id)
# ---------------------------------------------------------------------------
# quadrant_pair
# ---------------------------------------------------------------------------
def write_quadrant_pairs(
db_path: str,
*,
run_id: str,
epoch: int,
step: int,
pairs: list[dict[str, Any]],
) -> None:
"""落 quadrant_pair 多行:fast gate 后逐题四象限(prev/curr 翻转 + category)落库。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
step: epoch 内 step 序号(0-based)。
pairs: 每条含 question_id/task_type/prev_correct/curr_correct/category
prev_correct/curr_correct 为 bool,写库前转 0/1。
关键实现:
用 insert_many 批量落库;pairs 为空时只建表不插入(fast gate 无翻转的极端情况)。
"""
records = [
{
"epoch": epoch,
"step": step,
"question_id": pair["question_id"],
"task_type": pair["task_type"],
"prev_correct": int(pair["prev_correct"]),
"curr_correct": int(pair["curr_correct"]),
"category": pair["category"],
}
for pair in pairs
]
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("quadrant_pair", _QUADRANT_COLS)
if records:
log.insert_many("quadrant_pair", records)
def read_quadrant_pairs(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 quadrant_pair 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "quadrant_pair", run_id)
# ---------------------------------------------------------------------------
# gate_evidence
# ---------------------------------------------------------------------------
def write_gate_evidence(
db_path: str,
*,
run_id: str,
epoch: int,
step: int,
rows: list[dict[str, Any]],
) -> None:
"""落 gate_evidence 逐题行:CE-Gate 每次决策的可回放审计记录。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 该 gate 所属的轮次(1-based)。
step: epoch 内 step 序号(0-based)。
rows: 每题一行,含 question_id/task_type/block_idx/baseline_correct/
candidate_correct/e_value(该题所在块判定后的累计 e 值)/
stop_reason(仅最后一题携带最终 stop_reason,其余空串)。
关键实现:
逐行 insert(非 insert_many),保证每行独立事务。
"""
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("gate_evidence", _GATE_EVIDENCE_COLS)
for row in rows:
log.insert("gate_evidence", {"epoch": epoch, "step": step, **row})
def read_gate_evidence(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 gate_evidence 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "gate_evidence", run_id)
# ---------------------------------------------------------------------------
# 报告函数(从 TRM4 loop_report.py 迁移)
# ---------------------------------------------------------------------------
def write_step_report(
workspace_dir: Path,
epoch: int,
step: int,
global_step: int,
task_type: str,
gate_action: str,
candidate_acc: float,
class_baseline_acc: float,
edit_budget: int,
rank_clip_triggered: bool,
gate_w: int | None,
gate_l: int | None,
gate_e_value: float | None,
gate_n_used: int | None,
gate_stop_reason: str | None,
) -> Path:
"""写单个 (step, task_type) 快路径 gate 的最小观测记录 JSON。
文件名按 (epoch, step, task_type) 命名,slug 由 task_type 规范化(小写、空格转 '-')得到。
参数:
workspace_dir: 实验工作区目录。
epoch: 当前轮次(1-based)。
step: epoch 内 step 序号(0-based)。
global_step: 全局步计数(驱动 edit_budget 退火)。
task_type: 本条 gate 的任务类型。
gate_action: 闸门动作(accept_confirmed / accept_provisional / reject /
skipped / cooldown)。
candidate_acc: 候选在 gate 已观测题上的准确率(观测口径)。
class_baseline_acc: 基线在 gate 已观测题上的准确率(观测口径)。
edit_budget: 该 step 按 global_step 退火得到的 per-target 编辑预算上限。
rank_clip_triggered: 该 skill 进化是否触发了 rank 裁剪。
gate_w: e-process 累计 W(基线错->候选对翻转数);skipped/cooldown 路径传 None。
gate_l: e-process 累计 L(基线对->候选错翻转数);skipped/cooldown 路径传 None。
gate_e_value: 停时的 e 值;skipped/cooldown 路径传 None。
gate_n_used: gate 实际消费的阶梯题数;skipped/cooldown 路径传 None。
gate_stop_reason: e-process 停止原因;skipped/cooldown 路径传 None。
返回:
写入的 step_report 文件路径。
"""
report = {
"epoch": epoch,
"step": step,
"global_step": global_step,
"task_type": task_type,
"gate_action": gate_action,
"candidate_acc": candidate_acc,
"class_baseline_acc": class_baseline_acc,
"edit_budget": edit_budget,
"rank_clip_triggered": rank_clip_triggered,
"gate_w": gate_w,
"gate_l": gate_l,
"gate_e_value": gate_e_value,
"gate_n_used": gate_n_used,
"gate_stop_reason": gate_stop_reason,
}
slug = task_type.lower().replace(" ", "-")
out_dir = workspace_dir / "analyses"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"step_report_e{epoch}_s{step}_{slug}.json"
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def write_epoch_report(
workspace_dir: Path,
epoch: int,
system_tool_action: str,
momentum_updated_task_types: list[str],
best_val_acc: float,
) -> Path:
"""写 epoch 末慢更新汇总 JSON。
慢更新无单一 ValidationOutcome,故本函数只落慢更新可观测的最小集:
system/tool gate 动作、本 epoch 写过 momentum 的题型、慢更新后的全局 best。
参数:
workspace_dir: 实验工作区目录。
epoch: 当前轮次(1-based)。
system_tool_action: 慢更新 system/tool 动作(updated / reverted / none)。
momentum_updated_task_types: 本 epoch 写过 momentum 的题型列表。
best_val_acc: 慢更新后(含 best argmax)的全局 best 验证准确率。
返回:
写入的 epoch_report 文件路径。
"""
report = {
"epoch": epoch,
"system_tool_action": system_tool_action,
"momentum_updated_task_types": momentum_updated_task_types,
"best_val_acc": best_val_acc,
}
out_dir = workspace_dir / "analyses"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"epoch_report_{epoch}.json"
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return path
File diff suppressed because it is too large Load Diff
+665
View File
@@ -0,0 +1,665 @@
"""async 块序贯验证编排 — CE-Gate 局部验证的唯一独立子编排器。
TRM4 core/harness/validate.py (626 ) 迁移重大重构
- 同步 asyncrun_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 core.evolution import (
GateParams,
GateVerdict,
RejectedEdit,
classify_quadrants,
gate_decision,
pair_block,
)
if TYPE_CHECKING:
from app.harness.inference import InferenceResult
from app.harness.log import HarnessLog
from core.types import GeneratedQuestion
# 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负责绑定 llmtool_dispatch_fnprompt_builder
logconcurrencymax_stepsskill_mode 等共享依赖
validate 侧只传 questionsrun_idskills_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 二轨语义candidate_correctness 只含已观测题早停后是
阶梯前缀子集accept 时由 runner 按题粒度增量合并进 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 # 已观测题上的基线准确率(观测口径)
candidate_acc: float # 已观测题上的候选准确率(观测口径)
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/<base>
复制临时候选落 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 为键的行字典每行至少包含 predictionanswer
_correctsteps 等字段
"""
rows = log.query(
"SELECT question_id, prediction, answer, 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 _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(
chunk: list[GeneratedQuestion],
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], int, int]:
"""基线侧处理一个块:缓存优先,miss 的题新鲜跑基线版本并回写缓存。
参数:
chunk: 当前块的题目列表
task_type: 当前验证题型缓存键成分
s_hash: 基线侧生效 skill 的内容哈希缓存键成分
prompts_version: 当前 prompts 版本缓存键成分
baseline_cache: 基线侧逐题对错缓存
base_skills_dir: 基线 skills 版本目录
run_inference: 注入的 async 推理函数
log: HarnessLog 共享实例推理后读预测
run_id: 本块基线 run_id
返回:
(b_map, errors_inc, denom_inc)块内 question_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
]
errors_inc = 0
denom_inc = 0
if misses:
r_b = await run_inference(misses, 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)
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
async def _run_candidate_block(
chunk: list[GeneratedQuestion],
cand_dir: Path,
run_inference: RunInferenceFn,
log: HarnessLog,
run_id: str,
) -> tuple[dict[str, bool], int, int]:
"""候选侧处理一个块:全块新鲜跑候选版本并从 db 读逐题对错。
参数:
chunk: 当前块的题目列表
cand_dir: 已物化的候选 skills 目录
run_inference: 注入的 async 推理函数
log: HarnessLog 共享实例推理后读预测
run_id: 本块候选 run_id
返回:
(c_map, errors_inc, denom_inc)
"""
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
def _build_evidence_rows(
chunk: list[GeneratedQuestion],
b_map: dict[str, bool],
c_map: dict[str, bool],
task_type: str,
block_idx: int,
) -> list[dict]:
"""组装一个块的 gate_evidence 逐题证据行。
e_value None 待块判定后回填stop_reason 留空串待终态回填
参数:
chunk: 当前块的题目列表
b_map: 块内 question_id -> 基线对错
c_map: 块内 question_id -> 候选对错
task_type: 当前验证题型
block_idx: 当前块序号
返回:
逐题证据行列表
"""
return [
{
"question_id": q.question_id,
"task_type": task_type,
"block_idx": block_idx,
"baseline_correct": b_map[q.question_id],
"candidate_correct": c_map[q.question_id],
"e_value": None,
"stop_reason": "",
}
for q in chunk
]
# ---------------------------------------------------------------------------
# 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],
evidence_rows: list[dict],
task_type: str,
) -> ValidationOutcome:
"""将块循环终态判定组装为 ValidationOutcome。
参数:
verdict: 最后一块的 gate 判定结果
w: 累计 W基线错候选对翻转
l: 累计 L基线对候选错翻转
n_used: 已消费的阶梯题数
n_plan: 阶梯总题数
base_obs: 累计基线已观测对错
cand_obs: 累计候选已观测对错
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({qid: (base_obs[qid], cand_obs[qid]) for qid 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=cand_obs,
evidence_rows=evidence_rows,
)
# ---------------------------------------------------------------------------
# 主编排
# ---------------------------------------------------------------------------
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],
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 切阶梯前缀每块先补齐基线侧缓存 miss新鲜跑基线版本
并逐题写 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 作缓存键成分
plan: 已截断到 gate_n_max 的阶梯出题序
gate_params: e-process 判据阈值组
gate_block: 块大小
gate_guard_err: 跨块累计 INFRA 错误率护栏分母 >=10 才触发
baseline_cache: 基线侧逐题对错缓存
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
errors = 0
infra_denom = 0
evidence_rows: list[dict] = []
base_obs: dict[str, bool] = {}
cand_obs: 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)]
verdict: GateVerdict | None = None
for block_idx, chunk in enumerate(chunks):
# Phase 1: 基线侧(缓存优先,miss 新鲜跑)+ 候选侧(全块新鲜跑)
b_map, err_b, den_b = await _resolve_baseline_block(
chunk=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",
)
c_map, err_c, den_c = await _run_candidate_block(
chunk=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 才触发)
errors += err_b + err_c
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
block_rows = _build_evidence_rows(chunk, b_map, c_map, 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)
for row in block_rows:
row["e_value"] = verdict.e_value
evidence_rows.extend(block_rows)
if verdict.decision != "continue":
break
# 最后一块判定即终态(n_remaining=0 → provisional/inertia
assert verdict is not None, "空阶梯应已在 validate_skill_local 入口拒绝"
return _finalize_outcome(
verdict=verdict,
w=w,
l=l,
n_used=n_used,
n_plan=len(plan),
base_obs=base_obs,
cand_obs=cand_obs,
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: 块大小
gate_n_max: gate 题数上限
gate_guard_err: 跨块累计 INFRA 错误率护栏分母 >=10 才触发
baseline_cache: 基线侧逐题对错缓存
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} 阶梯为空,无法验证")
plan = 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,
plan=plan,
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)
+413
View File
@@ -0,0 +1,413 @@
"""app/harness/checkpoint.py 单元测试。
覆盖序列化/反序列化往返嵌套 dataclass 复活缺键硬失败
配置指纹结构性 vs 决策性判定原子写与 load 缺失场景
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from pathlib import Path
from app.harness.checkpoint import (
CHECKPOINT_SCHEMA_VERSION,
Probation,
check_fingerprint,
compute_fingerprint,
deserialize_state_fields,
load_checkpoint,
serialize_state,
write_checkpoint,
)
from core.evolution.types import (
CaseSample,
RejectedEdit,
SystemCasePack,
ToolCasePack,
)
# ---------------------------------------------------------------------------
# fixtures: 模拟 _TrainState 与 RunConfig
# ---------------------------------------------------------------------------
def _make_case_sample(**overrides: Any) -> CaseSample:
"""构造一个最小可用 CaseSample。"""
defaults: dict[str, Any] = {
"question_id": "q001",
"video_id": "v001",
"task_type": "temporal",
"question": "What happened?",
"options": ["A", "B", "C"],
"answer": "A",
"prediction": "B",
"correct": False,
"error_type": "reasoning",
"selection_reason": "worst",
"metrics": {"acc": 0.5},
"trace": [{"step": 1, "action": "search"}],
}
defaults.update(overrides)
return CaseSample(**defaults)
def _make_rejected_edit(**overrides: Any) -> RejectedEdit:
"""构造一个最小可用 RejectedEdit。"""
defaults: dict[str, Any] = {
"target_file": "temporal-reasoning.md",
"target_type": "skill",
"change_summary": "added step",
"delta": -0.05,
"source_version": "v2",
"epoch": 1,
"gate_w": 3,
"gate_l": 5,
"gate_e_value": 0.8,
"gate_delta_shrunk": -0.02,
}
defaults.update(overrides)
return RejectedEdit(**defaults)
def _make_system_pack() -> SystemCasePack:
"""构造包含嵌套 CaseSample 的 SystemCasePack。"""
return SystemCasePack(
stats={"pattern": "repeat_visit", "count": 3},
failure_cases=[_make_case_sample(question_id="q010")],
success_cases=[_make_case_sample(question_id="q011", correct=True, error_type=None)],
)
def _make_tool_pack() -> ToolCasePack:
"""构造 ToolCasePack。"""
return ToolCasePack(
tool_name="search_subtree",
target_files=["search_subtree_extract.md"],
stats={"completeness": 0.8},
failure_spans=[{"step": 2, "issue": "missing"}],
success_spans=[{"step": 3, "quality": "good"}],
)
def _make_probation() -> Probation:
"""构造包含嵌套 RejectedEdit 的 Probation。"""
return Probation(
task_type="temporal",
anchor_skills_version="v1",
target_file="temporal-reasoning.md",
correctness_snapshot={"q001": True, "q002": False},
opened_step=5,
pending_edits=[_make_rejected_edit()],
)
@dataclass
class _FakeState:
"""模拟 _TrainState 全部可持久化字段。"""
correctness: dict[str, bool]
eval_prev_acc: float
eval_prev_run_id: str
baseline_skills_version: str
baseline_prompts_version: str
steps_since_best_improved: int
epoch_start_skills: str
changed_task_types_this_epoch: set[str]
rejected_buffer: dict[str, list[RejectedEdit]]
system_packs: list[SystemCasePack]
tool_packs: list[ToolCasePack]
probations: dict[str, Probation]
gate_cooldown: dict[str, int]
gate_epoch_observed: dict[str, bool]
def _make_state() -> _FakeState:
"""构造一个填满全部字段的 _FakeState。"""
return _FakeState(
correctness={"q001": True, "q002": False},
eval_prev_acc=0.65,
eval_prev_run_id="run-abc",
baseline_skills_version="v1",
baseline_prompts_version="v1",
steps_since_best_improved=2,
epoch_start_skills="v1",
changed_task_types_this_epoch={"temporal", "causal"},
rejected_buffer={"temporal": [_make_rejected_edit()]},
system_packs=[_make_system_pack()],
tool_packs=[_make_tool_pack()],
probations={"temporal": _make_probation()},
gate_cooldown={"temporal": 3},
gate_epoch_observed={"temporal": True},
)
@dataclass(frozen=True)
class _FakeConfig:
"""模拟 RunConfig 的指纹相关字段。"""
batch_size: int = 8
min_class_per_batch: int = 2
epochs: int = 5
diag_size: int = 30
val_size: int = 50
batch_correct_ratio: float = 0.5
edit_budget_start: int = 6
edit_budget_end: int = 3
early_stop_patience: int = 3
use_slow_momentum: bool = True
skill_update_mode: str = "patch"
appendix_consolidate_threshold: int = 10
momentum_samples: int = 20
gate_e_confirm: float = 20.0
gate_e_provisional: float = 6.0
gate_w_net_min: int = 2
gate_delta_min: float = 0.02
gate_lambda_dir: float = -3.0
gate_e_rollback: float = 10.0
gate_block: int = 4
gate_n_max: int = 40
gate_p_low: float = 0.1
gate_p_high: float = 0.9
gate_probe_quota: float = 0.2
gate_gamma_decay: float = 0.9
gate_cooldown_steps: int = 2
gate_guard_err: float = 0.3
# =========================================================================
# 测试用例
# =========================================================================
class TestSerializeDeserializeRoundtrip:
"""序列化 → JSON 往返 → 反序列化应完全复原。"""
def test_serialize_deserialize_roundtrip(self) -> None:
state = _make_state()
serialized = serialize_state(state)
# JSON 往返(模拟实际落盘-读回)
json_str = json.dumps(serialized, ensure_ascii=False)
loaded = json.loads(json_str)
restored = deserialize_state_fields(loaded)
assert restored["correctness"] == state.correctness
assert restored["eval_prev_acc"] == state.eval_prev_acc
assert restored["eval_prev_run_id"] == state.eval_prev_run_id
assert restored["baseline_skills_version"] == state.baseline_skills_version
assert restored["baseline_prompts_version"] == state.baseline_prompts_version
assert restored["steps_since_best_improved"] == state.steps_since_best_improved
assert restored["epoch_start_skills"] == state.epoch_start_skills
assert restored["changed_task_types_this_epoch"] == state.changed_task_types_this_epoch
assert restored["gate_cooldown"] == state.gate_cooldown
assert restored["gate_epoch_observed"] == state.gate_epoch_observed
class TestSerializeSetToSortedList:
"""set 字段序列化为排序列表。"""
def test_serialize_set_to_sorted_list(self) -> None:
state = _make_state()
state.changed_task_types_this_epoch = {"z_type", "a_type", "m_type"}
serialized = serialize_state(state)
assert serialized["changed_task_types_this_epoch"] == ["a_type", "m_type", "z_type"]
class TestDeserializeNestedSystemPack:
"""SystemCasePack 内嵌套的 CaseSample 正确复活。"""
def test_deserialize_nested_system_pack(self) -> None:
state = _make_state()
serialized = serialize_state(state)
json_str = json.dumps(serialized, ensure_ascii=False)
loaded = json.loads(json_str)
restored = deserialize_state_fields(loaded)
packs = restored["system_packs"]
assert len(packs) == 1
pack = packs[0]
assert isinstance(pack, SystemCasePack)
assert len(pack.failure_cases) == 1
assert isinstance(pack.failure_cases[0], CaseSample)
assert pack.failure_cases[0].question_id == "q010"
assert len(pack.success_cases) == 1
assert isinstance(pack.success_cases[0], CaseSample)
assert pack.success_cases[0].question_id == "q011"
class TestDeserializeNestedProbation:
"""Probation 内嵌套的 RejectedEdit 正确复活。"""
def test_deserialize_nested_probation(self) -> None:
state = _make_state()
serialized = serialize_state(state)
json_str = json.dumps(serialized, ensure_ascii=False)
loaded = json.loads(json_str)
restored = deserialize_state_fields(loaded)
probations = restored["probations"]
assert "temporal" in probations
prob = probations["temporal"]
assert isinstance(prob, Probation)
assert prob.task_type == "temporal"
assert prob.anchor_skills_version == "v1"
assert prob.correctness_snapshot == {"q001": True, "q002": False}
assert len(prob.pending_edits) == 1
edit = prob.pending_edits[0]
assert isinstance(edit, RejectedEdit)
assert edit.target_file == "temporal-reasoning.md"
assert edit.delta == -0.05
class TestDeserializeMissingKeyRaises:
"""缺键即 checkpoint 损坏,应硬失败。"""
def test_deserialize_missing_key_raises(self) -> None:
state = _make_state()
serialized = serialize_state(state)
del serialized["gate_epoch_observed"]
with pytest.raises(KeyError):
deserialize_state_fields(serialized)
class TestFingerprintStructuralVsDecision:
"""compute_fingerprint 包含全部结构性 + 决策性键。"""
def test_fingerprint_structural_vs_decision(self) -> None:
config = _FakeConfig()
fp = compute_fingerprint(config)
structural = {
"batch_size",
"min_class_per_batch",
"epochs",
"diag_size",
"val_size",
"batch_correct_ratio",
}
decision = {
"edit_budget_start",
"edit_budget_end",
"early_stop_patience",
"use_slow_momentum",
"skill_update_mode",
"appendix_consolidate_threshold",
"momentum_samples",
"gate_e_confirm",
"gate_e_provisional",
"gate_w_net_min",
"gate_delta_min",
"gate_lambda_dir",
"gate_e_rollback",
"gate_block",
"gate_n_max",
"gate_p_low",
"gate_p_high",
"gate_probe_quota",
"gate_gamma_decay",
"gate_cooldown_steps",
"gate_guard_err",
}
assert structural | decision == set(fp.keys())
assert fp["batch_size"] == 8
assert fp["gate_e_confirm"] == 20.0
class TestCheckFingerprintStructuralReject:
"""结构性键变化应出现在 structural 列表中。"""
def test_check_fingerprint_structural_reject(self) -> None:
config_old = _FakeConfig()
saved = compute_fingerprint(config_old)
# 修改结构性参数
config_new = _FakeConfig(batch_size=16, epochs=10)
structural, decision = check_fingerprint(saved, config_new)
assert "batch_size" in structural
assert "epochs" in structural
assert len(decision) == 0
class TestCheckFingerprintDecisionWarn:
"""决策性键变化应出现在 decision 列表中,structural 为空。"""
def test_check_fingerprint_decision_warn(self) -> None:
config_old = _FakeConfig()
saved = compute_fingerprint(config_old)
config_new = _FakeConfig(early_stop_patience=10, gate_e_confirm=50.0)
structural, decision = check_fingerprint(saved, config_new)
assert len(structural) == 0
assert "early_stop_patience" in decision
assert "gate_e_confirm" in decision
class TestWriteCheckpointAtomic:
"""原子写:先 .tmp 再 os.replace。"""
def test_write_checkpoint_atomic(self, tmp_path: Path) -> None:
state = _make_state()
config = _FakeConfig()
write_checkpoint(
tmp_path,
state=state,
epoch=2,
step_completed=5,
phase="in_epoch",
global_step=15,
total_steps=40,
version_snapshot={"skills": "v3", "prompts": "v2"},
epoch_batches=[["q001", "q002"], ["q003"]],
config=config,
)
ckpt_path = tmp_path / "checkpoint.json"
assert ckpt_path.exists()
# .tmp 应已被 os.replace 移除
assert not (tmp_path / "checkpoint.json.tmp").exists()
payload = json.loads(ckpt_path.read_text())
assert payload["schema_version"] == CHECKPOINT_SCHEMA_VERSION
assert payload["progress"]["epoch"] == 2
assert payload["progress"]["step_completed"] == 5
assert payload["progress"]["phase"] == "in_epoch"
assert payload["progress"]["global_step"] == 15
assert payload["progress"]["total_steps"] == 40
assert payload["version_snapshot"] == {"skills": "v3", "prompts": "v2"}
assert payload["epoch_batches"] == [["q001", "q002"], ["q003"]]
assert "config_fingerprint" in payload
assert "state" in payload
class TestLoadCheckpointMissing:
"""checkpoint.json 不存在时返回 None。"""
def test_load_checkpoint_missing(self, tmp_path: Path) -> None:
result = load_checkpoint(tmp_path)
assert result is None
def test_load_checkpoint_exists(self, tmp_path: Path) -> None:
"""checkpoint.json 存在时正确读回。"""
state = _make_state()
config = _FakeConfig()
write_checkpoint(
tmp_path,
state=state,
epoch=1,
step_completed=3,
phase="post_evolve",
global_step=8,
total_steps=20,
version_snapshot={"skills": "v2", "prompts": "v1"},
epoch_batches=[["q001"]],
config=config,
)
loaded = load_checkpoint(tmp_path)
assert loaded is not None
assert loaded["schema_version"] == CHECKPOINT_SCHEMA_VERSION
assert loaded["progress"]["epoch"] == 1
# 完整往返测试:state 可 deserialize
restored = deserialize_state_fields(loaded["state"])
assert restored["eval_prev_acc"] == 0.65
+374
View File
@@ -0,0 +1,374 @@
"""app/harness/gate_ladder.py 单元测试。
覆盖冷启动交错Beta(1,1) 平滑warm 信息量排序
GatePools 原子读写与指纹校验BaselineCache 四维内容寻址与先盘后存
gamma-EMA 更新防泄露过滤等核心语义
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from app.harness.gate_ladder import (
BaselineCache,
GatePools,
LadderEntry,
build_cold_entries,
build_or_load_gate_pools,
order_ladder,
skill_hash,
)
from core.types import GeneratedQuestion
if TYPE_CHECKING:
from pathlib import Path
# ── 工具函数 ──────────────────────────────────────────────────────────
def _make_q(qid: str, task_type: str = "AR") -> GeneratedQuestion:
"""构造最小 GeneratedQuestion 实例。"""
return GeneratedQuestion(
question_id=qid,
video_id="v1",
task_type=task_type,
question="dummy",
options=("A", "B", "C", "D"),
answer="A",
source_nodes=("n1",),
difficulty="easy",
)
# ── 冷启动 ────────────────────────────────────────────────────────────
class TestColdStart:
"""冷启动排序:2:1 交错 + 探针插尾 + Beta(1,1) 平滑。"""
def test_cold_start_interleaving(self) -> None:
"""错题:对题 = 2:1 交错顺序。
6 3 probe_quota=0 无探针 交错序应为 W W R W W R W W R
"""
wrong_ids = [f"w{i}" for i in range(6)]
right_ids = [f"r{i}" for i in range(3)]
questions = [_make_q(qid) for qid in wrong_ids + right_ids]
correctness = dict.fromkeys(wrong_ids, False)
correctness.update(dict.fromkeys(right_ids, True))
entries = build_cold_entries(questions, correctness, probe_quota=0.0, seed=42)
assert len(entries) == 9
# 验证 2:1 交错模式(seed 固定后 shuffle 结果确定)
pattern = ["W" if not correctness[e.question_id] else "R" for e in entries]
# 前 9 个交错应为 W W R W W R W W R
assert pattern == ["W", "W", "R", "W", "W", "R", "W", "W", "R"]
def test_cold_start_p_hat_beta(self) -> None:
"""p_hat 遵循 Beta(1,1) 平滑:错=1/3,对=2/3。"""
questions = [_make_q("q1"), _make_q("q2")]
correctness = {"q1": False, "q2": True}
entries = build_cold_entries(questions, correctness, probe_quota=0.0, seed=0)
p_map = {e.question_id: e.p_hat for e in entries}
assert p_map["q1"] == pytest.approx(1 / 3)
assert p_map["q2"] == pytest.approx(2 / 3)
def test_cold_start_probe_at_tail(self) -> None:
"""probe_quota > 0 时探针题追加在尾部。"""
wrong_ids = [f"w{i}" for i in range(10)]
right_ids = [f"r{i}" for i in range(2)]
questions = [_make_q(qid) for qid in wrong_ids + right_ids]
correctness = dict.fromkeys(wrong_ids, False)
correctness.update(dict.fromkeys(right_ids, True))
entries = build_cold_entries(questions, correctness, probe_quota=0.3, seed=7)
# 10 错 * 0.3 = 3 个探针在尾部
n_probe = int(10 * 0.3)
assert n_probe == 3
# 尾部 3 个都应为错题
tail = entries[-n_probe:]
for e in tail:
assert not correctness[e.question_id]
# ── warm 排序 ──────────────────────────────────────────────────────────
class TestWarmOrdering:
"""warm 阶段:信息量 p_hat(1-p_hat) 降序 + p_hat 区间过滤。"""
def test_warm_ordering_information(self) -> None:
"""p_hat=0.5 信息量最高,排在最前。"""
entries = [
LadderEntry("a", 0.1),
LadderEntry("b", 0.5),
LadderEntry("c", 0.9),
LadderEntry("d", 0.3),
]
ordered = order_ladder(entries, p_low=0.0, p_high=1.0)
assert ordered[0].question_id == "b" # 0.5*(1-0.5)=0.25 最高
# d: 0.3*0.7=0.21, a: 0.1*0.9=0.09, c: 0.9*0.1=0.09
assert ordered[1].question_id == "d"
def test_warm_filter_bounds(self) -> None:
"""p_hat 不在 [p_low, p_high] 区间的题被剔除。"""
entries = [
LadderEntry("low", 0.05),
LadderEntry("mid", 0.5),
LadderEntry("high", 0.95),
]
ordered = order_ladder(entries, p_low=0.1, p_high=0.9)
ids = [e.question_id for e in ordered]
assert "mid" in ids
assert "low" not in ids
assert "high" not in ids
# ── GatePools 持久化 ──────────────────────────────────────────────────
class TestGatePoolsPersistence:
"""GatePools.save/load 原子性与指纹校验。"""
def test_gate_pools_save_load_atomic(self, tmp_path: Path) -> None:
"""save -> load 往返保真,且使用原子写(中间 .tmp 文件不残留)。"""
entries = {
"AR": [LadderEntry("q1", 0.33), LadderEntry("q2", 0.67)],
"CR": [LadderEntry("q3", 0.5)],
}
pools = GatePools(entries=entries, seed=42, fingerprint="abc123")
path = tmp_path / "gate_pools.json"
pools.save(path)
# .tmp 文件不应残留
assert not (tmp_path / "gate_pools.json.tmp").exists()
assert path.exists()
loaded = GatePools.load(path)
assert loaded.seed == 42
assert loaded.fingerprint == "abc123"
assert len(loaded.entries["AR"]) == 2
assert loaded.entries["AR"][0].question_id == "q1"
assert loaded.entries["AR"][0].p_hat == pytest.approx(0.33)
assert loaded.entries["CR"][0].question_id == "q3"
def test_gate_pools_fingerprint_mismatch(self, tmp_path: Path) -> None:
"""指纹不一致 -> RuntimeError(不静默重建)。"""
questions = [_make_q("q1", "AR"), _make_q("q2", "AR")]
correctness = {"q1": True, "q2": False}
# 第一次构建
build_or_load_gate_pools(
workspace_dir=tmp_path,
questions=questions,
test_qids=set(),
baseline_correctness=correctness,
task_types=["AR"],
probe_quota=0.0,
seed=1,
baseline_run_id="run_001",
)
# 改 baseline_run_id 导致指纹变化 -> 应报错
with pytest.raises(RuntimeError, match="指纹不一致"):
build_or_load_gate_pools(
workspace_dir=tmp_path,
questions=questions,
test_qids=set(),
baseline_correctness=correctness,
task_types=["AR"],
probe_quota=0.0,
seed=1,
baseline_run_id="run_002",
)
# ── ladder_for ────────────────────────────────────────────────────────
class TestLadderFor:
"""ladder_for 取题序与排除逻辑。"""
def test_ladder_for_excludes_qids(self) -> None:
"""exclude_qids 中的题被排除。"""
entries = {
"AR": [
LadderEntry("q1", 0.5),
LadderEntry("q2", 0.4),
LadderEntry("q3", 0.6),
],
}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
result = pools.ladder_for("AR", exclude_qids={"q2"}, p_low=0.0, p_high=1.0, cold=True)
assert "q2" not in result
assert "q1" in result
assert "q3" in result
def test_ladder_for_missing_task_type(self) -> None:
"""不存在的 task_type -> ValueError。"""
pools = GatePools(entries={}, seed=0, fingerprint="x")
with pytest.raises(ValueError, match="无阶梯"):
pools.ladder_for("MISSING", set(), 0.0, 1.0, cold=True)
def test_ladder_for_warm_uses_order_ladder(self) -> None:
"""cold=False 时走 warm 信息量排序。"""
entries = {
"AR": [
LadderEntry("low", 0.1),
LadderEntry("mid", 0.5),
LadderEntry("high", 0.9),
],
}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
result = pools.ladder_for("AR", set(), p_low=0.0, p_high=1.0, cold=False)
# 信息量排序:mid(0.25) > low(0.09) = high(0.09)
assert result[0] == "mid"
# ── gamma-EMA 更新 ─────────────────────────────────────────────────────
class TestGammaEMA:
"""gamma-EMA 更新 p_hat。"""
def test_gamma_ema_update(self) -> None:
"""p_hat <- gamma * p_hat + (1-gamma) * obs。"""
entries = {"AR": [LadderEntry("q1", 0.5)]}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
# 观测为正确(1.0), gamma=0.8
pools.update_probs({"q1": True}, gamma=0.8)
expected = 0.8 * 0.5 + 0.2 * 1.0 # 0.6
assert pools.entries["AR"][0].p_hat == pytest.approx(expected)
# 再次观测为错误(0.0), gamma=0.8
pools.update_probs({"q1": False}, gamma=0.8)
expected2 = 0.8 * expected + 0.2 * 0.0 # 0.48
assert pools.entries["AR"][0].p_hat == pytest.approx(expected2)
def test_update_probs_no_observation_unchanged(self) -> None:
"""无观测的题 p_hat 不变。"""
entries = {"AR": [LadderEntry("q1", 0.5), LadderEntry("q2", 0.3)]}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
pools.update_probs({"q1": True}, gamma=0.9)
assert pools.entries["AR"][1].p_hat == pytest.approx(0.3)
# ── 防泄露 ─────────────────────────────────────────────────────────────
class TestLeakPrevention:
"""防泄露铁律:gate 内 rollout 永不回流 p_hat(由调用方过滤)。"""
def test_update_probs_excludes_gate_runs(self) -> None:
"""调用方须过滤 run_id 含 '_gate_' 的观测。
update_probs 本身只接收已过滤的 observations这里验证
如果调用方正确过滤gate run 数据不会影响 p_hat
"""
entries = {"AR": [LadderEntry("q1", 0.5)]}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
# 模拟:所有 run 的原始观测(含 gate run
raw_observations = {
"run_normal": {"q1": True}, # 普通 run
"run_gate_01": {"q1": False}, # gate runrun_id 含 _gate_
}
# 调用方按 run_id 过滤:排除含 "_gate_" 的 run
filtered = {}
for run_id, obs in raw_observations.items():
if "_gate_" not in run_id:
filtered.update(obs)
# 只有普通 run 的观测进入 update_probs
assert filtered == {"q1": True}
pools.update_probs(filtered, gamma=0.8)
expected = 0.8 * 0.5 + 0.2 * 1.0
assert pools.entries["AR"][0].p_hat == pytest.approx(expected)
# ── BaselineCache ──────────────────────────────────────────────────────
class TestBaselineCache:
"""BaselineCache 四维内容寻址与先盘后存。"""
def test_baseline_cache_content_addressed(self, tmp_path: Path) -> None:
"""四维键唯一寻址:任一维度变化 -> miss。"""
path = tmp_path / "baseline_cache.json"
cache = BaselineCache(path)
cache.put("AR", "hash1", "v1", "q1", True)
assert cache.get("AR", "hash1", "v1", "q1") is True
# 改 skill_hash -> miss
assert cache.get("AR", "hash2", "v1", "q1") is None
# 改 prompts_version -> miss
assert cache.get("AR", "hash1", "v2", "q1") is None
# 改 task_type -> miss
assert cache.get("CR", "hash1", "v1", "q1") is None
# 改 qid -> miss
assert cache.get("AR", "hash1", "v1", "q2") is None
def test_baseline_cache_disk_first(self, tmp_path: Path) -> None:
"""先盘后存:磁盘写成功后内存才更新,新实例可从磁盘读到。"""
path = tmp_path / "baseline_cache.json"
cache = BaselineCache(path)
cache.put("AR", "h1", "v1", "q1", True)
# 内存可读
assert cache.get("AR", "h1", "v1", "q1") is True
# 新实例从磁盘加载也能读到(证明先落盘)
cache2 = BaselineCache(path)
assert cache2.get("AR", "h1", "v1", "q1") is True
# .tmp 文件不应残留
assert not (tmp_path / "baseline_cache.json.tmp").exists()
def test_baseline_cache_empty_init(self, tmp_path: Path) -> None:
"""不存在的文件 -> 空缓存初始化。"""
path = tmp_path / "nonexistent.json"
cache = BaselineCache(path)
assert cache.get("AR", "h1", "v1", "q1") is None
def test_baseline_cache_overwrite(self, tmp_path: Path) -> None:
"""同键重复写入覆盖旧值。"""
path = tmp_path / "baseline_cache.json"
cache = BaselineCache(path)
cache.put("AR", "h1", "v1", "q1", True)
assert cache.get("AR", "h1", "v1", "q1") is True
cache.put("AR", "h1", "v1", "q1", False)
assert cache.get("AR", "h1", "v1", "q1") is False
# ── skill_hash ─────────────────────────────────────────────────────────
class TestSkillHash:
"""skill_hash SHA1 摘要。"""
def test_deterministic(self) -> None:
"""相同输入产生相同摘要。"""
assert skill_hash("hello") == skill_hash("hello")
def test_different_content(self) -> None:
"""不同输入产生不同摘要。"""
assert skill_hash("hello") != skill_hash("world")
def test_is_sha1_hex(self) -> None:
"""输出为 40 字符十六进制。"""
h = skill_hash("test")
assert len(h) == 40
assert all(c in "0123456789abcdef" for c in h)
+659
View File
@@ -0,0 +1,659 @@
"""app/harness/inference.py 单元测试。
测试覆盖:
- run_inference 基本流程mock LLM + tool_dispatch
- 异常时 prediction 仍落库stop_reason=error
- _to_text_field 归一化
- run_id 空串 ValueError
- _aggregate_results 内存聚合
- questions 列表零值返回
- 并发控制 Semaphore
- plugins_factory 调用
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock
import pytest
from app.harness.inference import (
InferenceResult,
_aggregate_results,
_to_text_field,
run_inference,
)
from app.harness.log import HarnessLog
from core.types import GeneratedQuestion, LLMResponse
# ── 测试基础设施 ──────────────────────────────────────────────────
def _make_question(
question_id: str = "q1",
video_id: str = "v1",
task_type: str = "Action Reasoning",
answer: str = "B",
) -> GeneratedQuestion:
"""构造测试用题目。"""
return GeneratedQuestion(
question_id=question_id,
video_id=video_id,
task_type=task_type,
question="测试问题",
options=("A. 选项A", "B. 选项B", "C. 选项C", "D. 选项D"),
answer=answer,
source_nodes=("L1_001",),
difficulty="medium",
)
def _make_llm_response(answer: str = "B") -> LLMResponse:
"""构造测试用 LLMResponsesubmit_answer 场景)。"""
content = json.dumps(
{
"reflect": {"observation": "找到答案"},
"plan": {"next_step": "提交"},
"action": {
"tool": "submit_answer",
"args": {
"answer": answer,
"evidence": "证据文本",
"reasoning": "推理过程",
},
},
}
)
return LLMResponse(
content=content,
thinking="思考过程",
model="test-model",
provider="test",
prompt_tokens=100,
completion_tokens=50,
latency_ms=200,
ttft_ms=30.0,
max_inter_token_ms=5.0,
cache_hit=False,
call_id="test-call-001",
)
def _make_error_llm_response() -> LLMResponse:
"""构造触发解析失败的 LLMResponse。"""
return LLMResponse(
content="这不是JSON",
thinking="",
model="test-model",
provider="test",
prompt_tokens=10,
completion_tokens=5,
latency_ms=50,
ttft_ms=10.0,
max_inter_token_ms=2.0,
cache_hit=False,
call_id="test-call-err",
)
async def _stub_tool_dispatch(
tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str:
"""测试用工具调度函数。"""
if tool_name == "submit_answer":
return "答案已提交"
raise ValueError(f"未知工具: {tool_name}")
def _stub_prompt_builder(qa: GeneratedQuestion) -> tuple[str, str]:
"""测试用 prompt 构建函数。"""
return "系统提示词", f"用户问题: {qa.question}"
@pytest.fixture
def harness_log(tmp_path: Any, request: Any) -> HarnessLog:
"""创建临时 HarnessLog 实例。
使用 test 节点名称的 hash 作为 db 文件名避免冲突
run_id 固定为 "test-run"实际 run_inference 中传入的 run_id
HarnessLog.insert 自动覆盖为 HarnessLog 构造时的值
"""
db_name = f"harness_{id(request)}.db"
db_path = str(tmp_path / db_name)
log = HarnessLog(db_path, "test-run")
yield log
log.close()
# ── 测试用例 ──────────────────────────────────────────────────
class TestToTextField:
"""_to_text_field 归一化测试。"""
@pytest.mark.asyncio
async def test_string_passthrough(self) -> None:
"""字符串原样返回。"""
assert _to_text_field("hello") == "hello"
@pytest.mark.asyncio
async def test_empty_string(self) -> None:
"""空字符串原样返回。"""
assert _to_text_field("") == ""
@pytest.mark.asyncio
async def test_list_serialized(self) -> None:
"""list 被 JSON 序列化。"""
result = _to_text_field(["a", "b"])
assert result == '["a", "b"]'
@pytest.mark.asyncio
async def test_dict_serialized(self) -> None:
"""dict 被 JSON 序列化。"""
result = _to_text_field({"key": ""})
assert '"key"' in result
assert '""' in result
@pytest.mark.asyncio
async def test_int_serialized(self) -> None:
"""int 被 JSON 序列化。"""
assert _to_text_field(42) == "42"
@pytest.mark.asyncio
async def test_none_serialized(self) -> None:
"""None 被 JSON 序列化。"""
assert _to_text_field(None) == "null"
@pytest.mark.asyncio
async def test_unicode_preserved(self) -> None:
"""ensure_ascii=False 保留中文。"""
result = _to_text_field(["中文"])
assert "中文" in result
assert "\\u" not in result
class TestAggregateResults:
"""_aggregate_results 内存聚合测试。"""
@pytest.mark.asyncio
async def test_empty_records(self) -> None:
"""空列表返回零值 InferenceResult。"""
result = _aggregate_results([], "run-empty")
assert result.run_id == "run-empty"
assert result.accuracy == 0.0
assert result.total == 0
assert result.correct == 0
assert result.per_task_type == {}
assert result.steps_mean == 0.0
assert result.token_usage == {"prompt_tokens": 0, "completion_tokens": 0}
assert result.stop_reason_counts == {}
@pytest.mark.asyncio
async def test_single_correct(self) -> None:
"""单条正确记录 → accuracy=1.0。"""
records = [
{
"prediction": "B",
"answer": "B",
"task_type": "AR",
"steps_used": 3,
"prompt_tokens": 100,
"completion_tokens": 50,
"stop_reason": "finished",
}
]
result = _aggregate_results(records, "run-1")
assert result.accuracy == 1.0
assert result.total == 1
assert result.correct == 1
assert result.steps_mean == 3.0
@pytest.mark.asyncio
async def test_mixed_correct_wrong(self) -> None:
"""混合正确/错误 → 准确率与步数均正确聚合。"""
records = [
{
"prediction": "B",
"answer": "B",
"task_type": "AR",
"steps_used": 2,
"prompt_tokens": 100,
"completion_tokens": 50,
"stop_reason": "finished",
},
{
"prediction": "C",
"answer": "A",
"task_type": "AR",
"steps_used": 4,
"prompt_tokens": 200,
"completion_tokens": 100,
"stop_reason": "budget_exceeded",
},
{
"prediction": "D",
"answer": "D",
"task_type": "SP",
"steps_used": 1,
"prompt_tokens": 50,
"completion_tokens": 25,
"stop_reason": "finished",
},
]
result = _aggregate_results(records, "run-mix")
assert result.total == 3
assert result.correct == 2
assert abs(result.accuracy - 2 / 3) < 1e-9
assert abs(result.steps_mean - 7 / 3) < 1e-9
assert result.token_usage == {"prompt_tokens": 350, "completion_tokens": 175}
assert result.stop_reason_counts == {"finished": 2, "budget_exceeded": 1}
@pytest.mark.asyncio
async def test_per_task_type_grouping(self) -> None:
"""按 task_type 分组聚合。"""
records = [
{
"prediction": "B",
"answer": "B",
"task_type": "AR",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 5,
"stop_reason": "finished",
},
{
"prediction": "A",
"answer": "C",
"task_type": "AR",
"steps_used": 2,
"prompt_tokens": 20,
"completion_tokens": 10,
"stop_reason": "finished",
},
{
"prediction": "D",
"answer": "D",
"task_type": "SP",
"steps_used": 3,
"prompt_tokens": 30,
"completion_tokens": 15,
"stop_reason": "finished",
},
]
result = _aggregate_results(records, "run-task")
assert "AR" in result.per_task_type
assert "SP" in result.per_task_type
assert result.per_task_type["AR"]["total"] == 2
assert result.per_task_type["AR"]["correct"] == 1
assert result.per_task_type["AR"]["accuracy"] == 0.5
assert result.per_task_type["SP"]["total"] == 1
assert result.per_task_type["SP"]["correct"] == 1
assert result.per_task_type["SP"]["accuracy"] == 1.0
class TestRunIdValidation:
"""run_id 校验测试。"""
@pytest.mark.asyncio
async def test_empty_string_raises(self, harness_log: HarnessLog) -> None:
"""空串 run_id → ValueError。"""
llm = AsyncMock()
with pytest.raises(ValueError, match="run_id 不得为空"):
await run_inference(
[],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
@pytest.mark.asyncio
async def test_whitespace_only_raises(self, harness_log: HarnessLog) -> None:
"""纯空白 run_id → ValueError。"""
llm = AsyncMock()
with pytest.raises(ValueError, match="run_id 不得为空"):
await run_inference(
[],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id=" ",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
class TestEmptyQuestions:
"""空题目列表测试。"""
@pytest.mark.asyncio
async def test_empty_questions_returns_zero(self, harness_log: HarnessLog) -> None:
"""空 questions 列表直接返回零值 InferenceResult。"""
llm = AsyncMock()
result = await run_inference(
[],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-empty",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert isinstance(result, InferenceResult)
assert result.run_id == "run-empty"
assert result.accuracy == 0.0
assert result.total == 0
assert result.correct == 0
# LLM 未被调用
llm.chat.assert_not_called()
class TestRunInferenceBasic:
"""run_inference 基本流程测试。"""
@pytest.mark.asyncio
async def test_single_question_correct(self, harness_log: HarnessLog) -> None:
"""单题正确推理 → accuracy=1.0, stop_reason=finished。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
result = await run_inference(
[_make_question(answer="B")],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-basic",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert result.accuracy == 1.0
assert result.total == 1
assert result.correct == 1
assert result.stop_reason_counts.get("finished") == 1
@pytest.mark.asyncio
async def test_single_question_wrong(self, harness_log: HarnessLog) -> None:
"""单题错误推理 → accuracy=0.0。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="C")
result = await run_inference(
[_make_question(answer="B")],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-wrong",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert result.accuracy == 0.0
assert result.total == 1
assert result.correct == 0
@pytest.mark.asyncio
async def test_multiple_questions_concurrent(self, harness_log: HarnessLog) -> None:
"""3 题并发推理 → 结果正确聚合。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
questions = [
_make_question(question_id="q1", answer="B"),
_make_question(question_id="q2", answer="B"),
_make_question(question_id="q3", answer="A"),
]
result = await run_inference(
questions,
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-multi",
concurrency=3,
max_steps=10,
skill_mode="auto",
)
assert result.total == 3
assert result.correct == 2
assert abs(result.accuracy - 2 / 3) < 1e-9
@pytest.mark.asyncio
async def test_token_usage_accumulated(self, harness_log: HarnessLog) -> None:
"""多题 token 累加验证。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
questions = [
_make_question(question_id="q1"),
_make_question(question_id="q2"),
]
result = await run_inference(
questions,
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-token",
concurrency=2,
max_steps=10,
skill_mode="auto",
)
assert result.token_usage["prompt_tokens"] == 200
assert result.token_usage["completion_tokens"] == 100
class TestPredictionAlwaysWritten:
"""异常时 prediction 仍落库测试。"""
@pytest.mark.asyncio
async def test_error_still_persisted(self, harness_log: HarnessLog) -> None:
"""LLM 调用异常时,prediction 仍以 stop_reason=error 落库。"""
llm = AsyncMock()
llm.chat.side_effect = RuntimeError("LLM API 不可用")
result = await run_inference(
[_make_question()],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-error",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert result.total == 1
assert result.correct == 0
assert result.stop_reason_counts.get("error") == 1
# 验证 DB 中的记录(HarnessLog.insert 使用构造时的 run_id
rows = harness_log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",))
assert len(rows) == 1
assert rows[0]["stop_reason"] == "error"
assert rows[0]["prediction"] is None
@pytest.mark.asyncio
async def test_parse_error_still_persisted(self, harness_log: HarnessLog) -> None:
"""LLM 返回非 JSON 内容,parse_error 后 prediction 仍落库。"""
llm = AsyncMock()
llm.chat.return_value = _make_error_llm_response()
result = await run_inference(
[_make_question()],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-parse-err",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert result.total == 1
# HarnessLog.insert 使用构造时的 run_id
rows = harness_log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",))
assert len(rows) == 1
assert rows[0]["prediction"] is None
class TestPluginsFactory:
"""plugins_factory 调用测试。"""
@pytest.mark.asyncio
async def test_factory_called_per_question(self, harness_log: HarnessLog) -> None:
"""每题调用 plugins_factory,传入 (video_id, question_id)。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
factory_calls: list[tuple[str, str]] = []
def _factory(video_id: str, question_id: str) -> list[object]:
factory_calls.append((video_id, question_id))
return []
questions = [
_make_question(question_id="q1", video_id="v1"),
_make_question(question_id="q2", video_id="v2"),
]
await run_inference(
questions,
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-factory",
concurrency=2,
max_steps=10,
skill_mode="auto",
plugins_factory=_factory,
)
assert len(factory_calls) == 2
call_set = set(factory_calls)
assert ("v1", "q1") in call_set
assert ("v2", "q2") in call_set
@pytest.mark.asyncio
async def test_no_factory_uses_empty_plugins(self, harness_log: HarnessLog) -> None:
"""plugins_factory=None 时使用空 plugins 列表。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
result = await run_inference(
[_make_question()],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-no-factory",
concurrency=1,
max_steps=10,
skill_mode="auto",
plugins_factory=None,
)
assert result.total == 1
assert result.stop_reason_counts.get("finished") == 1
class TestConcurrencyControl:
"""并发控制 Semaphore 测试。"""
@pytest.mark.asyncio
async def test_concurrency_semaphore_limits(self, harness_log: HarnessLog) -> None:
"""Semaphore(1) 限制并发为 1 — 通过最大并发计数器验证。"""
import asyncio
llm = AsyncMock()
current_concurrent = 0
max_concurrent = 0
original_response = _make_llm_response(answer="B")
async def _slow_chat(
messages: Any,
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> LLMResponse:
nonlocal current_concurrent, max_concurrent
current_concurrent += 1
max_concurrent = max(max_concurrent, current_concurrent)
await asyncio.sleep(0.01)
current_concurrent -= 1
return original_response
llm.chat.side_effect = _slow_chat
questions = [_make_question(question_id=f"q{i}") for i in range(5)]
await run_inference(
questions,
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-sem",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert max_concurrent == 1
class TestTablesCreated:
"""表创建测试。"""
@pytest.mark.asyncio
async def test_five_tables_created(self, harness_log: HarnessLog) -> None:
"""run_inference 启动时创建 5 张推理表。"""
llm = AsyncMock()
await run_inference(
[],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-tables",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
expected_tables = [
"predictions",
"traces",
"validation_flags",
"anchor_check",
"observe_frame_health",
]
for table_name in expected_tables:
rows = harness_log.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
assert len(rows) == 1, f"{table_name} 未创建"
+261
View File
@@ -0,0 +1,261 @@
"""慢更新动量生成(app/harness/momentum.py)单元测试。
覆盖:
- _categorize_pair 四分类 + 缺键 KeyError
- _format_comparison_pairs 分组排序 + 空列表
- run_slow_momentum 正常解析 + 解析失败保留 prev_guidance
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock
import pytest
from app.harness.momentum import (
IMPROVED,
PERSISTENT_FAIL,
REGRESSED,
STABLE_SUCCESS,
_categorize_pair,
_format_comparison_pairs,
run_slow_momentum,
)
if TYPE_CHECKING:
from pathlib import Path
# =========================================================================
# _categorize_pair
# =========================================================================
class TestCategorizePair:
"""_categorize_pair 四分类测试。"""
def test_categorize_pair_all_four(self) -> None:
"""四种 correct_prev/correct_curr 组合应返回对应类别常量。"""
assert _categorize_pair({"correct_prev": False, "correct_curr": True}) == IMPROVED
assert _categorize_pair({"correct_prev": True, "correct_curr": False}) == REGRESSED
assert _categorize_pair({"correct_prev": False, "correct_curr": False}) == PERSISTENT_FAIL
assert _categorize_pair({"correct_prev": True, "correct_curr": True}) == STABLE_SUCCESS
def test_categorize_pair_truthy_values(self) -> None:
"""非布尔真值(int / str)也能正确分类。"""
assert _categorize_pair({"correct_prev": 0, "correct_curr": 1}) == IMPROVED
assert _categorize_pair({"correct_prev": "yes", "correct_curr": ""}) == REGRESSED
def test_categorize_pair_missing_key(self) -> None:
"""缺 correct_prev 或 correct_curr 键时抛 KeyError。"""
with pytest.raises(KeyError):
_categorize_pair({"correct_prev": True})
with pytest.raises(KeyError):
_categorize_pair({"correct_curr": False})
with pytest.raises(KeyError):
_categorize_pair({})
# =========================================================================
# _format_comparison_pairs
# =========================================================================
class TestFormatComparisonPairs:
"""_format_comparison_pairs 分组排序测试。"""
def test_format_comparison_pairs_empty(self) -> None:
"""空列表返回占位说明。"""
result = _format_comparison_pairs([])
assert "无可用" in result
def test_format_comparison_pairs_order(self) -> None:
"""REGRESSED 标题应出现在 IMPROVED 标题之前(伤害信号优先)。"""
pairs = [
{
"question": "Q1",
"prev_prediction": "A",
"curr_prediction": "B",
"correct_prev": False,
"correct_curr": True,
},
{
"question": "Q2",
"prev_prediction": "C",
"curr_prediction": "D",
"correct_prev": True,
"correct_curr": False,
},
]
result = _format_comparison_pairs(pairs)
regressed_pos = result.index("回退")
improved_pos = result.index("改善")
assert regressed_pos < improved_pos, "REGRESSED 应排在 IMPROVED 之前"
def test_format_comparison_pairs_all_categories(self) -> None:
"""四种类别的 pair 都能被正确分组。"""
pairs = [
{"question": "Q1", "correct_prev": False, "correct_curr": True},
{"question": "Q2", "correct_prev": True, "correct_curr": False},
{"question": "Q3", "correct_prev": False, "correct_curr": False},
{"question": "Q4", "correct_prev": True, "correct_curr": True},
]
result = _format_comparison_pairs(pairs)
assert "固定样本总数:4" in result
# 每个类别都应标注 1 题
assert "1 题" in result
def test_format_comparison_pairs_missing_key(self) -> None:
"""pair 缺键时 KeyError 不被吞。"""
with pytest.raises(KeyError):
_format_comparison_pairs([{"question": "Q1"}])
# =========================================================================
# run_slow_momentum
# =========================================================================
def _make_mock_llm(response_content: str) -> Any:
"""构造一个返回指定 content 的 mock LLMProvider。"""
@dataclass(frozen=True)
class _FakeResponse:
content: str
thinking: str = ""
model: str = "mock"
provider: str = "mock"
prompt_tokens: int = 0
completion_tokens: int = 0
latency_ms: int = 0
ttft_ms: float | None = None
max_inter_token_ms: float | None = None
cache_hit: bool = False
call_id: str = "test-call-id"
mock_llm = AsyncMock()
mock_llm.chat.return_value = _FakeResponse(content=response_content)
return mock_llm
@pytest.mark.asyncio
async def test_run_slow_momentum_basic(tmp_path: Path) -> None:
"""正常解析时返回新的动量指导文本。"""
# 准备 prompt 文件
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
new_guidance_text = "新一轮的动量指导内容"
llm_response = json.dumps({"slow_update_content": new_guidance_text}, ensure_ascii=False)
mock_llm = _make_mock_llm(llm_response)
result = await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill 正文",
prev_skill="上一版 skill 正文",
prev_guidance="旧的动量指导",
comparison_pairs=[
{
"question": "Q1",
"prev_prediction": "A",
"curr_prediction": "B",
"correct_prev": False,
"correct_curr": True,
}
],
)
assert result == new_guidance_text
mock_llm.chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_slow_momentum_parse_failure(tmp_path: Path) -> None:
"""LLM 返回无法解析的内容时保留 prev_guidance。"""
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
mock_llm = _make_mock_llm("这不是 JSON,无法解析")
prev_guidance = "应该被保留的旧动量指导"
result = await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill",
prev_skill="上一版 skill",
prev_guidance=prev_guidance,
comparison_pairs=[
{
"question": "Q1",
"prev_prediction": "A",
"curr_prediction": "B",
"correct_prev": True,
"correct_curr": True,
}
],
)
assert result == prev_guidance
@pytest.mark.asyncio
async def test_run_slow_momentum_missing_field(tmp_path: Path) -> None:
"""LLM 返回合法 JSON 但缺少 slow_update_content 字段时保留 prev_guidance。"""
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
llm_response = json.dumps({"other_field": "无关内容"}, ensure_ascii=False)
mock_llm = _make_mock_llm(llm_response)
prev_guidance = "应该被保留的旧动量指导"
result = await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill",
prev_skill="上一版 skill",
prev_guidance=prev_guidance,
comparison_pairs=[],
)
assert result == prev_guidance
@pytest.mark.asyncio
async def test_run_slow_momentum_keyerror_not_swallowed(tmp_path: Path) -> None:
"""comparison_pairs 缺键时 KeyError 不被 ValueError 吞掉。"""
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
mock_llm = _make_mock_llm('{"slow_update_content": "ok"}')
with pytest.raises(KeyError):
await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill",
prev_skill="上一版 skill",
prev_guidance="旧指导",
comparison_pairs=[{"question": "Q1"}], # 缺 correct_prev/correct_curr
)
@pytest.mark.asyncio
async def test_run_slow_momentum_fenced_json(tmp_path: Path) -> None:
"""LLM 返回 fenced code block 中的 JSON 也能正确解析。"""
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
new_guidance = "从 fenced block 中提取的指导"
llm_response = f'```json\n{{"slow_update_content": "{new_guidance}"}}\n```'
mock_llm = _make_mock_llm(llm_response)
result = await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill",
prev_skill="上一版 skill",
prev_guidance="旧指导",
comparison_pairs=[],
)
assert result == new_guidance
+354
View File
@@ -0,0 +1,354 @@
"""五张观测表 + step/epoch 报告的单元测试。"""
from __future__ import annotations
import json
import sqlite3
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
from app.harness.observation import (
read_dual_metric,
read_gate_evidence,
read_holdout_eval,
read_quadrant_pairs,
read_shadow_gate,
write_dual_metric,
write_epoch_report,
write_gate_evidence,
write_holdout_eval,
write_quadrant_pairs,
write_shadow_gate,
write_step_report,
)
@pytest.fixture()
def db_path(tmp_path: Path) -> str:
"""返回临时 SQLite 路径。"""
return str(tmp_path / "test_obs.db")
@pytest.fixture()
def run_id() -> str:
return "run-obs-001"
# ---------------------------------------------------------------------------
# dual_metric_eval
# ---------------------------------------------------------------------------
def test_write_read_dual_metric(db_path: str, run_id: str) -> None:
"""写入 dual_metric_eval 后回读应与输入一致。"""
write_dual_metric(
db_path,
run_id=run_id,
epoch=1,
version_kind="baseline",
skills_version="v0",
prompts_version="v0",
pool="val",
hard_acc=0.75,
soft_score=0.80,
mixed_score=0.775,
)
rows = read_dual_metric(db_path, run_id=run_id)
assert len(rows) == 1
row = rows[0]
assert row["epoch"] == 1
assert row["version_kind"] == "baseline"
assert row["skills_version"] == "v0"
assert row["prompts_version"] == "v0"
assert row["pool"] == "val"
assert row["hard_acc"] == pytest.approx(0.75)
assert row["soft_score"] == pytest.approx(0.80)
assert row["mixed_score"] == pytest.approx(0.775)
assert row["run_id"] == run_id
# ---------------------------------------------------------------------------
# shadow_gate
# ---------------------------------------------------------------------------
def test_write_read_shadow_gate(db_path: str, run_id: str) -> None:
"""写入 shadow_gate 后回读应与输入一致,is_mixed_best 布尔转 int。"""
write_shadow_gate(
db_path,
run_id=run_id,
epoch=2,
candidate_version="skills/v1+prompts/v1",
hard_acc=0.82,
soft_score=0.78,
mixed_score=0.80,
is_mixed_best=True,
)
rows = read_shadow_gate(db_path, run_id=run_id)
assert len(rows) == 1
row = rows[0]
assert row["epoch"] == 2
assert row["candidate_version"] == "skills/v1+prompts/v1"
assert row["hard_acc"] == pytest.approx(0.82)
assert row["is_mixed_best"] == 1
# ---------------------------------------------------------------------------
# holdout_eval
# ---------------------------------------------------------------------------
def test_write_read_holdout_eval(db_path: str, run_id: str) -> None:
"""写入 holdout_eval 后回读应与输入一致。"""
per_task = json.dumps({"temporal": {"accuracy": 0.9, "total": 10, "correct": 9}})
write_holdout_eval(
db_path,
run_id=run_id,
epoch=1,
version_kind="best_hard",
hard_acc=0.85,
soft_score=0.70,
mixed_score=0.775,
per_task_type_json=per_task,
)
rows = read_holdout_eval(db_path, run_id=run_id)
assert len(rows) == 1
row = rows[0]
assert row["version_kind"] == "best_hard"
assert row["hard_acc"] == pytest.approx(0.85)
parsed = json.loads(row["per_task_type_json"])
assert parsed["temporal"]["correct"] == 9
# ---------------------------------------------------------------------------
# gate_evidence
# ---------------------------------------------------------------------------
def test_write_read_gate_evidence(db_path: str, run_id: str) -> None:
"""写入 gate_evidence 后回读应与输入一致,逐行插入。"""
evidence_rows = [
{
"task_type": "temporal",
"question_id": "q1",
"block_idx": 0,
"baseline_correct": 1,
"candidate_correct": 1,
"e_value": 1.0,
"stop_reason": "",
},
{
"task_type": "temporal",
"question_id": "q2",
"block_idx": 0,
"baseline_correct": 0,
"candidate_correct": 1,
"e_value": 2.0,
"stop_reason": "confirmed",
},
]
write_gate_evidence(db_path, run_id=run_id, epoch=1, step=0, rows=evidence_rows)
rows = read_gate_evidence(db_path, run_id=run_id)
assert len(rows) == 2
assert rows[0]["question_id"] == "q1"
assert rows[1]["stop_reason"] == "confirmed"
assert rows[1]["e_value"] == pytest.approx(2.0)
# ---------------------------------------------------------------------------
# quadrant_pair
# ---------------------------------------------------------------------------
def test_write_read_quadrant_pairs(db_path: str, run_id: str) -> None:
"""写入 quadrant_pair 后回读,bool -> int 转换正确。"""
pairs = [
{
"question_id": "q1",
"task_type": "causal",
"prev_correct": True,
"curr_correct": False,
"category": "regression",
},
{
"question_id": "q2",
"task_type": "causal",
"prev_correct": False,
"curr_correct": True,
"category": "improvement",
},
]
write_quadrant_pairs(db_path, run_id=run_id, epoch=1, step=0, pairs=pairs)
rows = read_quadrant_pairs(db_path, run_id=run_id)
assert len(rows) == 2
# bool -> int 转换
assert rows[0]["prev_correct"] == 1
assert rows[0]["curr_correct"] == 0
assert rows[1]["prev_correct"] == 0
assert rows[1]["curr_correct"] == 1
# ---------------------------------------------------------------------------
# NULL vs 0 语义
# ---------------------------------------------------------------------------
def test_null_not_zero_for_soft(db_path: str, run_id: str) -> None:
"""soft_score/mixed_score 为 None 时存 NULL(非 0),回读也是 None。"""
write_dual_metric(
db_path,
run_id=run_id,
epoch=1,
version_kind="baseline",
skills_version="v0",
prompts_version="v0",
pool="val",
hard_acc=0.75,
soft_score=None,
mixed_score=None,
)
rows = read_dual_metric(db_path, run_id=run_id)
assert len(rows) == 1
row = rows[0]
assert row["soft_score"] is None
assert row["mixed_score"] is None
# 用原生 SQL 确认存的是 NULL 而非 0
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT soft_score, mixed_score FROM dual_metric_eval WHERE run_id=?",
(run_id,),
)
raw = cursor.fetchone()
conn.close()
assert raw[0] is None
assert raw[1] is None
# ---------------------------------------------------------------------------
# 只读连接隔离
# ---------------------------------------------------------------------------
def test_read_only_connection(db_path: str, run_id: str) -> None:
"""read_* 使用独立只读连接,不向 _runs 表插入新行。"""
write_dual_metric(
db_path,
run_id=run_id,
epoch=1,
version_kind="baseline",
skills_version="v0",
prompts_version="v0",
pool="val",
hard_acc=0.5,
soft_score=None,
mixed_score=None,
)
# 用另一个 run_id 回读——不应在 _runs 表中创建新行
other_run = "run-obs-ghost"
rows = read_dual_metric(db_path, run_id=other_run)
assert rows == []
conn = sqlite3.connect(db_path)
cursor = conn.execute("SELECT run_id FROM _runs")
run_ids = [r[0] for r in cursor.fetchall()]
conn.close()
assert other_run not in run_ids
# ---------------------------------------------------------------------------
# step_report
# ---------------------------------------------------------------------------
def test_write_step_report(tmp_path: Path) -> None:
"""step_report 写入 JSON 文件,内容字段完整。"""
workspace = tmp_path / "ws"
workspace.mkdir()
path = write_step_report(
workspace_dir=workspace,
epoch=1,
step=2,
global_step=12,
task_type="Temporal Order",
gate_action="accept_confirmed",
candidate_acc=0.85,
class_baseline_acc=0.70,
edit_budget=5,
rank_clip_triggered=False,
gate_w=3,
gate_l=1,
gate_e_value=4.2,
gate_n_used=8,
gate_stop_reason="confirmed",
)
assert path.exists()
assert path.name == "step_report_e1_s2_temporal-order.json"
data = json.loads(path.read_text(encoding="utf-8"))
assert data["epoch"] == 1
assert data["step"] == 2
assert data["global_step"] == 12
assert data["task_type"] == "Temporal Order"
assert data["gate_action"] == "accept_confirmed"
assert data["gate_w"] == 3
assert data["gate_stop_reason"] == "confirmed"
assert data["rank_clip_triggered"] is False
def test_write_step_report_skipped_null_fields(tmp_path: Path) -> None:
"""skipped/cooldown 路径的 gate 字段应为 null。"""
workspace = tmp_path / "ws"
workspace.mkdir()
path = write_step_report(
workspace_dir=workspace,
epoch=1,
step=0,
global_step=0,
task_type="causal",
gate_action="skipped",
candidate_acc=0.0,
class_baseline_acc=0.0,
edit_budget=10,
rank_clip_triggered=False,
gate_w=None,
gate_l=None,
gate_e_value=None,
gate_n_used=None,
gate_stop_reason=None,
)
data = json.loads(path.read_text(encoding="utf-8"))
assert data["gate_w"] is None
assert data["gate_l"] is None
assert data["gate_e_value"] is None
assert data["gate_n_used"] is None
assert data["gate_stop_reason"] is None
# ---------------------------------------------------------------------------
# epoch_report
# ---------------------------------------------------------------------------
def test_write_epoch_report(tmp_path: Path) -> None:
"""epoch_report 写入 JSON 文件,内容字段完整。"""
workspace = tmp_path / "ws"
workspace.mkdir()
path = write_epoch_report(
workspace_dir=workspace,
epoch=3,
system_tool_action="updated",
momentum_updated_task_types=["temporal", "causal"],
best_val_acc=0.88,
)
assert path.exists()
assert path.name == "epoch_report_3.json"
data = json.loads(path.read_text(encoding="utf-8"))
assert data["epoch"] == 3
assert data["system_tool_action"] == "updated"
assert data["momentum_updated_task_types"] == ["temporal", "causal"]
assert data["best_val_acc"] == pytest.approx(0.88)
+682
View File
@@ -0,0 +1,682 @@
"""runner.py 单元测试(算法保真 #13)。
覆盖 13a-13e 五个子任务测试 Runner 骨架三级嵌套gate/accept/reject/probation
慢更新十步序deliver_best + early stop大部分测试用纯函数或 mock 构造避免真实推理
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path # noqa: TC003 — 运行时 tmp_path 标注使用
from unittest.mock import MagicMock, patch
import pytest
from app.harness.runner import (
_apply_batch_correctness,
_batch_from_ids,
_build_comparison_pairs,
_compute_total_steps,
_fallback_summary,
_format_applied_edits,
_guard_infra_failures,
_outcome_to_quadrant_pairs,
_should_early_stop,
_snapshot_current_skills,
_TrainState,
_write_skip_report,
resume_plan,
)
from app.harness.validate import Probation, ValidationOutcome
from core.evolution import RejectedEdit
# ---------------------------------------------------------------------------
# 测试辅助
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _FakeInferenceResult:
"""InferenceResult 替身。"""
run_id: str = "test_run"
accuracy: float = 0.5
total: int = 10
correct: int = 5
per_task_type: dict = field(default_factory=dict)
steps_mean: float = 3.0
token_usage: dict = field(default_factory=lambda: {"prompt_tokens": 0, "completion_tokens": 0})
stop_reason_counts: dict = field(default_factory=lambda: {"finished": 10})
@dataclass(frozen=True)
class _FakeQuestion:
"""GeneratedQuestion 替身。"""
question_id: str
video_id: str = "v1"
task_type: str = "Action Reasoning"
question: str = "问题"
options: tuple = ("A", "B", "C", "D")
answer: str = "A"
source_nodes: tuple = ()
difficulty: str = "medium"
@dataclass
class _FakePools:
"""Pools 替身。"""
diagnosis: list = field(default_factory=list)
validation: list = field(default_factory=list)
test: list = field(default_factory=list)
baseline_run_id: str = "baseline_run"
baseline_val_accuracy: float = 0.5
correctness: dict = field(default_factory=dict)
# =========================================================================
# 13a: Runner 骨架 + 纯函数
# =========================================================================
class TestResumePlan:
"""resume_plan 纯函数。"""
def test_epoch_done_advances_epoch(self) -> None:
"""epoch_done 阶段:下一 epoch 从头开始。"""
plan = resume_plan(epoch=3, phase="epoch_done", step_completed=5)
assert plan["first_epoch"] == 4
assert plan["resume_epoch"] is None
assert plan["resume_step_from"] == 0
def test_in_epoch_resumes_same_epoch(self) -> None:
"""in_epoch 阶段:从同 epoch 的下一个 step 续跑。"""
plan = resume_plan(epoch=2, phase="in_epoch", step_completed=3)
assert plan["first_epoch"] == 2
assert plan["resume_epoch"] == 2
assert plan["resume_step_from"] == 4
def test_in_epoch_step_zero(self) -> None:
"""in_epoch step_completed=0:从 step 1 续跑。"""
plan = resume_plan(epoch=1, phase="in_epoch", step_completed=0)
assert plan["resume_step_from"] == 1
class TestGuardInfraFailures:
"""_guard_infra_failures 基础设施护栏。"""
def test_low_error_rate_passes(self) -> None:
"""error 率 <= 10% 不抛异常。"""
result = _FakeInferenceResult(
total=100,
stop_reason_counts={"finished": 95, "error": 5},
)
_guard_infra_failures(result, context="test") # 不应抛异常
def test_high_error_rate_raises(self) -> None:
"""error 率 > 10% 抛 RuntimeError。"""
result = _FakeInferenceResult(
total=10,
stop_reason_counts={"finished": 8, "error": 2},
)
with pytest.raises(RuntimeError, match="基础设施失败率过高"):
_guard_infra_failures(result, context="test")
def test_zero_total_does_not_crash(self) -> None:
"""total=0 时不除零崩溃。"""
result = _FakeInferenceResult(total=0, stop_reason_counts={})
_guard_infra_failures(result, context="test") # 不应抛异常
def test_no_error_key_passes(self) -> None:
"""stop_reason_counts 无 error 键时正常通过。"""
result = _FakeInferenceResult(
total=10,
stop_reason_counts={"finished": 10},
)
_guard_infra_failures(result, context="test")
# =========================================================================
# 13b: _apply_batch_correctness + _compute_total_steps
# =========================================================================
class TestApplyBatchCorrectness:
"""_apply_batch_correctness rollout 完整性护栏。"""
def test_complete_batch_updates_correctness(self) -> None:
"""完整 rollout 正常更新 correctness。"""
batch = [_FakeQuestion(question_id="q1"), _FakeQuestion(question_id="q2")]
correctness: dict[str, bool] = {}
# mock HarnessLog
mock_log = MagicMock()
mock_log.query.return_value = [
{"question_id": "q1", "prediction": "A", "answer": "A", "steps_json": "[]"},
{"question_id": "q2", "prediction": "B", "answer": "A", "steps_json": "[]"},
]
with patch("app.harness.validate._load_run_rows") as mock_load:
mock_load.return_value = {
"q1": {"prediction": "A", "answer": "A", "_correct": True, "steps": []},
"q2": {"prediction": "B", "answer": "A", "_correct": False, "steps": []},
}
_apply_batch_correctness(correctness, mock_log, "run_1", batch)
assert correctness["q1"] is True
assert correctness["q2"] is False
def test_missing_prediction_raises(self) -> None:
"""rollout 缺预测行时抛 RuntimeError。"""
batch = [_FakeQuestion(question_id="q1"), _FakeQuestion(question_id="q2")]
correctness: dict[str, bool] = {}
mock_log = MagicMock()
with patch("app.harness.validate._load_run_rows") as mock_load:
mock_load.return_value = {
"q1": {"prediction": "A", "answer": "A", "_correct": True, "steps": []},
# q2 缺失
}
with pytest.raises(RuntimeError, match="rollout 不完整"):
_apply_batch_correctness(correctness, mock_log, "run_1", batch)
class TestComputeTotalSteps:
"""_compute_total_steps 退火地平线。"""
def test_basic_calculation(self) -> None:
"""基本退火地平线计算。"""
questions = [
_FakeQuestion(question_id=f"q{i}", task_type="Action Reasoning") for i in range(20)
]
# 全错题
correctness = {q.question_id: False for q in questions}
config = MagicMock()
config.batch_size = 5
config.min_class_per_batch = 1
config.batch_correct_ratio = 0.0
config.epochs = 3
pools = _FakePools(diagnosis=questions, correctness=correctness)
total = _compute_total_steps(pools, correctness, config)
# 20 题 / batch_size 5 = 4 步/epoch * 3 epochs = 12
assert total == 12
# =========================================================================
# 13b: _batch_from_ids
# =========================================================================
class TestBatchFromIds:
"""_batch_from_ids 按 ID 重建 batch。"""
def test_preserves_order(self) -> None:
"""按 ids 顺序取出,保持原 batch 划分。"""
q1 = _FakeQuestion(question_id="q1")
q2 = _FakeQuestion(question_id="q2")
q3 = _FakeQuestion(question_id="q3")
pools = _FakePools(diagnosis=[q1, q2, q3])
batch = _batch_from_ids(pools, ["q3", "q1"])
assert [q.question_id for q in batch] == ["q3", "q1"]
# =========================================================================
# 13b: _snapshot_current_skills
# =========================================================================
class TestSnapshotCurrentSkills:
"""_snapshot_current_skills 快照 skill 文件。"""
def test_snapshots_md_files(self, tmp_path: Path) -> None:
"""只快照 .md 文件。"""
(tmp_path / "action-reasoning.md").write_text("skill content 1")
(tmp_path / "temporal.md").write_text("skill content 2")
(tmp_path / "meta.json").write_text("{}")
snapshot = _snapshot_current_skills(tmp_path)
assert "action-reasoning.md" in snapshot
assert "temporal.md" in snapshot
assert "meta.json" not in snapshot
assert snapshot["action-reasoning.md"] == "skill content 1"
# =========================================================================
# 13c: _outcome_to_quadrant_pairs
# =========================================================================
class TestOutcomeToQuadrantPairs:
"""_outcome_to_quadrant_pairs 四象限拍平。"""
def test_all_quadrants(self) -> None:
"""四象限各有一个 qid 时生成 4 条 pair。"""
outcome = ValidationOutcome(
action="accept_confirmed",
accepted=True,
stop_reason="confirmed",
e_value=10.0,
w=3,
l=0,
n_used=10,
delta_hat=0.3,
delta_shrunk=0.2,
baseline_acc=0.7,
candidate_acc=0.9,
improvements=["q1"],
regressions=["q2"],
persistent_fails=["q3"],
stable_successes=["q4"],
)
pairs = _outcome_to_quadrant_pairs("Action Reasoning", outcome)
assert len(pairs) == 4
by_qid = {p["question_id"]: p for p in pairs}
assert by_qid["q1"]["category"] == "improved"
assert by_qid["q1"]["prev_correct"] is False
assert by_qid["q1"]["curr_correct"] is True
assert by_qid["q2"]["category"] == "regressed"
assert by_qid["q3"]["category"] == "persistent_fail"
assert by_qid["q4"]["category"] == "stable_success"
def test_empty_outcome(self) -> None:
"""四象限全空时返回空列表。"""
outcome = ValidationOutcome(
action="reject",
accepted=False,
stop_reason="directional",
e_value=0.5,
w=0,
l=2,
n_used=5,
delta_hat=-0.1,
delta_shrunk=-0.05,
baseline_acc=0.8,
candidate_acc=0.6,
)
assert _outcome_to_quadrant_pairs("Any", outcome) == []
# =========================================================================
# 13c: _build_comparison_pairs
# =========================================================================
class TestBuildComparisonPairs:
"""_build_comparison_pairs momentum 纵向对比对。"""
def test_builds_pairs(self) -> None:
"""正确构造对比对。"""
sampled = [_FakeQuestion(question_id="q1", question="问题1")]
prev_rows = {
"q1": {"prediction": "A", "_correct": True},
}
curr_rows = {
"q1": {"prediction": "B", "_correct": False},
}
pairs = _build_comparison_pairs(sampled, prev_rows, curr_rows)
assert len(pairs) == 1
assert pairs[0]["question"] == "问题1"
assert pairs[0]["prev_prediction"] == "A"
assert pairs[0]["curr_prediction"] == "B"
assert pairs[0]["correct_prev"] is True
assert pairs[0]["correct_curr"] is False
def test_missing_rows_use_defaults(self) -> None:
"""缺失行时使用默认值。"""
sampled = [_FakeQuestion(question_id="q1")]
pairs = _build_comparison_pairs(sampled, {}, {})
assert pairs[0]["prev_prediction"] == ""
assert pairs[0]["correct_prev"] is False
# =========================================================================
# 13e: _should_early_stop
# =========================================================================
class TestShouldEarlyStop:
"""_should_early_stop 步粒度 early stop。"""
def test_improved_this_epoch_resets(self, tmp_path: Path) -> None:
"""本 epoch best 刷新时重置计数器。"""
# 写 manifest + best
manifest = {
"name": "test",
"store": ".",
"current": {"videos": "v", "questions": "q", "skills": "s/v1", "prompts": "p/v1"},
"best": {"epoch": 2, "val_acc": 0.9},
"history": [],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest))
state = MagicMock()
state.steps_since_best_improved = 10
result = _should_early_stop(tmp_path, epoch=2, steps_this_epoch=5, state=state, patience=20)
assert result is False
assert state.steps_since_best_improved == 0
def test_no_improvement_accumulates(self, tmp_path: Path) -> None:
"""未刷新时累加步数。"""
manifest = {
"name": "test",
"store": ".",
"current": {"videos": "v", "questions": "q", "skills": "s/v1", "prompts": "p/v1"},
"best": {"epoch": 1, "val_acc": 0.5},
"history": [],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest))
state = MagicMock()
state.steps_since_best_improved = 15
result = _should_early_stop(tmp_path, epoch=3, steps_this_epoch=5, state=state, patience=20)
assert result is True # 15 + 5 = 20 >= 20
assert state.steps_since_best_improved == 20
def test_below_patience_continues(self, tmp_path: Path) -> None:
"""累计步数未达阈值时继续。"""
manifest = {
"name": "test",
"store": ".",
"current": {"videos": "v", "questions": "q", "skills": "s/v1", "prompts": "p/v1"},
"best": {"epoch": 1, "val_acc": 0.5},
"history": [],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest))
state = MagicMock()
state.steps_since_best_improved = 10
result = _should_early_stop(tmp_path, epoch=3, steps_this_epoch=5, state=state, patience=20)
assert result is False
assert state.steps_since_best_improved == 15
def test_step_granularity(self, tmp_path: Path) -> None:
"""步粒度而非 epoch 粒度。"""
manifest = {
"name": "test",
"store": ".",
"current": {"videos": "v", "questions": "q", "skills": "s/v1", "prompts": "p/v1"},
"best": {"epoch": 1, "val_acc": 0.5},
"history": [],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest))
state = MagicMock()
# 连续 3 个 epoch,每个 3 步
state.steps_since_best_improved = 0
for ep in range(2, 5):
stopped = _should_early_stop(
tmp_path, epoch=ep, steps_this_epoch=3, state=state, patience=10
)
if ep < 4:
assert stopped is False
else:
# 3+3+3=9 < 10 但第三轮后 9+3=12>=10 在 ep=5 触发
# 实际:ep=2 → 3, ep=3 → 6, ep=4 → 9
assert stopped is False
stopped = _should_early_stop(
tmp_path, epoch=5, steps_this_epoch=3, state=state, patience=10
)
assert stopped is True # 9+3=12>=10
# =========================================================================
# 13c: Probation 数据结构测试
# =========================================================================
class TestProbation:
"""Probation 数据结构。"""
def test_probation_fields(self) -> None:
"""Probation 必须具备全部字段。"""
p = Probation(
task_type="Action Reasoning",
anchor_skills_version="v1",
target_file="action-reasoning.md",
correctness_snapshot={"q1": True},
opened_step=5,
)
assert p.task_type == "Action Reasoning"
assert p.pending_edits == []
def test_pending_edits_append(self) -> None:
"""pending_edits 可追加 RejectedEdit。"""
p = Probation(
task_type="Action Reasoning",
anchor_skills_version="v1",
target_file="action-reasoning.md",
correctness_snapshot={},
opened_step=0,
)
edit = RejectedEdit(
target_file="action-reasoning.md",
target_type="skill",
change_summary="test",
delta=0.1,
source_version="v1",
epoch=0,
gate_w=3,
gate_l=1,
gate_e_value=2.5,
gate_delta_shrunk=0.05,
)
p.pending_edits.append(edit)
assert len(p.pending_edits) == 1
# =========================================================================
# 13c: RejectedSummary 黑名单防污染
# =========================================================================
class TestFormatAppliedEdits:
"""_format_applied_edits 只拼 applied 的 edit。"""
def test_only_applied_edits_in_summary(self) -> None:
"""只有 applied 状态的 edit 进入摘要。"""
record = MagicMock()
record.edits = [
{"op": "replace", "target": "section1"},
{"op": "insert", "content": "new_content"},
{"op": "delete", "target": "old_stuff"},
]
record.apply_report = [
{"status": "applied_exact"},
{"status": "skipped_not_found"},
{"status": "applied_fuzzy"},
]
summary = _format_applied_edits(record)
assert summary is not None
assert "section1" in summary
assert "old_stuff" in summary
assert "new_content" not in summary
def test_zero_applied_returns_info_message(self) -> None:
"""0 applied 返回信息性消息(非 None)。"""
record = MagicMock()
record.edits = [{"op": "replace", "target": "sec"}]
record.apply_report = [{"status": "skipped_not_found"}]
summary = _format_applied_edits(record)
assert summary is not None
assert "0 applied" in summary
def test_no_edits_returns_none(self) -> None:
"""无 edit 时返回 None。"""
record = MagicMock()
record.edits = []
assert _format_applied_edits(record) is None
def test_no_report_includes_all_edits(self) -> None:
"""无 apply_report 时包含所有 edit。"""
record = MagicMock()
record.edits = [{"op": "replace", "target": "foo"}]
record.apply_report = []
summary = _format_applied_edits(record)
assert summary is not None
assert "foo" in summary
class TestFallbackSummary:
"""_fallback_summary 兜底黑名单摘要。"""
def test_from_suggestions(self) -> None:
"""有 suggestions 时拼接 change 字段。"""
record = MagicMock()
record.suggestions = [{"change": "改 A"}, {"change": "改 B"}]
outcome = MagicMock()
outcome.delta_hat = -0.1
summary = _fallback_summary(record, outcome)
assert "改 A" in summary
assert "改 B" in summary
def test_no_suggestions_uses_delta(self) -> None:
"""无 suggestions 时使用 delta 信息。"""
record = MagicMock()
record.suggestions = []
outcome = MagicMock()
outcome.delta_hat = -0.15
summary = _fallback_summary(record, outcome)
assert "delta" in summary
assert "-0.15" in summary
class TestRejectedSummaryIntegration:
"""_rejected_summary_static 集成:两个子函数组合。"""
def test_static_delegates_to_format_applied(self) -> None:
"""有 applied edit 时 static 方法返回 _format_applied_edits 结果。"""
from app.harness.runner import Runner
record = MagicMock()
record.edits = [{"op": "replace", "target": "section1"}]
record.apply_report = [{"status": "applied_exact"}]
record.suggestions = []
outcome = MagicMock()
outcome.delta_hat = 0.1
summary = Runner._rejected_summary_static(record, outcome)
assert "section1" in summary
def test_static_falls_back_to_suggestions(self) -> None:
"""无 edit 时 static 方法使用 _fallback_summary。"""
from app.harness.runner import Runner
record = MagicMock()
record.edits = []
record.suggestions = [{"change": "尝试 X"}]
outcome = MagicMock()
outcome.delta_hat = -0.2
summary = Runner._rejected_summary_static(record, outcome)
assert "尝试 X" in summary
class TestWriteSkipReport:
"""_write_skip_report 辅助函数。"""
def test_writes_cooldown_report(self, tmp_path: Path) -> None:
"""cooldown 路径写 step_report JSON 文件。"""
(tmp_path / "analyses").mkdir()
_write_skip_report(
tmp_path,
epoch=1,
step=0,
global_step=5,
task_type="Action Reasoning",
action="cooldown",
baseline_acc=0.75,
budget=3,
)
report_path = tmp_path / "analyses" / "step_report_e1_s0_action-reasoning.json"
assert report_path.exists()
data = json.loads(report_path.read_text())
assert data["gate_action"] == "cooldown"
assert data["candidate_acc"] == 0.75
assert data["gate_w"] is None
def test_writes_skipped_report(self, tmp_path: Path) -> None:
"""skipped 路径写 step_report 并传递 rank_clip_triggered。"""
(tmp_path / "analyses").mkdir()
_write_skip_report(
tmp_path,
epoch=2,
step=1,
global_step=10,
task_type="Temporal Reasoning",
action="skipped",
baseline_acc=0.6,
budget=2,
rank_clip_triggered=True,
)
report_path = tmp_path / "analyses" / "step_report_e2_s1_temporal-reasoning.json"
assert report_path.exists()
data = json.loads(report_path.read_text())
assert data["gate_action"] == "skipped"
assert data["rank_clip_triggered"] is True
# =========================================================================
# 13a: _TrainState 基本构造
# =========================================================================
class TestTrainState:
"""_TrainState dataclass 基本构造与字段默认值。"""
def test_default_fields(self) -> None:
"""默认字段值正确。"""
state = _TrainState(
correctness={"q1": True},
gate_pools=MagicMock(),
baseline_cache=MagicMock(),
eval_prev_acc=0.5,
eval_prev_run_id="run1",
best_val_acc=0.5,
best_skills_version="v1",
best_prompts_version="v1",
)
assert state.global_step == 0
assert state.gate_epoch_observed is False
assert state.probations == {}
assert state.gate_cooldown == {}
assert state.rejected_buffer == {}
assert state.system_packs == []
assert state.tool_packs == []
assert state.changed_task_types_this_epoch == set()
assert state.steps_since_best_improved == 0
# =========================================================================
# 13c: cooldown 递减测试
# =========================================================================
class TestCooldownDecrement:
"""gate_cooldown 每 step 递减、归零剔除。"""
def test_decrement_and_remove(self) -> None:
"""冷却值递减,归零剔除。"""
cooldown = {"type_a": 3, "type_b": 1, "type_c": 2}
# 模拟 _run_step 末尾的冷却递减
cooldown = {t: n - 1 for t, n in cooldown.items() if n - 1 > 0}
assert cooldown == {"type_a": 2, "type_c": 1}
cooldown = {t: n - 1 for t, n in cooldown.items() if n - 1 > 0}
assert cooldown == {"type_a": 1}
cooldown = {t: n - 1 for t, n in cooldown.items() if n - 1 > 0}
assert cooldown == {}
+554
View File
@@ -0,0 +1,554 @@
"""tests/unit/test_harness_validate.py — app/harness/validate.py 的单元测试。
覆盖数据类型字段materialize 物化与清理async validate_skill_local
accept/reject/prefix 校验/INFRA 护栏/缓存命中/最后一块终态
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from app.harness.gate_ladder import BaselineCache, skill_hash
from app.harness.inference import PREDICTIONS_SCHEMA, InferenceResult
from app.harness.log import HarnessLog
from app.harness.validate import (
Probation,
ValidationOutcome,
materialize_candidate_skill,
validate_skill_local,
)
from core.evolution import GateParams, RejectedEdit
from core.types import GeneratedQuestion
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# 辅助工具
# ---------------------------------------------------------------------------
_DEFAULT_GATE_PARAMS = GateParams(
e_confirm=20.0,
e_provisional=3.0,
w_net_min=2,
delta_min=0.05,
lambda_dir=-2.0,
e_rollback=10.0,
)
def _make_questions(
n: int,
task_type: str = "temporal",
prefix: str = "q",
) -> list[GeneratedQuestion]:
"""生成 n 个测试用 GeneratedQuestion。"""
return [
GeneratedQuestion(
question_id=f"{prefix}{i}",
video_id=f"v{i}",
task_type=task_type,
question=f"Question {i}?",
options=("A", "B", "C", "D"),
answer="A",
source_nodes=(),
difficulty="easy",
)
for i in range(n)
]
def _setup_workspace(tmp_path: Path) -> Path:
"""在 tmp_path 下构建最小 workspace 结构。"""
skills_dir = tmp_path / "skills" / "v1"
skills_dir.mkdir(parents=True)
(skills_dir / "temporal.md").write_text("baseline skill content", encoding="utf-8")
return tmp_path
def _make_log(workspace: Path, run_id: str = "test_master") -> HarnessLog:
"""创建 HarnessLog 并初始化 predictions 表。"""
db_path = workspace / "harness.db"
log = HarnessLog(str(db_path), run_id)
log.create_table("predictions", PREDICTIONS_SCHEMA)
return log
def _insert_predictions(
log: HarnessLog,
run_id: str,
correctness: dict[str, bool],
answer: str = "A",
) -> None:
"""向 predictions 表插入指定 run_id 的逐题预测记录。
通过在 record 中显式传入 run_id 覆盖 log 的默认 run_id
"""
for qid, correct in correctness.items():
prediction = answer if correct else "Z"
log.insert(
"predictions",
{
"run_id": run_id,
"video_id": "v0",
"question_id": qid,
"task_type": "temporal",
"prediction": prediction,
"answer": answer,
"evidence": "",
"reasoning": "",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 10,
"stop_reason": "completed",
"steps_json": "[]",
},
)
def _make_mock_run_inference(
log: HarnessLog,
baseline_correctness: dict[str, bool],
candidate_correctness: dict[str, bool],
error_count: int = 0,
):
"""构建 mock RunInferenceFn。
根据 run_id 中的 arm 标记_base / _cand决定使用基线或候选对错映射
将预测写入 log 的同一 DB返回 InferenceResult
"""
call_log: list[dict[str, Any]] = []
async def mock_fn(
questions: list[GeneratedQuestion],
*,
run_id: str,
skills_dir: Path,
) -> InferenceResult:
is_baseline = run_id.endswith("_base")
correctness = baseline_correctness if is_baseline else candidate_correctness
call_log.append({"run_id": run_id, "skills_dir": skills_dir, "n": len(questions)})
per_q = {q.question_id: correctness.get(q.question_id, False) for q in questions}
_insert_predictions(log, run_id, per_q)
correct = sum(per_q.values())
total = len(questions)
stop_counts: dict[str, int] = {"completed": total - error_count}
if error_count > 0:
stop_counts["error"] = error_count
return InferenceResult(
run_id=run_id,
accuracy=correct / total if total else 0.0,
total=total,
correct=correct,
per_task_type={},
steps_mean=1.0,
token_usage={"prompt_tokens": 10, "completion_tokens": 10},
stop_reason_counts=stop_counts,
)
return mock_fn, call_log
# ===========================================================================
# 数据类型测试
# ===========================================================================
class TestValidationOutcomeFields:
"""ValidationOutcome 数据类型字段完整性测试。"""
def test_validation_outcome_fields(self) -> None:
"""所有字段可构造、默认值合理。"""
outcome = ValidationOutcome(
action="accept_confirmed",
accepted=True,
stop_reason="confirmed",
e_value=25.0,
w=5,
l=1,
n_used=10,
delta_hat=0.4,
delta_shrunk=0.3,
baseline_acc=0.6,
candidate_acc=0.9,
)
assert outcome.action == "accept_confirmed"
assert outcome.accepted is True
assert outcome.stop_reason == "confirmed"
assert outcome.e_value == 25.0
assert outcome.w == 5
assert outcome.l == 1
assert outcome.n_used == 10
assert outcome.delta_hat == 0.4
assert outcome.delta_shrunk == 0.3
assert outcome.baseline_acc == 0.6
assert outcome.candidate_acc == 0.9
assert outcome.improvements == []
assert outcome.regressions == []
assert outcome.persistent_fails == []
assert outcome.stable_successes == []
assert outcome.candidate_correctness == {}
assert outcome.evidence_rows == []
class TestProbationFields:
"""Probation 数据类型字段完整性测试。"""
def test_probation_fields(self) -> None:
"""所有字段可构造、pending_edits 默认空列表。"""
prob = Probation(
task_type="temporal",
anchor_skills_version="v1",
target_file="temporal.md",
correctness_snapshot={"q0": True, "q1": False},
opened_step=5,
)
assert prob.task_type == "temporal"
assert prob.anchor_skills_version == "v1"
assert prob.target_file == "temporal.md"
assert prob.correctness_snapshot == {"q0": True, "q1": False}
assert prob.opened_step == 5
assert prob.pending_edits == []
def test_probation_with_pending_edits(self) -> None:
"""pending_edits 可附加 RejectedEdit。"""
edit = RejectedEdit(
target_file="temporal.md",
target_type="skill",
change_summary="bad change",
delta=-0.1,
source_version="v2",
epoch=1,
)
prob = Probation(
task_type="temporal",
anchor_skills_version="v1",
target_file="temporal.md",
correctness_snapshot={},
opened_step=3,
pending_edits=[edit],
)
assert len(prob.pending_edits) == 1
assert prob.pending_edits[0].change_summary == "bad change"
# ===========================================================================
# materialize 测试
# ===========================================================================
class TestMaterializeCandidateSkill:
"""materialize_candidate_skill 物化与清理测试。"""
def test_materialize_candidate_skill(self, tmp_path: Path) -> None:
"""正常物化:基线目录被复制,target_file 被覆写为候选内容。"""
workspace = _setup_workspace(tmp_path)
cand_dir = materialize_candidate_skill(
workspace, "v1", "temporal.md", "candidate skill content"
)
try:
assert cand_dir.exists()
assert cand_dir.parent == workspace / ".cand_tmp"
assert (cand_dir / "temporal.md").read_text(encoding="utf-8") == (
"candidate skill content"
)
finally:
import shutil
shutil.rmtree(cand_dir)
def test_materialize_cleanup_on_failure(self, tmp_path: Path) -> None:
"""基线目录不存在时 OSError,临时目录被清理。"""
workspace = tmp_path / "ws"
workspace.mkdir()
# 不创建 skills/v1copytree 应失败
with pytest.raises(OSError):
materialize_candidate_skill(workspace, "v1", "temporal.md", "content")
# .cand_tmp 可能存在但内部应被清理
cand_tmp = workspace / ".cand_tmp"
if cand_tmp.exists():
remaining = list(cand_tmp.iterdir())
assert remaining == [], f"临时目录未被清理: {remaining}"
# ===========================================================================
# async 验证测试
# ===========================================================================
@pytest.mark.asyncio
async def test_validate_skill_local_accept(tmp_path: Path) -> None:
"""候选全对、基线全错 → 高 e 值 → accept_confirmed。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
# 基线全错,候选全对 → W=6, L=0 → E=18.14 → CONFIRMEDe_confirm=15
baseline_correct = {f"q{i}": False for i in range(6)}
candidate_correct = {f"q{i}": True for i in range(6)}
mock_fn, call_log = _make_mock_run_inference(log, baseline_correct, candidate_correct)
# e_confirm=15 使 E=18.14 超过阈值触发 CONFIRMED
accept_params = GateParams(
e_confirm=15.0,
e_provisional=3.0,
w_net_min=2,
delta_min=0.05,
lambda_dir=-2.0,
e_rollback=10.0,
)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="improved skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=accept_params,
gate_block=6,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
assert outcome.accepted is True
assert outcome.action == "accept_confirmed"
assert outcome.stop_reason == "confirmed"
assert outcome.w == 6
assert outcome.l == 0
assert outcome.n_used == 6
assert outcome.candidate_acc == 1.0
assert outcome.baseline_acc == 0.0
assert len(outcome.evidence_rows) == 6
# 终态证据行携带 stop_reason
assert outcome.evidence_rows[-1]["stop_reason"] == "confirmed"
# 候选临时目录应被清理
cand_tmp = workspace / ".cand_tmp"
if cand_tmp.exists():
assert list(cand_tmp.iterdir()) == []
finally:
log.close()
@pytest.mark.asyncio
async def test_validate_skill_local_reject(tmp_path: Path) -> None:
"""候选全错、基线全对 → L 高 → 方向拒绝。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
# 基线全对,候选全错 → W=0, L=6 → 方向拒绝
baseline_correct = {f"q{i}": True for i in range(6)}
candidate_correct = {f"q{i}": False for i in range(6)}
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="bad skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=6,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
assert outcome.accepted is False
assert outcome.action == "reject"
assert outcome.stop_reason == "directional"
assert outcome.w == 0
assert outcome.l == 6
finally:
log.close()
@pytest.mark.asyncio
async def test_gate_prefix_must_contain_gate(tmp_path: Path) -> None:
"""gate_run_prefix 不含 '_gate_' 时抛 ValueError。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
async def noop_fn(questions, *, run_id, skills_dir):
raise AssertionError("不应被调用")
try:
with pytest.raises(ValueError, match="_gate_"):
await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="content",
base_skill_content="baseline",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=noop_fn,
log=log,
gate_run_prefix="step1_no_marker",
)
finally:
log.close()
@pytest.mark.asyncio
async def test_infra_guard_threshold(tmp_path: Path) -> None:
"""推理错误率超阈值时抛 RuntimeError。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
# 需要 >=10 题次才触发 INFRA 护栏
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
baseline_correct = {f"q{i}": False for i in range(6)}
candidate_correct = {f"q{i}": False for i in range(6)}
# 每次 run_inference 报 error_count=5,两侧各 5 → 10/12 > 0.5
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct, error_count=5)
try:
with pytest.raises(RuntimeError, match="错误率过高"):
await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="content",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=6,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
finally:
log.close()
@pytest.mark.asyncio
async def test_baseline_cache_hit(tmp_path: Path) -> None:
"""基线缓存全命中时不发起基线侧推理。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
s_hash = skill_hash("baseline skill content")
# 预填充缓存:全部题目基线全错
for q in questions:
cache.put("temporal", s_hash, "p1", q.question_id, False)
# 候选全对 → accept
candidate_correct = {f"q{i}": True for i in range(4)}
baseline_correct = {f"q{i}": False for i in range(4)}
mock_fn, call_log = _make_mock_run_inference(log, baseline_correct, candidate_correct)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="improved skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
# 只有候选侧调用了 run_inference(_cand),基线侧全命中不调用
base_calls = [c for c in call_log if c["run_id"].endswith("_base")]
cand_calls = [c for c in call_log if c["run_id"].endswith("_cand")]
assert len(base_calls) == 0, "基线缓存全命中不应发起推理"
assert len(cand_calls) == 1
assert outcome.accepted is True
finally:
log.close()
@pytest.mark.asyncio
async def test_last_block_terminal(tmp_path: Path) -> None:
"""单块 + n_remaining=0 → 终态判定(provisional 或 inertia),非 continue。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
# 4 题,gate_block=4 → 一块走完,n_remaining=0
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
# 两题翻转(W=2, L=0),但 e_confirm=20 难以达到 → provisional 或 inertia
baseline_correct = {"q0": False, "q1": False, "q2": True, "q3": True}
candidate_correct = {"q0": True, "q1": True, "q2": True, "q3": True}
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="candidate skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
gate_n_max=4,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
# n_remaining=0 → 不可能是 continue
assert outcome.stop_reason in (
"confirmed",
"provisional",
"inertia",
"directional",
"futility",
)
assert outcome.n_used == 4
# 终态行标记 stop_reason
assert outcome.evidence_rows[-1]["stop_reason"] != ""
finally:
log.close()