modelpin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modelpin/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Modelpin — Dependabot for AI models. Know before the model breaks you."""
2
+
3
+ __version__ = "0.1.0"
modelpin/cli.py ADDED
@@ -0,0 +1,336 @@
1
+ """Modelpin CLI (Typer). Wires the pipeline end-to-end:
2
+
3
+ mp scan -> detector
4
+ mp init -> scaffold modelpin.yaml + scenarios/
5
+ mp baseline -> replay current model N times, persist traces
6
+ mp check -> replay a new model, diff vs baseline, print the PR-style report
7
+
8
+ Replays use the END USER's API key from the environment (BYO-key, spec section 9);
9
+ `--provider fake --fixtures <file>` runs the whole flow offline for demos/tests.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import NoReturn, Optional
16
+
17
+ import typer
18
+ from rich.box import ASCII as ASCII_BOX
19
+ from rich.console import Console
20
+ from rich.table import Table
21
+
22
+ from modelpin import __version__
23
+ from modelpin.config import DEFAULT_PROVIDER, ConfigError, ModelpinConfig, load_config
24
+ from modelpin.detector import scan_repo
25
+ from modelpin.diff import diff_scenario
26
+ from modelpin.models import DiffVerdict, Scenario
27
+ from modelpin.providers import ProviderAdapter, ProviderError, get_adapter
28
+ from modelpin.providers.fake import FakeProvider
29
+ from modelpin.replay import replay
30
+ from modelpin.report import render_cli, render_pr_comment
31
+ from modelpin.scenarios import ScenarioError, load_scenarios
32
+ from modelpin.storage import STORE_DIRNAME, BaselineError, load_baseline, save_baseline
33
+
34
+ #: A behavioral diff compares run *distributions*; fewer than this can't form one.
35
+ MIN_RUNS = 2
36
+ #: Below this, the permutation test is underpowered — warn but proceed.
37
+ RECOMMENDED_RUNS = 5
38
+
39
+ app = typer.Typer(
40
+ help="Modelpin - Dependabot for AI models. Know before the model breaks you.",
41
+ no_args_is_help=True,
42
+ add_completion=False,
43
+ rich_markup_mode=None, # plain Click help: ASCII-only, never crashes on cp1252
44
+ )
45
+ console = Console()
46
+
47
+ _SAMPLE_SCENARIO = """{
48
+ "id": "greeting",
49
+ "name": "Simple greeting",
50
+ "kind": "single",
51
+ "input": {"messages": [{"role": "user", "content": "Say hello in one short sentence."}]},
52
+ "assertions": {"must_contain": ["hello"]}
53
+ }
54
+ """
55
+
56
+ _SAMPLE_CONFIG = """# modelpin.yaml - generated by `mp init`. See docs/ for the full reference.
57
+ models:
58
+ - gpt-4o-mini # the model your app currently depends on (set yours)
59
+ scenarios_dir: scenarios
60
+ providers:
61
+ - openai # uses YOUR OPENAI_API_KEY from the environment
62
+ runs: 5 # N replays per scenario (>=5 gives the diff real power)
63
+ judge_model: gpt-4o-mini # semantic LLM-judge (optional; extra calls). Remove to disable.
64
+ """
65
+
66
+
67
+ def _fail(message: str) -> NoReturn:
68
+ console.print(f"[red]error:[/] {message}")
69
+ raise typer.Exit(code=1)
70
+
71
+
72
+ def _adapter(provider: str, fixtures: Optional[str]) -> ProviderAdapter:
73
+ try:
74
+ if provider == "fake":
75
+ return FakeProvider.from_fixtures(fixtures) if fixtures else FakeProvider()
76
+ return get_adapter(provider)
77
+ except FileNotFoundError as exc:
78
+ _fail(f"fixtures file not found: {exc}")
79
+ except ValueError as exc: # unknown provider
80
+ _fail(str(exc))
81
+
82
+
83
+ def _unimplemented_msg(provider: str) -> str:
84
+ return (
85
+ f"the {provider!r} adapter isn't implemented yet. Try `--provider fake "
86
+ "--fixtures <file>` to run offline, or pick `--provider openai`."
87
+ )
88
+
89
+
90
+ def _preflight_or_fail(adapter: ProviderAdapter, provider: str) -> None:
91
+ """Fail before any tokens are spent if the provider isn't ready (key/SDK)."""
92
+ try:
93
+ adapter.preflight()
94
+ except ProviderError as exc:
95
+ _fail(str(exc))
96
+
97
+
98
+ def _guard_replay(provider: str, fn):
99
+ """Run a replay step, converting adapter failures into friendly CLI errors."""
100
+ try:
101
+ return fn()
102
+ except NotImplementedError:
103
+ _fail(_unimplemented_msg(provider))
104
+ except ProviderError as exc:
105
+ _fail(str(exc))
106
+
107
+
108
+ def _build_judge(provider: str, cfg: ModelpinConfig):
109
+ """Construct + preflight the semantic LLM-judge if configured. Returns None when no
110
+ judge_model is set or the run is offline (fake), so the diff stays purely structural."""
111
+ if not cfg.judge_model or provider == "fake":
112
+ return None
113
+ try:
114
+ from modelpin.judge import build_judge
115
+
116
+ # The judge is independent of the models being compared (the judge model id picks
117
+ # its provider), so a cross-vendor check (e.g. google vs openai) can still judge.
118
+ judge = build_judge(cfg.judge_model)
119
+ judge.preflight()
120
+ except (ProviderError, ImportError) as exc:
121
+ _fail(f"semantic judge ({cfg.judge_model!r}): {exc}")
122
+ console.print(f"[dim]semantic judge: {cfg.judge_model}[/]")
123
+ return judge
124
+
125
+
126
+ def _load_config_or_fail(config_path: str) -> ModelpinConfig:
127
+ try:
128
+ return load_config(config_path)
129
+ except ConfigError as exc:
130
+ _fail(str(exc))
131
+
132
+
133
+ def _load_scenarios_or_fail(scenarios_dir: str) -> list[Scenario]:
134
+ try:
135
+ scenarios = load_scenarios(scenarios_dir)
136
+ except ScenarioError as exc:
137
+ _fail(str(exc))
138
+ if not scenarios:
139
+ _fail(f"no scenarios in {scenarios_dir!r}. Run `mp init` first.")
140
+ return scenarios
141
+
142
+
143
+ def _resolve_provider(provider: Optional[str], cfg: ModelpinConfig) -> str:
144
+ return provider or (cfg.providers[0] if cfg.providers else DEFAULT_PROVIDER)
145
+
146
+
147
+ def _resolve_runs(runs: Optional[int], cfg: ModelpinConfig) -> int:
148
+ n = cfg.runs if runs is None else runs # an explicit --runs 0 must hit the floor, not coerce
149
+ if n < MIN_RUNS:
150
+ _fail(
151
+ f"--runs must be >= {MIN_RUNS}: the diff compares run *distributions*, and "
152
+ f"a single run can't form one (got {n})."
153
+ )
154
+ if n < RECOMMENDED_RUNS:
155
+ console.print(
156
+ f"[yellow]warning:[/] only {n} runs/scenario; {RECOMMENDED_RUNS}+ gives the "
157
+ "statistical diff real power and a lower false-positive rate."
158
+ )
159
+ return n
160
+
161
+
162
+ @app.command()
163
+ def version() -> None:
164
+ """Print the Modelpin version."""
165
+ console.print(f"modelpin {__version__}")
166
+
167
+
168
+ @app.command()
169
+ def scan(path: str = typer.Argument(".", help="Repo root to scan.")) -> None:
170
+ """Detect which AI models this repo depends on, and where."""
171
+ hits = scan_repo(path)
172
+ if not hits:
173
+ console.print("No model identifiers found.")
174
+ return
175
+ table = Table("model", "file", "line", title="Models this repo depends on", box=ASCII_BOX)
176
+ for h in sorted(hits, key=lambda x: (x["model"], x["file"], x["line"])):
177
+ table.add_row(h["model"], h["file"], str(h["line"]))
178
+ console.print(table)
179
+ console.print(f"[dim]{len({h['model'] for h in hits})} distinct model(s).[/]")
180
+
181
+
182
+ @app.command()
183
+ def init(directory: str = typer.Argument(".", help="Repo to scaffold.")) -> None:
184
+ """Create modelpin.yaml + scenarios/ in the current repo (never overwrites)."""
185
+ root = Path(directory)
186
+ cfg = root / "modelpin.yaml"
187
+ scenarios_dir = root / "scenarios"
188
+ created: list[str] = []
189
+ if not cfg.exists():
190
+ cfg.write_text(_SAMPLE_CONFIG, encoding="utf-8")
191
+ created.append(str(cfg))
192
+ scenarios_dir.mkdir(parents=True, exist_ok=True)
193
+ if not any(scenarios_dir.glob("*.json")):
194
+ (scenarios_dir / "greeting.json").write_text(_SAMPLE_SCENARIO, encoding="utf-8")
195
+ created.append(str(scenarios_dir / "greeting.json"))
196
+ if created:
197
+ console.print("[green]Scaffolded:[/]")
198
+ for c in created:
199
+ console.print(f" - {c}")
200
+ console.print("\nNext: add scenarios, then run [bold]mp baseline[/].")
201
+ else:
202
+ console.print("Already initialised (modelpin.yaml + scenarios/ present).")
203
+
204
+
205
+ @app.command()
206
+ def baseline(
207
+ model: Optional[str] = typer.Option(
208
+ None, "--model", help="Model to baseline (default: config)."
209
+ ),
210
+ provider: Optional[str] = typer.Option(
211
+ None, "--provider", help="openai | google | anthropic | groq | openrouter | fake."
212
+ ),
213
+ fixtures: Optional[str] = typer.Option(
214
+ None, "--fixtures", help="Canned traces for --provider fake."
215
+ ),
216
+ runs: Optional[int] = typer.Option(None, "--runs", help="Replays per scenario."),
217
+ config_path: str = typer.Option("modelpin.yaml", "--config"),
218
+ scenarios_dir: Optional[str] = typer.Option(None, "--scenarios-dir"),
219
+ store_dir: str = typer.Option(STORE_DIRNAME, "--store-dir"),
220
+ ) -> None:
221
+ """Record current model behavior for your scenarios (N runs)."""
222
+ cfg = _load_config_or_fail(config_path)
223
+ scenarios = _load_scenarios_or_fail(scenarios_dir or cfg.scenarios_dir)
224
+ from_model = model or (cfg.models[0] if cfg.models else None)
225
+ if not from_model:
226
+ _fail("no model to baseline. Pass --model or set `models:` in modelpin.yaml.")
227
+ n = _resolve_runs(runs, cfg)
228
+ prov = _resolve_provider(provider, cfg)
229
+ adapter = _adapter(prov, fixtures)
230
+ console.print(f"[dim]provider={prov} model={from_model} runs={n}[/]")
231
+ _preflight_or_fail(adapter, prov)
232
+ traces = _guard_replay(
233
+ prov, lambda: {s.id: replay(s, from_model, adapter, runs=n) for s in scenarios}
234
+ )
235
+ path = save_baseline(traces, from_model, store_dir)
236
+ console.print(
237
+ f"[green]Baseline recorded[/] for [bold]{from_model}[/]: "
238
+ f"{len(scenarios)} scenario(s) x{n} runs -> {path}"
239
+ )
240
+
241
+
242
+ @app.command()
243
+ def check(
244
+ to: str = typer.Option(..., "--to", help="The new model id to test against your baseline."),
245
+ from_: Optional[str] = typer.Option(None, "--from", help="Baseline model (default: config)."),
246
+ provider: Optional[str] = typer.Option(
247
+ None, "--provider", help="openai | google | anthropic | groq | openrouter | fake."
248
+ ),
249
+ fixtures: Optional[str] = typer.Option(
250
+ None, "--fixtures", help="Canned traces for --provider fake."
251
+ ),
252
+ runs: Optional[int] = typer.Option(None, "--runs", help="Replays per scenario."),
253
+ mode: str = typer.Option(
254
+ "strict", "--match", help="Tool-call match mode: strict|unordered|subset|superset."
255
+ ),
256
+ config_path: str = typer.Option("modelpin.yaml", "--config"),
257
+ scenarios_dir: Optional[str] = typer.Option(None, "--scenarios-dir"),
258
+ store_dir: str = typer.Option(STORE_DIRNAME, "--store-dir"),
259
+ ) -> None:
260
+ """Replay scenarios on a new model and report behavioral regressions."""
261
+ cfg = _load_config_or_fail(config_path)
262
+ scenarios = _load_scenarios_or_fail(scenarios_dir or cfg.scenarios_dir)
263
+ from_model = from_ or (cfg.models[0] if cfg.models else None)
264
+ if not from_model:
265
+ _fail("no baseline model. Pass --from or set `models:` in modelpin.yaml.")
266
+ try:
267
+ base = load_baseline(from_model, store_dir)
268
+ except FileNotFoundError as e:
269
+ _fail(str(e))
270
+ except BaselineError as e:
271
+ _fail(str(e))
272
+ n = _resolve_runs(runs, cfg)
273
+ prov = _resolve_provider(provider, cfg)
274
+ adapter = _adapter(prov, fixtures)
275
+ console.print(f"[dim]provider={prov} from={from_model} to={to} runs={n} match={mode}[/]")
276
+ _preflight_or_fail(adapter, prov)
277
+ judge = _build_judge(prov, cfg)
278
+
279
+ results = []
280
+ skipped: list[str] = []
281
+
282
+ def _run_check() -> None:
283
+ for s in scenarios:
284
+ base_traces = base.get(s.id)
285
+ if not base_traces:
286
+ skipped.append(s.id)
287
+ continue
288
+ cand = replay(s, to, adapter, runs=n)
289
+ results.append(
290
+ diff_scenario(s.id, from_model, to, base_traces, cand, s, mode, judge=judge)
291
+ )
292
+
293
+ _guard_replay(prov, _run_check)
294
+
295
+ if not results:
296
+ _fail("nothing to compare. Record a baseline first with `mp baseline`.")
297
+
298
+ console.print(render_cli(results, from_model, to, n))
299
+
300
+ # Decide the CI exit code BEFORE any side effect, so a report-write failure
301
+ # can never silently mask a real regression.
302
+ has_regression = any(r.verdict == DiffVerdict.regression for r in results)
303
+
304
+ report_path = Path(store_dir) / "last-report.md"
305
+ try:
306
+ report_path.parent.mkdir(parents=True, exist_ok=True)
307
+ report_path.write_text(render_pr_comment(results, from_model, to, n), encoding="utf-8")
308
+ console.print(f"\n[dim]PR-style Markdown report written to {report_path}[/]")
309
+ except OSError as exc:
310
+ console.print(f"[yellow]warning:[/] could not write report to {report_path}: {exc}")
311
+
312
+ if skipped:
313
+ console.print(
314
+ f"[yellow]note:[/] {len(skipped)} scenario(s) had no baseline and were skipped: "
315
+ f"{', '.join(skipped)}. Re-run `mp baseline` to cover them."
316
+ )
317
+
318
+ if has_regression:
319
+ raise typer.Exit(code=1) # fail CI on a real regression
320
+
321
+
322
+ @app.command()
323
+ def report() -> None:
324
+ """Run the public standard suite and draft a Modelpin Report. (Coming soon.)"""
325
+ console.print(
326
+ "[yellow]TODO[/]: the public Modelpin Report suite is not implemented yet. "
327
+ "See docs/Modelpin-Engineering-Context-Pack.md section 7."
328
+ )
329
+
330
+
331
+ def main() -> None:
332
+ app()
333
+
334
+
335
+ if __name__ == "__main__":
336
+ main()
modelpin/config.py ADDED
@@ -0,0 +1,46 @@
1
+ """Load and validate modelpin.yaml. See spec sections 3-4."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, Field, ValidationError
10
+
11
+ DEFAULT_CONFIG_FILE = "modelpin.yaml"
12
+
13
+ #: The default provider when none is given. OpenAI is the implemented adapter; the
14
+ #: Anthropic adapter is still a stub, so zero-config must not route to it.
15
+ DEFAULT_PROVIDER = "openai"
16
+
17
+
18
+ class ConfigError(Exception):
19
+ """modelpin.yaml is malformed or fails validation. Carries a user-facing message."""
20
+
21
+
22
+ class ModelpinConfig(BaseModel):
23
+ models: list[str] = Field(default_factory=list)
24
+ scenarios_dir: str = "scenarios"
25
+ providers: list[str] = Field(default_factory=lambda: [DEFAULT_PROVIDER])
26
+ runs: int = Field(default=3, ge=1)
27
+ judge_model: Optional[str] = None
28
+ regression_threshold: float = 0.2
29
+
30
+
31
+ def load_config(path: str | Path = DEFAULT_CONFIG_FILE) -> ModelpinConfig:
32
+ p = Path(path)
33
+ if not p.exists():
34
+ return ModelpinConfig()
35
+ try:
36
+ data: Any = yaml.safe_load(p.read_text(encoding="utf-8"))
37
+ except yaml.YAMLError as exc:
38
+ raise ConfigError(f"{p} is not valid YAML: {exc}") from exc
39
+ if data is None:
40
+ return ModelpinConfig()
41
+ if not isinstance(data, dict):
42
+ raise ConfigError(f"{p} must be a YAML mapping (got {type(data).__name__}).")
43
+ try:
44
+ return ModelpinConfig(**data)
45
+ except ValidationError as exc:
46
+ raise ConfigError(f"{p} has invalid settings: {exc}") from exc
@@ -0,0 +1,49 @@
1
+ """Detector — scans a repo for AI model identifier strings. See spec section 4.2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ # Conservative patterns; extend as providers add families.
10
+ MODEL_PATTERNS = [
11
+ re.compile(r"\bgpt-[0-9][\w.\-]*\b"),
12
+ re.compile(r"\bo[0-9][\w.\-]*\b"),
13
+ re.compile(r"\bclaude-[\w.\-]+\b"),
14
+ re.compile(r"\bgemini-[\w.\-]+\b"),
15
+ ]
16
+
17
+ DEFAULT_EXTS = {".py", ".env", ".yaml", ".yml", ".json", ".toml", ".js", ".ts"}
18
+ SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "dist", "build"}
19
+
20
+
21
+ def _iter_files(root: Path, exts: set[str]) -> Iterable[Path]:
22
+ for p in root.rglob("*"):
23
+ if p.is_dir():
24
+ continue
25
+ if any(part in SKIP_DIRS for part in p.parts):
26
+ continue
27
+ if p.suffix.lower() in exts or p.name == ".env":
28
+ yield p
29
+
30
+
31
+ def scan_repo(root: str | Path = ".", exts: set[str] | None = None) -> list[dict]:
32
+ """Return [{model, file, line}] for every model id found in the repo."""
33
+ root = Path(root)
34
+ exts = exts or DEFAULT_EXTS
35
+ hits: list[dict] = []
36
+ for f in _iter_files(root, exts):
37
+ try:
38
+ text = f.read_text(errors="ignore")
39
+ except OSError:
40
+ continue
41
+ for i, line in enumerate(text.splitlines(), start=1):
42
+ for pat in MODEL_PATTERNS:
43
+ for m in pat.findall(line):
44
+ hits.append({"model": m, "file": str(f.relative_to(root)), "line": i})
45
+ return hits
46
+
47
+
48
+ def models_used(root: str | Path = ".") -> set[str]:
49
+ return {h["model"] for h in scan_repo(root)}
@@ -0,0 +1,208 @@
1
+ """Behavioral diff orchestrator. See spec section 6.
2
+
3
+ Combines structural per-run signals (``structural.py``) with the distributional
4
+ permutation test (``stats.py``) into a single per-scenario verdict + confidence.
5
+
6
+ Decision rule (tuned for a low FALSE-POSITIVE rate — the north-star metric):
7
+ a signal counts as a regression only when the candidate distribution differs from
8
+ baseline at p <= ALPHA *and* the effect clears a minimum size. A single odd run,
9
+ or a majority that merely flips between two equally-likely behaviors, is NOT a
10
+ regression — the permutation test treats it as noise. The semantic LLM-judge
11
+ (``semantic.py``, spec 6B) is optional and injected: with ``judge=None`` this layer
12
+ stays purely structural + statistical and never makes a network call; with a judge it
13
+ adds a calibrated semantic-divergence signal through the same distributional test.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ from modelpin.diff.stats import (
21
+ permutation_pvalue_distribution,
22
+ permutation_pvalue_mean,
23
+ total_variation_distance,
24
+ )
25
+ from modelpin.diff.semantic import Judge, semantic_divergence_flags
26
+ from modelpin.diff.structural import (
27
+ MatchMode,
28
+ assertion_violation_flags,
29
+ canonical_sequence,
30
+ modal_sequence,
31
+ refusal_rate,
32
+ refused_flags,
33
+ tool_call_sequence,
34
+ )
35
+ from modelpin.models import DiffResult, DiffSignals, DiffVerdict, Scenario, Trace
36
+
37
+ #: Significance threshold for the permutation test. Lower = fewer false positives.
38
+ ALPHA = 0.05
39
+ #: A tool-call distribution must shift by at least this total-variation distance to
40
+ #: count — guards against trivially-significant jitter once N grows large.
41
+ MIN_TOOL_TVD = 0.5
42
+ #: Ignore refusal-rate rises smaller than this even if "significant" (one run in three).
43
+ MIN_REFUSAL_DELTA = 0.34
44
+ #: Candidate semantic-divergence rate must exceed the baseline's by at least this much.
45
+ #: CALIBRATED on examples/calibration/ (labeled set distinct from the held-out suite; see
46
+ #: docs/STATUS.md): equivalent-but-reworded pairs land at delta 0.0, and the meaning changes
47
+ #: that actually took effect land at delta >= 0.8 (one perturbation the model resisted scored
48
+ #: 0.0 — a failed perturbation, not a missed regression), so 0.5 sits in the empty gap with 0
49
+ #: false positives. The held-out suite re-validation stayed 0/8 with the promotion live. NOTE
50
+ #: the calibration set is still small and the perturbations synthetic — see docs/STATUS.md for
51
+ #: the limitations and the planned expansion to real migration traces + an independent judge.
52
+ MIN_SEMANTIC_DELTA = 0.5
53
+
54
+
55
+ def _scenario_task(scenario: Optional[Scenario]) -> Optional[str]:
56
+ """The user's request from a scenario (last user message) — context for the judge."""
57
+ if not scenario:
58
+ return None
59
+ for message in reversed(scenario.input.get("messages") or []):
60
+ if isinstance(message, dict) and message.get("role") == "user":
61
+ return str(message.get("content") or "")
62
+ return None
63
+
64
+
65
+ def _mean(values: list[float]) -> float:
66
+ return sum(values) / len(values) if values else 0.0
67
+
68
+
69
+ def diff_scenario(
70
+ scenario_id: str,
71
+ from_model: str,
72
+ to_model: str,
73
+ baseline_traces: list[Trace],
74
+ candidate_traces: list[Trace],
75
+ scenario: Optional[Scenario] = None,
76
+ mode: MatchMode = "strict",
77
+ judge: Optional[Judge] = None,
78
+ ) -> DiffResult:
79
+ """Compare baseline vs candidate trace distributions for one scenario.
80
+
81
+ With ``judge`` set, the semantic LLM-judge signal is evaluated (spec 6B); with it
82
+ ``None`` the diff is purely structural + statistical and makes no network call.
83
+ """
84
+ if not baseline_traces or not candidate_traces:
85
+ return DiffResult(
86
+ scenario_id=scenario_id,
87
+ from_model=from_model,
88
+ to_model=to_model,
89
+ verdict=DiffVerdict.unchanged,
90
+ confidence=0.0,
91
+ explanation="insufficient data: need baseline and candidate runs",
92
+ )
93
+
94
+ # --- tool-call trajectory distribution -------------------------------------
95
+ base_keys = [canonical_sequence(tool_call_sequence(t), mode) for t in baseline_traces]
96
+ cand_keys = [canonical_sequence(tool_call_sequence(t), mode) for t in candidate_traces]
97
+ tool_tvd = total_variation_distance(base_keys, cand_keys)
98
+ tool_p = permutation_pvalue_distribution(base_keys, cand_keys)
99
+ tool_regressed = tool_p <= ALPHA and tool_tvd >= MIN_TOOL_TVD
100
+
101
+ # --- refusal rate ----------------------------------------------------------
102
+ refusal_delta = refusal_rate(candidate_traces) - refusal_rate(baseline_traces)
103
+ refusal_p = permutation_pvalue_mean(
104
+ refused_flags(baseline_traces), refused_flags(candidate_traces)
105
+ )
106
+ refusal_regressed = refusal_p <= ALPHA and refusal_delta >= MIN_REFUSAL_DELTA
107
+
108
+ # --- output format / assertion drift (soft signal) -------------------------
109
+ fmt_p = 1.0
110
+ fmt_drift = False
111
+ if scenario and scenario.assertions:
112
+ a = scenario.assertions
113
+ base_v = assertion_violation_flags(baseline_traces, a.must_contain, a.must_not_contain)
114
+ cand_v = assertion_violation_flags(candidate_traces, a.must_contain, a.must_not_contain)
115
+ fmt_delta = _mean(cand_v) - _mean(base_v)
116
+ fmt_p = permutation_pvalue_mean(base_v, cand_v)
117
+ fmt_drift = fmt_p <= ALPHA and fmt_delta > 0
118
+
119
+ # --- semantic equivalence (LLM-as-judge; optional, only when a judge is given) ---
120
+ semantic_score: Optional[float] = None
121
+ semantic_p = 1.0
122
+ semantic_diverged = False
123
+ if judge is not None:
124
+ base_sem, cand_sem, semantic_score = semantic_divergence_flags(
125
+ baseline_traces, candidate_traces, judge, _scenario_task(scenario)
126
+ )
127
+ semantic_delta = _mean(cand_sem) - _mean(base_sem)
128
+ semantic_p = permutation_pvalue_mean(base_sem, cand_sem)
129
+ semantic_diverged = semantic_p <= ALPHA and semantic_delta >= MIN_SEMANTIC_DELTA
130
+
131
+ # --- cheap deltas (informational; not part of the verdict) -----------------
132
+ latency_delta = _mean([t.latency_ms for t in candidate_traces]) - _mean(
133
+ [t.latency_ms for t in baseline_traces]
134
+ )
135
+ token_delta = round(
136
+ _mean([t.tokens_out for t in candidate_traces])
137
+ - _mean([t.tokens_out for t in baseline_traces])
138
+ )
139
+
140
+ signals = DiffSignals(
141
+ tool_call_match=round(1.0 - tool_tvd, 3), # 1.0 == identical distributions
142
+ format_valid=not fmt_drift,
143
+ refusal_delta=round(refusal_delta, 3),
144
+ semantic_score=semantic_score,
145
+ latency_delta_ms=round(latency_delta, 3),
146
+ token_delta=int(token_delta),
147
+ )
148
+
149
+ # --- verdict ---------------------------------------------------------------
150
+ reasons: list[str] = []
151
+ hard_pvalues: list[float] = []
152
+ verdict = DiffVerdict.unchanged
153
+
154
+ if tool_regressed:
155
+ verdict = DiffVerdict.regression
156
+ hard_pvalues.append(tool_p)
157
+ reasons.append(
158
+ f"tool-call behavior changed: {list(modal_sequence(baseline_traces, mode))} "
159
+ f"-> {list(modal_sequence(candidate_traces, mode))}"
160
+ )
161
+ if refusal_regressed:
162
+ verdict = DiffVerdict.regression
163
+ hard_pvalues.append(refusal_p)
164
+ reasons.append(
165
+ f"refusal rate {refusal_rate(baseline_traces):.0%} -> {refusal_rate(candidate_traces):.0%}"
166
+ )
167
+ minor_pvalues: list[float] = []
168
+ if fmt_drift:
169
+ if verdict != DiffVerdict.regression:
170
+ verdict = DiffVerdict.changed_minor
171
+ minor_pvalues.append(fmt_p)
172
+ reasons.append("output format drift: violates the scenario's text assertions")
173
+ if semantic_diverged:
174
+ # Calibrated (examples/calibration/): a consistent semantic divergence beyond the
175
+ # baseline's own spread, clearing MIN_SEMANTIC_DELTA at p <= ALPHA, is a hard,
176
+ # CI-failing regression. The labeled sweep separated equivalent (delta 0.0) from real
177
+ # meaning changes (delta >= 0.8) with 0 false positives at this floor, so this no
178
+ # longer over-fires on reworded-but-equivalent answers. A single divergent run, or a
179
+ # minority below the floor, still reads as noise (the permutation test + the floor).
180
+ verdict = DiffVerdict.regression
181
+ hard_pvalues.append(semantic_p)
182
+ reasons.append(
183
+ f"semantic drift: candidate answers diverge in meaning from baseline "
184
+ f"(equivalence {semantic_score:.0%})"
185
+ )
186
+
187
+ # confidence = how sure we are of the verdict.
188
+ # regression/minor -> 1 - p of the firing signal (small p => high confidence);
189
+ # unchanged -> smallest p across signals (1.0 when distributions match,
190
+ # lower when something was a borderline near-miss).
191
+ if verdict == DiffVerdict.regression:
192
+ confidence = round(1.0 - min(hard_pvalues), 3)
193
+ elif verdict == DiffVerdict.changed_minor:
194
+ confidence = round(1.0 - min(minor_pvalues), 3)
195
+ else:
196
+ confidence = round(min(tool_p, refusal_p, fmt_p, semantic_p), 3)
197
+
198
+ explanation = "; ".join(reasons) if reasons else "no statistically significant behavior change"
199
+
200
+ return DiffResult(
201
+ scenario_id=scenario_id,
202
+ from_model=from_model,
203
+ to_model=to_model,
204
+ verdict=verdict,
205
+ signals=signals,
206
+ confidence=confidence,
207
+ explanation=explanation,
208
+ )