Files
Video-Tree-TRM5/tests/unit/test_diagnose.py
T

509 lines
17 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.
"""core/evolution/diagnose.py 单元测试。
覆盖:
- 7 个规则指标(空输入、边界、典型值)
- extract_json_from_response(三策略 + 拒绝非 dict + 垃圾输入)
- attribute_error 瀑布(4 条路径)
- question_soft_score(空→None
- aggregate_soft(跳过 None、全 None→None
"""
from __future__ import annotations
import json
from typing import Any
import pytest
from core.evolution.diagnose import (
_trigrams,
aggregate_soft,
attribute_error,
calc_budget_usage,
calc_confidence_calibration,
calc_format_compliance,
calc_level_jump_pattern,
calc_repeat_visit_rate,
calc_search_keyword_repetition,
calc_tool_usage,
extract_json_from_response,
extract_rule_metrics,
question_soft_score,
)
from core.evolution.types import (
QuestionMetrics,
SpanMetrics,
)
# =========================================================================
# 工厂函数
# =========================================================================
def _make_qm(
question_id: str = "q1",
video_id: str = "v1",
task_type: str = "Action Reasoning",
correct: bool = False,
format_compliance: float = 1.0,
budget_usage: float = 0.5,
confidence_calibration: str = "calibrated",
repeat_visit_rate: float = 0.0,
search_keyword_repetition: float = 0.0,
level_jump_pattern: str = "",
tool_usage: dict[str, int] | None = None,
span_metrics: list[SpanMetrics] | None = None,
missed_nodes: list[str] | None = None,
skill_adherence: list[Any] | None = None,
confirmation_bias: bool | None = None,
evidence_sufficient: bool | None = None,
degraded: bool = False,
) -> QuestionMetrics:
"""构造 QuestionMetrics,非关键字段使用合理默认值。"""
return QuestionMetrics(
question_id=question_id,
video_id=video_id,
task_type=task_type,
correct=correct,
format_compliance=format_compliance,
budget_usage=budget_usage,
confidence_calibration=confidence_calibration,
repeat_visit_rate=repeat_visit_rate,
search_keyword_repetition=search_keyword_repetition,
level_jump_pattern=level_jump_pattern,
tool_usage=tool_usage or {},
span_metrics=span_metrics or [],
missed_nodes=missed_nodes or [],
skill_adherence=skill_adherence or [],
confirmation_bias=confirmation_bias,
evidence_sufficient=evidence_sufficient,
degraded=degraded,
)
def _make_span(
step: int = 0,
tool_name: str = "view_node",
extraction_completeness: float = 0.8,
hallucination_rate: float = 0.1,
) -> SpanMetrics:
"""构造 SpanMetrics 快捷工厂。"""
return SpanMetrics(
step=step,
tool_name=tool_name,
extraction_completeness=extraction_completeness,
hallucination_rate=hallucination_rate,
)
# =========================================================================
# A. 规则指标测试
# =========================================================================
class TestCalcFormatCompliance:
"""calc_format_compliance 测试。"""
def test_empty_returns_one(self) -> None:
"""空列表返回 1.0。"""
assert calc_format_compliance([]) == 1.0
def test_all_compliant(self) -> None:
"""全部合规返回 1.0。"""
raw = json.dumps({"reflect": {}, "plan": {}, "action": {}})
assert calc_format_compliance([raw, raw]) == 1.0
def test_none_compliant(self) -> None:
"""全部不合规返回 0.0。"""
assert calc_format_compliance(["not json", '{"foo": 1}']) == 0.0
def test_partial_compliance(self) -> None:
"""部分合规返回正确比例。"""
good = json.dumps({"reflect": {}, "plan": {}, "action": {}})
bad = json.dumps({"reflect": {}, "plan": {}})
assert calc_format_compliance([good, bad]) == 0.5
class TestCalcBudgetUsage:
"""calc_budget_usage 测试。"""
def test_typical(self) -> None:
"""典型值。"""
assert calc_budget_usage(5, 10) == 0.5
def test_full_budget(self) -> None:
"""用满预算。"""
assert calc_budget_usage(10, 10) == 1.0
def test_zero_steps(self) -> None:
"""未使用步数。"""
assert calc_budget_usage(0, 10) == 0.0
def test_zero_max_steps_raises(self) -> None:
"""max_steps=0 应抛出 ZeroDivisionErrorP5: 不掩盖错误)。"""
with pytest.raises(ZeroDivisionError):
calc_budget_usage(5, 0)
class TestCalcConfidenceCalibration:
"""calc_confidence_calibration 测试。"""
def test_high_conf_wrong(self) -> None:
"""高置信度答错。"""
assert calc_confidence_calibration(0.7, correct=False) == "high_conf_wrong"
assert calc_confidence_calibration(0.9, correct=False) == "high_conf_wrong"
def test_low_conf_right(self) -> None:
"""低置信度答对。"""
assert calc_confidence_calibration(0.3, correct=True) == "low_conf_right"
assert calc_confidence_calibration(0.49, correct=True) == "low_conf_right"
def test_calibrated(self) -> None:
"""正常校准。"""
assert calc_confidence_calibration(0.5, correct=True) == "calibrated"
assert calc_confidence_calibration(0.7, correct=True) == "calibrated"
assert calc_confidence_calibration(0.3, correct=False) == "calibrated"
def test_boundary_high(self) -> None:
"""边界值: 0.7 答错。"""
assert calc_confidence_calibration(0.7, correct=False) == "high_conf_wrong"
def test_boundary_low(self) -> None:
"""边界值: 0.5 答对不算 low_conf_right。"""
assert calc_confidence_calibration(0.5, correct=True) == "calibrated"
class TestCalcRepeatVisitRate:
"""calc_repeat_visit_rate 测试。"""
def test_empty(self) -> None:
"""空列表返回 0.0。"""
assert calc_repeat_visit_rate([]) == 0.0
def test_no_repeats(self) -> None:
"""无重复。"""
assert calc_repeat_visit_rate(["a", "b", "c"]) == 0.0
def test_all_same(self) -> None:
"""全重复。"""
rate = calc_repeat_visit_rate(["a", "a", "a"])
assert abs(rate - (1 - 1 / 3)) < 1e-9
def test_partial_repeats(self) -> None:
"""部分重复。"""
rate = calc_repeat_visit_rate(["a", "b", "a"])
assert abs(rate - (1 - 2 / 3)) < 1e-9
class TestTrigrams:
"""_trigrams 辅助函数测试。"""
def test_short_string(self) -> None:
"""不足 3 字符返回空集。"""
assert _trigrams("ab") == set()
assert _trigrams("") == set()
def test_exact_three(self) -> None:
"""恰好 3 字符。"""
assert _trigrams("abc") == {"abc"}
def test_longer(self) -> None:
"""多字符。"""
assert _trigrams("abcd") == {"abc", "bcd"}
class TestCalcSearchKeywordRepetition:
"""calc_search_keyword_repetition 测试。"""
def test_single_query(self) -> None:
"""单条查询返回 0.0。"""
assert calc_search_keyword_repetition(["hello"]) == 0.0
def test_empty(self) -> None:
"""空列表返回 0.0。"""
assert calc_search_keyword_repetition([]) == 0.0
def test_identical_queries(self) -> None:
"""完全相同的连续查询,Jaccard=1.0。"""
assert calc_search_keyword_repetition(["hello world", "hello world"]) == 1.0
def test_disjoint_queries(self) -> None:
"""完全不同的查询,Jaccard 接近 0。"""
score = calc_search_keyword_repetition(["aaa", "zzz"])
assert score == 0.0
def test_takes_max_across_pairs(self) -> None:
"""取连续对的最大值。"""
score = calc_search_keyword_repetition(["aaa", "zzz", "zzz"])
assert score == 1.0 # 第二对完全相同
class TestCalcLevelJumpPattern:
"""calc_level_jump_pattern 测试。"""
def test_empty(self) -> None:
"""空列表返回空字符串。"""
assert calc_level_jump_pattern([]) == ""
def test_typical(self) -> None:
"""典型节点 ID 序列。"""
result = calc_level_jump_pattern(["vid_L1_001", "vid_L2_003", "vid_L3_005"])
assert result == "L1→L2→L3"
def test_no_match(self) -> None:
"""无匹配的节点 ID 被跳过。"""
assert calc_level_jump_pattern(["no_level_here"]) == ""
def test_mixed(self) -> None:
"""混合匹配和非匹配。"""
result = calc_level_jump_pattern(["vid_L2_001", "bad_id", "vid_L1_003"])
assert result == "L2→L1"
class TestCalcToolUsage:
"""calc_tool_usage 测试。"""
def test_empty(self) -> None:
"""空列表返回空字典。"""
assert calc_tool_usage([]) == {}
def test_counts(self) -> None:
"""正确计数。"""
result = calc_tool_usage(["view_node", "search_similar", "view_node"])
assert result == {"view_node": 2, "search_similar": 1}
class TestExtractRuleMetrics:
"""extract_rule_metrics 测试。"""
def test_basic_extraction(self) -> None:
"""基本规则指标提取。"""
prediction = {
"steps_json": [
{
"tool_call": {
"tool": "view_node",
"args": {"node_id": "vid_L1_001"},
}
},
{
"tool_call": {
"tool": "search_similar",
"args": {"query": "test query"},
}
},
],
"correct": True,
}
result = extract_rule_metrics(prediction, [], max_steps=10)
assert result["budget_usage"] == 0.2
assert result["tool_usage"] == {"view_node": 1, "search_similar": 1}
assert "L1" in result["level_jump_pattern"]
def test_empty_prediction(self) -> None:
"""空预测。"""
result = extract_rule_metrics({}, [], max_steps=10)
assert result["format_compliance"] == 1.0
assert result["budget_usage"] == 0.0
# =========================================================================
# B. JSON 提取测试
# =========================================================================
class TestExtractJsonFromResponse:
"""extract_json_from_response 测试。"""
def test_fenced_block(self) -> None:
"""从 markdown 代码块提取。"""
raw = '```json\n{"key": "value"}\n```'
assert extract_json_from_response(raw) == {"key": "value"}
def test_fenced_block_no_json_tag(self) -> None:
"""从无 json 标签的代码块提取。"""
raw = '```\n{"key": "value"}\n```'
assert extract_json_from_response(raw) == {"key": "value"}
def test_outermost_braces(self) -> None:
"""从最外层花括号提取。"""
raw = 'Some text before {"result": 42} and after'
assert extract_json_from_response(raw) == {"result": 42}
def test_non_dict_raises(self) -> None:
"""非 dict 类型抛出 ValueError。"""
raw = "[1, 2, 3]"
with pytest.raises(ValueError, match="无法从 LLM 回复中提取 JSON"):
extract_json_from_response(raw)
def test_garbage_raises(self) -> None:
"""完全无法解析的输入抛出 ValueError。"""
with pytest.raises(ValueError, match="无法从 LLM 回复中提取 JSON"):
extract_json_from_response("this is not json at all !!!")
def test_nested_braces(self) -> None:
"""嵌套花括号正确处理。"""
inner = {"nested": {"deep": True}}
raw = f"Result: {json.dumps(inner)}"
assert extract_json_from_response(raw) == inner
def test_fenced_block_non_dict_falls_through(self) -> None:
"""代码块中是列表时,回退到后续策略。"""
raw = '```json\n[1,2,3]\n``` {"fallback": true}'
result = extract_json_from_response(raw)
assert result == {"fallback": True}
# =========================================================================
# C. question_soft_score / aggregate_soft 测试
# =========================================================================
class TestQuestionSoftScore:
"""question_soft_score 测试。"""
def test_empty_returns_none(self) -> None:
"""空 span 列表返回 None。"""
assert question_soft_score([]) is None
def test_single_span(self) -> None:
"""单个 span 的计算。"""
span = _make_span(extraction_completeness=0.8, hallucination_rate=0.2)
score = question_soft_score([span])
# (0.8 + (1.0 - 0.2)) / 2 = (0.8 + 0.8) / 2 = 0.8
assert score is not None
assert abs(score - 0.8) < 1e-9
def test_multiple_spans(self) -> None:
"""多个 span 取均值。"""
span1 = _make_span(extraction_completeness=1.0, hallucination_rate=0.0)
span2 = _make_span(extraction_completeness=0.6, hallucination_rate=0.4)
score = question_soft_score([span1, span2])
# span1: (1.0 + 1.0) / 2 = 1.0
# span2: (0.6 + 0.6) / 2 = 0.6
# mean: (1.0 + 0.6) / 2 = 0.8
assert score is not None
assert abs(score - 0.8) < 1e-9
def test_perfect_span(self) -> None:
"""完美 span。"""
span = _make_span(extraction_completeness=1.0, hallucination_rate=0.0)
score = question_soft_score([span])
assert score == 1.0
class TestAggregateSoft:
"""aggregate_soft 测试。"""
def test_all_none_returns_none(self) -> None:
"""全部 None 返回 None。"""
assert aggregate_soft([None, None, None]) is None
def test_empty_returns_none(self) -> None:
"""空列表返回 None。"""
assert aggregate_soft([]) is None
def test_skip_none(self) -> None:
"""跳过 None 计算均值。"""
result = aggregate_soft([0.8, None, 0.6])
assert result is not None
assert abs(result - 0.7) < 1e-9
def test_all_valid(self) -> None:
"""全部有效。"""
result = aggregate_soft([0.5, 0.7, 0.9])
assert result is not None
assert abs(result - 0.7) < 1e-9
# =========================================================================
# D. attribute_error 瀑布测试
# =========================================================================
class TestAttributeError:
"""attribute_error 瀑布规则测试。"""
def test_extraction_failure_low_completeness(self) -> None:
"""avg completeness < 0.5 → extraction_failure。"""
qm = _make_qm(
span_metrics=[
_make_span(extraction_completeness=0.3, hallucination_rate=0.1),
_make_span(extraction_completeness=0.4, hallucination_rate=0.1),
],
missed_nodes=["node_1"], # 有遗漏,但 extraction 优先
)
result = attribute_error(qm)
assert result.error_type == "extraction_failure"
assert result.question_id == "q1"
def test_extraction_failure_high_hallucination(self) -> None:
"""max hallucination > 0.5 → extraction_failure。"""
qm = _make_qm(
span_metrics=[
_make_span(extraction_completeness=0.9, hallucination_rate=0.6),
],
)
result = attribute_error(qm)
assert result.error_type == "extraction_failure"
def test_search_failure(self) -> None:
"""有遗漏节点 → search_failure。"""
qm = _make_qm(
span_metrics=[
_make_span(extraction_completeness=0.8, hallucination_rate=0.1),
],
missed_nodes=["node_1", "node_2"],
)
result = attribute_error(qm)
assert result.error_type == "search_failure"
def test_reasoning_failure(self) -> None:
"""evidence_sufficient=True → reasoning_failure。"""
qm = _make_qm(
span_metrics=[
_make_span(extraction_completeness=0.8, hallucination_rate=0.1),
],
missed_nodes=[],
evidence_sufficient=True,
)
result = attribute_error(qm)
assert result.error_type == "reasoning_failure"
def test_mixed_evidence_none(self) -> None:
"""evidence_sufficient=None → mixed。"""
qm = _make_qm(
span_metrics=[
_make_span(extraction_completeness=0.8, hallucination_rate=0.1),
],
missed_nodes=[],
evidence_sufficient=None,
)
result = attribute_error(qm)
assert result.error_type == "mixed"
def test_mixed_evidence_false(self) -> None:
"""evidence_sufficient=False → mixed。"""
qm = _make_qm(
span_metrics=[
_make_span(extraction_completeness=0.8, hallucination_rate=0.1),
],
missed_nodes=[],
evidence_sufficient=False,
)
result = attribute_error(qm)
assert result.error_type == "mixed"
def test_no_spans_extraction(self) -> None:
"""无 span 时 avg_completeness=0 (< 0.5) → extraction_failure。"""
qm = _make_qm(span_metrics=[], missed_nodes=["node_x"])
result = attribute_error(qm)
# _mean([]) = 0.0 < 0.5 → extraction_failure
assert result.error_type == "extraction_failure"
def test_reasoning_failure_type_is_none(self) -> None:
"""attribute_error 不设 reasoning_failure_type(由后续阶段补充)。"""
qm = _make_qm(evidence_sufficient=True)
result = attribute_error(qm)
assert result.reasoning_failure_type is None