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.
Files changed (52) hide show
  1. aid_runtime/__init__.py +500 -0
  2. aid_runtime/agents/__init__.py +94 -0
  3. aid_runtime/agents/chat_model.py +351 -0
  4. aid_runtime/agents/deep_agents.py +600 -0
  5. aid_runtime/agents/engine.py +102 -0
  6. aid_runtime/agents/guardrails.py +519 -0
  7. aid_runtime/agents/ir.py +622 -0
  8. aid_runtime/agents/loop.py +1308 -0
  9. aid_runtime/agents/memory.py +241 -0
  10. aid_runtime/agents/policy.py +163 -0
  11. aid_runtime/agents/state.py +182 -0
  12. aid_runtime/cache.py +185 -0
  13. aid_runtime/concurrency.py +70 -0
  14. aid_runtime/control.py +329 -0
  15. aid_runtime/cost.py +168 -0
  16. aid_runtime/errors.py +337 -0
  17. aid_runtime/evals/__init__.py +72 -0
  18. aid_runtime/evals/dataset.py +77 -0
  19. aid_runtime/evals/gate.py +430 -0
  20. aid_runtime/evals/metrics.py +335 -0
  21. aid_runtime/evals/predicate.py +211 -0
  22. aid_runtime/evals/runner.py +224 -0
  23. aid_runtime/gateway.py +473 -0
  24. aid_runtime/pipeline.py +168 -0
  25. aid_runtime/prompts.py +185 -0
  26. aid_runtime/providers/__init__.py +20 -0
  27. aid_runtime/providers/base.py +57 -0
  28. aid_runtime/providers/fake.py +256 -0
  29. aid_runtime/providers/http.py +344 -0
  30. aid_runtime/py.typed +0 -0
  31. aid_runtime/retrieval/__init__.py +178 -0
  32. aid_runtime/retrieval/chunking.py +222 -0
  33. aid_runtime/retrieval/citations.py +131 -0
  34. aid_runtime/retrieval/connectors.py +131 -0
  35. aid_runtime/retrieval/embedding.py +293 -0
  36. aid_runtime/retrieval/gate.py +120 -0
  37. aid_runtime/retrieval/hybrid.py +164 -0
  38. aid_runtime/retrieval/metrics.py +213 -0
  39. aid_runtime/retrieval/pgvector.py +258 -0
  40. aid_runtime/retrieval/rerank.py +133 -0
  41. aid_runtime/retrieval/retriever.py +335 -0
  42. aid_runtime/retrieval/stores.py +161 -0
  43. aid_runtime/retrieval/types.py +433 -0
  44. aid_runtime/steps.py +284 -0
  45. aid_runtime/structured.py +150 -0
  46. aid_runtime/tools.py +271 -0
  47. aid_runtime/tracing.py +241 -0
  48. aid_runtime/types.py +332 -0
  49. aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
  50. aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
  51. aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
  52. 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
+ )