auditkit 1.0.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.
Files changed (81) hide show
  1. auditkit/README.md +99 -0
  2. auditkit/__init__.py +177 -0
  3. auditkit/__main__.py +3 -0
  4. auditkit/_bootstrap.py +77 -0
  5. auditkit/_identity_guard.py +99 -0
  6. auditkit/adapter.py +264 -0
  7. auditkit/annotator.py +339 -0
  8. auditkit/api.py +502 -0
  9. auditkit/assets/auditkit_logo.png +0 -0
  10. auditkit/cache.py +47 -0
  11. auditkit/cli.py +417 -0
  12. auditkit/comparison.py +563 -0
  13. auditkit/diff.py +265 -0
  14. auditkit/errors.py +54 -0
  15. auditkit/evaluator.py +20 -0
  16. auditkit/experiment.py +145 -0
  17. auditkit/hf_publish.py +262 -0
  18. auditkit/lmeval_engine.py +550 -0
  19. auditkit/loaders.py +121 -0
  20. auditkit/logs.py +18 -0
  21. auditkit/metric.py +199 -0
  22. auditkit/metrics/README.md +15 -0
  23. auditkit/metrics/__init__.py +0 -0
  24. auditkit/metrics/code.py +222 -0
  25. auditkit/metrics/embedding.py +131 -0
  26. auditkit/metrics/encoder_judge.py +423 -0
  27. auditkit/metrics/generation.py +331 -0
  28. auditkit/metrics/guard.py +412 -0
  29. auditkit/metrics/hallucination.py +45 -0
  30. auditkit/metrics/judge.py +547 -0
  31. auditkit/metrics/pairwise.py +153 -0
  32. auditkit/metrics/perf.py +53 -0
  33. auditkit/metrics/rag.py +149 -0
  34. auditkit/metrics/security.py +64 -0
  35. auditkit/metrics/toxicity.py +238 -0
  36. auditkit/model/README.md +16 -0
  37. auditkit/model/__init__.py +485 -0
  38. auditkit/model/anthropic.py +94 -0
  39. auditkit/model/api_gen.py +133 -0
  40. auditkit/model/groq_gen.py +121 -0
  41. auditkit/model/hf_gen.py +385 -0
  42. auditkit/model/lexsi.py +155 -0
  43. auditkit/model/litellm_gen.py +65 -0
  44. auditkit/model/openai.py +90 -0
  45. auditkit/model/openrouter_gen.py +152 -0
  46. auditkit/model/vllm_gen.py +316 -0
  47. auditkit/model_compare.py +655 -0
  48. auditkit/redteam/README.md +9 -0
  49. auditkit/redteam/__init__.py +26 -0
  50. auditkit/redteam/detector.py +37 -0
  51. auditkit/redteam/detectors/README.md +5 -0
  52. auditkit/redteam/detectors/builtin.py +126 -0
  53. auditkit/redteam/probe.py +39 -0
  54. auditkit/redteam/probes/README.md +5 -0
  55. auditkit/redteam/probes/builtin.py +85 -0
  56. auditkit/redteam/runner.py +206 -0
  57. auditkit/registry.py +65 -0
  58. auditkit/report.py +278 -0
  59. auditkit/report_format.py +52 -0
  60. auditkit/router.py +54 -0
  61. auditkit/runner.py +575 -0
  62. auditkit/runspec.py +159 -0
  63. auditkit/sample.py +40 -0
  64. auditkit/scenario.py +88 -0
  65. auditkit/scenarios/README.md +10 -0
  66. auditkit/scenarios/__init__.py +4 -0
  67. auditkit/scenarios/arc.py +33 -0
  68. auditkit/scenarios/gsm8k.py +32 -0
  69. auditkit/scenarios/hellaswag.py +33 -0
  70. auditkit/scenarios/humaneval.py +32 -0
  71. auditkit/scenarios/mmlu.py +34 -0
  72. auditkit/scenarios/truthfulqa.py +33 -0
  73. auditkit/score.py +165 -0
  74. auditkit/scorers.py +117 -0
  75. auditkit/scoring.py +79 -0
  76. auditkit/types.py +69 -0
  77. auditkit-1.0.0.dist-info/METADATA +396 -0
  78. auditkit-1.0.0.dist-info/RECORD +81 -0
  79. auditkit-1.0.0.dist-info/WHEEL +4 -0
  80. auditkit-1.0.0.dist-info/entry_points.txt +2 -0
  81. auditkit-1.0.0.dist-info/licenses/LICENSE.md +92 -0
@@ -0,0 +1,655 @@
1
+ """Multi-model comparison: run the same evaluation across several models."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import statistics
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable, Iterable, Sequence, Union
8
+
9
+ from .adapter import Adapter
10
+ from .api import evaluate
11
+ from .diff import best_model, direction_for, metric_directions
12
+ from .model import AutoModel, Model
13
+ from .report import RunResult
14
+ from .runspec import RunConfig
15
+ from .sample import Sample
16
+ from .scenario import Scenario
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class PerMetricComparison:
23
+ """Comparison of one metric across models."""
24
+
25
+ metric: str
26
+ scores: dict[str, float]
27
+ winner: str | None
28
+ deltas: dict[str, dict[str, float]]
29
+
30
+
31
+ @dataclass
32
+ class CompareResult:
33
+ """Result of comparing multiple models on the same dataset."""
34
+
35
+ runs: dict[str, RunResult]
36
+ dataset_size: int
37
+ scorers: list[str]
38
+ # Models that failed to run AT ALL (raised before producing a RunResult --
39
+ # e.g. an invalid/decommissioned model spec), keyed by name, value = the
40
+ # error message. Previously an exception from any one model crashed
41
+ # compare_models() entirely, losing every other model's already-computed
42
+ # results too; these are now caught and the comparison continues without
43
+ # that model, with the failure recorded here instead of silently lost.
44
+ errors: dict[str, str] = field(default_factory=dict)
45
+
46
+ def _warn_if_issues(self) -> None:
47
+ if self.errors:
48
+ logger.warning("CompareResult: %d model(s) failed to run entirely: %s",
49
+ len(self.errors), self.errors)
50
+ cw = self.coverage_warnings()
51
+ if cw:
52
+ logger.warning("CompareResult: coverage issues across models -- %s", cw)
53
+
54
+ def per_metric(self) -> list[PerMetricComparison]:
55
+ """For each metric, show each model's average score and pairwise deltas."""
56
+ self._warn_if_issues()
57
+ by_metric: dict[str, dict[str, float]] = {}
58
+ for model_name, run in self.runs.items():
59
+ for metric_name, value in run.headline.items():
60
+ by_metric.setdefault(metric_name, {})[model_name] = value
61
+
62
+ dirs = metric_directions(self.runs.values())
63
+ results = []
64
+ for metric, model_scores in by_metric.items():
65
+ winner = best_model(model_scores, direction_for(metric, dirs))
66
+ deltas = {}
67
+ for m1 in model_scores:
68
+ deltas[m1] = {}
69
+ for m2 in model_scores:
70
+ if m1 != m2:
71
+ deltas[m1][m2] = round(model_scores[m1] - model_scores[m2], 4)
72
+ results.append(PerMetricComparison(
73
+ metric=metric,
74
+ scores=model_scores,
75
+ winner=winner,
76
+ deltas=deltas,
77
+ ))
78
+ return results
79
+
80
+ def winner(self, metric: str | None = None) -> str | None:
81
+ """Return the model with the highest average on *metric* (or the first metric)."""
82
+ metrics = self.per_metric()
83
+ if not metrics:
84
+ return None
85
+ target = metric or metrics[0].metric
86
+ for m in metrics:
87
+ if m.metric == target:
88
+ return m.winner
89
+ return None
90
+
91
+ def significance(self, model_a: str, model_b: str, metric: str | None = None) -> dict[str, Any]:
92
+ """Paired-bootstrap significance test between two models on a given metric.
93
+
94
+ Per-sample scores are aligned **by sample_id** (via the shared
95
+ :func:`~auditkit._bootstrap.score_pairs`), so the test is robust even if
96
+ the two runs' prediction order diverges.
97
+ """
98
+ from ._bootstrap import paired_bootstrap, score_pairs
99
+
100
+ run_a = self.runs.get(model_a)
101
+ run_b = self.runs.get(model_b)
102
+ if not run_a or not run_b:
103
+ return {"error": f"Missing run for {model_a} or {model_b}"}
104
+ target = metric or next(iter(run_a.headline.keys()), None)
105
+ if target is None:
106
+ return {"error": "No metrics available"}
107
+
108
+ bs = paired_bootstrap(score_pairs(run_a, run_b, target))
109
+ if "error" in bs:
110
+ return {"error": bs["error"], "model_a": model_a, "model_b": model_b, "metric": target}
111
+ return {
112
+ "model_a": model_a,
113
+ "model_b": model_b,
114
+ "metric": target,
115
+ "mean_a": bs["mean_baseline"],
116
+ "mean_b": bs["mean_candidate"],
117
+ "delta": round(bs["mean_baseline"] - bs["mean_candidate"], 6), # a − b
118
+ "p_value": bs["p_value"],
119
+ "significant": bs["significant"],
120
+ }
121
+
122
+ def pairwise(self, baseline_name: str, candidate_name: str, **kw: Any):
123
+ """A baseline-anchored :class:`~auditkit.comparison.RunComparison` between
124
+ two of the already-run models (e.g. base vs pruned)."""
125
+ from .comparison import RunComparison
126
+
127
+ base, cand = self.runs.get(baseline_name), self.runs.get(candidate_name)
128
+ if base is None or cand is None:
129
+ raise KeyError(
130
+ f"unknown model name(s) {baseline_name!r}/{candidate_name!r}; "
131
+ f"have {list(self.runs)}"
132
+ )
133
+ return RunComparison(base, cand, **kw)
134
+
135
+ # -- N-model coverage visibility -----------------------------------------
136
+ def coverage_warnings(self) -> list[str]:
137
+ """Flags failed samples and per-metric sample-count mismatches across
138
+ ALL compared models at once -- the N-model analog of
139
+ :meth:`~auditkit.comparison.RunComparison.coverage_warnings`/
140
+ ``has_failures``. ``compare_models()`` itself performs none of this
141
+ checking on its own; ``per_metric()``/``summary()`` read each run's
142
+ already-aggregated ``.headline`` and would silently show a clean
143
+ table even if one model failed on several samples or a metric never
144
+ scored successfully for one model at all.
145
+ """
146
+ warnings: list[str] = []
147
+ for name, err in self.errors.items():
148
+ warnings.append(f"{name}: FAILED TO RUN ENTIRELY -- {err}")
149
+ for name, run in self.runs.items():
150
+ if run.failed_count:
151
+ warnings.append(f"{name}: {run.failed_count} failed sample(s) -- see run.errors")
152
+
153
+ all_metrics: set[str] = set()
154
+ for run in self.runs.values():
155
+ all_metrics |= set(run.stats.keys())
156
+ for metric in sorted(all_metrics):
157
+ counts = {name: run.stats[metric].count for name, run in self.runs.items() if metric in run.stats}
158
+ missing = [name for name in self.runs if metric not in self.runs[name].stats]
159
+ if missing:
160
+ warnings.append(f"{metric}: missing entirely from {missing} (never scored successfully there)")
161
+ elif len(set(counts.values())) > 1:
162
+ warnings.append(f"{metric}: sample counts differ across models -> {counts}")
163
+
164
+ # Per-task check: two tasks can individually mismatch in N while the
165
+ # blended totals above coincidentally agree.
166
+ for row in self.per_task_table():
167
+ counts = {name: row.get(f"{name}_n", 0) for name in self.runs}
168
+ if len(set(counts.values())) > 1:
169
+ warnings.append(f"{row['task']}/{row['metric']}: sample counts differ across models -> {counts}")
170
+ return warnings
171
+
172
+ @property
173
+ def has_failures(self) -> bool:
174
+ return bool(self.errors) or any(run.failed_count for run in self.runs.values())
175
+
176
+ def comparison_table(self) -> list[dict[str, Any]]:
177
+ """One row per metric, with every model's score/n/std side by side and
178
+ the winner marked -- the structured form behind ``summary()``'s printed
179
+ table, for programmatic use (dashboards, CI gates, etc.)."""
180
+ all_metrics: set[str] = set()
181
+ for run in self.runs.values():
182
+ all_metrics |= set(run.stats.keys())
183
+ dirs = metric_directions(self.runs.values())
184
+ rows = []
185
+ for metric in sorted(all_metrics):
186
+ scores: dict[str, float] = {}
187
+ row: dict[str, Any] = {"metric": metric}
188
+ for name, run in self.runs.items():
189
+ stat = run.stats.get(metric)
190
+ row[f"{name}_score"] = stat.mean if stat else None
191
+ row[f"{name}_n"] = stat.count if stat else 0
192
+ row[f"{name}_std"] = stat.std if stat else None
193
+ if stat:
194
+ scores[name] = stat.mean
195
+ row["winner"] = best_model(scores, direction_for(metric, dirs))
196
+ rows.append(row)
197
+ return rows
198
+
199
+ def per_task_table(self) -> list[dict[str, Any]]:
200
+ """One row per (task, metric), every model's score/n/std side by side --
201
+ the N-model analog of ``RunComparison.per_task_deltas()``.
202
+
203
+ ``comparison_table()``/``summary()`` blend every task into one number
204
+ per metric -- two models that fail in completely different,
205
+ non-overlapping ways (one bombs task A, the other bombs task B) can
206
+ show the *identical* blended score and be indistinguishable there.
207
+ This breaks it out per task so that's visible.
208
+ """
209
+ # (task, metric) -> {model_name: {"mean", "count", "std"}}
210
+ table: dict[tuple[str, str], dict[str, dict[str, float]]] = {}
211
+ for name, run in self.runs.items():
212
+ # Benchmark engine: stats keyed "task:metric" -- Stat already has count/std.
213
+ for key, stat in run.stats.items():
214
+ if ":" in key:
215
+ task, metric = key.split(":", 1)
216
+ table.setdefault((task, metric), {})[name] = {
217
+ "mean": stat.mean, "count": stat.count, "std": stat.std,
218
+ }
219
+ # Native engine: group predictions by task via their per-score dicts.
220
+ acc: dict[tuple[str, str], list[float]] = {}
221
+ for p in run.predictions:
222
+ for s in p.metadata.get("scores", []):
223
+ mname, val = s.get("name"), s.get("value")
224
+ if mname is not None and isinstance(val, (int, float)):
225
+ acc.setdefault((p.task or "", mname), []).append(float(val))
226
+ for (task, metric), vals in acc.items():
227
+ if vals and name not in table.get((task, metric), {}):
228
+ table.setdefault((task, metric), {})[name] = {
229
+ "mean": sum(vals) / len(vals),
230
+ "count": len(vals),
231
+ "std": statistics.pstdev(vals) if len(vals) > 1 else 0.0,
232
+ }
233
+
234
+ dirs = metric_directions(self.runs.values())
235
+ rows = []
236
+ for task, metric in sorted(table):
237
+ per_model = table[(task, metric)]
238
+ row: dict[str, Any] = {"task": task, "metric": metric}
239
+ scores: dict[str, float] = {}
240
+ for name in self.runs:
241
+ entry = per_model.get(name)
242
+ row[f"{name}_score"] = entry["mean"] if entry else None
243
+ row[f"{name}_n"] = entry["count"] if entry else 0
244
+ row[f"{name}_std"] = entry["std"] if entry else None
245
+ if entry:
246
+ scores[name] = entry["mean"]
247
+ row["winner"] = best_model(scores, direction_for(metric, dirs))
248
+ rows.append(row)
249
+ return rows
250
+
251
+ def task_macro_average_table(self) -> list[dict[str, Any]]:
252
+ """One row per metric: each model's macro-average across tasks --
253
+ the mean of that model's own per-task means, each task weighted
254
+ equally regardless of how many samples it has.
255
+
256
+ Different from ``comparison_table()`` (which blends every sample
257
+ together, so a task with more samples counts for more) whenever
258
+ tasks have unequal sample counts -- e.g. a model at 100% on a
259
+ 1-sample task and 0% on a 3-sample task blends (micro-average) to
260
+ 0.25, but macro-averages to 0.5 (each task counted once, equally).
261
+ Neither is "more correct" -- micro answers "how good overall,
262
+ weighted by real sample volume," macro answers "how good on the
263
+ average task, regardless of how many examples it had."
264
+ """
265
+ task_rows = self.per_task_table()
266
+ acc: dict[str, dict[str, list[float]]] = {}
267
+ for row in task_rows:
268
+ metric = row["metric"]
269
+ for name in self.runs:
270
+ val = row.get(f"{name}_score")
271
+ if val is not None:
272
+ acc.setdefault(metric, {}).setdefault(name, []).append(val)
273
+
274
+ dirs = metric_directions(self.runs.values())
275
+ rows = []
276
+ for metric in sorted(acc):
277
+ per_model = acc[metric]
278
+ row: dict[str, Any] = {"metric": metric}
279
+ scores: dict[str, float] = {}
280
+ for name in self.runs:
281
+ vals = per_model.get(name, [])
282
+ macro_mean = sum(vals) / len(vals) if vals else None
283
+ row[f"{name}_macro_mean"] = macro_mean
284
+ row[f"{name}_num_tasks"] = len(vals)
285
+ if macro_mean is not None:
286
+ scores[name] = macro_mean
287
+ row["winner"] = best_model(scores, direction_for(metric, dirs))
288
+ rows.append(row)
289
+ return rows
290
+
291
+ def performance_table(self) -> list[dict[str, Any]]:
292
+ """Each model's measured latency/throughput side by side -- the
293
+ N-model analog of ``RunComparison.performance()``. Straight from
294
+ each run's ``RunResult.perf`` (populated by ``Runner`` while it ran);
295
+ a model with no timed calls (e.g. a ``reads_actual_output`` run), or
296
+ one whose ``RunConfig.track_performance`` was never enabled at all,
297
+ reports ``None``s rather than being omitted or crashing, so the row
298
+ shape stays uniform across models."""
299
+ rows = []
300
+ for name, run in self.runs.items():
301
+ perf = run.perf
302
+ lat = (perf.get("latency_ms") or {}) if perf is not None else None
303
+ thr = (perf.get("throughput") or {}) if perf is not None else None
304
+ rows.append({
305
+ "model": name,
306
+ "latency_mean_ms": lat.get("mean") if lat is not None else None,
307
+ "throughput_rps": thr.get("rps") if thr is not None else None,
308
+ "output_tokens_per_sec": thr.get("output_tokens_per_sec") if thr is not None else None,
309
+ "total_tokens_per_sec": thr.get("total_tokens_per_sec") if thr is not None else None,
310
+ "calls": lat.get("count", 0) if lat is not None else None,
311
+ })
312
+ return rows
313
+
314
+ def cost_table(self, pricing: dict[str, dict[str, float]]) -> list[dict[str, Any]]:
315
+ """Dollar cost per model, given *pricing* -- ``{model_name:
316
+ {"input_per_1m": ..., "output_per_1m": ...}}``. Token counts are the
317
+ real, provider-reported numbers already on each ``RunResult``
318
+ (``.token_usage``); there is no library default price table (see
319
+ ``RunResult.cost()``), so a model with no entry in *pricing*, no
320
+ recorded token usage at all, or ``RunConfig.track_performance``
321
+ never enabled, reports ``cost=None`` and ``None`` token counts
322
+ rather than a guess or a crash."""
323
+ rows = []
324
+ for name, run in self.runs.items():
325
+ model_pricing = pricing.get(name)
326
+ cost = run.cost(model_pricing) if model_pricing else None
327
+ usage = run.token_usage
328
+ rows.append({
329
+ "model": name,
330
+ "prompt_tokens": usage.get("prompt_tokens", 0) if usage is not None else None,
331
+ "completion_tokens": usage.get("completion_tokens", 0) if usage is not None else None,
332
+ "cost": cost,
333
+ })
334
+ return rows
335
+
336
+ def size_table(self) -> list[dict[str, Any]]:
337
+ """Each model's size, side by side -- never caller-supplied. Local
338
+ backends report introspected params/sparsity/MB; hosted-API backends
339
+ (or anything with no ``model_size`` recorded) report just an
340
+ identity so the row still names the model instead of a blank."""
341
+ rows = []
342
+ for name, run in self.runs.items():
343
+ ms = run.model_size or {}
344
+ rows.append({
345
+ "model": name,
346
+ "is_local": ms.get("is_local", False),
347
+ "model_name": ms.get("model_name"),
348
+ "total_params": ms.get("total_params"),
349
+ "sparsity": ms.get("sparsity"),
350
+ "size_mb": ms.get("size_mb"),
351
+ })
352
+ return rows
353
+
354
+ def summary(self) -> str:
355
+ lines = [f"Model Comparison — {self.dataset_size} samples, {len(self.scorers)} scorers", ""]
356
+
357
+ lines.append("Runs:")
358
+ for name, run in self.runs.items():
359
+ flag = f" [!! {run.failed_count} FAILED]" if run.failed_count else ""
360
+ lines.append(f" {name}: run_id={run.run_id}{flag}")
361
+ for name, err in self.errors.items():
362
+ lines.append(f" {name}: DID NOT RUN -- {err}")
363
+ lines.append("")
364
+
365
+ cw = self.coverage_warnings()
366
+ if cw:
367
+ lines.append("WARNING -- coverage issues across models:")
368
+ lines += [f" {w}" for w in cw]
369
+ lines.append("")
370
+
371
+ metrics = self.per_metric()
372
+ if not metrics:
373
+ lines.append("(no metrics)")
374
+ return "\n".join(lines)
375
+
376
+ model_names = list(self.runs.keys())
377
+ table = {row["metric"]: row for row in self.comparison_table()}
378
+ header = f"{'Metric':<25}" + "".join(f"{m:<26}" for m in model_names)
379
+ lines.append(header)
380
+ lines.append("-" * len(header))
381
+ for m in metrics:
382
+ row = f"{m.metric:<25}"
383
+ for name in model_names:
384
+ val = m.scores.get(name, 0)
385
+ n = table[m.metric].get(f"{name}_n", 0)
386
+ marker = " <-" if m.winner == name else ""
387
+ cell = f"{val:.4f} (n={n}){marker}"
388
+ row += f"{cell:<26}"
389
+ lines.append(row)
390
+
391
+ task_rows = self.per_task_table()
392
+ # Only show the per-task breakout if it says something the blended
393
+ # table above doesn't -- i.e. there's more than one distinct task.
394
+ if len({r["task"] for r in task_rows}) > 1:
395
+ lines.append("")
396
+ lines.append("Per-task:")
397
+ task_header = f" {'Task/Metric':<25}" + "".join(f"{m:<26}" for m in model_names)
398
+ lines.append(task_header)
399
+ lines.append(" " + "-" * (len(task_header) - 2))
400
+ for row in task_rows:
401
+ label = f"{row['task']}/{row['metric']}"
402
+ line = f" {label:<25}"
403
+ for name in model_names:
404
+ val = row.get(f"{name}_score")
405
+ n = row.get(f"{name}_n", 0)
406
+ marker = " <-" if row["winner"] == name else ""
407
+ cell = "n/a" if val is None else f"{val:.4f} (n={n}){marker}"
408
+ line += f"{cell:<26}"
409
+ lines.append(line)
410
+
411
+ lines.append("")
412
+ lines.append("Macro-average across tasks (each task weighted equally, not by sample count):")
413
+ macro_header = f" {'Metric':<25}" + "".join(f"{m:<26}" for m in model_names)
414
+ lines.append(macro_header)
415
+ lines.append(" " + "-" * (len(macro_header) - 2))
416
+ for row in self.task_macro_average_table():
417
+ line = f" {row['metric']:<25}"
418
+ for name in model_names:
419
+ val = row.get(f"{name}_macro_mean")
420
+ marker = " <-" if row["winner"] == name else ""
421
+ cell = "n/a" if val is None else f"{val:.4f}{marker}"
422
+ line += f"{cell:<26}"
423
+ lines.append(line)
424
+
425
+ size_rows = [r for r in self.size_table() if r["model_name"] is not None]
426
+ if size_rows:
427
+ lines.append("")
428
+ lines.append("Model size:")
429
+ for row in size_rows:
430
+ if row["is_local"] and row["size_mb"] is not None:
431
+ lines.append(
432
+ f" {row['model']} ({row['model_name']}): "
433
+ f"{row['total_params']:,} params "
434
+ f"({row['sparsity']:.1%} sparse), {row['size_mb']:.1f} MB"
435
+ )
436
+ elif row["is_local"]:
437
+ # is_local=True but no size_mb -- a local backend that
438
+ # doesn't actually introspect size yet (e.g. VLLMModel),
439
+ # not a hosted-API model. Different reason, same "nothing
440
+ # to report" outcome -- said explicitly either way.
441
+ lines.append(f" {row['model']} ({row['model_name']}): local model, size not introspected")
442
+ else:
443
+ lines.append(f" {row['model']} ({row['model_name']}): not a local model -- no measurable size")
444
+
445
+ perf_rows = [r for r in self.performance_table() if r["calls"]]
446
+ if perf_rows:
447
+ lines.append("")
448
+ lines.append("Performance (measured):")
449
+ perf_header = f" {'Model':<20}{'latency (ms)':<14}{'req/s':<10}{'out-tok/s':<12}"
450
+ lines.append(perf_header)
451
+ lines.append(" " + "-" * (len(perf_header) - 2))
452
+ for row in self.performance_table():
453
+ if not row["calls"]:
454
+ lines.append(f" {row['model']:<20}{'n/a':<14}{'n/a':<10}{'n/a':<12}")
455
+ continue
456
+ # output_tokens_per_sec is 0 for local/callable backends that
457
+ # never report token usage -- shown as "n/a", not a
458
+ # misleading measured zero.
459
+ tok_s = row.get("output_tokens_per_sec") or 0.0
460
+ tok_cell = f"{tok_s:.1f}" if tok_s else "n/a"
461
+ lines.append(
462
+ f" {row['model']:<20}{row['latency_mean_ms']:<14.0f}"
463
+ f"{row['throughput_rps']:<10.2f}{tok_cell:<12}"
464
+ )
465
+
466
+ lines.append("")
467
+ lines.append("Significance (pairwise bootstrap p < 0.05):")
468
+ for i, a in enumerate(model_names):
469
+ for b in model_names[i + 1:]:
470
+ sig = self.significance(a, b)
471
+ p = sig.get("p_value", 1)
472
+ label = "significant" if sig.get("significant") else "not significant"
473
+ lines.append(f" {a} vs {b}: p={p:.4f} ({label})")
474
+
475
+ return "\n".join(lines)
476
+
477
+ def push_to_hub(
478
+ self,
479
+ repo_id: str,
480
+ private: bool = False,
481
+ token: str | None = None,
482
+ ) -> str:
483
+ """Push per-model predictions to a Hub dataset and stamp the AuditKIT card."""
484
+ from .hf_publish import push_rows_to_hub
485
+
486
+ rows: list[dict[str, Any]] = []
487
+ for name, run in self.runs.items():
488
+ for p in run.predictions:
489
+ doc = p.to_doc()
490
+ doc["model"] = name
491
+ rows.append(doc)
492
+ return push_rows_to_hub(
493
+ rows,
494
+ repo_id,
495
+ private=private,
496
+ token=token,
497
+ kind="compare",
498
+ method="compare_models",
499
+ )
500
+
501
+
502
+ def compare_models(
503
+ models: Sequence[Union[str, Model, Callable[[list[str]], list[str]]]],
504
+ dataset: Union[Scenario, Sequence[Sample], Callable[[], Iterable[Sample]], str],
505
+ scorers: Any = None,
506
+ *,
507
+ model_names: list[str] | None = None,
508
+ adapter: Union[Adapter, str, None] = None,
509
+ annotators: Any = None,
510
+ extract_with: str | None = None,
511
+ config: RunConfig | None = None,
512
+ configs: dict[str, RunConfig] | None = None,
513
+ model_opts: dict[str, dict[str, Any]] | None = None,
514
+ experiment_name: str | None = None,
515
+ tags: list[str] | None = None,
516
+ **opts: Any,
517
+ ) -> CompareResult:
518
+ """Evaluate the same dataset against multiple models and compare results.
519
+
520
+ Args:
521
+ models: List of model specs (strings, Model instances, or callables).
522
+ dataset: Dataset to evaluate (same for all models).
523
+ scorers: Metrics to use (same for all models -- comparison across
524
+ models assumes a shared metric set, so this is not overridable
525
+ per-model via ``model_opts``).
526
+ model_names: Optional human-readable names for each model.
527
+ adapter: Adapter shared across all models unless overridden per-model
528
+ via ``model_opts``. Same default as ``evaluate()`` (``None`` ->
529
+ ``GenerationAdapter``) when not given here.
530
+ annotators: Annotator(s) shared across all models unless overridden
531
+ per-model via ``model_opts``.
532
+ extract_with: Annotator name whose extraction feeds scoring, shared
533
+ across all models unless overridden per-model via ``model_opts``.
534
+ config: Run configuration shared across all evaluations.
535
+ configs: Optional per-model ``RunConfig`` overrides, keyed by model name
536
+ (falls back to ``config`` when a name is absent). Use this when a
537
+ quantized/pruned model legitimately needs different knobs than the base.
538
+ model_opts: Optional per-model overrides, keyed by model name. Each
539
+ entry is itself a dict that may contain any of ``adapter``/
540
+ ``annotators``/``extract_with`` (overriding the shared value
541
+ above, for that model only) plus arbitrary backend construction
542
+ kwargs (e.g. ``dtype``/``device``, layered over the shared
543
+ ``**opts``) -- anything not recognized as a pipeline override is
544
+ forwarded to model resolution unchanged.
545
+ experiment_name: If set, each model's run is persisted under
546
+ ``f"{experiment_name}:{name}"`` (mirrors ``evaluate_many()``).
547
+ Explicit real params, not swept into ``**opts`` -- previously
548
+ passing ``experiment_name=``/``tags=`` here silently leaked them
549
+ into ``AutoModel.resolve(**opts)``, i.e. into each model's own
550
+ constructor kwargs, and for a real hosted API backend that meant
551
+ landing in the live HTTP request body, which the provider then
552
+ rejected outright (a real, confirmed Groq 400).
553
+ tags: Passed to each model's underlying ``evaluate()`` call.
554
+ **opts: Extra kwargs forwarded to model resolution for every model.
555
+
556
+ Returns:
557
+ A CompareResult with per-model runs and comparison methods.
558
+ """
559
+ names = model_names or [str(m) if isinstance(m, str) else getattr(m, "name", f"model_{i}") for i, m in enumerate(models)]
560
+ if len(names) != len(models):
561
+ raise ValueError("model_names must match number of models")
562
+
563
+ runs: dict[str, RunResult] = {}
564
+ errors: dict[str, str] = {}
565
+ for i, (name, model_spec) in enumerate(zip(names, models)):
566
+ run_config = (configs or {}).get(name, config)
567
+ this_opts = dict((model_opts or {}).get(name, {}))
568
+ run_adapter = this_opts.pop("adapter", adapter)
569
+ run_annotators = this_opts.pop("annotators", annotators)
570
+ run_extract_with = this_opts.pop("extract_with", extract_with)
571
+ run_opts = {**opts, **this_opts}
572
+ # Resolve the model HERE (rather than letting evaluate() do it) so we
573
+ # hold the instance and can free its memory before the next model
574
+ # loads -- keeping peak GPU use at one model instead of accumulating
575
+ # across the comparison. Only tear down a model we resolved ourselves;
576
+ # a Model instance the caller passed in is theirs to keep/reuse.
577
+ owns = not isinstance(model_spec, Model)
578
+ # vLLM sleep mode is one engine per process. Enable it only when a
579
+ # later vllm: model still has to load, so unload() can sleep(level=2)
580
+ # and free the T4. The last vllm: engine stays a normal LLM().
581
+ # Confirmed live: enabling it on every model raised
582
+ # "Sleep mode can only be used for one instance per process."
583
+ if (
584
+ owns
585
+ and isinstance(model_spec, str)
586
+ and model_spec.startswith("vllm:")
587
+ and any(
588
+ isinstance(m, str) and m.startswith("vllm:")
589
+ for m in models[i + 1:]
590
+ )
591
+ ):
592
+ run_opts.setdefault("enable_sleep_mode", True)
593
+ resolved: Model | None = None
594
+ try:
595
+ resolved = AutoModel.resolve(model_spec, **run_opts)
596
+ # opts already applied at resolve(); don't re-forward them to
597
+ # evaluate (a resolved instance ignores them anyway).
598
+ result = evaluate(
599
+ dataset, model=resolved, scorers=scorers, config=run_config,
600
+ adapter=run_adapter, annotators=run_annotators, extract_with=run_extract_with,
601
+ experiment_name=f"{experiment_name}:{name}" if experiment_name else None,
602
+ tags=tags,
603
+ )
604
+ except Exception as e:
605
+ # One bad model (an invalid/decommissioned spec, a real API
606
+ # error that exhausted retries, ...) used to crash this whole
607
+ # function, discarding every other model's already-computed
608
+ # results too. Record it and keep going instead -- callers can
609
+ # see exactly which model(s) failed via CompareResult.errors/
610
+ # .coverage_warnings(), rather than losing everything.
611
+ detail = str(e)
612
+ if e.__cause__ is not None:
613
+ # Runner._retry_generate() re-raises as a generic ModelError
614
+ # ("generate failed after N retries"), chaining the real
615
+ # underlying exception via `raise ... from` -- surface that
616
+ # cause too, or the recorded message is nearly useless.
617
+ detail = f"{detail} (caused by {type(e.__cause__).__name__}: {e.__cause__})"
618
+ errors[name] = f"{type(e).__name__}: {detail}"
619
+ logger.error("compare_models(): model %r failed entirely: %s", name, errors[name])
620
+ continue
621
+ finally:
622
+ # Free this model's local weights/GPU memory before the next one
623
+ # loads (no-op for API/callable backends). Runs on both the success
624
+ # and error paths; only for a model we resolved ourselves. The
625
+ # RunResult stores a stringified model_spec, not the live model, so
626
+ # unloading after evaluate() returns never affects the result.
627
+ if owns and resolved is not None:
628
+ try:
629
+ resolved.unload()
630
+ except Exception: # noqa: BLE001 -- teardown is best-effort
631
+ logger.debug("compare_models(): unload() failed for %r", name, exc_info=True)
632
+ runs[name] = result
633
+
634
+ first = next(iter(models), None)
635
+ if first is None:
636
+ raise ValueError("At least one model is required")
637
+ if isinstance(dataset, str):
638
+ from .registry import SCENARIOS
639
+ scenario = SCENARIOS.get(dataset)()
640
+ samples = list(scenario.samples())
641
+ elif isinstance(dataset, Scenario):
642
+ samples = list(dataset.samples())
643
+ elif callable(dataset):
644
+ samples = list(dataset())
645
+ else:
646
+ samples = list(dataset)
647
+
648
+ scorer_names = list(next(iter(runs.values())).headline.keys()) if runs else []
649
+
650
+ return CompareResult(
651
+ runs=runs,
652
+ dataset_size=len(samples),
653
+ scorers=scorer_names,
654
+ errors=errors,
655
+ )
@@ -0,0 +1,9 @@
1
+ # Red Teaming
2
+
3
+ Probes and detectors for adversarial evaluation of LLM safety and robustness.
4
+
5
+ **Built-in probes** — `prompt_injection`, `jailbreak`, `encoding`, `refusal`
6
+
7
+ **Built-in detectors** — `keyword`, `refusal`, `injection_success`, `system_prompt_leak`
8
+
9
+ Run with `RedTeamRunner` which orchestrates probe → model → detector pipelines.