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
auditkit/api.py ADDED
@@ -0,0 +1,502 @@
1
+ """The public evaluation API: :func:`evaluate`.
2
+
3
+ This is what users import::
4
+
5
+ import auditkit as ak
6
+ r = ak.evaluate([ak.Sample(input="hi", target="HI")], model=lambda prompts: ["HI" for _ in prompts])
7
+ r.headline # {"exact_match": 1.0}
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any, Callable, Iterable, Optional, Sequence, Union
14
+
15
+ from .adapter import Adapter, GenerationAdapter
16
+ from .annotator import Annotator
17
+ from .experiment import Experiment, ExperimentDB
18
+ from .loaders import load_csv # noqa: F401 — re-exported via __init__
19
+ from .metric import ExactMatch, Metric, QuasiExactMatch
20
+ from .model import (
21
+ Model,
22
+ AutoModel,
23
+ )
24
+ from .report import RunResult
25
+ from .runner import Runner
26
+ from .runspec import RunConfig, RunSpec
27
+ from .sample import Sample
28
+ from .scenario import CallableScenario, ListScenario, Scenario
29
+ from .scorers import FunctionScorer, ScorerMetric, ScorerType, scorer # noqa: F401
30
+ from .errors import AuditKitError, RegistryError
31
+ from .registry import ADAPTERS, ANNOTATORS, METRICS, SCENARIOS
32
+
33
+ logger = logging.getLogger("auditkit")
34
+
35
+ #: Known metric names resolvable from a string.
36
+ _BUILTIN_METRICS: dict[str, type[Metric]] = {
37
+ "exact_match": ExactMatch,
38
+ "quasi_exact_match": QuasiExactMatch,
39
+ }
40
+
41
+
42
+ def _to_scenario(
43
+ dataset: Union[Scenario, Sequence[Sample], Callable[[], Iterable[Sample]], str],
44
+ ) -> Scenario:
45
+ """Coerce any supported dataset form to a :class:`Scenario`."""
46
+ if isinstance(dataset, Scenario):
47
+ return dataset
48
+ if isinstance(dataset, str):
49
+ try:
50
+ return SCENARIOS.get(dataset)()
51
+ except RegistryError:
52
+ available = ", ".join(SCENARIOS.names())
53
+ raise AuditKitError(f"Unknown dataset '{dataset}'. Available: [{available}]")
54
+ if callable(dataset):
55
+ return CallableScenario(dataset)
56
+ # treat it as a list/iterable of Samples
57
+ return ListScenario(list(dataset))
58
+
59
+
60
+ def _to_metrics(
61
+ scorers: Any,
62
+ samples: Sequence[Sample],
63
+ ) -> list[Metric]:
64
+ """Coerce scorers/user metrics to a list of internal :class:`Metric`.
65
+
66
+ The resolution rules (from HANDOFF §Cycle-8 *step 4*):
67
+
68
+ - ``None``: auto-select. If any sample is golden (has a ``target``),
69
+ return ``[ExactMatch()]``; otherwise return ``[]``.
70
+ - ``str``: look up in the built-in map (``"exact_match"``,
71
+ ``"quasi_exact_match"``), falling back to the :data:`METRICS` registry
72
+ (populated by every metric class in ``metrics/*.py``) for any other
73
+ registered name (e.g. ``"acc_norm"``, ``"bleu"``, ``"lexical_groundedness"``).
74
+ - :class:`Metric`: returned as-is.
75
+ - :class:`ScorerType` (``FunctionScorer`` / callable): wrap in
76
+ :class:`ScorerMetric`.
77
+ - ``list``: recurse on each element.
78
+ """
79
+ if scorers is None:
80
+ if any(s.is_golden for s in samples):
81
+ return [ExactMatch()]
82
+ return []
83
+
84
+ if isinstance(scorers, str):
85
+ cls = _BUILTIN_METRICS.get(scorers)
86
+ if cls is None:
87
+ try:
88
+ cls = METRICS.get(scorers)
89
+ except RegistryError:
90
+ cls = None
91
+ if cls is None:
92
+ known = sorted(set(_BUILTIN_METRICS) | set(METRICS.names()))
93
+ raise ValueError(f"unknown metric {scorers!r}; known: {known}")
94
+ return [cls()]
95
+
96
+ if isinstance(scorers, Metric):
97
+ return [scorers]
98
+
99
+ if isinstance(scorers, FunctionScorer) or callable(scorers):
100
+ # Wrap a bare callable as a FunctionScorer if needed, then as Metric
101
+ if not isinstance(scorers, FunctionScorer):
102
+ scorers = FunctionScorer(scorers)
103
+ return [ScorerMetric(scorers)]
104
+
105
+ if isinstance(scorers, list):
106
+ result: list[Metric] = []
107
+ for item in scorers:
108
+ result.extend(_to_metrics(item, samples))
109
+ return result
110
+
111
+ raise TypeError(f"cannot interpret scorers={scorers!r}")
112
+
113
+
114
+ def _to_adapter(adapter: Union[Adapter, str, None], samples: Any = None) -> Adapter:
115
+ """Coerce ``None``/``"auto"``/a name string/an :class:`Adapter` to an :class:`Adapter`.
116
+
117
+ ``"auto"`` routes on the dataset's shape (``choices`` -> MCQ,
118
+ ``retrieval_context`` -> RAG, else generation); it needs ``samples``.
119
+ """
120
+ if adapter is None:
121
+ return GenerationAdapter()
122
+ if adapter == "auto":
123
+ from .router import route_adapter
124
+ return route_adapter(samples if samples is not None else [])
125
+ if isinstance(adapter, str):
126
+ try:
127
+ return ADAPTERS.get(adapter)()
128
+ except RegistryError:
129
+ raise AuditKitError(f"Unknown adapter {adapter!r}. Available: {ADAPTERS.names()}")
130
+ return adapter
131
+
132
+
133
+ def _to_annotators(annotators: Any) -> list[Annotator]:
134
+ """Coerce ``None``/an :class:`Annotator`/a name string/a list of any of
135
+ those to ``list[Annotator]``."""
136
+ if annotators is None:
137
+ return []
138
+ if isinstance(annotators, Annotator):
139
+ return [annotators]
140
+ if isinstance(annotators, str):
141
+ try:
142
+ return [ANNOTATORS.get(annotators)()]
143
+ except RegistryError:
144
+ raise AuditKitError(f"Unknown annotator {annotators!r}. Available: {ANNOTATORS.names()}")
145
+ if isinstance(annotators, list):
146
+ result: list[Annotator] = []
147
+ for item in annotators:
148
+ result.extend(_to_annotators(item))
149
+ return result
150
+ raise TypeError(f"cannot interpret annotators={annotators!r}")
151
+
152
+
153
+ def _record_experiment(
154
+ result: RunResult, experiment_name: str | None, tags: list[str] | None
155
+ ) -> RunResult:
156
+ """Stamp experiment/tags on a result and persist it if named."""
157
+ result.experiment_name = experiment_name
158
+ result.tags = tags or []
159
+ if experiment_name:
160
+ exp = Experiment(name=experiment_name)
161
+ exp.add(result)
162
+ ExperimentDB().save(exp)
163
+ return result
164
+
165
+
166
+ def evaluate(
167
+ dataset: Union[Scenario, Sequence[Sample], Callable[[], Iterable[Sample]], str],
168
+ model: Union[Model, Callable[[list[str]], list[str]], str],
169
+ scorers: Any = None,
170
+ *,
171
+ engine: str = "native",
172
+ adapter: Union[Adapter, str, None] = None,
173
+ annotators: Any = None,
174
+ extract_with: str | None = None,
175
+ config: Optional[RunConfig] = None,
176
+ verbose: bool = False,
177
+ experiment_name: str | None = None,
178
+ tags: list[str] | None = None,
179
+ **opts: Any,
180
+ ) -> RunResult:
181
+ """Evaluate a model on a dataset and return a :class:`RunResult`.
182
+
183
+ The single front door for both engines. ``engine="native"`` (default) runs
184
+ the owned spine (Adapter → Model → Metric); ``engine="lmeval"`` runs the
185
+ lm-evaluation-harness with its full task machinery (see :func:`run_lmeval`),
186
+ treating ``dataset`` as the task name(s).
187
+
188
+ Parameters
189
+ ----------
190
+ dataset
191
+ A :class:`Scenario`, a list of :class:`Sample`, a callable that yields
192
+ samples, or a scenario name (native). With ``engine="lmeval"``, an
193
+ lm-eval task name, comma-separated string, or list of names.
194
+ model
195
+ A :class:`Model` instance, a ``list[str] -> list[str]`` callable, or a
196
+ string model spec (e.g. ``"hf:gpt2"``, ``"groq:llama-3.3-70b-versatile"``,
197
+ ``"precomputed"`` for samples with ``actual_output`` already set). With
198
+ ``engine="lmeval"`` a string spec is required (``"hf:gpt2"``, …).
199
+ scorers
200
+ ``None`` (auto-select ``ExactMatch`` if golden samples exist),
201
+ a metric name string, a :class:`Metric`, a :class:`FunctionScorer`,
202
+ a ``(sample, output) -> float|Score`` callable, or a list of any of
203
+ the above. Ignored by ``engine="lmeval"`` (lm-eval owns its scoring).
204
+ adapter
205
+ An :class:`Adapter` instance, a registered adapter name string (e.g.
206
+ ``"mcq"``, ``"chat"``, ``"rag"`` — see ``ADAPTERS.names()``), or
207
+ ``None``. Defaults to :class:`GenerationAdapter`.
208
+ annotators
209
+ An :class:`Annotator` instance, a registered name string (e.g.
210
+ ``"regex"`` for :class:`RegexAnnotator`), a list of either, or
211
+ ``None``. Annotator output lands in each :class:`Score`/metric's
212
+ ``context`` under the annotator's name; pass ``extract_with=`` to
213
+ actually score against one annotator's extracted value.
214
+ extract_with
215
+ Names an annotator (by its ``.name``) whose ``context["extracted"]``
216
+ value should be scored instead of the raw model output — e.g. a
217
+ ``RegexAnnotator(r"FINAL ANSWER:\\s*(\\d+)", group=1)`` pulling
218
+ ``"42"`` out of a longer chain-of-thought reply. ``None`` (default):
219
+ score the raw output, unchanged from before this existed.
220
+ config
221
+ A :class:`RunConfig` with evaluation knobs. Defaults to ``RunConfig()``.
222
+ **opts
223
+ Extra keyword args passed to the model constructor (native) or to
224
+ lm-eval's ``model_args`` (``engine="lmeval"``: ``base_url``, ``dtype``, …).
225
+ """
226
+ if engine == "lmeval":
227
+ from .lmeval_engine import run_benchmark
228
+
229
+ result = run_benchmark(
230
+ dataset, model, config=config, run_name=experiment_name or "", **opts
231
+ )
232
+ return _record_experiment(result, experiment_name, tags)
233
+ if engine != "native":
234
+ raise ValueError(f"unknown engine {engine!r}; use 'native' or 'lmeval'")
235
+
236
+ scenario = _to_scenario(dataset)
237
+ resolved_model = AutoModel.resolve(model, **opts)
238
+ samples = list(scenario.samples())
239
+ metrics = _to_metrics(scorers, samples)
240
+ cfg = config or RunConfig()
241
+
242
+ spec = RunSpec(
243
+ scenario=scenario,
244
+ model=resolved_model,
245
+ adapter=_to_adapter(adapter, samples),
246
+ metrics=metrics,
247
+ annotators=_to_annotators(annotators),
248
+ extracted_by=extract_with,
249
+ config=cfg,
250
+ )
251
+
252
+ result = Runner().run(spec, verbose=verbose)
253
+ return _record_experiment(result, experiment_name, tags)
254
+
255
+
256
+ def run_lmeval(
257
+ tasks: Union[str, list[str]],
258
+ model: str,
259
+ *,
260
+ num_fewshot: int | None = None,
261
+ limit: int | None = None,
262
+ config: Optional[RunConfig] = None,
263
+ experiment_name: str | None = None,
264
+ tags: list[str] | None = None,
265
+ **opts: Any,
266
+ ) -> RunResult:
267
+ """Run academic benchmarks via lm-evaluation-harness (its full task machinery).
268
+
269
+ The dedicated front door for the lm-eval engine -- unlike :func:`evaluate`
270
+ (which defaults to the native spine and only reaches lm-eval via
271
+ ``engine="lmeval"``), this function is *always* lm-eval, unconditionally;
272
+ there is no native/lmeval switch here. lm-eval owns the task, prompt
273
+ template, filters, and scoring; this returns the same uniform
274
+ :class:`RunResult` (headline + per-sample answer browser) so
275
+ :func:`compare`, diffs, and experiment tracking work identically.
276
+
277
+ Requires ``pip install auditkit[lmeval]``. For gated models (Llama,
278
+ Gemma, ...) pass ``hf_token=`` (or ``token=``); it is forwarded to lm-eval's
279
+ ``model_args`` and mirrored into ``HF_TOKEN`` for gated dataset access.
280
+
281
+ Model-backend options (``base_url``, ``dtype``, ``device``, ``hf_token``, …)
282
+ and lm-eval run knobs all pass through. Common run knobs: ``apply_chat_template``
283
+ (set ``True`` for instruct/chat/fine-tuned models), ``gen_kwargs`` (generation
284
+ params for generative tasks), ``system_instruction``, ``fewshot_as_multiturn``.
285
+ Anything else ``simple_evaluate`` accepts goes via ``lmeval_kwargs={...}``.
286
+
287
+ Examples
288
+ --------
289
+ ::
290
+
291
+ ak.run_lmeval(["arc_challenge", "gsm8k"], model="hf:gpt2", num_fewshot=5)
292
+ ak.run_lmeval("mmlu", model="vllm:meta-llama/Llama-3.2-1B", limit=100)
293
+ ak.run_lmeval("gsm8k", model="api:my-model", base_url="https://…/v1/completions")
294
+ ak.run_lmeval("mmlu", model="hf:meta-llama/Llama-3.2-1B", hf_token="hf_…") # gated
295
+ ak.run_lmeval("ifeval", model="hf:my-finetune", apply_chat_template=True) # instruct
296
+ ak.run_lmeval("gsm8k", model="hf:gpt2", gen_kwargs="temperature=0,max_gen_toks=256")
297
+ """
298
+ from .lmeval_engine import run_benchmark
299
+
300
+ cfg = config
301
+ if cfg is None:
302
+ cfg = RunConfig(num_fewshot=num_fewshot, limit=limit)
303
+ result = run_benchmark(
304
+ tasks, model, config=cfg, run_name=experiment_name or "", **opts
305
+ )
306
+ return _record_experiment(result, experiment_name, tags)
307
+
308
+
309
+ def generate(
310
+ dataset: Union[Scenario, Sequence[Sample], Callable[[], Iterable[Sample]], str],
311
+ model: Union[Model, Callable[[list[str]], list[str]], str],
312
+ *,
313
+ adapter: Union[Adapter, str, None] = None,
314
+ config: Optional[RunConfig] = None,
315
+ verbose: bool = False,
316
+ **opts: Any,
317
+ ) -> list[Sample]:
318
+ """Generate answers for a dataset and return samples with ``actual_output`` set.
319
+
320
+ Stage 1 of the generate→score flow. Runs ``model`` over the dataset's inputs
321
+ and returns copies of the samples with their answers filled in. Score them
322
+ afterwards — repeatedly, with different scorers, without re-generating::
323
+
324
+ answers = ak.generate(data, model="hf:my-pruned-model")
325
+ ak.evaluate(answers, model="precomputed", scorers=[judge])
326
+ ak.evaluate(answers, model="precomputed", scorers=[ak.Factuality(judge_model="openai:gpt-4o-mini")])
327
+ """
328
+ from dataclasses import replace
329
+
330
+ scenario = _to_scenario(dataset)
331
+ resolved_model = AutoModel.resolve(model, **opts)
332
+ samples = list(scenario.samples())
333
+ spec = RunSpec(
334
+ scenario=ListScenario(samples),
335
+ model=resolved_model,
336
+ adapter=_to_adapter(adapter, samples),
337
+ metrics=[],
338
+ config=config or RunConfig(),
339
+ )
340
+ result = Runner().run(spec, verbose=verbose)
341
+ return [replace(s, actual_output=p.raw_output)
342
+ for s, p in zip(samples, result.predictions)]
343
+
344
+
345
+ def evaluate_many(
346
+ datasets: Union[dict[str, Any], Sequence[Any]],
347
+ model: Union[Model, Callable[[list[str]], list[str]], str],
348
+ *,
349
+ scorers: Any = None,
350
+ adapter: Union[Adapter, str, None] = None,
351
+ engine: str = "native",
352
+ config: Optional[RunConfig] = None,
353
+ verbose: bool = False,
354
+ experiment_name: str | None = None,
355
+ tags: list[str] | None = None,
356
+ on_error: str = "raise",
357
+ **opts: Any,
358
+ ) -> dict[str, RunResult]:
359
+ """Evaluate one model across **several datasets** in a single call.
360
+
361
+ :func:`evaluate` takes exactly one dataset, one adapter, and one scorer set —
362
+ because a run is a single reproducible unit (one ``RunSpec``, one fingerprint).
363
+ Different benchmarks legitimately need different scorers (SORRY-Bench rewards
364
+ refusal; OR-Bench penalises *over*-refusal; GSM8K wants ``exact_match``) and
365
+ sometimes a different adapter, and each deserves its **own** headline number
366
+ and its own cache entry. So this runs each dataset as its own ``evaluate()``
367
+ call, **sequentially** (not concurrently), and returns one
368
+ :class:`RunResult` per dataset — never a blended mean.
369
+
370
+ Parameters
371
+ ----------
372
+ datasets
373
+ Either a ``dict[name -> spec]`` (recommended — you name each benchmark),
374
+ or a ``list[spec]`` (names are derived: a dataset string, else the
375
+ scenario's ``.name``, else ``"dataset_{i}"``). Each ``spec`` is either:
376
+
377
+ * a bare dataset — anything :func:`evaluate` accepts as ``dataset``
378
+ (a ``list[Sample]``, a :class:`Scenario`, a callable, or a scenario
379
+ name) — scored with the shared ``scorers``/``adapter`` below; or
380
+ * a **tuple** ``(dataset,)``, ``(dataset, scorers)``, or
381
+ ``(dataset, scorers, adapter)`` to override the scorers/adapter for
382
+ just that benchmark. (A tuple is unambiguous here — no dataset form is
383
+ itself a tuple.)
384
+ model
385
+ Resolved **once** up front (native engine) and reused for every dataset,
386
+ so a local ``hf:`` checkpoint is loaded a single time rather than
387
+ re-loaded per benchmark. ``**opts`` are the model constructor kwargs.
388
+ scorers, adapter
389
+ Shared defaults used for any dataset whose ``spec`` doesn't override them.
390
+ on_error
391
+ ``"raise"`` (default) — the first failing dataset aborts the batch, same
392
+ as calling :func:`evaluate` directly. ``"skip"`` — log the failure and
393
+ continue, omitting that dataset from the returned dict (so one bad
394
+ dataset load doesn't lose the benchmarks that did run).
395
+ experiment_name
396
+ When set, each dataset's run is persisted under ``f"{experiment_name}:{name}"``
397
+ so the per-benchmark runs stay individually addressable in the ExperimentDB.
398
+
399
+ Returns
400
+ -------
401
+ dict[str, RunResult]
402
+ One entry per dataset, keyed by name, in input order.
403
+
404
+ Examples
405
+ --------
406
+ Heterogeneous safety benches, each with its own judge, one model, one call::
407
+
408
+ results = ak.evaluate_many(
409
+ {
410
+ "sorry_bench": (sorry_samples, [refusal_judge]), # refusal = good
411
+ "or_bench": (or_samples, [over_refusal_judge]), # over-refusal = bad
412
+ "gsm8k_local": (gsm_samples, ["exact_match"]),
413
+ },
414
+ model="groq:llama-3.3-70b-versatile",
415
+ )
416
+ for name, r in results.items():
417
+ print(name, r.headline)
418
+
419
+ A list of same-shaped datasets sharing one scorer::
420
+
421
+ results = ak.evaluate_many([ds_a, ds_b, ds_c], model="hf:gpt2",
422
+ scorers=["exact_match"], device="cpu")
423
+ """
424
+ if on_error not in ("raise", "skip"):
425
+ raise ValueError(f"on_error must be 'raise' or 'skip', got {on_error!r}")
426
+
427
+ # Resolve the model ONCE (native) so a heavy local checkpoint isn't reloaded
428
+ # per dataset. lm-eval builds its own backend from a string spec, so leave
429
+ # the spec (and opts) untouched on that path.
430
+ if engine == "native":
431
+ model = AutoModel.resolve(model, **opts)
432
+ opts = {}
433
+
434
+ items = datasets.items() if isinstance(datasets, dict) else enumerate(datasets)
435
+ results: dict[str, RunResult] = {}
436
+ for key, spec in items:
437
+ if isinstance(spec, tuple):
438
+ ds = spec[0]
439
+ ds_scorers = spec[1] if len(spec) > 1 else scorers
440
+ ds_adapter = spec[2] if len(spec) > 2 else adapter
441
+ else:
442
+ ds, ds_scorers, ds_adapter = spec, scorers, adapter
443
+
444
+ if isinstance(key, str):
445
+ name = key
446
+ elif isinstance(ds, str):
447
+ name = ds
448
+ else:
449
+ name = getattr(ds, "name", None) or f"dataset_{key}"
450
+
451
+ try:
452
+ results[name] = evaluate(
453
+ ds, model=model, scorers=ds_scorers, adapter=ds_adapter,
454
+ engine=engine, config=config, verbose=verbose,
455
+ experiment_name=f"{experiment_name}:{name}" if experiment_name else None,
456
+ tags=tags, **opts,
457
+ )
458
+ except Exception as e: # noqa: BLE001 — isolation is opt-in via on_error
459
+ if on_error == "raise":
460
+ raise
461
+ logger.error("evaluate_many: dataset %r failed, skipping: %s", name, e)
462
+ return results
463
+
464
+
465
+ def compare(baseline_or_results, candidate=None, *, metric: str | None = None, **kw):
466
+ """Two shapes:
467
+
468
+ - ``compare(baseline_run, candidate_run)`` — two :class:`RunResult`s → a
469
+ :class:`~auditkit.comparison.RunComparison` (per-task deltas, pass/warn/fail,
470
+ newly-wrong browser, retention, significance). This is the base-vs-pruned view.
471
+ - ``compare([run1, run2, ...], metric=...)`` — a list of runs → a leaderboard
472
+ (one dict per run, sorted by ``metric`` or the first headline metric).
473
+
474
+ ``kw`` (``pass_threshold``/``warn_threshold``) is forwarded to ``RunComparison``.
475
+ """
476
+ if candidate is not None:
477
+ from .comparison import RunComparison
478
+ return RunComparison(baseline_or_results, candidate, **kw)
479
+
480
+ results = baseline_or_results
481
+ if not results:
482
+ return []
483
+ first = metric or next(iter(results[0].headline.keys()), None)
484
+ rows = []
485
+ for r in results:
486
+ row = {"run_id": r.run_id, "fingerprint": r.fingerprint}
487
+ row.update(r.headline)
488
+ rows.append(row)
489
+ if first:
490
+ rows.sort(key=lambda x: x.get(first, 0), reverse=True)
491
+ return rows
492
+
493
+
494
+ __all__ = [
495
+ "run_lmeval",
496
+ "compare",
497
+ "evaluate",
498
+ "evaluate_many",
499
+ "generate",
500
+ "load_csv",
501
+ "scorer",
502
+ ]
Binary file
auditkit/cache.py ADDED
@@ -0,0 +1,47 @@
1
+ """Simple disk cache for evaluation results keyed by fingerprint."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from .report import RunResult
9
+
10
+
11
+ class DiskCache:
12
+ """Persist RunResult objects keyed by run fingerprint.
13
+
14
+ Defaults to ``~/.cache/auditkit/runs/``.
15
+ """
16
+
17
+ def __init__(self, cache_dir: str | None = None) -> None:
18
+ self._dir = Path(cache_dir or os.path.join(
19
+ os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")),
20
+ "auditkit", "runs",
21
+ ))
22
+ self._dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ def _path(self, fingerprint: str) -> Path:
25
+ return self._dir / f"{fingerprint}.json"
26
+
27
+ def get(self, fingerprint: str) -> RunResult | None:
28
+ path = self._path(fingerprint)
29
+ if path.exists():
30
+ with open(path, "r") as fh:
31
+ return RunResult.from_dict(json.load(fh))
32
+ return None
33
+
34
+ def set(self, fingerprint: str, result: RunResult) -> None:
35
+ path = self._path(fingerprint)
36
+ tmp = path.with_suffix(".tmp")
37
+ with open(tmp, "w") as fh:
38
+ json.dump(result.to_dict(), fh, indent=2, default=str)
39
+ tmp.rename(path)
40
+
41
+ def clear(self) -> None:
42
+ for p in self._dir.iterdir():
43
+ if p.suffix == ".json":
44
+ p.unlink()
45
+
46
+ def __contains__(self, fingerprint: str) -> bool:
47
+ return self._path(fingerprint).exists()