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 +3 -0
- modelpin/cli.py +336 -0
- modelpin/config.py +46 -0
- modelpin/detector/__init__.py +49 -0
- modelpin/diff/__init__.py +208 -0
- modelpin/diff/semantic.py +71 -0
- modelpin/diff/stats.py +132 -0
- modelpin/diff/structural.py +110 -0
- modelpin/judge.py +102 -0
- modelpin/models.py +155 -0
- modelpin/providers/__init__.py +45 -0
- modelpin/providers/_common.py +45 -0
- modelpin/providers/anthropic.py +17 -0
- modelpin/providers/base.py +33 -0
- modelpin/providers/fake.py +39 -0
- modelpin/providers/google.py +276 -0
- modelpin/providers/openai.py +352 -0
- modelpin/replay/__init__.py +14 -0
- modelpin/report/__init__.py +81 -0
- modelpin/scenarios/__init__.py +35 -0
- modelpin/storage.py +72 -0
- modelpin/watcher/__init__.py +41 -0
- modelpin-0.1.0.dist-info/METADATA +380 -0
- modelpin-0.1.0.dist-info/RECORD +28 -0
- modelpin-0.1.0.dist-info/WHEEL +5 -0
- modelpin-0.1.0.dist-info/entry_points.txt +3 -0
- modelpin-0.1.0.dist-info/licenses/LICENSE +201 -0
- modelpin-0.1.0.dist-info/top_level.txt +1 -0
modelpin/__init__.py
ADDED
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
|
+
)
|