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/diff.py ADDED
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Iterable, Optional
5
+
6
+ from .report import RunResult, Prediction
7
+ from .types import Direction
8
+
9
+
10
+ class DeltaGrade(str, Enum):
11
+ PASS = "pass"
12
+ WARN = "warn"
13
+ FAIL = "fail"
14
+ # A metric that was scored on only one side (added after the baseline
15
+ # ran, or inapplicable to every sample there) has no real delta to
16
+ # grade at all. Previously the missing side silently defaulted to 0.0,
17
+ # producing a fabricated (and often FAIL-grade) delta indistinguishable
18
+ # from a genuine regression to zero. This is the explicit "nothing to
19
+ # compare" signal instead.
20
+ NOT_COMPARABLE = "not_comparable"
21
+
22
+
23
+ def grade_delta(
24
+ delta: float,
25
+ direction: Direction = Direction.MAXIMIZE,
26
+ pass_threshold: float = 0.02,
27
+ warn_threshold: float = 0.05,
28
+ ) -> DeltaGrade:
29
+ """Grade a candidate−baseline delta, accounting for the metric's direction.
30
+
31
+ The *regression* is how much worse the candidate is: for a MAXIMIZE metric a
32
+ drop (negative delta) is a regression; for a MINIMIZE metric (latency, WER,
33
+ toxicity) a rise (positive delta) is. An improvement, or a regression within
34
+ ``pass_threshold``, is PASS; within ``warn_threshold``, WARN; beyond, FAIL.
35
+ """
36
+ regression = -delta if direction == Direction.MAXIMIZE else delta
37
+ if regression <= pass_threshold:
38
+ return DeltaGrade.PASS
39
+ if regression <= warn_threshold:
40
+ return DeltaGrade.WARN
41
+ return DeltaGrade.FAIL
42
+
43
+
44
+ def metric_directions(runs: Iterable[RunResult]) -> dict[str, Direction]:
45
+ """Every metric name -> its declared :class:`Direction`, read straight
46
+ from each run's stored per-score dicts (``Prediction.metadata["scores"]``,
47
+ populated by ``Runner.score_one()`` from ``Metric.direction`` -- see
48
+ ``metric.py``'s compulsory-direction enforcement). Shared by
49
+ :class:`RunDiff`, :class:`~auditkit.comparison.RunComparison` (2 runs),
50
+ and :class:`~auditkit.model_compare.CompareResult` (N runs) so all three
51
+ read direction the exact same way, not independently-maintained lookups
52
+ that could drift apart.
53
+ """
54
+ dirs: dict[str, Direction] = {}
55
+ for run in runs:
56
+ for p in run.predictions:
57
+ for s in p.metadata.get("scores", []):
58
+ name, raw = s.get("name"), s.get("direction")
59
+ if name and name not in dirs and raw is not None:
60
+ try:
61
+ dirs[name] = Direction(raw)
62
+ except ValueError:
63
+ dirs[name] = Direction.MAXIMIZE
64
+ return dirs
65
+
66
+
67
+ def direction_for(metric: str, dirs: dict[str, Direction]) -> Direction:
68
+ """Look up *metric*'s direction in *dirs* (from :func:`metric_directions`),
69
+ falling back from a benchmark-style ``"task:metric"`` key to the base
70
+ metric name, and finally to MAXIMIZE if the metric was never scored at
71
+ all (nothing to look up)."""
72
+ base = metric.split(":", 1)[1] if ":" in metric else metric
73
+ return dirs.get(metric) or dirs.get(base) or Direction.MAXIMIZE
74
+
75
+
76
+ def best_model(scores: dict[str, float], direction: Direction) -> Optional[str]:
77
+ """The winning model name for *scores* (``{model_name: value}``),
78
+ respecting *direction* -- the lowest value wins for MINIMIZE metrics
79
+ (latency, error rate, ...), not just whichever is numerically largest."""
80
+ if not scores:
81
+ return None
82
+ if direction == Direction.MINIMIZE:
83
+ return min(scores, key=scores.get)
84
+ return max(scores, key=scores.get)
85
+
86
+
87
+ def relative_pct(baseline: Optional[float], delta: Optional[float]) -> Optional[float]:
88
+ """The change as a percentage of the baseline (``delta / baseline * 100``).
89
+
90
+ This is the "percentage difference in the metric" the comparison surfaces so
91
+ the reader can judge whether a change matters, rather than the library
92
+ imposing a fixed pass/warn/fail threshold that isn't meaningful across every
93
+ metric. Returns ``None`` when the baseline is missing or ``<= 0`` (a percent
94
+ of zero/negative is undefined -- fall back to the absolute delta there).
95
+ """
96
+ if baseline is None or delta is None or baseline <= 0:
97
+ return None
98
+ return delta / baseline * 100.0
99
+
100
+
101
+
102
+
103
+ class RunDiff:
104
+ def __init__(self, baseline: RunResult, contrast: RunResult) -> None:
105
+ self.baseline = baseline
106
+ self.contrast = contrast
107
+
108
+ def _directions(self) -> dict[str, Direction]:
109
+ return metric_directions((self.baseline, self.contrast))
110
+
111
+ def _direction(self, metric: str) -> Direction:
112
+ return direction_for(metric, self._directions())
113
+
114
+ def metric_deltas(self) -> dict[str, dict[str, float]]:
115
+ all_metrics = set(self.baseline.stats.keys()) | set(self.contrast.stats.keys())
116
+ result: dict[str, dict[str, float]] = {}
117
+ for name in sorted(all_metrics):
118
+ in_baseline = name in self.baseline.stats
119
+ in_contrast = name in self.contrast.stats
120
+ b_mean = self.baseline.stats[name].mean if in_baseline else None
121
+ c_mean = self.contrast.stats[name].mean if in_contrast else None
122
+ result[name] = {
123
+ "baseline": b_mean,
124
+ "contrast": c_mean,
125
+ # None (not 0.0 - c_mean's old fallback) when either side
126
+ # never scored this metric at all -- a fabricated delta
127
+ # against a phantom zero is worse than an honest "no data".
128
+ "delta": (c_mean - b_mean) if (in_baseline and in_contrast) else None,
129
+ "comparable": in_baseline and in_contrast,
130
+ }
131
+ return result
132
+
133
+ def grade(self, metric_name: str,
134
+ pass_threshold: float = 0.02,
135
+ warn_threshold: float = 0.05) -> DeltaGrade:
136
+ deltas = self.metric_deltas()
137
+ if metric_name not in deltas:
138
+ raise KeyError(f"metric {metric_name!r} not found in deltas")
139
+ info = deltas[metric_name]
140
+ if not info["comparable"]:
141
+ return DeltaGrade.NOT_COMPARABLE
142
+ return grade_delta(info["delta"], self._direction(metric_name), pass_threshold, warn_threshold)
143
+
144
+ def grades(self, pass_threshold: float = 0.02,
145
+ warn_threshold: float = 0.05) -> dict[str, DeltaGrade]:
146
+ return {name: self.grade(name, pass_threshold, warn_threshold)
147
+ for name in self.metric_deltas()}
148
+
149
+ @staticmethod
150
+ def _predictions_by_id(result: RunResult) -> dict[str, Prediction]:
151
+ return {p.sample_id: p for p in result.predictions}
152
+
153
+ @staticmethod
154
+ def _is_correct(pred: Prediction | None, metric_name: str) -> bool | None:
155
+ if pred is None:
156
+ return None
157
+ if not metric_name:
158
+ return pred.correct
159
+ for score_doc in pred.metadata.get("scores", []):
160
+ if score_doc.get("name") == metric_name:
161
+ # Use the metric's own gate (passed) when it has a threshold;
162
+ # bool(value) was wrong for any thresholded/continuous score
163
+ # (e.g. value=0.6, threshold=0.7, passed=False -- bool(0.6)
164
+ # is truthy, silently reporting a failing sample as correct).
165
+ # Falls back to the same value==1.0 convention Runner.score_one()
166
+ # itself uses for Prediction.correct when there's no threshold.
167
+ passed = score_doc.get("passed")
168
+ if passed is not None:
169
+ return bool(passed)
170
+ return score_doc.get("value") == 1.0
171
+ return None
172
+
173
+ def sample_diff(self, metric_name: str = "") -> list[dict]:
174
+ base_by_id = self._predictions_by_id(self.baseline)
175
+ cont_by_id = self._predictions_by_id(self.contrast)
176
+ all_ids = set(base_by_id.keys()) | set(cont_by_id.keys())
177
+
178
+ entries = []
179
+ for sid in sorted(all_ids):
180
+ bp = base_by_id.get(sid)
181
+ cp = cont_by_id.get(sid)
182
+ if bp is None or cp is None:
183
+ change = "unknown"
184
+ else:
185
+ b_correct = self._is_correct(bp, metric_name)
186
+ c_correct = self._is_correct(cp, metric_name)
187
+ if b_correct is True and c_correct is False:
188
+ change = "newly_wrong"
189
+ elif b_correct is False and c_correct is True:
190
+ change = "newly_correct"
191
+ elif b_correct is False and c_correct is False:
192
+ change = "still_wrong"
193
+ elif b_correct is True and c_correct is True:
194
+ change = "still_correct"
195
+ else:
196
+ change = "unknown"
197
+
198
+ entries.append({
199
+ "sample_id": sid,
200
+ "baseline_correct": self._is_correct(bp, metric_name) if bp else None,
201
+ "contrast_correct": self._is_correct(cp, metric_name) if cp else None,
202
+ "change": change,
203
+ })
204
+ return entries
205
+
206
+ def _filter_by_change(self, change: str, metric_name: str = "") -> list[Prediction]:
207
+ cont_by_id = self._predictions_by_id(self.contrast)
208
+ result = []
209
+ for entry in self.sample_diff(metric_name):
210
+ if entry["change"] == change:
211
+ sid = entry["sample_id"]
212
+ cp = cont_by_id.get(sid)
213
+ if cp is not None:
214
+ result.append(cp)
215
+ return result
216
+
217
+ def regressed(self, metric_name: str = "") -> list[Prediction]:
218
+ """Samples correct in ``baseline`` but wrong in ``contrast`` -- what broke.
219
+
220
+ Was ``newly_wrong()`` -- renamed because "newly" implied history/
221
+ tracking over time that doesn't exist here; this is always a one-shot,
222
+ two-run comparison.
223
+ """
224
+ return self._filter_by_change("newly_wrong", metric_name)
225
+
226
+ def improved(self, metric_name: str = "") -> list[Prediction]:
227
+ """Samples wrong in ``baseline`` but fixed in ``contrast``. Was ``newly_correct()``."""
228
+ return self._filter_by_change("newly_correct", metric_name)
229
+
230
+ def still_wrong(self, metric_name: str = "") -> list[Prediction]:
231
+ return self._filter_by_change("still_wrong", metric_name)
232
+
233
+ def still_correct(self, metric_name: str = "") -> list[Prediction]:
234
+ return self._filter_by_change("still_correct", metric_name)
235
+
236
+ def sample_summary(self, metric_name: str = "") -> dict[str, int]:
237
+ counts: dict[str, int] = {
238
+ "newly_wrong": 0,
239
+ "newly_correct": 0,
240
+ "still_wrong": 0,
241
+ "still_correct": 0,
242
+ "unknown": 0,
243
+ }
244
+ for entry in self.sample_diff(metric_name):
245
+ counts[entry["change"]] += 1
246
+ return counts
247
+
248
+ def summary(self) -> str:
249
+ # Reports the change per metric -- absolute delta AND relative % -- and
250
+ # nothing else. No pass/warn/fail verdict, and no better/worse framing:
251
+ # a fixed threshold isn't meaningful across every metric, and it's the
252
+ # reader's job to interpret the numbers for their own metrics. (An opt-in
253
+ # threshold gate is still available via .grade()/.grades().) ASCII only
254
+ # -- this string is often printed to a console that can't encode
255
+ # arrows/greek.
256
+ lines = [f"baseline {self.baseline.run_id} -> contrast {self.contrast.run_id}"]
257
+ for name, info in sorted(self.metric_deltas().items()):
258
+ if not info["comparable"]:
259
+ lines.append(f" {name}: N/A -- only scored on one side")
260
+ continue
261
+ pct = relative_pct(info["baseline"], info["delta"])
262
+ pct_s = f"{pct:+.1f}% rel" if pct is not None else "rel n/a"
263
+ lines.append(f" {name}: {info['baseline']:.4f} -> {info['contrast']:.4f} "
264
+ f"delta={info['delta']:+.4f} ({pct_s})")
265
+ return "\n".join(lines)
auditkit/errors.py ADDED
@@ -0,0 +1,54 @@
1
+ """Typed errors AuditKIT raises.
2
+
3
+ Everything derives from :class:`AuditKitError` so a caller can catch the whole
4
+ family with one ``except``. The platform maps each type to a ``#08-xxx`` code at
5
+ its service boundary; the library itself stays framework-free.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class AuditKitError(Exception):
12
+ """Base class for every error AuditKIT raises."""
13
+
14
+
15
+ class CapabilityError(AuditKitError):
16
+ """A model was asked for something it doesn't declare (e.g. loglikelihood)."""
17
+
18
+
19
+ class RegistryError(AuditKitError):
20
+ """A name was not found in, or already taken in, a registry."""
21
+
22
+
23
+ class ExtraNotInstalled(AuditKitError):
24
+ """An optional backend/metric was used without its extra installed.
25
+
26
+ *hint*, when given, is the complete, ready-to-run fix (e.g. ``"pip
27
+ install auditkit[vllm]"``) and becomes the message verbatim -- every
28
+ caller already passes a full actionable instruction here, so prefixing
29
+ a second, separately-generated ``"install auditkit[{extra}]"`` in front
30
+ of it (the previous behavior) just produced a redundant, confusing
31
+ message like ``"install auditkit[vllm] for pip install auditkit[vllm]"``.
32
+ Falls back to ``"install auditkit[{extra}]"`` only when no *hint* is
33
+ given at all.
34
+ """
35
+
36
+ def __init__(self, extra: str, hint: str = "") -> None:
37
+ super().__init__(hint if hint else f"install auditkit[{extra}]")
38
+ self.extra = extra
39
+
40
+
41
+ class ModelError(AuditKitError):
42
+ """Model backend failure (possibly transient)."""
43
+
44
+
45
+ class MetricError(AuditKitError):
46
+ """Metric computation failure."""
47
+
48
+
49
+ class SampleSkipped(AuditKitError):
50
+ """Sample-level error (non-fatal, result still returned)."""
51
+
52
+
53
+ class ModelTimeout(AuditKitError):
54
+ """Model call exceeded the configured timeout."""
auditkit/evaluator.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from auditkit.scenario import Scenario
8
+ from auditkit.runner import RunConfig
9
+ from auditkit.score import Score
10
+ from auditkit.model import Model
11
+ from auditkit.sample import Prediction
12
+
13
+
14
+ class Evaluator(ABC):
15
+ technique: str = "generic"
16
+
17
+ @abstractmethod
18
+ def evaluate(
19
+ self, scenario: Scenario, model: Model, config: RunConfig
20
+ ) -> tuple[list[Score], list[Prediction]]: ...
auditkit/experiment.py ADDED
@@ -0,0 +1,145 @@
1
+ """Experiment tracking for multi-run comparison and persistence."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .diff import RunDiff
11
+ from .errors import ExtraNotInstalled
12
+ from .report import RunResult
13
+
14
+
15
+ @dataclass
16
+ class Experiment:
17
+ name: str
18
+ runs: list[RunResult] = field(default_factory=list)
19
+ metadata: dict[str, Any] = field(default_factory=dict)
20
+
21
+ def add(self, run: RunResult) -> None:
22
+ self.runs.append(run)
23
+
24
+ def aggregate(self, metric: str | None = None) -> dict[str, float]:
25
+ if not self.runs:
26
+ return {}
27
+ if metric:
28
+ vals = [r.headline.get(metric, 0.0) for r in self.runs if r.headline]
29
+ return {metric: sum(vals) / len(vals) if vals else 0.0}
30
+ all_metrics = set()
31
+ for r in self.runs:
32
+ all_metrics.update(r.headline.keys())
33
+ result = {}
34
+ for m in sorted(all_metrics):
35
+ vals = [r.headline.get(m, 0.0) for r in self.runs if r.headline]
36
+ result[m] = sum(vals) / len(vals) if vals else 0.0
37
+ return result
38
+
39
+ def leaderboard(self) -> list[dict[str, Any]]:
40
+ if not self.runs:
41
+ return []
42
+ first_metric = next(iter(self.runs[0].headline.keys()), None)
43
+ rows = []
44
+ for r in self.runs:
45
+ row = {"run_id": r.run_id, "fingerprint": r.fingerprint}
46
+ row.update(r.headline)
47
+ rows.append(row)
48
+ if first_metric:
49
+ rows.sort(key=lambda x: x.get(first_metric, 0), reverse=True)
50
+ return rows
51
+
52
+ def pairwise_diff(self, run_a_index: int = 0, run_b_index: int = -1) -> RunDiff:
53
+ return RunDiff(self.runs[run_a_index], self.runs[run_b_index])
54
+
55
+ def log_mlflow(self, experiment_name: str | None = None, tracking_uri: str | None = None) -> None:
56
+ try:
57
+ import mlflow
58
+ except ImportError:
59
+ raise ExtraNotInstalled("mlflow", "pip install auditkit[mlflow]")
60
+ if tracking_uri:
61
+ mlflow.set_tracking_uri(tracking_uri)
62
+ exp_name = experiment_name or self.name
63
+ mlflow.set_experiment(exp_name)
64
+ for run in self.runs:
65
+ with mlflow.start_run(run_name=run.run_id):
66
+ for metric_name, value in run.headline.items():
67
+ mlflow.log_metric(metric_name, value)
68
+ mlflow.log_param("fingerprint", run.fingerprint)
69
+ mlflow.log_param("model", run.model_spec or "")
70
+ if run.config:
71
+ for k in ("temperature", "seed", "limit", "num_fewshot", "concurrency"):
72
+ v = getattr(run.config, k, None)
73
+ if v is not None:
74
+ mlflow.log_param(k, v)
75
+ if run.errors:
76
+ mlflow.log_metric("failed_count", run.failed_count)
77
+ mlflow.log_text(json.dumps(run.errors, default=str), "errors.json")
78
+
79
+ def significance(self, metric: str, method: str = "bootstrap", n_resamples: int = 1000) -> dict[str, Any]:
80
+ """Compare the best two runs and return a p-value.
81
+
82
+ Aligns per-sample scores **by sample_id** via the shared paired bootstrap
83
+ (see :mod:`auditkit._bootstrap`), rather than zipping by list position.
84
+ """
85
+ if len(self.runs) < 2:
86
+ return {"error": "Need at least 2 runs for significance test"}
87
+ from ._bootstrap import paired_bootstrap, score_pairs
88
+
89
+ scored = []
90
+ for r in self.runs:
91
+ vals = [p.score for p in r.predictions if p.score is not None]
92
+ if vals:
93
+ scored.append((sum(vals) / len(vals), r))
94
+ scored.sort(key=lambda x: x[0], reverse=True)
95
+ if len(scored) < 2:
96
+ return {"error": "Need at least 2 runs with predictions for significance test"}
97
+ baseline_run = scored[0][1]
98
+ contrast_run = scored[1][1]
99
+ bs = paired_bootstrap(score_pairs(baseline_run, contrast_run, None), n_resamples=n_resamples)
100
+ if "error" in bs:
101
+ return {"error": "Need at least 2 paired predictions per run"}
102
+ return {
103
+ "baseline": baseline_run.run_id,
104
+ "contrast": contrast_run.run_id,
105
+ "delta": round(bs["mean_baseline"] - bs["mean_candidate"], 6),
106
+ "p_value": bs["p_value"],
107
+ "significant": bs["significant"],
108
+ "method": method,
109
+ "n_resamples": n_resamples,
110
+ "n_samples": bs["n"],
111
+ }
112
+
113
+
114
+ class ExperimentDB:
115
+ def __init__(self, path: str | None = None) -> None:
116
+ self._dir = Path(path or os.path.join(
117
+ os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")),
118
+ "auditkit", "experiments",
119
+ ))
120
+ self._dir.mkdir(parents=True, exist_ok=True)
121
+
122
+ def _path(self, name: str) -> Path:
123
+ safe = name.replace("/", "_").replace(" ", "_")
124
+ return self._dir / f"{safe}.json"
125
+
126
+ def save(self, experiment: Experiment) -> None:
127
+ data = {
128
+ "name": experiment.name,
129
+ "metadata": experiment.metadata,
130
+ "run_count": len(experiment.runs),
131
+ "runs": [r.to_dict() for r in experiment.runs],
132
+ }
133
+ with open(self._path(experiment.name), "w") as f:
134
+ json.dump(data, f, indent=2, default=str)
135
+
136
+ def load(self, name: str) -> Experiment:
137
+ with open(self._path(name)) as f:
138
+ data = json.load(f)
139
+ exp = Experiment(name=data["name"], metadata=data.get("metadata", {}))
140
+ for rd in data.get("runs", []):
141
+ exp.add(RunResult.from_dict(rd))
142
+ return exp
143
+
144
+ def list_experiments(self) -> list[str]:
145
+ return [p.stem for p in self._dir.iterdir() if p.suffix == ".json"]