feat: parallel evolve + continuous gate wiring in runner (algo #6)

This commit is contained in:
2026-07-17 03:32:14 -04:00
parent b3aba7c31d
commit 23a64042fe
2 changed files with 508 additions and 113 deletions
+270 -113
View File
@@ -12,6 +12,7 @@
from __future__ import annotations
import asyncio
import json
import math
import random
@@ -34,6 +35,7 @@ from app.harness.checkpoint import (
)
from app.harness.config import RunConfig # noqa: TC001 — 运行时 _compute_total_steps 使用
from app.harness.gate_ladder import BaselineCache, GatePools, build_or_load_gate_pools
from app.harness.log import HarnessLog
from app.harness.observation import (
write_dual_metric,
write_epoch_report,
@@ -45,7 +47,13 @@ from app.harness.observation import (
)
from app.harness.question_units import build_units, unit_correctness_view
from app.harness.store import advance_version
from app.harness.validate import Probation, ValidationOutcome
from app.harness.validate import (
GateSpec,
Probation,
ValidationOutcome,
_ladder_units,
validate_skills_concurrent,
)
from app.harness.workspace import (
ResolvedPaths,
archive_workspace,
@@ -602,6 +610,32 @@ def _write_skip_report(
)
def _assert_disjoint_target_files(targets_by_type: dict[str, str]) -> None:
"""断言本 step 各题型进化目标文件互不相同(设计 v3 §1 fail-fast)。
题型并行进化 + 并行 gate 的前提是 skill 文件不相交;两题型 fallback 到
同一 default-strategy.md 时并行会互相覆盖候选与 accept,必须显式中止
而非静默串行(当前 12 题型均有专属文件,此断言防未来配置漂移)。
参数:
targets_by_type: {题型: 解析后 skill 文件名}。
返回:
无。
异常:
RuntimeError: 存在两个题型映射同一文件。
"""
seen: dict[str, str] = {}
for task_type, target in targets_by_type.items():
if target in seen:
raise RuntimeError(
f"题型 {seen[target]!r}{task_type!r} 映射同一 skill 文件 {target!r},"
"并行进化/gate 不支持共享目标文件(设计 v3 §1)"
)
seen[target] = task_type
def _escape_sql_like(text: str) -> str:
"""转义 SQL LIKE 模式中的全部特殊字符(`\\`、`%`、`_`)为字面匹配。
@@ -1199,7 +1233,7 @@ class Runner:
_guard_infra_failures(result, context="rollout")
# -----------------------------------------------------------------------
# _gate_batch_skillsper task_type gate
# _gate_batch_skills并行进化 + 连续并发 gate(四阶段)
# -----------------------------------------------------------------------
async def _gate_batch_skills(
@@ -1211,18 +1245,95 @@ class Runner:
pools: Pools,
state: _TrainState,
) -> None:
"""按 task_type 独立 evolve → 局部验证 → accept/reject。"""
from app.harness.workspace import VersionedSkillStore
from core.evolution import evolve_single_skill
"""按 task_type 并行 evolve → 连续并发 gate → 字母序统一落账。
四阶段(设计 v3 §2.1):Phase A 并行进化(cooldown/无改动照旧跳过);
Phase B 装配 GateSpec(阶梯出题 + 案例单元排除 + n_max 截断);
Phase C validate_skills_concurrent(共享题槽,统计按阶梯序前缀推进,
只读 state);Phase D 唯一写 state 阶段——按字母序 accept/reject 落账,
与原串行语义等价(题型 skill 文件不相交,合并顺序仅为确定性)。
参数:
epoch / step / total_steps: 训练坐标。
diagnosis: 本 step 诊断结果(skill_case_packs 按题型分组)。
pools: 冻结三池。
state: 训练状态(Phase D 唯一写入点)。
返回:
无。
"""
budget = edit_budget_at(
global_step=state.global_step,
total_steps=total_steps,
start=self._config.edit_budget_start,
end=self._config.edit_budget_end,
)
# ---- Phase A: 并行进化(冷却/无真实改动照旧写 skip 后出清) ----
records = await self._evolve_types_parallel(epoch, step, diagnosis, budget, pools, state)
if not records:
return
_assert_disjoint_target_files({t: r.target_file for t, r in records.items()})
# ---- Phase B: 装配 GateSpec(阶梯出题,收编原 _run_gate_validation 前半) ----
specs = self._assemble_gate_specs(epoch, step, diagnosis, records, pools, state)
# ---- Phase C: 连续并发 gate(只读 state) ----
with HarnessLog(str(self._paths.db_path), f"gate_e{epoch}_s{step}") as gate_log:
outcomes = await validate_skills_concurrent(
workspace_dir=self._config.workspace_dir,
base_skills_version=self._current_version("skills"),
specs=specs,
gate_params=GateParams(
e_confirm=self._config.gate_e_confirm,
e_provisional=self._config.gate_e_provisional,
w_net_min=self._config.gate_w_net_min,
delta_min=self._config.gate_delta_min,
lambda_dir=self._config.gate_lambda_dir,
e_rollback=self._config.gate_e_rollback,
),
gate_guard_err=self._config.gate_guard_err,
baseline_cache=state.baseline_cache,
prompts_version=self._current_version("prompts"),
run_inference=self._make_validate_run_inference_fn(gate_log),
log=gate_log,
concurrency=self._config.concurrency,
)
# ---- Phase D: 唯一写 state 阶段(字母序确定性落账) ----
self._settle_gate_outcomes(epoch, step, records, outcomes, budget, pools, state)
async def _evolve_types_parallel(
self,
epoch: int,
step: int,
diagnosis: DiagnosisResult,
budget: int,
pools: Pools,
state: _TrainState,
) -> dict[str, EvolutionRecord]:
"""Phase A:各题型进化 asyncio.gather 并行,冷却/无改动路径写 skip 出队。
cooldown 与"进化未产出真实改动"(rejected/skipped/内容未变)两类路径
与原串行实现语义一致:写 skip_report 后不进 gate。进化互相独立
(各题型 skill 文件不相交,VersionedSkillStore 只读基线版本),
gather 并行不改变单题型结果。
参数:
epoch / step: 训练坐标。
diagnosis: 本 step 诊断结果。
budget: 当步编辑预算。
pools: 冻结三池。
state: 训练状态(只读)。
返回:
{题型: EvolutionRecord},仅含产出真实改动、待 gate 的题型。
"""
from app.harness.workspace import VersionedSkillStore
from core.evolution import evolve_single_skill
active_types: list[str] = []
for task_type in sorted(diagnosis.skill_case_packs):
# 冷却 admission control
if state.gate_cooldown.get(task_type, 0) > 0:
_write_skip_report(
self._config.workspace_dir,
@@ -1237,22 +1348,40 @@ class Runner:
budget=budget,
)
continue
active_types.append(task_type)
if not active_types:
return {}
evolve_prompts = self._load_evolve_prompts()
skills_version = self._current_version("skills")
async def _evolve_one(task_type: str) -> EvolutionRecord:
pack = diagnosis.skill_case_packs[task_type]
skill_store = VersionedSkillStore(self._paths.skills_dir)
evolve_prompts = self._load_evolve_prompts()
record = await evolve_single_skill(
return await evolve_single_skill(
self._evolve_llm,
pack,
skill_store,
evolve_prompts,
self._current_version("skills"),
skills_version,
budget,
self._config.appendix_consolidate_threshold,
skill_update_mode=self._config.skill_update_mode,
rejected=state.rejected_buffer.get(task_type, []),
)
# 进化未产出真实改动
records = dict(
zip(
active_types,
await asyncio.gather(*[_evolve_one(t) for t in active_types]),
strict=True,
)
)
# 无真实改动的题型照旧写 skipped 后出队
gated: dict[str, EvolutionRecord] = {}
for task_type in active_types:
record = records[task_type]
if record.status in ("rejected", "skipped") or (
record.evolved_content == record.original_content
):
@@ -1270,11 +1399,107 @@ class Runner:
rank_clip_triggered=bool(record.clip_info.get("triggered", False)),
)
continue
gated[task_type] = record
return gated
outcome = await self._run_gate_validation(
epoch, step, task_type, pack, record, pools, state
def _assemble_gate_specs(
self,
epoch: int,
step: int,
diagnosis: DiagnosisResult,
records: dict[str, EvolutionRecord],
pools: Pools,
state: _TrainState,
) -> list[GateSpec]:
"""Phase B:为每个待 gate 题型装配 GateSpec(阶梯出题 + 截断)。
案例包按 unit 排除:把每个 case 的 question_id 映射到其所属 unit_id,
命中单元整体排除,防止只排 AR pair 半个成员而给 gate 池灌半个 pair
(下游 _ladder_units 会 fail-fast)。base_skill_content 读 step 起点
版本(self._paths 在 Phase D accept 前不变),保证所有题型对同一
基线版本验证。核心算法保真 #5。
参数:
epoch / step: 训练坐标(拼 gate_run_prefix)。
diagnosis: 本 step 诊断结果(案例排除来源)。
records: Phase A 产出的待 gate 进化记录。
pools: 冻结三池(baseline_run_id)。
state: 训练状态(只读 gate_pools / gate_epoch_observed)。
返回:
与 records 键序一致的 GateSpec 列表。
异常:
RuntimeError: 阶梯引用了题库中不存在的 unit_id。
"""
specs: list[GateSpec] = []
for task_type, record in records.items():
pack = diagnosis.skill_case_packs[task_type]
exclude_units = {
self._gate_questions_by_id[c.question_id].unit_id
for c in pack.failure_cases + pack.success_cases
if c.question_id in self._gate_questions_by_id
}
ladder_unit_ids = state.gate_pools.ladder_for(
task_type,
exclude_units,
p_low=self._config.gate_p_low,
p_high=self._config.gate_p_high,
cold=not state.gate_epoch_observed,
)
# 观测落库
missing = [uid for uid in ladder_unit_ids if uid not in self._gate_units_by_id]
if missing:
raise RuntimeError(
f"gate 阶梯引用未知 unit: {missing[:5]}(gate_pools.json 与题库失配)"
)
ladder_items = [
q for uid in ladder_unit_ids for q in self._gate_units_by_id[uid].questions
]
slug = task_type.lower().replace(" ", "-")
specs.append(
GateSpec(
task_type=task_type,
target_file=record.target_file,
candidate_content=record.evolved_content,
base_skill_content=(self._paths.skills_dir / record.target_file).read_text(
encoding="utf-8"
),
units=tuple(_ladder_units(ladder_items)[: self._config.gate_n_max]),
gate_run_prefix=f"{pools.baseline_run_id}_e{epoch}_s{step}_gate_{slug}",
)
)
return specs
def _settle_gate_outcomes(
self,
epoch: int,
step: int,
records: dict[str, EvolutionRecord],
outcomes: dict[str, ValidationOutcome],
budget: int,
pools: Pools,
state: _TrainState,
) -> None:
"""Phase D:按字母序统一落账(观测落库 + accept/reject 写 state)。
本阶段是 _gate_batch_skills 唯一写 state 的阶段。字母序仅为确定性
(题型 skill 文件不相交,accept 串行叠加时 _accept_skill 基于最新
manifest 版本追加各自 target_file,互不覆盖),与原串行语义等价。
参数:
epoch / step: 训练坐标。
records: Phase A 产出的进化记录。
outcomes: Phase C 产出的 gate 判定。
budget: 当步编辑预算(step_report 落账)。
pools: 冻结三池。
state: 训练状态(唯一写入点)。
返回:
无。
"""
for task_type in sorted(outcomes):
record = records[task_type]
outcome = outcomes[task_type]
write_gate_evidence(
str(self._paths.db_path),
run_id=pools.baseline_run_id,
@@ -1313,88 +1538,6 @@ class Runner:
state.rejected_buffer, task_type, record, outcome, state.global_step
)
async def _run_gate_validation(
self,
epoch: int,
step: int,
task_type: str,
pack: Any,
record: EvolutionRecord,
pools: Pools,
state: _TrainState,
) -> ValidationOutcome:
"""CE-Gate 块序贯配对验证:阶梯出题 → 基线/候选逐块配对 → e-process 四出口。
参数:
epoch: 轮次。
step: epoch 内 step。
task_type: 待验证题型。
pack: SkillCasePack。
record: 进化产物。
pools: 冻结三池。
state: 训练状态。
返回:
ValidationOutcome。
"""
from app.harness.log import HarnessLog
from app.harness.validate import validate_skill_local
# 案例包按 unit 排除:把每个 case 的 question_id 映射到其所属 unit_id
# 命中单元整体排除,防止只排 AR pair 半个成员而给 gate 池灌半个 pair
# (下游 _ladder_units 会 fail-fast)。核心算法保真 #5。
exclude_units = {
self._gate_questions_by_id[c.question_id].unit_id
for c in pack.failure_cases + pack.success_cases
if c.question_id in self._gate_questions_by_id
}
ladder_unit_ids = state.gate_pools.ladder_for(
task_type,
exclude_units,
p_low=self._config.gate_p_low,
p_high=self._config.gate_p_high,
cold=not state.gate_epoch_observed,
)
missing = [uid for uid in ladder_unit_ids if uid not in self._gate_units_by_id]
if missing:
raise ValueError(
f"gate 阶梯[{task_type}] 含 benchmark 中不存在的单元: "
f"{missing[:5]}gate_pools.json 与题库失配)"
)
# 单元展开为逐题(unit 内成员顺序保持),下游 validate 再按阶梯序聚合回单元。
ladder_items = [q for uid in ladder_unit_ids for q in self._gate_units_by_id[uid].questions]
base_skill_content = (self._paths.skills_dir / record.target_file).read_text(
encoding="utf-8"
)
slug = task_type.lower().replace(" ", "-")
run_inference_fn = self._make_validate_run_inference_fn()
with HarnessLog(str(self._paths.db_path), f"gate_{slug}") as gate_log:
return await validate_skill_local(
workspace_dir=self._config.workspace_dir,
base_skills_version=self._current_version("skills"),
task_type=task_type,
target_file=record.target_file,
candidate_content=record.evolved_content,
base_skill_content=base_skill_content,
ladder_items=ladder_items,
gate_params=GateParams(
e_confirm=self._config.gate_e_confirm,
e_provisional=self._config.gate_e_provisional,
w_net_min=self._config.gate_w_net_min,
delta_min=self._config.gate_delta_min,
lambda_dir=self._config.gate_lambda_dir,
e_rollback=self._config.gate_e_rollback,
),
gate_block=self._config.gate_block,
gate_n_max=self._config.gate_n_max,
gate_guard_err=self._config.gate_guard_err,
baseline_cache=state.baseline_cache,
prompts_version=self._current_version("prompts"),
run_inference=run_inference_fn,
log=gate_log,
gate_run_prefix=(f"{pools.baseline_run_id}_e{epoch}_s{step}_gate_{slug}"),
)
# -----------------------------------------------------------------------
# accept / reject / probation
# -----------------------------------------------------------------------
@@ -2475,10 +2618,23 @@ class Runner:
return _noop_builder
def _make_validate_run_inference_fn(self):
"""构造 validate 用的 RunInferenceFn绑定共享依赖)。"""
def _make_validate_run_inference_fn(self, gate_log: HarnessLog):
"""构造 validate 用的 RunInferenceFn(绑定共享依赖与共享 HarnessLog)。
连续并发 gate 下本函数被逐单元高频并发调用:每次调用新建 HarnessLog
连接会重现多连接争 SQLite 写锁(遥测同款教训),故复用调用方传入的
单一 gate_log(单连接 + threading.Lock 串行化)。_record_run 按 run_id
去重,避免逐单元重复 upsert。
参数:
gate_log: 本 step gate 阶段共享的 HarnessLog 实例。
返回:
符合 RunInferenceFn 协议的异步推理函数。
"""
from app.harness.inference import run_inference
from app.harness.log import HarnessLog
recorded: set[str] = set()
async def _run(
questions: list[GeneratedQuestion],
@@ -2486,21 +2642,22 @@ class Runner:
run_id: str,
skills_dir: Path,
) -> InferenceResult:
self._record_run(run_id)
with HarnessLog(str(self._paths.db_path), run_id) as log:
return await run_inference(
questions=questions,
llm=self._llm,
tool_dispatch_fn=self._make_tool_dispatch_fn(skills_dir=skills_dir),
prompt_builder=self._make_prompt_builder(
skills_dir=skills_dir, prompts_dir=self._paths.prompts_dir
),
log=log,
run_id=run_id,
concurrency=self._config.concurrency,
max_steps=self._config.max_steps,
skill_mode=self._config.skill_mode,
)
if run_id not in recorded:
recorded.add(run_id)
self._record_run(run_id)
return await run_inference(
questions=questions,
llm=self._llm,
tool_dispatch_fn=self._make_tool_dispatch_fn(skills_dir=skills_dir),
prompt_builder=self._make_prompt_builder(
skills_dir=skills_dir, prompts_dir=self._paths.prompts_dir
),
log=gate_log,
run_id=run_id,
concurrency=self._config.concurrency,
max_steps=self._config.max_steps,
skill_mode=self._config.skill_mode,
)
return _run