Files
Video-Tree-TRM5/core/evolution/evolve.py
T

725 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""进化引擎辅助函数与验证逻辑。
验证(validate_skill / validate_system / validate_tool)、受保护区构建、
编辑预算退火、rank-and-clip 裁剪、格式化工具等纯/准纯函数。
Task 8 将在此基础上添加进化入口(evolve_skill / evolve_system / evolve_tool)。
不依赖 app/ 或 adapters/LLMProvider 通过 core.protocols 注入)。
"""
from __future__ import annotations
import json
import re
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any
from loguru import logger
from core.evolution.patch import (
APPENDIX_END,
APPENDIX_START,
momentum_region_bounds,
)
if TYPE_CHECKING:
from core.evolution.protocols import SkillStore
from core.evolution.types import RejectedEdit
from core.protocols import LLMProvider
# =========================================================================
# 0. 局部类型
# =========================================================================
@dataclass
class ValidationResult:
"""格式验证的结果。
属性:
passed: 验证是否通过。
errors: 失败原因列表;passed=True 时为空。
"""
passed: bool
errors: list[str] = field(default_factory=list)
# =========================================================================
# A. 内部辅助函数
# =========================================================================
def _parse_frontmatter(text: str) -> dict[str, str] | None:
"""解析 YAML frontmatter,失败返回 None。
参数:
text: Markdown 文件全文。
返回:
frontmatter 字典,无有效 frontmatter 时返回 None。
"""
import yaml as _yaml
match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
if not match:
return None
try:
return _yaml.safe_load(match.group(1))
except _yaml.YAMLError:
return None
def _strip_appendix_region(text: str) -> str:
"""剥离 appendix 受保护区(含 marker),返回其余正文。
宽松语义:APPENDIX_START / APPENDIX_END 任一缺失即当作「无区」原样返回,不报错。
"""
if APPENDIX_START in text and APPENDIX_END in text:
head, rest = text.split(APPENDIX_START, 1)
_, tail = rest.split(APPENDIX_END, 1)
return head.rstrip() + tail
return text
def _strip_momentum_region(text: str) -> str:
"""剥离 momentum 受保护区(含 marker),返回其余正文。
严格语义:委托 momentum_region_bounds 做配对检测,
marker 损坏/不配对时 raise ValueError。
异常:
ValueError: momentum marker 损坏/不配对。
"""
bounds = momentum_region_bounds(text)
if bounds is None:
return text
start, end = bounds
return text[:start].rstrip() + text[end:]
def _strip_protected_regions(text: str) -> str:
"""剥离 appendix + momentum 两个受保护区,返回正文部分。
先 appendix(宽松),再 momentum(严格)——顺序有关:
appendix 宽松剥离不会误杀 momentum marker。
异常:
ValueError: momentum marker 损坏/不配对。
"""
text = _strip_appendix_region(text)
text = _strip_momentum_region(text)
return text
def _check_length(original: str, evolved: str) -> list[str]:
"""检查改写后长度是否在 [0.3x, 2.0x] 范围内。
仅比正文,剔除 appendix + momentum 两区。orig_len==0 时跳过。
参数:
original: 改写前全文。
evolved: 改写后全文。
返回:
错误消息列表(空列表表示通过)。
"""
errors: list[str] = []
orig_body = _strip_protected_regions(original)
evol_body = _strip_protected_regions(evolved)
orig_len = len(orig_body)
if orig_len == 0:
return errors
ratio = len(evol_body) / orig_len
evol_len = len(evol_body)
if ratio > 2.0:
errors.append(
f"长度超限: {evol_len} 字符是原文 {orig_len}{ratio:.1f} 倍 (上限 2.0)"
)
if ratio < 0.3:
errors.append(
f"长度不足: {evol_len} 字符是原文 {orig_len}{ratio:.1f} 倍 (下限 0.3)"
)
return errors
def _check_code_blocks(text: str) -> list[str]:
"""检查代码块是否闭合。
参数:
text: 待检查的文本。
返回:
错误消息列表(空列表表示通过)。
"""
count = text.count("```")
if count % 2 != 0:
return [f"Markdown 格式错误: 代码块未闭合 (``` 出现 {count} 次)"]
return []
def _extract_section(text: str, heading: str) -> str | None:
"""提取 ## heading 到下一个 ## 之间的文本。
参数:
text: Markdown 全文。
heading: 二级标题名。
返回:
该 section 的完整文本(含标题行),未找到时返回 None。
"""
pattern = rf"(## {re.escape(heading)}.*?)(?=\n## |\Z)"
match = re.search(pattern, text, re.DOTALL)
return match.group(1).strip() if match else None
# =========================================================================
# B. 受保护区构建
# =========================================================================
def _appendix_span(content: str) -> str:
"""返回 appendix 受保护区整段(含 marker);不存在返回空串。
参数:
content: 文本全文。
返回:
appendix 区的完整文本(含 marker),或空串。
"""
if APPENDIX_START in content and APPENDIX_END in content:
start = content.index(APPENDIX_START)
end = content.index(APPENDIX_END) + len(APPENDIX_END)
return content[start:end]
return ""
def _momentum_span(content: str) -> str:
"""返回 momentum 受保护区整段(含 marker);不存在返回空串。
委托 momentum_region_bounds 做配对检测:
marker 损坏/不配对时由其 raise ValueError。
参数:
content: 文本全文。
返回:
momentum 区的完整文本(含 marker),或空串。
异常:
ValueError: momentum marker 损坏/不配对。
"""
bounds = momentum_region_bounds(content)
if bounds is None:
return ""
start, end = bounds
return content[start:end]
def _skill_protected_spans(text: str) -> list[str]:
"""Skill 冻结块:frontmatter + appendix 区 + momentum 区(各项可选)。
参数:
text: Skill 文件全文。
返回:
冻结文本块列表。
"""
spans: list[str] = []
match = re.match(r"^---\n.*?\n---", text, re.DOTALL)
if match:
spans.append(match.group(0))
appendix = _appendix_span(text)
if appendix:
spans.append(appendix)
momentum = _momentum_span(text)
if momentum:
spans.append(momentum)
return spans
def _system_protected_spans(text: str) -> list[str]:
"""System Prompt 冻结块:能力边界 / 输出格式 / 视频树结构 三段 + appendix 区。
参数:
text: system.md 全文。
返回:
冻结文本块列表。
"""
spans: list[str] = [
section
for section in (
_extract_section(text, name)
for name in ("能力边界", "输出格式", "视频树结构")
)
if section
]
appendix = _appendix_span(text)
if appendix:
spans.append(appendix)
return spans
def _tool_protected_spans(text: str) -> list[str]:
"""Tool Prompt 冻结块:输出格式段 + appendix 区。
参数:
text: Tool Prompt 全文。
返回:
冻结文本块列表。
"""
spans: list[str] = []
section = _extract_section(text, "输出格式")
if section:
spans.append(section)
appendix = _appendix_span(text)
if appendix:
spans.append(appendix)
return spans
# =========================================================================
# C. 验证函数
# =========================================================================
def validate_skill(original: str, evolved: str) -> ValidationResult:
"""校验 Skill 改写结果。
检查项: frontmatter 三字段保留(name / description / task_type)、
长度比在 [0.3, 2.0]、代码块闭合。
参数:
original: 改写前的 Skill 文件全文。
evolved: 改写后的 Skill 文件全文。
返回:
ValidationResult 实例。
"""
errors: list[str] = []
orig_fm = _parse_frontmatter(original)
evol_fm = _parse_frontmatter(evolved)
if orig_fm is None:
errors.append("原文缺少有效 frontmatter")
elif evol_fm is None:
errors.append("改写后缺少有效 frontmatter")
else:
for key in ("name", "description", "task_type"):
if orig_fm.get(key) != evol_fm.get(key):
errors.append(
f"frontmatter 字段 {key} 被修改: "
f"{orig_fm.get(key)!r}{evol_fm.get(key)!r}"
)
errors.extend(_check_length(original, evolved))
errors.extend(_check_code_blocks(evolved))
return ValidationResult(passed=len(errors) == 0, errors=errors)
def validate_system(original: str, evolved: str) -> ValidationResult:
"""校验 System Prompt 改写结果。
检查项: 三个冻结区值比较(能力边界 / 输出格式 / 视频树结构)、
长度比在 [0.3, 2.0]、代码块闭合。
参数:
original: 改写前的 system.md 全文。
evolved: 改写后的 system.md 全文。
返回:
ValidationResult 实例。
"""
errors: list[str] = []
frozen_sections = ["能力边界", "输出格式", "视频树结构"]
for section_name in frozen_sections:
orig_section = _extract_section(original, section_name)
if orig_section is None:
continue
evol_section = _extract_section(evolved, section_name)
if evol_section is None:
errors.append(f"冻结区 '## {section_name}' 在改写后缺失")
elif orig_section != evol_section:
errors.append(f"冻结区 '## {section_name}' 在改写后被修改")
errors.extend(_check_length(original, evolved))
errors.extend(_check_code_blocks(evolved))
return ValidationResult(passed=len(errors) == 0, errors=errors)
def validate_tool(
orig_extract: str,
evol_extract: str,
orig_verify: str,
evol_verify: str,
) -> ValidationResult:
"""校验 Tool Prompt 改写结果。
检查项: 输出格式 section 保留(per file)、长度比在 [0.3, 2.0]。
与 skill / system 不同,**不检查代码块闭合**。
参数:
orig_extract: 改写前的 extract prompt。
evol_extract: 改写后的 extract prompt。
orig_verify: 改写前的 verify prompt。
evol_verify: 改写后的 verify prompt。
返回:
ValidationResult 实例。
"""
errors: list[str] = []
for label, orig, evol in [
("extract", orig_extract, evol_extract),
("verify", orig_verify, evol_verify),
]:
orig_fmt = _extract_section(orig, "输出格式")
if orig_fmt is not None:
evol_fmt = _extract_section(evol, "输出格式")
if evol_fmt is None:
errors.append(f"{label}: 冻结区 '## 输出格式' 在改写后缺失")
elif orig_fmt != evol_fmt:
errors.append(f"{label}: 冻结区 '## 输出格式' 在改写后被修改")
errors.extend(_check_length(orig, evol))
return ValidationResult(passed=len(errors) == 0, errors=errors)
# =========================================================================
# D. 纯数学
# =========================================================================
def edit_budget_at(global_step: int, total_steps: int, start: int, end: int) -> int:
"""按 global_step 线性退火的 per-target 编辑预算。
借鉴 SkillOpt LinearScheduler:在 [0, total_steps] 上把预算从 start
线性退火到 end。total_steps<=1 直接返回 start(避免单步取到最小值)。
round 用 Python banker's roundingmax(end, ...) 兜底硬下限。
参数:
global_step: 当前全局步(0-indexed)。
total_steps: 退火地平线(step 数),即分母。
start: 退火起点(需 >= end)。
end: 退火终点(亦为硬下限)。
返回:
当步 per-target 最大 edit 条数。
异常:
AssertionError: start < end。
"""
assert start >= end, (
f"edit_budget_at 要求 start >= end,实际 start={start}, end={end}"
)
if total_steps <= 1:
return start
t = min(global_step, total_steps) / total_steps
return max(end, round(start + (end - start) * t))
# =========================================================================
# E. JSON 解析(进化版本,不同于 metrics 版)
# =========================================================================
def _parse_llm_json(raw: str) -> dict | None:
"""从 LLM 响应中解析 JSON。
仅两种策略:(1) 提取 ```json 代码块;(2) 直接 json.loads。
不做 outermost braces 推断、不用 json_repair。失败返回 None。
参数:
raw: LLM 原始输出文本。
返回:
解析后的字典;失败或结果非 dict 时返回 None。
"""
text = raw.strip()
# 策略 1:提取 ```json ... ``` 代码块
code_block = re.search(r"```json\s*\n(.*?)```", text, re.DOTALL)
if code_block:
text = code_block.group(1).strip()
# 策略 2:直接解析
try:
result = json.loads(text)
if isinstance(result, dict):
return result
return None
except (json.JSONDecodeError, ValueError):
return None
# =========================================================================
# F. rank_and_clipasync
# =========================================================================
def _select_top_edits(
indices: list[Any],
edits: list[dict[str, Any]],
max_edits: int,
) -> list[dict[str, Any]]:
"""按 rank LLM 给出的优先级索引筛选 edits。
依次保留首个 max_edits 条合法、不重复、在范围内的索引对应 edit。
用 type(idx) is int(非 isinstance)以排除 bool。
参数:
indices: rank LLM 返回的 0-based 优先级索引。
edits: 候选 edit 列表。
max_edits: 最多保留条数。
返回:
按优先级顺序保留的 edit 列表。
"""
selected: list[dict[str, Any]] = []
seen: set[int] = set()
for idx in indices:
if type(idx) is int and 0 <= idx < len(edits) and idx not in seen:
selected.append(edits[idx])
seen.add(idx)
if len(selected) >= max_edits:
break
return selected
async def _request_rank_indices(
llm: LLMProvider,
prompts: str,
original: str,
edits: list[dict[str, Any]],
max_edits: int,
label: str,
) -> list[int]:
"""调 rank LLM 取重要性降序的索引列表。
守 P5:响应无法解析或 selected_indices 非列表时直接 raise ValueError。
参数:
llm: LLM 调用端口。
prompts: evolve_rank 模板内容。
original: 当前 prompt 全文(排序上下文)。
edits: 候选 edit 列表。
max_edits: 本轮预算上限。
label: 目标标签(仅用于报错信息)。
返回:
rank LLM 返回的原始索引列表(尚未去重/越界过滤)。
异常:
ValueError: 响应无 selected_indices,或其值非列表。
"""
edits_desc = "\n".join(
f"[{i}] op={e.get('op')} support_count={e.get('support_count', 0)} "
f"target={str(e.get('target', ''))[:60]!r} "
f"content={str(e.get('content', ''))[:60]!r}"
for i, e in enumerate(edits)
)
user_msg = (
f"## 当前文件\n\n{original}\n\n"
f"## 候选 edits{len(edits)} 条,预算 {max_edits} 条)\n\n{edits_desc}\n\n"
f"请选出最重要的 {max_edits} 条,返回其 0-based 索引(重要性降序)。"
)
response = await llm.chat(
[
{"role": "system", "content": prompts},
{"role": "user", "content": user_msg},
]
)
parsed = _parse_llm_json(response.content)
if not parsed or "selected_indices" not in parsed:
raise ValueError(f"{label} rank LLM 未返回 selected_indices,拒绝静默截断")
indices = parsed["selected_indices"]
if not isinstance(indices, list):
raise ValueError(f"{label} rank LLM selected_indices 非列表")
return indices
async def rank_and_clip(
llm: LLMProvider,
original_content: str,
edits: list[dict[str, Any]],
max_edits: int,
label: str,
*,
rank_prompt: str = "",
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""超预算时调 rank LLM 排序取 top-L;未超则原样返回。
三级降级:LLM rank → _select_top_edits → empty → fallback to edits[:max_edits]。
对 rank LLM 的输出波动一律优雅降级而非中止。
参数:
llm: LLM 调用端口。
original_content: 当前 prompt 全文(排序上下文)。
edits: 候选 edit 列表。
max_edits: 本轮预算上限。
label: 目标标签(skill/system/tool,仅用于日志)。
rank_prompt: evolve_rank 模板内容。
返回:
(裁剪后 edits, {"triggered": bool, "clipped": int})。
"""
if len(edits) <= max_edits:
return edits, {"triggered": False, "clipped": 0}
try:
indices = await _request_rank_indices(
llm, rank_prompt, original_content, edits, max_edits, label
)
except Exception as exc:
logger.warning(
"{} rank LLM 排序不可用({});退化为按原序取前 {} 条",
label,
exc,
max_edits,
)
indices = []
selected = _select_top_edits(indices, edits, max_edits)
if not selected:
selected = edits[:max_edits]
logger.warning("{} rank 有效索引为 0;退化为按原序取前 {} 条", label, max_edits)
elif len(selected) < max_edits:
logger.warning(
"{} rank 仅得 {} 条有效(<预算 {});按更保守的条数应用",
label,
len(selected),
max_edits,
)
logger.info("{} edits 超预算裁剪 {}->{}", label, len(edits), len(selected))
return selected, {"triggered": True, "clipped": len(edits) - len(selected)}
# =========================================================================
# G. resolve_skill_file
# =========================================================================
def resolve_skill_file(skill_store: SkillStore, task_type: str) -> str:
"""按运行时规则解析 task_type 对应的 skill 文件名。
转换规则:小写 + 空格替换为短横线 + .md 后缀。
若 store 中不存在匹配文件,退化到 default-strategy.md。
参数:
skill_store: 版本化技能读取端口。
task_type: 题目任务类型(如 "Action Reasoning")。
返回:
匹配的 skill 文件名。
"""
file_name = f"{task_type.lower().replace(' ', '-')}.md"
available = skill_store.list_skill_files()
if file_name in available:
return file_name
return "default-strategy.md"
# =========================================================================
# H. 格式化辅助
# =========================================================================
def _format_case_samples(cases: list[Any]) -> str:
"""将 CaseSample 列表格式化为 LLM 可读文本。
对 trace 中的 tool_output 截断到 500 字符。
参数:
cases: CaseSample 实例列表(也兼容 dict)。
返回:
格式化后的多行文本。
"""
lines: list[str] = []
for case in cases:
if not isinstance(case, dict):
case = asdict(case)
lines.append(f"### {case.get('question_id', 'unknown')}")
lines.append(f"- question: {case.get('question', '')}")
options = case.get("options", [])
if options:
lines.append(f"- options: {json.dumps(options, ensure_ascii=False)}")
lines.append(f"- answer: {case.get('answer', '')}")
lines.append(f"- prediction: {case.get('prediction', '')}")
lines.append(f"- error_type: {case.get('error_type', '')}")
lines.append(f"- selection_reason: {case.get('selection_reason', '')}")
trace = case.get("trace", [])
if trace:
lines.append("- trace:")
for step in trace:
output_text = str(step.get("tool_output", ""))
if len(output_text) > 500:
output_text = output_text[:500] + "..."
lines.append(
f" - step {step.get('step', '?')}: "
f"tool={step.get('tool_name', '')} "
f"args={json.dumps(step.get('tool_args', {}), ensure_ascii=False)} "
f"output={output_text}"
)
lines.append("")
return "\n".join(lines)
def _format_spans(spans: list[dict[str, Any]]) -> str:
"""将工具 span 字典列表格式化为 LLM 可读文本。
对 tool_output 截断到 500 字符。
参数:
spans: span 字典列表,每个包含 step / tool_name / tool_args 等字段。
返回:
格式化后的多行文本。
"""
lines: list[str] = []
for span in spans:
lines.append(f"### step {span.get('step', '?')}")
lines.append(f"- tool_name: {span.get('tool_name', '')}")
lines.append(
f"- tool_args: {json.dumps(span.get('tool_args', {}), ensure_ascii=False)}"
)
output_text = str(span.get("tool_output", ""))
if len(output_text) > 500:
output_text = output_text[:500] + "..."
lines.append(f"- tool_output: {output_text}")
lines.append(
f"- extraction_completeness: {span.get('extraction_completeness', '')}"
)
lines.append(f"- hallucination_rate: {span.get('hallucination_rate', '')}")
missed = span.get("missed_info_tags", [])
if missed:
lines.append(
f"- missed_info_tags: {json.dumps(missed, ensure_ascii=False)}"
)
hall_tags = span.get("hallucination_tags", [])
if hall_tags:
lines.append(
f"- hallucination_tags: {json.dumps(hall_tags, ensure_ascii=False)}"
)
lines.append("")
return "\n".join(lines)
def _format_rejected_edits(rejected: list[RejectedEdit]) -> str:
"""将已验证无效的改法列表格式化为 LLM 可读文本。
gate 证据格式:W=... L=... E={:.2f} delta_hat={:+.3f}
参数:
rejected: RejectedEdit 实例列表。
返回:
格式化后的多行文本。
"""
lines: list[str] = []
for edit in rejected:
lines.append(f"### {edit.target_file} | delta {edit.delta:+.2f}")
lines.append(f"- 已验证无效的改法: {edit.change_summary}")
if edit.gate_e_value is not None:
lines.append(
f"- 已验证无效: W={edit.gate_w} L={edit.gate_l} "
f"E={edit.gate_e_value:.2f} δ̂={edit.gate_delta_shrunk:+.3f}"
)
lines.append("")
return "\n".join(lines)