feat(evolution): diagnose.py aggregation + case packs + run_diagnosis (#8)

This commit is contained in:
2026-07-07 10:04:48 -04:00
parent 49d6fe8f51
commit dc091361c3
2 changed files with 1554 additions and 3 deletions
File diff suppressed because it is too large Load Diff
+266
View File
@@ -12,11 +12,17 @@ from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from core.evolution.diagnose import (
_percentile,
_trigrams,
aggregate_d2,
aggregate_d3,
aggregate_d4,
aggregate_d5,
aggregate_soft,
attribute_error,
calc_budget_usage,
@@ -28,11 +34,20 @@ from core.evolution.diagnose import (
calc_tool_usage,
extract_json_from_response,
extract_rule_metrics,
merge_system_packs,
merge_tool_packs,
question_soft_score,
run_diagnosis,
)
from core.evolution.types import (
CaseSample,
DiagnosePrompts,
DiagnosisResult,
QuestionMetrics,
SkillStepAdherence,
SpanMetrics,
SystemCasePack,
ToolCasePack,
)
# =========================================================================
@@ -506,3 +521,254 @@ class TestAttributeError:
qm = _make_qm(evidence_sufficient=True)
result = attribute_error(qm)
assert result.reasoning_failure_type is None
# =========================================================================
# E. D2-D5 聚合测试
# =========================================================================
class TestPercentile:
"""_percentile 辅助函数测试。"""
def test_empty_returns_zero(self) -> None:
"""空列表返回 0.0。"""
assert _percentile([], 0.5) == 0.0
def test_single_element(self) -> None:
"""单元素返回该元素。"""
assert _percentile([42.0], 0.5) == 42.0
def test_median_two_elements(self) -> None:
"""两元素中位数。"""
assert _percentile([1.0, 3.0], 0.5) == 2.0
def test_quartiles(self) -> None:
"""四分位线性插值。"""
values = [1.0, 2.0, 3.0, 4.0, 5.0]
assert _percentile(values, 0.0) == 1.0
assert _percentile(values, 1.0) == 5.0
p25 = _percentile(values, 0.25)
assert abs(p25 - 2.0) < 1e-9
class TestAggregation:
"""D2-D5 聚合函数测试。"""
def test_d2_empty(self) -> None:
"""空输入返回空字典。"""
assert aggregate_d2([]) == {}
def test_d2_groups_by_tool(self) -> None:
"""按工具名分组聚合。"""
qm = _make_qm(
span_metrics=[
_make_span(
step=0,
tool_name="view_node",
extraction_completeness=0.8,
hallucination_rate=0.2,
),
_make_span(
step=1,
tool_name="view_node",
extraction_completeness=0.6,
hallucination_rate=0.1,
),
_make_span(
step=2,
tool_name="search_similar",
extraction_completeness=0.9,
hallucination_rate=0.0,
),
]
)
result = aggregate_d2([qm])
assert "view_node" in result
assert "search_similar" in result
assert result["view_node"]["n_calls"] == 2
assert result["search_similar"]["n_calls"] == 1
assert abs(result["view_node"]["avg_completeness"] - 0.7) < 1e-9
def test_d3_empty(self) -> None:
"""空输入返回空字典。"""
assert aggregate_d3([]) == {}
def test_d3_correct_vs_incorrect(self) -> None:
"""按正误拆分。"""
qm_correct = _make_qm(correct=True, task_type="T1", budget_usage=0.5)
qm_wrong = _make_qm(correct=False, task_type="T1", budget_usage=0.8)
result = aggregate_d3([qm_correct, qm_wrong])
assert "T1" in result
assert result["T1"]["correct"]["n_questions"] == 1
assert result["T1"]["incorrect"]["n_questions"] == 1
# avg_steps 存储 budget_usage 均值
assert result["T1"]["correct"]["avg_steps"] == 0.5
assert result["T1"]["incorrect"]["avg_steps"] == 0.8
def test_d4_empty(self) -> None:
"""空输入返回空字典。"""
assert aggregate_d4([]) == {}
def test_d4_adherence_rate(self) -> None:
"""技能遵循率计算。"""
qm = _make_qm(
task_type="T1",
correct=True,
skill_adherence=[
SkillStepAdherence(step_label="S1", adhered=True, description=""),
SkillStepAdherence(step_label="S1", adhered=False, description=""),
],
)
result = aggregate_d4([qm])
assert "T1" in result
assert result["T1"]["overall_adherence"] == 0.5
def test_d5_empty_returns_zero_structure(self) -> None:
"""空输入返回完整零结构。"""
result = aggregate_d5([])
assert "early_submit_rate" in result
assert result["early_submit_rate"] == 0.0
assert "format_compliance_rate" in result
assert "budget_usage_median" in result
assert "confirmation_bias_rate" in result
assert "per_type_bias" in result
assert result["per_type_bias"] == {}
def test_d5_with_data(self) -> None:
"""有数据时正确计算。"""
qm1 = _make_qm(
correct=True,
budget_usage=0.5,
format_compliance=1.0,
confidence_calibration="calibrated",
confirmation_bias=False,
)
qm2 = _make_qm(
correct=False,
budget_usage=0.2,
format_compliance=0.8,
confidence_calibration="high_conf_wrong",
confirmation_bias=True,
)
result = aggregate_d5([qm1, qm2])
assert result["format_compliance_rate"] == 0.9
assert result["high_conf_wrong_rate"] == 0.5
assert result["early_submit_rate"] == 1.0 # 1 wrong with budget<0.3
# =========================================================================
# F. Merge 函数测试
# =========================================================================
class TestMerge:
"""merge_system_packs / merge_tool_packs 测试。"""
def test_merge_system_packs_none_on_empty(self) -> None:
"""空列表返回 None。"""
assert merge_system_packs([]) is None
def test_merge_system_packs_wraps_stats(self) -> None:
"""stats 包裹为 per_step 列表。"""
pack = SystemCasePack(stats={"a": 1}, failure_cases=[], success_cases=[])
merged = merge_system_packs([pack, pack])
assert merged is not None
assert "per_step" in merged.stats
assert len(merged.stats["per_step"]) == 2
def test_merge_system_packs_concats_cases(self) -> None:
"""failure/success cases 拼接。"""
case = CaseSample(
question_id="q1",
video_id="v1",
task_type="T1",
question="q",
options=[],
answer="a",
prediction="b",
correct=False,
error_type="mixed",
selection_reason="test",
metrics={},
trace=[],
)
p1 = SystemCasePack(stats={}, failure_cases=[case], success_cases=[])
p2 = SystemCasePack(stats={}, failure_cases=[case], success_cases=[case])
merged = merge_system_packs([p1, p2])
assert merged is not None
assert len(merged.failure_cases) == 2
assert len(merged.success_cases) == 1
def test_merge_tool_packs_empty(self) -> None:
"""空列表返回空字典。"""
assert merge_tool_packs([]) == {}
def test_merge_tool_packs_groups_by_name(self) -> None:
"""同名工具合并。"""
p1 = ToolCasePack(
tool_name="view_node",
target_files=["f1.md"],
stats={"x": 1},
failure_spans=[{"a": 1}],
success_spans=[],
)
p2 = ToolCasePack(
tool_name="view_node",
target_files=["f1.md"],
stats={"x": 2},
failure_spans=[{"b": 2}],
success_spans=[{"c": 3}],
)
merged = merge_tool_packs([p1, p2])
assert "view_node" in merged
vn = merged["view_node"]
assert len(vn.failure_spans) == 2
assert len(vn.success_spans) == 1
assert "per_step" in vn.stats
# =========================================================================
# G. run_diagnosis 入口测试
# =========================================================================
class TestRunDiagnosis:
"""run_diagnosis 入口测试。"""
def test_empty_predictions_returns_empty_result(self) -> None:
"""无预测时返回空 DiagnosisResult。"""
import asyncio
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)
assert result.run_id == "run1"
assert result.error_attributions == []
assert result.degraded_count == 0