baseltest 0.7.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.
- baseltest/__init__.py +35 -0
- baseltest/_version.py +11 -0
- baseltest/baseline/__init__.py +46 -0
- baseltest/baseline/reader.py +247 -0
- baseltest/baseline/record.py +152 -0
- baseltest/baseline/writer.py +132 -0
- baseltest/contract/__init__.py +84 -0
- baseltest/contract/errors.py +21 -0
- baseltest/contract/evaluation.py +236 -0
- baseltest/contract/model.py +363 -0
- baseltest/contract/postconditions.py +159 -0
- baseltest/declarative/__init__.py +66 -0
- baseltest/declarative/_cli.py +431 -0
- baseltest/declarative/_disclosure.py +56 -0
- baseltest/declarative/_errors.py +13 -0
- baseltest/declarative/_instantiate/__init__.py +34 -0
- baseltest/declarative/_instantiate/_baseline.py +183 -0
- baseltest/declarative/_instantiate/_compose.py +158 -0
- baseltest/declarative/_instantiate/_explore.py +135 -0
- baseltest/declarative/_instantiate/_latency.py +108 -0
- baseltest/declarative/_instantiate/_optimize_point.py +122 -0
- baseltest/declarative/_instantiate/_postconditions.py +151 -0
- baseltest/declarative/_instantiate/_service.py +129 -0
- baseltest/declarative/_instantiate/_sizing_policy.py +90 -0
- baseltest/declarative/_instantiate/_views.py +79 -0
- baseltest/declarative/_materialise.py +122 -0
- baseltest/declarative/_optimize.py +319 -0
- baseltest/declarative/_parser/__init__.py +40 -0
- baseltest/declarative/_parser/_contract.py +84 -0
- baseltest/declarative/_parser/_criteria.py +100 -0
- baseltest/declarative/_parser/_forms.py +59 -0
- baseltest/declarative/_parser/_inputs.py +162 -0
- baseltest/declarative/_parser/_latency.py +108 -0
- baseltest/declarative/_parser/_model.py +93 -0
- baseltest/declarative/_parser/_shape.py +47 -0
- baseltest/declarative/_parser/_structure.py +88 -0
- baseltest/declarative/_providers/__init__.py +247 -0
- baseltest/declarative/_providers/_anthropic.py +134 -0
- baseltest/declarative/_providers/_apertus.py +34 -0
- baseltest/declarative/_providers/_litellm.py +94 -0
- baseltest/declarative/_providers/_media.py +93 -0
- baseltest/declarative/_providers/_mistral.py +28 -0
- baseltest/declarative/_providers/_ollama.py +72 -0
- baseltest/declarative/_providers/_openai.py +45 -0
- baseltest/declarative/_providers/_protocol.py +223 -0
- baseltest/declarative/_registrations.py +62 -0
- baseltest/declarative/_registry/__init__.py +31 -0
- baseltest/declarative/_registry/_bindings.py +104 -0
- baseltest/declarative/_registry/_core.py +353 -0
- baseltest/declarative/_registry/_guards.py +56 -0
- baseltest/declarative/_registry/_service_types.py +163 -0
- baseltest/declarative/_registry/_transform.py +82 -0
- baseltest/declarative/_runner/__init__.py +43 -0
- baseltest/declarative/_runner/_check.py +90 -0
- baseltest/declarative/_runner/_explore.py +171 -0
- baseltest/declarative/_runner/_load.py +45 -0
- baseltest/declarative/_runner/_optimize.py +120 -0
- baseltest/declarative/_runner/_optimize_loop.py +297 -0
- baseltest/declarative/_runner/_report.py +57 -0
- baseltest/declarative/_runner/_run.py +207 -0
- baseltest/declarative/_runner/_shared.py +56 -0
- baseltest/declarative/_schema_walk.py +355 -0
- baseltest/declarative/_services/__init__.py +47 -0
- baseltest/declarative/_services/_language_model.py +315 -0
- baseltest/declarative/_services/_model.py +78 -0
- baseltest/declarative/_services/_parse.py +180 -0
- baseltest/declarative/_signatures.py +42 -0
- baseltest/declarative/_sizing/__init__.py +34 -0
- baseltest/declarative/_sizing/_criteria.py +115 -0
- baseltest/declarative/_sizing/_flags.py +79 -0
- baseltest/declarative/_sizing/_model.py +62 -0
- baseltest/declarative/_sizing/_modes.py +195 -0
- baseltest/declarative/_sizing/_pricing.py +66 -0
- baseltest/declarative/_sizing/_prompts.py +92 -0
- baseltest/declarative/_sizing/_rates.py +43 -0
- baseltest/declarative/_sizing/_render.py +108 -0
- baseltest/declarative/_sizing/_resolve.py +161 -0
- baseltest/declarative/_steppers/__init__.py +74 -0
- baseltest/declarative/_steppers/_builtins.py +34 -0
- baseltest/declarative/_steppers/_context.py +114 -0
- baseltest/declarative/_steppers/_contract.py +148 -0
- baseltest/declarative/_steppers/_linear_sweep.py +43 -0
- baseltest/declarative/_steppers/_numeric.py +30 -0
- baseltest/declarative/_steppers/_prompt_engineer.py +112 -0
- baseltest/declarative/_steppers/_refining_grid.py +240 -0
- baseltest/declarative/_structured.py +219 -0
- baseltest/declarative/_types.py +84 -0
- baseltest/engine/__init__.py +65 -0
- baseltest/engine/artefact.py +68 -0
- baseltest/engine/defect.py +68 -0
- baseltest/engine/latency.py +225 -0
- baseltest/engine/naming.py +62 -0
- baseltest/engine/run/__init__.py +36 -0
- baseltest/engine/run/attainment.py +48 -0
- baseltest/engine/run/execute.py +138 -0
- baseltest/engine/run/feasibility.py +77 -0
- baseltest/engine/run/identity.py +44 -0
- baseltest/engine/run/judge.py +29 -0
- baseltest/engine/run/model.py +165 -0
- baseltest/engine/run/sample.py +171 -0
- baseltest/exploration/__init__.py +21 -0
- baseltest/exploration/writer.py +167 -0
- baseltest/observation/__init__.py +19 -0
- baseltest/observation/emit.py +80 -0
- baseltest/observation/record.py +189 -0
- baseltest/optimization/__init__.py +29 -0
- baseltest/optimization/record.py +114 -0
- baseltest/optimization/writer.py +96 -0
- baseltest/py.typed +0 -0
- baseltest/reporting/__init__.py +51 -0
- baseltest/reporting/console.py +352 -0
- baseltest/reporting/report_html.py +233 -0
- baseltest/reporting/run_design.py +86 -0
- baseltest/reporting/test_report.py +277 -0
- baseltest/reporting/verdict_reader.py +253 -0
- baseltest/reporting/verdict_xml.py +204 -0
- baseltest/statistics/__init__.py +94 -0
- baseltest/statistics/_constants.py +19 -0
- baseltest/statistics/_validation.py +32 -0
- baseltest/statistics/feasibility.py +93 -0
- baseltest/statistics/latency.py +166 -0
- baseltest/statistics/power.py +111 -0
- baseltest/statistics/proportion.py +33 -0
- baseltest/statistics/sizing.py +210 -0
- baseltest/statistics/summary.py +74 -0
- baseltest/statistics/threshold.py +269 -0
- baseltest/statistics/verdict.py +168 -0
- baseltest/statistics/wilson.py +168 -0
- baseltest-0.7.0.dist-info/METADATA +141 -0
- baseltest-0.7.0.dist-info/RECORD +134 -0
- baseltest-0.7.0.dist-info/WHEEL +4 -0
- baseltest-0.7.0.dist-info/entry_points.txt +2 -0
- baseltest-0.7.0.dist-info/licenses/LICENSE +202 -0
- baseltest-0.7.0.dist-info/licenses/NOTICE +11 -0
baseltest/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""baseltest: probabilistic testing for stochastic services.
|
|
2
|
+
|
|
3
|
+
Python-native counterpart to punit (Java) and feotest (Rust) in the
|
|
4
|
+
mavai framework family — statistical inference over repeated samples,
|
|
5
|
+
not a single pass/fail assertion.
|
|
6
|
+
|
|
7
|
+
The common entry points are re-exported here: construct a :class:`Bindings`,
|
|
8
|
+
then call :func:`run`, :func:`explore`, :func:`optimize`, or
|
|
9
|
+
:func:`check_contract` on a contract file. These are the declarative
|
|
10
|
+
authoring surface (:mod:`baseltest.declarative`), promoted to the package
|
|
11
|
+
root for convenience; that module also carries the narrower stepper/scorer
|
|
12
|
+
context types, and :mod:`baseltest.contract` is the surface for authoring a
|
|
13
|
+
service contract in Python directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from baseltest._version import __version__
|
|
17
|
+
from baseltest.contract import FileInput, MessageParts
|
|
18
|
+
from baseltest.declarative import (
|
|
19
|
+
Bindings,
|
|
20
|
+
check_contract,
|
|
21
|
+
explore,
|
|
22
|
+
optimize,
|
|
23
|
+
run,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Bindings",
|
|
28
|
+
"FileInput",
|
|
29
|
+
"MessageParts",
|
|
30
|
+
"__version__",
|
|
31
|
+
"check_contract",
|
|
32
|
+
"explore",
|
|
33
|
+
"optimize",
|
|
34
|
+
"run",
|
|
35
|
+
]
|
baseltest/_version.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Single source of the package version, importable without the API surface.
|
|
2
|
+
|
|
3
|
+
Lives apart from the package ``__init__`` so a lower layer (the reporting
|
|
4
|
+
renderers stamp the version into the verdict record) can read the version
|
|
5
|
+
without importing the top-level authoring surface that ``__init__``
|
|
6
|
+
re-exports — which would make a lower layer depend on a higher one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
|
|
11
|
+
__version__ = metadata.version("baseltest")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The baseline artefact: the durable record of a measurement run.
|
|
2
|
+
|
|
3
|
+
A measurement persists what was observed -- per-criterion counts and rates,
|
|
4
|
+
identity, and provenance -- as a YAML artefact other tooling can later read.
|
|
5
|
+
This package owns the artefact's schema and is its **single writer**: the
|
|
6
|
+
serialisation lives here and nowhere else. The writer accepts
|
|
7
|
+
characterisation data as input, so any caller that has measured something
|
|
8
|
+
-- this framework's own measure runs, or an external characteriser -- hands
|
|
9
|
+
its data over rather than emitting the format itself.
|
|
10
|
+
|
|
11
|
+
Nothing in this package reads baselines back for threshold derivation; the
|
|
12
|
+
artefact is a durable record whose consumption is a deliberately separate,
|
|
13
|
+
later capability.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .reader import (
|
|
17
|
+
BaselineResolution,
|
|
18
|
+
StoredBaseline,
|
|
19
|
+
StoredCriterion,
|
|
20
|
+
StoredLatency,
|
|
21
|
+
read_baseline,
|
|
22
|
+
resolve_baseline,
|
|
23
|
+
)
|
|
24
|
+
from .record import (
|
|
25
|
+
BaselineRecord,
|
|
26
|
+
CriterionCharacterisation,
|
|
27
|
+
JudgementState,
|
|
28
|
+
NormativeJudgement,
|
|
29
|
+
)
|
|
30
|
+
from .writer import baseline_filename, render_baseline, write_baseline
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"BaselineRecord",
|
|
34
|
+
"BaselineResolution",
|
|
35
|
+
"CriterionCharacterisation",
|
|
36
|
+
"JudgementState",
|
|
37
|
+
"NormativeJudgement",
|
|
38
|
+
"StoredBaseline",
|
|
39
|
+
"StoredCriterion",
|
|
40
|
+
"StoredLatency",
|
|
41
|
+
"baseline_filename",
|
|
42
|
+
"read_baseline",
|
|
43
|
+
"render_baseline",
|
|
44
|
+
"resolve_baseline",
|
|
45
|
+
"write_baseline",
|
|
46
|
+
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Read-side of the baseline artefact: parsing and resolution.
|
|
2
|
+
|
|
3
|
+
The single-writer rule is untouched — reading is not writing. Because the
|
|
4
|
+
artefact is emitted by exactly one writer (:mod:`.writer`), the parser here
|
|
5
|
+
accepts precisely that emission grammar: two-space indentation, one
|
|
6
|
+
``key: value`` per line or one ``- item`` per line under a list key, every
|
|
7
|
+
string JSON-quoted. No third-party dependency; the scalars are JSON, the
|
|
8
|
+
structure is indentation.
|
|
9
|
+
|
|
10
|
+
Both artefact generations read back: ``baseltest-baseline-2`` (current)
|
|
11
|
+
and ``baseltest-baseline-1`` (no ``latency:`` block — a version-1 artefact
|
|
12
|
+
simply characterises the functional dimension only).
|
|
13
|
+
|
|
14
|
+
Resolution is strict identity of what was measured: same contract, same
|
|
15
|
+
inputs fingerprint, same covariates (the recorded provenance, minus the
|
|
16
|
+
volatile keys). A near-miss is reported with the reason it did not match —
|
|
17
|
+
a config drift must never silently downgrade a judged criterion.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from baseltest.engine import LatencyBasis
|
|
26
|
+
|
|
27
|
+
from .writer import SCHEMA_VERSION
|
|
28
|
+
|
|
29
|
+
_READABLE_SCHEMAS = frozenset({SCHEMA_VERSION, "baseltest-baseline-1"})
|
|
30
|
+
|
|
31
|
+
# Provenance keys that legitimately differ between the measure run and a
|
|
32
|
+
# later test run: they identify the run, not the thing measured.
|
|
33
|
+
_VOLATILE_PROVENANCE = frozenset({"runMode", "taskFile"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class StoredCriterion:
|
|
38
|
+
"""One criterion's recorded evidence, as read back from the artefact."""
|
|
39
|
+
|
|
40
|
+
successes: int
|
|
41
|
+
trials: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class StoredLatency:
|
|
46
|
+
"""The artefact's latency block, read back for bound derivation.
|
|
47
|
+
|
|
48
|
+
The sorted vector is the payload a later test derives its bound from;
|
|
49
|
+
the percentiles are the measurement run's descriptive summary.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
basis: LatencyBasis
|
|
53
|
+
contributing_samples: int
|
|
54
|
+
total_samples: int
|
|
55
|
+
percentiles: tuple[tuple[str, int], ...]
|
|
56
|
+
sorted_passing_latencies_ms: tuple[int, ...]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class StoredBaseline:
|
|
61
|
+
"""The artefact's content, read back for resolution and judgement."""
|
|
62
|
+
|
|
63
|
+
path: Path
|
|
64
|
+
contract_id: str
|
|
65
|
+
sample_count: int
|
|
66
|
+
inputs_identity: str
|
|
67
|
+
generated_at: str
|
|
68
|
+
provenance: dict[str, str]
|
|
69
|
+
criteria: dict[str, StoredCriterion]
|
|
70
|
+
latency: StoredLatency | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class BaselineResolution:
|
|
75
|
+
"""The outcome of looking for a matching baseline.
|
|
76
|
+
|
|
77
|
+
Exactly one of ``baseline`` / ``reason`` is meaningful: a match carries
|
|
78
|
+
the stored baseline; a non-match carries the honest reason.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
baseline: StoredBaseline | None = None
|
|
82
|
+
reason: str | None = None
|
|
83
|
+
mismatched_keys: tuple[str, ...] = field(default=())
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def matched(self) -> bool:
|
|
87
|
+
return self.baseline is not None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_lines(lines: list[str]) -> dict[str, Any]:
|
|
91
|
+
"""Parse the writer's emission grammar into nested mappings and lists.
|
|
92
|
+
|
|
93
|
+
A ``key:`` line opens a nested container that starts as a mapping and
|
|
94
|
+
becomes a list on its first ``- item`` line — the writer only ever
|
|
95
|
+
emits homogeneous containers, so the switch is unambiguous.
|
|
96
|
+
"""
|
|
97
|
+
root: dict[str, Any] = {}
|
|
98
|
+
# (indent, container, parent, key-in-parent); the root has no parent.
|
|
99
|
+
stack: list[tuple[int, Any, dict[str, Any] | None, str | None]] = [(0, root, None, None)]
|
|
100
|
+
for raw in lines:
|
|
101
|
+
if not raw.strip():
|
|
102
|
+
continue
|
|
103
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
104
|
+
if indent % 2 != 0:
|
|
105
|
+
raise ValueError(f"malformed indentation: {raw!r}")
|
|
106
|
+
line = raw.strip()
|
|
107
|
+
while stack and stack[-1][0] > indent:
|
|
108
|
+
stack.pop()
|
|
109
|
+
top_indent, container, parent, parent_key = stack[-1]
|
|
110
|
+
if line.startswith("- "):
|
|
111
|
+
if isinstance(container, dict):
|
|
112
|
+
if container or parent is None or parent_key is None:
|
|
113
|
+
raise ValueError(f"malformed list item: {raw!r}")
|
|
114
|
+
container = []
|
|
115
|
+
parent[parent_key] = container
|
|
116
|
+
stack[-1] = (top_indent, container, parent, parent_key)
|
|
117
|
+
container.append(json.loads(line[2:]))
|
|
118
|
+
elif isinstance(container, list):
|
|
119
|
+
raise ValueError(f"malformed line inside a list: {raw!r}")
|
|
120
|
+
else:
|
|
121
|
+
key, value_text = _split_entry(line)
|
|
122
|
+
if value_text is None:
|
|
123
|
+
child: dict[str, Any] = {}
|
|
124
|
+
container[key] = child
|
|
125
|
+
stack.append((indent + 2, child, container, key))
|
|
126
|
+
else:
|
|
127
|
+
container[key] = json.loads(value_text)
|
|
128
|
+
return root
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_DECODER = json.JSONDecoder()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _split_entry(line: str) -> tuple[str, str | None]:
|
|
135
|
+
"""One emitted mapping line into its key and value text.
|
|
136
|
+
|
|
137
|
+
Returns ``(key, None)`` for a block-opening ``key:`` line, otherwise
|
|
138
|
+
``(key, value_text)``. A JSON-quoted key is decoded, never searched
|
|
139
|
+
for a separator: the key itself may contain ``": "`` (a
|
|
140
|
+
failure-reason string quoting a regex, a covariate value).
|
|
141
|
+
"""
|
|
142
|
+
if line.startswith('"'):
|
|
143
|
+
try:
|
|
144
|
+
key, end = _DECODER.raw_decode(line)
|
|
145
|
+
except json.JSONDecodeError as error:
|
|
146
|
+
raise ValueError(f"malformed line: {line!r}") from error
|
|
147
|
+
rest = line[end:]
|
|
148
|
+
if rest == ":":
|
|
149
|
+
return str(key), None
|
|
150
|
+
if rest.startswith(": ") and rest[2:]:
|
|
151
|
+
return str(key), rest[2:]
|
|
152
|
+
raise ValueError(f"malformed line: {line!r}")
|
|
153
|
+
if line.endswith(":"):
|
|
154
|
+
return line[:-1], None
|
|
155
|
+
key, _, value_text = line.partition(": ")
|
|
156
|
+
if not value_text:
|
|
157
|
+
raise ValueError(f"malformed line: {line!r}")
|
|
158
|
+
return key, value_text
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_latency(body: dict[str, Any] | None) -> StoredLatency | None:
|
|
162
|
+
if body is None:
|
|
163
|
+
return None
|
|
164
|
+
percentiles = tuple(
|
|
165
|
+
(key, int(value))
|
|
166
|
+
for key, value in body.items()
|
|
167
|
+
if key.startswith("p") and key.endswith("Ms")
|
|
168
|
+
)
|
|
169
|
+
vector = tuple(int(v) for v in body.get("sortedPassingLatenciesMs", []))
|
|
170
|
+
return StoredLatency(
|
|
171
|
+
basis=LatencyBasis(body["basis"]),
|
|
172
|
+
contributing_samples=int(body["contributingSamples"]),
|
|
173
|
+
total_samples=int(body["totalSamples"]),
|
|
174
|
+
percentiles=percentiles,
|
|
175
|
+
sorted_passing_latencies_ms=vector,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_baseline(path: Path) -> StoredBaseline:
|
|
180
|
+
"""Read one artefact back.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValueError: The file is not a readable baseline artefact (unknown
|
|
184
|
+
schema generation, malformed emission).
|
|
185
|
+
OSError: The file cannot be read.
|
|
186
|
+
"""
|
|
187
|
+
data = _parse_lines(path.read_text(encoding="utf-8").splitlines())
|
|
188
|
+
schema = data.get("schemaVersion")
|
|
189
|
+
if schema not in _READABLE_SCHEMAS:
|
|
190
|
+
readable = ", ".join(sorted(_READABLE_SCHEMAS))
|
|
191
|
+
raise ValueError(f"{path.name}: schema {schema!r} is not one of: {readable}")
|
|
192
|
+
criteria: dict[str, StoredCriterion] = {}
|
|
193
|
+
for name, body in data.get("criteria", {}).items():
|
|
194
|
+
criteria[name] = StoredCriterion(
|
|
195
|
+
successes=int(body["successes"]), trials=int(body["trials"])
|
|
196
|
+
)
|
|
197
|
+
provenance = {str(k): str(v) for k, v in data.get("provenance", {}).items()}
|
|
198
|
+
return StoredBaseline(
|
|
199
|
+
path=path,
|
|
200
|
+
contract_id=str(data["contractId"]),
|
|
201
|
+
sample_count=int(data["sampleCount"]),
|
|
202
|
+
inputs_identity=str(data["inputsIdentity"]),
|
|
203
|
+
generated_at=str(data.get("generatedAt", "")),
|
|
204
|
+
provenance=provenance,
|
|
205
|
+
criteria=criteria,
|
|
206
|
+
latency=_parse_latency(data.get("latency")),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def resolve_baseline(
|
|
211
|
+
baseline_dir: Path,
|
|
212
|
+
contract_id: str,
|
|
213
|
+
inputs_identity: str,
|
|
214
|
+
provenance: dict[str, str],
|
|
215
|
+
) -> BaselineResolution:
|
|
216
|
+
"""Find the baseline matching what this run would measure.
|
|
217
|
+
|
|
218
|
+
Matching is strict identity: the deterministic filename locates the
|
|
219
|
+
candidate (same contract, same inputs fingerprint), and the recorded
|
|
220
|
+
covariates — the provenance minus volatile run-identity keys — must be
|
|
221
|
+
equal. Any difference is a non-match, and the resolution says which
|
|
222
|
+
keys differed: a drifted configuration is surfaced, never silently
|
|
223
|
+
treated as "no baseline".
|
|
224
|
+
"""
|
|
225
|
+
candidate = baseline_dir / f"{contract_id}-{inputs_identity[:12]}.yaml"
|
|
226
|
+
if not candidate.is_file():
|
|
227
|
+
return BaselineResolution(reason=f"no baseline found (expected {candidate.as_posix()})")
|
|
228
|
+
try:
|
|
229
|
+
stored = read_baseline(candidate)
|
|
230
|
+
except (ValueError, OSError, json.JSONDecodeError) as error:
|
|
231
|
+
return BaselineResolution(reason=f"baseline {candidate.name} is unreadable: {error}")
|
|
232
|
+
if stored.inputs_identity != inputs_identity:
|
|
233
|
+
return BaselineResolution(reason=f"baseline {candidate.name} records different inputs")
|
|
234
|
+
theirs = {k: v for k, v in stored.provenance.items() if k not in _VOLATILE_PROVENANCE}
|
|
235
|
+
ours = {k: v for k, v in provenance.items() if k not in _VOLATILE_PROVENANCE}
|
|
236
|
+
if theirs != ours:
|
|
237
|
+
differing = sorted(
|
|
238
|
+
key for key in set(theirs) | set(ours) if theirs.get(key) != ours.get(key)
|
|
239
|
+
)
|
|
240
|
+
return BaselineResolution(
|
|
241
|
+
reason=(
|
|
242
|
+
f"baseline {candidate.name} was measured under a different "
|
|
243
|
+
f"configuration (differing: {', '.join(differing)})"
|
|
244
|
+
),
|
|
245
|
+
mismatched_keys=tuple(differing),
|
|
246
|
+
)
|
|
247
|
+
return BaselineResolution(baseline=stored)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""The baseline record: what a measurement run durably states about a service."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
|
|
9
|
+
from baseltest.engine import LatencyBlock, RunResult, latency_block
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JudgementState(StrEnum):
|
|
13
|
+
"""A measurement-time normative judgement's outcome.
|
|
14
|
+
|
|
15
|
+
The schema also reserves ``unsupportable`` for callers whose sample size
|
|
16
|
+
was not validated up front; baseltest validates every run's size before
|
|
17
|
+
sampling, so it emits only ``met`` or ``failed``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
MET = "met"
|
|
21
|
+
FAILED = "failed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class NormativeJudgement:
|
|
26
|
+
"""The measurement-time judgement of one criterion against its declared threshold.
|
|
27
|
+
|
|
28
|
+
Purely documentary: a later reader sees not only what was measured but
|
|
29
|
+
how the measurement stood relative to a bar in force at measurement
|
|
30
|
+
time. It never affects how the artefact is consumed.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
state: The :class:`JudgementState` reached against the bar.
|
|
34
|
+
stipulated_threshold: The declared threshold judged against.
|
|
35
|
+
confidence: The confidence level of the judgement.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
state: JudgementState
|
|
39
|
+
stipulated_threshold: float
|
|
40
|
+
confidence: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class CriterionCharacterisation:
|
|
45
|
+
"""One criterion's measured characterisation.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
successes: Passing trials.
|
|
49
|
+
trials: Total trials.
|
|
50
|
+
failure_distribution: Failure reasons and their counts; empty when
|
|
51
|
+
every trial passed.
|
|
52
|
+
judgement: The measurement-time judgement, when the criterion
|
|
53
|
+
declared a threshold; ``None`` otherwise.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
successes: int
|
|
57
|
+
trials: int
|
|
58
|
+
failure_distribution: Mapping[str, int] = field(default_factory=dict)
|
|
59
|
+
judgement: NormativeJudgement | None = None
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
object.__setattr__(
|
|
63
|
+
self, "failure_distribution", MappingProxyType(dict(self.failure_distribution))
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def observed_rate(self) -> float:
|
|
68
|
+
"""The observed pass rate. A recorded characterisation has at least
|
|
69
|
+
one trial."""
|
|
70
|
+
return self.successes / self.trials
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class BaselineRecord:
|
|
75
|
+
"""Everything the baseline artefact states.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
contract_id: The measured contract's identity.
|
|
79
|
+
generated_at: Measurement time, UTC.
|
|
80
|
+
sample_count: The run's total sample count.
|
|
81
|
+
inputs_identity: Order-insensitive fingerprint of the input list.
|
|
82
|
+
criteria: Per-criterion characterisations, keyed by criterion name,
|
|
83
|
+
in declaration order.
|
|
84
|
+
provenance: Additional provenance the caller supplies (e.g. the
|
|
85
|
+
contract-format identifier, the resolved binding's name). String
|
|
86
|
+
keys and values; recorded verbatim.
|
|
87
|
+
latency: The gated aggregate-latency summary, carrying the full
|
|
88
|
+
ascending vector of passing-sample durations — the raw material
|
|
89
|
+
a later test needs to derive its own bound at its own
|
|
90
|
+
confidence. ``None`` when no sample passed or no per-sample
|
|
91
|
+
observations were recorded.
|
|
92
|
+
views: Descriptive fingerprints of declared view output schemas
|
|
93
|
+
that are NOT covariates, keyed by view name — visible and
|
|
94
|
+
diffable in the artefact, never compared by baseline
|
|
95
|
+
resolution (covariate fingerprints travel in ``provenance``
|
|
96
|
+
instead). Additive, optional field of the artefact schema.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
contract_id: str
|
|
100
|
+
generated_at: datetime
|
|
101
|
+
sample_count: int
|
|
102
|
+
inputs_identity: str
|
|
103
|
+
criteria: Mapping[str, CriterionCharacterisation]
|
|
104
|
+
provenance: Mapping[str, str] = field(default_factory=dict)
|
|
105
|
+
latency: LatencyBlock | None = None
|
|
106
|
+
views: Mapping[str, str] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
def __post_init__(self) -> None:
|
|
109
|
+
object.__setattr__(self, "views", MappingProxyType(dict(self.views)))
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def from_run_result(
|
|
113
|
+
result: RunResult,
|
|
114
|
+
provenance: Mapping[str, str] | None = None,
|
|
115
|
+
views: Mapping[str, str] | None = None,
|
|
116
|
+
) -> "BaselineRecord":
|
|
117
|
+
"""Build a record from a completed run.
|
|
118
|
+
|
|
119
|
+
Thresholded criteria carry their measurement-time judgement
|
|
120
|
+
(met/failed from the run's verdict); unthresholded criteria are
|
|
121
|
+
characterised without one.
|
|
122
|
+
"""
|
|
123
|
+
criteria: dict[str, CriterionCharacterisation] = {}
|
|
124
|
+
for criterion_result in result.criterion_results:
|
|
125
|
+
judgement = None
|
|
126
|
+
if criterion_result.verdict is not None:
|
|
127
|
+
criterion = criterion_result.criterion
|
|
128
|
+
assert criterion.threshold is not None
|
|
129
|
+
judgement = NormativeJudgement(
|
|
130
|
+
state=JudgementState.MET
|
|
131
|
+
if criterion_result.verdict.value == "pass"
|
|
132
|
+
else JudgementState.FAILED,
|
|
133
|
+
stipulated_threshold=criterion.threshold,
|
|
134
|
+
confidence=criterion.confidence,
|
|
135
|
+
)
|
|
136
|
+
tally = criterion_result.tally
|
|
137
|
+
criteria[criterion_result.name] = CriterionCharacterisation(
|
|
138
|
+
successes=tally.successes,
|
|
139
|
+
trials=tally.trials,
|
|
140
|
+
failure_distribution=dict(tally.failure_reasons),
|
|
141
|
+
judgement=judgement,
|
|
142
|
+
)
|
|
143
|
+
return BaselineRecord(
|
|
144
|
+
contract_id=result.contract_id,
|
|
145
|
+
generated_at=result.finished_at,
|
|
146
|
+
sample_count=result.plan.samples,
|
|
147
|
+
inputs_identity=result.inputs_identity,
|
|
148
|
+
criteria=criteria,
|
|
149
|
+
provenance=dict(provenance or {}),
|
|
150
|
+
latency=latency_block(result.samples),
|
|
151
|
+
views=dict(views or {}),
|
|
152
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The single writer: serialising a baseline record to the artefact schema.
|
|
2
|
+
|
|
3
|
+
Schema ``baseltest-baseline-2`` (draft), emitted deterministically with no
|
|
4
|
+
third-party dependency: the schema is this package's own, every emitted
|
|
5
|
+
string is JSON-quoted (a JSON string is a valid YAML flow scalar), and key
|
|
6
|
+
order is fixed, so identical records produce identical bytes. Version 2
|
|
7
|
+
adds the ``latency:`` block — field-compatible with punit's baseline
|
|
8
|
+
latency block — to a version-1 body that is otherwise unchanged.
|
|
9
|
+
|
|
10
|
+
Illustrative artefact:
|
|
11
|
+
|
|
12
|
+
.. code-block:: yaml
|
|
13
|
+
|
|
14
|
+
schemaVersion: "baseltest-baseline-2"
|
|
15
|
+
contractId: "refund-confirmation"
|
|
16
|
+
generatedAt: "2026-07-06T12:00:00+00:00"
|
|
17
|
+
sampleCount: 300
|
|
18
|
+
inputsIdentity: "3fd0..."
|
|
19
|
+
provenance:
|
|
20
|
+
taskFormat: "mavai-contract/1"
|
|
21
|
+
binding: "refund-service"
|
|
22
|
+
criteria:
|
|
23
|
+
"relevant":
|
|
24
|
+
observedPassRate: 0.98
|
|
25
|
+
successes: 294
|
|
26
|
+
trials: 300
|
|
27
|
+
failureDistribution:
|
|
28
|
+
"response does not contain 'refund'": 6
|
|
29
|
+
normativeJudgement:
|
|
30
|
+
state: "met"
|
|
31
|
+
stipulatedThreshold: 0.95
|
|
32
|
+
confidence: 0.95
|
|
33
|
+
latency:
|
|
34
|
+
basis: "passing-samples"
|
|
35
|
+
contributingSamples: 294
|
|
36
|
+
totalSamples: 300
|
|
37
|
+
p50Ms: 240
|
|
38
|
+
p90Ms: 480
|
|
39
|
+
p95Ms: 760
|
|
40
|
+
p99Ms: 1180
|
|
41
|
+
sortedPassingLatenciesMs:
|
|
42
|
+
- 118
|
|
43
|
+
- 121
|
|
44
|
+
# ... every contributing duration, ascending
|
|
45
|
+
|
|
46
|
+
The ``latency:`` block appears when at least one sample passed and carries
|
|
47
|
+
only the percentiles its contributing-sample count can support (p50 needs
|
|
48
|
+
1, p90 needs 10, p95 needs 20, p99 needs 100), followed by the full
|
|
49
|
+
ascending vector of passing-sample durations. The vector, not the
|
|
50
|
+
percentiles, is what a later test consumes to derive a latency bound at
|
|
51
|
+
its own confidence — nothing derived is persisted here.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
from baseltest.engine.artefact import latency_lines, quote
|
|
57
|
+
from baseltest.engine.naming import bounded_key
|
|
58
|
+
|
|
59
|
+
from .record import BaselineRecord, CriterionCharacterisation
|
|
60
|
+
|
|
61
|
+
SCHEMA_VERSION = "baseltest-baseline-2"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _criterion_lines(name: str, c: CriterionCharacterisation) -> list[str]:
|
|
65
|
+
lines = [
|
|
66
|
+
f" {quote(bounded_key(name))}:",
|
|
67
|
+
f" observedPassRate: {c.observed_rate:.6f}",
|
|
68
|
+
f" successes: {c.successes}",
|
|
69
|
+
f" trials: {c.trials}",
|
|
70
|
+
]
|
|
71
|
+
if c.failure_distribution:
|
|
72
|
+
lines.append(" failureDistribution:")
|
|
73
|
+
for reason in sorted(c.failure_distribution):
|
|
74
|
+
lines.append(f" {quote(bounded_key(reason))}: {c.failure_distribution[reason]}")
|
|
75
|
+
if c.judgement is not None:
|
|
76
|
+
lines.extend(
|
|
77
|
+
[
|
|
78
|
+
" normativeJudgement:",
|
|
79
|
+
f" state: {quote(c.judgement.state)}",
|
|
80
|
+
f" stipulatedThreshold: {c.judgement.stipulated_threshold}",
|
|
81
|
+
f" confidence: {c.judgement.confidence}",
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
return lines
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_baseline(record: BaselineRecord) -> str:
|
|
88
|
+
"""Serialise a record to the artefact schema, deterministically."""
|
|
89
|
+
lines = [
|
|
90
|
+
f"schemaVersion: {quote(SCHEMA_VERSION)}",
|
|
91
|
+
f"contractId: {quote(record.contract_id)}",
|
|
92
|
+
f"generatedAt: {quote(record.generated_at.isoformat())}",
|
|
93
|
+
f"sampleCount: {record.sample_count}",
|
|
94
|
+
f"inputsIdentity: {quote(record.inputs_identity)}",
|
|
95
|
+
]
|
|
96
|
+
if record.provenance:
|
|
97
|
+
lines.append("provenance:")
|
|
98
|
+
for key in sorted(record.provenance):
|
|
99
|
+
lines.append(f" {quote(key)}: {quote(record.provenance[key])}")
|
|
100
|
+
if record.views:
|
|
101
|
+
lines.append("views:")
|
|
102
|
+
for view in sorted(record.views):
|
|
103
|
+
lines.append(f" {quote(view)}:")
|
|
104
|
+
lines.append(f" outputSchemaFingerprint: {quote(record.views[view])}")
|
|
105
|
+
lines.append("criteria:")
|
|
106
|
+
for name, characterisation in record.criteria.items():
|
|
107
|
+
lines.extend(_criterion_lines(name, characterisation))
|
|
108
|
+
if record.latency is not None:
|
|
109
|
+
lines.extend(latency_lines(record.latency))
|
|
110
|
+
return "\n".join(lines) + "\n"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def baseline_filename(record: BaselineRecord) -> str:
|
|
114
|
+
"""The artefact's canonical filename: contract identity plus input-set tail.
|
|
115
|
+
|
|
116
|
+
Stable per (contract, input set): re-measuring the same pairing
|
|
117
|
+
refreshes the artefact rather than accumulating copies.
|
|
118
|
+
"""
|
|
119
|
+
return f"{record.contract_id}-{record.inputs_identity[:12]}.yaml"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_baseline(record: BaselineRecord, directory: Path) -> Path:
|
|
123
|
+
"""Write the artefact under ``directory``, creating it if needed.
|
|
124
|
+
|
|
125
|
+
Returns the written path. The artefact is on disk when this returns --
|
|
126
|
+
callers that assert on results afterwards get persistence-before-
|
|
127
|
+
assertion by construction.
|
|
128
|
+
"""
|
|
129
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
path = directory / baseline_filename(record)
|
|
131
|
+
path.write_text(render_baseline(record), encoding="utf-8")
|
|
132
|
+
return path
|