44 KiB
core/evolution/ Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Migrate TRM4 evolution engine into a Clean Architecture extractable kernel (core/evolution/).
Architecture: 7 files, dependency-ordered. Pure decision logic only — no DB writes, no filesystem versioning, no inference orchestration. All external I/O through 3 Protocols + 1 existing LLMProvider. Async-first (asyncio.gather + Semaphore).
Tech Stack: Python 3.11, asyncio, scipy.special, json_repair, loguru, pluggy (already in project)
Design doc: research-wiki/designs/2026-07-07-core-evolution-design.md
TRM4 source: /home/iomgaa/Projects/Video-Tree-TRM4/core/harness/
File Structure
| File | Lines (est.) | Creates | Depends on |
|---|---|---|---|
core/evolution/protocols.py |
60 | New | core/evolution/types.py (TYPE_CHECKING) |
core/evolution/types.py |
280 | New | — |
core/evolution/gate.py |
170 | New | types.py |
core/evolution/patch.py |
440 | New | — (loguru only) |
core/evolution/validate.py |
70 | New | gate.py, types.py |
core/evolution/diagnose.py |
1200 | New | types.py, protocols.py, core/protocols.py |
core/evolution/evolve.py |
900 | New | types.py, protocols.py, patch.py, core/protocols.py |
core/evolution/__init__.py |
30 | Modify | all above |
tests/unit/test_gate.py |
200 | New | — |
tests/unit/test_patch.py |
350 | New | — |
tests/unit/test_validate.py |
120 | New | — |
tests/unit/test_diagnose.py |
400 | New | — |
tests/unit/test_evolve.py |
400 | New | — |
Task 1: protocols.py + types.py(基础层)
Files:
-
Create:
core/evolution/protocols.py -
Create:
core/evolution/types.py -
Test:
tests/unit/test_evolution_types.py -
Step 1: Write type construction tests
"""tests/unit/test_evolution_types.py"""
from core.evolution.types import (
GateParams, GateVerdict, SpanMetrics, SkillStepAdherence,
QuestionMetrics, ErrorAttribution, CaseSample,
SkillCasePack, SystemCasePack, ToolCasePack, DiagnosisResult,
EvolutionRecord, RejectedEdit, EvolutionResult,
PairResult, QuadrantClassification,
DiagnosePrompts, EvolvePrompts,
)
def test_gate_params_frozen():
p = GateParams(e_confirm=20.0, e_provisional=3.0, w_net_min=2,
delta_min=0.02, lambda_dir=-0.642, e_rollback=10.0)
assert p.e_confirm == 20.0
import pytest
with pytest.raises(AttributeError):
p.e_confirm = 1.0
def test_evolution_record_mutable():
r = EvolutionRecord(
target_file="test.md", target_type="skill",
original_content="a", evolved_content="b",
reason="test", status="accepted", source_version="v1",
suggestions=[], edits=[], apply_report=[], clip_info={},
)
r.status = "rejected"
assert r.status == "rejected"
def test_diagnose_prompts_frozen():
dp = DiagnosePrompts(
defect_vs_lapse="p1", reasoning_sub="p2",
span_eval_system="p3", span_eval_user="p4",
missed_nodes="p5", skill_adherence="p6",
confirmation_bias="p7", evidence_sufficiency="p8",
)
assert dp.defect_vs_lapse == "p1"
- Step 2: Run test — expect FAIL (ImportError)
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_evolution_types.py -v
- Step 3: Implement types.py
从 TRM4 迁移所有 dataclass。关键变更:
| TRM4 位置 | TRM5 变更 |
|---|---|
eprocess.py::GateParams/GateVerdict |
原样迁移 |
diagnose.py::SpanMetrics 等 9 个 |
全部标 frozen=True(TRM4 中 QuestionMetrics 非 frozen,TRM5 一次性构造) |
evolve.py::EvolutionRecord |
保持 mutable,新增 `result_version: str |
evolve.py::RejectedEdit |
原样迁移,frozen |
evolve.py::EvolutionResult |
移除 skills_version/prompts_version(app/ 职责) |
validate.py::ValidationOutcome/Probation/InferenceRunConfig |
不迁——属 app/harness/ |
新增 PairResult/QuadrantClassification |
块验证纯决策输出 |
新增 DiagnosePrompts/EvolvePrompts |
模板束 |
保真校验点:逐字段对比 TRM4 dataclass,确保无遗漏字段。特别注意 DiagnosisResult 的完整字段列表(约 20 个字段)。
- Step 4: Implement protocols.py
"""core/evolution/protocols.py — 3 个只读 Protocol"""
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class SkillStore(Protocol):
def read_skill(self, filename: str) -> str: ...
def list_skill_files(self) -> list[str]: ...
@runtime_checkable
class PromptStore(Protocol):
def read_prompt(self, filename: str) -> str: ...
def list_prompt_files(self) -> list[str]: ...
@runtime_checkable
class RunLog(Protocol):
async def get_predictions(
self, run_id: str, *, question_ids: list[str] | None = None,
) -> list[dict[str, Any]]: ...
async def get_traces(
self, run_id: str, *, question_ids: list[str] | None = None,
) -> list[dict[str, Any]]: ...
- Step 5: Run test — expect PASS
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_evolution_types.py -v
- Step 6: Commit
git add core/evolution/types.py core/evolution/protocols.py tests/unit/test_evolution_types.py
git commit -m "feat(evolution): types.py + protocols.py — foundation dataclasses and Protocol ports"
Task 2: gate.py — CE-Gate e-process(算法 #5)
Files:
-
Create:
core/evolution/gate.py -
Test:
tests/unit/test_gate.py -
Source: TRM4
eprocess.py(163 行,原样迁移,零有意变更) -
Step 1: Write gate tests
"""tests/unit/test_gate.py"""
import math
import pytest
from core.evolution.gate import compute_e_value, gate_decision, probation_verdict
from core.evolution.types import GateParams, GateVerdict
_PARAMS = GateParams(
e_confirm=20.0, e_provisional=3.0, w_net_min=2,
delta_min=0.02, lambda_dir=-0.642, e_rollback=10.0,
)
class TestComputeEValue:
def test_zero_zero_returns_one(self):
assert compute_e_value(0, 0) == pytest.approx(1.0)
def test_negative_w_raises(self):
with pytest.raises(ValueError):
compute_e_value(-1, 0)
def test_negative_l_raises(self):
with pytest.raises(ValueError):
compute_e_value(0, -1)
def test_heavy_loss_returns_near_zero(self):
assert compute_e_value(0, 20) < 0.01
def test_heavy_win_returns_large(self):
assert compute_e_value(10, 0) > 100
def test_symmetric(self):
e_5_3 = compute_e_value(5, 3)
e_3_5 = compute_e_value(3, 5)
assert e_5_3 > e_3_5
class TestGateDecision:
def test_confirmed_needs_both_e_and_delta(self):
v = gate_decision(10, 0, 10, 10, params=_PARAMS)
assert v.decision == "accept_confirmed"
def test_continue_on_balanced(self):
v = gate_decision(3, 3, 6, 20, params=_PARAMS)
assert v.decision == "continue"
def test_reject_inertia_on_exhaustion(self):
v = gate_decision(1, 1, 2, 0, params=_PARAMS)
assert v.decision == "reject_inertia"
def test_n_used_zero_raises(self):
with pytest.raises(ValueError):
gate_decision(0, 0, 0, 10, params=_PARAMS)
def test_n_remaining_negative_raises(self):
with pytest.raises(ValueError):
gate_decision(1, 0, 1, -1, params=_PARAMS)
class TestProbationVerdict:
def test_strong_win_confirmed(self):
assert probation_verdict(10, 0, params=_PARAMS) == "confirmed"
def test_strong_loss_rollback(self):
assert probation_verdict(0, 10, params=_PARAMS) == "rollback"
def test_balanced_unverified(self):
assert probation_verdict(3, 3, params=_PARAMS) == "unverified"
- Step 2: Run test — expect FAIL
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_gate.py -v
- Step 3: Implement gate.py
从 TRM4 eprocess.py 原样迁移全部代码(163 行)。变更仅限:
- import 路径:
core.harness.eprocess→core.evolution.gate GateParams/GateVerdict从core.evolution.types导入(不在 gate.py 定义)
保真校验:逐行比对 TRM4 eprocess.py,确保 _WALD_WIN/_WALD_LOSS/_SHRINK_PSEUDO 常量值、log 空间公式、对称性技巧、四出口优先级链、futility best-case 检查全部保留。
- Step 4: Run test — expect PASS
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_gate.py -v
- Step 5: Commit
git add core/evolution/gate.py tests/unit/test_gate.py
git commit -m "feat(evolution): gate.py — CE-Gate e-process (#5)"
Task 3: patch.py — 补丁引擎(算法 #9 局部)
Files:
-
Create:
core/evolution/patch.py -
Test:
tests/unit/test_patch.py -
Source: TRM4
patch.py(427 行,原样迁移,零有意变更) -
Step 1: Write patch tests
"""tests/unit/test_patch.py"""
import pytest
from core.evolution.patch import (
APPENDIX_START, APPENDIX_END, MOMENTUM_START, MOMENTUM_END,
appendix_region_bounds, momentum_region_bounds, momentum_inner,
append_to_appendix, extract_appendix_notes, replace_appendix_notes,
replace_momentum, apply_patch_with_report,
)
class TestRegionBounds:
def test_no_markers_returns_none(self):
assert appendix_region_bounds("hello") is None
assert momentum_region_bounds("hello") is None
def test_both_markers_returns_range(self):
text = f"head\n{APPENDIX_START}\nbody\n{APPENDIX_END}\ntail"
start, end = appendix_region_bounds(text)
assert text[start:end].startswith(APPENDIX_START)
assert text[start:end].endswith(APPENDIX_END)
def test_single_marker_raises(self):
with pytest.raises(ValueError):
appendix_region_bounds(f"head\n{APPENDIX_START}\nbody")
with pytest.raises(ValueError):
momentum_region_bounds(f"head\n{MOMENTUM_END}\nbody")
class TestAppendix:
def test_append_creates_region(self):
result = append_to_appendix("content", ["note1"])
assert APPENDIX_START in result
assert "- note1" in result
def test_extract_notes(self):
text = f"{APPENDIX_START}\n- a\n- b\n{APPENDIX_END}"
assert extract_appendix_notes(text) == ["a", "b"]
def test_replace_empty_deletes_region(self):
text = f"head\n{APPENDIX_START}\n- old\n{APPENDIX_END}\ntail"
result = replace_appendix_notes(text, [])
assert APPENDIX_START not in result
class TestMomentum:
def test_replace_creates_region(self):
result = replace_momentum("content", "guidance text")
assert MOMENTUM_START in result
assert "guidance text" in result
def test_marker_injection_raises(self):
with pytest.raises(ValueError):
replace_momentum("content", f"evil {MOMENTUM_START}")
def test_empty_guidance_clears(self):
text = replace_momentum("content", "old")
result = replace_momentum(text, "")
inner = momentum_inner(result)
assert inner == ""
class TestApplyPatch:
def test_append_before_protected(self):
content = f"body\n{APPENDIX_START}\nprotected\n{APPENDIX_END}"
edits = [{"op": "append", "target": "", "content": "new line"}]
new, report = apply_patch_with_report(content, edits, [f"{APPENDIX_START}\nprotected\n{APPENDIX_END}"])
assert report[0]["status"].startswith("applied")
assert new.index("new line") < new.index(APPENDIX_START)
def test_replace_in_protected_skipped(self):
protected = f"{APPENDIX_START}\nprotected\n{APPENDIX_END}"
content = f"body\n{protected}"
edits = [{"op": "replace", "target": "protected", "content": "replaced"}]
new, report = apply_patch_with_report(content, edits, [protected])
assert report[0]["status"] == "skipped_protected"
assert "protected" in new
def test_insert_after_fallback(self):
content = "line1\nline2"
edits = [{"op": "insert_after", "target": "nonexistent", "content": "new"}]
new, report = apply_patch_with_report(content, edits)
assert "applied_insert_after_fallback" in report[0]["status"]
def test_delete_first_occurrence(self):
content = "a\nb\na\nc"
edits = [{"op": "delete", "target": "a", "content": ""}]
new, report = apply_patch_with_report(content, edits)
assert new.count("a") == 1
- Step 2: Run test — expect FAIL
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_patch.py -v
- Step 3: Implement patch.py
从 TRM4 patch.py 原样迁移全部代码(427 行)。零有意变更——import 路径调整除外。
保真校验:
-
7 个常量值完全一致
-
_protected_ranges半开区间语义[start, end) -
_in_ranges用start <= pos < end -
append op 的
start > 0过滤(跳过 frontmatter) -
insert_after 三结果(成功 / 降级 append / skip)
-
target 不 strip,payload strip
-
每条 edit 前重算 ranges
-
report 字段 truncation(target[:200], content[:200])
-
Step 4: Run test — expect PASS
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_patch.py -v
- Step 5: Commit
git add core/evolution/patch.py tests/unit/test_patch.py
git commit -m "feat(evolution): patch.py — patch engine with protected regions (#9)"
Task 4: validate.py — 纯决策函数(算法 #7 局部)
Files:
-
Create:
core/evolution/validate.py -
Test:
tests/unit/test_validate.py -
Step 1: Write validate tests
"""tests/unit/test_validate.py"""
from core.evolution.validate import pair_block, classify_quadrants, compute_accuracy
class TestPairBlock:
def test_basic_flips(self):
baseline = {"q1": False, "q2": True, "q3": True}
candidate = {"q1": True, "q2": False, "q3": True}
result = pair_block(baseline, candidate, ["q1", "q2", "q3"])
assert result.w == 1 # q1: wrong→right
assert result.l == 1 # q2: right→wrong
assert result.observed == {
"q1": (False, True), "q2": (True, False), "q3": (True, True)
}
def test_empty(self):
result = pair_block({}, {}, [])
assert result.w == 0 and result.l == 0
class TestClassifyQuadrants:
def test_all_four(self):
observed = {
"q1": (False, True), # improved
"q2": (True, False), # regressed
"q3": (False, False), # persistent_fail
"q4": (True, True), # stable_success
}
qc = classify_quadrants(observed)
assert qc.improvements == ["q1"]
assert qc.regressions == ["q2"]
assert qc.persistent_fails == ["q3"]
assert qc.stable_successes == ["q4"]
def test_sorted_within_quadrant(self):
observed = {"z": (False, True), "a": (False, True)}
qc = classify_quadrants(observed)
assert qc.improvements == ["a", "z"]
class TestComputeAccuracy:
def test_basic(self):
assert compute_accuracy({"q1": True, "q2": False}, ["q1", "q2"]) == 0.5
def test_empty_raises(self):
import pytest
with pytest.raises(ZeroDivisionError):
compute_accuracy({}, [])
- Step 2: Run test — expect FAIL
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_validate.py -v
- Step 3: Implement validate.py
三个纯函数,约 70 行。从 TRM4 validate.py 的 _pair_block 和 _classify_quadrants 提取纯逻辑。
"""core/evolution/validate.py — 块验证纯决策函数。"""
from core.evolution.types import PairResult, QuadrantClassification
def pair_block(
baseline: dict[str, bool],
candidate: dict[str, bool],
question_ids: list[str],
) -> PairResult:
"""逐题比对基线与候选对错,统计翻转。"""
w = l = 0
observed: dict[str, tuple[bool, bool]] = {}
for qid in question_ids:
b, c = baseline[qid], candidate[qid]
observed[qid] = (b, c)
if not b and c:
w += 1
elif b and not c:
l += 1
return PairResult(w=w, l=l, observed=observed)
def classify_quadrants(
observed: dict[str, tuple[bool, bool]],
) -> QuadrantClassification:
"""按 (baseline, candidate) 四组分类,各组内 sorted。"""
improvements, regressions, persistent_fails, stable_successes = [], [], [], []
for qid, (prev, curr) in observed.items():
if not prev and curr:
improvements.append(qid)
elif prev and not curr:
regressions.append(qid)
elif not prev and not curr:
persistent_fails.append(qid)
else:
stable_successes.append(qid)
return QuadrantClassification(
improvements=sorted(improvements),
regressions=sorted(regressions),
persistent_fails=sorted(persistent_fails),
stable_successes=sorted(stable_successes),
)
def compute_accuracy(
correctness: dict[str, bool],
question_ids: list[str],
) -> float:
"""纯算术:sum(correct) / len(ids)。"""
return sum(correctness[qid] for qid in question_ids) / len(question_ids)
- Step 4: Run test — expect PASS
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_validate.py -v
- Step 5: Commit
git add core/evolution/validate.py tests/unit/test_validate.py
git commit -m "feat(evolution): validate.py — pure block validation decision functions (#7)"
Task 5: diagnose.py — 指标计算与 judge 辅助函数
Files:
-
Create:
core/evolution/diagnose.py(本 Task 写 metrics 部分,约 500 行) -
Test:
tests/unit/test_diagnose.py(本 Task 写 metrics 测试) -
Source: TRM4
metrics.py+diagnose.py的attribute_error/classify_defect_vs_lapse -
Step 1: Write metrics + attribution tests
"""tests/unit/test_diagnose.py"""
import pytest
from core.evolution.diagnose import (
calc_format_compliance, calc_budget_usage,
calc_confidence_calibration, calc_repeat_visit_rate,
calc_search_keyword_repetition, calc_level_jump_pattern,
calc_tool_usage, extract_json_from_response,
attribute_error, question_soft_score, aggregate_soft,
)
class TestRuleMetrics:
def test_format_compliance_empty_returns_one(self):
assert calc_format_compliance([]) == 1.0
def test_budget_usage(self):
assert calc_budget_usage(5, 15) == pytest.approx(1/3)
def test_confidence_calibration(self):
assert calc_confidence_calibration(0.9, False) == "high_conf_wrong"
assert calc_confidence_calibration(0.3, True) == "low_conf_right"
assert calc_confidence_calibration(0.6, True) == "calibrated"
def test_repeat_visit_empty(self):
assert calc_repeat_visit_rate([]) == 0.0
def test_repeat_visit_all_unique(self):
assert calc_repeat_visit_rate(["a", "b", "c"]) == 0.0
def test_repeat_visit_all_same(self):
assert calc_repeat_visit_rate(["a", "a", "a"]) == pytest.approx(2/3)
def test_keyword_repetition_lt2(self):
assert calc_search_keyword_repetition(["one"]) == 0.0
def test_keyword_repetition_max_jaccard(self):
val = calc_search_keyword_repetition(["abcdef", "abcxyz"])
assert 0.0 < val < 1.0
def test_level_jump_pattern(self):
assert "L1" in calc_level_jump_pattern(["seg_L1_000", "seg_L2_001"])
def test_tool_usage_counts(self):
assert calc_tool_usage(["view_node", "view_node", "search_similar"]) == {
"view_node": 2, "search_similar": 1,
}
class TestJsonExtraction:
def test_fenced_block(self):
raw = '```json\n{"key": "val"}\n```'
assert extract_json_from_response(raw) == {"key": "val"}
def test_outermost_braces(self):
raw = 'prefix {"key": 1} suffix'
assert extract_json_from_response(raw) == {"key": 1}
def test_non_dict_raises(self):
with pytest.raises(ValueError):
extract_json_from_response("[1,2,3]")
def test_garbage_raises(self):
with pytest.raises(ValueError):
extract_json_from_response("not json at all")
class TestAttributeError:
def test_extraction_failure(self):
from core.evolution.types import QuestionMetrics, SpanMetrics
span = SpanMetrics(step=1, tool_name="view_node",
extraction_completeness=0.3, hallucination_rate=0.0,
missed_info_tags=[], hallucination_tags=[])
qm = _make_qm(correct=False, span_metrics=[span], missed_nodes=[],
evidence_sufficient=True)
ea = attribute_error(qm)
assert ea.error_type == "extraction_failure"
def test_search_failure(self):
qm = _make_qm(correct=False, span_metrics=[], missed_nodes=["L2_001"],
evidence_sufficient=False)
ea = attribute_error(qm)
assert ea.error_type == "search_failure"
def test_reasoning_failure(self):
qm = _make_qm(correct=False, span_metrics=[], missed_nodes=[],
evidence_sufficient=True)
ea = attribute_error(qm)
assert ea.error_type == "reasoning_failure"
def test_mixed_fallback(self):
qm = _make_qm(correct=False, span_metrics=[], missed_nodes=[],
evidence_sufficient=False)
ea = attribute_error(qm)
assert ea.error_type == "mixed"
class TestSoftScore:
def test_no_spans_returns_none(self):
assert question_soft_score([]) is None
def test_aggregate_skips_none(self):
assert aggregate_soft([0.8, None, 0.6]) == pytest.approx(0.7)
def test_aggregate_all_none(self):
assert aggregate_soft([None, None]) is None
注:_make_qm 是测试辅助工厂函数,构造 QuestionMetrics 并为非关键字段填充合理默认值。
- Step 2: Run test — expect FAIL
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_diagnose.py -v
- Step 3: Implement diagnose.py metrics 部分
从 TRM4 metrics.py 迁移以下函数组:
extract_rule_metrics(从 prediction dict + raw_contents 提取 7 个规则指标;confidence 优先取末步 JSON 的reflect.confidence,否则prediction["answer_confidence"]默认 0.5)- 7 个规则指标函数(
calc_format_compliance等)+_trigrams+_parse_json_object+_extract_last_confidence extract_json_from_response(三级解析:fenced → outermost{}→json_repair)_call_judge(async 化,max_retries=2 即共 3 次,仅 ValueError 重试,API 错误直传)question_soft_score+aggregate_soft- 5 个 judge 函数(
evaluate_span等,async 化),prompt 文件名:diagnose_span.md/diagnose_missed_nodes.md/diagnose_skill_adherence.md/diagnose_confirmation_bias.md/diagnose_evidence_sufficiency.md compute_question_metrics(async 化)_format_trace_text(metrics 版:thought[:100], output[:200])
从 TRM4 diagnose.py 迁移:
attribute_error(归因瀑布,纯函数)classify_defect_vs_lapse(async 化,LLMProvider 替代 LLMClient)_make_degraded_metrics(worker 抛 ValueError 时生成 degraded=True 的 QuestionMetrics,judge 字段 None/空列表;其他异常直传)
关键变更:
LLMClient→LLMProvider;response.choices[0].message.content→response.content_call_judge变 async:await llm.chat(messages)- judge 函数均变 async
load_diagnose_prompt(prompts_dir, filename)→ 直接从DiagnosePrompts束取属性
保真校验:
-
_SPAN_EVAL_TOOLS = {"view_node", "search_similar", "observe_frame"} -
trigram 是字符级,取 MAX(非 mean)
-
calc_format_compliance空返回 1.0;calc_budget_usage无除零 guard(P5) -
confidence 阈值:
>=0.7且错 → high_conf_wrong,<0.5且对 → low_conf_right -
calc_level_jump_patternregexr"_L(\d+)_",用→连接 -
_call_judgemax_retries=2(共 3 次),API 错误直传 -
归因瀑布精确顺序:extraction → search → reasoning → mixed
-
defect_vs_lapse 解析失败降级 "lapse"
-
_extract_last_confidence任意异常返回 0.5 -
judge 返回值默认:span completeness/hallucination 默认 0.0,tags 用
list(),missed_nodes 非 list 返[] -
Step 4: Run test — expect PASS
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_diagnose.py -v
- Step 5: Commit
git add core/evolution/diagnose.py tests/unit/test_diagnose.py
git commit -m "feat(evolution): diagnose.py metrics + attribution (Stage 1)"
Task 6: diagnose.py — 聚合 + 案例包 + 入口
Files:
-
Modify:
core/evolution/diagnose.py(追加 ~700 行) -
Modify:
tests/unit/test_diagnose.py(追加聚合 + 入口测试) -
Step 1: Write aggregation + case pack tests
# 追加到 tests/unit/test_diagnose.py
from unittest.mock import AsyncMock
from core.evolution.types import (
QuestionMetrics, ErrorAttribution, SpanMetrics,
SkillCasePack, SystemCasePack, ToolCasePack, DiagnosisResult,
)
from core.evolution.diagnose import (
aggregate_d2, aggregate_d3, aggregate_d4, aggregate_d5,
merge_system_packs, merge_tool_packs, run_diagnosis,
)
class TestAggregation:
def test_d2_empty(self):
assert aggregate_d2([]) == {}
def test_d5_empty_returns_zero_structure(self):
result = aggregate_d5([])
assert "early_submit_rate" in result
assert result["early_submit_rate"] == 0.0
class TestMerge:
def test_merge_system_packs_none_on_empty(self):
assert merge_system_packs([]) is None
def test_merge_system_packs_wraps_stats(self):
pack = SystemCasePack(
stats={"a": 1}, failure_cases=[], success_cases=[],
)
merged = merge_system_packs([pack, pack])
assert "per_step" in merged.stats
assert len(merged.stats["per_step"]) == 2
class TestRunDiagnosis:
def test_empty_predictions_returns_empty_result(self):
import asyncio
from core.evolution.types import DiagnosePrompts
mock_log = AsyncMock()
mock_log.get_predictions.return_value = []
mock_log.get_traces.return_value = []
mock_llm = AsyncMock()
mock_store = MagicMock()
mock_store.list_skill_files.return_value = []
prompts = DiagnosePrompts(
defect_vs_lapse="", reasoning_sub="",
span_eval_system="", span_eval_user="",
missed_nodes="", skill_adherence="",
confirmation_bias="", evidence_sufficiency="",
)
result = asyncio.run(run_diagnosis(
"run1", [], {}, mock_llm, mock_log, mock_store, prompts,
concurrency=1,
))
assert isinstance(result, DiagnosisResult)
- Step 2: Run test — expect FAIL
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_diagnose.py::TestAggregation tests/unit/test_diagnose.py::TestMerge tests/unit/test_diagnose.py::TestRunDiagnosis -v
- Step 3: Implement aggregation + case packs + run_diagnosis
从 TRM4 diagnose.py 迁移:
_mean,_percentile辅助函数aggregate_d2/d3/d4/d5_build_skill_case_packs(含 severity 函数、C3 lapse routing、single-failure fallback;成功案例n_success=max(2, len(failures)//2),acc≤0.3 按 budget 升序否则按 adherence 降序)_build_system_case_pack(_MIN_PATTERN_COUNT=3,3 种行为模式;成功案例要求 correct+calibrated+no_bias+0.3≤budget≤0.8,按 abs(budget-0.5) 排序)_build_tool_case_packs(_TOOL_TARGET_FILES映射;低 completeness 先选最多 4 条,高 hallucination 补到总数 4 上限;成功 span 要求 completeness≥0.9 且 hallucination==0.0)merge_system_packs/merge_tool_packs(stats 用{"per_step": [...]}包裹)_classify_reasoning_failure(串行 pass,promptdiagnose_reasoning_failure.md,JSON keytype,解析失败 →reasoning_failure_type=None不中断)run_diagnosis入口(async,Semaphore 限并发,reasoning_failure 串行 pass)
注:resolve_skill_file 定义在 evolve.py(Task 7),diagnose.py 从 evolve 导入。
关键变更:
ThreadPoolExecutor→asyncio.gather+Semaphore(concurrency)HarnessLog→RunLogProtocol(get_predictions/get_traces)- 不写 DB(
_ensure_diagnosis_tables/_clear_existing/_insert_*全部移除) - 不写 JSON 文件(
write analyses/...移除) - 树数据从参数传入(非
_load_tree_cache文件读) - skill 内容从
SkillStore读 - INFRA 统计按 task/video/question 过滤范围重算,不受 stop_reason 过滤影响
保真校验:
-
_INFRA_STOP_REASONS = frozenset({"error", "parse_error"}) -
案例包选择规则(见上述各函数描述)
-
single-failure fallback:1 个 defect → lapse_note(fallback 文本
"复核该类已有规则,避免重复此类单例失败") -
lapse_note 空白过滤(strip 后空则丢弃)
-
_format_trace_text(diagnose 版不截断,与 metrics 版不同!) -
D3
avg_stepskey 实际存 budget_usage mean(TRM4 命名不一致,保留) -
_make_case_samplemetrics 子字典固定 key:correct/error_type/budget_usage/confidence_calibration/repeat_visit_rate/tool_usage/missed_nodes/adherence_rate/confirmation_bias/evidence_sufficient -
Step 4: Run test — expect PASS
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_diagnose.py -v
- Step 5: Commit
git add core/evolution/diagnose.py tests/unit/test_diagnose.py
git commit -m "feat(evolution): diagnose.py aggregation + case packs + run_diagnosis (#8)"
Task 7: evolve.py — 验证 + 辅助函数
Files:
-
Create:
core/evolution/evolve.py(本 Task 写验证 + 辅助部分,约 400 行) -
Test:
tests/unit/test_evolve.py -
Source: TRM4
evolve.py的validate_*/rank_and_clip/edit_budget_at/_resolve_skill_file等 -
Step 1: Write evolve validation + helpers tests
"""tests/unit/test_evolve.py"""
import pytest
from core.evolution.evolve import (
validate_skill, validate_system, validate_tool,
edit_budget_at, resolve_skill_file,
)
class TestValidateSkill:
def test_identical_passes(self):
content = "---\nname: test\ndescription: d\ntask_type: t\n---\nbody"
result = validate_skill(content, content)
assert result.passed
def test_changed_frontmatter_fails(self):
orig = "---\nname: a\ndescription: d\ntask_type: t\n---\nbody"
evol = "---\nname: b\ndescription: d\ntask_type: t\n---\nbody"
result = validate_skill(orig, evol)
assert not result.passed
def test_length_ratio_too_short_fails(self):
orig = "---\nname: a\ndescription: d\ntask_type: t\n---\n" + "x" * 1000
evol = "---\nname: a\ndescription: d\ntask_type: t\n---\nshort"
result = validate_skill(orig, evol)
assert not result.passed
class TestValidateSystem:
def test_identical_passes(self):
content = "intro\n## 能力边界\nfrozen\n## 输出格式\nfrozen2\n## other\nbody"
result = validate_system(content, content)
assert result.passed
def test_changed_frozen_section_fails(self):
orig = "intro\n## 能力边界\noriginal\n## other\nbody"
evol = "intro\n## 能力边界\nchanged\n## other\nbody"
result = validate_system(orig, evol)
assert not result.passed
class TestValidateTool:
def test_identical_passes(self):
extract = "## 输出格式\nfixed\n## other\nbody"
verify = "## 输出格式\nfixed2\n## other\nbody2"
result = validate_tool(extract, extract, verify, verify)
assert result.passed
def test_no_code_block_check(self):
extract = "## 输出格式\nfixed\n```\nunclosed"
result = validate_tool(extract, extract, "v", "v")
assert result.passed # tool 不检查代码块闭合
class TestEditBudget:
def test_start_at_zero(self):
assert edit_budget_at(0, 100, 5, 2) == 5
def test_end_at_total(self):
assert edit_budget_at(100, 100, 5, 2) == 2
def test_total_steps_one(self):
assert edit_budget_at(0, 1, 5, 2) == 5
def test_start_less_than_end_asserts(self):
with pytest.raises(AssertionError):
edit_budget_at(0, 100, 2, 5)
class TestResolveSkillFile:
def test_direct_match(self):
class FakeStore:
def list_skill_files(self): return ["action-reasoning.md", "default-strategy.md"]
def read_skill(self, f): return ""
assert resolve_skill_file(FakeStore(), "Action Reasoning") == "action-reasoning.md"
def test_fallback_to_default(self):
class FakeStore:
def list_skill_files(self): return ["default-strategy.md"]
def read_skill(self, f): return ""
assert resolve_skill_file(FakeStore(), "Unknown Type") == "default-strategy.md"
- Step 2: Run test — expect FAIL
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_evolve.py -v
- Step 3: Implement evolve.py validation + helpers
从 TRM4 evolve.py 迁移:
_parse_frontmatter、_strip_appendix_region(宽容版)、_strip_momentum_region(严格版)、_strip_protected_regions_check_length(去 appendix+momentum 后比较,ratio [0.3, 2.0])_check_code_blocks_extract_section_skill_protected_spans/_system_protected_spans/_tool_protected_spansvalidate_skill/validate_system/validate_tool→ 返回ValidationResult(定义在此文件内部,非 types.py)edit_budget_at(纯数学,保持 TRM4 断言和 banker's rounding)rank_and_clip(async 化,type(idx) is int排除 bool)_select_top_edits_parse_llm_json(两级:fenced → json.loads,失败返回 None)resolve_skill_file(接受SkillStore而非Path)_format_case_samples(tool_output[:500] 截断)_format_spans(tool_output[:500] 截断)_format_rejected_edits
关键变更:
LLMClient→LLMProvider_resolve_skill_file(skills_dir: Path, ...)→resolve_skill_file(skill_store: SkillStore, ...)rank_and_clip变 async
保真校验:
-
冻结区配置:Skill(frontmatter+appendix+momentum)、System(3 sections+appendix)、Tool(输出格式+appendix)
-
frontmatter 三字段:name/description/task_type
-
_parse_frontmatterregex 必须从文件开头匹配^---\n...\n---,yaml.safe_load失败返回 None -
_strip_appendix_region宽容 vs_strip_momentum_region严格(不对称保留) -
_parse_llm_json:只匹配```jsonfenced block(非 ``` 不带 json)→json.loads,失败返回 None(与 metrics 的三级不同!) -
validate_tool 不检查代码块闭合(与 skill/system 不同)
-
type(idx) is int(非 isinstance) -
rank_and_clip/_request_rank_indices:rank LLM ValueError 降级,API 异常不捕获 -
rewrite_from_suggestions:promptevolve_rewrite.md,JSON keyrewritten,重写不得长于原文,只捕 ValueError/KeyError/TypeError/AttributeError -
_format_case_samplestool_output[:500] 截断 -
_format_rejected_editsgate 证据格式W=... L=... E={:.2f} δ̂={:+.3f} -
Step 4: Run test — expect PASS
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_evolve.py -v
- Step 5: Commit
git add core/evolution/evolve.py tests/unit/test_evolve.py
git commit -m "feat(evolution): evolve.py validation + helpers"
Task 8: evolve.py — per-target 进化
Files:
-
Modify:
core/evolution/evolve.py(追加 ~500 行) -
Modify:
tests/unit/test_evolve.py(追加进化测试) -
Step 1: Write evolution loop tests
# 追加到 tests/unit/test_evolve.py
import asyncio
from unittest.mock import AsyncMock, MagicMock
from core.evolution.types import (
SkillCasePack, SystemCasePack, ToolCasePack,
EvolutionRecord, EvolvePrompts,
)
from core.evolution.evolve import (
evolve_single_skill, evolve_system_prompt, evolve_single_tool,
consolidate_appendix,
)
_PROMPTS = EvolvePrompts(
evolve_skill="sk", evolve_system="sys", evolve_tool="tool",
evolve_rank="rank", consolidate_system="cons",
)
def _make_fake_llm(response_content: str):
"""构造返回固定内容的假 LLMProvider。"""
from core.types import LLMResponse
mock = AsyncMock()
mock.chat.return_value = LLMResponse(
content=response_content, thinking="", model="test",
provider="test", prompt_tokens=0, completion_tokens=0,
latency_ms=0, ttft_ms=None, max_inter_token_ms=None,
cache_hit=False, call_id="test-id",
)
return mock
class TestEvolveSingleSkill:
def test_empty_pack_skipped(self):
pack = SkillCasePack(
task_type="test", target_file="test.md",
stats={}, failure_cases=[], success_cases=[], lapse_notes=[],
)
store = MagicMock()
store.read_skill.return_value = "---\nname: t\ndescription: d\ntask_type: t\n---\nbody"
store.list_skill_files.return_value = ["test.md"]
llm = _make_fake_llm('{"suggestions":[],"edits":[]}')
record = asyncio.run(evolve_single_skill(
llm, pack, store, _PROMPTS, "v1", 5, 6,
))
assert record.status in ("rejected", "skipped")
class TestEvolveSystemPrompt:
def test_no_failures_returns_skipped(self):
pack = SystemCasePack(stats={}, failure_cases=[], success_cases=[])
store = MagicMock()
store.read_prompt.return_value = "## 能力边界\nfixed\n## 输出格式\nfixed\n## 视频树结构\nfixed\nbody"
llm = _make_fake_llm('{"suggestions":[],"edits":[]}')
record = asyncio.run(evolve_system_prompt(
llm, pack, store, _PROMPTS, "v1", 5,
))
assert record.status in ("rejected", "skipped")
class TestEvolveSingleTool:
def test_evolved_content_is_json(self):
pack = ToolCasePack(
tool_name="view_node",
target_files=["view_node_extract.md", "view_node_verify.md"],
stats={}, failure_spans=[], success_spans=[],
)
store = MagicMock()
store.read_prompt.return_value = "## 输出格式\nfixed\nbody"
llm = _make_fake_llm('{"suggestions":[],"edits":[]}')
record = asyncio.run(evolve_single_tool(
llm, pack, store, _PROMPTS, "v1", 5,
))
import json
parsed = json.loads(record.evolved_content)
assert "extract" in parsed and "verify" in parsed
class TestConsolidateAppendix:
def test_single_note_passthrough(self):
llm = _make_fake_llm("")
result = asyncio.run(consolidate_appendix(llm, ["note1"]))
assert result == ["note1"]
def test_exception_returns_original(self):
llm = AsyncMock()
llm.chat.side_effect = RuntimeError("boom")
result = asyncio.run(consolidate_appendix(llm, ["a", "b", "c"]))
assert result == ["a", "b", "c"]
- Step 2: Run test — expect FAIL
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_evolve.py::TestEvolveSingleSkill tests/unit/test_evolve.py::TestEvolveSystemPrompt tests/unit/test_evolve.py::TestEvolveSingleTool tests/unit/test_evolve.py::TestConsolidateAppendix -v
- Step 3: Implement per-target evolution
从 TRM4 evolve.py 迁移:
_run_patch_evolution_loop(async 化,range(2)两轮,三种失败反馈)_build_lapse_only_attempt(合成applied_appendreport)evolve_single_skill(三分支:lapse-only/rewrite/patch + appendix 追加 + consolidation)evolve_system_prompt(无 lapse、无 rewrite、无 appendix)evolve_single_tool(extract+verify 合池_src标记、shared budget、JSON evolved_content)consolidate_appendix(async 化,四守卫)rewrite_from_suggestions(async 化,3 个拒绝条件)_append_lapse_with_consolidation(>= threshold触发、G4>=拒绝等长)
关键变更:
- 全部
client.chat()→await llm.chat(messages) response.choices[0].message.content→response.contentskills_dir / target_file→skill_store.read_skill(target_file)prompts_dir / filename→prompt_store.read_prompt(filename)- 版本写入(
advance_version/copytree)全部移除——返回EvolutionRecord run_evolution编排移除——per-target 函数是最高粒度
保真校验:
-
Skill 三分支精确条件和行为
-
_build_lapse_only_attempt合成applied_append状态 -
rank_and_clip 三级降级
-
Tool
_src标记合池/拆回 -
consolidate 四守卫(G4 在调用方)
-
rewrite 长度限制(重写不得长于原文)、异常类型(仅捕 ValueError/KeyError/TypeError/AttributeError)
-
两轮重试反馈文本
-
Step 4: Run test — expect PASS
Run: conda activate Video-Tree-TRM & pytest tests/unit/test_evolve.py -v
- Step 5: Commit
git add core/evolution/evolve.py tests/unit/test_evolve.py
git commit -m "feat(evolution): evolve.py per-target evolution — skill/system/tool (#9)"
Task 9: __init__.py + 集成 + lint
Files:
-
Modify:
core/evolution/__init__.py -
Run: lint + dependency check
-
Step 1: Write __init__.py public API
"""core/evolution/ — 自进化循环决策内核。"""
from core.evolution.gate import compute_e_value, gate_decision, probation_verdict
from core.evolution.patch import (
apply_patch_with_report, append_to_appendix,
extract_appendix_notes, replace_appendix_notes,
replace_momentum, momentum_inner,
)
from core.evolution.validate import pair_block, classify_quadrants, compute_accuracy
from core.evolution.diagnose import run_diagnosis
from core.evolution.evolve import (
evolve_single_skill, evolve_system_prompt, evolve_single_tool,
edit_budget_at, resolve_skill_file,
)
__all__ = [
"compute_e_value", "gate_decision", "probation_verdict",
"apply_patch_with_report", "append_to_appendix",
"extract_appendix_notes", "replace_appendix_notes",
"replace_momentum", "momentum_inner",
"pair_block", "classify_quadrants", "compute_accuracy",
"run_diagnosis",
"evolve_single_skill", "evolve_system_prompt", "evolve_single_tool",
"edit_budget_at", "resolve_skill_file",
]
- Step 2: Run lint
conda activate Video-Tree-TRM & ruff check core/evolution/ --fix
conda activate Video-Tree-TRM & ruff format core/evolution/
- Step 3: Dependency direction check
# core/evolution/ 不得 import app/ 或 adapters/
grep -rn "from app\." core/evolution/ && echo "VIOLATION" || echo "OK"
grep -rn "from adapters\." core/evolution/ && echo "VIOLATION" || echo "OK"
grep -rn "import app\." core/evolution/ && echo "VIOLATION" || echo "OK"
grep -rn "import adapters\." core/evolution/ && echo "VIOLATION" || echo "OK"
Expected: 全部 OK
- Step 4: Update ARCHITECTURE.md §3.1
将 SkillStore/PromptStore/RunLog 的 Protocol 定义从"含写方法"修订为"core/ 只读,写方法在 app/ 实现类"。同步设计文档 §3 的修订说明。
- Step 5: Run full test suite
conda activate Video-Tree-TRM & pytest tests/unit/test_gate.py tests/unit/test_patch.py tests/unit/test_validate.py tests/unit/test_diagnose.py tests/unit/test_evolve.py tests/unit/test_evolution_types.py -v --tb=short
- Step 6: Commit
git add core/evolution/__init__.py research-wiki/ARCHITECTURE.md
git commit -m "feat(evolution): __init__.py public API + ARCHITECTURE.md Protocol update"
Algorithm Fidelity Check
本计划涉及 4 项核心算法迁移:
| # | 算法 | 计划 Task | 保真措施 |
|---|---|---|---|
| 5 | CE-Gate e-process | Task 2 | 原样迁移 163 行,零有意变更;测试覆盖边界(W=L=0、负值、重 win/loss) |
| 7 | 块顺序验证 | Task 4 | 纯决策函数提取;编排留 app/harness/(Design B 约定) |
| 8 | 诊断瀑布 | Task 5-6 | 归因瀑布顺序、defect/lapse 分类、案例包选择规则逐条保真 |
| 9 | 进化 patch 引擎 | Task 3, 7-8 | patch.py 原样迁移;evolve.py 三分支/rank_clip/consolidate 四守卫逐条保真 |
不涉及的算法:#1-4(建树/检索器)、#6(信息阶梯,app/harness/)、#10(mini-batch,app/harness/)、#11(Agent Loop,已迁移)、#12(树环境语义搜索,已迁移)、#13(训练循环编排,app/harness/)。