Compare commits
10 Commits
e0f3ee10ec
...
f1dea4f68f
| Author | SHA1 | Date | |
|---|---|---|---|
| f1dea4f68f | |||
| 7a00bc1a28 | |||
| be0e89401e | |||
| 2296134f73 | |||
| 7bc6fc752c | |||
| a6b816db94 | |||
| d7f1bdeea6 | |||
| 886a444d1d | |||
| a550d39e1c | |||
| bd4e438c6c |
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 是 set,JSON 无 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。
|
||||
|
||||
返回:
|
||||
复活的 SystemCasePack;failure_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 dict;checkpoint.json 不存在时返回 None。
|
||||
"""
|
||||
path = workspace_dir / "checkpoint.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return json.loads(path.read_text())
|
||||
@@ -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_hat:p_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 变化、缓存自然 miss;prompts 版本变化同理。
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""慢更新动量生成 — epoch 末为单个 skill 产出新的动量指导。
|
||||
|
||||
对标 SkillOpt 的 slow_update 机制:拿上一 epoch 末与当前 epoch 末两版 skill,
|
||||
在固定样本上各跑一遍得到纵向对比(comparison_pairs),反思上一轮动量指导是否奏效、
|
||||
本轮正文改动是改善还是漂移,据此重写动量指导。新指导经 patch 引擎的 replace_momentum
|
||||
写回 skill 的 momentum 受保护区,作为下一轮进化的方向锚。
|
||||
|
||||
从 TRM4 core/harness/momentum.py(156 行)迁移 + 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
|
||||
@@ -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 为 None(invalid,无 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/*.md(run_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*soft;soft 缺失传 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*soft;soft 缺失传 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*soft;soft 缺失传 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
@@ -0,0 +1,665 @@
|
||||
"""async 块序贯验证编排 — CE-Gate 局部验证的唯一独立子编排器。
|
||||
|
||||
从 TRM4 core/harness/validate.py (626 行) 迁移,重大重构:
|
||||
- 同步 → async(run_inference 注入为 async callable)
|
||||
- _classify_quadrants → core.evolution.classify_quadrants 纯函数
|
||||
- 配对逻辑 → 复用 core.evolution.pair_block + 本地证据行组装
|
||||
- _load_run_rows / _candidate_correctness_from_db → 共享 log.query()
|
||||
- materialize_candidate_skill 保持同步(纯文件操作)
|
||||
|
||||
基线与候选在同一阶梯前缀上逐块配对,只数翻转(基线错→候选对 = W,
|
||||
基线对→候选错 = L),每块结束调 gate_decision 做四出口判定。
|
||||
基线侧逐题对错走 BaselineCache 内容寻址缓存,miss 才新鲜跑。
|
||||
判定逻辑全部在 core/evolution/gate,本模块只负责推理编排与证据收集。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.harness.gate_ladder import BaselineCache, skill_hash
|
||||
from 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)负责绑定 llm、tool_dispatch_fn、prompt_builder、
|
||||
log、concurrency、max_steps、skill_mode 等共享依赖。
|
||||
validate 侧只传 questions、run_id、skills_dir 三个逐块变化的参数。
|
||||
"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
questions: list[GeneratedQuestion],
|
||||
*,
|
||||
run_id: str,
|
||||
skills_dir: Path,
|
||||
) -> InferenceResult: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据类型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InferenceRunConfig:
|
||||
"""一次推理运行的配置三元组,把"如何跑推理"内聚成一组。
|
||||
|
||||
字段:
|
||||
concurrency: 推理并发度。
|
||||
max_steps: 单题最大推理步数。
|
||||
skill_mode: 推理 skill 模式("auto" / "manual" / "none")。
|
||||
"""
|
||||
|
||||
concurrency: int
|
||||
max_steps: int
|
||||
skill_mode: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationOutcome:
|
||||
"""CE-Gate 局部验证结果:三态动作 + e-process 证据 + 已观测题逐题对错。
|
||||
|
||||
correctness 二轨语义: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 为键的行字典。每行至少包含 prediction、answer、
|
||||
_correct、steps 等字段。
|
||||
"""
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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 run(run_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)
|
||||
@@ -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:
|
||||
"""构造测试用 LLMResponse(submit_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} 未创建"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 == {}
|
||||
@@ -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/v1,copytree 应失败
|
||||
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 → CONFIRMED(e_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()
|
||||
Reference in New Issue
Block a user