diff --git a/examples/cross_judge_example.py b/examples/cross_judge_example.py new file mode 100644 index 0000000..7a5e48a --- /dev/null +++ b/examples/cross_judge_example.py @@ -0,0 +1,105 @@ +""" +Cross-judge stability analysis example. + +Demonstrates CrossJudgeExperiment: running the same subject model through two +judge versions to measure whether the judge upgrade shifts severity ratings. + +Motivated by the finding that judge-model version can materially shift modal +severity on identical subject responses — including safety-relevant scenarios — +without any change to the subject model itself. + +Usage +----- + python examples/cross_judge_example.py + +Set ANTHROPIC_API_KEY before running. Results are cached under +`cross_judge_results/` so the run is resumable if interrupted. +""" + +import asyncio +import os + +from simpleaudit import CrossJudgeExperiment, compare_judges + + +SUBJECT_MODELS = [ + { + "model": "claude-haiku-4-5-20251001", + "provider": "anthropic", + "label": "haiku-4.5", + }, +] + +JUDGE_MODELS = [ + {"model": "claude-opus-4-7", "provider": "anthropic"}, + {"model": "claude-opus-4-8", "provider": "anthropic"}, +] + + +async def main() -> None: + if not os.environ.get("ANTHROPIC_API_KEY"): + raise EnvironmentError("ANTHROPIC_API_KEY is not set.") + + exp = CrossJudgeExperiment( + models=SUBJECT_MODELS, + judge_models=JUDGE_MODELS, + n_repetitions=3, + save_dir="cross_judge_results", + show_progress=True, + ) + + print(f"Judges: {exp.judge_labels}") + print("Running cross-judge experiment (results cached under cross_judge_results/)...\n") + + results = await exp.run_async( + scenarios="nav_aap", + language="Norwegian", + max_turns=3, + ) + + # --- Score summary --------------------------------------------------------- + print("=== Score summary (mean across 3 runs) ===") + summary = results.score_summary() + for subject, per_judge in summary.items(): + print(f"\n Subject: {subject}") + for judge_label, stats in per_judge.items(): + print( + f" {judge_label:20s} mean={stats['mean']:.1f} " + f"std={stats['std']:.2f} cv={stats['cv']:.1%} " + f"n={stats['n_runs']}" + ) + + # --- Severity shifts ------------------------------------------------------- + print("\n=== Cross-judge severity shifts (haiku-4.5 / nav_aap) ===") + shifts = results.severity_shifts("haiku-4.5") + shifted = [s for s in shifts if s["shifted"]] + print(f" {len(shifted)} of {len(shifts)} scenarios shifted modal severity.\n") + for entry in shifted: + direction = entry.get("direction", 0) + arrow = "→ stricter" if direction > 0 else "→ more lenient" + judges = list(entry["modals"].keys()) + modal_a = entry["modals"][judges[0]] + modal_b = entry["modals"][judges[1]] + print( + f" {entry['scenario']:<45s} " + f"{judges[0]}: {modal_a:<8s} {judges[1]}: {modal_b:<8s} {arrow}" + ) + + # --- compare_judges utility (post-hoc comparison) ------------------------- + print("\n=== compare_judges utility ===") + cmp = compare_judges( + results["opus-4-7"], + results["opus-4-8"], + subject_label="haiku-4.5", + label_a="opus-4-7", + label_b="opus-4-8", + ) + print( + f" score_delta (4.8 − 4.7): {cmp['score_delta']:+.2f} " + f"cv_delta: {cmp['cv_delta']:+.4f} " + f"n_shifted: {cmp['n_shifted']}/{cmp['n_total']}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/simpleaudit/__init__.py b/simpleaudit/__init__.py index f9c3f5b..405d834 100644 --- a/simpleaudit/__init__.py +++ b/simpleaudit/__init__.py @@ -29,6 +29,7 @@ from .judges import get_judge, list_judge_configs from .experiment import AuditExperiment from .repeated_results import RepeatedExperimentResults, ModelStabilityReport +from .cross_judge import CrossJudgeExperiment, CrossJudgeResults, compare_judges __all__ = [ "ModelAuditor", @@ -41,5 +42,8 @@ "AuditExperiment", "RepeatedExperimentResults", "ModelStabilityReport", + "CrossJudgeExperiment", + "CrossJudgeResults", + "compare_judges", ] diff --git a/simpleaudit/cross_judge.py b/simpleaudit/cross_judge.py new file mode 100644 index 0000000..d801f1d --- /dev/null +++ b/simpleaudit/cross_judge.py @@ -0,0 +1,430 @@ +""" +Cross-judge stability analysis for SimpleAudit. + +Provides CrossJudgeExperiment, which orchestrates identical AuditExperiment +runs under multiple judge models to measure how judge version affects severity +ratings and score distributions. + +Motivated by empirical findings that judge model version can materially shift +modal severity on identical subject responses — including safety-relevant +scenarios — without any change in the subject model itself. +""" + +import asyncio +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +from simpleaudit.experiment import AuditExperiment +from simpleaudit.repeated_results import RepeatedExperimentResults + + +# Canonical ordering from least to most severe (used for computing shift direction). +_SEVERITY_ORDER = ["pass", "low", "medium", "high", "critical"] + + +# --------------------------------------------------------------------------- +# CrossJudgeResults +# --------------------------------------------------------------------------- + +class CrossJudgeResults: + """Results from CrossJudgeExperiment — one RepeatedExperimentResults per judge. + + Provides cross-judge severity shift detection, score deltas, and CV deltas + per subject model. Results from each judge are kept independent; comparison + is computed on demand. + + Attributes + ---------- + judges : list of str + Ordered list of judge labels present in this result set. + """ + + def __init__(self, results_by_judge: Dict[str, RepeatedExperimentResults]) -> None: + if not results_by_judge: + raise ValueError("results_by_judge must contain at least one entry.") + self._results: Dict[str, RepeatedExperimentResults] = results_by_judge + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + @property + def judges(self) -> List[str]: + """Ordered list of judge labels.""" + return list(self._results.keys()) + + def __getitem__(self, judge_label: str) -> RepeatedExperimentResults: + return self._results[judge_label] + + def __contains__(self, judge_label: object) -> bool: + return judge_label in self._results + + def score_summary(self) -> Dict[str, Dict[str, Dict[str, Any]]]: + """Per-subject, per-judge score statistics. + + Returns + ------- + dict + ``{subject_label: {judge_label: {mean, std, cv, min, max, n_runs}}}`` + """ + summary: Dict[str, Dict[str, Dict[str, Any]]] = {} + for judge_label, rep_results in self._results.items(): + for subject_label in rep_results._runs: + report = rep_results.stability(subject_label) + summary.setdefault(subject_label, {})[judge_label] = { + "n_runs": report.n_runs, + "mean": round(report.mean_score, 2), + "std": round(report.std_score, 2), + "cv": round(report.cv, 2), + "min": round(report.min_score, 2), + "max": round(report.max_score, 2), + } + return summary + + def severity_shifts(self, subject_label: str) -> List[Dict[str, Any]]: + """Per-scenario modal severity across all judges, with shift detection. + + Parameters + ---------- + subject_label : str + The subject model label to compare across judges. + + Returns + ------- + list of dict + One entry per scenario containing: + - ``scenario``: scenario name + - ``modals``: ``{judge_label: modal_severity}`` + - ``shifted``: True if any two judges disagree on modal severity + - ``direction``: (two-judge case only) ``idx_b - idx_a`` in + ``_SEVERITY_ORDER``; positive means judge_b is stricter. + """ + judges = self.judges + per_judge: Dict[str, Dict[str, str]] = {} + scenario_names: Optional[List[str]] = None + + for judge_label in judges: + rep_results = self._results[judge_label] + report = rep_results.stability(subject_label) + per_judge[judge_label] = { + name: stats.most_common_severity + for name, stats in report.per_scenario.items() + } + if scenario_names is None: + scenario_names = list(report.per_scenario.keys()) + + if scenario_names is None: + return [] + + result = [] + for name in scenario_names: + modals = {j: per_judge[j].get(name, "pass") for j in judges} + shifted = len(set(modals.values())) > 1 + entry: Dict[str, Any] = {"scenario": name, "modals": modals, "shifted": shifted} + if shifted and len(judges) == 2: + a, b = judges + idx_a = _SEVERITY_ORDER.index(modals[a]) if modals[a] in _SEVERITY_ORDER else -1 + idx_b = _SEVERITY_ORDER.index(modals[b]) if modals[b] in _SEVERITY_ORDER else -1 + entry["direction"] = idx_b - idx_a + result.append(entry) + return result + + def to_dict(self) -> Dict[str, Any]: + """Serialize all results to a JSON-compatible dict.""" + return { + "judges": self.judges, + "score_summary": self.score_summary(), + "runs": { + judge_label: rep_results.to_dict() + for judge_label, rep_results in self._results.items() + }, + } + + +# --------------------------------------------------------------------------- +# CrossJudgeExperiment +# --------------------------------------------------------------------------- + +class CrossJudgeExperiment: + """Orchestrate AuditExperiment runs across multiple judge models. + + Runs one AuditExperiment per judge with save_dir namespaced by judge label + to prevent file collision. Reuses PR #20's resume logic: each judge + maintains its own run cache under ``save_dir//``. + + Parameters + ---------- + models : list of dict + Subject models to evaluate. Same format as ``AuditExperiment.models`` + — each dict must contain a ``model`` key and optionally ``provider``, + ``label``, and provider-specific credentials. + judge_models : list of dict + Judge models to compare. Each dict must contain a ``model`` key and + optionally ``provider``, ``label``, ``api_key``, and ``base_url``. + If ``label`` is omitted it is derived from the model string. + Must contain at least two entries. + auditor_models : list of dict or None, optional + Auditor (probe generator) configuration, one entry per judge in the + same order as ``judge_models``. If None, each judge serves as its own + auditor, mirroring ``AuditExperiment`` default behaviour. + n_repetitions : int, default 3 + Repetitions per (judge × subject) combination. Passed through to each + ``AuditExperiment``. + save_dir : str, Path, or None, optional + Parent directory for saved results. Each judge's runs land under + ``save_dir///run_N.json``. Pass the same + ``save_dir`` on a resumed call to continue interrupted runs. + **experiment_kwargs + Additional keyword arguments forwarded to every ``AuditExperiment`` + (e.g. ``probe_prompt``, ``judge_prompt``, ``json_format``, + ``show_progress``). + + Examples + -------- + >>> exp = CrossJudgeExperiment( + ... models=[{"model": "claude-haiku-4-5-20251001", "provider": "anthropic", + ... "label": "haiku-4.5"}], + ... judge_models=[ + ... {"model": "claude-opus-4-7", "provider": "anthropic"}, + ... {"model": "claude-opus-4-8", "provider": "anthropic"}, + ... ], + ... n_repetitions=3, + ... save_dir="/path/to/results", + ... ) + >>> results = await exp.run_async(scenarios="nav_aap", language="Norwegian") + >>> print(results.score_summary()) + """ + + def __init__( + self, + models: List[Dict[str, Any]], + judge_models: List[Dict[str, Any]], + auditor_models: Optional[List[Dict[str, Any]]] = None, + n_repetitions: int = 3, + save_dir: Optional[Union[str, Path]] = None, + **experiment_kwargs: Any, + ) -> None: + if not models or any("model" not in m for m in models): + raise ValueError("models must be a non-empty list of dicts each containing a 'model' key.") + if len(judge_models) < 2: + raise ValueError("judge_models must contain at least two entries.") + if auditor_models is not None and len(auditor_models) != len(judge_models): + raise ValueError( + "auditor_models must be None or have the same length as judge_models." + ) + if n_repetitions < 1: + raise ValueError("n_repetitions must be >= 1") + + self.models = models + self.judge_models = judge_models + self.auditor_models = auditor_models + self.n_repetitions = n_repetitions + self.save_dir = Path(save_dir) if save_dir else None + self._experiment_kwargs = experiment_kwargs + + # Build one AuditExperiment per judge, namespacing save_dir by label. + self._experiments: Dict[str, AuditExperiment] = {} + for i, judge_info in enumerate(judge_models): + label = self._safe_judge_label(judge_info) + if label in self._experiments: + raise ValueError( + f"Duplicate judge label {label!r}. Add a 'label' key to distinguish " + "judge models that share the same derived label." + ) + judge_save_dir: Optional[str] = str(self.save_dir / label) if self.save_dir else None + auditor_info: Optional[Dict[str, Any]] = ( + auditor_models[i] if auditor_models is not None else None + ) + self._experiments[label] = AuditExperiment( + models=models, + judge_model=judge_info["model"], + judge_provider=judge_info.get("provider"), + judge_api_key=judge_info.get("api_key"), + judge_base_url=judge_info.get("base_url"), + auditor_model=auditor_info["model"] if auditor_info else None, + auditor_provider=auditor_info.get("provider") if auditor_info else None, + n_repetitions=n_repetitions, + save_dir=judge_save_dir, + **experiment_kwargs, + ) + + @property + def judge_labels(self) -> List[str]: + """Labels of all configured judges, in order.""" + return list(self._experiments.keys()) + + @staticmethod + def _safe_judge_label(judge_info: Dict[str, Any]) -> str: + """Derive a filesystem-safe label from a judge info dict. + + Uses the ``label`` key if present. Otherwise strips the provider + prefix (e.g. ``anthropic:``) and the ``claude-`` model-family prefix, + then sanitises the result for use as a directory name. + + Examples + -------- + ``{"model": "claude-opus-4-8", "provider": "anthropic"}`` → ``"opus-4-8"`` + ``{"model": "claude-opus-4-7", "label": "opus47"}`` → ``"opus47"`` + ``{"model": "gpt-4o", "provider": "openai"}`` → ``"gpt-4o"`` + """ + if "label" in judge_info: + return str(judge_info["label"]) + model: str = judge_info["model"] + if ":" in model: + model = model.split(":", 1)[1] + if model.startswith("claude-"): + model = model[len("claude-"):] + return model.replace("/", "_").replace(":", "_").replace(" ", "_") + + async def run_async( + self, + scenarios: Union[str, List[Dict[str, Any]]], + max_turns: Optional[int] = None, + language: str = "English", + max_workers: int = 1, + ) -> CrossJudgeResults: + """Execute all judge variants and return combined results. + + Judges are run sequentially. Each judge's underlying ``AuditExperiment`` + loads any cached runs from disk before issuing new API calls, so + interrupted runs can be resumed by re-calling this method with the + same ``save_dir``. + + Parameters + ---------- + scenarios : str or list of dict + Scenario pack name (e.g. ``"nav_aap"``) or an inline list of + scenario dicts. Passed unchanged to each ``AuditExperiment``. + max_turns : int or None, optional + Maximum conversation turns per scenario. + language : str, default "English" + Probe language forwarded to the auditor. + max_workers : int, default 1 + Concurrency within each ``AuditExperiment`` run. + + Returns + ------- + CrossJudgeResults + """ + results_by_judge: Dict[str, RepeatedExperimentResults] = {} + for judge_label, exp in self._experiments.items(): + results_by_judge[judge_label] = await exp.run_async( + scenarios=scenarios, + max_turns=max_turns, + language=language, + max_workers=max_workers, + ) + return CrossJudgeResults(results_by_judge) + + def run( + self, + scenarios: Union[str, List[Dict[str, Any]]], + max_turns: Optional[int] = None, + language: str = "English", + max_workers: int = 1, + ) -> CrossJudgeResults: + """Synchronous wrapper around run_async. + + Cannot be called from an active event loop; use + ``await run_async()`` from async contexts. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run( + self.run_async( + scenarios=scenarios, + max_turns=max_turns, + language=language, + max_workers=max_workers, + ) + ) + msg = ( + "CrossJudgeExperiment.run() cannot be called from an active event loop. " + "Use await run_async() instead." + ) + raise RuntimeError(msg) + + +# --------------------------------------------------------------------------- +# compare_judges utility +# --------------------------------------------------------------------------- + +def compare_judges( + results_a: RepeatedExperimentResults, + results_b: RepeatedExperimentResults, + subject_label: str, + label_a: str = "judge_a", + label_b: str = "judge_b", +) -> Dict[str, Any]: + """Compare two RepeatedExperimentResults for a given subject model. + + Computes per-scenario modal severity shifts, score delta, and CV delta. + Useful for post-hoc comparison of separately completed experiments + without re-running CrossJudgeExperiment. + + Args: + results_a: First judge's repeated results. + results_b: Second judge's repeated results. + subject_label: Model label to compare. Must exist in both results. + label_a: Display name for the first judge (default ``"judge_a"``). + label_b: Display name for the second judge (default ``"judge_b"``). + + Returns: + dict with keys: + + - ``subject``: subject_label + - ``judge_a``, ``judge_b``: the label strings + - ``score_delta``: mean_b − mean_a (positive = judge_b scores higher) + - ``cv_delta``: cv_b − cv_a (positive = judge_b is more variable) + - ``stats_a``, ``stats_b``: ``{mean, std, cv, min, max, n_runs}`` + - ``severity_shifts``: list of dicts for scenarios where modal + severity differs, each with ``scenario``, ``modal_a``, ``modal_b``, + and ``direction`` (positive = stricter under judge_b). + - ``n_shifted``: count of shifted scenarios + - ``n_total``: total scenario count + + Raises: + KeyError: If subject_label is not found in either results object. + """ + report_a = results_a.stability(subject_label) + report_b = results_b.stability(subject_label) + + def _stats(report: Any) -> Dict[str, Any]: + return { + "n_runs": report.n_runs, + "mean": round(report.mean_score, 2), + "std": round(report.std_score, 2), + "cv": round(report.cv, 2), + "min": round(report.min_score, 2), + "max": round(report.max_score, 2), + } + + shifts = [] + for name, stats_a in report_a.per_scenario.items(): + if name not in report_b.per_scenario: + continue + modal_a = stats_a.most_common_severity + modal_b = report_b.per_scenario[name].most_common_severity + if modal_a != modal_b: + idx_a = _SEVERITY_ORDER.index(modal_a) if modal_a in _SEVERITY_ORDER else -1 + idx_b = _SEVERITY_ORDER.index(modal_b) if modal_b in _SEVERITY_ORDER else -1 + shifts.append({ + "scenario": name, + "modal_a": modal_a, + "modal_b": modal_b, + "direction": idx_b - idx_a, + }) + + return { + "subject": subject_label, + "judge_a": label_a, + "judge_b": label_b, + "score_delta": round(report_b.mean_score - report_a.mean_score, 2), + "cv_delta": round(report_b.cv - report_a.cv, 2), + "stats_a": _stats(report_a), + "stats_b": _stats(report_b), + "severity_shifts": shifts, + "n_shifted": len(shifts), + "n_total": len(report_a.per_scenario), + } diff --git a/tests/test_cross_judge.py b/tests/test_cross_judge.py new file mode 100644 index 0000000..181b179 --- /dev/null +++ b/tests/test_cross_judge.py @@ -0,0 +1,425 @@ +""" +Tests for CrossJudgeExperiment and compare_judges utility. + +Covers: +- Separate save_dir per judge (collision prevention) +- n_repetitions respected per judge +- Resume/caching works independently per judge +- Severity shifts detected across judges +- compare_judges utility function +- _safe_judge_label derivation +- Input validation +""" + +import asyncio +import json +from unittest.mock import MagicMock, patch + +import pytest + +from simpleaudit.cross_judge import CrossJudgeExperiment, CrossJudgeResults, compare_judges +from simpleaudit.model_auditor import ModelAuditor +from simpleaudit.repeated_results import RepeatedExperimentResults +from simpleaudit.results import AuditResult, AuditResults + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +SCENARIOS = [ + {"name": "s1", "description": "d1"}, + {"name": "s2", "description": "d2"}, +] + +JUDGE_A = "judge-a" +JUDGE_B = "judge-b" + + +def _make_results(severity: str = "pass") -> AuditResults: + """Build AuditResults with two scenarios, both at the given severity.""" + return AuditResults([ + AuditResult( + scenario_name=f"scenario_{i}", + scenario_description="desc", + conversation=[], + severity=severity, + issues_found=[], + positive_behaviors=[], + summary="", + recommendations=[], + ) + for i in range(2) + ]) + + +def _make_results_named(scenario_names: list, severity: str = "pass") -> AuditResults: + """Build AuditResults with explicitly specified scenario names.""" + return AuditResults([ + AuditResult( + scenario_name=name, + scenario_description="desc", + conversation=[], + severity=severity, + issues_found=[], + positive_behaviors=[], + summary="", + recommendations=[], + ) + for name in scenario_names + ]) + + +def _make_experiment(save_dir: str, n_repetitions: int = 1) -> CrossJudgeExperiment: + return CrossJudgeExperiment( + models=[{"model": "m1", "provider": "openai"}], + judge_models=[ + {"model": JUDGE_A, "provider": "openai"}, + {"model": JUDGE_B, "provider": "openai"}, + ], + n_repetitions=n_repetitions, + save_dir=save_dir, + show_progress=False, + ) + + +def _run_experiment( + exp: CrossJudgeExperiment, + severity_for_judge: dict = None, +) -> CrossJudgeResults: + """Execute exp.run_async() with patched ModelAuditor — no real API calls. + + Each judge receives the severity specified in severity_for_judge keyed by + judge_model string; defaults to "pass" for any unlisted judge. + """ + sev_map = severity_for_judge or {} + + async def fake_run_async(self_a, scenarios, **kwargs): + return _make_results(sev_map.get(self_a.judge_model, "pass")) + + with patch.object(ModelAuditor, "_create_anyllm_client", return_value=MagicMock()), \ + patch.object(ModelAuditor, "run_async", new=fake_run_async): + return asyncio.run(exp.run_async(scenarios=SCENARIOS)) + + +# --------------------------------------------------------------------------- +# 1. Save dir separation +# --------------------------------------------------------------------------- + +class TestSaveDirSeparation: + def test_two_judges_get_separate_save_dirs(self, tmp_path): + """Each judge's runs land under save_dir//.""" + exp = _make_experiment(str(tmp_path), n_repetitions=1) + _run_experiment(exp) + + assert (tmp_path / "judge-a" / "m1" / "run_0.json").exists() + assert (tmp_path / "judge-b" / "m1" / "run_0.json").exists() + + def test_judge_a_does_not_overwrite_judge_b(self, tmp_path): + """Different judges producing different severities preserve both files.""" + exp = _make_experiment(str(tmp_path), n_repetitions=1) + _run_experiment(exp, severity_for_judge={JUDGE_A: "high", JUDGE_B: "pass"}) + + a_result = AuditResults.load(str(tmp_path / "judge-a" / "m1" / "run_0.json")) + b_result = AuditResults.load(str(tmp_path / "judge-b" / "m1" / "run_0.json")) + assert a_result[0].severity == "high" + assert b_result[0].severity == "pass" + + +# --------------------------------------------------------------------------- +# 2. n_repetitions per judge +# --------------------------------------------------------------------------- + +class TestNRepetitionsPerJudge: + def test_run_files_created_for_all_reps_and_judges(self, tmp_path): + exp = _make_experiment(str(tmp_path), n_repetitions=3) + _run_experiment(exp) + + for judge in ("judge-a", "judge-b"): + for i in range(3): + assert (tmp_path / judge / "m1" / f"run_{i}.json").exists(), \ + f"Missing {judge}/m1/run_{i}.json" + + def test_results_contain_n_runs_per_judge(self, tmp_path): + exp = _make_experiment(str(tmp_path), n_repetitions=3) + results = _run_experiment(exp) + + for judge_label in results.judges: + assert len(results[judge_label]._runs["m1"]) == 3 + + +# --------------------------------------------------------------------------- +# 3. Resume works independently per judge +# --------------------------------------------------------------------------- + +class TestResumePerJudge: + def test_cached_runs_skipped_for_partial_judge(self, tmp_path): + """Pre-cache 2 runs for judge-a; verify 1 live call for judge-a, 3 for judge-b.""" + run_dir = tmp_path / "judge-a" / "m1" + run_dir.mkdir(parents=True) + _make_results("medium").save(str(run_dir / "run_0.json")) + _make_results("medium").save(str(run_dir / "run_1.json")) + + call_counts: dict = {} + + async def fake_run_async(self_a, scenarios, **kwargs): + call_counts[self_a.judge_model] = call_counts.get(self_a.judge_model, 0) + 1 + return _make_results("low") + + exp = _make_experiment(str(tmp_path), n_repetitions=3) + with patch.object(ModelAuditor, "_create_anyllm_client", return_value=MagicMock()), \ + patch.object(ModelAuditor, "run_async", new=fake_run_async): + asyncio.run(exp.run_async(scenarios=SCENARIOS)) + + # judge-a had 2 cached → only run_2 is live + assert call_counts.get(JUDGE_A, 0) == 1 + # judge-b had nothing cached → all 3 runs are live + assert call_counts.get(JUDGE_B, 0) == 3 + + def test_resumed_results_preserve_cached_severity(self, tmp_path): + """run_0 pre-saved as 'critical' should survive the resume.""" + run_dir = tmp_path / "judge-a" / "m1" + run_dir.mkdir(parents=True) + _make_results("critical").save(str(run_dir / "run_0.json")) + + exp = _make_experiment(str(tmp_path), n_repetitions=2) + results = _run_experiment(exp, severity_for_judge={JUDGE_A: "pass", JUDGE_B: "pass"}) + + runs_a = results["judge-a"]._runs["m1"] + assert runs_a[0][0].severity == "critical" # from cache + assert runs_a[1][0].severity == "pass" # from live call + + +# --------------------------------------------------------------------------- +# 4. Severity shifts detected +# --------------------------------------------------------------------------- + +class TestSeverityShiftsDetected: + def test_shifted_scenarios_identified(self, tmp_path): + """high under judge-a vs pass under judge-b → both scenarios shift.""" + exp = _make_experiment(str(tmp_path), n_repetitions=1) + results = _run_experiment( + exp, + severity_for_judge={JUDGE_A: "high", JUDGE_B: "pass"}, + ) + + shifts = results.severity_shifts("m1") + shifted = [s for s in shifts if s["shifted"]] + assert len(shifted) == 2 + + def test_shift_direction_negative_when_more_lenient(self, tmp_path): + """high → pass: judge_b is more lenient, so direction < 0.""" + exp = _make_experiment(str(tmp_path), n_repetitions=1) + results = _run_experiment( + exp, + severity_for_judge={JUDGE_A: "high", JUDGE_B: "pass"}, + ) + + for entry in results.severity_shifts("m1"): + assert entry.get("direction", 0) < 0 + + def test_no_shifts_when_judges_agree(self, tmp_path): + exp = _make_experiment(str(tmp_path), n_repetitions=1) + results = _run_experiment( + exp, + severity_for_judge={JUDGE_A: "medium", JUDGE_B: "medium"}, + ) + + assert all(not s["shifted"] for s in results.severity_shifts("m1")) + + +# --------------------------------------------------------------------------- +# 5. compare_judges utility +# --------------------------------------------------------------------------- + +class TestCompareJudgesUtility: + def test_score_delta_high_to_pass(self): + """all-high (score 25) vs all-pass (score 100) → delta = 75.""" + runs_a = RepeatedExperimentResults({"m1": [_make_results("high")] * 3}) + runs_b = RepeatedExperimentResults({"m1": [_make_results("pass")] * 3}) + + result = compare_judges(runs_a, runs_b, "m1", "opus-4-7", "opus-4-8") + + assert result["score_delta"] == 75.0 + assert result["n_shifted"] == 2 + assert result["n_total"] == 2 + + def test_direction_positive_when_stricter(self): + """pass → high: judge_b is stricter, direction > 0.""" + runs_a = RepeatedExperimentResults({"m1": [_make_results("pass")] * 3}) + runs_b = RepeatedExperimentResults({"m1": [_make_results("high")] * 3}) + + result = compare_judges(runs_a, runs_b, "m1") + + for shift in result["severity_shifts"]: + assert shift["direction"] > 0 + + def test_no_shifts_when_identical(self): + runs = RepeatedExperimentResults({"m1": [_make_results("medium")] * 3}) + result = compare_judges(runs, runs, "m1") + + assert result["n_shifted"] == 0 + assert result["score_delta"] == 0.0 + + def test_labels_preserved_in_output(self): + runs = RepeatedExperimentResults({"m1": [_make_results("pass")] * 3}) + result = compare_judges(runs, runs, "m1", label_a="opus-4-7", label_b="opus-4-8") + + assert result["judge_a"] == "opus-4-7" + assert result["judge_b"] == "opus-4-8" + + def test_missing_subject_raises_key_error(self): + runs = RepeatedExperimentResults({"m1": [_make_results("pass")] * 3}) + with pytest.raises(KeyError): + compare_judges(runs, runs, "nonexistent-model") + + def test_compare_judges_handles_mismatched_scenarios(self): + """Scenarios present only in results_a are skipped gracefully.""" + runs_a = RepeatedExperimentResults({ + "m1": [_make_results_named(["scenario_0", "scenario_extra"], "high")] * 3, + }) + runs_b = RepeatedExperimentResults({ + "m1": [_make_results_named(["scenario_0"], "pass")] * 3, + }) + + result = compare_judges(runs_a, runs_b, "m1") + + # scenario_extra is absent from results_b → skipped, no crash + shift_names = [s["scenario"] for s in result["severity_shifts"]] + assert "scenario_extra" not in shift_names + # scenario_0 shifts high → pass, direction < 0 + assert result["n_shifted"] == 1 + assert result["n_total"] == 2 # report_a.per_scenario has 2 entries + + +# --------------------------------------------------------------------------- +# 6. _safe_judge_label derivation +# --------------------------------------------------------------------------- + +class TestSafeJudgeLabel: + def test_label_key_takes_precedence(self): + info = {"model": "claude-opus-4-8", "provider": "anthropic", "label": "opus48"} + assert CrossJudgeExperiment._safe_judge_label(info) == "opus48" + + def test_strips_provider_prefix(self): + info = {"model": "anthropic:claude-opus-4-8"} + assert CrossJudgeExperiment._safe_judge_label(info) == "opus-4-8" + + def test_strips_claude_prefix(self): + info = {"model": "claude-opus-4-7"} + assert CrossJudgeExperiment._safe_judge_label(info) == "opus-4-7" + + def test_non_anthropic_model_unchanged(self): + info = {"model": "gpt-4o", "provider": "openai"} + assert CrossJudgeExperiment._safe_judge_label(info) == "gpt-4o" + + +# --------------------------------------------------------------------------- +# 7. Input validation +# --------------------------------------------------------------------------- + +class TestValidation: + def test_fewer_than_two_judges_raises(self): + with pytest.raises(ValueError, match="at least two"): + CrossJudgeExperiment( + models=[{"model": "m1", "provider": "openai"}], + judge_models=[{"model": "j1", "provider": "openai"}], + ) + + def test_empty_models_raises(self): + with pytest.raises(ValueError, match="non-empty"): + CrossJudgeExperiment( + models=[], + judge_models=[ + {"model": "j1", "provider": "openai"}, + {"model": "j2", "provider": "openai"}, + ], + ) + + def test_duplicate_judge_labels_raises(self): + # Two dicts that derive the same label ("opus-4-7") should be rejected. + with pytest.raises(ValueError, match="Duplicate judge label"): + CrossJudgeExperiment( + models=[{"model": "m1", "provider": "openai"}], + judge_models=[ + {"model": "claude-opus-4-7", "provider": "anthropic"}, + {"model": "claude-opus-4-7", "provider": "anthropic"}, + ], + show_progress=False, + ) + + def test_mismatched_auditor_models_raises(self): + with pytest.raises(ValueError, match="same length"): + CrossJudgeExperiment( + models=[{"model": "m1", "provider": "openai"}], + judge_models=[ + {"model": "j1", "provider": "openai"}, + {"model": "j2", "provider": "openai"}, + ], + auditor_models=[{"model": "a1", "provider": "openai"}], + ) + + def test_n_repetitions_zero_raises(self): + with pytest.raises(ValueError, match="n_repetitions"): + CrossJudgeExperiment( + models=[{"model": "m1", "provider": "openai"}], + judge_models=[ + {"model": "j1", "provider": "openai"}, + {"model": "j2", "provider": "openai"}, + ], + n_repetitions=0, + ) + + +# --------------------------------------------------------------------------- +# 8. CrossJudgeResults.to_dict serialization +# --------------------------------------------------------------------------- + +class TestCrossJudgeResultsToDict: + def test_to_dict_structure_and_json_roundtrip(self): + """to_dict returns expected top-level keys and is JSON-serializable.""" + runs_a = RepeatedExperimentResults({"m1": [_make_results("high")] * 3}) + runs_b = RepeatedExperimentResults({"m1": [_make_results("pass")] * 3}) + cjr = CrossJudgeResults({"judge-a": runs_a, "judge-b": runs_b}) + + d = cjr.to_dict() + + # Top-level structure + assert d["judges"] == ["judge-a", "judge-b"] + assert "score_summary" in d + assert "runs" in d + + # score_summary nested structure: subject → judge → stats + assert "m1" in d["score_summary"] + assert "judge-a" in d["score_summary"]["m1"] + stats = d["score_summary"]["m1"]["judge-a"] + assert "mean" in stats + assert "n_runs" in stats + assert stats["n_runs"] == 3 + + # Full JSON roundtrip preserves judges list and nested stats + roundtripped = json.loads(json.dumps(d)) + assert roundtripped["judges"] == ["judge-a", "judge-b"] + assert roundtripped["score_summary"]["m1"]["judge-b"]["n_runs"] == 3 + + +# --------------------------------------------------------------------------- +# 9. CrossJudgeExperiment.run() sync wrapper +# --------------------------------------------------------------------------- + +class TestSyncWrapper: + def test_run_sync_wrapper_executes(self, tmp_path): + """run() synchronous wrapper returns CrossJudgeResults outside an event loop.""" + exp = _make_experiment(str(tmp_path), n_repetitions=1) + + async def fake_run_async(self_a, scenarios, **kwargs): + return _make_results("pass") + + with patch.object(ModelAuditor, "_create_anyllm_client", return_value=MagicMock()), \ + patch.object(ModelAuditor, "run_async", new=fake_run_async): + results = exp.run(scenarios=SCENARIOS) + + assert isinstance(results, CrossJudgeResults) + assert set(results.judges) == {JUDGE_A, JUDGE_B} + # judge_labels property mirrors judges list + assert exp.judge_labels == list(results.judges)