diff --git a/core/evolution/validate.py b/core/evolution/validate.py new file mode 100644 index 0000000..5c140fd --- /dev/null +++ b/core/evolution/validate.py @@ -0,0 +1,85 @@ +"""core/evolution/validate.py — 块验证纯决策函数。 + +算法 #7(块顺序验证)的局部实现:pair_block 逐题比对基线与候选、 +classify_quadrants 四象限分类、compute_accuracy 纯算术准确率。 + +三个函数均为纯函数,无副作用、无外部依赖。 +""" + +from core.evolution.types import PairResult, QuadrantClassification + + +def pair_block( + baseline: dict[str, bool], + candidate: dict[str, bool], + question_ids: list[str], +) -> PairResult: + """逐题比对基线与候选对错,统计翻转。 + + 参数: + baseline: 基线臂每题正确性映射。 + candidate: 候选臂每题正确性映射。 + question_ids: 参与比对的题目 ID 列表。 + + 返回: + PairResult,包含 w(基线错→候选对翻转数)、l(基线对→候选错翻转数) + 和 observed(每题的 (基线, 候选) 对错记录)。 + """ + w = l = 0 # noqa: E741 — 数学记号 W/L(win/loss),与 gate.py 一致 + 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 # noqa: E741 + return PairResult(w=w, l=l, observed=observed) + + +def classify_quadrants( + observed: dict[str, tuple[bool, bool]], +) -> QuadrantClassification: + """按 (baseline, candidate) 四组分类,各组内 sorted。 + + 参数: + observed: 每题的 (基线是否正确, 候选是否正确) 记录。 + + 返回: + QuadrantClassification,四个象限各含排序后的题目 ID 列表。 + """ + improvements: list[str] = [] + regressions: list[str] = [] + persistent_fails: list[str] = [] + stable_successes: list[str] = [] + 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)。 + + 参数: + correctness: 每题正确性映射。 + question_ids: 参与计算的题目 ID 列表。 + + 返回: + 准确率浮点数。question_ids 为空时抛出 ZeroDivisionError。 + """ + return sum(correctness[qid] for qid in question_ids) / len(question_ids) diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py new file mode 100644 index 0000000..a90a8dc --- /dev/null +++ b/tests/unit/test_validate.py @@ -0,0 +1,105 @@ +"""tests/unit/test_validate.py — 块验证纯决策函数的单元测试。""" + +import pytest + +from core.evolution.validate import classify_quadrants, compute_accuracy, pair_block + + +class TestPairBlock: + """pair_block 逐题比对基线与候选的翻转统计测试。""" + + def test_basic_flips(self) -> None: + """基本翻转:一题从错到对(w)、一题从对到错(l)、一题不变。""" + 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 + assert result.l == 1 + assert result.observed == { + "q1": (False, True), + "q2": (True, False), + "q3": (True, True), + } + + def test_empty(self) -> None: + """空输入应返回零翻转。""" + result = pair_block({}, {}, []) + assert result.w == 0 and result.l == 0 + + def test_all_wins(self) -> None: + """全部从错到对的极端情况。""" + baseline = {"q1": False, "q2": False} + candidate = {"q1": True, "q2": True} + result = pair_block(baseline, candidate, ["q1", "q2"]) + assert result.w == 2 + assert result.l == 0 + + def test_all_losses(self) -> None: + """全部从对到错的极端情况。""" + baseline = {"q1": True, "q2": True} + candidate = {"q1": False, "q2": False} + result = pair_block(baseline, candidate, ["q1", "q2"]) + assert result.w == 0 + assert result.l == 2 + + def test_subset_of_questions(self) -> None: + """只对 question_ids 中指定的子集进行比对。""" + baseline = {"q1": False, "q2": True, "q3": True} + candidate = {"q1": True, "q2": False, "q3": True} + result = pair_block(baseline, candidate, ["q1"]) + assert result.w == 1 + assert result.l == 0 + assert "q2" not in result.observed + + +class TestClassifyQuadrants: + """classify_quadrants 四象限分类测试。""" + + def test_all_four(self) -> None: + """四个象限各有一题。""" + observed = { + "q1": (False, True), + "q2": (True, False), + "q3": (False, False), + "q4": (True, True), + } + 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) -> None: + """同象限内题目 ID 应按字典序排列。""" + observed = {"z": (False, True), "a": (False, True)} + qc = classify_quadrants(observed) + assert qc.improvements == ["a", "z"] + + def test_empty_observed(self) -> None: + """空输入应返回全空象限。""" + qc = classify_quadrants({}) + assert qc.improvements == [] + assert qc.regressions == [] + assert qc.persistent_fails == [] + assert qc.stable_successes == [] + + +class TestComputeAccuracy: + """compute_accuracy 准确率计算测试。""" + + def test_basic(self) -> None: + """一对一错,准确率 0.5。""" + assert compute_accuracy({"q1": True, "q2": False}, ["q1", "q2"]) == 0.5 + + def test_all_correct(self) -> None: + """全部正确,准确率 1.0。""" + assert compute_accuracy({"q1": True, "q2": True}, ["q1", "q2"]) == 1.0 + + def test_all_wrong(self) -> None: + """全部错误,准确率 0.0。""" + assert compute_accuracy({"q1": False, "q2": False}, ["q1", "q2"]) == 0.0 + + def test_empty_raises(self) -> None: + """空题目列表应抛出 ZeroDivisionError。""" + with pytest.raises(ZeroDivisionError): + compute_accuracy({}, [])