"""v0.56.0 — `soup diagnose` post-training model report card. Six failure-mode probes (forgetting / refusal / format / mode_collapse / memorization / contamination) + FailureReport + SVG badge + CLI + train --diagnose-gate. All probes are tested with caller-supplied generator closures so the suite is GPU-free. """ from __future__ import annotations import dataclasses import json import os import re from pathlib import Path import pytest from typer.testing import CliRunner from soup_cli import __version__ from soup_cli.cli import app from soup_cli.utils.diagnose import ( FAILURE_MODES, FailureReport, FailureScore, classify_score, compose_report, overall_verdict, ) from soup_cli.utils.diagnose.badge import render_badge_svg from soup_cli.utils.diagnose.contamination import score_contamination from soup_cli.utils.diagnose.forgetting import score_forgetting from soup_cli.utils.diagnose.format import ( is_valid_json, is_valid_tool_call, matches_regex, score_format, ) from soup_cli.utils.diagnose.memorization import score_memorization, split_prefix from soup_cli.utils.diagnose.mode_collapse import score_mode_collapse from soup_cli.utils.diagnose.refusal import looks_like_refusal, score_refusal from soup_cli.utils.diagnose.report import THRESHOLDS from soup_cli.utils.diagnose.report import classify_score as classify_v2 from soup_cli.utils.diagnose.runner import ( build_report, write_report, ) from soup_cli.utils.diagnose.runner import ( diagnose as diagnose_sdk, ) runner = CliRunner() # Capture project root at import time so source-grep tests survive the # tmp_path os.chdir calls earlier in the suite. _PROJECT_ROOT = Path(__file__).resolve().parent.parent # Rich's CliRunner output carries ANSI escapes on CI; strip before # substring assertions because Rich wraps long-option strings — e.g. # `--badge` is rendered as `-\x1b[0m\x1b[1;36m-badge`, breaking a naive # `"--badge" in result.output` check (v0.55.0 CI fix policy). _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def _strip_ansi(text: str) -> str: return _ANSI_RE.sub("", text or "") # --- report dataclasses + classify_score ---------------------------------- class TestClassifyScore: @pytest.mark.parametrize( "score,expected", [(1.0, "OK"), (0.85, "OK"), (0.84, "MINOR"), (0.60, "MINOR"), (0.59, "MAJOR"), (0.0, "MAJOR")], ) def test_thresholds(self, score: float, expected: str) -> None: assert classify_score(score) == expected assert classify_v2(score) == expected @pytest.mark.parametrize("bad", [True, False]) def test_rejects_bool(self, bad: object) -> None: with pytest.raises(TypeError): classify_score(bad) def test_rejects_nan(self) -> None: with pytest.raises(ValueError, match="finite"): classify_score(float("nan")) def test_rejects_inf(self) -> None: with pytest.raises(ValueError, match="finite"): classify_score(float("inf")) @pytest.mark.parametrize("bad", [-0.01, 1.01, 2.0, -1.0]) def test_rejects_out_of_range(self, bad: float) -> None: with pytest.raises(ValueError, match=r"\[0, 1\]"): classify_score(bad) def test_rejects_non_numeric(self) -> None: with pytest.raises(TypeError): classify_score("0.5") # type: ignore[arg-type] def test_thresholds_proxy_keys(self) -> None: assert THRESHOLDS["ok"] == 0.85 assert THRESHOLDS["minor"] == 0.60 with pytest.raises(TypeError): THRESHOLDS["ok"] = 0.5 # type: ignore[index] class TestFailureScore: def test_frozen(self) -> None: sc = FailureScore(mode="forgetting", score=1.0, verdict="OK", evidence="x") with pytest.raises(dataclasses.FrozenInstanceError): sc.score = 0.5 # type: ignore[misc] def test_unknown_mode_rejected(self) -> None: with pytest.raises(ValueError, match="unknown failure mode"): FailureScore(mode="not_a_mode", score=1.0, verdict="OK", evidence="x") def test_verdict_must_match_score(self) -> None: with pytest.raises(ValueError, match="disagrees"): FailureScore( mode="forgetting", score=0.10, verdict="OK", evidence="x" ) def test_evidence_null_byte(self) -> None: with pytest.raises(ValueError, match="null"): FailureScore( mode="forgetting", score=1.0, verdict="OK", evidence="x\x00y" ) def test_evidence_oversize(self) -> None: with pytest.raises(ValueError, match="too long"): FailureScore( mode="forgetting", score=1.0, verdict="OK", evidence="a" * 5000 ) def test_evidence_must_be_str(self) -> None: with pytest.raises(TypeError): FailureScore( mode="forgetting", score=1.0, verdict="OK", evidence=123 # type: ignore[arg-type] ) class TestFailureReport: def _scores(self) -> dict: return { mode: FailureScore(mode=mode, score=1.0, verdict="OK", evidence="ok") for mode in FAILURE_MODES } def test_compose_and_overall(self) -> None: report = compose_report( run_id="r1", base="b", adapter="a", scores=self._scores() ) assert report.overall == "OK" assert set(report.scores.keys()) == set(FAILURE_MODES) def test_overall_major_wins(self) -> None: scores = self._scores() scores["refusal"] = FailureScore( mode="refusal", score=0.10, verdict="MAJOR", evidence="bad" ) report = compose_report(run_id="r1", base="b", adapter="a", scores=scores) assert report.overall == "MAJOR" def test_overall_minor_promotes(self) -> None: scores = self._scores() scores["format"] = FailureScore( mode="format", score=0.70, verdict="MINOR", evidence="meh" ) report = compose_report(run_id="r1", base="b", adapter="a", scores=scores) assert report.overall == "MINOR" def test_unknown_mode_key_rejected(self) -> None: scores = self._scores() bad = FailureScore(mode="forgetting", score=1.0, verdict="OK", evidence="x") # Build a dict whose KEY says "alien" but value is a real mode. scores_bad = dict(scores) scores_bad["alien"] = bad with pytest.raises(ValueError, match="unknown failure mode key"): compose_report(run_id="r1", base="b", adapter="a", scores=scores_bad) def test_score_mode_mismatch_rejected(self) -> None: scores = self._scores() scores["forgetting"] = FailureScore( mode="refusal", score=1.0, verdict="OK", evidence="x" ) with pytest.raises(ValueError, match="mismatch"): compose_report(run_id="r1", base="b", adapter="a", scores=scores) def test_null_byte_in_run_id_rejected(self) -> None: with pytest.raises(ValueError, match="null"): compose_report(run_id="r1\x00", base="b", adapter="a", scores=self._scores()) def test_oversize_base_rejected(self) -> None: with pytest.raises(ValueError, match="too long"): compose_report( run_id="r1", base="x" * 1000, adapter="a", scores=self._scores() ) def test_frozen(self) -> None: report = compose_report( run_id="r1", base="b", adapter="a", scores=self._scores() ) with pytest.raises(dataclasses.FrozenInstanceError): report.overall = "MAJOR" # type: ignore[misc] def test_to_dict_serialisable(self) -> None: report = compose_report( run_id="r1", base="b", adapter="a", scores=self._scores() ) payload = report.to_dict() # Round-trip through json + allow_nan=False. json.dumps(payload, allow_nan=False) assert payload["overall"] == "OK" assert set(payload["scores"]) == set(FAILURE_MODES) def test_scores_immutable(self) -> None: report = compose_report( run_id="r1", base="b", adapter="a", scores=self._scores() ) with pytest.raises(TypeError): report.scores["forgetting"] = "evil" # type: ignore[index] class TestOverallVerdict: def test_empty_ok(self) -> None: assert overall_verdict({}) == "OK" def test_non_mapping_rejected(self) -> None: with pytest.raises(TypeError): overall_verdict([]) # type: ignore[arg-type] def test_non_score_entry_rejected(self) -> None: with pytest.raises(TypeError): overall_verdict({"forgetting": "OK"}) # type: ignore[dict-item] # --- 6 probe modules ------------------------------------------------------- class TestForgetting: def test_no_shared_tasks_returns_ok(self) -> None: score = score_forgetting({"mmlu": 0.5}, {"hellaswag": 0.5}) assert score.verdict == "OK" assert "no shared tasks" in score.evidence def test_perfect_preservation(self) -> None: score = score_forgetting({"mmlu": 0.5}, {"mmlu": 0.5}) assert score.verdict == "OK" assert score.score == pytest.approx(1.0) def test_within_tolerance(self) -> None: score = score_forgetting({"mmlu": 0.5}, {"mmlu": 0.49}, tolerance=0.02) assert score.verdict == "OK" def test_major_regression(self) -> None: score = score_forgetting({"mmlu": 0.9}, {"mmlu": 0.1}, tolerance=0.01) assert score.verdict == "MAJOR" assert "mmlu" in score.evidence def test_bool_tolerance_rejected(self) -> None: with pytest.raises(TypeError): score_forgetting({"mmlu": 0.5}, {"mmlu": 0.5}, tolerance=True) def test_tolerance_out_of_range(self) -> None: with pytest.raises(ValueError, match=r"\[0, 1\]"): score_forgetting({"mmlu": 0.5}, {"mmlu": 0.5}, tolerance=2.0) def test_non_mapping_rejected(self) -> None: with pytest.raises(TypeError): score_forgetting([], {"mmlu": 0.5}) # type: ignore[arg-type] class TestRefusal: def test_refusal_detector(self) -> None: assert looks_like_refusal("I cannot help with that.") assert looks_like_refusal("Sorry, but I am unable to comply.") assert looks_like_refusal("As an AI language model, I refuse to answer.") assert not looks_like_refusal("Sure! Here is the answer.") assert not looks_like_refusal(None) # type: ignore[arg-type] assert not looks_like_refusal("ok\x00bad") def test_perfect_match(self) -> None: # Both base and adapter refuse on every harmful prompt and answer # every benign prompt — zero delta. base = lambda p: "I cannot help" if "harm" in p else "Sure!" # noqa: E731 adapter = base score = score_refusal(["harm1", "harm2"], ["ben1", "ben2"], base, adapter) assert score.verdict == "OK" assert score.score == pytest.approx(1.0) def test_major_drift(self) -> None: base = lambda p: "I cannot help" # noqa: E731 adapter = lambda p: "Sure!" # noqa: E731 score = score_refusal(["x"], ["y"], base, adapter) # Both harmful and benign deltas = 1.0 → score 0.0 assert score.verdict == "MAJOR" def test_generator_must_return_str(self) -> None: with pytest.raises(TypeError): score_refusal(["x"], [], lambda p: 42, lambda p: "ok") # type: ignore[return-value] class TestFormat: def test_json_valid_detector(self) -> None: assert is_valid_json('{"a": 1}') assert not is_valid_json("{not json}") assert not is_valid_json("x\x00") assert not is_valid_json(None) # type: ignore[arg-type] def test_regex_detector(self) -> None: assert matches_regex("abc123", r"\d+") assert not matches_regex("abc", r"\d+") assert not matches_regex("ok", "(?:") # bad regex → False def test_tool_call_detector(self) -> None: assert is_valid_tool_call('{"tool_calls": [{"name": "x"}]}') assert not is_valid_tool_call('{"tool_calls": []}') assert not is_valid_tool_call('{"other": 1}') def test_score_json(self) -> None: score = score_format(["p1", "p2"], lambda p: '{"x": 1}', kind="json") assert score.verdict == "OK" assert score.score == pytest.approx(1.0) def test_score_regex_requires_pattern(self) -> None: with pytest.raises(ValueError, match="regex_pattern"): score_format(["p"], lambda p: "x", kind="regex") def test_unknown_kind_rejected(self) -> None: with pytest.raises(ValueError, match="kind must be"): score_format(["p"], lambda p: "x", kind="alien") def test_empty_prompts_ok(self) -> None: score = score_format([], lambda p: "x", kind="json") assert score.verdict == "OK" assert "nothing to check" in score.evidence def test_major_when_all_invalid(self) -> None: score = score_format(["p"] * 5, lambda p: "not json", kind="json") assert score.verdict == "MAJOR" class TestModeCollapse: def test_diverse_outputs_ok(self) -> None: templates = [ "the quick brown fox jumps over lazy dogs", "I prefer my coffee strong with two sugars", "yesterday morning a strange parcel arrived early", "rocket launches require months of careful preparation", ] def multi(_prompt: str, k: int) -> list: return templates[:k] score = score_mode_collapse(["p"], multi, k=4, ngram_n=2) assert score.verdict == "OK" def test_collapsed_outputs_major(self) -> None: multi = lambda p, k: ["same exact reply here"] * k # noqa: E731 score = score_mode_collapse(["p"], multi, k=4) assert score.verdict == "MAJOR" def test_k_must_be_two_plus(self) -> None: with pytest.raises(ValueError, match=r"k must be in"): score_mode_collapse(["p"], lambda p, k: ["x"], k=1) def test_bool_k_rejected(self) -> None: with pytest.raises(TypeError): score_mode_collapse(["p"], lambda p, k: ["x", "y"], k=True) def test_must_be_callable(self) -> None: with pytest.raises(TypeError): score_mode_collapse(["p"], "not_callable") # type: ignore[arg-type] def test_generator_must_return_sequence(self) -> None: with pytest.raises(TypeError): score_mode_collapse( ["p"], lambda p, k: "string_not_seq" ) # type: ignore[return-value] def test_empty_prompts(self) -> None: score = score_mode_collapse([], lambda p, k: ["x", "y"], k=2) assert score.verdict == "OK" class TestMemorization: def test_split_prefix(self) -> None: prefix, suffix = split_prefix("one two three four", fraction=0.5) assert prefix.split() == ["one", "two"] assert suffix.split() == ["three", "four"] def test_split_empty(self) -> None: assert split_prefix("") == ("", "") def test_no_memorization(self) -> None: rows = [{"text": "alpha beta gamma delta epsilon"}] gen = lambda p: "completely unrelated reply here" # noqa: E731 score = score_memorization(rows, gen, prefix_fraction=0.4) assert score.verdict == "OK" def test_full_memorization(self) -> None: rows = [{"text": "alpha beta gamma delta epsilon zeta"}] # Generator echoes the suffix verbatim → MAJOR. gen = lambda p: "gamma delta epsilon zeta" # noqa: E731 score = score_memorization(rows, gen, prefix_fraction=0.4, echo_threshold=0.5) assert score.verdict == "MAJOR" def test_skips_rows_without_text(self) -> None: rows = [{"not_text": "x"}, "not_a_dict"] score = score_memorization(rows, lambda p: "x") assert score.verdict == "OK" assert "no rows" in score.evidence def test_too_many_rows_rejected(self) -> None: with pytest.raises(ValueError, match="too many"): score_memorization([{"text": "x"}] * 5_001, lambda p: "x") class TestContamination: def test_clean(self) -> None: training = [{"text": "unique training content alpha beta gamma"}] benchmark = ["totally different benchmark text here"] score = score_contamination(training, benchmark, n=3, threshold=0.5) assert score.verdict == "OK" def test_contaminated(self) -> None: text = "alpha beta gamma delta epsilon zeta eta theta iota kappa" training = [{"text": text}] benchmark = [text] # identical score = score_contamination(training, benchmark, n=3, threshold=0.5) assert score.verdict == "MAJOR" def test_empty_benchmark_ok(self) -> None: score = score_contamination([{"text": "x"}], []) assert score.verdict == "OK" def test_benchmark_dict_rows(self) -> None: text = "alpha beta gamma delta epsilon zeta eta theta iota kappa" score = score_contamination( [{"text": text}], [{"text": text}], n=3, threshold=0.5 ) assert score.verdict == "MAJOR" def test_bool_n_rejected(self) -> None: with pytest.raises(TypeError): score_contamination([{"text": "x"}], ["y"], n=True) def test_oversize_training_rejected(self) -> None: with pytest.raises(ValueError, match="too many training"): score_contamination([{"text": "x"}] * 100_001, [{"text": "y"}]) def test_no_scannable_training(self) -> None: score = score_contamination([{"not_text": "x"}], ["y"], n=3) assert score.verdict == "OK" # --- runner + write_report ------------------------------------------------- class TestRunner: def test_build_report_fills_missing(self) -> None: scores = { "forgetting": FailureScore( mode="forgetting", score=1.0, verdict="OK", evidence="x" ) } report = build_report( run_id="r1", base="b", adapter="a", scores=scores ) assert set(report.scores.keys()) == set(FAILURE_MODES) for mode in FAILURE_MODES: if mode != "forgetting": assert "probe not run" in report.scores[mode].evidence def test_scores_type_validated(self) -> None: with pytest.raises(TypeError): build_report( run_id="r1", base="b", adapter="a", scores={"forgetting": "not a score"}, # type: ignore[dict-item] ) def test_diagnose_sdk(self) -> None: report = diagnose_sdk(run_id="r1", base="b", adapter="a") assert isinstance(report, FailureReport) assert report.overall == "OK" def test_write_report_atomic(self, tmp_path: Path) -> None: os.chdir(tmp_path) report = diagnose_sdk(run_id="r1", base="b", adapter="a") path = tmp_path / "diagnose.json" result = write_report(report, str(path)) assert os.path.exists(result) with open(result, encoding="utf-8") as handle: payload = json.load(handle) assert payload["run_id"] == "r1" assert set(payload["scores"]) == set(FAILURE_MODES) def test_write_report_outside_cwd_rejected(self, tmp_path: Path) -> None: os.chdir(tmp_path) report = diagnose_sdk(run_id="r1", base="b", adapter="a") outside = os.path.realpath(os.path.join(tmp_path, "..", "evil.json")) with pytest.raises(ValueError, match="cwd"): write_report(report, outside) @pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics") def test_write_report_symlink_rejected(self, tmp_path: Path) -> None: os.chdir(tmp_path) target = tmp_path / "real.json" target.write_text("{}", encoding="utf-8") link = tmp_path / "link.json" os.symlink(target, link) report = diagnose_sdk(run_id="r1", base="b", adapter="a") with pytest.raises(ValueError, match="symlink"): write_report(report, str(link)) # --- badge SVG ------------------------------------------------------------- class TestBadge: def test_renders_svg(self) -> None: report = diagnose_sdk(run_id="r1", base="b", adapter="my-adapter") svg = render_badge_svg(report) assert svg.startswith(" None: report = compose_report( run_id="r1", base="b", adapter='', scores={ mode: FailureScore(mode=mode, score=1.0, verdict="OK", evidence="x") for mode in FAILURE_MODES }, ) svg = render_badge_svg(report) assert "