merge: feat/app-harness — app/harness/ 训练循环编排层(14 文件, 算法保真 #6/#10/#13)

This commit is contained in:
2026-07-07 20:35:04 -04:00
28 changed files with 12486 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
"""app/harness/ — 训练循环编排层。
组合 core/evolution/(决策内核)+ core/agent/AgentLoop+ adapters/LLM/VLM/telemetry),
实现自进化闭环的训练循环三级嵌套、块序贯验证、快慢双速进化、checkpoint/resume。
"""
from app.harness.config import RunConfig, load_config
from app.harness.inference import InferenceResult, run_inference
from app.harness.log import HarnessLog, RunLogImpl
from app.harness.pools import Pools, build_or_load_pools, build_pools, load_pools, save_pools
from app.harness.runner import Runner
from app.harness.workspace import (
ResolvedPaths,
VersionedPromptStore,
VersionedSkillStore,
resolve_paths,
)
__all__ = [
"HarnessLog",
"InferenceResult",
"Pools",
"ResolvedPaths",
"RunConfig",
"RunLogImpl",
"Runner",
"VersionedPromptStore",
"VersionedSkillStore",
"build_or_load_pools",
"build_pools",
"load_config",
"load_pools",
"resolve_paths",
"run_inference",
"save_pools",
]
+240
View File
@@ -0,0 +1,240 @@
"""混合 mini-batch 切分:大类打散、小类整锁,供 runner 每 step 处理一个 batch。"""
from __future__ import annotations
import math
import random
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from core.types import GeneratedQuestion
def build_batches(
items: list[GeneratedQuestion],
correctness: dict[str, bool],
batch_size: int,
min_class_per_batch: int,
seed: int,
correct_ratio: float = 0.0,
) -> tuple[list[list[GeneratedQuestion]], int]:
"""把诊断池里的题目切成多个混合 mini-batch。
当 ``correct_ratio > 0`` 时,按题型为每组错题配比一定数量的正确题,使 batch
包含正误混合样本("动量"机制);``correct_ratio <= 0`` 时退化为纯错题模式。
参数:
items: 候选题目全集。
correctness: question_id -> 基线是否答对。
batch_size: 单个 batch 的样本数上限(> 0)。
min_class_per_batch: 小类判定阈值——题目数 ≤ 此值的题型整组锁进单一
batch> 0)。
seed: 随机种子,保证相同输入产出完全一致的切分。
correct_ratio: 正确题占比(0.0 ~ 1.0)。0.0 = 纯错题;0.5 = 错题:正确题 = 1:1。
返回:
(非空 mini-batch 列表, selected_count);无错题时返回 ([], 0)。
selected_count 是所有 batch 中题目总数。
异常:
ValueError: batch_size 或 min_class_per_batch < 1, 或
min_class_per_batch >= batch_size(破坏小类整组装箱不超容的前提)。
关键实现细节:
装箱顺序为「先小类后大类」。小类整组用 first-fit-decreasing 装箱:按组大小
降序处理(同大小再按 task_type 排序保证确定性),每组放进第一个剩余容量足够
的 batch;若现有 batch 都装不下就新开一个空 batch——因小类组大小
≤ min_class_per_batch < batch_size,新空 batch 必能容纳,故小类装箱永不抛
ValueError,且保证整组不拆。再把大类样本(seed 确定性 shuffle 后)round-robin
分发到所有现存 batch 填充剩余容量。这样小类聚集于单 batch、大类散布多 batch
且与小类共箱,自然产生多类混合 batch(纯类切片会被 multiclass 断言拒绝)。
nb = ceil(总题数/batch_size) 是初始 batch 数下界估计而非硬上限:小类装箱可能
新开 bin 使实际 batch 数超过 nb。每次新开 bin 都意味着总容量随之增加,故总容量
恒 ≥ 总题数,大类 round-robin 跳过满箱后仍能放下全部样本,不会违反 batch_size
上限。题型按名称排序处理以保证跨运行确定性,不依赖 dict 遍历顺序。
"""
_validate_params(batch_size, min_class_per_batch)
rng = random.Random(seed)
grouped = _select_mixed_by_task_type(items, correctness, correct_ratio, rng)
total = sum(len(g) for g in grouped.values())
if total == 0:
return [], 0
nb = max(1, math.ceil(total / batch_size))
batches: list[list[GeneratedQuestion]] = [[] for _ in range(nb)]
small, large = _split_by_size(grouped, min_class_per_batch)
for group in _small_groups_decreasing(small):
_pack_small_class(batches, group, batch_size)
_distribute_large_classes(batches, large, batch_size, rng)
result = [b for b in batches if b]
selected_count = sum(len(b) for b in result)
return result, selected_count
def _validate_params(batch_size: int, min_class_per_batch: int) -> None:
"""校验切分参数,非法值直接报错而非用默认值掩盖。
除各自 >= 1 外,强制 min_class_per_batch < batch_size:小类组大小 ≤
min_class_per_batch,唯有此前提成立才能保证小类整组放入单一 batch 而不超容;否则
_pack_small_class 新开的 bin 会装入超 batch_size 的整组,静默违反容量合约。此约束
与 config._validate_minibatch 一致,是 build_batches 对自身前提的防御性自校验(P5)。
"""
if batch_size < 1:
raise ValueError(f"batch_size 必须 >= 1, 实为 {batch_size}")
if min_class_per_batch < 1:
raise ValueError(f"min_class_per_batch 必须 >= 1, 实为 {min_class_per_batch}")
if min_class_per_batch >= batch_size:
raise ValueError(
f"min_class_per_batch 必须严格 < batch_size, 否则无法保证小类整组放入单一 "
f"batch 不超容; 实为 min_class_per_batch={min_class_per_batch}, "
f"batch_size={batch_size}"
)
def _split_by_size(
grouped: dict[str, list[GeneratedQuestion]],
min_class_per_batch: int,
) -> tuple[dict[str, list[GeneratedQuestion]], dict[str, list[GeneratedQuestion]]]:
"""按错题数把题型分为小类(≤ 阈值)与大类(> 阈值)两组。"""
small = {t: g for t, g in grouped.items() if len(g) <= min_class_per_batch}
large = {t: g for t, g in grouped.items() if len(g) > min_class_per_batch}
return small, large
def _select_mixed_by_task_type(
items: list[GeneratedQuestion],
correctness: dict[str, bool],
correct_ratio: float,
rng: random.Random,
) -> dict[str, list[GeneratedQuestion]]:
"""按题型分组,为每组错题按比例采样正确题混入。
只对有错题的题型做混合——无错题的题型不进 batch,即使有正确题。
``correct_ratio <= 0`` 时退化为纯错题模式(向后兼容)。
参数:
items: 候选题目全集。
correctness: question_id -> 基线是否答对。
correct_ratio: 正确题占比(0.0 ~ 1.0)。
rng: 随机数发生器,用于采样正确题。
返回:
task_type -> 该题型的混合题目列表(错题全部 + 按比例采样的正确题)。
"""
errors_by_type: dict[str, list[GeneratedQuestion]] = {}
correct_by_type: dict[str, list[GeneratedQuestion]] = {}
for q in items:
qid = q.question_id
if correctness.get(qid) is False:
errors_by_type.setdefault(q.task_type, []).append(q)
elif correctness.get(qid, False):
correct_by_type.setdefault(q.task_type, []).append(q)
if correct_ratio <= 0:
return errors_by_type
# 为每个有错题的 task_type 混入正确题
grouped: dict[str, list[GeneratedQuestion]] = {}
for task_type in sorted(errors_by_type):
errs = errors_by_type[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)
)
grouped[task_type] = errs + sampled
return grouped
def _small_groups_decreasing(
small: dict[str, list[GeneratedQuestion]],
) -> list[list[GeneratedQuestion]]:
"""按组大小降序、同大小按 task_type 升序排出小类组(first-fit-decreasing 顺序)。
参数:
small: task_type -> 小类错题列表。
返回:
排好序的小类组列表;降序处理可降低碎片,确定性 tie-break 保证跨运行一致。
"""
return [small[t] for t in sorted(small, key=lambda t: (-len(small[t]), t))]
def _pack_small_class(
batches: list[list[GeneratedQuestion]],
group: list[GeneratedQuestion],
batch_size: int,
) -> None:
"""用 first-fit 把一个小类整组放入首个容得下的 batch,装不下则新开 bin(就地修改)。
因小类组大小 ≤ min_class_per_batch < batch_size,新开的空 batch 必能容纳整组,
故此函数永不抛 ValueError,且整组不拆。
参数:
batches: 当前各 batch(就地追加,必要时 append 新空 batch)。
group: 待锁定的小类错题(整组不拆)。
batch_size: 单 batch 容量上限。
"""
for b in batches:
if len(b) + len(group) <= batch_size:
b.extend(group)
return
batches.append(list(group))
def _distribute_large_classes(
batches: list[list[GeneratedQuestion]],
large: dict[str, list[GeneratedQuestion]],
batch_size: int,
rng: random.Random,
) -> None:
"""将各大类样本 shuffle 后 round-robin 分发到所有现存 batch(就地修改)。
参数:
batches: 当前各 batch(含小类装箱可能新开的 bin,就地追加)。
large: task_type -> 大类错题列表。
batch_size: 单 batch 容量上限。
rng: 复用的随机数发生器,保证 shuffle 确定性。
异常:
ValueError: 所有 batch 均满仍有样本未放置(总容量估算异常,合法输入不可达)。
关键实现细节:
轮转范围是「所有现存 batch」而非固定 nb 个——小类装箱新开的 bin 也参与分发。
总容量 = 现存 batch 数 × batch_size,每次新开 bin 都同步抬高总容量,故总容量恒
≥ 总错题数,防御性 ValueError 在合法输入下不可达。全局指针在所有大类样本间持续
轮转(不为每类重置),满箱即跳过,使大类充分散布并与已锁定的小类共箱。题型按名称
排序以保证分发顺序确定。
"""
nb = len(batches)
pointer = 0
for task_type in sorted(large):
group = list(large[task_type])
rng.shuffle(group)
for q in group:
pointer = _place_round_robin(batches, q, pointer, batch_size, nb)
def _place_round_robin(
batches: list[list[GeneratedQuestion]],
q: GeneratedQuestion,
pointer: int,
batch_size: int,
nb: int,
) -> int:
"""从 pointer 起找第一个未满 batch 放入 q,返回下一次起始指针。
参数:
batches: 当前各 batch(就地追加)。
q: 待放置的样本。
pointer: 本次轮转起始 batch 下标。
batch_size: 单 batch 容量上限。
nb: batch 总数。
返回:
下一次轮转的起始指针(已前移一位)。
异常:
ValueError: 扫描一轮所有 batch 均满(总容量估算异常)。
"""
for offset in range(nb):
idx = (pointer + offset) % nb
if len(batches[idx]) < batch_size:
batches[idx].append(q)
return (idx + 1) % nb
raise ValueError("所有 batch 均满仍有样本待放置, 总容量估算异常")
+274
View File
@@ -0,0 +1,274 @@
"""step 级续训 checkpoint_TrainState 可持久化字段的序列化 / 反序列化。
_TrainState 的累加包均为扁平纯数据 dataclass,经 dataclasses.asdict 序列化为
纯 JSON dict;反序列化时用 Cls(**d) 还原,其中 SystemCasePack 含嵌套 CaseSample
列表、Probation 含嵌套 RejectedEdit 列表,需逐个重建。
不持久化的字段:gate_pools / baseline_cache(各自文件级自持久化,resume 时按
指纹重载)、best_*(从 manifest best 指针读)、global_step(存 progress 块,
由 train 单独赋值)。gate_epoch_observed 持久化:warm p-hat 在 gate_pools.json
幸存,观测开关须随行,否则 resume 后阶梯排序回退冷启动序。
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict
from typing import TYPE_CHECKING, Any
from app.harness.validate import Probation
from core.evolution.types import (
CaseSample,
RejectedEdit,
SystemCasePack,
ToolCasePack,
)
if TYPE_CHECKING:
from pathlib import Path
CHECKPOINT_SCHEMA_VERSION = 1
# ---------------------------------------------------------------------------
# 结构性 / 决策性指纹键
# ---------------------------------------------------------------------------
_STRUCTURAL_KEYS = (
"batch_size",
"min_class_per_batch",
"epochs",
"diag_size",
"val_size",
"batch_correct_ratio",
)
_DECISION_KEYS = (
"edit_budget_start",
"edit_budget_end",
"early_stop_patience",
"use_slow_momentum",
"skill_update_mode",
"appendix_consolidate_threshold",
"momentum_samples",
"gate_e_confirm",
"gate_e_provisional",
"gate_w_net_min",
"gate_delta_min",
"gate_lambda_dir",
"gate_e_rollback",
"gate_block",
"gate_n_max",
"gate_p_low",
"gate_p_high",
"gate_probe_quota",
"gate_gamma_decay",
"gate_cooldown_steps",
"gate_guard_err",
)
# ---------------------------------------------------------------------------
# 序列化 / 反序列化
# ---------------------------------------------------------------------------
def serialize_state(state: Any) -> dict[str, Any]:
"""把 _TrainState 的可持久化字段转为纯 JSON dict。
参数:
state: _TrainState 实例(duck-typed,仅需含可持久化字段)。
返回:
纯 JSON 可序列化的 dict,不含 gate_pools / baseline_cache /
best_* / global_step。
关键实现细节:
- changed_task_types_this_epoch 是 setJSON 无 set,故 sorted 成有序列表。
- dataclass 均经 asdict 递归转 dict(含 SystemCasePack 嵌套 CaseSample、
Probation 嵌套 RejectedEdit)。
"""
return {
"correctness": state.correctness,
"eval_prev_acc": state.eval_prev_acc,
"eval_prev_run_id": state.eval_prev_run_id,
"baseline_skills_version": state.baseline_skills_version,
"baseline_prompts_version": state.baseline_prompts_version,
"steps_since_best_improved": state.steps_since_best_improved,
"epoch_start_skills": state.epoch_start_skills,
"changed_task_types_this_epoch": sorted(state.changed_task_types_this_epoch),
"rejected_buffer": {k: [asdict(x) for x in v] for k, v in state.rejected_buffer.items()},
"system_packs": [asdict(x) for x in state.system_packs],
"tool_packs": [asdict(x) for x in state.tool_packs],
"probations": {t: asdict(p) for t, p in state.probations.items()},
"gate_cooldown": state.gate_cooldown,
"gate_epoch_observed": state.gate_epoch_observed,
}
def _restore_system_pack(d: dict[str, Any]) -> SystemCasePack:
"""还原 SystemCasePack,含嵌套 CaseSample 列表。
参数:
d: asdict(SystemCasePack) 产出的 dict。
返回:
复活的 SystemCasePackfailure_cases / success_cases 重建为 CaseSample 实例。
"""
return SystemCasePack(
stats=d["stats"],
failure_cases=[CaseSample(**c) for c in d["failure_cases"]],
success_cases=[CaseSample(**c) for c in d["success_cases"]],
)
def deserialize_state_fields(d: dict[str, Any]) -> dict[str, Any]:
"""把序列化 dict 还原为可填入 _TrainState 的字段字典(dataclass 复活)。
参数:
d: serialize_state 产出并经 JSON 往返的 dict。
返回:
字段名 -> 值的 dict,可直接铺到 _TrainState;其中各 dataclass 已复活、
changed_task_types_this_epoch 还原为 set。
关键实现细节:
- RejectedEdit / ToolCasePack 字段均为标量/dict/list[dict]Cls(**d) 直接构造。
- SystemCasePack 含嵌套 CaseSample,交由 _restore_system_pack 重建。
- Probation 含嵌套 RejectedEdit 列表(pending_edits),先重建内层再构造外层。
- 直接取 d[...] 不用 .get 兜底:serialize 后的 checkpoint 必带全部键,
缺键即 checkpoint 损坏,应硬失败(P5 不掩盖)。
"""
return {
"correctness": d["correctness"],
"eval_prev_acc": d["eval_prev_acc"],
"eval_prev_run_id": d["eval_prev_run_id"],
"baseline_skills_version": d["baseline_skills_version"],
"baseline_prompts_version": d["baseline_prompts_version"],
"steps_since_best_improved": d["steps_since_best_improved"],
"epoch_start_skills": d["epoch_start_skills"],
"changed_task_types_this_epoch": set(d["changed_task_types_this_epoch"]),
"rejected_buffer": {
k: [RejectedEdit(**x) for x in v] for k, v in d["rejected_buffer"].items()
},
"system_packs": [_restore_system_pack(x) for x in d["system_packs"]],
"tool_packs": [ToolCasePack(**x) for x in d["tool_packs"]],
"probations": {
t: Probation(
**{
**d_p,
"pending_edits": [RejectedEdit(**x) for x in d_p["pending_edits"]],
}
)
for t, d_p in d["probations"].items()
},
"gate_cooldown": d["gate_cooldown"],
"gate_epoch_observed": d["gate_epoch_observed"],
}
# ---------------------------------------------------------------------------
# 配置指纹
# ---------------------------------------------------------------------------
def compute_fingerprint(config: Any) -> dict[str, Any]:
"""采集影响训练轨迹的配置项(结构性 + 决策性)。
参数:
config: 训练配置对象(duck-typed,需含 _STRUCTURAL_KEYS + _DECISION_KEYS 属性)。
返回:
指纹 dict,键为配置项名,值为对应配置值。
"""
return {k: getattr(config, k) for k in _STRUCTURAL_KEYS + _DECISION_KEYS}
def check_fingerprint(saved: dict[str, Any], config: Any) -> tuple[list[str], list[str]]:
"""比对保存的指纹与当前配置。返回 (结构性不一致项, 决策性不一致项)。
参数:
saved: checkpoint 中保存的 config_fingerprint。
config: 当前训练配置对象。
返回:
(structural, decision) 两个不一致项名列表。
关键实现细节:
结构性不一致(batch_size/min_class_per_batch/epochs/diag_size/val_size/
batch_correct_ratio)→ 调用方应拒绝 resume;决策性不一致 → 仅告警放行。
"""
cur = compute_fingerprint(config)
structural = [k for k in _STRUCTURAL_KEYS if saved.get(k) != cur[k]]
decision = [k for k in _DECISION_KEYS if saved.get(k) != cur[k]]
return structural, decision
# ---------------------------------------------------------------------------
# 读写 checkpoint
# ---------------------------------------------------------------------------
def write_checkpoint(
workspace_dir: Path,
*,
state: Any,
epoch: int,
step_completed: int,
phase: str,
global_step: int,
total_steps: int,
version_snapshot: dict[str, str],
epoch_batches: list[list[str]],
config: Any,
) -> None:
"""原子写 checkpoint.json.tmp 再 os.replace)。
参数:
workspace_dir: workspace 目录,checkpoint.json 写入其下。
state: _TrainState 实例,交由 serialize_state 序列化。
epoch: 当前 epoch 序号。
step_completed: 本 epoch 内已完成的 step 数。
phase: 续训阶段标识(如 "in_epoch")。
global_step: 全局 step 序号。
total_steps: 全局总 step 数。
version_snapshot: skills/prompts 版本快照。
epoch_batches: 本 epoch 的 batch 划分(question_id 列表的列表)。
config: 训练配置对象,用于计算 config_fingerprint。
关键实现细节:
先写 checkpoint.json.tmp 再 os.replace,保证 checkpoint 不被写一半的中断破坏。
"""
payload = {
"schema_version": CHECKPOINT_SCHEMA_VERSION,
"progress": {
"epoch": epoch,
"step_completed": step_completed,
"phase": phase,
"global_step": global_step,
"total_steps": total_steps,
},
"version_snapshot": version_snapshot,
"epoch_batches": epoch_batches,
"config_fingerprint": compute_fingerprint(config),
"state": serialize_state(state),
}
path = workspace_dir / "checkpoint.json"
tmp = path.with_name("checkpoint.json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
os.replace(tmp, path)
def load_checkpoint(workspace_dir: Path) -> dict[str, Any] | None:
"""读 checkpoint.json,不存在返回 None。
参数:
workspace_dir: workspace 目录。
返回:
checkpoint payload dictcheckpoint.json 不存在时返回 None。
"""
path = workspace_dir / "checkpoint.json"
if not path.exists():
return None
return json.loads(path.read_text())
+427
View File
@@ -0,0 +1,427 @@
"""运行配置:RunConfig frozen dataclass 与 YAML + CLI + .env 三层加载。
三层合并优先级:CLI > .env > YAML(遵循 CLAUDE.md §4.5 配置管理规范)。
- YAML:科研实验配置(会在实验中反复扫动的参数),存放于 config/ 下。
- .env:工程配置(少变路径如 workspace_dir、store_dir),通过环境变量注入。
- CLI:单次临时覆盖。
"""
from __future__ import annotations
import dataclasses
import os
from dataclasses import dataclass
from pathlib import Path
import yaml
_VALID_MODES = {"infer", "train", "diagnose", "evolve", "eval", "promote"}
_VALID_SKILL_MODES = {"auto", "manual", "none"}
_VALID_SKILL_UPDATE_MODES = {"patch", "rewrite"}
_PATH_FIELDS = {"workspace_dir", "store_dir"}
# Video-MME 的任务类型数量:验证池每类至少保底 eval_min_per_class 题,共 11 类。
_VIDEO_MME_TASK_TYPE_COUNT = 11
# .env 工程配置字段映射(环境变量名 → RunConfig 字段名)。
# 仅路径类工程配置走 .env,科研实验参数走 YAML。
_ENV_FIELD_MAP: dict[str, str] = {
"HARNESS_WORKSPACE_DIR": "workspace_dir",
"HARNESS_STORE_DIR": "store_dir",
}
@dataclass(frozen=True)
class RunConfig:
"""实验运行配置,所有参数的唯一归口。
frozen=True 确保配置在创建后不可变,防止运行中被意外修改。
三层合并优先级:CLI > .env > YAML。
字段:
workspace_dir: Workspace 根目录。
store_dir: Store 根目录。
mode: 运行模式,"infer" / "train" / "diagnose" / "evolve" / "eval" / "promote"
concurrency: 并行 worker 数。
max_steps: AgentLoop 单题最大步数。
skill_mode: Skill 加载模式,"auto" / "manual" / "none"
n_samples: 题目截取数,0 表示全量。
questions: 题目在 questions/ 下的相对路径。
skills_version: Skills 版本号。
prompts_version: Prompts 版本号。
epochs: 训练轮数。
diag_size: 诊断池题目数。
diag_correct_ratio: 诊断池中正确题目占比。
val_size: 验证池题目数。
val_correct_ratio: 验证池中正确题目占比。
edit_budget_start: 编辑预算前期上限。
edit_budget_end: 编辑预算后期下限。
batch_size: mini-batch 单批题目数。
min_class_per_batch: 单批中每个任务类型至少保留的题目数(< batch_size)。
eval_min_per_class: 验证池中每个任务类型至少保底的题目数。
early_stop_patience: 全局 best 连续未提升的容忍轮数,达到即早停。
test_size: held-out 测试池题目数。
use_slow_momentum: 是否启用快慢双速进化中的慢速 momentum 更新。
gate_e_confirm: CE-Gate CONFIRMED 接受的 e 值门槛(1/alphaVille 界假阳率 alpha)。
gate_e_provisional: 题尽暂定接受门 + futility 提前止损的代数界。
gate_w_net_min: 题尽暂定接受要求的最小净胜数(win - loss)。
gate_delta_min: 最小点估计效应量下限(承接旧 margin 语义)。
gate_lambda_dir: Wald 方向拒绝的对数似然比阈值(必须为负)。
gate_e_rollback: 试用期对称回滚门(回滚 e 值门槛)。
gate_block: 块序贯验证的块大小(=推理并发度,块内跑满)。
gate_n_max: 单次 gate 消耗的题数上限。
gate_p_low: 信息量阶梯 p-hat 保留区间下界(剔除必错零信息题)。
gate_p_high: 信息量阶梯 p-hat 保留区间上界(剔除必对零信息题)。
gate_probe_quota: 冷启动探针集比例(全错题中插尾的比例)。
gate_gamma_decay: 逐题正确率估计 p-hat 的 EMA 衰减系数。
gate_cooldown_steps: 回滚后该题型跳过进化的冷却 step 数。
gate_guard_err: gate 内跨块累计 INFRA 错误率护栏。
skill_update_mode: skill 进化模式,"patch"(局部 edit/ "rewrite"(整篇重写)。
appendix_consolidate_threshold: appendix note 条数达此值触发 LLM consolidation。
run_id: diagnose/evolve 模式要分析的运行 ID,默认空字符串。
batch_correct_ratio: 单批中正确题目占比,范围 [0, 1)。
momentum_samples: 慢速 momentum 更新时从诊断池采样的题目数,必须 >= 1。
seed: fresh 训练的种子名(对应 seed.json),默认 "initial"
version: eval/promote 模式指定的 store 版本号(如 "v3")。
resume: train 模式是否从已有 checkpoint 续训。
fresh: train 模式是否从种子全新开始。
"""
# ── 必填字段(无默认值,来自 YAML 或 CLI) ──
workspace_dir: Path
store_dir: Path
mode: str
concurrency: int
max_steps: int
skill_mode: str
n_samples: int
questions: str
skills_version: str
prompts_version: str
epochs: int
diag_size: int
diag_correct_ratio: float
val_size: int
val_correct_ratio: float
edit_budget_start: int
edit_budget_end: int
batch_size: int
min_class_per_batch: int
eval_min_per_class: int
early_stop_patience: int
test_size: int
use_slow_momentum: bool
gate_e_confirm: float
gate_e_provisional: float
gate_w_net_min: int
gate_delta_min: float
gate_lambda_dir: float
gate_e_rollback: float
gate_block: int
gate_n_max: int
gate_p_low: float
gate_p_high: float
gate_probe_quota: float
gate_gamma_decay: float
gate_cooldown_steps: int
gate_guard_err: float
skill_update_mode: str
appendix_consolidate_threshold: int
# ── 有默认值的字段(通常由 CLI 传入或可选) ──
run_id: str = ""
batch_correct_ratio: float = 0.5
momentum_samples: int = 20
seed: str = "initial"
version: str = ""
resume: bool = False
fresh: bool = False
def _validate(config: RunConfig) -> None:
"""校验 RunConfig 全部字段约束。
六层校验链:mode → 基础标量 → 编辑预算 → mini-batch → gate 阈值 → gate 阶梯。
参数:
config: 待校验的配置实例。
异常:
ValueError: 任一字段值不合法。
"""
_validate_mode(config)
_validate_mode_deps(config)
_validate_basic(config)
_validate_edit_budget(config)
_validate_minibatch(config)
_validate_gate(config)
def _validate_mode(config: RunConfig) -> None:
"""校验运行模式枚举合法性。
参数:
config: 待校验的配置实例。
异常:
ValueError: mode 值不在合法集合中。
"""
if config.mode not in _VALID_MODES:
raise ValueError(f"mode 必须为 {_VALID_MODES} 之一,实际: {config.mode!r}")
def _validate_mode_deps(config: RunConfig) -> None:
"""校验各运行模式的依赖字段(run_id、version)。
参数:
config: 待校验的配置实例。
异常:
ValueError: 模式依赖字段缺失。
"""
if config.mode in ("diagnose", "evolve") and not config.run_id:
raise ValueError(f"mode 为 {config.mode!r} 时必须提供 run_id。")
if config.mode in ("eval", "promote") and not config.version:
raise ValueError(f"mode 为 {config.mode!r} 时必须提供 --version。")
if config.mode == "promote" and not config.run_id:
raise ValueError("promote 必须提供 --run-id(指定 canonical eval run)。")
_validate_train_run_id(config)
def _validate_train_run_id(config: RunConfig) -> None:
"""校验 train 模式非 resume/fresh 时必须提供 run_id。
参数:
config: 待校验的配置实例。
异常:
ValueError: train 模式既非 resume 也非 fresh 且缺少 run_id。
"""
if config.mode != "train":
return
if config.resume or config.fresh:
return
if not config.run_id:
raise ValueError("train 非 resume/fresh 时必须提供 run_id(旧式基线 run)。")
def _validate_basic(config: RunConfig) -> None:
"""校验基础标量字段:枚举合法性与正整数约束。
参数:
config: 待校验的配置实例。
异常:
ValueError: 任一基础字段值不合法。
"""
if config.skill_mode not in _VALID_SKILL_MODES:
raise ValueError(
f"skill_mode 必须为 {_VALID_SKILL_MODES} 之一,实际: {config.skill_mode!r}"
)
if config.concurrency <= 0:
raise ValueError(f"concurrency 必须 > 0,实际: {config.concurrency}")
if config.max_steps <= 0:
raise ValueError(f"max_steps 必须 > 0,实际: {config.max_steps}")
if config.n_samples < 0:
raise ValueError(f"n_samples 必须 >= 0,实际: {config.n_samples}")
if config.epochs <= 0:
raise ValueError(f"epochs 必须 > 0,实际: {config.epochs}")
if config.skill_update_mode not in _VALID_SKILL_UPDATE_MODES:
raise ValueError(
f"skill_update_mode 必须为 {_VALID_SKILL_UPDATE_MODES} 之一,"
f"实际: {config.skill_update_mode!r}"
)
if config.appendix_consolidate_threshold < 1:
raise ValueError(
f"appendix_consolidate_threshold 必须 >= 1"
f"实际: {config.appendix_consolidate_threshold}"
)
def _validate_edit_budget(config: RunConfig) -> None:
"""校验编辑预算退火的前期/后期上限约束。
参数:
config: 待校验的配置实例。
异常:
ValueError: edit_budget_start < edit_budget_end,或 end <= 0。
"""
if config.edit_budget_start < config.edit_budget_end:
raise ValueError(
f"edit_budget_start({config.edit_budget_start}) 必须 >= "
f"edit_budget_end({config.edit_budget_end})"
)
if config.edit_budget_end <= 0:
raise ValueError(f"edit_budget_end 必须 > 0,实际: {config.edit_budget_end}")
def _validate_minibatch(config: RunConfig) -> None:
"""校验 mini-batch 自进化闭环参数约束。
参数:
config: 待校验的 RunConfig 配置对象。
异常:
ValueError: 任一约束被违反。
关键实现细节:
val_size 必须 >= eval_min_per_class * _VIDEO_MME_TASK_TYPE_COUNT,保证验证池
能为 Video-MME 的全部 11 个任务类型各保底 eval_min_per_class 题。
"""
if config.batch_size <= 0:
raise ValueError(f"batch_size 必须 > 0,实际: {config.batch_size}")
if not (1 <= config.min_class_per_batch < config.batch_size):
raise ValueError(
f"min_class_per_batch 必须满足 1 <= 值 < batch_size"
f"({config.batch_size}),实际: {config.min_class_per_batch}"
)
if config.eval_min_per_class < 1:
raise ValueError(f"eval_min_per_class 必须 >= 1,实际: {config.eval_min_per_class}")
floor = config.eval_min_per_class * _VIDEO_MME_TASK_TYPE_COUNT
if config.val_size < floor:
raise ValueError(
f"val_size 必须 >= eval_min_per_class * {_VIDEO_MME_TASK_TYPE_COUNT}"
f"(={floor})Video-MME 共 {_VIDEO_MME_TASK_TYPE_COUNT} 个任务类型,"
f"每类需 eval_min_per_class 题保底,故验证池下限为 {floor}"
f"实际: {config.val_size}"
)
if config.early_stop_patience <= 0:
raise ValueError(f"early_stop_patience 必须 > 0,实际: {config.early_stop_patience}")
if config.test_size <= 0:
raise ValueError(f"test_size 必须 > 0,实际: {config.test_size}")
if not (0 <= config.batch_correct_ratio < 1):
raise ValueError(
f"batch_correct_ratio 必须满足 0 <= 值 < 1,实际: {config.batch_correct_ratio}"
)
if config.momentum_samples < 1:
raise ValueError(f"momentum_samples 必须 >= 1,实际: {config.momentum_samples}")
def _validate_gate(config: RunConfig) -> None:
"""校验 CE-Gate 全部参数:判据阈值 + 信息量阶梯。
参数:
config: 待校验的配置实例。
异常:
ValueError: 任一 gate 参数不合法。
"""
_validate_gate_thresholds(config)
_validate_gate_ladder(config)
def _validate_gate_thresholds(config: RunConfig) -> None:
"""校验 CE-Gate 判据阈值参数(e 值、净胜数、效应量、方向拒绝)。
参数:
config: 待校验的配置实例。
异常:
ValueError: 任一阈值参数不合法。
"""
if config.gate_e_confirm <= 1:
raise ValueError(f"gate_e_confirm 必须 > 1,实际: {config.gate_e_confirm}")
if not (1 < config.gate_e_provisional <= config.gate_e_confirm):
raise ValueError(
f"gate_e_provisional 必须在 (1, gate_e_confirm] 内,实际: {config.gate_e_provisional}"
)
if config.gate_e_rollback <= 1:
raise ValueError(f"gate_e_rollback 必须 > 1,实际: {config.gate_e_rollback}")
if config.gate_w_net_min < 1:
raise ValueError(f"gate_w_net_min 必须 >= 1,实际: {config.gate_w_net_min}")
if config.gate_delta_min < 0:
raise ValueError(f"gate_delta_min 必须 >= 0,实际: {config.gate_delta_min}")
if config.gate_lambda_dir >= 0:
raise ValueError(f"gate_lambda_dir 必须 < 0,实际: {config.gate_lambda_dir}")
def _validate_gate_ladder(config: RunConfig) -> None:
"""校验 CE-Gate 信息量阶梯与块序贯参数。
参数:
config: 待校验的配置实例。
异常:
ValueError: 任一阶梯参数不合法。
"""
if config.gate_block <= 0 or config.gate_n_max < config.gate_block:
raise ValueError(
f"需 0 < gate_block <= gate_n_max"
f"实际: block={config.gate_block}, n_max={config.gate_n_max}"
)
if not (0 <= config.gate_p_low < config.gate_p_high <= 1):
raise ValueError(
f"需 0 <= gate_p_low < gate_p_high <= 1"
f"实际: [{config.gate_p_low}, {config.gate_p_high}]"
)
if not (0 <= config.gate_probe_quota <= 1):
raise ValueError(f"gate_probe_quota 须在 [0,1],实际: {config.gate_probe_quota}")
if not (0 < config.gate_gamma_decay < 1):
raise ValueError(f"gate_gamma_decay 须在 (0,1),实际: {config.gate_gamma_decay}")
if config.gate_cooldown_steps < 1:
raise ValueError(f"gate_cooldown_steps 必须 >= 1,实际: {config.gate_cooldown_steps}")
if not (0 < config.gate_guard_err < 1):
raise ValueError(f"gate_guard_err 须在 (0,1),实际: {config.gate_guard_err}")
def _apply_env_var_overrides(data: dict) -> None:
"""从环境变量覆盖路径字段(原地修改)。
.env 文件由入口脚本 load_dotenv 加载到环境变量,本函数仅从 os.environ 读取。
仅覆盖 _ENV_FIELD_MAP 中声明的工程配置字段(workspace_dir、store_dir)。
参数:
data: 待覆盖的配置字典。
"""
for env_key, field_name in _ENV_FIELD_MAP.items():
env_val = os.environ.get(env_key)
if env_val is not None:
data[field_name] = env_val
def load_config(
yaml_path: Path,
cli_overrides: dict[str, object] | None = None,
) -> RunConfig:
"""从 YAML 加载配置,叠加 .env 和 CLI 覆盖层后构造 RunConfig。
三层合并优先级:CLI > .env > YAML。
参数:
yaml_path: YAML 配置文件路径,需包含 ``harness`` 段。
cli_overrides: CLI 参数字典,值为 None 表示未传入(不覆盖)。
返回:
构造并校验后的 RunConfig 实例。
异常:
FileNotFoundError: YAML 文件不存在。
ValueError: 校验失败。
"""
# Phase 1: 加载 YAML 基础层
with open(yaml_path, encoding="utf-8") as f:
raw: dict = yaml.safe_load(f)
# 支持嵌套 harness 段和扁平 YAML 两种格式
yaml_data: dict = raw.get("harness", raw)
# Phase 2: .env 覆盖层(仅工程配置字段)
_apply_env_var_overrides(yaml_data)
# Phase 3: CLI 覆盖层(最高优先级)
valid_fields = {f.name for f in dataclasses.fields(RunConfig)}
if cli_overrides:
for key, value in cli_overrides.items():
if value is not None and key in valid_fields:
yaml_data[key] = value
# Phase 4: 类型转换 — 路径字段转 Path
for field_name in _PATH_FIELDS:
if field_name in yaml_data:
yaml_data[field_name] = Path(yaml_data[field_name])
# Phase 5: 构造并校验
config = RunConfig(**{k: v for k, v in yaml_data.items() if k in valid_fields})
_validate(config)
return config
+335
View File
@@ -0,0 +1,335 @@
"""CE-Gate 信息量阶梯与基线缓存。
阶梯(每题型一条):gate 的出题顺序表。冷启动(FRESH)用种子基线对错
两档粗排(错题高优先 2:1 交错 + 全错题 probe_quota 探针插尾);
epoch >=1 用非 gate run 观测做 gamma-EMA 更新 p_hat,按信息量 p_hat(1-p_hat) 降序、
剔 p_hat 不在 [p_low, p_high]。防泄露铁律:gate 内 rollout 永不回流 p_hat
(调用方以 run_id 含 "_gate_" 过滤观测源)。
BaselineCache:基线侧逐题对错缓存,键 = (task_type, skill_hash,
prompts_version, qid) 内容寻址、无显式失效。JSON 持久化到 workspace
供 resume 后合法复用已冻结阶梯上的新鲜 draw。
"""
from __future__ import annotations
import hashlib
import json
import os
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
from pathlib import Path
from core.types import GeneratedQuestion
def skill_hash(content: str) -> str:
"""对 skill 正文取 sha1 摘要,作缓存键的内容维度。
参数:
content: skill 文件全文(基线侧为解析后生效文件的正文)。
返回:
sha1 十六进制摘要。
"""
return hashlib.sha1(content.encode("utf-8")).hexdigest()
@dataclass
class LadderEntry:
"""阶梯单元:题目与其估计答对率。
字段:
question_id: 题目唯一标识。
p_hat: 估计答对率。冷启动为 Beta(1,1) 平滑的单次观测后验均值
(错=1/3、对=2/3),此后经 gamma-EMA 更新。
"""
question_id: str
p_hat: float
def build_cold_entries(
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
probe_quota: float,
seed: int,
) -> list[LadderEntry]:
"""冷启动排序:错题高优先 2:1 交错 + 全错题 probe_quota 探针插尾。
参数:
questions: 该题型的全部候选题(已排除 test 池)。
correctness: question_id -> 种子基线是否答对(900 题全量对错)。
probe_quota: 从错题中随机抽出插到梯尾的探针比例(防"解锁新能力"盲区)。
seed: 洗牌种子,保证确定性重建。
返回:
排序后的 LadderEntry 列表(p_hat 用 Beta(1,1) 平滑:错=1/3、对=2/3
与 warm 阶段 gamma-EMA / 信息量排序自然衔接)。
关键实现细节:
错题、对题各自固定种子洗牌 -> 抽探针 -> 剩余按 错错对 2:1 交错
(一方耗尽后顺排另一方)-> 探针追加尾部。
"""
rng = random.Random(seed)
wrong = [q for q in questions if not correctness.get(q.question_id, False)]
right = [q for q in questions if correctness.get(q.question_id, False)]
rng.shuffle(wrong)
rng.shuffle(right)
n_probe = int(len(wrong) * probe_quota)
probes, wrong_main = wrong[:n_probe], wrong[n_probe:]
interleaved: list[GeneratedQuestion] = []
wi, ri = 0, 0
while wi < len(wrong_main) or ri < len(right):
for _ in range(2):
if wi < len(wrong_main):
interleaved.append(wrong_main[wi])
wi += 1
if ri < len(right):
interleaved.append(right[ri])
ri += 1
interleaved.extend(probes)
def _p0(q: GeneratedQuestion) -> float:
return 2 / 3 if correctness.get(q.question_id, False) else 1 / 3
return [LadderEntry(q.question_id, _p0(q)) for q in interleaved]
def order_ladder(entries: list[LadderEntry], p_low: float, p_high: float) -> list[LadderEntry]:
"""warm 排序:剔 p_hat 不在 [p_low, p_high] 的零信息题,按信息量 p_hat(1-p_hat) 降序。
参数:
entries: 待排序的阶梯单元。
p_low / p_high: p_hat 保留区间。
返回:
过滤并排序后的新列表(稳定排序,同信息量保持原相对序)。
"""
kept = [e for e in entries if p_low <= e.p_hat <= p_high]
return sorted(kept, key=lambda e: e.p_hat * (1 - e.p_hat), reverse=True)
@dataclass
class GatePools:
"""全部题型的阶梯容器,含构建种子与数据指纹(确定性重建凭据)。
字段:
entries: task_type -> 冷启动序 LadderEntry 列表(warm 排序在取用时做,
保持存储序稳定、避免每次更新重写全表顺序)。
seed: 冷启动洗牌种子。
fingerprint: 构建输入指纹(基线 run_id + 题集 hash 等),resume 校验用。
"""
entries: dict[str, list[LadderEntry]]
seed: int
fingerprint: str
def ladder_for(
self,
task_type: str,
exclude_qids: set[str],
p_low: float,
p_high: float,
cold: bool,
) -> list[str]:
"""取该题型的 gate 出题序(qid 列表),排除本 step 进化案例包题。
参数:
task_type: 目标题型。
exclude_qids: 本 step 案例包(failure/success cases)的题目 id
防止在"刚学的那道题"上自测。
p_low / p_high: warm 阶段的 p_hat 保留区间。
cold: True 表示尚无 epoch 级观测(epoch 1),用冷启动存储序;
False 走 order_ladder 信息量排序。
返回:
排除后的有序 question_id 列表。
异常:
ValueError: 该题型无阶梯(冷启动构建缺失),或该题型阶梯为空。
"""
if task_type not in self.entries:
raise ValueError(f"task_type={task_type} 无阶梯,冷启动构建缺失该题型")
pool = self.entries[task_type]
if not pool:
raise ValueError(f"task_type={task_type} 阶梯为空,无可出题目")
ordered = pool if cold else order_ladder(pool, p_low, p_high)
return [e.question_id for e in ordered if e.question_id not in exclude_qids]
def update_probs(self, observations: dict[str, bool], gamma: float) -> None:
"""gamma-EMA 更新 p_hatp_hat <- gamma * p_hat + (1-gamma) * obs。只更新有新观测的题。
参数:
observations: question_id -> 本 epoch 非 gate run 的最新对错。
调用方必须已按 run_id 过滤掉 gate 内 rollout(防泄露铁律)。
gamma: EMA 衰减系数。
"""
for entries in self.entries.values():
for e in entries:
if e.question_id in observations:
obs = 1.0 if observations[e.question_id] else 0.0
e.p_hat = gamma * e.p_hat + (1 - gamma) * obs
def save(self, path: Path) -> None:
"""原子写 gate_pools.json.tmp 再 replace)。
参数:
path: 目标 JSON 路径。
"""
payload = {
"seed": self.seed,
"fingerprint": self.fingerprint,
"entries": {
t: [{"question_id": e.question_id, "p_hat": e.p_hat} for e in es]
for t, es in self.entries.items()
},
}
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
@classmethod
def load(cls, path: Path) -> GatePools:
"""从 gate_pools.json 恢复。
参数:
path: gate_pools.json 路径。
返回:
复活的 GatePools。
"""
d = json.loads(path.read_text(encoding="utf-8"))
return cls(
entries={
t: [LadderEntry(x["question_id"], x["p_hat"]) for x in es]
for t, es in d["entries"].items()
},
seed=d["seed"],
fingerprint=d["fingerprint"],
)
def build_or_load_gate_pools(
workspace_dir: Path,
questions: list[GeneratedQuestion],
test_qids: set[str],
baseline_correctness: dict[str, bool],
task_types: list[str],
probe_quota: float,
seed: int,
baseline_run_id: str,
) -> GatePools:
"""gate 阶梯获取入口:gate_pools.json 存在且指纹一致则加载,否则冷启动构建。
参数:
workspace_dir: workspace 根目录(gate_pools.json 落其下)。
questions: benchmark 全量题(900 题)。
test_qids: held-out test 池题目 id(阶梯题源必须排除)。
baseline_correctness: 种子基线 900 题全量对错(从基线 run 的 db 读)。
task_types: 参与进化的题型列表。
probe_quota: 冷启动探针比例。
seed: 冷启动洗牌种子。
baseline_run_id: 指纹成分。
返回:
GatePools。
关键实现细节:
指纹 = sha1(baseline_run_id|全 qid|seed|probe_quota|task_types|test_qids)。
指纹不一致(题集/基线/参数变了)直接报错——FRESH 语义下不该发生,
防御性拒绝而非静默重建。
"""
joined = ",".join(sorted(q.question_id for q in questions))
fp_src = (
f"{baseline_run_id}|{joined}|{seed}|{probe_quota}"
f"|{','.join(sorted(task_types))}|{','.join(sorted(test_qids))}"
)
fingerprint = hashlib.sha1(fp_src.encode()).hexdigest()
path = workspace_dir / "gate_pools.json"
if path.exists():
pools = GatePools.load(path)
if pools.fingerprint != fingerprint:
raise RuntimeError(
f"gate_pools.json 指纹不一致(题集或基线变更),拒绝静默重建: {path}"
)
return pools
entries: dict[str, list[LadderEntry]] = {}
for t in task_types:
pool = [q for q in questions if q.task_type == t and q.question_id not in test_qids]
if not pool:
raise ValueError(f"task_type={t} 无非 test 题,无法建阶梯")
entries[t] = build_cold_entries(pool, baseline_correctness, probe_quota, seed)
logger.info("gate 阶梯[{}]: {} 题(冷启动)", t, len(entries[t]))
pools = GatePools(entries=entries, seed=seed, fingerprint=fingerprint)
pools.save(path)
return pools
class BaselineCache:
"""基线侧逐题对错缓存(内容寻址,JSON 持久化)。
键 = (task_type, skill_hash, prompts_version, qid):任何影响该题型
有效 skill 的变化(含共享 default-strategy.md 被他类 accept 改写)
都使 skill_hash 变化、缓存自然 missprompts 版本变化同理。
"""
def __init__(self, path: Path) -> None:
"""加载或初始化缓存文件。
参数:
path: 缓存 JSON 路径(workspace/baseline_cache.json)。
"""
self._path = path
self._store: dict[str, bool] = {}
if path.exists():
self._store = json.loads(path.read_text(encoding="utf-8"))
@staticmethod
def _key(task_type: str, s_hash: str, prompts_version: str, qid: str) -> str:
"""拼缓存键(四维内容寻址)。"""
return f"{task_type}|{s_hash}|{prompts_version}|{qid}"
def get(self, task_type: str, s_hash: str, prompts_version: str, qid: str) -> bool | None:
"""读缓存;未命中返回 None。
参数:
task_type: 题型。
s_hash: 基线侧生效 skill 文件的内容哈希。
prompts_version: 当前 prompts 版本。
qid: 题目 id。
返回:
缓存的对错;未命中 None。
"""
return self._store.get(self._key(task_type, s_hash, prompts_version, qid))
def put(
self, task_type: str, s_hash: str, prompts_version: str, qid: str, correct: bool
) -> None:
"""写缓存并落盘(原子写,gate 频度低、全量重写成本可忽略)。
参数:
task_type / s_hash / prompts_version / qid: 缓存键四维。
correct: 基线侧该题对错。
关键实现细节:
先盘后存:新条目先原子落盘(tmp 写 + os.replace)成功后才更新
内存,磁盘写失败时内存与磁盘一致(均无新条目),无分裂窗口。
"""
updated = {
**self._store,
self._key(task_type, s_hash, prompts_version, qid): correct,
}
tmp = self._path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(updated, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, self._path)
self._store = updated
+441
View File
@@ -0,0 +1,441 @@
"""async 推理编排 — 训练循环的 forward()。
从 TRM4 core/harness/inference.py (~560 行) 迁移,重大重构:
- 同步 ThreadPoolExecutor → asyncio.Semaphore + asyncio.gather
- LLMClient.from_env() 每题构造 → llm: LLMProvider 注入共享
- SentenceTransformer/OCR 内部构造 → 调用方通过 tool_dispatch_fn 注入
- run_id 必传,空串 → ValueError
- _aggregate_results 从内存 results 聚合(非 DB 回读)
- record_run 由调用方(Runner)负责
- prompt 构建由调用方注入 prompt_builder
"""
from __future__ import annotations
import asyncio
import json
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from loguru import logger
from core.agent.loop import AgentLoop
if TYPE_CHECKING:
from collections.abc import Callable
from app.harness.log import HarnessLog
from core.agent.types import LoopResult
from core.protocols import LLMProvider
from core.types import GeneratedQuestion
@dataclass(frozen=True)
class InferenceResult:
"""推理聚合结果。
属性:
run_id: 运行标识。
accuracy: 总正确率。
total: 总题数。
correct: 正确题数。
per_task_type: 按题型分组的指标 {task_type: {accuracy, total, correct}}。
steps_mean: 平均步数。
token_usage: token 总用量 {prompt_tokens, completion_tokens}。
stop_reason_counts: 终止原因计数 {reason: count}。
"""
run_id: str
accuracy: float
total: int
correct: int
per_task_type: dict[str, dict]
steps_mean: float
token_usage: dict[str, int]
stop_reason_counts: dict[str, int]
# ---------------------------------------------------------------------------
# 表 Schema 定义(5 张表,保留 TRM4 全部 schema
# ---------------------------------------------------------------------------
PREDICTIONS_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"task_type": "TEXT",
"prediction": "TEXT",
"answer": "TEXT",
"evidence": "TEXT",
"reasoning": "TEXT",
"steps_used": "INTEGER",
"prompt_tokens": "INTEGER",
"completion_tokens": "INTEGER",
"stop_reason": "TEXT",
"steps_json": "JSON",
}
TRACES_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"step": "INTEGER",
"tool_name": "TEXT",
"tool_args": "JSON",
"tool_output": "TEXT",
"thought": "TEXT",
}
VALIDATION_FLAGS_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"has_l3_visit": "INTEGER",
"l1_count": "INTEGER",
"l2_count": "INTEGER",
"l3_count": "INTEGER",
}
ANCHOR_CHECK_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"step": "INTEGER",
"n_assertions": "INTEGER",
"n_anchored": "INTEGER",
"n_illegal": "INTEGER",
"n_expanded": "INTEGER",
"n_trunc": "INTEGER",
"output_chars": "INTEGER",
}
OF_HEALTH_SCHEMA: dict[str, str] = {
"video_id": "TEXT",
"question_id": "TEXT",
"step": "INTEGER",
"ocr_injected": "INTEGER",
"ocr_chars": "INTEGER",
"ocr_failed": "INTEGER",
"discrepancy": "INTEGER",
"abstain": "INTEGER",
}
# ---------------------------------------------------------------------------
# 内部工具
# ---------------------------------------------------------------------------
class _DispatcherAdapter:
"""将裸 async callable 包装为 ToolDispatcher Protocol 实例。
AgentLoop 要求 ToolDispatcher(有 dispatch 方法),而 run_inference
接收的 tool_dispatch_fn 是裸 async callable。此适配器桥接两者。
参数:
fn: async def (tool_name, args, *, context) -> str。
"""
def __init__(self, fn: Callable[..., Any]) -> None:
self._fn = fn
async def dispatch(
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str:
"""转发工具调用给被包装的 callable。"""
return await self._fn(tool_name, args, context=context)
def _to_text_field(value: Any) -> str:
"""把 prediction 的 evidence/reasoning 归一为可入库的文本。
LLM 有时把这些字段返回成 list 或 dict(而非字符串)。sqlite 无法绑定
非标量类型,直接入库会抛 ProgrammingError 致该题丢失预测行、进而触发
rollout 完整性护栏中止整轮。凡非 str 一律 JSON 序列化为文本。
参数:
value: evidence/reasoning 原始值(可能是 str/list/dict)。
返回:
可直接入库的字符串。
"""
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
def _zero_result(run_id: str) -> InferenceResult:
"""空记录时的零值 InferenceResult。
参数:
run_id: 运行标识。
返回:
全零的 InferenceResult。
"""
return InferenceResult(
run_id=run_id,
accuracy=0.0,
total=0,
correct=0,
per_task_type={},
steps_mean=0.0,
token_usage={"prompt_tokens": 0, "completion_tokens": 0},
stop_reason_counts={},
)
def _group_by_task_type(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""按 task_type 分组聚合正确率指标。
参数:
records: 预测记录列表。
返回:
{task_type: {accuracy, total, correct}} 映射。
"""
task_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for r in records:
task_groups[r["task_type"]].append(r)
per_task_type: dict[str, dict[str, Any]] = {}
for task_type, group in task_groups.items():
t_total = len(group)
t_correct = sum(1 for r in group if r["prediction"] == r["answer"])
per_task_type[task_type] = {
"accuracy": t_correct / t_total,
"total": t_total,
"correct": t_correct,
}
return per_task_type
def _aggregate_results(records: list[dict[str, Any]], run_id: str) -> InferenceResult:
"""从内存 records 聚合推理指标。
TRM4 从 DB 回读 predictions 表聚合;TRM5 改为从内存直接聚合,
避免 DB 回读的同步开销和额外依赖。
参数:
records: _run_single_question 返回的 record 列表。
run_id: 当前运行标识。
返回:
InferenceResult 冻结实例。
"""
total = len(records)
if total == 0:
return _zero_result(run_id)
correct = sum(1 for r in records if r["prediction"] == r["answer"])
stop_counts: dict[str, int] = defaultdict(int)
for r in records:
stop_counts[r["stop_reason"]] += 1
return InferenceResult(
run_id=run_id,
accuracy=correct / total,
total=total,
correct=correct,
per_task_type=_group_by_task_type(records),
steps_mean=sum(r["steps_used"] for r in records) / total,
token_usage={
"prompt_tokens": sum(r["prompt_tokens"] for r in records),
"completion_tokens": sum(r["completion_tokens"] for r in records),
},
stop_reason_counts=dict(stop_counts),
)
# ---------------------------------------------------------------------------
# 单题推理
# ---------------------------------------------------------------------------
async def _run_single_question(
qa: GeneratedQuestion,
*,
llm: LLMProvider,
tool_dispatch_fn: Callable[..., Any],
prompt_builder: Callable[[GeneratedQuestion], tuple[str, str]],
log: HarnessLog,
max_steps: int,
plugins: list[object],
) -> dict[str, Any]:
"""执行单道题目的 Agent 推理。
悲观默认值:record 初始 stop_reason="error",成功后覆盖。
prediction 必落库:log.insert 在 try/except 之后(无论成败)。
参数:
qa: 待推理的题目。
llm: LLMProvider 共享实例。
tool_dispatch_fn: async 工具调度函数 (tool_name, args, *, context) -> str。
prompt_builder: (GeneratedQuestion) -> (system_prompt, user_prompt)。
log: HarnessLog 实例(线程安全)。
max_steps: AgentLoop 最大步数。
plugins: pluggy 插件列表。
返回:
预测结果字典(含 video_id, question_id, prediction, answer 等)。
"""
record: dict[str, Any] = {
"video_id": qa.video_id,
"question_id": qa.question_id,
"task_type": qa.task_type,
"prediction": None,
"answer": qa.answer,
"evidence": "",
"reasoning": "",
"steps_used": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"stop_reason": "error", # 悲观默认
"steps_json": "[]",
}
try:
system_prompt, user_prompt = prompt_builder(qa)
dispatcher = _DispatcherAdapter(tool_dispatch_fn)
loop = AgentLoop(llm, max_steps=max_steps)
loop_result: LoopResult = await loop.run(
system_prompt,
user_prompt,
dispatcher,
plugins=plugins,
session_id=qa.question_id,
)
result_dict = loop_result.result if isinstance(loop_result.result, dict) else {}
evidence = _to_text_field(result_dict.get("evidence", ""))
reasoning = _to_text_field(result_dict.get("reasoning", ""))
record.update(
{
"prediction": result_dict.get("answer"),
"evidence": evidence,
"reasoning": reasoning,
"steps_used": loop_result.steps_used,
"prompt_tokens": loop_result.token_usage["prompt_tokens"],
"completion_tokens": loop_result.token_usage["completion_tokens"],
"stop_reason": loop_result.stop_reason,
"steps_json": json.dumps(
[
{
"thought": s.thought,
"tool_call": s.tool_call,
"tool_output": s.tool_output,
}
for s in loop_result.steps
],
ensure_ascii=False,
),
}
)
except Exception:
logger.exception("[{}] QA {} 执行异常", qa.video_id, qa.question_id)
# prediction 必落库(try 外,无论成败)
await asyncio.to_thread(log.insert, "predictions", record)
return record
# ---------------------------------------------------------------------------
# 建表
# ---------------------------------------------------------------------------
def _ensure_tables(log: HarnessLog) -> None:
"""创建推理所需的 5 张表。
参数:
log: HarnessLog 实例。
"""
log.create_table("predictions", PREDICTIONS_SCHEMA)
log.create_table("traces", TRACES_SCHEMA)
log.create_table("validation_flags", VALIDATION_FLAGS_SCHEMA)
log.create_table("anchor_check", ANCHOR_CHECK_SCHEMA)
log.create_table("observe_frame_health", OF_HEALTH_SCHEMA)
# ---------------------------------------------------------------------------
# 公共入口
# ---------------------------------------------------------------------------
async def run_inference(
questions: list[GeneratedQuestion],
*,
llm: LLMProvider,
tool_dispatch_fn: Callable[..., Any],
prompt_builder: Callable[[GeneratedQuestion], tuple[str, str]],
log: HarnessLog,
run_id: str,
concurrency: int,
max_steps: int,
skill_mode: str,
plugins_factory: Callable[[str, str], list[object]] | None = None,
) -> InferenceResult:
"""在视频树上执行 Agent 推理,对应训练循环的 forward()。
参数:
questions: 待推理的题目列表。
llm: LLMProvider 共享实例(依赖注入)。
tool_dispatch_fn: async 工具调度函数 (tool_name, args, *, context) -> str。
prompt_builder: prompt 构建函数 (GeneratedQuestion) -> (system_prompt, user_prompt)。
log: HarnessLog 实例(由调用方管理生命周期)。
run_id: 运行标识(必传,空串 → ValueError)。
concurrency: 最大并发数(asyncio.Semaphore 控制)。
max_steps: AgentLoop 单题最大步数。
skill_mode: "auto" / "manual" / "none"(传递给调用方的 prompt/plugin 构建逻辑)。
plugins_factory: 可选的插件工厂 (video_id, question_id) -> plugins 列表。
返回:
InferenceResult(含 accuracy、per_task_type 等聚合指标)。
异常:
ValueError: run_id 为空串或纯空白。
"""
if not run_id or not run_id.strip():
raise ValueError("run_id 不得为空串或纯空白")
_ensure_tables(log)
if not questions:
logger.info("题目列表为空,返回零值 InferenceResult")
return _aggregate_results([], run_id)
sem = asyncio.Semaphore(concurrency)
total_count = len(questions)
async def _bounded(index: int, qa: GeneratedQuestion) -> dict[str, Any]:
"""信号量限流的单题推理包装。"""
async with sem:
plugins = (
plugins_factory(qa.video_id, qa.question_id) if plugins_factory is not None else []
)
result = await _run_single_question(
qa,
llm=llm,
tool_dispatch_fn=tool_dispatch_fn,
prompt_builder=prompt_builder,
log=log,
max_steps=max_steps,
plugins=plugins,
)
logger.info(
"[{}/{}] {} QA {} 完成 (stop={})",
index + 1,
total_count,
qa.video_id,
qa.question_id,
result["stop_reason"],
)
return result
results = await asyncio.gather(*[_bounded(i, qa) for i, qa in enumerate(questions)])
inference_result = _aggregate_results(list(results), run_id)
logger.info(
"推理完成: accuracy={:.2%} ({}/{})",
inference_result.accuracy,
inference_result.correct,
inference_result.total,
)
return inference_result
+333
View File
@@ -0,0 +1,333 @@
"""HarnessLogSQLite 薄包装 + RunLogImpl 只读查询端口。
HarnessLog 提供统一的结构化日志接口,从 TRM4 直搬,保留全部线程安全与幂等语义。
RunLogImpl 实现 core/evolution/protocols.py::RunLog Protocol,用独立连接做只读 SELECT,
不经 HarnessLog 生命周期(不触发 _runs INSERT OR IGNORE),避免污染运行状态。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import subprocess
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
def _get_git_sha() -> str | None:
"""获取当前 git commit SHA。"""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def _now_iso() -> str:
"""返回当前 UTC 时间的 ISO 格式字符串。"""
return datetime.now(UTC).isoformat()
class HarnessLog:
"""SQLite 薄包装,为科研项目提供统一的结构化日志接口。
关键设计:
- WAL 模式 + threading.Lock 保证共享连接下并发安全。
- INSERT OR IGNORE INTO _runs 保证幂等(同 run_id 多次创建不报错)。
- query 也持锁:共享连接(check_same_thread=False)下并发 SELECT + INSERT
在同一连接上 execute 会损坏游标状态,故读也须串行化。
- context manager 语义:正常退出 completed,异常退出 failed。
参数:
db_path: SQLite 数据库文件路径。
run_id: 本次运行的唯一标识。
git_sha: 代码版本,默认自动获取。
config_snapshot: 本次运行的配置快照。
"""
def __init__(
self,
db_path: str,
run_id: str,
git_sha: str | None = None,
config_snapshot: dict[str, Any] | None = None,
) -> None:
self._run_id = run_id
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._lock = threading.Lock()
self._conn.row_factory = sqlite3.Row
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
self._conn.execute(
"INSERT OR IGNORE INTO _runs"
" (run_id, git_sha, started_at, config, status)"
" VALUES (?, ?, ?, ?, ?)",
(run_id, resolved_sha, _now_iso(), config_json, "running"),
)
self._conn.commit()
def _init_fixed_tables(self) -> None:
"""创建 _runs 和 _events 固定表。"""
self._conn.execute("""
CREATE TABLE IF NOT EXISTS _runs (
run_id TEXT PRIMARY KEY,
git_sha TEXT,
started_at TEXT,
finished_at TEXT,
config JSON,
status TEXT DEFAULT 'running',
skills_version TEXT,
prompts_version TEXT,
questions_ref TEXT
)
""")
self._conn.execute("""
CREATE TABLE IF NOT EXISTS _events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT,
timestamp TEXT,
event_type TEXT,
payload JSON
)
""")
self._conn.commit()
def create_table(
self,
name: str,
columns: dict[str, str],
primary_key: str | None = None,
) -> None:
"""创建自定义表,自动追加 run_id 和 timestamp 列。
参数:
name: 表名。
columns: 列定义,如 {"epoch": "INTEGER", "loss": "REAL"}。
primary_key: 主键列名。
"""
all_columns = {"run_id": "TEXT", "timestamp": "TEXT"}
all_columns.update(columns)
col_defs = []
for col_name, col_type in all_columns.items():
pk_suffix = " PRIMARY KEY" if col_name == primary_key else ""
col_defs.append(f"{col_name} {col_type}{pk_suffix}")
sql = f"CREATE TABLE IF NOT EXISTS {name} ({', '.join(col_defs)})"
self._conn.execute(sql)
self._conn.commit()
def insert(self, table: str, record: dict[str, Any], mode: str = "append") -> None:
"""插入一条记录,自动填充 run_id 和 timestamp。
参数:
table: 目标表名。
record: 要插入的数据。
mode: "append""upsert"
"""
enriched = {"run_id": self._run_id, "timestamp": _now_iso()}
enriched.update(record)
cols = list(enriched.keys())
placeholders = ", ".join(["?"] * len(cols))
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})"
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:
"""批量插入多条记录。
参数:
table: 目标表名。
records: 要插入的数据列表。
mode: "append""upsert"
"""
for record in records:
self.insert(table, record, mode=mode)
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> None:
"""执行原生 SQL 写操作。
参数:
sql: SQL 语句。
params: 参数元组。
"""
with self._lock:
self._conn.execute(sql, params)
self._conn.commit()
def query(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
"""执行原生 SQL 查询,返回 list[dict]。
与所有写方法同持 self._lock:共享连接(check_same_thread=False)下,
并发 SELECT 与 INSERT 在同一连接上 execute 会损坏游标状态,故读也须串行化。
参数:
sql: SQL 查询语句。
params: 参数元组。
返回:
查询结果列表,每行为一个字典。
"""
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()]
def log_event(self, event_type: str, payload: dict[str, Any]) -> None:
"""向 _events 表写入一条事件。
参数:
event_type: 事件类型标识。
payload: 事件数据。
"""
with self._lock:
self._conn.execute(
"INSERT INTO _events (run_id, timestamp, event_type, payload) VALUES (?, ?, ?, ?)",
(
self._run_id,
_now_iso(),
event_type,
json.dumps(payload, ensure_ascii=False),
),
)
self._conn.commit()
def close(self, status: str = "completed") -> None:
"""更新运行状态并关闭连接。
参数:
status: 最终状态,"completed""failed"
"""
with self._lock:
self._conn.execute(
"UPDATE _runs SET finished_at = ?, status = ? WHERE run_id = ?",
(_now_iso(), status, self._run_id),
)
self._conn.commit()
self._conn.close()
def __enter__(self) -> HarnessLog:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
status = "failed" if exc_type is not None else "completed"
self.close(status=status)
# ---------------------------------------------------------------------------
# RunLogImpl — core/evolution/protocols.py::RunLog 的只读实现
# ---------------------------------------------------------------------------
def _read_table(
db_path: str,
table: str,
run_id: str,
*,
question_ids: list[str] | None = None,
) -> list[dict[str, Any]]:
"""纯读某表指定 run 的行——不经 HarnessLog 生命周期,避免回读污染 _runs 运行状态。
HarnessLog.__enter__/__exit__ 会对 run_id 做 INSERT OR IGNORE 并在退出时标 completed
回读指标绝不应改运行状态,故走独立只读连接(仅 SELECT)。
参数:
db_path: SQLite 路径。
table: 表名(内部固定常量,非外部输入,无注入风险)。
run_id: 过滤的 run ID。
question_ids: 可选的 question_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 []
if question_ids is not None:
placeholders = ", ".join(["?"] * len(question_ids))
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()
return [dict(r) for r in rows]
finally:
conn.close()
class RunLogImpl:
"""RunLog Protocol 的只读实现。
用独立 sqlite3.connect 做 SELECT,不经 HarnessLog 生命周期(不触发 _runs INSERT),
asyncio.to_thread 包装同步 SQL 查询,避免引入 aiosqlite 新依赖。
参数:
db_path: SQLite 数据库文件路径。
"""
def __init__(self, db_path: str) -> None:
self._db_path = db_path
async def get_predictions(
self,
run_id: str,
*,
question_ids: list[str] | None = None,
) -> list[dict[str, Any]]:
"""查询指定 run 的预测记录。
参数:
run_id: 运行标识。
question_ids: 可选的题目 ID 过滤列表。
返回:
预测记录字典列表。
"""
return await asyncio.to_thread(
_read_table, self._db_path, "predictions", run_id, question_ids=question_ids
)
async def get_traces(
self,
run_id: str,
*,
question_ids: list[str] | None = None,
) -> list[dict[str, Any]]:
"""查询指定 run 的推理轨迹。
参数:
run_id: 运行标识。
question_ids: 可选的题目 ID 过滤列表。
返回:
轨迹记录字典列表。
"""
return await asyncio.to_thread(
_read_table, self._db_path, "traces", run_id, question_ids=question_ids
)
+178
View File
@@ -0,0 +1,178 @@
"""慢更新动量生成 — epoch 末为单个 skill 产出新的动量指导。
对标 SkillOpt 的 slow_update 机制:拿上一 epoch 末与当前 epoch 末两版 skill
在固定样本上各跑一遍得到纵向对比(comparison_pairs),反思上一轮动量指导是否奏效、
本轮正文改动是改善还是漂移,据此重写动量指导。新指导经 patch 引擎的 replace_momentum
写回 skill 的 momentum 受保护区,作为下一轮进化的方向锚。
从 TRM4 core/harness/momentum.py156 行)迁移 + async 化。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from loguru import logger
from core.evolution.diagnose import extract_json_from_response
if TYPE_CHECKING:
from pathlib import Path
from core.protocols import LLMProvider
# =========================================================================
# 四类纵向对比类别名(单一真源)
# =========================================================================
IMPROVED = "improved" # 错→对
REGRESSED = "regressed" # 对→错
PERSISTENT_FAIL = "persistent_fail" # 错→错
STABLE_SUCCESS = "stable_success" # 对→对
# 类别名 → 展示标题,列表顺序即展示顺序。
# 回退(REGRESSED)刻意排在改善(IMPROVED)之前——它是最该警惕的伤害信号。
_CATEGORY_LABELS: tuple[tuple[str, str], ...] = (
(REGRESSED, "从对变错(回退,最高优先级)"),
(PERSISTENT_FAIL, "始终答错(持续失败)"),
(IMPROVED, "从错变对(改善)"),
(STABLE_SUCCESS, "始终答对(稳定成功)"),
)
# =========================================================================
# 辅助函数
# =========================================================================
def _categorize_pair(pair: dict[str, Any]) -> str:
"""按两版正误派生纵向对比类别。
用键值的真值(bool(...))表示该题在两版上各自的正误:缺 correct_prev/
correct_curr 键时直接抛 KeyError 向上传播——这是上游数据损坏(不是裁判语义
歧义),静默当 False 会伪造 persistent_fail 证据、污染动量指导,故不掩盖。
参数:
pair: 单个纵向对比对,须含 correct_prev/correct_curr 两键。
返回:
四个类别命名常量之一:IMPROVED(错→对)/REGRESSED(对→错)/
PERSISTENT_FAIL(错→错)/STABLE_SUCCESS(对→对)。
异常:
KeyError: 缺 correct_prev 或 correct_curr 键时。
"""
correct_prev = bool(pair["correct_prev"])
correct_curr = bool(pair["correct_curr"])
if not correct_prev and correct_curr:
return IMPROVED
if correct_prev and not correct_curr:
return REGRESSED
if not correct_prev and not correct_curr:
return PERSISTENT_FAIL
return STABLE_SUCCESS
def _format_comparison_pairs(comparison_pairs: list[dict[str, Any]]) -> str:
"""将纵向对比对格式化为裁判可读文本,按 _CATEGORY_LABELS 分组与排序。
参数:
comparison_pairs: 每个 dict 含 question/prev_prediction/curr_prediction/
correct_prev/correct_curr 字段,描述一道固定样本上两版的成对结果。
返回:
可读的纵向对比文本;空列表返回占位说明。
异常:
KeyError: 任一 pair 缺 correct_prev/correct_curr 键时;不掩盖的理由见
_categorize_pair docstring。
"""
if not comparison_pairs:
return "(本轮无可用纵向对比样本)"
grouped: dict[str, list[dict[str, Any]]] = {key: [] for key, _ in _CATEGORY_LABELS}
for pair in comparison_pairs:
grouped[_categorize_pair(pair)].append(pair)
lines: list[str] = [f"固定样本总数:{len(comparison_pairs)}"]
for key, label in _CATEGORY_LABELS:
entries = grouped[key]
lines.append(f"\n### {label}{len(entries)} 题)")
if not entries:
lines.append("(无)")
continue
for pair in entries:
lines.append(
f"- 题目:{pair.get('question', '')}\n"
f" 上版预测:{pair.get('prev_prediction', '')} | "
f"当前版预测:{pair.get('curr_prediction', '')}"
)
return "\n".join(lines)
# =========================================================================
# 入口
# =========================================================================
async def run_slow_momentum(
llm: LLMProvider,
diagnose_prompts_dir: Path,
skill_content: str,
prev_skill: str,
prev_guidance: str,
comparison_pairs: list[dict[str, Any]],
) -> str:
"""为单个 skill 生成新的慢更新动量指导。
参数:
llm: LLM 端口(async chat)。
diagnose_prompts_dir: 诊断 prompt 目录(根 prompts/slow_momentum.md 在此)。
skill_content: 当前版 skill 正文。
prev_skill: 上一版 skill 正文。
prev_guidance: 上一轮写下的动量指导。
comparison_pairs: 固定样本上两版 rollout 的成对结果(含 question/
prev_prediction/curr_prediction/correct_prev/correct_curr)。
返回:
新的动量指导文本;解析失败时保留 prev_guidance。
关键实现细节:
- _format_comparison_pairs 刻意置于 try 块之外(prompt 构造阶段):它对每个
pair 取 correct_prev/correct_curr,缺键抛 KeyError 直接向上传播,不被下方
针对裁判语义歧义的 except ValueError 吞掉。
- 解析失败保留上轮指导:extract_json_from_response 抛 ValueError、缺
slow_update_content 字段、或该字段非 str,均视为语义解析失败,返回
prev_guidance(判不准时保守保留上轮指导,对标 diagnose 的保护性 fallback)。
- P5 边界:仅捕 ValueError 这一语义歧义;llm.chat 的基础设施失败
(网络/API 异常)刻意不捕,向上传播,绝不用默认值掩盖。
"""
system_prompt = (diagnose_prompts_dir / "slow_momentum.md").read_text(encoding="utf-8")
# _format_comparison_pairs 刻意置于下方 try 块之外(prompt 构造阶段):它对每个
# pair 取 correct_prev/correct_curr,缺键抛 KeyError 直接向上传播,不被下方针对
# 裁判语义歧义的 except ValueError 吞掉。异常类型选 KeyError(非 ValueError),
# 即便位置疏忽落入 try 也不会被误吞。
user_prompt = (
f"## 上一版 skill 正文\n{prev_skill}\n\n"
f"## 当前版 skill 正文\n{skill_content}\n\n"
f"## 上一轮的动量指导\n{prev_guidance}\n\n"
f"## 固定样本纵向对比(上版 vs 当前版)\n"
f"{_format_comparison_pairs(comparison_pairs)}"
)
response = await llm.chat(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
)
raw = response.content
try:
parsed = extract_json_from_response(raw)
new_guidance = parsed.get("slow_update_content")
if not isinstance(new_guidance, str):
raise ValueError("slow_update_content 字段缺失或非字符串")
except ValueError:
logger.warning("慢更新动量解析失败,保留上轮动量指导")
return prev_guidance
return new_guidance
+459
View File
@@ -0,0 +1,459 @@
"""五张观测表的落库写入与回读 + step/epoch 报告文件输出。
合并 TRM4 的 metric_log.py(五表)和 loop_report.py(报告)。
五张表均经 structured-logging 定义,DDL 与之逐列一致:
dual_metric_eval / shadow_gate / holdout_eval / quadrant_pair / gate_evidence。
公共契约(守 P5):soft/mixed 为 Noneinvalid,无 span / 诊断失败)时存 NULL**绝不存 0**——
SQLite 对 dict 中 None 值写入即 NULL,分析时按 NULL 跳过。每个写函数内幂等建表
``HarnessLog.create_table`` 用 CREATE TABLE IF NOT EXISTS),run_id/timestamp 列由
HarnessLog 自动补。
报告函数输出 JSON 到 workspace 的 analyses/ 目录,供人工审查诊断 prompt 与进化 prompt。
"""
from __future__ import annotations
import json
import sqlite3
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
def _read_table(db_path: str, table: str, run_id: str) -> list[dict[str, Any]]:
"""纯读某表指定 run 的全部行——不经 HarnessLog 生命周期,避免回读污染 _runs 运行状态。
HarnessLog.__enter__/__exit__ 会对 run_id 做 INSERT OR IGNORE 并在退出时标 completed
回读指标绝不应改运行状态,故 read_* 一律走本只读连接(仅 SELECT)。
参数:
db_path: SQLite 路径。
table: 表名(内部固定常量,非外部输入,无注入风险)。
run_id: 过滤的 run ID。
返回:
行 dict 列表;表尚未建(没写过)视为无数据返 []。
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
exists = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone()
if exists is None:
return []
rows = conn.execute(f"SELECT * FROM {table} WHERE run_id=?", (run_id,)).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
# ---------------------------------------------------------------------------
# 列定义严格对齐 research-wiki/schemas/*.mdrun_id/timestamp 由 create_table 自动补)
# ---------------------------------------------------------------------------
_DUAL_COLS: dict[str, str] = {
"epoch": "INTEGER",
"version_kind": "TEXT",
"skills_version": "TEXT",
"prompts_version": "TEXT",
"pool": "TEXT",
"hard_acc": "REAL",
"soft_score": "REAL",
"mixed_score": "REAL",
}
_SHADOW_COLS: dict[str, str] = {
"epoch": "INTEGER",
"candidate_version": "TEXT",
"hard_acc": "REAL",
"soft_score": "REAL",
"mixed_score": "REAL",
"is_mixed_best": "INTEGER",
}
_HOLDOUT_COLS: dict[str, str] = {
"epoch": "INTEGER",
"version_kind": "TEXT",
"hard_acc": "REAL",
"soft_score": "REAL",
"mixed_score": "REAL",
"per_task_type_json": "TEXT",
}
_QUADRANT_COLS: dict[str, str] = {
"epoch": "INTEGER",
"step": "INTEGER",
"question_id": "TEXT",
"task_type": "TEXT",
"prev_correct": "INTEGER",
"curr_correct": "INTEGER",
"category": "TEXT",
}
_GATE_EVIDENCE_COLS: dict[str, str] = {
"epoch": "INTEGER",
"step": "INTEGER",
"task_type": "TEXT",
"question_id": "TEXT",
"block_idx": "INTEGER",
"baseline_correct": "INTEGER",
"candidate_correct": "INTEGER",
"e_value": "REAL",
"stop_reason": "TEXT",
}
# ---------------------------------------------------------------------------
# dual_metric_eval
# ---------------------------------------------------------------------------
def write_dual_metric(
db_path: str,
*,
run_id: str,
epoch: int,
version_kind: str,
skills_version: str,
prompts_version: str,
pool: str,
hard_acc: float,
soft_score: float | None,
mixed_score: float | None,
) -> None:
"""落 dual_metric_eval 一行:epoch 末关键版本的 hard+soft+mixed 双轨度量。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
version_kind: baseline / best_hard / best_mixed / final。
skills_version / prompts_version: 评估的资源版本。
pool: val / test。
hard_acc: hard 准确率。
soft_score: soft 连续分;invalid 传 None -> 存 NULL。
mixed_score: 0.5*hard+0.5*softsoft 缺失传 None -> 存 NULL。
"""
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("dual_metric_eval", _DUAL_COLS)
log.insert(
"dual_metric_eval",
{
"epoch": epoch,
"version_kind": version_kind,
"skills_version": skills_version,
"prompts_version": prompts_version,
"pool": pool,
"hard_acc": hard_acc,
"soft_score": soft_score,
"mixed_score": mixed_score,
},
)
def read_dual_metric(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 dual_metric_eval 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "dual_metric_eval", run_id)
# ---------------------------------------------------------------------------
# shadow_gate
# ---------------------------------------------------------------------------
def write_shadow_gate(
db_path: str,
*,
run_id: str,
epoch: int,
candidate_version: str,
hard_acc: float,
soft_score: float | None,
mixed_score: float | None,
is_mixed_best: bool,
) -> None:
"""落 shadow_gate 一行:mixed 影子 best 候选的 hard/soft/mixed 及是否 argmax 选中。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
candidate_version: 候选版本标识(如 skills/vX+prompts/vY)。
hard_acc: hard 准确率。
soft_score: soft 连续分;invalid 传 None -> 存 NULL(该版本不进 argmax)。
mixed_score: 0.5*hard+0.5*softsoft 缺失传 None -> 存 NULL。
is_mixed_best: 是否本 epoch mixed argmax 选中(存 1/0)。
"""
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("shadow_gate", _SHADOW_COLS)
log.insert(
"shadow_gate",
{
"epoch": epoch,
"candidate_version": candidate_version,
"hard_acc": hard_acc,
"soft_score": soft_score,
"mixed_score": mixed_score,
"is_mixed_best": int(is_mixed_best),
},
)
def read_shadow_gate(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 shadow_gate 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "shadow_gate", run_id)
# ---------------------------------------------------------------------------
# holdout_eval
# ---------------------------------------------------------------------------
def write_holdout_eval(
db_path: str,
*,
run_id: str,
epoch: int,
version_kind: str,
hard_acc: float,
soft_score: float | None,
mixed_score: float | None,
per_task_type_json: str,
) -> None:
"""落 holdout_eval 一行:四向 held-out 在 test 池的 hard+soft+mixed 及按题型细分。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
version_kind: baseline / best_hard / best_mixed / final。
hard_acc: hard 准确率。
soft_score: soft 连续分;invalid 传 None -> 存 NULL。
mixed_score: 0.5*hard+0.5*softsoft 缺失传 None -> 存 NULL。
per_task_type_json: 按 task_type 的 {accuracy,total,correct} JSON 串。
"""
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("holdout_eval", _HOLDOUT_COLS)
log.insert(
"holdout_eval",
{
"epoch": epoch,
"version_kind": version_kind,
"hard_acc": hard_acc,
"soft_score": soft_score,
"mixed_score": mixed_score,
"per_task_type_json": per_task_type_json,
},
)
def read_holdout_eval(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 holdout_eval 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "holdout_eval", run_id)
# ---------------------------------------------------------------------------
# quadrant_pair
# ---------------------------------------------------------------------------
def write_quadrant_pairs(
db_path: str,
*,
run_id: str,
epoch: int,
step: int,
pairs: list[dict[str, Any]],
) -> None:
"""落 quadrant_pair 多行:fast gate 后逐题四象限(prev/curr 翻转 + category)落库。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 轮次(1-based)。
step: epoch 内 step 序号(0-based)。
pairs: 每条含 question_id/task_type/prev_correct/curr_correct/category
prev_correct/curr_correct 为 bool,写库前转 0/1。
关键实现:
用 insert_many 批量落库;pairs 为空时只建表不插入(fast gate 无翻转的极端情况)。
"""
records = [
{
"epoch": epoch,
"step": step,
"question_id": pair["question_id"],
"task_type": pair["task_type"],
"prev_correct": int(pair["prev_correct"]),
"curr_correct": int(pair["curr_correct"]),
"category": pair["category"],
}
for pair in pairs
]
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("quadrant_pair", _QUADRANT_COLS)
if records:
log.insert_many("quadrant_pair", records)
def read_quadrant_pairs(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 quadrant_pair 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "quadrant_pair", run_id)
# ---------------------------------------------------------------------------
# gate_evidence
# ---------------------------------------------------------------------------
def write_gate_evidence(
db_path: str,
*,
run_id: str,
epoch: int,
step: int,
rows: list[dict[str, Any]],
) -> None:
"""落 gate_evidence 逐题行:CE-Gate 每次决策的可回放审计记录。
参数:
db_path: SQLite 路径。
run_id: 训练 run ID。
epoch: 该 gate 所属的轮次(1-based)。
step: epoch 内 step 序号(0-based)。
rows: 每题一行,含 question_id/task_type/block_idx/baseline_correct/
candidate_correct/e_value(该题所在块判定后的累计 e 值)/
stop_reason(仅最后一题携带最终 stop_reason,其余空串)。
关键实现:
逐行 insert(非 insert_many),保证每行独立事务。
"""
from app.harness.log import HarnessLog
with HarnessLog(db_path, run_id) as log:
log.create_table("gate_evidence", _GATE_EVIDENCE_COLS)
for row in rows:
log.insert("gate_evidence", {"epoch": epoch, "step": step, **row})
def read_gate_evidence(db_path: str, *, run_id: str) -> list[dict[str, Any]]:
"""回读指定 run 的 gate_evidence 全部行(纯读,不污染运行状态)。"""
return _read_table(db_path, "gate_evidence", run_id)
# ---------------------------------------------------------------------------
# 报告函数(从 TRM4 loop_report.py 迁移)
# ---------------------------------------------------------------------------
def write_step_report(
workspace_dir: Path,
epoch: int,
step: int,
global_step: int,
task_type: str,
gate_action: str,
candidate_acc: float,
class_baseline_acc: float,
edit_budget: int,
rank_clip_triggered: bool,
gate_w: int | None,
gate_l: int | None,
gate_e_value: float | None,
gate_n_used: int | None,
gate_stop_reason: str | None,
) -> Path:
"""写单个 (step, task_type) 快路径 gate 的最小观测记录 JSON。
文件名按 (epoch, step, task_type) 命名,slug 由 task_type 规范化(小写、空格转 '-')得到。
参数:
workspace_dir: 实验工作区目录。
epoch: 当前轮次(1-based)。
step: epoch 内 step 序号(0-based)。
global_step: 全局步计数(驱动 edit_budget 退火)。
task_type: 本条 gate 的任务类型。
gate_action: 闸门动作(accept_confirmed / accept_provisional / reject /
skipped / cooldown)。
candidate_acc: 候选在 gate 已观测题上的准确率(观测口径)。
class_baseline_acc: 基线在 gate 已观测题上的准确率(观测口径)。
edit_budget: 该 step 按 global_step 退火得到的 per-target 编辑预算上限。
rank_clip_triggered: 该 skill 进化是否触发了 rank 裁剪。
gate_w: e-process 累计 W(基线错->候选对翻转数);skipped/cooldown 路径传 None。
gate_l: e-process 累计 L(基线对->候选错翻转数);skipped/cooldown 路径传 None。
gate_e_value: 停时的 e 值;skipped/cooldown 路径传 None。
gate_n_used: gate 实际消费的阶梯题数;skipped/cooldown 路径传 None。
gate_stop_reason: e-process 停止原因;skipped/cooldown 路径传 None。
返回:
写入的 step_report 文件路径。
"""
report = {
"epoch": epoch,
"step": step,
"global_step": global_step,
"task_type": task_type,
"gate_action": gate_action,
"candidate_acc": candidate_acc,
"class_baseline_acc": class_baseline_acc,
"edit_budget": edit_budget,
"rank_clip_triggered": rank_clip_triggered,
"gate_w": gate_w,
"gate_l": gate_l,
"gate_e_value": gate_e_value,
"gate_n_used": gate_n_used,
"gate_stop_reason": gate_stop_reason,
}
slug = task_type.lower().replace(" ", "-")
out_dir = workspace_dir / "analyses"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"step_report_e{epoch}_s{step}_{slug}.json"
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def write_epoch_report(
workspace_dir: Path,
epoch: int,
system_tool_action: str,
momentum_updated_task_types: list[str],
best_val_acc: float,
) -> Path:
"""写 epoch 末慢更新汇总 JSON。
慢更新无单一 ValidationOutcome,故本函数只落慢更新可观测的最小集:
system/tool gate 动作、本 epoch 写过 momentum 的题型、慢更新后的全局 best。
参数:
workspace_dir: 实验工作区目录。
epoch: 当前轮次(1-based)。
system_tool_action: 慢更新 system/tool 动作(updated / reverted / none)。
momentum_updated_task_types: 本 epoch 写过 momentum 的题型列表。
best_val_acc: 慢更新后(含 best argmax)的全局 best 验证准确率。
返回:
写入的 epoch_report 文件路径。
"""
report = {
"epoch": epoch,
"system_tool_action": system_tool_action,
"momentum_updated_task_types": momentum_updated_task_types,
"best_val_acc": best_val_acc,
}
out_dir = workspace_dir / "analyses"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"epoch_report_{epoch}.json"
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return path
+283
View File
@@ -0,0 +1,283 @@
"""三池:held-out test + 验证 + 诊断,分层采样 + 冻结持久化。
三池切分对应训练循环中的 DataLoader 阶段——从题目全集中按
test -> validation -> diagnosis 的顺序 progressive exclusion
保证 question_id 互斥。test 池用自然分布(correct_ratio=None),
验证池/诊断池按对错比例分层采样。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from app.question_gen import stratified_sample
from core.types import GeneratedQuestion
if TYPE_CHECKING:
from pathlib import Path
from app.harness.config import RunConfig
@dataclass
class Pools:
"""冻结的三池及其基线指标。
字段:
diagnosis: 诊断池(用于错误归因,对应 loss.backward)。
validation: 验证池(按类局部验证,每题型有保底样本)。
test: held-out 测试池(自然分布,用于最终无偏评估)。
baseline_run_id: 基线 run 标识。
baseline_val_accuracy: 基线在验证池上的准确率。
correctness: 三池所有题的 question_id -> 基线是否答对。
"""
diagnosis: list[GeneratedQuestion]
validation: list[GeneratedQuestion]
test: list[GeneratedQuestion]
baseline_run_id: str
baseline_val_accuracy: float
correctness: dict[str, bool] = field(default_factory=dict)
def build_pools(
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
diag_cfg: dict,
val_cfg: dict,
test_cfg: dict,
baseline_run_id: str,
) -> Pools:
"""先抽 held-out test,再抽验证集,最后抽诊断池,三池互斥。
参数:
questions: 题目全集。
correctness: question_id -> 基线是否答对。
diag_cfg: 诊断池采样配置(size/correct_ratio/task_types[/seed])。
val_cfg: 验证池采样配置,可含 min_per_class 做按类保底。
test_cfg: 测试池配置(size[/seed]);走自然分布,不强制对错比与题型。
baseline_run_id: 基线 run 标识。
返回:
冻结的三池 Pools。
关键实现细节:
切分顺序 test -> validation -> diagnosis;后两步从剩余题中采样以保证
question_id 互斥。test 池用 correct_ratio=None 的自然分布采样。
"""
test = _sample_excluding(
questions,
set(),
correctness,
size=test_cfg["size"],
correct_ratio=None,
task_types=None,
seed=test_cfg.get("seed", 0),
min_per_class=None,
)
selected_ids = {q.question_id for q in test}
validation = _sample_excluding(questions, selected_ids, correctness, **val_cfg)
selected_ids |= {q.question_id for q in validation}
diagnosis = _sample_excluding(questions, selected_ids, correctness, **diag_cfg)
val_correct = sum(1 for q in validation if correctness.get(q.question_id))
baseline_val_accuracy = val_correct / len(validation) if validation else 0.0
return Pools(
diagnosis=diagnosis,
validation=validation,
test=test,
baseline_run_id=baseline_run_id,
baseline_val_accuracy=baseline_val_accuracy,
correctness={
q.question_id: correctness.get(q.question_id, False)
for q in test + validation + diagnosis
},
)
def _sample_excluding(
questions: list[GeneratedQuestion],
exclude_ids: set[str],
correctness: dict[str, bool],
**cfg: object,
) -> list[GeneratedQuestion]:
"""排除已选 question_id 后,按 cfg 对剩余题做分层采样。
参数:
questions: 题目全集。
exclude_ids: 已被其他池选走的 question_id,从候选中剔除以保证三池互斥。
correctness: question_id -> 基线是否答对。
cfg: 透传给 stratified_sample 的采样配置
size/correct_ratio/task_types[/seed/min_per_class])。
返回:
采样后的题目列表。
"""
pool = [q for q in questions if q.question_id not in exclude_ids]
return stratified_sample(pool, correctness, **cfg)
def _q_to_dict(q: GeneratedQuestion) -> dict:
"""将 GeneratedQuestion 转为可序列化字典。
参数:
q: 题目对象。
返回:
包含全部字段的字典(options/source_nodes 从 tuple 转为 list)。
"""
return {
"question_id": q.question_id,
"video_id": q.video_id,
"task_type": q.task_type,
"question": q.question,
"options": list(q.options),
"answer": q.answer,
"source_nodes": list(q.source_nodes),
"difficulty": q.difficulty,
}
def _dict_to_q(d: dict) -> GeneratedQuestion:
"""从字典恢复 GeneratedQuestion。
参数:
d: 由 _q_to_dict 产出的字典。
返回:
恢复的 GeneratedQuestion 实例(options/source_nodes 恢复为 tuple)。
"""
return GeneratedQuestion(
question_id=d["question_id"],
video_id=d["video_id"],
task_type=d["task_type"],
question=d["question"],
options=tuple(d["options"]),
answer=d["answer"],
source_nodes=tuple(d.get("source_nodes", ())),
difficulty=d.get("difficulty", "medium"),
)
def save_pools(pools: Pools, path: Path) -> None:
"""将三池及基线指标冻结为 JSON。
参数:
pools: 待冻结的三池。
path: 目标 JSON 文件路径。
"""
path.write_text(
json.dumps(
{
"baseline_run_id": pools.baseline_run_id,
"baseline_val_accuracy": pools.baseline_val_accuracy,
"correctness": pools.correctness,
"diagnosis": [_q_to_dict(q) for q in pools.diagnosis],
"validation": [_q_to_dict(q) for q in pools.validation],
"test": [_q_to_dict(q) for q in pools.test],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def load_pools(path: Path) -> Pools:
"""从 JSON 恢复冻结的三池。
参数:
path: 冻结的 pools.json 路径。
返回:
恢复的三池 Pools。
异常:
ValueError: 旧格式 pools.json(无 test 池)。
关键实现细节:
旧格式 pools.json(无 test 池)会以清晰的 ValueError 中止——本项目不做
向后兼容,也不为缺失字段填默认值。删除旧文件后 build_pools 会重新采样切分,
无需重新推理。
"""
d = json.loads(path.read_text(encoding="utf-8"))
if "test" not in d:
raise ValueError(
f"{path} 为旧格式 pools.json(缺 test 池),"
"请删除后重新切分(build_pools 会重新采样,无需重新推理)。"
)
return Pools(
diagnosis=[_dict_to_q(x) for x in d["diagnosis"]],
validation=[_dict_to_q(x) for x in d["validation"]],
test=[_dict_to_q(x) for x in d["test"]],
baseline_run_id=d["baseline_run_id"],
baseline_val_accuracy=d["baseline_val_accuracy"],
correctness=d["correctness"],
)
def build_or_load_pools(
config: RunConfig,
run_id: str,
task_types: list[str] | None = None,
) -> Pools:
"""train 模式的三池获取入口:pools.json 已存在则加载,否则从基线 db 切分并冻结。
把 main.py train 分支「pools.json 存在则 load_pools 否则 build_pools 再 save_pools」
那段抽成纯函数,使 main 与集成测试共用同一切分逻辑、避免重复。pools.json 是
一次 fresh 训练的冻结切分,resume/重跑同一 workspace 时直接复用以保证三池一致。
参数:
config: 运行配置,提供 workspace_dir 与三池采样旋钮(diag/val/test 各项)。
run_id: 基线全量记录的 run_idfresh 时来自 seed.json,决定从哪个 run 读对错)。
task_types: 可选题型过滤,限定诊断/验证池只采样这些题型;None 表示不过滤。
返回:
冻结的三池 Pools。
关键实现:
切分前从基线 db 的 predictions 表读该 run_id 的逐题对错,作为分层采样依据。
pools.json 落在 config.workspace_dir 下,存在即视为已冻结,原样加载不重切。
"""
from app.harness.log import HarnessLog
from app.harness.workspace import resolve_paths
from app.question_gen import load_benchmark
pools_path = config.workspace_dir / "pools.json"
if pools_path.exists():
return load_pools(pools_path)
paths = resolve_paths(config.workspace_dir)
questions = load_benchmark(paths.questions_dir)
with HarnessLog(str(paths.db_path), run_id) as log:
rows = log.query(
"SELECT question_id, prediction, answer FROM predictions WHERE run_id=?",
(run_id,),
)
correctness = {r["question_id"]: r["prediction"] == r["answer"] for r in rows}
pools = build_pools(
questions,
correctness,
diag_cfg={
"size": config.diag_size,
"correct_ratio": config.diag_correct_ratio,
"task_types": task_types,
"seed": 0,
"min_per_class": None,
},
val_cfg={
"size": config.val_size,
"correct_ratio": config.val_correct_ratio,
"task_types": task_types,
"seed": 0,
"min_per_class": config.eval_min_per_class,
},
test_cfg={"size": config.test_size},
baseline_run_id=run_id,
)
save_pools(pools, pools_path)
return pools
File diff suppressed because it is too large Load Diff
+378
View File
@@ -0,0 +1,378 @@
"""Store 版本操作 + Seed 管理。
Store 存储版本化资源(视频、题目、Skill、Prompt),
通过版本号(v1, v2, ...)管理资源的演化历史。
Seed 是可复现的实验起点,包含权重快照 + baseline 数据库。
"""
from __future__ import annotations
import json
import re
import shutil
import sqlite3
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
from pathlib import Path
def _now_iso() -> str:
"""返回当前 UTC 时间的 ISO 格式字符串。"""
return datetime.now(UTC).isoformat()
def _parse_version(name: str) -> int:
"""解析版本目录名 ``v\\d+`` 为整数。
参数:
name: 版本目录名,如 ``"v1"``、``"v10"``。
返回:
版本号整数。
异常:
ValueError: 版本目录名格式不合法(不匹配 ``v\\d+``)。
"""
match = re.match(r"v(\d+)$", name)
if not match:
raise ValueError(f"无效版本号: {name}")
return int(match.group(1))
def list_versions(store_dir: Path, resource_type: str) -> list[str]:
"""列出 Store 中某类资源的所有版本号,按数字值排序。
按数字排序保证 v10 排在 v2 后面(而非字典序 v10 < v2)。
参数:
store_dir: Store 根目录。
resource_type: 资源类型路径,如 ``"skills"``、``"questions/generated"``。
返回:
排序后的版本号列表,如 ``["v1", "v2", "v10"]``。
"""
resource_dir = store_dir / resource_type
if not resource_dir.is_dir():
return []
versions = []
for entry in resource_dir.iterdir():
if entry.is_dir() and re.match(r"v\d+$", entry.name):
versions.append(entry.name)
return sorted(versions, key=_parse_version)
def next_version(store_dir: Path, resource_type: str) -> str:
"""返回某类资源的下一个可用版本号。
参数:
store_dir: Store 根目录。
resource_type: 资源类型路径。
返回:
下一个版本号字符串,如 ``"v3"``。
"""
versions = list_versions(store_dir, resource_type)
if not versions:
return "v1"
latest = _parse_version(versions[-1])
return f"v{latest + 1}"
def _write_meta(target_dir: Path, version: str, source: str, **extra: str | None) -> None:
"""写入版本元数据文件 ``meta.json``。
参数:
target_dir: 版本目录。
version: 版本号。
source: 来源标识(``"manual"`` / ``"evolution"`` / ``"auto-gen"``)。
**extra: 额外字段(parent, trigger_run, trigger_workspace, description)。
"""
meta = {
"version": version,
"created_at": _now_iso(),
"parent": extra.get("parent"),
"source": source,
"trigger_run": extra.get("trigger_run"),
"trigger_workspace": extra.get("trigger_workspace"),
"description": extra.get("description", ""),
}
(target_dir / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
def advance_version(
store_dir: Path,
resource_type: str,
source_dir: Path,
meta: dict,
) -> str:
"""将 source_dir 的内容写入 Store 的下一个版本目录,写入 meta.json。
参数:
store_dir: Store 根目录。
resource_type: 资源类型路径,如 ``"skills"``、``"questions/generated"``。
source_dir: 包含新版本资源文件的源目录。
meta: 元数据字典,至少包含 ``source`` 字段。
返回:
新版本号字符串,如 ``"v2"``。
"""
version = next_version(store_dir, resource_type)
target = store_dir / resource_type / version
shutil.copytree(source_dir, target)
_write_meta(
target,
version,
meta.get("source", "manual"),
parent=meta.get("parent"),
trigger_run=meta.get("trigger_run"),
trigger_workspace=meta.get("trigger_workspace"),
description=meta.get("description", ""),
)
logger.info("Store 版本推进: {}/{}", resource_type, version)
return version
def init_store(
store_dir: Path,
videos_source: Path,
skills_dir: Path,
prompts_dir: Path,
) -> None:
"""初始化 Store:拷贝视频数据,创建 skills/v1、prompts/v1 和 questions 目录。
参数:
store_dir: Store 目标路径(不得已存在)。
videos_source: 视频数据源目录。
skills_dir: 初始 Skill 文件目录。
prompts_dir: 初始 Prompt 文件目录。
异常:
FileExistsError: Store 目录已存在。
"""
if store_dir.exists():
raise FileExistsError(f"Store 已存在: {store_dir}")
store_dir.mkdir(parents=True)
shutil.copytree(videos_source, store_dir / "videos")
(store_dir / "questions" / "benchmarks").mkdir(parents=True)
(store_dir / "questions" / "generated").mkdir(parents=True)
shutil.copytree(skills_dir, store_dir / "skills" / "v1")
_write_meta(
store_dir / "skills" / "v1",
"v1",
"manual",
description="手工创建的初始版本",
)
shutil.copytree(prompts_dir, store_dir / "prompts" / "v1")
_write_meta(
store_dir / "prompts" / "v1",
"v1",
"manual",
description="手工创建的初始版本",
)
logger.info("Store 初始化完成: {}", store_dir)
# ---------------------------------------------------------------------------
# 种子库(Seed)函数
# ---------------------------------------------------------------------------
def init_seed(
store_dir: Path,
name: str,
skills_dir: Path,
prompts_dir: Path,
baseline_db: Path,
baseline_run_id: str,
parent: str | None,
description: str,
) -> Path:
"""在 store/seeds/<name> 写一个种子:权重 + baseline.db + seed.json。
参数:
store_dir: Store 根目录。
name: 种子名(如 ``'initial'``、``'from-evolve-v20'``)。
skills_dir: 该版本 Skill 权重源目录。
prompts_dir: 该版本 Prompt 权重源目录。
baseline_db: 该版本全量记录 db(含 _runs + predictions 行)。
baseline_run_id: 全量记录的 run_idfresh 时注入 build_pools。
parent: 来源(initial 为 None)。
description: 人类可读说明。
返回:
种子目录路径。
异常:
FileExistsError: 同名种子已存在(不覆盖)。
"""
seed_dir = store_dir / "seeds" / name
if seed_dir.exists():
raise FileExistsError(f"种子已存在,不覆盖: {seed_dir}")
seed_dir.mkdir(parents=True)
shutil.copytree(skills_dir, seed_dir / "skills")
shutil.copytree(prompts_dir, seed_dir / "prompts")
shutil.copy2(baseline_db, seed_dir / "baseline.db")
(seed_dir / "seed.json").write_text(
json.dumps(
{
"baseline_run_id": baseline_run_id,
"parent": parent,
"created_at": _now_iso(),
"description": description,
},
ensure_ascii=False,
indent=2,
)
)
logger.info("种子创建完成: {}", seed_dir)
return seed_dir
def list_seeds(store_dir: Path) -> list[str]:
"""列出 store/seeds 下所有种子名(按名排序)。
参数:
store_dir: Store 根目录。
返回:
种子名列表(仅含 seed.json 存在的目录),按名排序。
"""
seeds_root = store_dir / "seeds"
if not seeds_root.is_dir():
return []
return sorted(e.name for e in seeds_root.iterdir() if (e / "seed.json").exists())
def read_seed(store_dir: Path, name: str) -> dict:
"""读取种子 seed.json;不存在则报错。
参数:
store_dir: Store 根目录。
name: 种子名。
返回:
seed.json 解析后的字典。
异常:
FileNotFoundError: 该种子不存在。
"""
seed_json = store_dir / "seeds" / name / "seed.json"
if not seed_json.exists():
raise FileNotFoundError(f"种子不存在: {name}{seed_json}")
return json.loads(seed_json.read_text())
def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None:
"""从 src_db 抽出某 run_id 的 _runs + predictions 行,写一个最小 db(种子 baseline.db)。
用源表的**原始 CREATE 语句**重建目标表,保留主键/列类型/约束——
``_runs.run_id TEXT PRIMARY KEY`` 是 HarnessLog ``INSERT OR IGNORE`` 去重的依据,
若 seed db 丢主键则续训/fresh-bootstrap 的去重失效。
参数:
src_db: 源 harness.db。
dst_db: 目标 db(不得已存在)。
run_id: 要抽取的 run。
异常:
RuntimeError: 源中无该表或无该 run 的行。
"""
src = sqlite3.connect(src_db)
dst = sqlite3.connect(dst_db)
try:
for table in ("_runs", "predictions"):
create_sql = src.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
(table,),
).fetchone()
if create_sql is None or create_sql[0] is None:
raise RuntimeError(f"源 db 无表 {table}")
dst.execute(create_sql[0])
cols = [r[1] for r in src.execute(f"PRAGMA table_info({table})")]
col_sql = ", ".join(cols)
rows = src.execute(
f"SELECT {col_sql} FROM {table} WHERE run_id=?", (run_id,)
).fetchall()
if not rows:
raise RuntimeError(f"{table} 中无 run_id={run_id} 的行")
ph = ", ".join("?" * len(cols))
dst.executemany(f"INSERT INTO {table} ({col_sql}) VALUES ({ph})", rows)
dst.commit()
finally:
dst.close()
src.close()
def promote_to_seed(
workspace_dir: Path,
store_dir: Path,
version: str,
eval_run_id: str,
name: str,
description: str,
) -> Path:
"""把 workspace 的指定版本 + 配套 prompts + 指定 eval run 全量记录固化成新种子。
强校验 eval_run_id 对应的 _runs 行中 skills_version 必须与 version 一致,
且 skills_version/prompts_version 均不得为 NULL。
参数:
workspace_dir: 来源 workspace。
store_dir: Store 根目录。
version: skills 版本号。
eval_run_id: canonical eval run(其 _runs 行提供配套 prompts 版本与全量记录)。
name: 新种子名(冲突报错不覆盖)。
description: 说明。
返回:
新种子目录。
异常:
ValueError: eval_run_id 不存在,或其 skills_version 与 version 不符,或版本为 NULL。
FileExistsError: 同名种子已存在(由 init_seed 抛出)。
"""
con = sqlite3.connect(workspace_dir / "harness.db")
con.row_factory = sqlite3.Row
try:
row = con.execute(
"SELECT skills_version, prompts_version FROM _runs WHERE run_id=?",
(eval_run_id,),
).fetchone()
finally:
con.close()
if row is None:
raise ValueError(f"eval run 不存在: {eval_run_id}")
skills_v, prompts_v = row["skills_version"], row["prompts_version"]
# 强校验——eval run 的版本必须与 --version 一致,且不得为 NULL
if skills_v is None or prompts_v is None:
raise ValueError(f"eval run {eval_run_id} 的 _runs 版本对为 NULL(未回填?),无法 promote")
if skills_v != version:
raise ValueError(f"eval run {eval_run_id} 的版本 {skills_v} 与 --version {version} 不符")
tmp_db = workspace_dir / "_promote_tmp.db"
if tmp_db.exists():
tmp_db.unlink()
extract_run_db(workspace_dir / "harness.db", tmp_db, eval_run_id)
try:
seed_dir = init_seed(
store_dir,
name,
workspace_dir / "skills" / skills_v,
workspace_dir / "prompts" / prompts_v,
tmp_db,
baseline_run_id=eval_run_id,
parent=f"{workspace_dir.name}:{version}",
description=description,
)
finally:
tmp_db.unlink()
logger.info("Promote 完成: {} -> {}", workspace_dir.name, seed_dir)
return seed_dir
+665
View File
@@ -0,0 +1,665 @@
"""async 块序贯验证编排 — CE-Gate 局部验证的唯一独立子编排器。
从 TRM4 core/harness/validate.py (626 行) 迁移,重大重构:
- 同步 → asyncrun_inference 注入为 async callable
- _classify_quadrants → core.evolution.classify_quadrants 纯函数
- 配对逻辑 → 复用 core.evolution.pair_block + 本地证据行组装
- _load_run_rows / _candidate_correctness_from_db → 共享 log.query()
- materialize_candidate_skill 保持同步(纯文件操作)
基线与候选在同一阶梯前缀上逐块配对,只数翻转(基线错→候选对 = W,
基线对→候选错 = L),每块结束调 gate_decision 做四出口判定。
基线侧逐题对错走 BaselineCache 内容寻址缓存,miss 才新鲜跑。
判定逻辑全部在 core/evolution/gate,本模块只负责推理编排与证据收集。
"""
from __future__ import annotations
import json
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from loguru import logger
from app.harness.gate_ladder import BaselineCache, skill_hash
from core.evolution import (
GateParams,
GateVerdict,
RejectedEdit,
classify_quadrants,
gate_decision,
pair_block,
)
if TYPE_CHECKING:
from app.harness.inference import InferenceResult
from app.harness.log import HarnessLog
from core.types import GeneratedQuestion
# gate_decision 的 decision → ValidationOutcome.stop_reason 映射
_STOP_REASON_BY_DECISION: dict[str, str] = {
"accept_confirmed": "confirmed",
"reject_directional": "directional",
"reject_futility": "futility",
"accept_provisional": "provisional",
"reject_inertia": "inertia",
}
# ---------------------------------------------------------------------------
# 注入协议
# ---------------------------------------------------------------------------
@runtime_checkable
class RunInferenceFn(Protocol):
"""注入的推理函数协议。
调用方(runner)负责绑定 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)
+478
View File
@@ -0,0 +1,478 @@
"""Workspace 生命周期管理 + manifest 读写 + Protocol 实现。
Workspace 是一次实验的独立工作区,通过 manifest.json 引用 Store 中的
特定版本资源并记录实验过程。Skills/Prompts 权重拷入 workspace 本地,
训练产物只进 workspace 不污染 Store。
VersionedSkillStore / VersionedPromptStore 实现 core/evolution/protocols.py
中定义的只读端口,供 core/ 层以 Protocol 方式读取技能和提示词。
"""
from __future__ import annotations
import json
import os
import shutil
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from loguru import logger
from app.harness.store import read_seed
if TYPE_CHECKING:
from pathlib import Path
@dataclass(frozen=True)
class ResolvedPaths:
"""manifest 解析后的绝对路径集合。
属性:
store_dir: Store 根目录绝对路径。
videos_dir: 视频数据目录。
questions_dir: 当前引用的题目目录。
skills_dir: 当前引用的 Skill 版本目录(workspace 内)。
prompts_dir: 当前引用的 Prompt 版本目录(workspace 内)。
workspace_dir: Workspace 根目录。
db_path: harness.db 路径。
analyses_dir: 分析报告目录。
runs_dir: 运行临时状态目录。
"""
store_dir: Path
videos_dir: Path
questions_dir: Path
skills_dir: Path
prompts_dir: Path
workspace_dir: Path
db_path: Path
analyses_dir: Path
runs_dir: Path
# ---------------------------------------------------------------------------
# 内部工具
# ---------------------------------------------------------------------------
_MANIFEST_CURRENT_KEYS = {"videos", "questions", "skills", "prompts"}
def _now_iso() -> str:
"""返回当前 UTC 时间的 ISO 格式字符串。"""
return datetime.now(UTC).isoformat()
# ---------------------------------------------------------------------------
# Workspace 核心函数
# ---------------------------------------------------------------------------
def _scaffold_workspace(
workspace_dir: Path,
store_dir: Path,
questions: str,
skills_version: str,
prompts_version: str,
) -> None:
"""写 manifest + 建 analyses/runs 目录(不拷权重;权重由调用方按来源拷入)。
参数:
workspace_dir: 目标 workspace(由调用方保证不存在)。
store_dir: Store 根目录。
questions: 题目相对路径,如 ``'benchmarks/Video-MME'``。
skills_version: manifest.current.skills 初始版本号。
prompts_version: manifest.current.prompts 初始版本号。
关键实现:
不依赖任何外部资源源(store 中的 skills/prompts 是否存在不在此校验),
因此可被 init_workspace 与种子初始化复用;store 引用以相对路径写入 manifest。
"""
workspace_dir.mkdir(parents=True)
(workspace_dir / "analyses").mkdir()
(workspace_dir / "runs").mkdir()
store_abs = store_dir.resolve()
store_rel = os.path.relpath(store_abs, workspace_dir.resolve())
manifest = {
"name": workspace_dir.name,
"created_at": _now_iso(),
"store": store_rel,
"current": {
"videos": "videos",
"questions": f"questions/{questions}",
"skills": f"skills/{skills_version}",
"prompts": f"prompts/{prompts_version}",
},
"history": [],
}
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
def init_workspace(
workspace_dir: Path,
store_dir: Path,
questions: str,
skills_version: str,
prompts_version: str,
) -> None:
"""创建 Workspace 目录并写入初始 manifest.json,拷贝种子权重。
参数:
workspace_dir: Workspace 目标路径(不得已存在)。
store_dir: Store 根目录。
questions: 题目在 questions/ 下的相对路径,如 ``"benchmarks/Video-MME"``。
skills_version: Skills 版本号,如 ``"v1"``。
prompts_version: Prompts 版本号,如 ``"v1"``。
异常:
FileExistsError: Workspace 目录已存在。
FileNotFoundError: 引用的资源在 Store 中不存在。
"""
if workspace_dir.exists():
raise FileExistsError(f"Workspace 已存在: {workspace_dir}")
store_abs = store_dir.resolve()
refs = {
"skills": f"skills/{skills_version}",
"prompts": f"prompts/{prompts_version}",
"questions": f"questions/{questions}",
}
for label, rel in refs.items():
full = store_abs / rel
if not full.is_dir():
raise FileNotFoundError(f"Store 中不存在 {label}: {full}")
_scaffold_workspace(workspace_dir, store_dir, questions, skills_version, prompts_version)
# 拷种子权重进 workspacev2+ 训练产物只进 workspace,不污染 store
shutil.copytree(store_abs / refs["skills"], workspace_dir / refs["skills"])
shutil.copytree(store_abs / refs["prompts"], workspace_dir / refs["prompts"])
logger.info("Workspace 初始化完成: {}", workspace_dir)
def init_workspace_from_seed(
workspace_dir: Path,
store_dir: Path,
seed_name: str,
questions: str,
) -> str:
"""从种子全新建 workspace:拷权重 -> v1、baseline.db -> harness.db、读 baseline_run_id。
参数:
workspace_dir: 目标 workspace(不得已存在)。
store_dir: Store 根目录。
seed_name: 种子名(store/seeds 下)。
questions: 题目相对路径,如 ``'benchmarks/Video-MME'``。
返回:
baseline_run_id(供 build_pools 使用)。
异常:
FileExistsError: workspace 已存在。
FileNotFoundError: 种子不存在(由 read_seed 抛出),或 questions ref 目录不存在。
关键实现:
破坏性/创建操作前先校验 questions ref 存在:fresh 路径在 runner 侧已先
归档旧 ws,若到 build_pools 才发现 questions 缺失则旧 ws 已被毁;
故在此尽早报错(fail-fast),让新 ws 在创建前失败。
"""
if workspace_dir.exists():
raise FileExistsError(f"Workspace 已存在: {workspace_dir}")
# 校验种子存在 + 取 baseline_run_id
meta = read_seed(store_dir, seed_name)
# fail-fast:校验 questions ref 存在
questions_ref = store_dir / "questions" / questions
if not questions_ref.is_dir():
raise FileNotFoundError(f"questions ref 目录不存在: {questions_ref}")
seed_dir = store_dir / "seeds" / seed_name
_scaffold_workspace(workspace_dir, store_dir, questions, "v1", "v1")
shutil.copytree(seed_dir / "skills", workspace_dir / "skills" / "v1")
shutil.copytree(seed_dir / "prompts", workspace_dir / "prompts" / "v1")
shutil.copy2(seed_dir / "baseline.db", workspace_dir / "harness.db")
logger.info("Workspace 从种子 '{}' 初始化完成: {}", seed_name, workspace_dir)
return meta["baseline_run_id"]
def load_manifest(workspace_dir: Path) -> dict:
"""读取并返回 workspace 的 manifest.json。
参数:
workspace_dir: Workspace 根目录。
返回:
manifest 字典。
异常:
FileNotFoundError: manifest.json 不存在。
"""
manifest_path = workspace_dir / "manifest.json"
if not manifest_path.exists():
raise FileNotFoundError(f"manifest.json 不存在: {manifest_path}")
return json.loads(manifest_path.read_text())
def resolve_paths(workspace_dir: Path) -> ResolvedPaths:
"""读取 manifest,解析 current 中所有资源的绝对路径。
skills_dir/prompts_dir 解析到 workspace(非 store),
videos_dir/questions_dir 解析到 store。
参数:
workspace_dir: Workspace 根目录。
返回:
ResolvedPaths 实例,包含所有资源的绝对路径。
"""
manifest = load_manifest(workspace_dir)
ws_abs = workspace_dir.resolve()
store_abs = (ws_abs / manifest["store"]).resolve()
current = manifest["current"]
return ResolvedPaths(
store_dir=store_abs,
videos_dir=store_abs / current["videos"],
questions_dir=store_abs / current["questions"],
skills_dir=ws_abs / current["skills"],
prompts_dir=ws_abs / current["prompts"],
workspace_dir=ws_abs,
db_path=ws_abs / "harness.db",
analyses_dir=ws_abs / "analyses",
runs_dir=ws_abs / "runs",
)
def list_video_ids(workspace_dir: Path) -> list[str]:
"""列出 workspace 引用的所有视频 ID(含 tree.json 的子目录名)。
参数:
workspace_dir: Workspace 根目录。
返回:
排序后的视频 ID 列表。
"""
paths = resolve_paths(workspace_dir)
video_ids = []
for entry in paths.videos_dir.iterdir():
if entry.is_dir() and (entry / "tree.json").exists():
video_ids.append(entry.name)
return sorted(video_ids)
def update_manifest(workspace_dir: Path, **version_updates: str) -> None:
"""更新 manifest 的 current 字段。
参数:
workspace_dir: Workspace 根目录。
**version_updates: 要更新的字段及其新值,如 ``skills="skills/v2"``。
异常:
KeyError: 更新的字段不在 current 允许的 key 白名单中。
"""
invalid = set(version_updates) - _MANIFEST_CURRENT_KEYS
if invalid:
raise KeyError(f"无效的 manifest current 字段: {invalid}")
manifest = load_manifest(workspace_dir)
manifest["current"].update(version_updates)
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
def record_run(workspace_dir: Path, run_id: str) -> Path:
"""将 current 版本快照追加到 manifest history,创建 run 目录和 per-video wiki 目录。
幂等:同 run_id 不重复追加 history(长跑中断后重启 / held-out 复用 run_id 时)。
参数:
workspace_dir: Workspace 根目录。
run_id: 本次运行的唯一标识,如 ``"run_001"``。
返回:
创建的 run 目录路径。
"""
manifest = load_manifest(workspace_dir)
current = manifest["current"]
# 幂等:同 run_id 不重复追加 history
if not any(h["run_id"] == run_id for h in manifest["history"]):
manifest["history"].append(
{
"run_id": run_id,
"started_at": _now_iso(),
"skills": current["skills"],
"prompts": current["prompts"],
"questions": current["questions"],
}
)
(workspace_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2)
)
run_dir = workspace_dir / "runs" / run_id
# exist_ok:同 run_id 重跑时 run 目录已存在不应崩溃
run_dir.mkdir(parents=True, exist_ok=True)
for video_id in list_video_ids(workspace_dir):
(run_dir / video_id / "wiki").mkdir(parents=True, exist_ok=True)
logger.debug("Run 已记录: {}", run_id)
return run_dir
def read_best(workspace_dir: Path) -> dict | None:
"""读取 manifest 的 best 指针,未设置时返回 None。
参数:
workspace_dir: Workspace 根目录。
返回:
best 字典(skills/prompts/val_acc/run_id/epoch),未设置时 None。
"""
return load_manifest(workspace_dir).get("best")
def update_best(
workspace_dir: Path,
skills: str,
prompts: str,
val_acc: float,
run_id: str,
epoch: int,
) -> None:
"""写入 manifest 的 best 指针(历史最优版本快照,与 current 平级)。
best 独立于 current——更新 best 不影响 current。
参数:
workspace_dir: Workspace 根目录。
skills: 最优 skills 版本完整 ref,如 ``'skills/v2'``。
prompts: 最优 prompts 版本完整 ref,如 ``'prompts/v2'``。
val_acc: 该版本验证集准确率。
run_id: 该版本验证 run_id。
epoch: 达成该最优的轮次。
"""
manifest = load_manifest(workspace_dir)
manifest["best"] = {
"skills": skills,
"prompts": prompts,
"val_acc": val_acc,
"run_id": run_id,
"epoch": epoch,
}
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
logger.info("Best 已更新: val_acc={}, run={}, epoch={}", val_acc, run_id, epoch)
def archive_workspace(workspace_dir: Path) -> Path:
"""把 workspace 整体移动到同级 .archive/<name>-<ts>,返回归档路径。
参数:
workspace_dir: 要归档的 Workspace 根目录。
返回:
归档后的目标路径。
异常:
FileNotFoundError: workspace 不存在。
"""
if not workspace_dir.exists():
raise FileNotFoundError(f"workspace 不存在: {workspace_dir}")
archive_root = workspace_dir.parent / ".archive"
archive_root.mkdir(exist_ok=True)
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
target = archive_root / f"{workspace_dir.name}-{ts}"
shutil.move(str(workspace_dir), str(target))
logger.info("Workspace 已归档: {} -> {}", workspace_dir, target)
return target
# ---------------------------------------------------------------------------
# Protocol 实现:VersionedSkillStore / VersionedPromptStore
# ---------------------------------------------------------------------------
class VersionedSkillStore:
"""版本化技能读取端口实现。
满足 ``core/evolution/protocols.py::SkillStore`` Protocol。
从指定的 skills 版本目录读取 ``.md`` 文件。
参数:
skills_dir: skills 版本目录绝对路径(如 ``workspace/skills/v1``)。
"""
def __init__(self, skills_dir: Path) -> None:
if not skills_dir.is_dir():
raise FileNotFoundError(f"Skills 目录不存在: {skills_dir}")
self._dir = skills_dir
def read_skill(self, filename: str) -> str:
"""读取指定 skill 文件的全文内容。
参数:
filename: skill 文件名,如 ``'temporal-reasoning.md'``。
返回:
文件全文内容。
异常:
FileNotFoundError: 文件不存在。
"""
path = self._dir / filename
if not path.exists():
raise FileNotFoundError(f"Skill 文件不存在: {path}")
return path.read_text()
def list_skill_files(self) -> list[str]:
"""列出当前版本所有 skill 文件名。
返回:
文件名列表(排序)。
"""
return sorted(entry.name for entry in self._dir.iterdir() if entry.is_file())
class VersionedPromptStore:
"""版本化提示词读取端口实现。
满足 ``core/evolution/protocols.py::PromptStore`` Protocol。
从指定的 prompts 版本目录读取 ``.md`` 文件。
参数:
prompts_dir: prompts 版本目录绝对路径(如 ``workspace/prompts/v1``)。
"""
def __init__(self, prompts_dir: Path) -> None:
if not prompts_dir.is_dir():
raise FileNotFoundError(f"Prompts 目录不存在: {prompts_dir}")
self._dir = prompts_dir
def read_prompt(self, filename: str) -> str:
"""读取指定 prompt 文件的全文内容。
参数:
filename: prompt 文件名,如 ``'system.md'``。
返回:
文件全文内容。
异常:
FileNotFoundError: 文件不存在。
"""
path = self._dir / filename
if not path.exists():
raise FileNotFoundError(f"Prompt 文件不存在: {path}")
return path.read_text()
def list_prompt_files(self) -> list[str]:
"""列出当前版本所有 prompt 文件名。
返回:
文件名列表(排序)。
"""
return sorted(entry.name for entry in self._dir.iterdir() if entry.is_file())
+38
View File
@@ -21,9 +21,47 @@ from core.evolution.patch import (
replace_appendix_notes,
replace_momentum,
)
from core.evolution.types import (
CaseSample,
DiagnosePrompts,
DiagnosisResult,
ErrorAttribution,
EvolutionRecord,
EvolutionResult,
EvolvePrompts,
GateParams,
GateVerdict,
PairResult,
QuadrantClassification,
QuestionMetrics,
RejectedEdit,
SkillCasePack,
SkillStepAdherence,
SpanMetrics,
SystemCasePack,
ToolCasePack,
)
from core.evolution.validate import classify_quadrants, compute_accuracy, pair_block
__all__ = [
"CaseSample",
"DiagnosePrompts",
"DiagnosisResult",
"ErrorAttribution",
"EvolutionRecord",
"EvolutionResult",
"EvolvePrompts",
"GateParams",
"GateVerdict",
"PairResult",
"QuadrantClassification",
"QuestionMetrics",
"RejectedEdit",
"SkillCasePack",
"SkillStepAdherence",
"SpanMetrics",
"SystemCasePack",
"ToolCasePack",
"append_to_appendix",
"apply_patch_with_report",
"classify_quadrants",
+267
View File
@@ -0,0 +1,267 @@
"""app/harness/batching.py 的单元测试。
覆盖 FFD + round-robin mini-batch 构建的核心场景:
确定性、小类不拆、大类 round-robin、正确率混合、空错题、参数校验、
correctness False vs None 精确匹配。
"""
from __future__ import annotations
import pytest
from core.types import GeneratedQuestion
from app.harness.batching import (
build_batches,
_validate_params,
_select_mixed_by_task_type,
)
import random
# ---------------------------------------------------------------------------
# 辅助构造
# ---------------------------------------------------------------------------
def _make_q(
qid: str,
task_type: str = "default",
video_id: str = "v1",
) -> GeneratedQuestion:
"""构造最小 GeneratedQuestion 用于测试。"""
return GeneratedQuestion(
question_id=qid,
video_id=video_id,
task_type=task_type,
question=f"question_{qid}",
options=("A. a", "B. b", "C. c", "D. d"),
answer="A",
source_nodes=("n1",),
difficulty="medium",
)
# ---------------------------------------------------------------------------
# test_build_batches_deterministic
# ---------------------------------------------------------------------------
class TestBuildBatchesDeterministic:
"""相同输入 + 相同 seed 产出完全一致的切分。"""
def test_same_seed_same_result(self) -> None:
items = [_make_q(f"q{i}", task_type=f"type_{i % 3}") for i in range(20)]
correctness = {f"q{i}": False for i in range(20)}
r1 = build_batches(items, correctness, batch_size=5, min_class_per_batch=2, seed=42)
r2 = build_batches(items, correctness, batch_size=5, min_class_per_batch=2, seed=42)
assert r1 == r2
def test_different_seed_may_differ(self) -> None:
"""不同 seed 结果应不同(极大概率,用大量样本保证)。"""
items = [_make_q(f"q{i}", task_type=f"type_{i % 5}") for i in range(50)]
correctness = {f"q{i}": False for i in range(50)}
r1 = build_batches(items, correctness, batch_size=10, min_class_per_batch=3, seed=1)
r2 = build_batches(items, correctness, batch_size=10, min_class_per_batch=3, seed=99)
# 至少 batch 内容不同(不比较结构,只比较 selected_count 一致性)
assert r1[1] == r2[1] # 总题数一致
# 但 batch 内部排列几乎必然不同
flat1 = [q.question_id for b in r1[0] for q in b]
flat2 = [q.question_id for b in r2[0] for q in b]
assert flat1 != flat2
# ---------------------------------------------------------------------------
# test_small_class_not_split
# ---------------------------------------------------------------------------
class TestSmallClassNotSplit:
"""小类(≤ min_class_per_batch)整组不拆,锁在同一 batch。"""
def test_small_group_stays_together(self) -> None:
# 2 道题属于 small_type(≤ min_class=3),应在同一 batch
items = [
_make_q("s1", task_type="small_type"),
_make_q("s2", task_type="small_type"),
# 大类 8 道题
*[_make_q(f"big{i}", task_type="big_type") for i in range(8)],
]
correctness = {q.question_id: False for q in items}
batches, count = build_batches(
items, correctness, batch_size=5, min_class_per_batch=3, seed=0
)
assert count == 10
# 找到包含 small_type 的 batch
small_batch = [
b for b in batches
if any(q.task_type == "small_type" for q in b)
]
assert len(small_batch) == 1 # 整组在同一个 batch
small_ids = {q.question_id for q in small_batch[0] if q.task_type == "small_type"}
assert small_ids == {"s1", "s2"}
# ---------------------------------------------------------------------------
# test_large_class_round_robin
# ---------------------------------------------------------------------------
class TestLargeClassRoundRobin:
"""大类样本 round-robin 散布到多个 batch,不集中于单一 batch。"""
def test_large_group_distributed(self) -> None:
# 12 道大类题,batch_size=4min_class=2 → 大类 > 2 → round-robin
items = [_make_q(f"q{i}", task_type="large_type") for i in range(12)]
correctness = {q.question_id: False for q in items}
batches, count = build_batches(
items, correctness, batch_size=4, min_class_per_batch=2, seed=7
)
assert count == 12
assert len(batches) >= 3 # ceil(12/4) = 3
# 每个 batch 不超过 batch_size
for b in batches:
assert len(b) <= 4
# ---------------------------------------------------------------------------
# test_correct_ratio_mixing
# ---------------------------------------------------------------------------
class TestCorrectRatioMixing:
"""correct_ratio > 0 时混入正确题。"""
def test_mixed_includes_correct(self) -> None:
items = [
_make_q("e1", task_type="t1"),
_make_q("e2", task_type="t1"),
_make_q("c1", task_type="t1"),
_make_q("c2", task_type="t1"),
_make_q("c3", task_type="t1"),
]
correctness = {"e1": False, "e2": False, "c1": True, "c2": True, "c3": True}
batches, count = build_batches(
items, correctness, batch_size=10, min_class_per_batch=2, seed=0,
correct_ratio=0.5,
)
# correct_ratio=0.5 → 错:正 = 1:1 → 2 错 + 2 正 = 4 题
assert count == 4
all_ids = {q.question_id for b in batches for q in b}
assert {"e1", "e2"}.issubset(all_ids) # 错题全部
correct_in = all_ids - {"e1", "e2"}
assert len(correct_in) == 2 # 采样 2 个正确题
assert correct_in.issubset({"c1", "c2", "c3"})
def test_ratio_zero_pure_errors(self) -> None:
items = [
_make_q("e1", task_type="t1"),
_make_q("c1", task_type="t1"),
]
correctness = {"e1": False, "c1": True}
batches, count = build_batches(
items, correctness, batch_size=10, min_class_per_batch=2, seed=0,
correct_ratio=0.0,
)
assert count == 1
assert batches[0][0].question_id == "e1"
# ---------------------------------------------------------------------------
# test_no_wrong_answers_empty
# ---------------------------------------------------------------------------
class TestNoWrongAnswersEmpty:
"""无错题时返回空列表。"""
def test_all_correct_returns_empty(self) -> None:
items = [_make_q(f"q{i}") for i in range(5)]
correctness = {f"q{i}": True for i in range(5)}
batches, count = build_batches(
items, correctness, batch_size=3, min_class_per_batch=1, seed=0,
)
assert batches == []
assert count == 0
def test_empty_items_returns_empty(self) -> None:
batches, count = build_batches(
[], {}, batch_size=3, min_class_per_batch=1, seed=0,
)
assert batches == []
assert count == 0
# ---------------------------------------------------------------------------
# test_validate_params_strict
# ---------------------------------------------------------------------------
class TestValidateParamsStrict:
"""参数校验:batch_size < 1、min_class < 1、min_class >= batch_size 都报错。"""
def test_batch_size_zero(self) -> None:
with pytest.raises(ValueError, match="batch_size 必须 >= 1"):
_validate_params(0, 1)
def test_batch_size_negative(self) -> None:
with pytest.raises(ValueError, match="batch_size 必须 >= 1"):
_validate_params(-1, 1)
def test_min_class_zero(self) -> None:
with pytest.raises(ValueError, match="min_class_per_batch 必须 >= 1"):
_validate_params(5, 0)
def test_min_class_equals_batch_size(self) -> None:
with pytest.raises(ValueError, match="min_class_per_batch 必须严格 < batch_size"):
_validate_params(5, 5)
def test_min_class_exceeds_batch_size(self) -> None:
with pytest.raises(ValueError, match="min_class_per_batch 必须严格 < batch_size"):
_validate_params(3, 5)
def test_valid_params_no_error(self) -> None:
_validate_params(5, 3) # 不抛异常
# ---------------------------------------------------------------------------
# test_correctness_false_vs_none
# ---------------------------------------------------------------------------
class TestCorrectnessFalseVsNone:
"""correctness.get(qid) is False 精确匹配:None(未知题)不算错题。"""
def test_none_excluded_from_errors(self) -> None:
items = [
_make_q("wrong", task_type="t1"),
_make_q("right", task_type="t1"),
_make_q("unknown", task_type="t1"),
]
# wrong=False(错题),right=True(正确题),unknown 不在 correctnessNone
correctness: dict[str, bool] = {"wrong": False, "right": True}
batches, count = build_batches(
items, correctness, batch_size=10, min_class_per_batch=2, seed=0,
correct_ratio=0.0,
)
# 仅 wrong 进入 batchunknown 不算错题
assert count == 1
assert batches[0][0].question_id == "wrong"
def test_explicit_false_only(self) -> None:
"""直接测试 _select_mixed_by_task_type 内部逻辑。"""
items = [
_make_q("f1", task_type="t1"),
_make_q("n1", task_type="t1"), # None(未知)
_make_q("t1", task_type="t1"), # True(正确)
]
correctness: dict[str, bool] = {"f1": False, "t1": True}
rng = random.Random(0)
result = _select_mixed_by_task_type(items, correctness, 0.0, rng)
assert "t1" in result
assert len(result["t1"]) == 1
assert result["t1"][0].question_id == "f1"
def test_none_not_treated_as_correct(self) -> None:
"""None(未知)不进正确组,不被 correct_ratio 采样。"""
items = [
_make_q("err", task_type="t1"),
_make_q("unk", task_type="t1"),
]
correctness: dict[str, bool] = {"err": False}
rng = random.Random(0)
result = _select_mixed_by_task_type(items, correctness, 0.5, rng)
# 只有 err 一题错题,unk 不在 correctness 中 → get 返回 None → 不进 correct 组
assert len(result["t1"]) == 1 # 只有错题,无正确题可混入
+413
View File
@@ -0,0 +1,413 @@
"""app/harness/checkpoint.py 单元测试。
覆盖序列化/反序列化往返、嵌套 dataclass 复活、缺键硬失败、
配置指纹结构性 vs 决策性判定、原子写与 load 缺失场景。
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from pathlib import Path
from app.harness.checkpoint import (
CHECKPOINT_SCHEMA_VERSION,
Probation,
check_fingerprint,
compute_fingerprint,
deserialize_state_fields,
load_checkpoint,
serialize_state,
write_checkpoint,
)
from core.evolution.types import (
CaseSample,
RejectedEdit,
SystemCasePack,
ToolCasePack,
)
# ---------------------------------------------------------------------------
# fixtures: 模拟 _TrainState 与 RunConfig
# ---------------------------------------------------------------------------
def _make_case_sample(**overrides: Any) -> CaseSample:
"""构造一个最小可用 CaseSample。"""
defaults: dict[str, Any] = {
"question_id": "q001",
"video_id": "v001",
"task_type": "temporal",
"question": "What happened?",
"options": ["A", "B", "C"],
"answer": "A",
"prediction": "B",
"correct": False,
"error_type": "reasoning",
"selection_reason": "worst",
"metrics": {"acc": 0.5},
"trace": [{"step": 1, "action": "search"}],
}
defaults.update(overrides)
return CaseSample(**defaults)
def _make_rejected_edit(**overrides: Any) -> RejectedEdit:
"""构造一个最小可用 RejectedEdit。"""
defaults: dict[str, Any] = {
"target_file": "temporal-reasoning.md",
"target_type": "skill",
"change_summary": "added step",
"delta": -0.05,
"source_version": "v2",
"epoch": 1,
"gate_w": 3,
"gate_l": 5,
"gate_e_value": 0.8,
"gate_delta_shrunk": -0.02,
}
defaults.update(overrides)
return RejectedEdit(**defaults)
def _make_system_pack() -> SystemCasePack:
"""构造包含嵌套 CaseSample 的 SystemCasePack。"""
return SystemCasePack(
stats={"pattern": "repeat_visit", "count": 3},
failure_cases=[_make_case_sample(question_id="q010")],
success_cases=[_make_case_sample(question_id="q011", correct=True, error_type=None)],
)
def _make_tool_pack() -> ToolCasePack:
"""构造 ToolCasePack。"""
return ToolCasePack(
tool_name="search_subtree",
target_files=["search_subtree_extract.md"],
stats={"completeness": 0.8},
failure_spans=[{"step": 2, "issue": "missing"}],
success_spans=[{"step": 3, "quality": "good"}],
)
def _make_probation() -> Probation:
"""构造包含嵌套 RejectedEdit 的 Probation。"""
return Probation(
task_type="temporal",
anchor_skills_version="v1",
target_file="temporal-reasoning.md",
correctness_snapshot={"q001": True, "q002": False},
opened_step=5,
pending_edits=[_make_rejected_edit()],
)
@dataclass
class _FakeState:
"""模拟 _TrainState 全部可持久化字段。"""
correctness: dict[str, bool]
eval_prev_acc: float
eval_prev_run_id: str
baseline_skills_version: str
baseline_prompts_version: str
steps_since_best_improved: int
epoch_start_skills: str
changed_task_types_this_epoch: set[str]
rejected_buffer: dict[str, list[RejectedEdit]]
system_packs: list[SystemCasePack]
tool_packs: list[ToolCasePack]
probations: dict[str, Probation]
gate_cooldown: dict[str, int]
gate_epoch_observed: dict[str, bool]
def _make_state() -> _FakeState:
"""构造一个填满全部字段的 _FakeState。"""
return _FakeState(
correctness={"q001": True, "q002": False},
eval_prev_acc=0.65,
eval_prev_run_id="run-abc",
baseline_skills_version="v1",
baseline_prompts_version="v1",
steps_since_best_improved=2,
epoch_start_skills="v1",
changed_task_types_this_epoch={"temporal", "causal"},
rejected_buffer={"temporal": [_make_rejected_edit()]},
system_packs=[_make_system_pack()],
tool_packs=[_make_tool_pack()],
probations={"temporal": _make_probation()},
gate_cooldown={"temporal": 3},
gate_epoch_observed={"temporal": True},
)
@dataclass(frozen=True)
class _FakeConfig:
"""模拟 RunConfig 的指纹相关字段。"""
batch_size: int = 8
min_class_per_batch: int = 2
epochs: int = 5
diag_size: int = 30
val_size: int = 50
batch_correct_ratio: float = 0.5
edit_budget_start: int = 6
edit_budget_end: int = 3
early_stop_patience: int = 3
use_slow_momentum: bool = True
skill_update_mode: str = "patch"
appendix_consolidate_threshold: int = 10
momentum_samples: int = 20
gate_e_confirm: float = 20.0
gate_e_provisional: float = 6.0
gate_w_net_min: int = 2
gate_delta_min: float = 0.02
gate_lambda_dir: float = -3.0
gate_e_rollback: float = 10.0
gate_block: int = 4
gate_n_max: int = 40
gate_p_low: float = 0.1
gate_p_high: float = 0.9
gate_probe_quota: float = 0.2
gate_gamma_decay: float = 0.9
gate_cooldown_steps: int = 2
gate_guard_err: float = 0.3
# =========================================================================
# 测试用例
# =========================================================================
class TestSerializeDeserializeRoundtrip:
"""序列化 → JSON 往返 → 反序列化应完全复原。"""
def test_serialize_deserialize_roundtrip(self) -> None:
state = _make_state()
serialized = serialize_state(state)
# JSON 往返(模拟实际落盘-读回)
json_str = json.dumps(serialized, ensure_ascii=False)
loaded = json.loads(json_str)
restored = deserialize_state_fields(loaded)
assert restored["correctness"] == state.correctness
assert restored["eval_prev_acc"] == state.eval_prev_acc
assert restored["eval_prev_run_id"] == state.eval_prev_run_id
assert restored["baseline_skills_version"] == state.baseline_skills_version
assert restored["baseline_prompts_version"] == state.baseline_prompts_version
assert restored["steps_since_best_improved"] == state.steps_since_best_improved
assert restored["epoch_start_skills"] == state.epoch_start_skills
assert restored["changed_task_types_this_epoch"] == state.changed_task_types_this_epoch
assert restored["gate_cooldown"] == state.gate_cooldown
assert restored["gate_epoch_observed"] == state.gate_epoch_observed
class TestSerializeSetToSortedList:
"""set 字段序列化为排序列表。"""
def test_serialize_set_to_sorted_list(self) -> None:
state = _make_state()
state.changed_task_types_this_epoch = {"z_type", "a_type", "m_type"}
serialized = serialize_state(state)
assert serialized["changed_task_types_this_epoch"] == ["a_type", "m_type", "z_type"]
class TestDeserializeNestedSystemPack:
"""SystemCasePack 内嵌套的 CaseSample 正确复活。"""
def test_deserialize_nested_system_pack(self) -> None:
state = _make_state()
serialized = serialize_state(state)
json_str = json.dumps(serialized, ensure_ascii=False)
loaded = json.loads(json_str)
restored = deserialize_state_fields(loaded)
packs = restored["system_packs"]
assert len(packs) == 1
pack = packs[0]
assert isinstance(pack, SystemCasePack)
assert len(pack.failure_cases) == 1
assert isinstance(pack.failure_cases[0], CaseSample)
assert pack.failure_cases[0].question_id == "q010"
assert len(pack.success_cases) == 1
assert isinstance(pack.success_cases[0], CaseSample)
assert pack.success_cases[0].question_id == "q011"
class TestDeserializeNestedProbation:
"""Probation 内嵌套的 RejectedEdit 正确复活。"""
def test_deserialize_nested_probation(self) -> None:
state = _make_state()
serialized = serialize_state(state)
json_str = json.dumps(serialized, ensure_ascii=False)
loaded = json.loads(json_str)
restored = deserialize_state_fields(loaded)
probations = restored["probations"]
assert "temporal" in probations
prob = probations["temporal"]
assert isinstance(prob, Probation)
assert prob.task_type == "temporal"
assert prob.anchor_skills_version == "v1"
assert prob.correctness_snapshot == {"q001": True, "q002": False}
assert len(prob.pending_edits) == 1
edit = prob.pending_edits[0]
assert isinstance(edit, RejectedEdit)
assert edit.target_file == "temporal-reasoning.md"
assert edit.delta == -0.05
class TestDeserializeMissingKeyRaises:
"""缺键即 checkpoint 损坏,应硬失败。"""
def test_deserialize_missing_key_raises(self) -> None:
state = _make_state()
serialized = serialize_state(state)
del serialized["gate_epoch_observed"]
with pytest.raises(KeyError):
deserialize_state_fields(serialized)
class TestFingerprintStructuralVsDecision:
"""compute_fingerprint 包含全部结构性 + 决策性键。"""
def test_fingerprint_structural_vs_decision(self) -> None:
config = _FakeConfig()
fp = compute_fingerprint(config)
structural = {
"batch_size",
"min_class_per_batch",
"epochs",
"diag_size",
"val_size",
"batch_correct_ratio",
}
decision = {
"edit_budget_start",
"edit_budget_end",
"early_stop_patience",
"use_slow_momentum",
"skill_update_mode",
"appendix_consolidate_threshold",
"momentum_samples",
"gate_e_confirm",
"gate_e_provisional",
"gate_w_net_min",
"gate_delta_min",
"gate_lambda_dir",
"gate_e_rollback",
"gate_block",
"gate_n_max",
"gate_p_low",
"gate_p_high",
"gate_probe_quota",
"gate_gamma_decay",
"gate_cooldown_steps",
"gate_guard_err",
}
assert structural | decision == set(fp.keys())
assert fp["batch_size"] == 8
assert fp["gate_e_confirm"] == 20.0
class TestCheckFingerprintStructuralReject:
"""结构性键变化应出现在 structural 列表中。"""
def test_check_fingerprint_structural_reject(self) -> None:
config_old = _FakeConfig()
saved = compute_fingerprint(config_old)
# 修改结构性参数
config_new = _FakeConfig(batch_size=16, epochs=10)
structural, decision = check_fingerprint(saved, config_new)
assert "batch_size" in structural
assert "epochs" in structural
assert len(decision) == 0
class TestCheckFingerprintDecisionWarn:
"""决策性键变化应出现在 decision 列表中,structural 为空。"""
def test_check_fingerprint_decision_warn(self) -> None:
config_old = _FakeConfig()
saved = compute_fingerprint(config_old)
config_new = _FakeConfig(early_stop_patience=10, gate_e_confirm=50.0)
structural, decision = check_fingerprint(saved, config_new)
assert len(structural) == 0
assert "early_stop_patience" in decision
assert "gate_e_confirm" in decision
class TestWriteCheckpointAtomic:
"""原子写:先 .tmp 再 os.replace。"""
def test_write_checkpoint_atomic(self, tmp_path: Path) -> None:
state = _make_state()
config = _FakeConfig()
write_checkpoint(
tmp_path,
state=state,
epoch=2,
step_completed=5,
phase="in_epoch",
global_step=15,
total_steps=40,
version_snapshot={"skills": "v3", "prompts": "v2"},
epoch_batches=[["q001", "q002"], ["q003"]],
config=config,
)
ckpt_path = tmp_path / "checkpoint.json"
assert ckpt_path.exists()
# .tmp 应已被 os.replace 移除
assert not (tmp_path / "checkpoint.json.tmp").exists()
payload = json.loads(ckpt_path.read_text())
assert payload["schema_version"] == CHECKPOINT_SCHEMA_VERSION
assert payload["progress"]["epoch"] == 2
assert payload["progress"]["step_completed"] == 5
assert payload["progress"]["phase"] == "in_epoch"
assert payload["progress"]["global_step"] == 15
assert payload["progress"]["total_steps"] == 40
assert payload["version_snapshot"] == {"skills": "v3", "prompts": "v2"}
assert payload["epoch_batches"] == [["q001", "q002"], ["q003"]]
assert "config_fingerprint" in payload
assert "state" in payload
class TestLoadCheckpointMissing:
"""checkpoint.json 不存在时返回 None。"""
def test_load_checkpoint_missing(self, tmp_path: Path) -> None:
result = load_checkpoint(tmp_path)
assert result is None
def test_load_checkpoint_exists(self, tmp_path: Path) -> None:
"""checkpoint.json 存在时正确读回。"""
state = _make_state()
config = _FakeConfig()
write_checkpoint(
tmp_path,
state=state,
epoch=1,
step_completed=3,
phase="post_evolve",
global_step=8,
total_steps=20,
version_snapshot={"skills": "v2", "prompts": "v1"},
epoch_batches=[["q001"]],
config=config,
)
loaded = load_checkpoint(tmp_path)
assert loaded is not None
assert loaded["schema_version"] == CHECKPOINT_SCHEMA_VERSION
assert loaded["progress"]["epoch"] == 1
# 完整往返测试:state 可 deserialize
restored = deserialize_state_fields(loaded["state"])
assert restored["eval_prev_acc"] == 0.65
+595
View File
@@ -0,0 +1,595 @@
"""app/harness/config.py 单元测试。
覆盖 RunConfig 构造、四层校验(_validate → 三个子校验)、
YAML + CLI + .env 三层加载优先级。
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from app.harness.config import RunConfig, _validate, load_config
# ────────────────────────────── 测试数据工厂 ──────────────────────────────
def _valid_kwargs() -> dict:
"""构造一组完整合法的 RunConfig 字段值(使用真实 default.yaml 数据)。"""
return {
"workspace_dir": Path("workspaces/default"),
"store_dir": Path("store"),
"mode": "infer",
"run_id": "",
"concurrency": 12,
"max_steps": 15,
"skill_mode": "auto",
"n_samples": 0,
"questions": "benchmarks/Video-MME",
"skills_version": "v1",
"prompts_version": "v1",
"epochs": 1,
"diag_size": 200,
"diag_correct_ratio": 0.5,
"val_size": 30,
"val_correct_ratio": 0.5,
"edit_budget_start": 5,
"edit_budget_end": 2,
"batch_size": 15,
"min_class_per_batch": 2,
"eval_min_per_class": 2,
"early_stop_patience": 8,
"test_size": 60,
"use_slow_momentum": True,
"gate_e_confirm": 20.0,
"gate_e_provisional": 3.0,
"gate_w_net_min": 2,
"gate_delta_min": 0.02,
"gate_lambda_dir": -0.642,
"gate_e_rollback": 10.0,
"gate_block": 8,
"gate_n_max": 40,
"gate_p_low": 0.05,
"gate_p_high": 0.95,
"gate_probe_quota": 0.2,
"gate_gamma_decay": 0.9,
"gate_cooldown_steps": 2,
"gate_guard_err": 0.10,
"skill_update_mode": "patch",
"appendix_consolidate_threshold": 6,
"batch_correct_ratio": 0.5,
"momentum_samples": 20,
}
def _make_config(**overrides: object) -> RunConfig:
"""用 _valid_kwargs 构造 RunConfig,支持字段覆盖。"""
kwargs = _valid_kwargs()
kwargs.update(overrides)
return RunConfig(**kwargs)
def _yaml_harness_dict() -> dict:
"""构造可序列化为 YAML 的合法 harness 配置字典(路径用字符串)。"""
d = _valid_kwargs()
d["workspace_dir"] = str(d["workspace_dir"])
d["store_dir"] = str(d["store_dir"])
return d
def _write_yaml(tmp_path: Path, harness_data: dict) -> Path:
"""将 harness 配置写入临时 YAML 文件,返回文件路径。"""
yaml_path = tmp_path / "experiment.yaml"
with open(yaml_path, "w", encoding="utf-8") as f:
yaml.dump({"harness": harness_data}, f)
return yaml_path
# ──────────────────────────── test_valid_config ────────────────────────────
class TestValidConfig:
"""合法参数应能正常构造并通过校验。"""
def test_default_yaml_values_pass_validation(self) -> None:
"""使用 default.yaml 真实默认值构造的 RunConfig 应通过全部校验。"""
cfg = _make_config()
_validate(cfg)
assert cfg.mode == "infer"
assert cfg.workspace_dir == Path("workspaces/default")
assert cfg.store_dir == Path("store")
def test_frozen_immutability(self) -> None:
"""frozen=True 应禁止字段赋值。"""
cfg = _make_config()
with pytest.raises(AttributeError):
cfg.mode = "train" # type: ignore[misc]
def test_default_field_values(self) -> None:
"""有默认值的字段不传时应使用默认值。"""
kwargs = _valid_kwargs()
# 不传 run_id / seed / version / resume / fresh,使用默认值
kwargs.pop("run_id", None)
cfg = RunConfig(**kwargs)
assert cfg.run_id == ""
assert cfg.seed == "initial"
assert cfg.version == ""
assert cfg.resume is False
assert cfg.fresh is False
# ──────────────────────────── test_mode_validation ────────────────────────
class TestModeValidation:
"""mode 字段校验。"""
@pytest.mark.parametrize("bad_mode", ["unknown", "test", "", "INFER", "Train"])
def test_invalid_mode_rejected(self, bad_mode: str) -> None:
"""非法 mode 应抛出 ValueError。"""
cfg = _make_config(mode=bad_mode)
with pytest.raises(ValueError, match="mode"):
_validate(cfg)
@pytest.mark.parametrize(
"valid_mode", ["infer", "train", "diagnose", "evolve", "eval", "promote"]
)
def test_all_valid_modes_accepted(self, valid_mode: str) -> None:
"""全部合法 mode 应通过校验(diagnose/evolve 需 run_id)。"""
overrides: dict = {"mode": valid_mode}
if valid_mode in ("diagnose", "evolve", "train"):
overrides["run_id"] = "run-001"
if valid_mode in ("eval", "promote"):
overrides["version"] = "v1"
if valid_mode == "promote":
overrides["run_id"] = "run-001"
cfg = _make_config(**overrides)
_validate(cfg)
def test_diagnose_requires_run_id(self) -> None:
"""diagnose 模式缺少 run_id 应报错。"""
cfg = _make_config(mode="diagnose", run_id="")
with pytest.raises(ValueError, match="run_id"):
_validate(cfg)
def test_evolve_requires_run_id(self) -> None:
"""evolve 模式缺少 run_id 应报错。"""
cfg = _make_config(mode="evolve", run_id="")
with pytest.raises(ValueError, match="run_id"):
_validate(cfg)
def test_eval_requires_version(self) -> None:
"""eval 模式缺少 version 应报错。"""
cfg = _make_config(mode="eval", version="")
with pytest.raises(ValueError, match="version"):
_validate(cfg)
def test_promote_requires_run_id_and_version(self) -> None:
"""promote 模式需同时提供 run_id 和 version。"""
cfg = _make_config(mode="promote", run_id="", version="v1")
with pytest.raises(ValueError, match="promote.*run-id"):
_validate(cfg)
def test_train_mode_requires_run_id_without_resume_fresh(self) -> None:
"""train 模式非 resume/fresh 时必须提供 run_id。"""
cfg = _make_config(mode="train", run_id="", resume=False, fresh=False)
with pytest.raises(ValueError, match="run_id"):
_validate(cfg)
def test_train_mode_resume_without_run_id_accepted(self) -> None:
"""train 模式 resume=True 时不需要 run_id。"""
cfg = _make_config(mode="train", run_id="", resume=True)
_validate(cfg)
def test_train_mode_fresh_without_run_id_accepted(self) -> None:
"""train 模式 fresh=True 时不需要 run_id。"""
cfg = _make_config(mode="train", run_id="", fresh=True)
_validate(cfg)
def test_train_mode_with_run_id_accepted(self) -> None:
"""train 模式提供 run_id 时应通过(旧式基线 run)。"""
cfg = _make_config(mode="train", run_id="baseline-001")
_validate(cfg)
def test_concurrency_positive(self) -> None:
"""concurrency <= 0 应报错。"""
cfg = _make_config(concurrency=0)
with pytest.raises(ValueError, match="concurrency"):
_validate(cfg)
def test_max_steps_positive(self) -> None:
"""max_steps <= 0 应报错。"""
cfg = _make_config(max_steps=-1)
with pytest.raises(ValueError, match="max_steps"):
_validate(cfg)
def test_n_samples_non_negative(self) -> None:
"""n_samples < 0 应报错。"""
cfg = _make_config(n_samples=-1)
with pytest.raises(ValueError, match="n_samples"):
_validate(cfg)
def test_epochs_positive(self) -> None:
"""epochs <= 0 应报错。"""
cfg = _make_config(epochs=0)
with pytest.raises(ValueError, match="epochs"):
_validate(cfg)
@pytest.mark.parametrize("bad_skill_mode", ["Auto", "disabled", ""])
def test_invalid_skill_mode(self, bad_skill_mode: str) -> None:
"""非法 skill_mode 应报错。"""
cfg = _make_config(skill_mode=bad_skill_mode)
with pytest.raises(ValueError, match="skill_mode"):
_validate(cfg)
@pytest.mark.parametrize("bad_update_mode", ["append", "delete", ""])
def test_invalid_skill_update_mode(self, bad_update_mode: str) -> None:
"""非法 skill_update_mode 应报错。"""
cfg = _make_config(skill_update_mode=bad_update_mode)
with pytest.raises(ValueError, match="skill_update_mode"):
_validate(cfg)
def test_appendix_consolidate_threshold_positive(self) -> None:
"""appendix_consolidate_threshold < 1 应报错。"""
cfg = _make_config(appendix_consolidate_threshold=0)
with pytest.raises(ValueError, match="appendix_consolidate_threshold"):
_validate(cfg)
# ────────────────────── test_edit_budget_validation ───────────────────────
class TestEditBudgetValidation:
"""编辑预算退火校验(_validate_edit_budget)。"""
def test_start_less_than_end_rejected(self) -> None:
"""edit_budget_start < edit_budget_end 应抛出 ValueError。"""
cfg = _make_config(edit_budget_start=1, edit_budget_end=5)
with pytest.raises(ValueError, match="edit_budget_start"):
_validate(cfg)
def test_end_zero_rejected(self) -> None:
"""edit_budget_end <= 0 应抛出 ValueError。"""
cfg = _make_config(edit_budget_start=1, edit_budget_end=0)
with pytest.raises(ValueError, match="edit_budget_end"):
_validate(cfg)
def test_equal_values_accepted(self) -> None:
"""edit_budget_start == edit_budget_end 应通过。"""
cfg = _make_config(edit_budget_start=3, edit_budget_end=3)
_validate(cfg)
# ─────────────────────── test_minibatch_validation ───────────────────────
class TestMinibatchValidation:
"""mini-batch 自进化闭环参数校验(_validate_minibatch)。"""
def test_batch_size_zero_rejected(self) -> None:
"""batch_size <= 0 应抛出 ValueError。"""
cfg = _make_config(batch_size=0)
with pytest.raises(ValueError, match="batch_size"):
_validate(cfg)
def test_min_class_per_batch_equals_batch_size_rejected(self) -> None:
"""min_class_per_batch >= batch_size 应抛出 ValueError。"""
cfg = _make_config(batch_size=5, min_class_per_batch=5)
with pytest.raises(ValueError, match="min_class_per_batch"):
_validate(cfg)
def test_min_class_per_batch_zero_rejected(self) -> None:
"""min_class_per_batch < 1 应抛出 ValueError。"""
cfg = _make_config(min_class_per_batch=0)
with pytest.raises(ValueError, match="min_class_per_batch"):
_validate(cfg)
def test_eval_min_per_class_zero_rejected(self) -> None:
"""eval_min_per_class < 1 应抛出 ValueError。"""
cfg = _make_config(eval_min_per_class=0)
with pytest.raises(ValueError, match="eval_min_per_class"):
_validate(cfg)
def test_early_stop_patience_zero_rejected(self) -> None:
"""early_stop_patience <= 0 应抛出 ValueError。"""
cfg = _make_config(early_stop_patience=0)
with pytest.raises(ValueError, match="early_stop_patience"):
_validate(cfg)
def test_test_size_zero_rejected(self) -> None:
"""test_size <= 0 应抛出 ValueError。"""
cfg = _make_config(test_size=0)
with pytest.raises(ValueError, match="test_size"):
_validate(cfg)
def test_batch_correct_ratio_one_rejected(self) -> None:
"""batch_correct_ratio >= 1 应抛出 ValueError。"""
cfg = _make_config(batch_correct_ratio=1.0)
with pytest.raises(ValueError, match="batch_correct_ratio"):
_validate(cfg)
def test_batch_correct_ratio_negative_rejected(self) -> None:
"""batch_correct_ratio < 0 应抛出 ValueError。"""
cfg = _make_config(batch_correct_ratio=-0.1)
with pytest.raises(ValueError, match="batch_correct_ratio"):
_validate(cfg)
def test_momentum_samples_zero_rejected(self) -> None:
"""momentum_samples < 1 应抛出 ValueError。"""
cfg = _make_config(momentum_samples=0)
with pytest.raises(ValueError, match="momentum_samples"):
_validate(cfg)
# ─────────────────────────── test_gate_validation ────────────────────────
class TestGateValidation:
"""CE-Gate 参数校验(_validate_gate)。"""
def test_e_confirm_at_one_rejected(self) -> None:
"""gate_e_confirm <= 1 应抛出 ValueError。"""
cfg = _make_config(gate_e_confirm=1.0, gate_e_provisional=1.0)
with pytest.raises(ValueError, match="gate_e_confirm"):
_validate(cfg)
def test_e_provisional_exceeds_confirm_rejected(self) -> None:
"""gate_e_provisional > gate_e_confirm 应抛出 ValueError。"""
cfg = _make_config(gate_e_confirm=10.0, gate_e_provisional=15.0)
with pytest.raises(ValueError, match="gate_e_provisional"):
_validate(cfg)
def test_e_provisional_at_one_rejected(self) -> None:
"""gate_e_provisional <= 1 应抛出 ValueError。"""
cfg = _make_config(gate_e_provisional=0.5)
with pytest.raises(ValueError, match="gate_e_provisional"):
_validate(cfg)
def test_e_rollback_at_one_rejected(self) -> None:
"""gate_e_rollback <= 1 应抛出 ValueError。"""
cfg = _make_config(gate_e_rollback=1.0)
with pytest.raises(ValueError, match="gate_e_rollback"):
_validate(cfg)
def test_w_net_min_zero_rejected(self) -> None:
"""gate_w_net_min < 1 应抛出 ValueError。"""
cfg = _make_config(gate_w_net_min=0)
with pytest.raises(ValueError, match="gate_w_net_min"):
_validate(cfg)
def test_delta_min_negative_rejected(self) -> None:
"""gate_delta_min < 0 应抛出 ValueError。"""
cfg = _make_config(gate_delta_min=-0.1)
with pytest.raises(ValueError, match="gate_delta_min"):
_validate(cfg)
def test_lambda_dir_positive_rejected(self) -> None:
"""gate_lambda_dir >= 0 应抛出 ValueError。"""
cfg = _make_config(gate_lambda_dir=0.5)
with pytest.raises(ValueError, match="gate_lambda_dir"):
_validate(cfg)
def test_lambda_dir_zero_rejected(self) -> None:
"""gate_lambda_dir == 0 也应报错。"""
cfg = _make_config(gate_lambda_dir=0.0)
with pytest.raises(ValueError, match="gate_lambda_dir"):
_validate(cfg)
def test_block_exceeds_n_max_rejected(self) -> None:
"""gate_block > gate_n_max 应抛出 ValueError。"""
cfg = _make_config(gate_block=50, gate_n_max=40)
with pytest.raises(ValueError, match="gate_block"):
_validate(cfg)
def test_block_zero_rejected(self) -> None:
"""gate_block <= 0 应抛出 ValueError。"""
cfg = _make_config(gate_block=0)
with pytest.raises(ValueError, match="gate_block"):
_validate(cfg)
def test_p_low_exceeds_p_high_rejected(self) -> None:
"""gate_p_low >= gate_p_high 应抛出 ValueError。"""
cfg = _make_config(gate_p_low=0.9, gate_p_high=0.1)
with pytest.raises(ValueError, match="gate_p_low"):
_validate(cfg)
def test_probe_quota_negative_rejected(self) -> None:
"""gate_probe_quota < 0 应抛出 ValueError。"""
cfg = _make_config(gate_probe_quota=-0.1)
with pytest.raises(ValueError, match="gate_probe_quota"):
_validate(cfg)
def test_gamma_decay_zero_rejected(self) -> None:
"""gate_gamma_decay <= 0 应抛出 ValueError。"""
cfg = _make_config(gate_gamma_decay=0.0)
with pytest.raises(ValueError, match="gate_gamma_decay"):
_validate(cfg)
def test_gamma_decay_one_rejected(self) -> None:
"""gate_gamma_decay >= 1 应抛出 ValueError。"""
cfg = _make_config(gate_gamma_decay=1.0)
with pytest.raises(ValueError, match="gate_gamma_decay"):
_validate(cfg)
def test_cooldown_steps_zero_rejected(self) -> None:
"""gate_cooldown_steps < 1 应抛出 ValueError。"""
cfg = _make_config(gate_cooldown_steps=0)
with pytest.raises(ValueError, match="gate_cooldown_steps"):
_validate(cfg)
def test_guard_err_zero_rejected(self) -> None:
"""gate_guard_err <= 0 应抛出 ValueError。"""
cfg = _make_config(gate_guard_err=0.0)
with pytest.raises(ValueError, match="gate_guard_err"):
_validate(cfg)
def test_guard_err_one_rejected(self) -> None:
"""gate_guard_err >= 1 应抛出 ValueError。"""
cfg = _make_config(gate_guard_err=1.0)
with pytest.raises(ValueError, match="gate_guard_err"):
_validate(cfg)
# ─────────────────────── test_load_config_cli_overrides ──────────────────
class TestLoadConfigCliOverrides:
"""load_config 的 CLI 覆盖层测试。"""
def test_cli_overrides_yaml_values(self, tmp_path: Path) -> None:
"""CLI 参数应覆盖 YAML 中的同名字段。"""
harness_data = _yaml_harness_dict()
yaml_path = _write_yaml(tmp_path, harness_data)
cfg = load_config(yaml_path, cli_overrides={"concurrency": 4, "max_steps": 30})
assert cfg.concurrency == 4
assert cfg.max_steps == 30
def test_cli_none_values_ignored(self, tmp_path: Path) -> None:
"""CLI 中值为 None 的字段不应覆盖 YAML。"""
harness_data = _yaml_harness_dict()
yaml_path = _write_yaml(tmp_path, harness_data)
cfg = load_config(yaml_path, cli_overrides={"concurrency": None, "max_steps": 20})
assert cfg.concurrency == 12 # YAML 默认值
assert cfg.max_steps == 20
def test_cli_run_id_override(self, tmp_path: Path) -> None:
"""CLI 可通过 run_id 覆盖默认空字符串。"""
harness_data = _yaml_harness_dict()
yaml_path = _write_yaml(tmp_path, harness_data)
cfg = load_config(
yaml_path,
cli_overrides={"mode": "diagnose", "run_id": "run-abc-123"},
)
assert cfg.run_id == "run-abc-123"
assert cfg.mode == "diagnose"
def test_path_fields_converted_to_path(self, tmp_path: Path) -> None:
"""workspace_dir 和 store_dir 应被转换为 Path 对象。"""
harness_data = _yaml_harness_dict()
yaml_path = _write_yaml(tmp_path, harness_data)
cfg = load_config(yaml_path)
assert isinstance(cfg.workspace_dir, Path)
assert isinstance(cfg.store_dir, Path)
def test_unknown_cli_keys_ignored(self, tmp_path: Path) -> None:
"""YAML 和 RunConfig 中不存在的 CLI key 应被忽略。"""
harness_data = _yaml_harness_dict()
yaml_path = _write_yaml(tmp_path, harness_data)
cfg = load_config(yaml_path, cli_overrides={"nonexistent_field": 42})
assert cfg.concurrency == 12 # 正常字段不受影响
def test_validation_runs_after_loading(self, tmp_path: Path) -> None:
"""load_config 加载后应运行校验,非法值应报错。"""
harness_data = _yaml_harness_dict()
harness_data["mode"] = "invalid_mode"
yaml_path = _write_yaml(tmp_path, harness_data)
with pytest.raises(ValueError, match="mode"):
load_config(yaml_path)
# ──────────────────── test_load_config_env_overrides ─────────────────────
class TestLoadConfigEnvOverrides:
"""load_config 的 .env 环境变量覆盖层测试。"""
def test_env_overrides_yaml_workspace_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""环境变量 HARNESS_WORKSPACE_DIR 应覆盖 YAML 中的 workspace_dir。"""
harness_data = _yaml_harness_dict()
yaml_path = _write_yaml(tmp_path, harness_data)
monkeypatch.setenv("HARNESS_WORKSPACE_DIR", "/custom/workspace")
cfg = load_config(yaml_path)
assert cfg.workspace_dir == Path("/custom/workspace")
def test_env_overrides_yaml_store_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""环境变量 HARNESS_STORE_DIR 应覆盖 YAML 中的 store_dir。"""
harness_data = _yaml_harness_dict()
yaml_path = _write_yaml(tmp_path, harness_data)
monkeypatch.setenv("HARNESS_STORE_DIR", "/custom/store")
cfg = load_config(yaml_path)
assert cfg.store_dir == Path("/custom/store")
def test_cli_overrides_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""CLI 优先级高于 .envCLI > .env > YAML。"""
harness_data = _yaml_harness_dict()
yaml_path = _write_yaml(tmp_path, harness_data)
monkeypatch.setenv("HARNESS_WORKSPACE_DIR", "/env/workspace")
cfg = load_config(yaml_path, cli_overrides={"workspace_dir": "/cli/workspace"})
assert cfg.workspace_dir == Path("/cli/workspace")
# ──────────────────── test_load_config_real_yaml ─────────────────────────
class TestLoadConfigRealYaml:
"""用真实 config/default.yaml 验证 load_config 嵌套解析。"""
def test_load_config_real_default_yaml(self) -> None:
"""真实 config/default.yaml 的 harness 段应正确解析并通过校验。"""
cfg = load_config(Path("config/default.yaml"), {})
assert cfg.mode == "infer"
assert cfg.gate_e_confirm == 20.0
assert cfg.batch_size == 15
assert cfg.concurrency == 12
assert cfg.workspace_dir == Path("workspaces/default")
assert cfg.store_dir == Path("store")
assert cfg.skill_mode == "auto"
def test_real_yaml_cli_override(self) -> None:
"""真实 YAML + CLI 覆盖应正确合并。"""
cfg = load_config(
Path("config/default.yaml"),
{"concurrency": 4, "max_steps": 30},
)
assert cfg.concurrency == 4
assert cfg.max_steps == 30
assert cfg.mode == "infer" # 未覆盖字段保持 YAML 值
# ─────────────────────────── test_val_size_floor ─────────────────────────
class TestValSizeFloor:
"""val_size >= eval_min_per_class * 11 的下限校验。"""
def test_val_size_below_floor_rejected(self) -> None:
"""val_size < eval_min_per_class * 11 应抛出 ValueError。
eval_min_per_class=3 → 下限 = 3 * 11 = 33val_size=30 不足。
"""
cfg = _make_config(eval_min_per_class=3, val_size=30)
with pytest.raises(ValueError, match="val_size"):
_validate(cfg)
def test_val_size_at_floor_accepted(self) -> None:
"""val_size == eval_min_per_class * 11 应通过。"""
cfg = _make_config(eval_min_per_class=3, val_size=33)
_validate(cfg)
def test_val_size_above_floor_accepted(self) -> None:
"""val_size > eval_min_per_class * 11 应通过。"""
cfg = _make_config(eval_min_per_class=2, val_size=100)
_validate(cfg)
def test_default_yaml_values_satisfy_floor(self) -> None:
"""default.yaml 的默认值(val_size=30, eval_min_per_class=2)应满足下限。
下限 = 2 * 11 = 22val_size=30 >= 22,通过。
"""
cfg = _make_config()
_validate(cfg) # 不应抛出异常
+374
View File
@@ -0,0 +1,374 @@
"""app/harness/gate_ladder.py 单元测试。
覆盖冷启动交错、Beta(1,1) 平滑、warm 信息量排序、
GatePools 原子读写与指纹校验、BaselineCache 四维内容寻址与先盘后存、
gamma-EMA 更新、防泄露过滤等核心语义。
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from app.harness.gate_ladder import (
BaselineCache,
GatePools,
LadderEntry,
build_cold_entries,
build_or_load_gate_pools,
order_ladder,
skill_hash,
)
from core.types import GeneratedQuestion
if TYPE_CHECKING:
from pathlib import Path
# ── 工具函数 ──────────────────────────────────────────────────────────
def _make_q(qid: str, task_type: str = "AR") -> GeneratedQuestion:
"""构造最小 GeneratedQuestion 实例。"""
return GeneratedQuestion(
question_id=qid,
video_id="v1",
task_type=task_type,
question="dummy",
options=("A", "B", "C", "D"),
answer="A",
source_nodes=("n1",),
difficulty="easy",
)
# ── 冷启动 ────────────────────────────────────────────────────────────
class TestColdStart:
"""冷启动排序:2:1 交错 + 探针插尾 + Beta(1,1) 平滑。"""
def test_cold_start_interleaving(self) -> None:
"""错题:对题 = 2:1 交错顺序。
6 错 3 对(probe_quota=0 无探针)→ 交错序应为 W W R W W R W W R。
"""
wrong_ids = [f"w{i}" for i in range(6)]
right_ids = [f"r{i}" for i in range(3)]
questions = [_make_q(qid) for qid in wrong_ids + right_ids]
correctness = dict.fromkeys(wrong_ids, False)
correctness.update(dict.fromkeys(right_ids, True))
entries = build_cold_entries(questions, correctness, probe_quota=0.0, seed=42)
assert len(entries) == 9
# 验证 2:1 交错模式(seed 固定后 shuffle 结果确定)
pattern = ["W" if not correctness[e.question_id] else "R" for e in entries]
# 前 9 个交错应为 W W R W W R W W R
assert pattern == ["W", "W", "R", "W", "W", "R", "W", "W", "R"]
def test_cold_start_p_hat_beta(self) -> None:
"""p_hat 遵循 Beta(1,1) 平滑:错=1/3,对=2/3。"""
questions = [_make_q("q1"), _make_q("q2")]
correctness = {"q1": False, "q2": True}
entries = build_cold_entries(questions, correctness, probe_quota=0.0, seed=0)
p_map = {e.question_id: e.p_hat for e in entries}
assert p_map["q1"] == pytest.approx(1 / 3)
assert p_map["q2"] == pytest.approx(2 / 3)
def test_cold_start_probe_at_tail(self) -> None:
"""probe_quota > 0 时探针题追加在尾部。"""
wrong_ids = [f"w{i}" for i in range(10)]
right_ids = [f"r{i}" for i in range(2)]
questions = [_make_q(qid) for qid in wrong_ids + right_ids]
correctness = dict.fromkeys(wrong_ids, False)
correctness.update(dict.fromkeys(right_ids, True))
entries = build_cold_entries(questions, correctness, probe_quota=0.3, seed=7)
# 10 错 * 0.3 = 3 个探针在尾部
n_probe = int(10 * 0.3)
assert n_probe == 3
# 尾部 3 个都应为错题
tail = entries[-n_probe:]
for e in tail:
assert not correctness[e.question_id]
# ── warm 排序 ──────────────────────────────────────────────────────────
class TestWarmOrdering:
"""warm 阶段:信息量 p_hat(1-p_hat) 降序 + p_hat 区间过滤。"""
def test_warm_ordering_information(self) -> None:
"""p_hat=0.5 信息量最高,排在最前。"""
entries = [
LadderEntry("a", 0.1),
LadderEntry("b", 0.5),
LadderEntry("c", 0.9),
LadderEntry("d", 0.3),
]
ordered = order_ladder(entries, p_low=0.0, p_high=1.0)
assert ordered[0].question_id == "b" # 0.5*(1-0.5)=0.25 最高
# d: 0.3*0.7=0.21, a: 0.1*0.9=0.09, c: 0.9*0.1=0.09
assert ordered[1].question_id == "d"
def test_warm_filter_bounds(self) -> None:
"""p_hat 不在 [p_low, p_high] 区间的题被剔除。"""
entries = [
LadderEntry("low", 0.05),
LadderEntry("mid", 0.5),
LadderEntry("high", 0.95),
]
ordered = order_ladder(entries, p_low=0.1, p_high=0.9)
ids = [e.question_id for e in ordered]
assert "mid" in ids
assert "low" not in ids
assert "high" not in ids
# ── GatePools 持久化 ──────────────────────────────────────────────────
class TestGatePoolsPersistence:
"""GatePools.save/load 原子性与指纹校验。"""
def test_gate_pools_save_load_atomic(self, tmp_path: Path) -> None:
"""save -> load 往返保真,且使用原子写(中间 .tmp 文件不残留)。"""
entries = {
"AR": [LadderEntry("q1", 0.33), LadderEntry("q2", 0.67)],
"CR": [LadderEntry("q3", 0.5)],
}
pools = GatePools(entries=entries, seed=42, fingerprint="abc123")
path = tmp_path / "gate_pools.json"
pools.save(path)
# .tmp 文件不应残留
assert not (tmp_path / "gate_pools.json.tmp").exists()
assert path.exists()
loaded = GatePools.load(path)
assert loaded.seed == 42
assert loaded.fingerprint == "abc123"
assert len(loaded.entries["AR"]) == 2
assert loaded.entries["AR"][0].question_id == "q1"
assert loaded.entries["AR"][0].p_hat == pytest.approx(0.33)
assert loaded.entries["CR"][0].question_id == "q3"
def test_gate_pools_fingerprint_mismatch(self, tmp_path: Path) -> None:
"""指纹不一致 -> RuntimeError(不静默重建)。"""
questions = [_make_q("q1", "AR"), _make_q("q2", "AR")]
correctness = {"q1": True, "q2": False}
# 第一次构建
build_or_load_gate_pools(
workspace_dir=tmp_path,
questions=questions,
test_qids=set(),
baseline_correctness=correctness,
task_types=["AR"],
probe_quota=0.0,
seed=1,
baseline_run_id="run_001",
)
# 改 baseline_run_id 导致指纹变化 -> 应报错
with pytest.raises(RuntimeError, match="指纹不一致"):
build_or_load_gate_pools(
workspace_dir=tmp_path,
questions=questions,
test_qids=set(),
baseline_correctness=correctness,
task_types=["AR"],
probe_quota=0.0,
seed=1,
baseline_run_id="run_002",
)
# ── ladder_for ────────────────────────────────────────────────────────
class TestLadderFor:
"""ladder_for 取题序与排除逻辑。"""
def test_ladder_for_excludes_qids(self) -> None:
"""exclude_qids 中的题被排除。"""
entries = {
"AR": [
LadderEntry("q1", 0.5),
LadderEntry("q2", 0.4),
LadderEntry("q3", 0.6),
],
}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
result = pools.ladder_for("AR", exclude_qids={"q2"}, p_low=0.0, p_high=1.0, cold=True)
assert "q2" not in result
assert "q1" in result
assert "q3" in result
def test_ladder_for_missing_task_type(self) -> None:
"""不存在的 task_type -> ValueError。"""
pools = GatePools(entries={}, seed=0, fingerprint="x")
with pytest.raises(ValueError, match="无阶梯"):
pools.ladder_for("MISSING", set(), 0.0, 1.0, cold=True)
def test_ladder_for_warm_uses_order_ladder(self) -> None:
"""cold=False 时走 warm 信息量排序。"""
entries = {
"AR": [
LadderEntry("low", 0.1),
LadderEntry("mid", 0.5),
LadderEntry("high", 0.9),
],
}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
result = pools.ladder_for("AR", set(), p_low=0.0, p_high=1.0, cold=False)
# 信息量排序:mid(0.25) > low(0.09) = high(0.09)
assert result[0] == "mid"
# ── gamma-EMA 更新 ─────────────────────────────────────────────────────
class TestGammaEMA:
"""gamma-EMA 更新 p_hat。"""
def test_gamma_ema_update(self) -> None:
"""p_hat <- gamma * p_hat + (1-gamma) * obs。"""
entries = {"AR": [LadderEntry("q1", 0.5)]}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
# 观测为正确(1.0), gamma=0.8
pools.update_probs({"q1": True}, gamma=0.8)
expected = 0.8 * 0.5 + 0.2 * 1.0 # 0.6
assert pools.entries["AR"][0].p_hat == pytest.approx(expected)
# 再次观测为错误(0.0), gamma=0.8
pools.update_probs({"q1": False}, gamma=0.8)
expected2 = 0.8 * expected + 0.2 * 0.0 # 0.48
assert pools.entries["AR"][0].p_hat == pytest.approx(expected2)
def test_update_probs_no_observation_unchanged(self) -> None:
"""无观测的题 p_hat 不变。"""
entries = {"AR": [LadderEntry("q1", 0.5), LadderEntry("q2", 0.3)]}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
pools.update_probs({"q1": True}, gamma=0.9)
assert pools.entries["AR"][1].p_hat == pytest.approx(0.3)
# ── 防泄露 ─────────────────────────────────────────────────────────────
class TestLeakPrevention:
"""防泄露铁律:gate 内 rollout 永不回流 p_hat(由调用方过滤)。"""
def test_update_probs_excludes_gate_runs(self) -> None:
"""调用方须过滤 run_id 含 '_gate_' 的观测。
update_probs 本身只接收已过滤的 observations,这里验证
如果调用方正确过滤,gate run 数据不会影响 p_hat。
"""
entries = {"AR": [LadderEntry("q1", 0.5)]}
pools = GatePools(entries=entries, seed=0, fingerprint="x")
# 模拟:所有 run 的原始观测(含 gate run
raw_observations = {
"run_normal": {"q1": True}, # 普通 run
"run_gate_01": {"q1": False}, # gate runrun_id 含 _gate_
}
# 调用方按 run_id 过滤:排除含 "_gate_" 的 run
filtered = {}
for run_id, obs in raw_observations.items():
if "_gate_" not in run_id:
filtered.update(obs)
# 只有普通 run 的观测进入 update_probs
assert filtered == {"q1": True}
pools.update_probs(filtered, gamma=0.8)
expected = 0.8 * 0.5 + 0.2 * 1.0
assert pools.entries["AR"][0].p_hat == pytest.approx(expected)
# ── BaselineCache ──────────────────────────────────────────────────────
class TestBaselineCache:
"""BaselineCache 四维内容寻址与先盘后存。"""
def test_baseline_cache_content_addressed(self, tmp_path: Path) -> None:
"""四维键唯一寻址:任一维度变化 -> miss。"""
path = tmp_path / "baseline_cache.json"
cache = BaselineCache(path)
cache.put("AR", "hash1", "v1", "q1", True)
assert cache.get("AR", "hash1", "v1", "q1") is True
# 改 skill_hash -> miss
assert cache.get("AR", "hash2", "v1", "q1") is None
# 改 prompts_version -> miss
assert cache.get("AR", "hash1", "v2", "q1") is None
# 改 task_type -> miss
assert cache.get("CR", "hash1", "v1", "q1") is None
# 改 qid -> miss
assert cache.get("AR", "hash1", "v1", "q2") is None
def test_baseline_cache_disk_first(self, tmp_path: Path) -> None:
"""先盘后存:磁盘写成功后内存才更新,新实例可从磁盘读到。"""
path = tmp_path / "baseline_cache.json"
cache = BaselineCache(path)
cache.put("AR", "h1", "v1", "q1", True)
# 内存可读
assert cache.get("AR", "h1", "v1", "q1") is True
# 新实例从磁盘加载也能读到(证明先落盘)
cache2 = BaselineCache(path)
assert cache2.get("AR", "h1", "v1", "q1") is True
# .tmp 文件不应残留
assert not (tmp_path / "baseline_cache.json.tmp").exists()
def test_baseline_cache_empty_init(self, tmp_path: Path) -> None:
"""不存在的文件 -> 空缓存初始化。"""
path = tmp_path / "nonexistent.json"
cache = BaselineCache(path)
assert cache.get("AR", "h1", "v1", "q1") is None
def test_baseline_cache_overwrite(self, tmp_path: Path) -> None:
"""同键重复写入覆盖旧值。"""
path = tmp_path / "baseline_cache.json"
cache = BaselineCache(path)
cache.put("AR", "h1", "v1", "q1", True)
assert cache.get("AR", "h1", "v1", "q1") is True
cache.put("AR", "h1", "v1", "q1", False)
assert cache.get("AR", "h1", "v1", "q1") is False
# ── skill_hash ─────────────────────────────────────────────────────────
class TestSkillHash:
"""skill_hash SHA1 摘要。"""
def test_deterministic(self) -> None:
"""相同输入产生相同摘要。"""
assert skill_hash("hello") == skill_hash("hello")
def test_different_content(self) -> None:
"""不同输入产生不同摘要。"""
assert skill_hash("hello") != skill_hash("world")
def test_is_sha1_hex(self) -> None:
"""输出为 40 字符十六进制。"""
h = skill_hash("test")
assert len(h) == 40
assert all(c in "0123456789abcdef" for c in h)
+659
View File
@@ -0,0 +1,659 @@
"""app/harness/inference.py 单元测试。
测试覆盖:
- run_inference 基本流程(mock LLM + tool_dispatch
- 异常时 prediction 仍落库(stop_reason=error
- _to_text_field 归一化
- run_id 空串 → ValueError
- _aggregate_results 内存聚合
- 空 questions 列表零值返回
- 并发控制 Semaphore
- plugins_factory 调用
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock
import pytest
from app.harness.inference import (
InferenceResult,
_aggregate_results,
_to_text_field,
run_inference,
)
from app.harness.log import HarnessLog
from core.types import GeneratedQuestion, LLMResponse
# ── 测试基础设施 ──────────────────────────────────────────────────
def _make_question(
question_id: str = "q1",
video_id: str = "v1",
task_type: str = "Action Reasoning",
answer: str = "B",
) -> GeneratedQuestion:
"""构造测试用题目。"""
return GeneratedQuestion(
question_id=question_id,
video_id=video_id,
task_type=task_type,
question="测试问题",
options=("A. 选项A", "B. 选项B", "C. 选项C", "D. 选项D"),
answer=answer,
source_nodes=("L1_001",),
difficulty="medium",
)
def _make_llm_response(answer: str = "B") -> LLMResponse:
"""构造测试用 LLMResponsesubmit_answer 场景)。"""
content = json.dumps(
{
"reflect": {"observation": "找到答案"},
"plan": {"next_step": "提交"},
"action": {
"tool": "submit_answer",
"args": {
"answer": answer,
"evidence": "证据文本",
"reasoning": "推理过程",
},
},
}
)
return LLMResponse(
content=content,
thinking="思考过程",
model="test-model",
provider="test",
prompt_tokens=100,
completion_tokens=50,
latency_ms=200,
ttft_ms=30.0,
max_inter_token_ms=5.0,
cache_hit=False,
call_id="test-call-001",
)
def _make_error_llm_response() -> LLMResponse:
"""构造触发解析失败的 LLMResponse。"""
return LLMResponse(
content="这不是JSON",
thinking="",
model="test-model",
provider="test",
prompt_tokens=10,
completion_tokens=5,
latency_ms=50,
ttft_ms=10.0,
max_inter_token_ms=2.0,
cache_hit=False,
call_id="test-call-err",
)
async def _stub_tool_dispatch(
tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str:
"""测试用工具调度函数。"""
if tool_name == "submit_answer":
return "答案已提交"
raise ValueError(f"未知工具: {tool_name}")
def _stub_prompt_builder(qa: GeneratedQuestion) -> tuple[str, str]:
"""测试用 prompt 构建函数。"""
return "系统提示词", f"用户问题: {qa.question}"
@pytest.fixture
def harness_log(tmp_path: Any, request: Any) -> HarnessLog:
"""创建临时 HarnessLog 实例。
使用 test 节点名称的 hash 作为 db 文件名,避免冲突。
run_id 固定为 "test-run",实际 run_inference 中传入的 run_id
由 HarnessLog.insert 自动覆盖为 HarnessLog 构造时的值。
"""
db_name = f"harness_{id(request)}.db"
db_path = str(tmp_path / db_name)
log = HarnessLog(db_path, "test-run")
yield log
log.close()
# ── 测试用例 ──────────────────────────────────────────────────
class TestToTextField:
"""_to_text_field 归一化测试。"""
@pytest.mark.asyncio
async def test_string_passthrough(self) -> None:
"""字符串原样返回。"""
assert _to_text_field("hello") == "hello"
@pytest.mark.asyncio
async def test_empty_string(self) -> None:
"""空字符串原样返回。"""
assert _to_text_field("") == ""
@pytest.mark.asyncio
async def test_list_serialized(self) -> None:
"""list 被 JSON 序列化。"""
result = _to_text_field(["a", "b"])
assert result == '["a", "b"]'
@pytest.mark.asyncio
async def test_dict_serialized(self) -> None:
"""dict 被 JSON 序列化。"""
result = _to_text_field({"key": ""})
assert '"key"' in result
assert '""' in result
@pytest.mark.asyncio
async def test_int_serialized(self) -> None:
"""int 被 JSON 序列化。"""
assert _to_text_field(42) == "42"
@pytest.mark.asyncio
async def test_none_serialized(self) -> None:
"""None 被 JSON 序列化。"""
assert _to_text_field(None) == "null"
@pytest.mark.asyncio
async def test_unicode_preserved(self) -> None:
"""ensure_ascii=False 保留中文。"""
result = _to_text_field(["中文"])
assert "中文" in result
assert "\\u" not in result
class TestAggregateResults:
"""_aggregate_results 内存聚合测试。"""
@pytest.mark.asyncio
async def test_empty_records(self) -> None:
"""空列表返回零值 InferenceResult。"""
result = _aggregate_results([], "run-empty")
assert result.run_id == "run-empty"
assert result.accuracy == 0.0
assert result.total == 0
assert result.correct == 0
assert result.per_task_type == {}
assert result.steps_mean == 0.0
assert result.token_usage == {"prompt_tokens": 0, "completion_tokens": 0}
assert result.stop_reason_counts == {}
@pytest.mark.asyncio
async def test_single_correct(self) -> None:
"""单条正确记录 → accuracy=1.0。"""
records = [
{
"prediction": "B",
"answer": "B",
"task_type": "AR",
"steps_used": 3,
"prompt_tokens": 100,
"completion_tokens": 50,
"stop_reason": "finished",
}
]
result = _aggregate_results(records, "run-1")
assert result.accuracy == 1.0
assert result.total == 1
assert result.correct == 1
assert result.steps_mean == 3.0
@pytest.mark.asyncio
async def test_mixed_correct_wrong(self) -> None:
"""混合正确/错误 → 准确率与步数均正确聚合。"""
records = [
{
"prediction": "B",
"answer": "B",
"task_type": "AR",
"steps_used": 2,
"prompt_tokens": 100,
"completion_tokens": 50,
"stop_reason": "finished",
},
{
"prediction": "C",
"answer": "A",
"task_type": "AR",
"steps_used": 4,
"prompt_tokens": 200,
"completion_tokens": 100,
"stop_reason": "budget_exceeded",
},
{
"prediction": "D",
"answer": "D",
"task_type": "SP",
"steps_used": 1,
"prompt_tokens": 50,
"completion_tokens": 25,
"stop_reason": "finished",
},
]
result = _aggregate_results(records, "run-mix")
assert result.total == 3
assert result.correct == 2
assert abs(result.accuracy - 2 / 3) < 1e-9
assert abs(result.steps_mean - 7 / 3) < 1e-9
assert result.token_usage == {"prompt_tokens": 350, "completion_tokens": 175}
assert result.stop_reason_counts == {"finished": 2, "budget_exceeded": 1}
@pytest.mark.asyncio
async def test_per_task_type_grouping(self) -> None:
"""按 task_type 分组聚合。"""
records = [
{
"prediction": "B",
"answer": "B",
"task_type": "AR",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 5,
"stop_reason": "finished",
},
{
"prediction": "A",
"answer": "C",
"task_type": "AR",
"steps_used": 2,
"prompt_tokens": 20,
"completion_tokens": 10,
"stop_reason": "finished",
},
{
"prediction": "D",
"answer": "D",
"task_type": "SP",
"steps_used": 3,
"prompt_tokens": 30,
"completion_tokens": 15,
"stop_reason": "finished",
},
]
result = _aggregate_results(records, "run-task")
assert "AR" in result.per_task_type
assert "SP" in result.per_task_type
assert result.per_task_type["AR"]["total"] == 2
assert result.per_task_type["AR"]["correct"] == 1
assert result.per_task_type["AR"]["accuracy"] == 0.5
assert result.per_task_type["SP"]["total"] == 1
assert result.per_task_type["SP"]["correct"] == 1
assert result.per_task_type["SP"]["accuracy"] == 1.0
class TestRunIdValidation:
"""run_id 校验测试。"""
@pytest.mark.asyncio
async def test_empty_string_raises(self, harness_log: HarnessLog) -> None:
"""空串 run_id → ValueError。"""
llm = AsyncMock()
with pytest.raises(ValueError, match="run_id 不得为空"):
await run_inference(
[],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
@pytest.mark.asyncio
async def test_whitespace_only_raises(self, harness_log: HarnessLog) -> None:
"""纯空白 run_id → ValueError。"""
llm = AsyncMock()
with pytest.raises(ValueError, match="run_id 不得为空"):
await run_inference(
[],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id=" ",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
class TestEmptyQuestions:
"""空题目列表测试。"""
@pytest.mark.asyncio
async def test_empty_questions_returns_zero(self, harness_log: HarnessLog) -> None:
"""空 questions 列表直接返回零值 InferenceResult。"""
llm = AsyncMock()
result = await run_inference(
[],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-empty",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert isinstance(result, InferenceResult)
assert result.run_id == "run-empty"
assert result.accuracy == 0.0
assert result.total == 0
assert result.correct == 0
# LLM 未被调用
llm.chat.assert_not_called()
class TestRunInferenceBasic:
"""run_inference 基本流程测试。"""
@pytest.mark.asyncio
async def test_single_question_correct(self, harness_log: HarnessLog) -> None:
"""单题正确推理 → accuracy=1.0, stop_reason=finished。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
result = await run_inference(
[_make_question(answer="B")],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-basic",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert result.accuracy == 1.0
assert result.total == 1
assert result.correct == 1
assert result.stop_reason_counts.get("finished") == 1
@pytest.mark.asyncio
async def test_single_question_wrong(self, harness_log: HarnessLog) -> None:
"""单题错误推理 → accuracy=0.0。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="C")
result = await run_inference(
[_make_question(answer="B")],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-wrong",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert result.accuracy == 0.0
assert result.total == 1
assert result.correct == 0
@pytest.mark.asyncio
async def test_multiple_questions_concurrent(self, harness_log: HarnessLog) -> None:
"""3 题并发推理 → 结果正确聚合。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
questions = [
_make_question(question_id="q1", answer="B"),
_make_question(question_id="q2", answer="B"),
_make_question(question_id="q3", answer="A"),
]
result = await run_inference(
questions,
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-multi",
concurrency=3,
max_steps=10,
skill_mode="auto",
)
assert result.total == 3
assert result.correct == 2
assert abs(result.accuracy - 2 / 3) < 1e-9
@pytest.mark.asyncio
async def test_token_usage_accumulated(self, harness_log: HarnessLog) -> None:
"""多题 token 累加验证。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
questions = [
_make_question(question_id="q1"),
_make_question(question_id="q2"),
]
result = await run_inference(
questions,
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-token",
concurrency=2,
max_steps=10,
skill_mode="auto",
)
assert result.token_usage["prompt_tokens"] == 200
assert result.token_usage["completion_tokens"] == 100
class TestPredictionAlwaysWritten:
"""异常时 prediction 仍落库测试。"""
@pytest.mark.asyncio
async def test_error_still_persisted(self, harness_log: HarnessLog) -> None:
"""LLM 调用异常时,prediction 仍以 stop_reason=error 落库。"""
llm = AsyncMock()
llm.chat.side_effect = RuntimeError("LLM API 不可用")
result = await run_inference(
[_make_question()],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-error",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert result.total == 1
assert result.correct == 0
assert result.stop_reason_counts.get("error") == 1
# 验证 DB 中的记录(HarnessLog.insert 使用构造时的 run_id
rows = harness_log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",))
assert len(rows) == 1
assert rows[0]["stop_reason"] == "error"
assert rows[0]["prediction"] is None
@pytest.mark.asyncio
async def test_parse_error_still_persisted(self, harness_log: HarnessLog) -> None:
"""LLM 返回非 JSON 内容,parse_error 后 prediction 仍落库。"""
llm = AsyncMock()
llm.chat.return_value = _make_error_llm_response()
result = await run_inference(
[_make_question()],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-parse-err",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert result.total == 1
# HarnessLog.insert 使用构造时的 run_id
rows = harness_log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",))
assert len(rows) == 1
assert rows[0]["prediction"] is None
class TestPluginsFactory:
"""plugins_factory 调用测试。"""
@pytest.mark.asyncio
async def test_factory_called_per_question(self, harness_log: HarnessLog) -> None:
"""每题调用 plugins_factory,传入 (video_id, question_id)。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
factory_calls: list[tuple[str, str]] = []
def _factory(video_id: str, question_id: str) -> list[object]:
factory_calls.append((video_id, question_id))
return []
questions = [
_make_question(question_id="q1", video_id="v1"),
_make_question(question_id="q2", video_id="v2"),
]
await run_inference(
questions,
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-factory",
concurrency=2,
max_steps=10,
skill_mode="auto",
plugins_factory=_factory,
)
assert len(factory_calls) == 2
call_set = set(factory_calls)
assert ("v1", "q1") in call_set
assert ("v2", "q2") in call_set
@pytest.mark.asyncio
async def test_no_factory_uses_empty_plugins(self, harness_log: HarnessLog) -> None:
"""plugins_factory=None 时使用空 plugins 列表。"""
llm = AsyncMock()
llm.chat.return_value = _make_llm_response(answer="B")
result = await run_inference(
[_make_question()],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-no-factory",
concurrency=1,
max_steps=10,
skill_mode="auto",
plugins_factory=None,
)
assert result.total == 1
assert result.stop_reason_counts.get("finished") == 1
class TestConcurrencyControl:
"""并发控制 Semaphore 测试。"""
@pytest.mark.asyncio
async def test_concurrency_semaphore_limits(self, harness_log: HarnessLog) -> None:
"""Semaphore(1) 限制并发为 1 — 通过最大并发计数器验证。"""
import asyncio
llm = AsyncMock()
current_concurrent = 0
max_concurrent = 0
original_response = _make_llm_response(answer="B")
async def _slow_chat(
messages: Any,
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> LLMResponse:
nonlocal current_concurrent, max_concurrent
current_concurrent += 1
max_concurrent = max(max_concurrent, current_concurrent)
await asyncio.sleep(0.01)
current_concurrent -= 1
return original_response
llm.chat.side_effect = _slow_chat
questions = [_make_question(question_id=f"q{i}") for i in range(5)]
await run_inference(
questions,
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-sem",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
assert max_concurrent == 1
class TestTablesCreated:
"""表创建测试。"""
@pytest.mark.asyncio
async def test_five_tables_created(self, harness_log: HarnessLog) -> None:
"""run_inference 启动时创建 5 张推理表。"""
llm = AsyncMock()
await run_inference(
[],
llm=llm,
tool_dispatch_fn=_stub_tool_dispatch,
prompt_builder=_stub_prompt_builder,
log=harness_log,
run_id="run-tables",
concurrency=1,
max_steps=10,
skill_mode="auto",
)
expected_tables = [
"predictions",
"traces",
"validation_flags",
"anchor_check",
"observe_frame_health",
]
for table_name in expected_tables:
rows = harness_log.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
assert len(rows) == 1, f"{table_name} 未创建"
+347
View File
@@ -0,0 +1,347 @@
"""HarnessLog + RunLogImpl 单元测试。
HarnessLog: SQLite 薄包装的线程安全、幂等、context manager 语义验证。
RunLogImpl: 只读 RunLog Protocol 实现,独立连接不污染 _runs 运行状态。
"""
from __future__ import annotations
import sqlite3
import threading
from typing import TYPE_CHECKING
import pytest
from app.harness.log import HarnessLog, RunLogImpl
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def db_path(tmp_path: Path) -> str:
"""返回临时 SQLite 数据库路径。"""
return str(tmp_path / "test.db")
@pytest.fixture()
def run_id() -> str:
return "test-run-001"
# ===========================================================================
# HarnessLog 测试
# ===========================================================================
class TestHarnessLog:
"""HarnessLog 核心功能测试。"""
def test_create_table_and_insert(self, db_path: str, run_id: str) -> None:
"""create_table 建表 + insert 写入 + query 读回。"""
with HarnessLog(db_path, run_id) as log:
log.create_table("metrics", {"epoch": "INTEGER", "loss": "REAL"})
log.insert("metrics", {"epoch": 1, "loss": 0.5})
rows = log.query("SELECT * FROM metrics WHERE run_id = ?", (run_id,))
assert len(rows) == 1
assert rows[0]["epoch"] == 1
assert rows[0]["loss"] == 0.5
assert rows[0]["run_id"] == run_id
# insert 自动填充 timestamp
assert rows[0]["timestamp"] is not None
def test_query_thread_safety(self, db_path: str, run_id: str) -> None:
"""并发读写不损坏游标状态。"""
errors: list[Exception] = []
with HarnessLog(db_path, run_id) as log:
log.create_table("counter", {"value": "INTEGER"})
def writer() -> None:
try:
for i in range(50):
log.insert("counter", {"value": i})
except Exception as exc:
errors.append(exc)
def reader() -> None:
try:
for _ in range(50):
log.query("SELECT COUNT(*) as cnt FROM counter")
except Exception as exc:
errors.append(exc)
threads = [
threading.Thread(target=writer),
threading.Thread(target=reader),
threading.Thread(target=reader),
]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"并发读写产生错误: {errors}"
def test_context_manager_completed(self, db_path: str, run_id: str) -> None:
"""正常退出时 status = completed。"""
with HarnessLog(db_path, run_id):
pass
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT status, finished_at FROM _runs WHERE run_id = ?", (run_id,)
).fetchone()
conn.close()
assert row["status"] == "completed"
assert row["finished_at"] is not None
def test_context_manager_failed(self, db_path: str, run_id: str) -> None:
"""异常退出时 status = failed。"""
with pytest.raises(ValueError, match="boom"), HarnessLog(db_path, run_id):
raise ValueError("boom")
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT status FROM _runs WHERE run_id = ?", (run_id,)
).fetchone()
conn.close()
assert row["status"] == "failed"
def test_insert_or_ignore_idempotent(self, db_path: str, run_id: str) -> None:
"""同一 run_id 多次创建 HarnessLog 不报错(INSERT OR IGNORE 幂等)。"""
with HarnessLog(db_path, run_id):
pass
# 再次用同一 run_id 打开——不应抛异常
with HarnessLog(db_path, run_id) as log:
log.create_table("t", {"x": "INTEGER"})
log.insert("t", {"x": 42})
conn = sqlite3.connect(db_path)
count = conn.execute(
"SELECT COUNT(*) FROM _runs WHERE run_id = ?", (run_id,)
).fetchone()[0]
conn.close()
assert count == 1, "INSERT OR IGNORE 应保证 _runs 只有一行"
def test_wal_mode(self, db_path: str, run_id: str) -> None:
"""连接初始化后 journal_mode 应为 WAL。"""
with HarnessLog(db_path, run_id) as log:
rows = log.query("PRAGMA journal_mode")
assert rows[0]["journal_mode"].lower() == "wal"
def test_insert_many(self, db_path: str, run_id: str) -> None:
"""insert_many 批量插入多条记录。"""
records = [{"epoch": i, "loss": float(i) * 0.1} for i in range(5)]
with HarnessLog(db_path, run_id) as log:
log.create_table("batch", {"epoch": "INTEGER", "loss": "REAL"})
log.insert_many("batch", records)
rows = log.query(
"SELECT * FROM batch WHERE run_id = ? ORDER BY epoch", (run_id,)
)
assert len(rows) == 5
assert [r["epoch"] for r in rows] == [0, 1, 2, 3, 4]
def test_log_event(self, db_path: str, run_id: str) -> None:
"""log_event 向 _events 表写入事件。"""
with HarnessLog(db_path, run_id) as log:
log.log_event("train_start", {"epoch": 1, "lr": 0.001})
log.log_event("train_end", {"epoch": 1, "loss": 0.42})
rows = log.query(
"SELECT * FROM _events WHERE run_id = ? ORDER BY id", (run_id,)
)
assert len(rows) == 2
assert rows[0]["event_type"] == "train_start"
assert rows[1]["event_type"] == "train_end"
def test_execute_raw_sql(self, db_path: str, run_id: str) -> None:
"""execute 执行原生 SQL 写操作。"""
with HarnessLog(db_path, run_id) as log:
log.create_table("raw", {"val": "INTEGER"})
log.execute(
"INSERT INTO raw (run_id, timestamp, val) VALUES (?, ?, ?)",
(run_id, "2026-01-01T00:00:00", 99),
)
rows = log.query("SELECT val FROM raw WHERE run_id = ?", (run_id,))
assert len(rows) == 1
assert rows[0]["val"] == 99
def test_upsert_mode(self, db_path: str, run_id: str) -> None:
"""insert mode='upsert' 使用 INSERT OR REPLACE。"""
with HarnessLog(db_path, run_id) as log:
log.create_table("kv", {"key": "TEXT", "val": "TEXT"}, primary_key="key")
log.insert("kv", {"key": "a", "val": "1"}, mode="upsert")
log.insert("kv", {"key": "a", "val": "2"}, mode="upsert")
rows = log.query("SELECT val FROM kv WHERE key = 'a'")
assert len(rows) == 1
assert rows[0]["val"] == "2"
# ===========================================================================
# RunLogImpl 测试
# ===========================================================================
def _setup_predictions_and_traces(db_path: str, run_id: str) -> None:
"""向测试数据库写入 predictions 和 traces 数据,模拟 inference 阶段产物。"""
with HarnessLog(db_path, run_id) as log:
log.create_table(
"predictions",
{
"question_id": "TEXT",
"predicted_answer": "TEXT",
"correct": "INTEGER",
},
)
log.create_table(
"traces",
{
"question_id": "TEXT",
"step_idx": "INTEGER",
"action": "TEXT",
"observation": "TEXT",
},
)
log.insert_many(
"predictions",
[
{"question_id": "q1", "predicted_answer": "A", "correct": 1},
{"question_id": "q2", "predicted_answer": "B", "correct": 0},
{"question_id": "q3", "predicted_answer": "C", "correct": 1},
],
)
log.insert_many(
"traces",
[
{
"question_id": "q1",
"step_idx": 0,
"action": "search",
"observation": "found",
},
{
"question_id": "q1",
"step_idx": 1,
"action": "verify",
"observation": "ok",
},
{
"question_id": "q2",
"step_idx": 0,
"action": "search",
"observation": "not found",
},
],
)
class TestRunLogImpl:
"""RunLogImpl 只读查询端口测试。"""
@pytest.mark.asyncio
async def test_get_predictions(self, db_path: str, run_id: str) -> None:
"""get_predictions 返回指定 run 的全部预测记录。"""
_setup_predictions_and_traces(db_path, run_id)
impl = RunLogImpl(db_path)
preds = await impl.get_predictions(run_id)
assert len(preds) == 3
q_ids = {p["question_id"] for p in preds}
assert q_ids == {"q1", "q2", "q3"}
@pytest.mark.asyncio
async def test_get_predictions_filtered(self, db_path: str, run_id: str) -> None:
"""get_predictions 按 question_ids 过滤。"""
_setup_predictions_and_traces(db_path, run_id)
impl = RunLogImpl(db_path)
preds = await impl.get_predictions(run_id, question_ids=["q1", "q3"])
assert len(preds) == 2
q_ids = {p["question_id"] for p in preds}
assert q_ids == {"q1", "q3"}
@pytest.mark.asyncio
async def test_get_traces(self, db_path: str, run_id: str) -> None:
"""get_traces 返回指定 run 的全部轨迹记录。"""
_setup_predictions_and_traces(db_path, run_id)
impl = RunLogImpl(db_path)
traces = await impl.get_traces(run_id)
assert len(traces) == 3
# q1 有 2 步,q2 有 1 步
q1_traces = [t for t in traces if t["question_id"] == "q1"]
assert len(q1_traces) == 2
@pytest.mark.asyncio
async def test_get_traces_filtered(self, db_path: str, run_id: str) -> None:
"""get_traces 按 question_ids 过滤。"""
_setup_predictions_and_traces(db_path, run_id)
impl = RunLogImpl(db_path)
traces = await impl.get_traces(run_id, question_ids=["q2"])
assert len(traces) == 1
assert traces[0]["question_id"] == "q2"
@pytest.mark.asyncio
async def test_readonly_no_runs_insert(self, db_path: str, run_id: str) -> None:
"""RunLogImpl 查询不触发 _runs INSERT(不污染运行状态)。"""
_setup_predictions_and_traces(db_path, run_id)
# 用不同 run_id 查询,确保不会创建新的 _runs 行
other_run = "nonexistent-run"
impl = RunLogImpl(db_path)
preds = await impl.get_predictions(other_run)
assert preds == []
# 验证 _runs 表没有 other_run 的记录
conn = sqlite3.connect(db_path)
count = conn.execute(
"SELECT COUNT(*) FROM _runs WHERE run_id = ?", (other_run,)
).fetchone()[0]
conn.close()
assert count == 0, "RunLogImpl 不应向 _runs 插入记录"
@pytest.mark.asyncio
async def test_protocol_compliance(self, db_path: str, run_id: str) -> None:
"""RunLogImpl 满足 RunLog Protocolruntime_checkable isinstance 检查)。"""
from core.evolution.protocols import RunLog
impl = RunLogImpl(db_path)
assert isinstance(impl, RunLog)
@pytest.mark.asyncio
async def test_missing_table_returns_empty(self, db_path: str, run_id: str) -> None:
"""查询不存在的表(predictions/traces 未建)返回空列表。"""
# 仅创建 _runs,不建 predictions/traces 表
with HarnessLog(db_path, run_id):
pass
impl = RunLogImpl(db_path)
preds = await impl.get_predictions(run_id)
traces = await impl.get_traces(run_id)
assert preds == []
assert traces == []
+261
View File
@@ -0,0 +1,261 @@
"""慢更新动量生成(app/harness/momentum.py)单元测试。
覆盖:
- _categorize_pair 四分类 + 缺键 KeyError
- _format_comparison_pairs 分组排序 + 空列表
- run_slow_momentum 正常解析 + 解析失败保留 prev_guidance
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock
import pytest
from app.harness.momentum import (
IMPROVED,
PERSISTENT_FAIL,
REGRESSED,
STABLE_SUCCESS,
_categorize_pair,
_format_comparison_pairs,
run_slow_momentum,
)
if TYPE_CHECKING:
from pathlib import Path
# =========================================================================
# _categorize_pair
# =========================================================================
class TestCategorizePair:
"""_categorize_pair 四分类测试。"""
def test_categorize_pair_all_four(self) -> None:
"""四种 correct_prev/correct_curr 组合应返回对应类别常量。"""
assert _categorize_pair({"correct_prev": False, "correct_curr": True}) == IMPROVED
assert _categorize_pair({"correct_prev": True, "correct_curr": False}) == REGRESSED
assert _categorize_pair({"correct_prev": False, "correct_curr": False}) == PERSISTENT_FAIL
assert _categorize_pair({"correct_prev": True, "correct_curr": True}) == STABLE_SUCCESS
def test_categorize_pair_truthy_values(self) -> None:
"""非布尔真值(int / str)也能正确分类。"""
assert _categorize_pair({"correct_prev": 0, "correct_curr": 1}) == IMPROVED
assert _categorize_pair({"correct_prev": "yes", "correct_curr": ""}) == REGRESSED
def test_categorize_pair_missing_key(self) -> None:
"""缺 correct_prev 或 correct_curr 键时抛 KeyError。"""
with pytest.raises(KeyError):
_categorize_pair({"correct_prev": True})
with pytest.raises(KeyError):
_categorize_pair({"correct_curr": False})
with pytest.raises(KeyError):
_categorize_pair({})
# =========================================================================
# _format_comparison_pairs
# =========================================================================
class TestFormatComparisonPairs:
"""_format_comparison_pairs 分组排序测试。"""
def test_format_comparison_pairs_empty(self) -> None:
"""空列表返回占位说明。"""
result = _format_comparison_pairs([])
assert "无可用" in result
def test_format_comparison_pairs_order(self) -> None:
"""REGRESSED 标题应出现在 IMPROVED 标题之前(伤害信号优先)。"""
pairs = [
{
"question": "Q1",
"prev_prediction": "A",
"curr_prediction": "B",
"correct_prev": False,
"correct_curr": True,
},
{
"question": "Q2",
"prev_prediction": "C",
"curr_prediction": "D",
"correct_prev": True,
"correct_curr": False,
},
]
result = _format_comparison_pairs(pairs)
regressed_pos = result.index("回退")
improved_pos = result.index("改善")
assert regressed_pos < improved_pos, "REGRESSED 应排在 IMPROVED 之前"
def test_format_comparison_pairs_all_categories(self) -> None:
"""四种类别的 pair 都能被正确分组。"""
pairs = [
{"question": "Q1", "correct_prev": False, "correct_curr": True},
{"question": "Q2", "correct_prev": True, "correct_curr": False},
{"question": "Q3", "correct_prev": False, "correct_curr": False},
{"question": "Q4", "correct_prev": True, "correct_curr": True},
]
result = _format_comparison_pairs(pairs)
assert "固定样本总数:4" in result
# 每个类别都应标注 1 题
assert "1 题" in result
def test_format_comparison_pairs_missing_key(self) -> None:
"""pair 缺键时 KeyError 不被吞。"""
with pytest.raises(KeyError):
_format_comparison_pairs([{"question": "Q1"}])
# =========================================================================
# run_slow_momentum
# =========================================================================
def _make_mock_llm(response_content: str) -> Any:
"""构造一个返回指定 content 的 mock LLMProvider。"""
@dataclass(frozen=True)
class _FakeResponse:
content: str
thinking: str = ""
model: str = "mock"
provider: str = "mock"
prompt_tokens: int = 0
completion_tokens: int = 0
latency_ms: int = 0
ttft_ms: float | None = None
max_inter_token_ms: float | None = None
cache_hit: bool = False
call_id: str = "test-call-id"
mock_llm = AsyncMock()
mock_llm.chat.return_value = _FakeResponse(content=response_content)
return mock_llm
@pytest.mark.asyncio
async def test_run_slow_momentum_basic(tmp_path: Path) -> None:
"""正常解析时返回新的动量指导文本。"""
# 准备 prompt 文件
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
new_guidance_text = "新一轮的动量指导内容"
llm_response = json.dumps({"slow_update_content": new_guidance_text}, ensure_ascii=False)
mock_llm = _make_mock_llm(llm_response)
result = await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill 正文",
prev_skill="上一版 skill 正文",
prev_guidance="旧的动量指导",
comparison_pairs=[
{
"question": "Q1",
"prev_prediction": "A",
"curr_prediction": "B",
"correct_prev": False,
"correct_curr": True,
}
],
)
assert result == new_guidance_text
mock_llm.chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_slow_momentum_parse_failure(tmp_path: Path) -> None:
"""LLM 返回无法解析的内容时保留 prev_guidance。"""
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
mock_llm = _make_mock_llm("这不是 JSON,无法解析")
prev_guidance = "应该被保留的旧动量指导"
result = await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill",
prev_skill="上一版 skill",
prev_guidance=prev_guidance,
comparison_pairs=[
{
"question": "Q1",
"prev_prediction": "A",
"curr_prediction": "B",
"correct_prev": True,
"correct_curr": True,
}
],
)
assert result == prev_guidance
@pytest.mark.asyncio
async def test_run_slow_momentum_missing_field(tmp_path: Path) -> None:
"""LLM 返回合法 JSON 但缺少 slow_update_content 字段时保留 prev_guidance。"""
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
llm_response = json.dumps({"other_field": "无关内容"}, ensure_ascii=False)
mock_llm = _make_mock_llm(llm_response)
prev_guidance = "应该被保留的旧动量指导"
result = await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill",
prev_skill="上一版 skill",
prev_guidance=prev_guidance,
comparison_pairs=[],
)
assert result == prev_guidance
@pytest.mark.asyncio
async def test_run_slow_momentum_keyerror_not_swallowed(tmp_path: Path) -> None:
"""comparison_pairs 缺键时 KeyError 不被 ValueError 吞掉。"""
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
mock_llm = _make_mock_llm('{"slow_update_content": "ok"}')
with pytest.raises(KeyError):
await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill",
prev_skill="上一版 skill",
prev_guidance="旧指导",
comparison_pairs=[{"question": "Q1"}], # 缺 correct_prev/correct_curr
)
@pytest.mark.asyncio
async def test_run_slow_momentum_fenced_json(tmp_path: Path) -> None:
"""LLM 返回 fenced code block 中的 JSON 也能正确解析。"""
prompt_file = tmp_path / "slow_momentum.md"
prompt_file.write_text("你是一个慢更新动量裁判。", encoding="utf-8")
new_guidance = "从 fenced block 中提取的指导"
llm_response = f'```json\n{{"slow_update_content": "{new_guidance}"}}\n```'
mock_llm = _make_mock_llm(llm_response)
result = await run_slow_momentum(
llm=mock_llm,
diagnose_prompts_dir=tmp_path,
skill_content="当前 skill",
prev_skill="上一版 skill",
prev_guidance="旧指导",
comparison_pairs=[],
)
assert result == new_guidance
+354
View File
@@ -0,0 +1,354 @@
"""五张观测表 + step/epoch 报告的单元测试。"""
from __future__ import annotations
import json
import sqlite3
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
from app.harness.observation import (
read_dual_metric,
read_gate_evidence,
read_holdout_eval,
read_quadrant_pairs,
read_shadow_gate,
write_dual_metric,
write_epoch_report,
write_gate_evidence,
write_holdout_eval,
write_quadrant_pairs,
write_shadow_gate,
write_step_report,
)
@pytest.fixture()
def db_path(tmp_path: Path) -> str:
"""返回临时 SQLite 路径。"""
return str(tmp_path / "test_obs.db")
@pytest.fixture()
def run_id() -> str:
return "run-obs-001"
# ---------------------------------------------------------------------------
# dual_metric_eval
# ---------------------------------------------------------------------------
def test_write_read_dual_metric(db_path: str, run_id: str) -> None:
"""写入 dual_metric_eval 后回读应与输入一致。"""
write_dual_metric(
db_path,
run_id=run_id,
epoch=1,
version_kind="baseline",
skills_version="v0",
prompts_version="v0",
pool="val",
hard_acc=0.75,
soft_score=0.80,
mixed_score=0.775,
)
rows = read_dual_metric(db_path, run_id=run_id)
assert len(rows) == 1
row = rows[0]
assert row["epoch"] == 1
assert row["version_kind"] == "baseline"
assert row["skills_version"] == "v0"
assert row["prompts_version"] == "v0"
assert row["pool"] == "val"
assert row["hard_acc"] == pytest.approx(0.75)
assert row["soft_score"] == pytest.approx(0.80)
assert row["mixed_score"] == pytest.approx(0.775)
assert row["run_id"] == run_id
# ---------------------------------------------------------------------------
# shadow_gate
# ---------------------------------------------------------------------------
def test_write_read_shadow_gate(db_path: str, run_id: str) -> None:
"""写入 shadow_gate 后回读应与输入一致,is_mixed_best 布尔转 int。"""
write_shadow_gate(
db_path,
run_id=run_id,
epoch=2,
candidate_version="skills/v1+prompts/v1",
hard_acc=0.82,
soft_score=0.78,
mixed_score=0.80,
is_mixed_best=True,
)
rows = read_shadow_gate(db_path, run_id=run_id)
assert len(rows) == 1
row = rows[0]
assert row["epoch"] == 2
assert row["candidate_version"] == "skills/v1+prompts/v1"
assert row["hard_acc"] == pytest.approx(0.82)
assert row["is_mixed_best"] == 1
# ---------------------------------------------------------------------------
# holdout_eval
# ---------------------------------------------------------------------------
def test_write_read_holdout_eval(db_path: str, run_id: str) -> None:
"""写入 holdout_eval 后回读应与输入一致。"""
per_task = json.dumps({"temporal": {"accuracy": 0.9, "total": 10, "correct": 9}})
write_holdout_eval(
db_path,
run_id=run_id,
epoch=1,
version_kind="best_hard",
hard_acc=0.85,
soft_score=0.70,
mixed_score=0.775,
per_task_type_json=per_task,
)
rows = read_holdout_eval(db_path, run_id=run_id)
assert len(rows) == 1
row = rows[0]
assert row["version_kind"] == "best_hard"
assert row["hard_acc"] == pytest.approx(0.85)
parsed = json.loads(row["per_task_type_json"])
assert parsed["temporal"]["correct"] == 9
# ---------------------------------------------------------------------------
# gate_evidence
# ---------------------------------------------------------------------------
def test_write_read_gate_evidence(db_path: str, run_id: str) -> None:
"""写入 gate_evidence 后回读应与输入一致,逐行插入。"""
evidence_rows = [
{
"task_type": "temporal",
"question_id": "q1",
"block_idx": 0,
"baseline_correct": 1,
"candidate_correct": 1,
"e_value": 1.0,
"stop_reason": "",
},
{
"task_type": "temporal",
"question_id": "q2",
"block_idx": 0,
"baseline_correct": 0,
"candidate_correct": 1,
"e_value": 2.0,
"stop_reason": "confirmed",
},
]
write_gate_evidence(db_path, run_id=run_id, epoch=1, step=0, rows=evidence_rows)
rows = read_gate_evidence(db_path, run_id=run_id)
assert len(rows) == 2
assert rows[0]["question_id"] == "q1"
assert rows[1]["stop_reason"] == "confirmed"
assert rows[1]["e_value"] == pytest.approx(2.0)
# ---------------------------------------------------------------------------
# quadrant_pair
# ---------------------------------------------------------------------------
def test_write_read_quadrant_pairs(db_path: str, run_id: str) -> None:
"""写入 quadrant_pair 后回读,bool -> int 转换正确。"""
pairs = [
{
"question_id": "q1",
"task_type": "causal",
"prev_correct": True,
"curr_correct": False,
"category": "regression",
},
{
"question_id": "q2",
"task_type": "causal",
"prev_correct": False,
"curr_correct": True,
"category": "improvement",
},
]
write_quadrant_pairs(db_path, run_id=run_id, epoch=1, step=0, pairs=pairs)
rows = read_quadrant_pairs(db_path, run_id=run_id)
assert len(rows) == 2
# bool -> int 转换
assert rows[0]["prev_correct"] == 1
assert rows[0]["curr_correct"] == 0
assert rows[1]["prev_correct"] == 0
assert rows[1]["curr_correct"] == 1
# ---------------------------------------------------------------------------
# NULL vs 0 语义
# ---------------------------------------------------------------------------
def test_null_not_zero_for_soft(db_path: str, run_id: str) -> None:
"""soft_score/mixed_score 为 None 时存 NULL(非 0),回读也是 None。"""
write_dual_metric(
db_path,
run_id=run_id,
epoch=1,
version_kind="baseline",
skills_version="v0",
prompts_version="v0",
pool="val",
hard_acc=0.75,
soft_score=None,
mixed_score=None,
)
rows = read_dual_metric(db_path, run_id=run_id)
assert len(rows) == 1
row = rows[0]
assert row["soft_score"] is None
assert row["mixed_score"] is None
# 用原生 SQL 确认存的是 NULL 而非 0
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT soft_score, mixed_score FROM dual_metric_eval WHERE run_id=?",
(run_id,),
)
raw = cursor.fetchone()
conn.close()
assert raw[0] is None
assert raw[1] is None
# ---------------------------------------------------------------------------
# 只读连接隔离
# ---------------------------------------------------------------------------
def test_read_only_connection(db_path: str, run_id: str) -> None:
"""read_* 使用独立只读连接,不向 _runs 表插入新行。"""
write_dual_metric(
db_path,
run_id=run_id,
epoch=1,
version_kind="baseline",
skills_version="v0",
prompts_version="v0",
pool="val",
hard_acc=0.5,
soft_score=None,
mixed_score=None,
)
# 用另一个 run_id 回读——不应在 _runs 表中创建新行
other_run = "run-obs-ghost"
rows = read_dual_metric(db_path, run_id=other_run)
assert rows == []
conn = sqlite3.connect(db_path)
cursor = conn.execute("SELECT run_id FROM _runs")
run_ids = [r[0] for r in cursor.fetchall()]
conn.close()
assert other_run not in run_ids
# ---------------------------------------------------------------------------
# step_report
# ---------------------------------------------------------------------------
def test_write_step_report(tmp_path: Path) -> None:
"""step_report 写入 JSON 文件,内容字段完整。"""
workspace = tmp_path / "ws"
workspace.mkdir()
path = write_step_report(
workspace_dir=workspace,
epoch=1,
step=2,
global_step=12,
task_type="Temporal Order",
gate_action="accept_confirmed",
candidate_acc=0.85,
class_baseline_acc=0.70,
edit_budget=5,
rank_clip_triggered=False,
gate_w=3,
gate_l=1,
gate_e_value=4.2,
gate_n_used=8,
gate_stop_reason="confirmed",
)
assert path.exists()
assert path.name == "step_report_e1_s2_temporal-order.json"
data = json.loads(path.read_text(encoding="utf-8"))
assert data["epoch"] == 1
assert data["step"] == 2
assert data["global_step"] == 12
assert data["task_type"] == "Temporal Order"
assert data["gate_action"] == "accept_confirmed"
assert data["gate_w"] == 3
assert data["gate_stop_reason"] == "confirmed"
assert data["rank_clip_triggered"] is False
def test_write_step_report_skipped_null_fields(tmp_path: Path) -> None:
"""skipped/cooldown 路径的 gate 字段应为 null。"""
workspace = tmp_path / "ws"
workspace.mkdir()
path = write_step_report(
workspace_dir=workspace,
epoch=1,
step=0,
global_step=0,
task_type="causal",
gate_action="skipped",
candidate_acc=0.0,
class_baseline_acc=0.0,
edit_budget=10,
rank_clip_triggered=False,
gate_w=None,
gate_l=None,
gate_e_value=None,
gate_n_used=None,
gate_stop_reason=None,
)
data = json.loads(path.read_text(encoding="utf-8"))
assert data["gate_w"] is None
assert data["gate_l"] is None
assert data["gate_e_value"] is None
assert data["gate_n_used"] is None
assert data["gate_stop_reason"] is None
# ---------------------------------------------------------------------------
# epoch_report
# ---------------------------------------------------------------------------
def test_write_epoch_report(tmp_path: Path) -> None:
"""epoch_report 写入 JSON 文件,内容字段完整。"""
workspace = tmp_path / "ws"
workspace.mkdir()
path = write_epoch_report(
workspace_dir=workspace,
epoch=3,
system_tool_action="updated",
momentum_updated_task_types=["temporal", "causal"],
best_val_acc=0.88,
)
assert path.exists()
assert path.name == "epoch_report_3.json"
data = json.loads(path.read_text(encoding="utf-8"))
assert data["epoch"] == 3
assert data["system_tool_action"] == "updated"
assert data["momentum_updated_task_types"] == ["temporal", "causal"]
assert data["best_val_acc"] == pytest.approx(0.88)
+290
View File
@@ -0,0 +1,290 @@
"""三池切分单元测试。
验证:
- 三池互斥(question_id 无重叠)
- test 池自然分布(correct_ratio=None
- save/load 往返一致
- 旧格式拒绝(无 test 键 → ValueError
- build_or_load_pools 冻结复用(pools.json 存在时不重切)
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from app.harness.pools import (
build_pools,
load_pools,
save_pools,
)
from core.types import GeneratedQuestion
if TYPE_CHECKING:
from pathlib import Path
def _make_question(qid: str, task_type: str = "Action Reasoning") -> GeneratedQuestion:
"""构造测试用 GeneratedQuestion。
参数:
qid: 题目 ID。
task_type: 题型。
返回:
GeneratedQuestion 实例。
"""
return GeneratedQuestion(
question_id=qid,
video_id="video_001",
task_type=task_type,
question=f"Question {qid}?",
options=("A. opt1", "B. opt2", "C. opt3", "D. opt4"),
answer="A",
source_nodes=("node_1",),
difficulty="medium",
)
def _make_question_set(
n: int,
task_types: list[str] | None = None,
) -> list[GeneratedQuestion]:
"""构造 n 道题,交替分配题型。
参数:
n: 题目数量。
task_types: 可选题型列表,轮转分配;None 默认 2 类。
返回:
题目列表。
"""
types = task_types or ["Action Reasoning", "Scene Understanding"]
return [_make_question(f"q_{i:04d}", types[i % len(types)]) for i in range(n)]
def _make_correctness(
questions: list[GeneratedQuestion],
correct_ratio: float = 0.5,
) -> dict[str, bool]:
"""构造 correctness 字典,前 correct_ratio 比例标对。
参数:
questions: 题目列表。
correct_ratio: 对题占比。
返回:
question_id -> bool。
"""
n_correct = round(len(questions) * correct_ratio)
return {q.question_id: (i < n_correct) for i, q in enumerate(questions)}
class TestBuildPoolsMutualExclusion:
"""三池 question_id 互斥验证。"""
def test_build_pools_mutual_exclusion(self) -> None:
"""三池切分后,任意两池不共享 question_id。"""
questions = _make_question_set(200)
correctness = _make_correctness(questions, 0.5)
pools = build_pools(
questions,
correctness,
diag_cfg={
"size": 30,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 30,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 30},
baseline_run_id="run_baseline",
)
diag_ids = {q.question_id for q in pools.diagnosis}
val_ids = {q.question_id for q in pools.validation}
test_ids = {q.question_id for q in pools.test}
assert diag_ids & val_ids == set(), "诊断池与验证池有重叠"
assert diag_ids & test_ids == set(), "诊断池与测试池有重叠"
assert val_ids & test_ids == set(), "验证池与测试池有重叠"
assert len(diag_ids) == 30
assert len(val_ids) == 30
assert len(test_ids) == 30
class TestBuildPoolsTestNaturalDistribution:
"""test 池使用自然分布(correct_ratio=None)。"""
def test_build_pools_test_natural_distribution(self) -> None:
"""test 池不强制对错比例,保留候选池的自然分布。
构造 correctness 为 50% 对/50% 错,diag/val 用 correct_ratio=0.3
强制裁剪,test 池走自然分布(correct_ratio=None)。验证 test 池
不受 correct_ratio 约束。
"""
questions = _make_question_set(300)
correctness = _make_correctness(questions, 0.5)
pools = build_pools(
questions,
correctness,
diag_cfg={
"size": 20,
"correct_ratio": 0.3,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 20,
"correct_ratio": 0.3,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 20},
baseline_run_id="run_baseline",
)
# diag/val 被 correct_ratio=0.3 裁剪:round(20*0.3) = 6 对, 14 错
diag_correct = sum(1 for q in pools.diagnosis if correctness[q.question_id])
val_correct = sum(1 for q in pools.validation if correctness[q.question_id])
assert diag_correct == 6, "诊断池应强制 30% 对题"
assert val_correct == 6, "验证池应强制 30% 对题"
# test 池自然分布:不受 correct_ratio 约束
assert len(pools.test) == 20
class TestSaveLoadPoolsRoundtrip:
"""save/load 往返一致验证。"""
def test_save_load_pools_roundtrip(self, tmp_path: Path) -> None:
"""save_pools → load_pools 后全字段一致。"""
questions = _make_question_set(100)
correctness = _make_correctness(questions, 0.5)
original = build_pools(
questions,
correctness,
diag_cfg={
"size": 15,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 15,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 15},
baseline_run_id="run_001",
)
pools_path = tmp_path / "pools.json"
save_pools(original, pools_path)
restored = load_pools(pools_path)
# 标量字段
assert restored.baseline_run_id == original.baseline_run_id
assert restored.baseline_val_accuracy == pytest.approx(original.baseline_val_accuracy)
assert restored.correctness == original.correctness
# 三池逐题比对
for pool_name in ("diagnosis", "validation", "test"):
orig_list = getattr(original, pool_name)
rest_list = getattr(restored, pool_name)
assert len(rest_list) == len(orig_list), f"{pool_name} 长度不一致"
for o, r in zip(orig_list, rest_list, strict=False):
assert o.question_id == r.question_id
assert o.video_id == r.video_id
assert o.task_type == r.task_type
assert o.question == r.question
assert o.options == r.options
assert o.answer == r.answer
assert o.source_nodes == r.source_nodes
assert o.difficulty == r.difficulty
class TestLoadPoolsOldFormatReject:
"""旧格式 pools.json(无 test 键)→ ValueError。"""
def test_load_pools_old_format_reject(self, tmp_path: Path) -> None:
"""缺少 test 键的 pools.json 必须抛出 ValueError。"""
old_format = {
"baseline_run_id": "run_old",
"baseline_val_accuracy": 0.5,
"correctness": {},
"diagnosis": [],
"validation": [],
}
pools_path = tmp_path / "pools.json"
pools_path.write_text(json.dumps(old_format), encoding="utf-8")
with pytest.raises(ValueError, match="旧格式"):
load_pools(pools_path)
class TestBuildOrLoadPoolsFrozen:
"""build_or_load_pools 冻结复用:pools.json 存在时原样加载不重切。"""
def test_build_or_load_pools_frozen(self, tmp_path: Path) -> None:
"""pools.json 已存在时,build_or_load_pools 返回冻结内容。"""
questions = _make_question_set(60)
correctness = _make_correctness(questions, 0.5)
frozen = build_pools(
questions,
correctness,
diag_cfg={
"size": 10,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 10,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 10},
baseline_run_id="run_frozen",
)
pools_path = tmp_path / "pools.json"
save_pools(frozen, pools_path)
# build_or_load_pools 中 pools.json 存在 → 直接 load_pools
# 此处直接测试 load_pools 行为等价
loaded = load_pools(pools_path)
assert loaded.baseline_run_id == frozen.baseline_run_id
assert loaded.baseline_val_accuracy == pytest.approx(frozen.baseline_val_accuracy)
assert len(loaded.test) == len(frozen.test)
assert len(loaded.validation) == len(frozen.validation)
assert len(loaded.diagnosis) == len(frozen.diagnosis)
# question_id 完全一致
for pool_name in ("diagnosis", "validation", "test"):
orig_ids = [q.question_id for q in getattr(frozen, pool_name)]
load_ids = [q.question_id for q in getattr(loaded, pool_name)]
assert orig_ids == load_ids, f"{pool_name} 冻结后 ID 顺序不一致"
+682
View File
@@ -0,0 +1,682 @@
"""runner.py 单元测试(算法保真 #13)。
覆盖 13a-13e 五个子任务,测试 Runner 骨架、三级嵌套、gate/accept/reject/probation、
慢更新十步序、deliver_best + early stop。大部分测试用纯函数或 mock 构造避免真实推理。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path # noqa: TC003 — 运行时 tmp_path 标注使用
from unittest.mock import MagicMock, patch
import pytest
from app.harness.runner import (
_apply_batch_correctness,
_batch_from_ids,
_build_comparison_pairs,
_compute_total_steps,
_fallback_summary,
_format_applied_edits,
_guard_infra_failures,
_outcome_to_quadrant_pairs,
_should_early_stop,
_snapshot_current_skills,
_TrainState,
_write_skip_report,
resume_plan,
)
from app.harness.validate import Probation, ValidationOutcome
from core.evolution import RejectedEdit
# ---------------------------------------------------------------------------
# 测试辅助
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _FakeInferenceResult:
"""InferenceResult 替身。"""
run_id: str = "test_run"
accuracy: float = 0.5
total: int = 10
correct: int = 5
per_task_type: dict = field(default_factory=dict)
steps_mean: float = 3.0
token_usage: dict = field(default_factory=lambda: {"prompt_tokens": 0, "completion_tokens": 0})
stop_reason_counts: dict = field(default_factory=lambda: {"finished": 10})
@dataclass(frozen=True)
class _FakeQuestion:
"""GeneratedQuestion 替身。"""
question_id: str
video_id: str = "v1"
task_type: str = "Action Reasoning"
question: str = "问题"
options: tuple = ("A", "B", "C", "D")
answer: str = "A"
source_nodes: tuple = ()
difficulty: str = "medium"
@dataclass
class _FakePools:
"""Pools 替身。"""
diagnosis: list = field(default_factory=list)
validation: list = field(default_factory=list)
test: list = field(default_factory=list)
baseline_run_id: str = "baseline_run"
baseline_val_accuracy: float = 0.5
correctness: dict = field(default_factory=dict)
# =========================================================================
# 13a: Runner 骨架 + 纯函数
# =========================================================================
class TestResumePlan:
"""resume_plan 纯函数。"""
def test_epoch_done_advances_epoch(self) -> None:
"""epoch_done 阶段:下一 epoch 从头开始。"""
plan = resume_plan(epoch=3, phase="epoch_done", step_completed=5)
assert plan["first_epoch"] == 4
assert plan["resume_epoch"] is None
assert plan["resume_step_from"] == 0
def test_in_epoch_resumes_same_epoch(self) -> None:
"""in_epoch 阶段:从同 epoch 的下一个 step 续跑。"""
plan = resume_plan(epoch=2, phase="in_epoch", step_completed=3)
assert plan["first_epoch"] == 2
assert plan["resume_epoch"] == 2
assert plan["resume_step_from"] == 4
def test_in_epoch_step_zero(self) -> None:
"""in_epoch step_completed=0:从 step 1 续跑。"""
plan = resume_plan(epoch=1, phase="in_epoch", step_completed=0)
assert plan["resume_step_from"] == 1
class TestGuardInfraFailures:
"""_guard_infra_failures 基础设施护栏。"""
def test_low_error_rate_passes(self) -> None:
"""error 率 <= 10% 不抛异常。"""
result = _FakeInferenceResult(
total=100,
stop_reason_counts={"finished": 95, "error": 5},
)
_guard_infra_failures(result, context="test") # 不应抛异常
def test_high_error_rate_raises(self) -> None:
"""error 率 > 10% 抛 RuntimeError。"""
result = _FakeInferenceResult(
total=10,
stop_reason_counts={"finished": 8, "error": 2},
)
with pytest.raises(RuntimeError, match="基础设施失败率过高"):
_guard_infra_failures(result, context="test")
def test_zero_total_does_not_crash(self) -> None:
"""total=0 时不除零崩溃。"""
result = _FakeInferenceResult(total=0, stop_reason_counts={})
_guard_infra_failures(result, context="test") # 不应抛异常
def test_no_error_key_passes(self) -> None:
"""stop_reason_counts 无 error 键时正常通过。"""
result = _FakeInferenceResult(
total=10,
stop_reason_counts={"finished": 10},
)
_guard_infra_failures(result, context="test")
# =========================================================================
# 13b: _apply_batch_correctness + _compute_total_steps
# =========================================================================
class TestApplyBatchCorrectness:
"""_apply_batch_correctness rollout 完整性护栏。"""
def test_complete_batch_updates_correctness(self) -> None:
"""完整 rollout 正常更新 correctness。"""
batch = [_FakeQuestion(question_id="q1"), _FakeQuestion(question_id="q2")]
correctness: dict[str, bool] = {}
# mock HarnessLog
mock_log = MagicMock()
mock_log.query.return_value = [
{"question_id": "q1", "prediction": "A", "answer": "A", "steps_json": "[]"},
{"question_id": "q2", "prediction": "B", "answer": "A", "steps_json": "[]"},
]
with patch("app.harness.validate._load_run_rows") as mock_load:
mock_load.return_value = {
"q1": {"prediction": "A", "answer": "A", "_correct": True, "steps": []},
"q2": {"prediction": "B", "answer": "A", "_correct": False, "steps": []},
}
_apply_batch_correctness(correctness, mock_log, "run_1", batch)
assert correctness["q1"] is True
assert correctness["q2"] is False
def test_missing_prediction_raises(self) -> None:
"""rollout 缺预测行时抛 RuntimeError。"""
batch = [_FakeQuestion(question_id="q1"), _FakeQuestion(question_id="q2")]
correctness: dict[str, bool] = {}
mock_log = MagicMock()
with patch("app.harness.validate._load_run_rows") as mock_load:
mock_load.return_value = {
"q1": {"prediction": "A", "answer": "A", "_correct": True, "steps": []},
# q2 缺失
}
with pytest.raises(RuntimeError, match="rollout 不完整"):
_apply_batch_correctness(correctness, mock_log, "run_1", batch)
class TestComputeTotalSteps:
"""_compute_total_steps 退火地平线。"""
def test_basic_calculation(self) -> None:
"""基本退火地平线计算。"""
questions = [
_FakeQuestion(question_id=f"q{i}", task_type="Action Reasoning") for i in range(20)
]
# 全错题
correctness = {q.question_id: False for q in questions}
config = MagicMock()
config.batch_size = 5
config.min_class_per_batch = 1
config.batch_correct_ratio = 0.0
config.epochs = 3
pools = _FakePools(diagnosis=questions, correctness=correctness)
total = _compute_total_steps(pools, correctness, config)
# 20 题 / batch_size 5 = 4 步/epoch * 3 epochs = 12
assert total == 12
# =========================================================================
# 13b: _batch_from_ids
# =========================================================================
class TestBatchFromIds:
"""_batch_from_ids 按 ID 重建 batch。"""
def test_preserves_order(self) -> None:
"""按 ids 顺序取出,保持原 batch 划分。"""
q1 = _FakeQuestion(question_id="q1")
q2 = _FakeQuestion(question_id="q2")
q3 = _FakeQuestion(question_id="q3")
pools = _FakePools(diagnosis=[q1, q2, q3])
batch = _batch_from_ids(pools, ["q3", "q1"])
assert [q.question_id for q in batch] == ["q3", "q1"]
# =========================================================================
# 13b: _snapshot_current_skills
# =========================================================================
class TestSnapshotCurrentSkills:
"""_snapshot_current_skills 快照 skill 文件。"""
def test_snapshots_md_files(self, tmp_path: Path) -> None:
"""只快照 .md 文件。"""
(tmp_path / "action-reasoning.md").write_text("skill content 1")
(tmp_path / "temporal.md").write_text("skill content 2")
(tmp_path / "meta.json").write_text("{}")
snapshot = _snapshot_current_skills(tmp_path)
assert "action-reasoning.md" in snapshot
assert "temporal.md" in snapshot
assert "meta.json" not in snapshot
assert snapshot["action-reasoning.md"] == "skill content 1"
# =========================================================================
# 13c: _outcome_to_quadrant_pairs
# =========================================================================
class TestOutcomeToQuadrantPairs:
"""_outcome_to_quadrant_pairs 四象限拍平。"""
def test_all_quadrants(self) -> None:
"""四象限各有一个 qid 时生成 4 条 pair。"""
outcome = ValidationOutcome(
action="accept_confirmed",
accepted=True,
stop_reason="confirmed",
e_value=10.0,
w=3,
l=0,
n_used=10,
delta_hat=0.3,
delta_shrunk=0.2,
baseline_acc=0.7,
candidate_acc=0.9,
improvements=["q1"],
regressions=["q2"],
persistent_fails=["q3"],
stable_successes=["q4"],
)
pairs = _outcome_to_quadrant_pairs("Action Reasoning", outcome)
assert len(pairs) == 4
by_qid = {p["question_id"]: p for p in pairs}
assert by_qid["q1"]["category"] == "improved"
assert by_qid["q1"]["prev_correct"] is False
assert by_qid["q1"]["curr_correct"] is True
assert by_qid["q2"]["category"] == "regressed"
assert by_qid["q3"]["category"] == "persistent_fail"
assert by_qid["q4"]["category"] == "stable_success"
def test_empty_outcome(self) -> None:
"""四象限全空时返回空列表。"""
outcome = ValidationOutcome(
action="reject",
accepted=False,
stop_reason="directional",
e_value=0.5,
w=0,
l=2,
n_used=5,
delta_hat=-0.1,
delta_shrunk=-0.05,
baseline_acc=0.8,
candidate_acc=0.6,
)
assert _outcome_to_quadrant_pairs("Any", outcome) == []
# =========================================================================
# 13c: _build_comparison_pairs
# =========================================================================
class TestBuildComparisonPairs:
"""_build_comparison_pairs momentum 纵向对比对。"""
def test_builds_pairs(self) -> None:
"""正确构造对比对。"""
sampled = [_FakeQuestion(question_id="q1", question="问题1")]
prev_rows = {
"q1": {"prediction": "A", "_correct": True},
}
curr_rows = {
"q1": {"prediction": "B", "_correct": False},
}
pairs = _build_comparison_pairs(sampled, prev_rows, curr_rows)
assert len(pairs) == 1
assert pairs[0]["question"] == "问题1"
assert pairs[0]["prev_prediction"] == "A"
assert pairs[0]["curr_prediction"] == "B"
assert pairs[0]["correct_prev"] is True
assert pairs[0]["correct_curr"] is False
def test_missing_rows_use_defaults(self) -> None:
"""缺失行时使用默认值。"""
sampled = [_FakeQuestion(question_id="q1")]
pairs = _build_comparison_pairs(sampled, {}, {})
assert pairs[0]["prev_prediction"] == ""
assert pairs[0]["correct_prev"] is False
# =========================================================================
# 13e: _should_early_stop
# =========================================================================
class TestShouldEarlyStop:
"""_should_early_stop 步粒度 early stop。"""
def test_improved_this_epoch_resets(self, tmp_path: Path) -> None:
"""本 epoch best 刷新时重置计数器。"""
# 写 manifest + best
manifest = {
"name": "test",
"store": ".",
"current": {"videos": "v", "questions": "q", "skills": "s/v1", "prompts": "p/v1"},
"best": {"epoch": 2, "val_acc": 0.9},
"history": [],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest))
state = MagicMock()
state.steps_since_best_improved = 10
result = _should_early_stop(tmp_path, epoch=2, steps_this_epoch=5, state=state, patience=20)
assert result is False
assert state.steps_since_best_improved == 0
def test_no_improvement_accumulates(self, tmp_path: Path) -> None:
"""未刷新时累加步数。"""
manifest = {
"name": "test",
"store": ".",
"current": {"videos": "v", "questions": "q", "skills": "s/v1", "prompts": "p/v1"},
"best": {"epoch": 1, "val_acc": 0.5},
"history": [],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest))
state = MagicMock()
state.steps_since_best_improved = 15
result = _should_early_stop(tmp_path, epoch=3, steps_this_epoch=5, state=state, patience=20)
assert result is True # 15 + 5 = 20 >= 20
assert state.steps_since_best_improved == 20
def test_below_patience_continues(self, tmp_path: Path) -> None:
"""累计步数未达阈值时继续。"""
manifest = {
"name": "test",
"store": ".",
"current": {"videos": "v", "questions": "q", "skills": "s/v1", "prompts": "p/v1"},
"best": {"epoch": 1, "val_acc": 0.5},
"history": [],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest))
state = MagicMock()
state.steps_since_best_improved = 10
result = _should_early_stop(tmp_path, epoch=3, steps_this_epoch=5, state=state, patience=20)
assert result is False
assert state.steps_since_best_improved == 15
def test_step_granularity(self, tmp_path: Path) -> None:
"""步粒度而非 epoch 粒度。"""
manifest = {
"name": "test",
"store": ".",
"current": {"videos": "v", "questions": "q", "skills": "s/v1", "prompts": "p/v1"},
"best": {"epoch": 1, "val_acc": 0.5},
"history": [],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest))
state = MagicMock()
# 连续 3 个 epoch,每个 3 步
state.steps_since_best_improved = 0
for ep in range(2, 5):
stopped = _should_early_stop(
tmp_path, epoch=ep, steps_this_epoch=3, state=state, patience=10
)
if ep < 4:
assert stopped is False
else:
# 3+3+3=9 < 10 但第三轮后 9+3=12>=10 在 ep=5 触发
# 实际:ep=2 → 3, ep=3 → 6, ep=4 → 9
assert stopped is False
stopped = _should_early_stop(
tmp_path, epoch=5, steps_this_epoch=3, state=state, patience=10
)
assert stopped is True # 9+3=12>=10
# =========================================================================
# 13c: Probation 数据结构测试
# =========================================================================
class TestProbation:
"""Probation 数据结构。"""
def test_probation_fields(self) -> None:
"""Probation 必须具备全部字段。"""
p = Probation(
task_type="Action Reasoning",
anchor_skills_version="v1",
target_file="action-reasoning.md",
correctness_snapshot={"q1": True},
opened_step=5,
)
assert p.task_type == "Action Reasoning"
assert p.pending_edits == []
def test_pending_edits_append(self) -> None:
"""pending_edits 可追加 RejectedEdit。"""
p = Probation(
task_type="Action Reasoning",
anchor_skills_version="v1",
target_file="action-reasoning.md",
correctness_snapshot={},
opened_step=0,
)
edit = RejectedEdit(
target_file="action-reasoning.md",
target_type="skill",
change_summary="test",
delta=0.1,
source_version="v1",
epoch=0,
gate_w=3,
gate_l=1,
gate_e_value=2.5,
gate_delta_shrunk=0.05,
)
p.pending_edits.append(edit)
assert len(p.pending_edits) == 1
# =========================================================================
# 13c: RejectedSummary 黑名单防污染
# =========================================================================
class TestFormatAppliedEdits:
"""_format_applied_edits 只拼 applied 的 edit。"""
def test_only_applied_edits_in_summary(self) -> None:
"""只有 applied 状态的 edit 进入摘要。"""
record = MagicMock()
record.edits = [
{"op": "replace", "target": "section1"},
{"op": "insert", "content": "new_content"},
{"op": "delete", "target": "old_stuff"},
]
record.apply_report = [
{"status": "applied_exact"},
{"status": "skipped_not_found"},
{"status": "applied_fuzzy"},
]
summary = _format_applied_edits(record)
assert summary is not None
assert "section1" in summary
assert "old_stuff" in summary
assert "new_content" not in summary
def test_zero_applied_returns_info_message(self) -> None:
"""0 applied 返回信息性消息(非 None)。"""
record = MagicMock()
record.edits = [{"op": "replace", "target": "sec"}]
record.apply_report = [{"status": "skipped_not_found"}]
summary = _format_applied_edits(record)
assert summary is not None
assert "0 applied" in summary
def test_no_edits_returns_none(self) -> None:
"""无 edit 时返回 None。"""
record = MagicMock()
record.edits = []
assert _format_applied_edits(record) is None
def test_no_report_includes_all_edits(self) -> None:
"""无 apply_report 时包含所有 edit。"""
record = MagicMock()
record.edits = [{"op": "replace", "target": "foo"}]
record.apply_report = []
summary = _format_applied_edits(record)
assert summary is not None
assert "foo" in summary
class TestFallbackSummary:
"""_fallback_summary 兜底黑名单摘要。"""
def test_from_suggestions(self) -> None:
"""有 suggestions 时拼接 change 字段。"""
record = MagicMock()
record.suggestions = [{"change": "改 A"}, {"change": "改 B"}]
outcome = MagicMock()
outcome.delta_hat = -0.1
summary = _fallback_summary(record, outcome)
assert "改 A" in summary
assert "改 B" in summary
def test_no_suggestions_uses_delta(self) -> None:
"""无 suggestions 时使用 delta 信息。"""
record = MagicMock()
record.suggestions = []
outcome = MagicMock()
outcome.delta_hat = -0.15
summary = _fallback_summary(record, outcome)
assert "delta" in summary
assert "-0.15" in summary
class TestRejectedSummaryIntegration:
"""_rejected_summary_static 集成:两个子函数组合。"""
def test_static_delegates_to_format_applied(self) -> None:
"""有 applied edit 时 static 方法返回 _format_applied_edits 结果。"""
from app.harness.runner import Runner
record = MagicMock()
record.edits = [{"op": "replace", "target": "section1"}]
record.apply_report = [{"status": "applied_exact"}]
record.suggestions = []
outcome = MagicMock()
outcome.delta_hat = 0.1
summary = Runner._rejected_summary_static(record, outcome)
assert "section1" in summary
def test_static_falls_back_to_suggestions(self) -> None:
"""无 edit 时 static 方法使用 _fallback_summary。"""
from app.harness.runner import Runner
record = MagicMock()
record.edits = []
record.suggestions = [{"change": "尝试 X"}]
outcome = MagicMock()
outcome.delta_hat = -0.2
summary = Runner._rejected_summary_static(record, outcome)
assert "尝试 X" in summary
class TestWriteSkipReport:
"""_write_skip_report 辅助函数。"""
def test_writes_cooldown_report(self, tmp_path: Path) -> None:
"""cooldown 路径写 step_report JSON 文件。"""
(tmp_path / "analyses").mkdir()
_write_skip_report(
tmp_path,
epoch=1,
step=0,
global_step=5,
task_type="Action Reasoning",
action="cooldown",
baseline_acc=0.75,
budget=3,
)
report_path = tmp_path / "analyses" / "step_report_e1_s0_action-reasoning.json"
assert report_path.exists()
data = json.loads(report_path.read_text())
assert data["gate_action"] == "cooldown"
assert data["candidate_acc"] == 0.75
assert data["gate_w"] is None
def test_writes_skipped_report(self, tmp_path: Path) -> None:
"""skipped 路径写 step_report 并传递 rank_clip_triggered。"""
(tmp_path / "analyses").mkdir()
_write_skip_report(
tmp_path,
epoch=2,
step=1,
global_step=10,
task_type="Temporal Reasoning",
action="skipped",
baseline_acc=0.6,
budget=2,
rank_clip_triggered=True,
)
report_path = tmp_path / "analyses" / "step_report_e2_s1_temporal-reasoning.json"
assert report_path.exists()
data = json.loads(report_path.read_text())
assert data["gate_action"] == "skipped"
assert data["rank_clip_triggered"] is True
# =========================================================================
# 13a: _TrainState 基本构造
# =========================================================================
class TestTrainState:
"""_TrainState dataclass 基本构造与字段默认值。"""
def test_default_fields(self) -> None:
"""默认字段值正确。"""
state = _TrainState(
correctness={"q1": True},
gate_pools=MagicMock(),
baseline_cache=MagicMock(),
eval_prev_acc=0.5,
eval_prev_run_id="run1",
best_val_acc=0.5,
best_skills_version="v1",
best_prompts_version="v1",
)
assert state.global_step == 0
assert state.gate_epoch_observed is False
assert state.probations == {}
assert state.gate_cooldown == {}
assert state.rejected_buffer == {}
assert state.system_packs == []
assert state.tool_packs == []
assert state.changed_task_types_this_epoch == set()
assert state.steps_since_best_improved == 0
# =========================================================================
# 13c: cooldown 递减测试
# =========================================================================
class TestCooldownDecrement:
"""gate_cooldown 每 step 递减、归零剔除。"""
def test_decrement_and_remove(self) -> None:
"""冷却值递减,归零剔除。"""
cooldown = {"type_a": 3, "type_b": 1, "type_c": 2}
# 模拟 _run_step 末尾的冷却递减
cooldown = {t: n - 1 for t, n in cooldown.items() if n - 1 > 0}
assert cooldown == {"type_a": 2, "type_c": 1}
cooldown = {t: n - 1 for t, n in cooldown.items() if n - 1 > 0}
assert cooldown == {"type_a": 1}
cooldown = {t: n - 1 for t, n in cooldown.items() if n - 1 > 0}
assert cooldown == {}
+460
View File
@@ -0,0 +1,460 @@
"""Store 版本操作 + Seed 管理的单元测试。"""
from __future__ import annotations
import json
import sqlite3
import pytest
from app.harness.store import (
_parse_version,
_write_meta,
advance_version,
extract_run_db,
init_seed,
init_store,
list_seeds,
list_versions,
next_version,
promote_to_seed,
read_seed,
)
# ---------------------------------------------------------------------------
# _parse_version
# ---------------------------------------------------------------------------
class TestParseVersion:
"""_parse_version 解析 v\\d+ 格式版本号。"""
def test_parse_version_normal(self) -> None:
assert _parse_version("v1") == 1
assert _parse_version("v10") == 10
assert _parse_version("v999") == 999
def test_parse_version_invalid(self) -> None:
with pytest.raises(ValueError, match="无效版本号"):
_parse_version("abc")
with pytest.raises(ValueError, match="无效版本号"):
_parse_version("v")
with pytest.raises(ValueError, match="无效版本号"):
_parse_version("v1.0")
# ---------------------------------------------------------------------------
# list_versions — 数字排序
# ---------------------------------------------------------------------------
class TestListVersions:
"""list_versions 按数字排序,v10 排在 v2 后。"""
def test_list_versions_numeric_sort(self, tmp_path: "Path") -> None:
"""v10 必须排在 v2 后面(非字典序)。"""
store = tmp_path / "store"
resource = store / "skills"
resource.mkdir(parents=True)
for v in ("v1", "v10", "v2", "v20", "v3"):
(resource / v).mkdir()
result = list_versions(store, "skills")
assert result == ["v1", "v2", "v3", "v10", "v20"]
def test_list_versions_empty(self, tmp_path: "Path") -> None:
store = tmp_path / "store"
assert list_versions(store, "skills") == []
def test_list_versions_ignores_non_version_dirs(self, tmp_path: "Path") -> None:
"""非 v\\d+ 格式的目录被忽略。"""
store = tmp_path / "store"
resource = store / "skills"
resource.mkdir(parents=True)
(resource / "v1").mkdir()
(resource / "backup").mkdir()
(resource / ".hidden").mkdir()
assert list_versions(store, "skills") == ["v1"]
# ---------------------------------------------------------------------------
# next_version
# ---------------------------------------------------------------------------
class TestNextVersion:
"""next_version 返回下一个可用版本号。"""
def test_next_version_empty(self, tmp_path: "Path") -> None:
store = tmp_path / "store"
assert next_version(store, "skills") == "v1"
def test_next_version_after_existing(self, tmp_path: "Path") -> None:
store = tmp_path / "store"
resource = store / "skills"
resource.mkdir(parents=True)
(resource / "v1").mkdir()
(resource / "v2").mkdir()
assert next_version(store, "skills") == "v3"
def test_next_version_with_gap(self, tmp_path: "Path") -> None:
"""v1 和 v10 之间有 gapnext 应为 v11。"""
store = tmp_path / "store"
resource = store / "skills"
resource.mkdir(parents=True)
(resource / "v1").mkdir()
(resource / "v10").mkdir()
assert next_version(store, "skills") == "v11"
# ---------------------------------------------------------------------------
# advance_version
# ---------------------------------------------------------------------------
class TestAdvanceVersion:
"""advance_version copytree + _write_meta。"""
def test_advance_version(self, tmp_path: "Path") -> None:
store = tmp_path / "store"
resource = store / "skills"
resource.mkdir(parents=True)
(resource / "v1").mkdir()
source = tmp_path / "new_skills"
source.mkdir()
(source / "skill_a.md").write_text("内容A")
version = advance_version(
store,
"skills",
source,
{"source": "evolution", "description": "进化产出"},
)
assert version == "v2"
assert (resource / "v2" / "skill_a.md").read_text() == "内容A"
meta = json.loads((resource / "v2" / "meta.json").read_text())
assert meta["version"] == "v2"
assert meta["source"] == "evolution"
assert meta["description"] == "进化产出"
assert "created_at" in meta
# ---------------------------------------------------------------------------
# init_store
# ---------------------------------------------------------------------------
class TestInitStore:
"""init_store 初始化 Store 目录结构。"""
def test_init_store(self, tmp_path: "Path") -> None:
videos = tmp_path / "videos_src"
videos.mkdir()
(videos / "v001").mkdir()
(videos / "v001" / "tree.json").write_text("{}")
skills = tmp_path / "skills_src"
skills.mkdir()
(skills / "search.md").write_text("skill")
prompts = tmp_path / "prompts_src"
prompts.mkdir()
(prompts / "system.md").write_text("prompt")
store = tmp_path / "store"
init_store(store, videos, skills, prompts)
assert (store / "videos" / "v001" / "tree.json").exists()
assert (store / "questions" / "benchmarks").is_dir()
assert (store / "questions" / "generated").is_dir()
assert (store / "skills" / "v1" / "search.md").read_text() == "skill"
assert (store / "prompts" / "v1" / "system.md").read_text() == "prompt"
skills_meta = json.loads(
(store / "skills" / "v1" / "meta.json").read_text()
)
assert skills_meta["version"] == "v1"
assert skills_meta["source"] == "manual"
def test_init_store_exists_raises(self, tmp_path: "Path") -> None:
store = tmp_path / "store"
store.mkdir()
with pytest.raises(FileExistsError, match="Store 已存在"):
init_store(store, tmp_path, tmp_path, tmp_path)
# ---------------------------------------------------------------------------
# Seed 相关
# ---------------------------------------------------------------------------
def _make_seed_fixtures(tmp_path):
"""创建 seed 测试所需的公共 fixture。"""
store = tmp_path / "store"
store.mkdir(parents=True)
skills_dir = tmp_path / "sk"
skills_dir.mkdir()
(skills_dir / "search.md").write_text("skill")
prompts_dir = tmp_path / "pr"
prompts_dir.mkdir()
(prompts_dir / "system.md").write_text("prompt")
baseline_db = tmp_path / "base.db"
conn = sqlite3.connect(baseline_db)
conn.execute(
"CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)"
)
conn.execute("INSERT INTO _runs VALUES ('r1', 'done')")
conn.execute(
"CREATE TABLE predictions (run_id TEXT, question_id TEXT, answer TEXT)"
)
conn.execute("INSERT INTO predictions VALUES ('r1', 'q1', 'A')")
conn.commit()
conn.close()
return store, skills_dir, prompts_dir, baseline_db
class TestInitSeed:
"""init_seed 创建种子目录。"""
def test_init_seed(self, tmp_path: "Path") -> None:
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
seed_dir = init_seed(
store,
"initial",
skills_dir,
prompts_dir,
baseline_db,
"r1",
None,
"初始种子",
)
assert seed_dir == store / "seeds" / "initial"
assert (seed_dir / "skills" / "search.md").exists()
assert (seed_dir / "prompts" / "system.md").exists()
assert (seed_dir / "baseline.db").exists()
meta = json.loads((seed_dir / "seed.json").read_text())
assert meta["baseline_run_id"] == "r1"
assert meta["parent"] is None
assert meta["description"] == "初始种子"
assert "created_at" in meta
def test_init_seed_exists_raises(self, tmp_path: "Path") -> None:
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
init_seed(store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "first")
with pytest.raises(FileExistsError, match="种子已存在"):
init_seed(
store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "second"
)
class TestListSeeds:
"""list_seeds 列出所有种子。"""
def test_list_seeds(self, tmp_path: "Path") -> None:
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
init_seed(store, "beta", skills_dir, prompts_dir, baseline_db, "r1", None, "b")
init_seed(store, "alpha", skills_dir, prompts_dir, baseline_db, "r1", None, "a")
assert list_seeds(store) == ["alpha", "beta"]
def test_list_seeds_empty(self, tmp_path: "Path") -> None:
store = tmp_path / "store"
assert list_seeds(store) == []
class TestReadSeed:
"""read_seed 读取 seed.json。"""
def test_read_seed(self, tmp_path: "Path") -> None:
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
init_seed(store, "s1", skills_dir, prompts_dir, baseline_db, "r1", None, "desc")
meta = read_seed(store, "s1")
assert meta["baseline_run_id"] == "r1"
assert meta["description"] == "desc"
def test_read_seed_not_found(self, tmp_path: "Path") -> None:
store = tmp_path / "store"
store.mkdir()
with pytest.raises(FileNotFoundError, match="种子不存在"):
read_seed(store, "no_such")
# ---------------------------------------------------------------------------
# extract_run_db
# ---------------------------------------------------------------------------
class TestExtractRunDb:
"""extract_run_db 抽取指定 run 的行并保留 PK。"""
def _make_src_db(self, path):
"""创建带 _runs + predictions 表的源 db。"""
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)"
)
conn.execute("INSERT INTO _runs VALUES ('r1', 'done')")
conn.execute("INSERT INTO _runs VALUES ('r2', 'done')")
conn.execute(
"CREATE TABLE predictions "
"(run_id TEXT, question_id TEXT, answer TEXT)"
)
conn.execute("INSERT INTO predictions VALUES ('r1', 'q1', 'A')")
conn.execute("INSERT INTO predictions VALUES ('r1', 'q2', 'B')")
conn.execute("INSERT INTO predictions VALUES ('r2', 'q1', 'C')")
conn.commit()
conn.close()
def test_extract_run_db_preserves_pk(self, tmp_path: "Path") -> None:
"""原始 CREATE 保留主键约束。"""
src = tmp_path / "src.db"
dst = tmp_path / "dst.db"
self._make_src_db(src)
extract_run_db(src, dst, "r1")
conn = sqlite3.connect(dst)
# 验证 _runs 表有 PK
create_sql = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='_runs'"
).fetchone()[0]
assert "PRIMARY KEY" in create_sql
# 验证只有 r1 的行
runs = conn.execute("SELECT * FROM _runs").fetchall()
assert len(runs) == 1
assert runs[0][0] == "r1"
preds = conn.execute("SELECT * FROM predictions").fetchall()
assert len(preds) == 2
conn.close()
def test_extract_run_db_missing_table(self, tmp_path: "Path") -> None:
"""源 db 无目标表时报错。"""
src = tmp_path / "src.db"
dst = tmp_path / "dst.db"
conn = sqlite3.connect(src)
conn.execute("CREATE TABLE other (id TEXT)")
conn.commit()
conn.close()
with pytest.raises(RuntimeError, match="源 db 无表"):
extract_run_db(src, dst, "r1")
def test_extract_run_db_no_rows(self, tmp_path: "Path") -> None:
"""目标 run_id 不存在时报错。"""
src = tmp_path / "src.db"
dst = tmp_path / "dst.db"
self._make_src_db(src)
with pytest.raises(RuntimeError, match="无 run_id="):
extract_run_db(src, dst, "nonexistent")
# ---------------------------------------------------------------------------
# promote_to_seed
# ---------------------------------------------------------------------------
def _make_promote_fixtures(tmp_path):
"""创建 promote_to_seed 测试所需的 workspace + store。"""
ws = tmp_path / "ws"
ws.mkdir()
store = tmp_path / "store"
store.mkdir()
# workspace 内的 skills/prompts 版本目录
(ws / "skills" / "v2").mkdir(parents=True)
(ws / "skills" / "v2" / "skill.md").write_text("evolved")
(ws / "prompts" / "v2").mkdir(parents=True)
(ws / "prompts" / "v2" / "system.md").write_text("prompt v2")
# workspace harness.db
db_path = ws / "harness.db"
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE _runs (
run_id TEXT PRIMARY KEY,
skills_version TEXT,
prompts_version TEXT
)
""")
conn.execute(
"INSERT INTO _runs VALUES ('eval_001', 'v2', 'v2')"
)
conn.execute("""
CREATE TABLE predictions (
run_id TEXT, question_id TEXT, answer TEXT
)
""")
conn.execute("INSERT INTO predictions VALUES ('eval_001', 'q1', 'A')")
conn.commit()
conn.close()
return ws, store
class TestPromoteToSeed:
"""promote_to_seed 固化 workspace 版本为种子。"""
def test_promote_to_seed_success(self, tmp_path: "Path") -> None:
ws, store = _make_promote_fixtures(tmp_path)
seed_dir = promote_to_seed(ws, store, "v2", "eval_001", "evolved-seed", "good")
assert seed_dir == store / "seeds" / "evolved-seed"
assert (seed_dir / "skills" / "skill.md").exists()
assert (seed_dir / "prompts" / "system.md").exists()
assert (seed_dir / "baseline.db").exists()
meta = json.loads((seed_dir / "seed.json").read_text())
assert meta["baseline_run_id"] == "eval_001"
assert meta["parent"] == "ws:v2"
def test_promote_to_seed_version_mismatch(self, tmp_path: "Path") -> None:
"""eval run 的 skills_version 与 --version 不符时报错。"""
ws, store = _make_promote_fixtures(tmp_path)
with pytest.raises(ValueError, match="版本.*不符"):
promote_to_seed(ws, store, "v3", "eval_001", "bad", "mismatch")
def test_promote_to_seed_null_version(self, tmp_path: "Path") -> None:
"""eval run 的版本为 NULL 时报错。"""
ws = tmp_path / "ws2"
ws.mkdir()
store = tmp_path / "store2"
store.mkdir()
db_path = ws / "harness.db"
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE _runs (
run_id TEXT PRIMARY KEY,
skills_version TEXT,
prompts_version TEXT
)
""")
conn.execute("INSERT INTO _runs VALUES ('eval_null', NULL, NULL)")
conn.commit()
conn.close()
with pytest.raises(ValueError, match="NULL"):
promote_to_seed(ws, store, "v1", "eval_null", "bad", "null ver")
def test_promote_to_seed_run_not_found(self, tmp_path: "Path") -> None:
"""eval run 不存在时报错。"""
ws, store = _make_promote_fixtures(tmp_path)
with pytest.raises(ValueError, match="eval run 不存在"):
promote_to_seed(ws, store, "v1", "nonexistent", "bad", "no run")
def test_promote_cleanup_tmp_db(self, tmp_path: "Path") -> None:
"""finally 清理临时 db 文件。"""
ws, store = _make_promote_fixtures(tmp_path)
promote_to_seed(ws, store, "v2", "eval_001", "clean-test", "cleanup")
# 临时 db 应已清理
assert not (ws / "_promote_tmp.db").exists()
def test_promote_cleanup_tmp_db_on_error(self, tmp_path: "Path") -> None:
"""即使 init_seed 失败(同名种子),临时 db 也应被清理。"""
ws, store = _make_promote_fixtures(tmp_path)
promote_to_seed(ws, store, "v2", "eval_001", "first", "first time")
with pytest.raises(FileExistsError):
promote_to_seed(ws, store, "v2", "eval_001", "first", "second time")
assert not (ws / "_promote_tmp.db").exists()
+554
View File
@@ -0,0 +1,554 @@
"""tests/unit/test_harness_validate.py — app/harness/validate.py 的单元测试。
覆盖:数据类型字段、materialize 物化与清理、async validate_skill_local
accept/reject/prefix 校验/INFRA 护栏/缓存命中/最后一块终态)。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from app.harness.gate_ladder import BaselineCache, skill_hash
from app.harness.inference import PREDICTIONS_SCHEMA, InferenceResult
from app.harness.log import HarnessLog
from app.harness.validate import (
Probation,
ValidationOutcome,
materialize_candidate_skill,
validate_skill_local,
)
from core.evolution import GateParams, RejectedEdit
from core.types import GeneratedQuestion
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# 辅助工具
# ---------------------------------------------------------------------------
_DEFAULT_GATE_PARAMS = GateParams(
e_confirm=20.0,
e_provisional=3.0,
w_net_min=2,
delta_min=0.05,
lambda_dir=-2.0,
e_rollback=10.0,
)
def _make_questions(
n: int,
task_type: str = "temporal",
prefix: str = "q",
) -> list[GeneratedQuestion]:
"""生成 n 个测试用 GeneratedQuestion。"""
return [
GeneratedQuestion(
question_id=f"{prefix}{i}",
video_id=f"v{i}",
task_type=task_type,
question=f"Question {i}?",
options=("A", "B", "C", "D"),
answer="A",
source_nodes=(),
difficulty="easy",
)
for i in range(n)
]
def _setup_workspace(tmp_path: Path) -> Path:
"""在 tmp_path 下构建最小 workspace 结构。"""
skills_dir = tmp_path / "skills" / "v1"
skills_dir.mkdir(parents=True)
(skills_dir / "temporal.md").write_text("baseline skill content", encoding="utf-8")
return tmp_path
def _make_log(workspace: Path, run_id: str = "test_master") -> HarnessLog:
"""创建 HarnessLog 并初始化 predictions 表。"""
db_path = workspace / "harness.db"
log = HarnessLog(str(db_path), run_id)
log.create_table("predictions", PREDICTIONS_SCHEMA)
return log
def _insert_predictions(
log: HarnessLog,
run_id: str,
correctness: dict[str, bool],
answer: str = "A",
) -> None:
"""向 predictions 表插入指定 run_id 的逐题预测记录。
通过在 record 中显式传入 run_id 覆盖 log 的默认 run_id。
"""
for qid, correct in correctness.items():
prediction = answer if correct else "Z"
log.insert(
"predictions",
{
"run_id": run_id,
"video_id": "v0",
"question_id": qid,
"task_type": "temporal",
"prediction": prediction,
"answer": answer,
"evidence": "",
"reasoning": "",
"steps_used": 1,
"prompt_tokens": 10,
"completion_tokens": 10,
"stop_reason": "completed",
"steps_json": "[]",
},
)
def _make_mock_run_inference(
log: HarnessLog,
baseline_correctness: dict[str, bool],
candidate_correctness: dict[str, bool],
error_count: int = 0,
):
"""构建 mock RunInferenceFn。
根据 run_id 中的 arm 标记(_base / _cand)决定使用基线或候选对错映射,
将预测写入 log 的同一 DB,返回 InferenceResult。
"""
call_log: list[dict[str, Any]] = []
async def mock_fn(
questions: list[GeneratedQuestion],
*,
run_id: str,
skills_dir: Path,
) -> InferenceResult:
is_baseline = run_id.endswith("_base")
correctness = baseline_correctness if is_baseline else candidate_correctness
call_log.append({"run_id": run_id, "skills_dir": skills_dir, "n": len(questions)})
per_q = {q.question_id: correctness.get(q.question_id, False) for q in questions}
_insert_predictions(log, run_id, per_q)
correct = sum(per_q.values())
total = len(questions)
stop_counts: dict[str, int] = {"completed": total - error_count}
if error_count > 0:
stop_counts["error"] = error_count
return InferenceResult(
run_id=run_id,
accuracy=correct / total if total else 0.0,
total=total,
correct=correct,
per_task_type={},
steps_mean=1.0,
token_usage={"prompt_tokens": 10, "completion_tokens": 10},
stop_reason_counts=stop_counts,
)
return mock_fn, call_log
# ===========================================================================
# 数据类型测试
# ===========================================================================
class TestValidationOutcomeFields:
"""ValidationOutcome 数据类型字段完整性测试。"""
def test_validation_outcome_fields(self) -> None:
"""所有字段可构造、默认值合理。"""
outcome = ValidationOutcome(
action="accept_confirmed",
accepted=True,
stop_reason="confirmed",
e_value=25.0,
w=5,
l=1,
n_used=10,
delta_hat=0.4,
delta_shrunk=0.3,
baseline_acc=0.6,
candidate_acc=0.9,
)
assert outcome.action == "accept_confirmed"
assert outcome.accepted is True
assert outcome.stop_reason == "confirmed"
assert outcome.e_value == 25.0
assert outcome.w == 5
assert outcome.l == 1
assert outcome.n_used == 10
assert outcome.delta_hat == 0.4
assert outcome.delta_shrunk == 0.3
assert outcome.baseline_acc == 0.6
assert outcome.candidate_acc == 0.9
assert outcome.improvements == []
assert outcome.regressions == []
assert outcome.persistent_fails == []
assert outcome.stable_successes == []
assert outcome.candidate_correctness == {}
assert outcome.evidence_rows == []
class TestProbationFields:
"""Probation 数据类型字段完整性测试。"""
def test_probation_fields(self) -> None:
"""所有字段可构造、pending_edits 默认空列表。"""
prob = Probation(
task_type="temporal",
anchor_skills_version="v1",
target_file="temporal.md",
correctness_snapshot={"q0": True, "q1": False},
opened_step=5,
)
assert prob.task_type == "temporal"
assert prob.anchor_skills_version == "v1"
assert prob.target_file == "temporal.md"
assert prob.correctness_snapshot == {"q0": True, "q1": False}
assert prob.opened_step == 5
assert prob.pending_edits == []
def test_probation_with_pending_edits(self) -> None:
"""pending_edits 可附加 RejectedEdit。"""
edit = RejectedEdit(
target_file="temporal.md",
target_type="skill",
change_summary="bad change",
delta=-0.1,
source_version="v2",
epoch=1,
)
prob = Probation(
task_type="temporal",
anchor_skills_version="v1",
target_file="temporal.md",
correctness_snapshot={},
opened_step=3,
pending_edits=[edit],
)
assert len(prob.pending_edits) == 1
assert prob.pending_edits[0].change_summary == "bad change"
# ===========================================================================
# materialize 测试
# ===========================================================================
class TestMaterializeCandidateSkill:
"""materialize_candidate_skill 物化与清理测试。"""
def test_materialize_candidate_skill(self, tmp_path: Path) -> None:
"""正常物化:基线目录被复制,target_file 被覆写为候选内容。"""
workspace = _setup_workspace(tmp_path)
cand_dir = materialize_candidate_skill(
workspace, "v1", "temporal.md", "candidate skill content"
)
try:
assert cand_dir.exists()
assert cand_dir.parent == workspace / ".cand_tmp"
assert (cand_dir / "temporal.md").read_text(encoding="utf-8") == (
"candidate skill content"
)
finally:
import shutil
shutil.rmtree(cand_dir)
def test_materialize_cleanup_on_failure(self, tmp_path: Path) -> None:
"""基线目录不存在时 OSError,临时目录被清理。"""
workspace = tmp_path / "ws"
workspace.mkdir()
# 不创建 skills/v1copytree 应失败
with pytest.raises(OSError):
materialize_candidate_skill(workspace, "v1", "temporal.md", "content")
# .cand_tmp 可能存在但内部应被清理
cand_tmp = workspace / ".cand_tmp"
if cand_tmp.exists():
remaining = list(cand_tmp.iterdir())
assert remaining == [], f"临时目录未被清理: {remaining}"
# ===========================================================================
# async 验证测试
# ===========================================================================
@pytest.mark.asyncio
async def test_validate_skill_local_accept(tmp_path: Path) -> None:
"""候选全对、基线全错 → 高 e 值 → accept_confirmed。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
# 基线全错,候选全对 → W=6, L=0 → E=18.14 → CONFIRMEDe_confirm=15
baseline_correct = {f"q{i}": False for i in range(6)}
candidate_correct = {f"q{i}": True for i in range(6)}
mock_fn, call_log = _make_mock_run_inference(log, baseline_correct, candidate_correct)
# e_confirm=15 使 E=18.14 超过阈值触发 CONFIRMED
accept_params = GateParams(
e_confirm=15.0,
e_provisional=3.0,
w_net_min=2,
delta_min=0.05,
lambda_dir=-2.0,
e_rollback=10.0,
)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="improved skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=accept_params,
gate_block=6,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
assert outcome.accepted is True
assert outcome.action == "accept_confirmed"
assert outcome.stop_reason == "confirmed"
assert outcome.w == 6
assert outcome.l == 0
assert outcome.n_used == 6
assert outcome.candidate_acc == 1.0
assert outcome.baseline_acc == 0.0
assert len(outcome.evidence_rows) == 6
# 终态证据行携带 stop_reason
assert outcome.evidence_rows[-1]["stop_reason"] == "confirmed"
# 候选临时目录应被清理
cand_tmp = workspace / ".cand_tmp"
if cand_tmp.exists():
assert list(cand_tmp.iterdir()) == []
finally:
log.close()
@pytest.mark.asyncio
async def test_validate_skill_local_reject(tmp_path: Path) -> None:
"""候选全错、基线全对 → L 高 → 方向拒绝。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
# 基线全对,候选全错 → W=0, L=6 → 方向拒绝
baseline_correct = {f"q{i}": True for i in range(6)}
candidate_correct = {f"q{i}": False for i in range(6)}
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="bad skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=6,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
assert outcome.accepted is False
assert outcome.action == "reject"
assert outcome.stop_reason == "directional"
assert outcome.w == 0
assert outcome.l == 6
finally:
log.close()
@pytest.mark.asyncio
async def test_gate_prefix_must_contain_gate(tmp_path: Path) -> None:
"""gate_run_prefix 不含 '_gate_' 时抛 ValueError。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
async def noop_fn(questions, *, run_id, skills_dir):
raise AssertionError("不应被调用")
try:
with pytest.raises(ValueError, match="_gate_"):
await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="content",
base_skill_content="baseline",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=noop_fn,
log=log,
gate_run_prefix="step1_no_marker",
)
finally:
log.close()
@pytest.mark.asyncio
async def test_infra_guard_threshold(tmp_path: Path) -> None:
"""推理错误率超阈值时抛 RuntimeError。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
# 需要 >=10 题次才触发 INFRA 护栏
questions = _make_questions(6)
cache = BaselineCache(workspace / "baseline_cache.json")
baseline_correct = {f"q{i}": False for i in range(6)}
candidate_correct = {f"q{i}": False for i in range(6)}
# 每次 run_inference 报 error_count=5,两侧各 5 → 10/12 > 0.5
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct, error_count=5)
try:
with pytest.raises(RuntimeError, match="错误率过高"):
await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="content",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=6,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
finally:
log.close()
@pytest.mark.asyncio
async def test_baseline_cache_hit(tmp_path: Path) -> None:
"""基线缓存全命中时不发起基线侧推理。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
s_hash = skill_hash("baseline skill content")
# 预填充缓存:全部题目基线全错
for q in questions:
cache.put("temporal", s_hash, "p1", q.question_id, False)
# 候选全对 → accept
candidate_correct = {f"q{i}": True for i in range(4)}
baseline_correct = {f"q{i}": False for i in range(4)}
mock_fn, call_log = _make_mock_run_inference(log, baseline_correct, candidate_correct)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="improved skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
gate_n_max=20,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
# 只有候选侧调用了 run_inference(_cand),基线侧全命中不调用
base_calls = [c for c in call_log if c["run_id"].endswith("_base")]
cand_calls = [c for c in call_log if c["run_id"].endswith("_cand")]
assert len(base_calls) == 0, "基线缓存全命中不应发起推理"
assert len(cand_calls) == 1
assert outcome.accepted is True
finally:
log.close()
@pytest.mark.asyncio
async def test_last_block_terminal(tmp_path: Path) -> None:
"""单块 + n_remaining=0 → 终态判定(provisional 或 inertia),非 continue。"""
workspace = _setup_workspace(tmp_path)
log = _make_log(workspace)
# 4 题,gate_block=4 → 一块走完,n_remaining=0
questions = _make_questions(4)
cache = BaselineCache(workspace / "baseline_cache.json")
# 两题翻转(W=2, L=0),但 e_confirm=20 难以达到 → provisional 或 inertia
baseline_correct = {"q0": False, "q1": False, "q2": True, "q3": True}
candidate_correct = {"q0": True, "q1": True, "q2": True, "q3": True}
mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct)
try:
outcome = await validate_skill_local(
workspace_dir=workspace,
base_skills_version="v1",
task_type="temporal",
target_file="temporal.md",
candidate_content="candidate skill",
base_skill_content="baseline skill content",
ladder_items=questions,
gate_params=_DEFAULT_GATE_PARAMS,
gate_block=4,
gate_n_max=4,
gate_guard_err=0.5,
baseline_cache=cache,
prompts_version="p1",
run_inference=mock_fn,
log=log,
gate_run_prefix="step1_gate_test",
)
# n_remaining=0 → 不可能是 continue
assert outcome.stop_reason in (
"confirmed",
"provisional",
"inertia",
"directional",
"futility",
)
assert outcome.n_used == 4
# 终态行标记 stop_reason
assert outcome.evidence_rows[-1]["stop_reason"] != ""
finally:
log.close()
+519
View File
@@ -0,0 +1,519 @@
"""app/harness/workspace 单元测试。
覆盖 Workspace 生命周期管理、manifest 读写、
VersionedSkillStore / VersionedPromptStore Protocol 合规。
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from app.harness.workspace import (
ResolvedPaths,
VersionedPromptStore,
VersionedSkillStore,
archive_workspace,
init_workspace,
init_workspace_from_seed,
list_video_ids,
load_manifest,
read_best,
record_run,
resolve_paths,
update_best,
update_manifest,
)
from core.evolution.protocols import PromptStore, SkillStore
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def store_dir(tmp_path: Path) -> Path:
"""构建一个最小 Store 目录结构供测试使用。"""
sd = tmp_path / "store"
sd.mkdir()
# videos:两个含 tree.json 的视频目录
for vid in ("vid_A", "vid_B"):
vdir = sd / "videos" / vid
vdir.mkdir(parents=True)
(vdir / "tree.json").write_text("{}")
# skills/v1 + prompts/v1
s1 = sd / "skills" / "v1"
s1.mkdir(parents=True)
(s1 / "temporal-reasoning.md").write_text("skill content A")
(s1 / "spatial-analysis.md").write_text("skill content B")
p1 = sd / "prompts" / "v1"
p1.mkdir(parents=True)
(p1 / "system.md").write_text("system prompt v1")
(p1 / "extract.md").write_text("extract prompt v1")
# questions/benchmarks/Video-MME
q = sd / "questions" / "benchmarks" / "Video-MME"
q.mkdir(parents=True)
(q / "q1.json").write_text('{"id": "q1"}')
# seed "initial"
seed_dir = sd / "seeds" / "initial"
seed_dir.mkdir(parents=True)
shutil.copytree(s1, seed_dir / "skills")
shutil.copytree(p1, seed_dir / "prompts")
baseline_db = seed_dir / "baseline.db"
baseline_db.write_bytes(b"")
(seed_dir / "seed.json").write_text(
json.dumps({"baseline_run_id": "baseline-001", "parent": None})
)
return sd
@pytest.fixture()
def workspace_dir(tmp_path: Path) -> Path:
"""返回一个不存在的 workspace 路径。"""
return tmp_path / "ws_test"
# ---------------------------------------------------------------------------
# ResolvedPaths
# ---------------------------------------------------------------------------
def test_resolved_paths_frozen() -> None:
"""ResolvedPaths 实例应不可变。"""
rp = ResolvedPaths(
store_dir=Path("/s"),
videos_dir=Path("/v"),
questions_dir=Path("/q"),
skills_dir=Path("/sk"),
prompts_dir=Path("/p"),
workspace_dir=Path("/w"),
db_path=Path("/d"),
analyses_dir=Path("/a"),
runs_dir=Path("/r"),
)
with pytest.raises(AttributeError):
rp.store_dir = Path("/other") # type: ignore[misc]
# ---------------------------------------------------------------------------
# init_workspace
# ---------------------------------------------------------------------------
def test_init_workspace(store_dir: Path, workspace_dir: Path) -> None:
"""init_workspace 应创建 manifest、拷贝权重。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
# manifest 存在
manifest = json.loads((workspace_dir / "manifest.json").read_text())
assert manifest["current"]["skills"] == "skills/v1"
assert manifest["current"]["prompts"] == "prompts/v1"
assert manifest["current"]["questions"] == "questions/benchmarks/Video-MME"
# 权重已拷入 workspace
assert (workspace_dir / "skills" / "v1" / "temporal-reasoning.md").exists()
assert (workspace_dir / "prompts" / "v1" / "system.md").exists()
# analyses, runs 目录已创建
assert (workspace_dir / "analyses").is_dir()
assert (workspace_dir / "runs").is_dir()
# 重复创建应报错
with pytest.raises(FileExistsError):
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
# ---------------------------------------------------------------------------
# init_workspace_from_seed
# ---------------------------------------------------------------------------
def test_init_workspace_from_seed(store_dir: Path, workspace_dir: Path) -> None:
"""从种子初始化应拷权重 + baseline.db + 返回 baseline_run_id。"""
run_id = init_workspace_from_seed(
workspace_dir,
store_dir,
seed_name="initial",
questions="benchmarks/Video-MME",
)
assert run_id == "baseline-001"
# 权重在 workspace
assert (workspace_dir / "skills" / "v1" / "temporal-reasoning.md").exists()
assert (workspace_dir / "prompts" / "v1" / "system.md").exists()
# baseline.db -> harness.db
assert (workspace_dir / "harness.db").exists()
# manifest 正确
manifest = json.loads((workspace_dir / "manifest.json").read_text())
assert manifest["current"]["skills"] == "skills/v1"
def test_init_workspace_from_seed_missing_questions(store_dir: Path, workspace_dir: Path) -> None:
"""questions ref 不存在应 fail-fastFileNotFoundError),不创建 workspace。"""
with pytest.raises(FileNotFoundError, match="questions ref"):
init_workspace_from_seed(
workspace_dir,
store_dir,
seed_name="initial",
questions="benchmarks/NONEXISTENT",
)
# workspace 不应被创建
assert not workspace_dir.exists()
# ---------------------------------------------------------------------------
# load_manifest
# ---------------------------------------------------------------------------
def test_load_manifest(store_dir: Path, workspace_dir: Path) -> None:
"""正常加载 manifest。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
manifest = load_manifest(workspace_dir)
assert "current" in manifest
assert "history" in manifest
assert manifest["current"]["skills"] == "skills/v1"
def test_load_manifest_missing(workspace_dir: Path) -> None:
"""manifest 不存在应 FileNotFoundError。"""
workspace_dir.mkdir(parents=True)
with pytest.raises(FileNotFoundError):
load_manifest(workspace_dir)
# ---------------------------------------------------------------------------
# update_manifest
# ---------------------------------------------------------------------------
def test_update_manifest_invalid_key(store_dir: Path, workspace_dir: Path) -> None:
"""非法 key 应 KeyError。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
with pytest.raises(KeyError, match="无效"):
update_manifest(workspace_dir, bad_key="skills/v2")
def test_update_manifest_valid(store_dir: Path, workspace_dir: Path) -> None:
"""合法 key 应更新 manifest current。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
update_manifest(workspace_dir, skills="skills/v2")
manifest = load_manifest(workspace_dir)
assert manifest["current"]["skills"] == "skills/v2"
# ---------------------------------------------------------------------------
# record_run
# ---------------------------------------------------------------------------
def test_record_run_idempotent(store_dir: Path, workspace_dir: Path) -> None:
"""同 run_id 调用两次不应重复追加 history。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
run_dir1 = record_run(workspace_dir, "run_001")
run_dir2 = record_run(workspace_dir, "run_001")
# 返回路径一致
assert run_dir1 == run_dir2
# history 只有一条
manifest = load_manifest(workspace_dir)
matched = [h for h in manifest["history"] if h["run_id"] == "run_001"]
assert len(matched) == 1
# run 目录存在
assert run_dir1.is_dir()
# per-video wiki 目录存在
assert (run_dir1 / "vid_A" / "wiki").is_dir()
assert (run_dir1 / "vid_B" / "wiki").is_dir()
# ---------------------------------------------------------------------------
# update_best / read_best
# ---------------------------------------------------------------------------
def test_update_best_independent_of_current(store_dir: Path, workspace_dir: Path) -> None:
"""best 应独立于 current——更新 best 不影响 current。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
# 初始无 best
assert read_best(workspace_dir) is None
# 设置 best
update_best(
workspace_dir,
skills="skills/v3",
prompts="prompts/v3",
val_acc=0.85,
run_id="run_005",
epoch=3,
)
best = read_best(workspace_dir)
assert best is not None
assert best["skills"] == "skills/v3"
assert best["val_acc"] == 0.85
assert best["epoch"] == 3
# current 不受影响
manifest = load_manifest(workspace_dir)
assert manifest["current"]["skills"] == "skills/v1"
# ---------------------------------------------------------------------------
# archive_workspace
# ---------------------------------------------------------------------------
def test_archive_workspace(store_dir: Path, workspace_dir: Path) -> None:
"""归档应把 workspace 移到 .archive/ 下。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
archived = archive_workspace(workspace_dir)
# 原目录不存在
assert not workspace_dir.exists()
# 归档目录存在并包含 manifest
assert archived.is_dir()
assert (archived / "manifest.json").exists()
# 归档路径在 .archive 下
assert archived.parent.name == ".archive"
def test_archive_workspace_missing(workspace_dir: Path) -> None:
"""归档不存在的 workspace 应 FileNotFoundError。"""
with pytest.raises(FileNotFoundError):
archive_workspace(workspace_dir)
# ---------------------------------------------------------------------------
# list_video_ids
# ---------------------------------------------------------------------------
def test_list_video_ids(store_dir: Path, workspace_dir: Path) -> None:
"""列出 workspace 引用的所有视频 ID。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
ids = list_video_ids(workspace_dir)
assert ids == ["vid_A", "vid_B"]
# ---------------------------------------------------------------------------
# resolve_paths
# ---------------------------------------------------------------------------
def test_resolve_paths(store_dir: Path, workspace_dir: Path) -> None:
"""resolve_paths 应正确解析绝对路径。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
rp = resolve_paths(workspace_dir)
# skills_dir/prompts_dir 解析到 workspace(非 store
ws_abs = workspace_dir.resolve()
assert rp.skills_dir == ws_abs / "skills" / "v1"
assert rp.prompts_dir == ws_abs / "prompts" / "v1"
# videos/questions 解析到 store
store_abs = store_dir.resolve()
assert rp.videos_dir == store_abs / "videos"
assert rp.questions_dir == store_abs / "questions" / "benchmarks" / "Video-MME"
# db_path, analyses, runs
assert rp.db_path == ws_abs / "harness.db"
assert rp.analyses_dir == ws_abs / "analyses"
assert rp.runs_dir == ws_abs / "runs"
# ---------------------------------------------------------------------------
# VersionedSkillStore
# ---------------------------------------------------------------------------
def test_versioned_skill_store_read(store_dir: Path, workspace_dir: Path) -> None:
"""read_skill 应返回文件全文。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
skills_dir = workspace_dir / "skills" / "v1"
store = VersionedSkillStore(skills_dir)
content = store.read_skill("temporal-reasoning.md")
assert content == "skill content A"
def test_versioned_skill_store_read_missing(store_dir: Path, workspace_dir: Path) -> None:
"""读取不存在的 skill 文件应 FileNotFoundError。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
skills_dir = workspace_dir / "skills" / "v1"
store = VersionedSkillStore(skills_dir)
with pytest.raises(FileNotFoundError):
store.read_skill("nonexistent.md")
def test_versioned_skill_store_list(store_dir: Path, workspace_dir: Path) -> None:
"""list_skill_files 应列出所有 skill 文件名(排序)。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
skills_dir = workspace_dir / "skills" / "v1"
store = VersionedSkillStore(skills_dir)
files = store.list_skill_files()
assert sorted(files) == ["spatial-analysis.md", "temporal-reasoning.md"]
# ---------------------------------------------------------------------------
# VersionedPromptStore
# ---------------------------------------------------------------------------
def test_versioned_prompt_store(store_dir: Path, workspace_dir: Path) -> None:
"""VersionedPromptStore 读写功能验证。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
prompts_dir = workspace_dir / "prompts" / "v1"
store = VersionedPromptStore(prompts_dir)
content = store.read_prompt("system.md")
assert content == "system prompt v1"
files = store.list_prompt_files()
assert sorted(files) == ["extract.md", "system.md"]
def test_versioned_prompt_store_read_missing(store_dir: Path, workspace_dir: Path) -> None:
"""读取不存在的 prompt 文件应 FileNotFoundError。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
prompts_dir = workspace_dir / "prompts" / "v1"
store = VersionedPromptStore(prompts_dir)
with pytest.raises(FileNotFoundError):
store.read_prompt("nonexistent.md")
# ---------------------------------------------------------------------------
# Protocol 合规
# ---------------------------------------------------------------------------
def test_skill_store_protocol_compliance(store_dir: Path, workspace_dir: Path) -> None:
"""VersionedSkillStore 应满足 core/evolution/protocols.py::SkillStore Protocol。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
skills_dir = workspace_dir / "skills" / "v1"
store = VersionedSkillStore(skills_dir)
assert isinstance(store, SkillStore)
def test_prompt_store_protocol_compliance(store_dir: Path, workspace_dir: Path) -> None:
"""VersionedPromptStore 应满足 core/evolution/protocols.py::PromptStore Protocol。"""
init_workspace(
workspace_dir,
store_dir,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
)
prompts_dir = workspace_dir / "prompts" / "v1"
store = VersionedPromptStore(prompts_dir)
assert isinstance(store, PromptStore)