"""Tests for v0.55.0 — soup eval design / discover / lock / coverage / gate. Covers Parts A-D plus CLI plumbing + source-grep regression guards. """ from __future__ import annotations import dataclasses import json import os import re from pathlib import Path from typing import List import pytest from typer.testing import CliRunner from soup_cli.utils.canary_discovery import ( CanarySet, canary_set_to_dict, discover_canaries, load_canary_set, write_canary_set, ) from soup_cli.utils.eval_design import ( SCORER_TYPES, EvalDesign, EvalDimension, design_evals_from_data, design_to_dict, load_eval_design, write_eval_design, ) from soup_cli.utils.eval_gate_hook import ( GateThresholds, RegressionVerdict, decide_regression, paired_bootstrap_ci, render_pre_push_hook, write_pre_push_hook, ) from soup_cli.utils.eval_lock_coverage import ( CoverageReport, LockedSuite, canonicalise_design_bytes, checksum_design, compute_coverage, lock_suite, ) POSIX_ONLY = pytest.mark.skipif(os.name == "nt", reason="POSIX-only symlink test") # Rich wraps option names with ANSI escapes when the terminal is narrow # (macOS CI runners default to a smaller width than Linux/Windows), so # substring searches like `"--goal" in output` fail without stripping. # Project precedent: tests/test_auto_tuning.py, test_eval_platform.py, # v0.53.5 / v0.53.6 / v0.53.8 / v0.53.9 CI hardening commits. _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def _strip_ansi(text: str) -> str: return _ANSI_RE.sub("", text) # --------------------------------------------------------------------------- # Part A — Eval design from data # --------------------------------------------------------------------------- class TestEvalDesignFromData: def _rows(self) -> List[dict]: return [ {"messages": [ {"role": "user", "content": "what is sql"}, {"role": "assistant", "content": "SQL is structured query language for databases."}, ]}, {"messages": [ {"role": "user", "content": "write a query"}, {"role": "assistant", "content": "SELECT id FROM users WHERE active = true"}, ]}, {"messages": [ {"role": "user", "content": "join tables"}, {"role": "assistant", "content": "SELECT users.id FROM users JOIN orders ON ..."}, ]}, ] def test_happy_path_returns_frozen_design(self): rows = self._rows() design = design_evals_from_data(rows, goal="better at SQL") assert isinstance(design, EvalDesign) assert design.row_count == 3 assert len(design.dimensions) >= 1 assert all(isinstance(d, EvalDimension) for d in design.dimensions) # Frozen invariant — mutation must raise the dataclass-specific # error, not just any exception. with pytest.raises(dataclasses.FrozenInstanceError): design.dimensions[0].name = "rewrite" # type: ignore[misc] @pytest.mark.parametrize( "goal,expected_scorer", [ ("output json schema", "rlvr"), ("write python function", "rlvr"), ("solve math word problem", "rlvr"), ("classify intent", "exact_match"), ("extract field value", "regex"), ("summarize emails", "judge"), ("just generally helpful", "judge"), # fallback ], ) def test_scorer_picked_by_goal_keyword(self, goal, expected_scorer): design = design_evals_from_data(self._rows(), goal=goal) assert all( d.scorer_type == expected_scorer for d in design.dimensions ) def test_scorer_is_allowlisted(self): design = design_evals_from_data(self._rows(), goal="x") for d in design.dimensions: assert d.scorer_type in SCORER_TYPES def test_dimension_names_are_unique_and_well_formed(self): design = design_evals_from_data(self._rows(), goal="x") names = [d.name for d in design.dimensions] assert len(names) == len(set(names)) for n in names: assert re.match(r"^[a-z][a-z0-9_]{0,63}$", n) def test_num_dimensions_bounds(self): rows = self._rows() for bad in [0, 21, -1]: with pytest.raises(ValueError): design_evals_from_data(rows, goal="x", num_dimensions=bad) def test_num_dimensions_bool_rejected(self): with pytest.raises(TypeError): design_evals_from_data(self._rows(), goal="x", num_dimensions=True) def test_goal_bool_rejected(self): with pytest.raises(TypeError): design_evals_from_data(self._rows(), goal=True) # type: ignore[arg-type] def test_goal_null_byte_rejected(self): with pytest.raises(ValueError): design_evals_from_data(self._rows(), goal="hi\x00there") def test_goal_oversize_rejected(self): with pytest.raises(ValueError): design_evals_from_data(self._rows(), goal="a" * 5000) def test_non_sequence_rows_rejected(self): with pytest.raises(TypeError): design_evals_from_data("not-a-list", goal="x") # type: ignore[arg-type] def test_empty_rows_still_produces_goal_alignment(self): design = design_evals_from_data([], goal="x") assert len(design.dimensions) == 1 assert design.dimensions[0].name == "goal_alignment" def test_rows_with_no_text_falls_through(self): design = design_evals_from_data( [{"messages": []}, {"messages": []}], goal="x", ) assert design.dimensions # at least the fallback class TestEvalDesignIO: def test_roundtrip(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) design = design_evals_from_data( [{"output": "the quick brown fox"}], goal="x", ) out = "evals/d.json" write_eval_design(design, out) loaded = load_eval_design(out) assert loaded == design def test_write_outside_cwd_rejected(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) design = design_evals_from_data([{"output": "x"}], goal="x") with pytest.raises(ValueError): write_eval_design(design, "/tmp/escape.json") def test_load_null_byte_rejected(self): with pytest.raises(ValueError): load_eval_design("evil\x00path.json") def test_load_non_string_rejected(self): with pytest.raises(TypeError): load_eval_design(123) # type: ignore[arg-type] @POSIX_ONLY def test_load_symlink_rejected(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) real = tmp_path / "real.json" real.write_text("{}") link = tmp_path / "link.json" link.symlink_to(real) with pytest.raises(ValueError): load_eval_design("link.json") def test_load_rejects_unknown_scorer(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) path = tmp_path / "bad.json" path.write_text(json.dumps({ "goal": "x", "row_count": 1, "dimensions": [{ "name": "d1", "rubric": "r", "scorer_type": "fnord", "keywords": [], }], })) with pytest.raises(ValueError, match="unknown scorer_type"): load_eval_design("bad.json") def test_load_rejects_bad_name(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) path = tmp_path / "bad.json" path.write_text(json.dumps({ "goal": "x", "row_count": 1, "dimensions": [{ "name": "Has Space", "rubric": "r", "scorer_type": "judge", "keywords": [], }], })) with pytest.raises(ValueError, match="invalid dimension name"): load_eval_design("bad.json") def test_design_to_dict_rejects_non_design(self): with pytest.raises(TypeError): design_to_dict({"goal": "x"}) # type: ignore[arg-type] # --------------------------------------------------------------------------- # Part B — Canary discovery # --------------------------------------------------------------------------- class TestCanaryDiscovery: def _rows(self) -> List[dict]: return [ {"prompt": "what is sql", "output": "structured query language"}, {"prompt": "write a select query", "output": "SELECT id FROM users"}, {"prompt": "join two tables", "output": "SELECT * FROM a JOIN b"}, {"prompt": "translate to french", "output": "bonjour le monde"}, {"prompt": "translate to german", "output": "guten tag welt"}, ] def test_happy_path(self): canary = discover_canaries( self._rows(), base="meta-llama/Llama-3-8B", num_clusters=2, per_cluster=2, seed=42, ) assert isinstance(canary, CanarySet) assert canary.base == "meta-llama/Llama-3-8B" assert canary.cluster_count >= 1 # Held-out + memorization populated assert len(canary.held_out) >= 1 assert len(canary.memorization_probes) >= 1 def test_frozen_dataclass(self): canary = discover_canaries(self._rows(), num_clusters=2) with pytest.raises(dataclasses.FrozenInstanceError): canary.held_out = ("changed",) # type: ignore[misc] def test_deterministic_with_seed(self): c1 = discover_canaries(self._rows(), num_clusters=2, seed=7) c2 = discover_canaries(self._rows(), num_clusters=2, seed=7) assert c1.held_out == c2.held_out def test_seed_changes_output_distribution(self): # Different seeds may produce different first-centroid picks. c1 = discover_canaries(self._rows(), num_clusters=3, seed=0) c2 = discover_canaries(self._rows(), num_clusters=3, seed=100) # Don't assert inequality (could collide by chance for small data); # just assert both are valid. assert c1.held_out assert c2.held_out @pytest.mark.parametrize("bad", [True, 1.5, "5", None]) def test_num_clusters_type_rejected(self, bad): with pytest.raises((TypeError, ValueError)): discover_canaries(self._rows(), num_clusters=bad) def test_num_clusters_oob(self): with pytest.raises(ValueError): discover_canaries(self._rows(), num_clusters=0) with pytest.raises(ValueError): discover_canaries(self._rows(), num_clusters=999) def test_base_validation(self): with pytest.raises(ValueError): discover_canaries(self._rows(), base="bad\x00name") with pytest.raises(ValueError): discover_canaries(self._rows(), base="a" * 600) with pytest.raises(TypeError): discover_canaries(self._rows(), base=True) # type: ignore[arg-type] def test_empty_rows(self): canary = discover_canaries([], num_clusters=3) assert canary.held_out == () assert canary.adjacent_skills == () assert canary.memorization_probes == () assert canary.cluster_count == 0 def test_dimensions_validated(self): canary = discover_canaries( self._rows(), dimensions=("rewrite", "summarize"), ) assert canary.dimensions == ("rewrite", "summarize") with pytest.raises(ValueError): discover_canaries(self._rows(), dimensions=["bad\x00d"]) with pytest.raises(TypeError): discover_canaries(self._rows(), dimensions="not-a-list") # type: ignore[arg-type] def test_memorization_probe_is_truncated(self): # A long prompt → memorization probe is shorter. rows = [{"prompt": " ".join(["word"] * 40), "output": "x"}] canary = discover_canaries(rows, num_clusters=1, per_cluster=1) assert canary.memorization_probes assert ( len(canary.memorization_probes[0].split()) < len(rows[0]["prompt"].split()) ) def test_dedup_held_out(self): # Two identical rows → only one canary canary = discover_canaries( [{"prompt": "hi"}, {"prompt": "hi"}], num_clusters=1, per_cluster=2, ) assert canary.held_out.count("hi") <= 1 class TestCanaryIO: def test_roundtrip(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) canary = discover_canaries( [{"prompt": "x", "output": "y"}], num_clusters=1, ) write_canary_set(canary, "c.json") loaded = load_canary_set("c.json") assert loaded == canary def test_write_outside_cwd_rejected(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) c = discover_canaries([{"prompt": "x"}], num_clusters=1) with pytest.raises(ValueError): write_canary_set(c, "/tmp/escape.json") def test_canary_set_to_dict_rejects_non_canary(self): with pytest.raises(TypeError): canary_set_to_dict({"held_out": []}) # type: ignore[arg-type] # --------------------------------------------------------------------------- # Part C — Eval lock + coverage # --------------------------------------------------------------------------- class TestEvalLock: def test_canonicalise_deterministic(self): design = design_evals_from_data([{"output": "abc"}], goal="x") a = canonicalise_design_bytes(design) b = canonicalise_design_bytes(design) assert a == b # Strict canonical layout: no structural whitespace. (Content # strings may still contain ": " or "\n" — we only assert that # json.dumps was called with the separators+sort_keys flags by # checking common-prefix structure is compact.) assert a.startswith(b"{\"") # The keys come back sorted: dimensions / goal / row_count. assert a.index(b"\"dimensions\"") < a.index(b"\"goal\"") assert a.index(b"\"goal\"") < a.index(b"\"row_count\"") def test_checksum_stable(self): design = design_evals_from_data([{"output": "abc"}], goal="x") h1 = checksum_design(design) h2 = checksum_design(design) assert h1 == h2 assert len(h1) == 64 # SHA-256 hex def test_canonicalise_rejects_non_design(self): with pytest.raises(TypeError): canonicalise_design_bytes({"goal": "x"}) # type: ignore[arg-type] def test_lock_writes_file(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) design = design_evals_from_data([{"output": "abc"}], goal="x") locked = lock_suite(design, "evals/locked.json") assert isinstance(locked, LockedSuite) assert locked.dimension_count == len(design.dimensions) assert os.path.isfile("evals/locked.json") # checksum == sha256 of file bytes on_disk = Path("evals/locked.json").read_bytes() import hashlib assert locked.checksum == hashlib.sha256(on_disk).hexdigest() def test_lock_outside_cwd_rejected(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) design = design_evals_from_data([{"output": "x"}], goal="x") with pytest.raises(ValueError): lock_suite(design, "/tmp/escape.json") class TestCoverage: def _design_with_scorers(self, scorers: list) -> EvalDesign: return EvalDesign( goal="x", row_count=1, dimensions=tuple( EvalDimension( name=f"d{i}", rubric="r", scorer_type=s, keywords=(), ) for i, s in enumerate(scorers) ), ) def test_happy_path_factual_lookup(self): design = self._design_with_scorers(["exact_match", "judge"]) report = compute_coverage(design, task_category="factual_lookup") assert isinstance(report, CoverageReport) assert report.task_category == "factual_lookup" assert report.missing_scorers == () # has both expected def test_missing_scorer_surfaces(self): design = self._design_with_scorers(["judge"]) report = compute_coverage(design, task_category="reasoning") assert "rlvr" in report.missing_scorers def test_scorer_mix_counts(self): design = self._design_with_scorers(["judge", "judge", "rlvr"]) report = compute_coverage(design, task_category="reasoning") assert report.scorer_mix["judge"] == 2 assert report.scorer_mix["rlvr"] == 1 assert report.scorer_mix["exact_match"] == 0 def test_unknown_task_category(self): design = self._design_with_scorers(["judge"]) with pytest.raises(ValueError, match="unknown task_category"): compute_coverage(design, task_category="not_a_real_category") def test_task_category_bool_rejected(self): design = self._design_with_scorers(["judge"]) with pytest.raises(TypeError): compute_coverage(design, task_category=True) # type: ignore[arg-type] def test_empty_design_recommendations(self): design = EvalDesign(goal="", row_count=0, dimensions=()) report = compute_coverage(design, task_category="summarization") assert any( "no dimensions" in rec for rec in report.recommendations ) def test_case_insensitive_task_category(self): design = self._design_with_scorers(["judge"]) report = compute_coverage(design, task_category="Reasoning") assert report.task_category == "reasoning" # --------------------------------------------------------------------------- # Part D — git-hook regression gate + paired bootstrap # --------------------------------------------------------------------------- class TestPairedBootstrap: def test_identical_samples_zero_mean(self): a = [0.5] * 50 b = [0.5] * 50 lo, hi, mean = paired_bootstrap_ci(a, b, n_samples=200, seed=42) assert mean == pytest.approx(0.0) assert lo == pytest.approx(0.0) assert hi == pytest.approx(0.0) def test_improvement_positive_ci(self): baseline = [0.5] * 100 candidate = [0.7] * 100 lo, hi, mean = paired_bootstrap_ci( baseline, candidate, n_samples=500, seed=0, ) assert mean == pytest.approx(0.2) # Constant series → CI collapses to point. assert hi - lo == pytest.approx(0.0) def test_length_mismatch_rejected(self): with pytest.raises(ValueError, match="length"): paired_bootstrap_ci([1.0, 2.0], [1.0]) def test_empty_rejected(self): with pytest.raises(ValueError, match="non-empty"): paired_bootstrap_ci([], []) def test_n_samples_bounds(self): with pytest.raises(ValueError): paired_bootstrap_ci([1.0], [1.0], n_samples=10) with pytest.raises(ValueError): paired_bootstrap_ci([1.0], [1.0], n_samples=200_000) def test_ci_level_bounds(self): with pytest.raises(ValueError): paired_bootstrap_ci([1.0], [1.0], n_samples=100, ci_level=0.0) with pytest.raises(ValueError): paired_bootstrap_ci([1.0], [1.0], n_samples=100, ci_level=1.0) def test_seed_bool_rejected(self): with pytest.raises(TypeError): paired_bootstrap_ci( [1.0], [1.0], n_samples=100, seed=True, # type: ignore[arg-type] ) def test_non_finite_rejected(self): import math with pytest.raises(ValueError): paired_bootstrap_ci( [math.nan, 1.0], [1.0, 1.0], n_samples=100, ) def test_deterministic(self): a = [0.1, 0.4, 0.5, 0.7] b = [0.2, 0.4, 0.6, 0.6] r1 = paired_bootstrap_ci(a, b, n_samples=500, seed=0) r2 = paired_bootstrap_ci(a, b, n_samples=500, seed=0) assert r1 == r2 class TestDecideRegression: def test_higher_better_no_regression_on_improvement(self): baseline = [0.5] * 50 candidate = [0.7] * 50 verdict = decide_regression( "task_accuracy", baseline, candidate, GateThresholds(), n_samples=200, seed=0, ) assert isinstance(verdict, RegressionVerdict) assert verdict.regressed is False assert verdict.offenders == () def test_higher_better_regression_on_drop(self): baseline = [0.9] * 50 candidate = [0.5] * 50 verdict = decide_regression( "task_accuracy", baseline, candidate, GateThresholds(), n_samples=200, seed=0, ) assert verdict.regressed is True assert "task_accuracy" in verdict.offenders def test_lower_better_no_regression_on_improvement(self): # Lower latency = better. Tolerance is +100ms (regression # acceptable up to +100ms). 90ms improvement → not regressed. baseline = [200.0] * 50 candidate = [110.0] * 50 verdict = decide_regression( "p95_latency_ms", baseline, candidate, GateThresholds(), n_samples=200, seed=0, ) assert verdict.regressed is False def test_lower_better_regression_on_increase(self): # +500ms latency — beyond tolerance. baseline = [100.0] * 50 candidate = [600.0] * 50 verdict = decide_regression( "p95_latency_ms", baseline, candidate, GateThresholds(), n_samples=200, seed=0, ) assert verdict.regressed is True def test_unknown_metric_rejected(self): with pytest.raises(ValueError, match="unknown metric"): decide_regression( "made_up_metric", [1.0], [1.0], GateThresholds(), ) def test_metric_bool_rejected(self): with pytest.raises(TypeError): decide_regression( True, [1.0], [1.0], GateThresholds(), # type: ignore[arg-type] ) def test_thresholds_type_rejected(self): with pytest.raises(TypeError): decide_regression( "task_accuracy", [1.0], [1.0], {"task_accuracy": -0.05}, # type: ignore[arg-type] ) class TestPrePushHookRendering: def test_basic_render(self): body = render_pre_push_hook( baseline_run_id="run-abc-123", suite_path="evals/locked.json", ) assert "#!/usr/bin/env bash" in body assert "soup eval against" in body assert "run-abc-123" in body assert "set -euo pipefail" in body def test_bad_run_id_rejected(self): for bad in ["", "has space", "../escape", "a\x00b", True]: with pytest.raises((TypeError, ValueError)): render_pre_push_hook( baseline_run_id=bad, # type: ignore[arg-type] suite_path="evals/locked.json", ) def test_suite_path_null_byte_rejected(self): with pytest.raises(ValueError): render_pre_push_hook( baseline_run_id="abc", suite_path="evals/loc\x00ked.json", ) def test_suite_path_newline_rejected(self): with pytest.raises(ValueError): render_pre_push_hook( baseline_run_id="abc", suite_path="evals/locked.json\nrm -rf /", ) def test_suite_path_outside_cwd_rejected(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) with pytest.raises(ValueError): render_pre_push_hook( baseline_run_id="abc", suite_path="/etc/passwd", ) def test_shell_escape_resists_quote_injection(self, tmp_path, monkeypatch): # Suite path with single quotes — must be safely escaped. monkeypatch.chdir(tmp_path) (tmp_path / "weird's name.json").write_text("{}") body = render_pre_push_hook( baseline_run_id="abc", suite_path="weird's name.json", ) # Must not break the quoting: bash -n would validate but we # just check the escape pattern is present. assert "'\"'\"'" in body or "\\'" in body or "'weird" in body class TestPrePushHookWrite: def test_atomic_write(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "evals").mkdir() (tmp_path / "evals" / "locked.json").write_text("{}") hook = tmp_path / "hooks" / "pre-push" path = write_pre_push_hook( baseline_run_id="run-1", suite_path="evals/locked.json", hook_path=str(hook.relative_to(tmp_path)), ) assert os.path.isfile(path) body = Path(path).read_text() assert "run-1" in body def test_refuses_overwrite_by_default(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "evals").mkdir() (tmp_path / "evals" / "locked.json").write_text("{}") hook = "hooks/pre-push" write_pre_push_hook( baseline_run_id="run-1", suite_path="evals/locked.json", hook_path=hook, ) with pytest.raises(ValueError, match="already exists"): write_pre_push_hook( baseline_run_id="run-2", suite_path="evals/locked.json", hook_path=hook, ) def test_force_overwrites(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "evals").mkdir() (tmp_path / "evals" / "locked.json").write_text("{}") hook = "hooks/pre-push" write_pre_push_hook( baseline_run_id="run-1", suite_path="evals/locked.json", hook_path=hook, ) write_pre_push_hook( baseline_run_id="run-2", suite_path="evals/locked.json", hook_path=hook, overwrite=True, ) body = Path(hook).read_text() assert "run-2" in body @POSIX_ONLY def test_symlink_target_rejected(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "evals").mkdir() (tmp_path / "evals" / "locked.json").write_text("{}") target = tmp_path / "real-file" target.write_text("") link = tmp_path / "hook-link" link.symlink_to(target) with pytest.raises(ValueError, match="symlink"): write_pre_push_hook( baseline_run_id="run-1", suite_path="evals/locked.json", hook_path="hook-link", overwrite=True, ) def test_hook_path_outside_cwd_rejected(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "evals").mkdir() (tmp_path / "evals" / "locked.json").write_text("{}") with pytest.raises(ValueError): write_pre_push_hook( baseline_run_id="run-1", suite_path="evals/locked.json", hook_path="/tmp/escape", ) def test_hook_path_bool_rejected(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "evals").mkdir() (tmp_path / "evals" / "locked.json").write_text("{}") with pytest.raises(TypeError): write_pre_push_hook( baseline_run_id="run-1", suite_path="evals/locked.json", hook_path=True, # type: ignore[arg-type] ) # --------------------------------------------------------------------------- # Registry integration — eval_suite + canaries are valid artifact kinds. # --------------------------------------------------------------------------- class TestRegistryArtifactKinds: def test_eval_suite_in_valid_kinds(self): from soup_cli.registry.store import _VALID_KINDS assert "eval_suite" in _VALID_KINDS assert "canaries" in _VALID_KINDS # --------------------------------------------------------------------------- # CLI smoke # --------------------------------------------------------------------------- class TestCLIPlumbing: def setup_method(self): self.runner = CliRunner() def test_eval_help_lists_new_commands(self): from soup_cli.commands.eval import app result = self.runner.invoke(app, ["--help"]) assert result.exit_code == 0, result.output for cmd in ["design", "discover", "lock", "coverage", "gate-install"]: assert cmd in result.output, f"missing {cmd!r} in --help" def test_eval_design_help(self): from soup_cli.commands.eval import app result = self.runner.invoke(app, ["design", "--help"]) assert result.exit_code == 0, result.output assert "--goal" in _strip_ansi(result.output) def test_eval_design_end_to_end(self, tmp_path, monkeypatch): from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) data = tmp_path / "data.jsonl" data.write_text( '{"messages":[{"role":"user","content":"q"},' '{"role":"assistant","content":"sql query database"}]}\n' ) result = self.runner.invoke( app, ["design", "data.jsonl", "--goal", "better at SQL"], ) assert result.exit_code == 0, (result.output, repr(result.exception)) assert os.path.isfile("evals/design.json") def test_eval_design_missing_data_exits_nonzero( self, tmp_path, monkeypatch, ): from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) result = self.runner.invoke( app, ["design", "nope.jsonl", "--goal", "x"], ) assert result.exit_code != 0 def test_eval_discover_end_to_end(self, tmp_path, monkeypatch): from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) data = tmp_path / "d.jsonl" data.write_text( '{"prompt":"a","output":"x"}\n' '{"prompt":"b","output":"y"}\n' ) result = self.runner.invoke( app, ["discover", "d.jsonl", "--num-clusters", "2"], ) assert result.exit_code == 0, (result.output, repr(result.exception)) assert os.path.isfile("evals/canaries.json") def test_eval_lock_end_to_end(self, tmp_path, monkeypatch): from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) design = design_evals_from_data([{"output": "x"}], goal="y") (tmp_path / "evals").mkdir() write_eval_design(design, "evals/d.json") result = self.runner.invoke(app, ["lock", "evals/d.json"]) assert result.exit_code == 0, (result.output, repr(result.exception)) assert os.path.isfile("evals/locked.json") def test_eval_coverage_end_to_end(self, tmp_path, monkeypatch): from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) design = design_evals_from_data([{"output": "x"}], goal="y") (tmp_path / "evals").mkdir() write_eval_design(design, "evals/d.json") result = self.runner.invoke( app, ["coverage", "evals/d.json", "--task", "summarization"], ) assert result.exit_code == 0, (result.output, repr(result.exception)) def test_eval_coverage_bad_task_exits_nonzero(self, tmp_path, monkeypatch): from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) design = design_evals_from_data([{"output": "x"}], goal="y") (tmp_path / "evals").mkdir() write_eval_design(design, "evals/d.json") result = self.runner.invoke( app, ["coverage", "evals/d.json", "--task", "garbage"], ) assert result.exit_code == 2 def test_gate_install_end_to_end(self, tmp_path, monkeypatch): from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) (tmp_path / "evals").mkdir() (tmp_path / "evals" / "locked.json").write_text("{}") result = self.runner.invoke( app, [ "gate-install", "--baseline", "run-1", "--suite", "evals/locked.json", "--hook-path", "hooks/pre-push", ], ) assert result.exit_code == 0, (result.output, repr(result.exception)) assert os.path.isfile("hooks/pre-push") # --------------------------------------------------------------------------- # Source-grep regression guards. # --------------------------------------------------------------------------- class TestSourceGrep: REPO_ROOT = Path(__file__).resolve().parent.parent def test_eval_py_registers_v0550_module(self): text = (self.REPO_ROOT / "src" / "soup_cli" / "commands" / "eval.py").read_text( encoding="utf-8", ) assert "_eval_v0550" in text assert "_register_v0550(app, console)" in text def test_v0550_module_uses_lazy_imports(self): # The Typer registration module itself must not eagerly import # heavy deps. Lazy imports happen inside each command body. text = (self.REPO_ROOT / "src" / "soup_cli" / "commands" / "_eval_v0550.py").read_text( encoding="utf-8", ) # Top-level imports allowed: typer, rich.*. No torch/transformers/peft. for forbidden in ("import torch", "import transformers", "import peft"): assert forbidden not in text.splitlines()[:15], ( f"top-level {forbidden} would slow CLI startup" ) def test_version_bumped_to_0_55_0(self): # v0.55.0+ floor — released versions are always >= 0.55.0. import re init_text = ( self.REPO_ROOT / "src" / "soup_cli" / "__init__.py" ).read_text(encoding="utf-8") match = re.search(r'__version__ = "(\d+)\.(\d+)\.(\d+)"', init_text) assert match is not None, "version line not found" major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) assert (major, minor) >= (0, 55), ( f"version must be >= 0.55.0 (found {major}.{minor}.{patch})" ) # --------------------------------------------------------------------------- # `soup eval against` — run-vs-run regression check (Part D) # --------------------------------------------------------------------------- class TestEvalAgainst: def setup_method(self): self.runner = CliRunner() def test_against_listed_in_help(self): from soup_cli.commands.eval import app result = self.runner.invoke(app, ["--help"]) assert result.exit_code == 0, (result.output, repr(result.exception)) assert "against" in result.output, "missing 'against' in --help" def test_against_help(self): from soup_cli.commands.eval import app result = self.runner.invoke(app, ["against", "--help"]) assert result.exit_code == 0, (result.output, repr(result.exception)) out = _strip_ansi(result.output) assert "--candidate" in out assert "--metric" in out assert "--json-only" in out def test_against_requires_candidate(self): # `--candidate` is a required option; omitting must fail. from soup_cli.commands.eval import app result = self.runner.invoke(app, ["against", "run-baseline"]) assert result.exit_code != 0 assert "candidate" in result.output.lower() def test_against_unknown_metric_rejected(self, monkeypatch, tmp_path): from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) result = self.runner.invoke( app, [ "against", "run-baseline", "--candidate", "run-cand", "--metric", "made_up", ], ) assert result.exit_code != 0 # The deferred-tracker path will fire first (AttributeError → exit 2) # OR the metric validator fires (exit 1). Either is acceptable — what # we care about is that an invalid metric doesn't silently succeed. def test_against_deferred_tracker_advisory(self, monkeypatch, tmp_path): # The tracker doesn't yet expose `get_metric_series` — the command # must catch the AttributeError and emit a v0.55.1-deferred advisory. from soup_cli.commands.eval import app monkeypatch.chdir(tmp_path) result = self.runner.invoke( app, [ "against", "run-baseline", "--candidate", "run-cand", ], ) # Either the deferred-advisory (exit 2) or the empty-series failure # (exit 1) — both are acceptable absence-of-data signals. What we # assert is that we do NOT exit 0 (false-pass) and that the message # is informative. assert result.exit_code != 0 class TestHookTemplate: """v0.55.0 hook template references SOUP_CANDIDATE_RUN_ID env var.""" def test_template_calls_soup_eval_against(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "evals").mkdir() (tmp_path / "evals" / "locked.json").write_text("{}") from soup_cli.utils.eval_gate_hook import render_pre_push_hook body = render_pre_push_hook( baseline_run_id="run-1", suite_path="evals/locked.json", ) # Must call the new subcommand, NOT a stale --against flag. assert "soup eval against" in body # Must reference the env var so users can wire it from their CI. assert "SOUP_CANDIDATE_RUN_ID" in body # Hook is bash-strict. assert "set -euo pipefail" in body