aid-runtime-python 0.1.39__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.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""N-run sampling and the statistical baseline gate.
|
|
2
|
+
|
|
3
|
+
The ratified Phase 0 decision (design §17 #2) is that gating uses **N-run sampling
|
|
4
|
+
with statistical thresholds compared against a stored baseline**, in the
|
|
5
|
+
``maxRegression`` style -- *not* strict equality. Non-determinism is expected and
|
|
6
|
+
must not be treated as a bug.
|
|
7
|
+
|
|
8
|
+
Concretely, for each metric we:
|
|
9
|
+
|
|
10
|
+
1. Run the dataset ``n_runs`` times, giving one mean score per run. Those means
|
|
11
|
+
are the *sample*, and their spread is the honest measure of how much this
|
|
12
|
+
system varies when nothing changed.
|
|
13
|
+
2. Compare the sample against a stored baseline (its own mean, spread and size).
|
|
14
|
+
3. Fail only when the drop exceeds ``max_regression`` **and** that drop is larger
|
|
15
|
+
than the standard error of the difference could explain, at the configured
|
|
16
|
+
significance level.
|
|
17
|
+
|
|
18
|
+
Step 3 is what makes the gate trustworthy in both directions. A gate that fires on
|
|
19
|
+
any decrease is a flaky gate; a gate that only compares means can be fooled by a
|
|
20
|
+
noisy run. Requiring both a material *and* a statistically distinguishable drop
|
|
21
|
+
means the gate reports a regression when the evidence says there is one, and
|
|
22
|
+
stays quiet otherwise.
|
|
23
|
+
|
|
24
|
+
The z critical values are a table rather than a library call because adding a
|
|
25
|
+
statistics dependency (scipy) to enforce one one-sided test is not a trade worth
|
|
26
|
+
making in a runtime that must stay small.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
import statistics
|
|
33
|
+
from collections.abc import Mapping, Sequence
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
37
|
+
|
|
38
|
+
from aid_runtime.errors import EvalError, GateConfigurationError
|
|
39
|
+
from aid_runtime.evals.metrics import Metric
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"BaselineGate",
|
|
43
|
+
"EvalBaseline",
|
|
44
|
+
"EvalRun",
|
|
45
|
+
"GateResult",
|
|
46
|
+
"MetricBaseline",
|
|
47
|
+
"MetricGateResult",
|
|
48
|
+
"MetricRun",
|
|
49
|
+
"Threshold",
|
|
50
|
+
"gate_from_config",
|
|
51
|
+
"z_critical",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
#: One-sided z critical values, keyed by significance level.
|
|
55
|
+
_Z_CRITICAL: dict[float, float] = {
|
|
56
|
+
0.20: 0.8416,
|
|
57
|
+
0.10: 1.2816,
|
|
58
|
+
0.05: 1.6449,
|
|
59
|
+
0.025: 1.9600,
|
|
60
|
+
0.01: 2.3263,
|
|
61
|
+
0.005: 2.5758,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def z_critical(alpha: float) -> float:
|
|
66
|
+
"""The one-sided z critical value at ``alpha``, rounded to a stricter level.
|
|
67
|
+
|
|
68
|
+
``alpha`` is rounded to the next *stricter* tabulated level -- a smaller alpha,
|
|
69
|
+
hence a larger z, hence a gate that demands slightly more evidence than asked
|
|
70
|
+
for. That is the safe direction for a regression gate: a gate that fires on
|
|
71
|
+
borderline evidence is a gate that gets switched off. An ``alpha`` below the
|
|
72
|
+
strictest tabulated level gets the strictest value, for the same reason.
|
|
73
|
+
"""
|
|
74
|
+
if not 0.0 < alpha < 1.0:
|
|
75
|
+
raise ValueError("alpha must be between 0 and 1")
|
|
76
|
+
levels = sorted(_Z_CRITICAL)
|
|
77
|
+
at_or_below = [level for level in levels if level <= alpha]
|
|
78
|
+
return _Z_CRITICAL[max(at_or_below) if at_or_below else levels[0]]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MetricRun(BaseModel):
|
|
82
|
+
"""Per-run, per-metric means: one value per run of the dataset."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
85
|
+
|
|
86
|
+
metric: str
|
|
87
|
+
values: tuple[float, ...] = Field(min_length=1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class EvalRun(BaseModel):
|
|
91
|
+
"""The sampled result of running a dataset N times."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
94
|
+
|
|
95
|
+
dataset: str
|
|
96
|
+
dataset_version: str
|
|
97
|
+
runs: int = Field(gt=0)
|
|
98
|
+
metrics: tuple[MetricRun, ...] = Field(min_length=1)
|
|
99
|
+
|
|
100
|
+
def means(self) -> dict[str, float]:
|
|
101
|
+
return {entry.metric: statistics.fmean(entry.values) for entry in self.metrics}
|
|
102
|
+
|
|
103
|
+
def spread(self) -> dict[str, float]:
|
|
104
|
+
return {entry.metric: _stdev(entry.values) for entry in self.metrics}
|
|
105
|
+
|
|
106
|
+
def values_for(self, metric: str) -> tuple[float, ...]:
|
|
107
|
+
for entry in self.metrics:
|
|
108
|
+
if entry.metric == metric:
|
|
109
|
+
return entry.values
|
|
110
|
+
raise EvalError(f"metric {metric!r} is not in this run")
|
|
111
|
+
|
|
112
|
+
def is_varying(self) -> bool:
|
|
113
|
+
"""Whether any metric produced different scores across runs.
|
|
114
|
+
|
|
115
|
+
Exposed because "the run varied" is a *fact about the system*, not a
|
|
116
|
+
failure: a test asserts on it to prove the gate tolerates noise rather
|
|
117
|
+
than assuming a deterministic system.
|
|
118
|
+
"""
|
|
119
|
+
return any(len(set(entry.values)) > 1 for entry in self.metrics)
|
|
120
|
+
|
|
121
|
+
def to_json(self) -> str:
|
|
122
|
+
return self.model_dump_json(indent=2)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class MetricBaseline(BaseModel):
|
|
126
|
+
"""A stored metric distribution, recorded from a run that was accepted."""
|
|
127
|
+
|
|
128
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
129
|
+
|
|
130
|
+
metric: str
|
|
131
|
+
runs: int = Field(gt=0)
|
|
132
|
+
mean: float = Field(ge=0.0, le=1.0)
|
|
133
|
+
stdev: float = Field(ge=0.0)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_values(cls, metric: str, values: Sequence[float]) -> MetricBaseline:
|
|
137
|
+
return cls(
|
|
138
|
+
metric=metric,
|
|
139
|
+
runs=len(values),
|
|
140
|
+
mean=statistics.fmean(values),
|
|
141
|
+
stdev=_stdev(values),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class EvalBaseline(BaseModel):
|
|
146
|
+
"""A committed baseline: the accepted distribution for a dataset and model."""
|
|
147
|
+
|
|
148
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
149
|
+
|
|
150
|
+
dataset: str
|
|
151
|
+
dataset_version: str
|
|
152
|
+
model: str
|
|
153
|
+
metrics: tuple[MetricBaseline, ...] = Field(min_length=1)
|
|
154
|
+
note: str = ""
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_run(cls, run: EvalRun, *, model: str, note: str = "") -> EvalBaseline:
|
|
158
|
+
return cls(
|
|
159
|
+
dataset=run.dataset,
|
|
160
|
+
dataset_version=run.dataset_version,
|
|
161
|
+
model=model,
|
|
162
|
+
metrics=tuple(
|
|
163
|
+
MetricBaseline.from_values(entry.metric, entry.values) for entry in run.metrics
|
|
164
|
+
),
|
|
165
|
+
note=note,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def metric(self, name: str) -> MetricBaseline:
|
|
169
|
+
for entry in self.metrics:
|
|
170
|
+
if entry.metric == name:
|
|
171
|
+
return entry
|
|
172
|
+
raise EvalError(f"baseline has no metric {name!r}")
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_json(cls, payload: str) -> EvalBaseline:
|
|
176
|
+
return cls.model_validate_json(payload)
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_path(cls, path: str | Path) -> EvalBaseline:
|
|
180
|
+
return cls.from_json(Path(path).read_text(encoding="utf-8"))
|
|
181
|
+
|
|
182
|
+
def to_json(self) -> str:
|
|
183
|
+
return self.model_dump_json(indent=2) + "\n"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Threshold(BaseModel):
|
|
187
|
+
"""The acceptance rule for one metric."""
|
|
188
|
+
|
|
189
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
190
|
+
|
|
191
|
+
metric: str = Field(min_length=1)
|
|
192
|
+
#: Absolute floor on the mean. Independent of the baseline: it says what
|
|
193
|
+
#: "good enough" means, which no comparison to a past run can express.
|
|
194
|
+
min_mean: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
195
|
+
#: Largest mean drop, versus the baseline, still considered acceptable.
|
|
196
|
+
max_regression: float = Field(default=0.02, ge=0.0, le=1.0)
|
|
197
|
+
#: Significance level for the one-sided regression test.
|
|
198
|
+
alpha: float = Field(default=0.05, gt=0.0, lt=1.0)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class MetricGateResult(BaseModel):
|
|
202
|
+
"""Why one metric passed or failed. The rationale is the product."""
|
|
203
|
+
|
|
204
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
205
|
+
|
|
206
|
+
metric: str
|
|
207
|
+
passed: bool
|
|
208
|
+
current_mean: float
|
|
209
|
+
current_stdev: float
|
|
210
|
+
baseline_mean: float
|
|
211
|
+
baseline_stdev: float
|
|
212
|
+
delta: float
|
|
213
|
+
z: float
|
|
214
|
+
alpha: float
|
|
215
|
+
max_regression: float
|
|
216
|
+
rationale: str
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class GateResult(BaseModel):
|
|
220
|
+
"""The gate's verdict over every metric."""
|
|
221
|
+
|
|
222
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
223
|
+
|
|
224
|
+
passed: bool
|
|
225
|
+
dataset: str
|
|
226
|
+
dataset_version: str
|
|
227
|
+
runs: int
|
|
228
|
+
results: tuple[MetricGateResult, ...] = Field(min_length=1)
|
|
229
|
+
varying: bool = False
|
|
230
|
+
|
|
231
|
+
def failures(self) -> tuple[MetricGateResult, ...]:
|
|
232
|
+
return tuple(result for result in self.results if not result.passed)
|
|
233
|
+
|
|
234
|
+
def summary(self) -> str:
|
|
235
|
+
lines = [
|
|
236
|
+
f"eval gate {'PASSED' if self.passed else 'FAILED'} "
|
|
237
|
+
f"({self.dataset}@{self.dataset_version}, {self.runs} runs, "
|
|
238
|
+
f"run-to-run variation: {'yes' if self.varying else 'no'})"
|
|
239
|
+
]
|
|
240
|
+
for result in self.results:
|
|
241
|
+
mark = "ok " if result.passed else "FAIL"
|
|
242
|
+
lines.append(f" [{mark}] {result.rationale}")
|
|
243
|
+
return "\n".join(lines)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class BaselineGate:
|
|
247
|
+
"""Compares a sampled run against a stored baseline."""
|
|
248
|
+
|
|
249
|
+
def __init__(
|
|
250
|
+
self,
|
|
251
|
+
thresholds: Sequence[Threshold],
|
|
252
|
+
*,
|
|
253
|
+
metrics: Sequence[Metric] = (),
|
|
254
|
+
allow_nondeterministic: bool = False,
|
|
255
|
+
) -> None:
|
|
256
|
+
if not thresholds:
|
|
257
|
+
raise GateConfigurationError("the gate needs at least one threshold")
|
|
258
|
+
names = [threshold.metric for threshold in thresholds]
|
|
259
|
+
duplicates = sorted({name for name in names if names.count(name) > 1})
|
|
260
|
+
if duplicates:
|
|
261
|
+
raise GateConfigurationError(f"duplicate thresholds for: {', '.join(duplicates)}")
|
|
262
|
+
self._thresholds = tuple(thresholds)
|
|
263
|
+
by_name = {metric.name: metric for metric in metrics}
|
|
264
|
+
self._metrics = by_name
|
|
265
|
+
self._allow_nondeterministic = allow_nondeterministic
|
|
266
|
+
self._check_metric_eligibility()
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def thresholds(self) -> tuple[Threshold, ...]:
|
|
270
|
+
return self._thresholds
|
|
271
|
+
|
|
272
|
+
def evaluate(self, run: EvalRun, baseline: EvalBaseline) -> GateResult:
|
|
273
|
+
"""Decide whether ``run`` regressed against ``baseline``."""
|
|
274
|
+
if (run.dataset, run.dataset_version) != (baseline.dataset, baseline.dataset_version):
|
|
275
|
+
raise EvalError(
|
|
276
|
+
f"baseline was recorded on {baseline.dataset}@{baseline.dataset_version} but the "
|
|
277
|
+
f"run is {run.dataset}@{run.dataset_version}; compare like with like"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
results = tuple(
|
|
281
|
+
self._evaluate_metric(threshold, run, baseline) for threshold in self._thresholds
|
|
282
|
+
)
|
|
283
|
+
return GateResult(
|
|
284
|
+
passed=all(result.passed for result in results),
|
|
285
|
+
dataset=run.dataset,
|
|
286
|
+
dataset_version=run.dataset_version,
|
|
287
|
+
runs=run.runs,
|
|
288
|
+
results=results,
|
|
289
|
+
varying=run.is_varying(),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def _evaluate_metric(
|
|
293
|
+
self, threshold: Threshold, run: EvalRun, baseline: EvalBaseline
|
|
294
|
+
) -> MetricGateResult:
|
|
295
|
+
values = run.values_for(threshold.metric)
|
|
296
|
+
stored = baseline.metric(threshold.metric)
|
|
297
|
+
current_mean = statistics.fmean(values)
|
|
298
|
+
current_stdev = _stdev(values)
|
|
299
|
+
delta = stored.mean - current_mean
|
|
300
|
+
z = _regression_z(
|
|
301
|
+
current_mean=current_mean,
|
|
302
|
+
current_stdev=current_stdev,
|
|
303
|
+
current_n=len(values),
|
|
304
|
+
baseline_mean=stored.mean,
|
|
305
|
+
baseline_stdev=stored.stdev,
|
|
306
|
+
baseline_n=stored.runs,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
passed, rationale = self._verdict(threshold, current_mean, stored, delta, z)
|
|
310
|
+
return MetricGateResult(
|
|
311
|
+
metric=threshold.metric,
|
|
312
|
+
passed=passed,
|
|
313
|
+
current_mean=current_mean,
|
|
314
|
+
current_stdev=current_stdev,
|
|
315
|
+
baseline_mean=stored.mean,
|
|
316
|
+
baseline_stdev=stored.stdev,
|
|
317
|
+
delta=delta,
|
|
318
|
+
z=z,
|
|
319
|
+
alpha=threshold.alpha,
|
|
320
|
+
max_regression=threshold.max_regression,
|
|
321
|
+
rationale=rationale,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _verdict(
|
|
325
|
+
self,
|
|
326
|
+
threshold: Threshold,
|
|
327
|
+
current_mean: float,
|
|
328
|
+
stored: MetricBaseline,
|
|
329
|
+
delta: float,
|
|
330
|
+
z: float,
|
|
331
|
+
) -> tuple[bool, str]:
|
|
332
|
+
"""The pass/fail call and the sentence explaining it.
|
|
333
|
+
|
|
334
|
+
Three ways to pass, and the distinction between them is the whole point:
|
|
335
|
+
no floor breached, no material drop, or a drop that is material but within
|
|
336
|
+
what the two runs' own variation can explain. Only a drop that is both
|
|
337
|
+
material *and* beyond the noise threshold counts as a regression.
|
|
338
|
+
"""
|
|
339
|
+
where = f"{threshold.metric}: mean {current_mean:.4f} vs baseline {stored.mean:.4f}"
|
|
340
|
+
|
|
341
|
+
if threshold.min_mean is not None and current_mean < threshold.min_mean:
|
|
342
|
+
return False, (
|
|
343
|
+
f"{threshold.metric}: mean {current_mean:.4f} is below the absolute floor "
|
|
344
|
+
f"{threshold.min_mean:.4f} (baseline {stored.mean:.4f})"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if delta <= threshold.max_regression:
|
|
348
|
+
return True, (
|
|
349
|
+
f"{where} -- delta {delta:+.4f} is within "
|
|
350
|
+
f"max_regression {threshold.max_regression:.4f}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if z <= z_critical(threshold.alpha):
|
|
354
|
+
return True, (
|
|
355
|
+
f"{where} -- delta {delta:+.4f} exceeds max_regression but z={z:.3f} is not "
|
|
356
|
+
f"beyond {z_critical(threshold.alpha):.3f} at alpha={threshold.alpha}; "
|
|
357
|
+
f"consistent with run-to-run noise"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return False, (
|
|
361
|
+
f"{where} -- delta {delta:+.4f} exceeds max_regression "
|
|
362
|
+
f"{threshold.max_regression:.4f} and z={z:.3f} > {z_critical(threshold.alpha):.3f} "
|
|
363
|
+
f"at alpha={threshold.alpha}"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def _check_metric_eligibility(self) -> None:
|
|
367
|
+
"""Refuse thresholds that a non-deterministic metric would undermine.
|
|
368
|
+
|
|
369
|
+
The rule is enforced here, at construction, rather than documented and
|
|
370
|
+
hoped for: a gate configured on a model-backed metric is not a gate, and
|
|
371
|
+
refusing at configuration time is the only moment that is cheap to fix.
|
|
372
|
+
"""
|
|
373
|
+
if self._allow_nondeterministic:
|
|
374
|
+
return
|
|
375
|
+
offending = sorted(
|
|
376
|
+
threshold.metric
|
|
377
|
+
for threshold in self._thresholds
|
|
378
|
+
if (metric := self._metrics.get(threshold.metric)) is not None
|
|
379
|
+
and not metric.deterministic
|
|
380
|
+
)
|
|
381
|
+
if offending:
|
|
382
|
+
raise GateConfigurationError(
|
|
383
|
+
f"thresholds reference non-deterministic metric(s): {', '.join(offending)}. "
|
|
384
|
+
"A model-backed metric varies run to run, so it cannot distinguish a regression "
|
|
385
|
+
"from its own noise; keep it as a reported metric and gate on a deterministic one. "
|
|
386
|
+
"Pass allow_nondeterministic=True only for local exploration, never in CI."
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _stdev(values: Sequence[float]) -> float:
|
|
391
|
+
return statistics.stdev(values) if len(values) > 1 else 0.0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _regression_z(
|
|
395
|
+
*,
|
|
396
|
+
current_mean: float,
|
|
397
|
+
current_stdev: float,
|
|
398
|
+
current_n: int,
|
|
399
|
+
baseline_mean: float,
|
|
400
|
+
baseline_stdev: float,
|
|
401
|
+
baseline_n: int,
|
|
402
|
+
) -> float:
|
|
403
|
+
"""One-sided z for "the current mean is lower than the baseline mean".
|
|
404
|
+
|
|
405
|
+
Uses the standard error of the difference of two means, so *both* spreads
|
|
406
|
+
contribute: a noisy baseline is a weak claim to regress against, and the gate
|
|
407
|
+
should demand more evidence before rejecting it.
|
|
408
|
+
"""
|
|
409
|
+
standard_error = math.sqrt(
|
|
410
|
+
(current_stdev**2 / max(current_n, 1)) + (baseline_stdev**2 / max(baseline_n, 1))
|
|
411
|
+
)
|
|
412
|
+
if standard_error <= 0.0:
|
|
413
|
+
# Both distributions are degenerate: any drop is real, so report an
|
|
414
|
+
# arbitrarily large statistic rather than dividing by zero.
|
|
415
|
+
return math.inf if current_mean < baseline_mean else 0.0
|
|
416
|
+
return (baseline_mean - current_mean) / standard_error
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def gate_from_config(
|
|
420
|
+
config: Mapping[str, object], *, metrics: Sequence[Metric] = ()
|
|
421
|
+
) -> BaselineGate:
|
|
422
|
+
"""Build a gate from parsed JSON/YAML, so CI config stays declarative."""
|
|
423
|
+
raw = config.get("thresholds")
|
|
424
|
+
if not isinstance(raw, list):
|
|
425
|
+
raise GateConfigurationError("config needs a 'thresholds' list")
|
|
426
|
+
return BaselineGate(
|
|
427
|
+
[Threshold.model_validate(entry) for entry in raw],
|
|
428
|
+
metrics=metrics,
|
|
429
|
+
allow_nondeterministic=bool(config.get("allow_nondeterministic", False)),
|
|
430
|
+
)
|