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.
Files changed (134) hide show
  1. baseltest/__init__.py +35 -0
  2. baseltest/_version.py +11 -0
  3. baseltest/baseline/__init__.py +46 -0
  4. baseltest/baseline/reader.py +247 -0
  5. baseltest/baseline/record.py +152 -0
  6. baseltest/baseline/writer.py +132 -0
  7. baseltest/contract/__init__.py +84 -0
  8. baseltest/contract/errors.py +21 -0
  9. baseltest/contract/evaluation.py +236 -0
  10. baseltest/contract/model.py +363 -0
  11. baseltest/contract/postconditions.py +159 -0
  12. baseltest/declarative/__init__.py +66 -0
  13. baseltest/declarative/_cli.py +431 -0
  14. baseltest/declarative/_disclosure.py +56 -0
  15. baseltest/declarative/_errors.py +13 -0
  16. baseltest/declarative/_instantiate/__init__.py +34 -0
  17. baseltest/declarative/_instantiate/_baseline.py +183 -0
  18. baseltest/declarative/_instantiate/_compose.py +158 -0
  19. baseltest/declarative/_instantiate/_explore.py +135 -0
  20. baseltest/declarative/_instantiate/_latency.py +108 -0
  21. baseltest/declarative/_instantiate/_optimize_point.py +122 -0
  22. baseltest/declarative/_instantiate/_postconditions.py +151 -0
  23. baseltest/declarative/_instantiate/_service.py +129 -0
  24. baseltest/declarative/_instantiate/_sizing_policy.py +90 -0
  25. baseltest/declarative/_instantiate/_views.py +79 -0
  26. baseltest/declarative/_materialise.py +122 -0
  27. baseltest/declarative/_optimize.py +319 -0
  28. baseltest/declarative/_parser/__init__.py +40 -0
  29. baseltest/declarative/_parser/_contract.py +84 -0
  30. baseltest/declarative/_parser/_criteria.py +100 -0
  31. baseltest/declarative/_parser/_forms.py +59 -0
  32. baseltest/declarative/_parser/_inputs.py +162 -0
  33. baseltest/declarative/_parser/_latency.py +108 -0
  34. baseltest/declarative/_parser/_model.py +93 -0
  35. baseltest/declarative/_parser/_shape.py +47 -0
  36. baseltest/declarative/_parser/_structure.py +88 -0
  37. baseltest/declarative/_providers/__init__.py +247 -0
  38. baseltest/declarative/_providers/_anthropic.py +134 -0
  39. baseltest/declarative/_providers/_apertus.py +34 -0
  40. baseltest/declarative/_providers/_litellm.py +94 -0
  41. baseltest/declarative/_providers/_media.py +93 -0
  42. baseltest/declarative/_providers/_mistral.py +28 -0
  43. baseltest/declarative/_providers/_ollama.py +72 -0
  44. baseltest/declarative/_providers/_openai.py +45 -0
  45. baseltest/declarative/_providers/_protocol.py +223 -0
  46. baseltest/declarative/_registrations.py +62 -0
  47. baseltest/declarative/_registry/__init__.py +31 -0
  48. baseltest/declarative/_registry/_bindings.py +104 -0
  49. baseltest/declarative/_registry/_core.py +353 -0
  50. baseltest/declarative/_registry/_guards.py +56 -0
  51. baseltest/declarative/_registry/_service_types.py +163 -0
  52. baseltest/declarative/_registry/_transform.py +82 -0
  53. baseltest/declarative/_runner/__init__.py +43 -0
  54. baseltest/declarative/_runner/_check.py +90 -0
  55. baseltest/declarative/_runner/_explore.py +171 -0
  56. baseltest/declarative/_runner/_load.py +45 -0
  57. baseltest/declarative/_runner/_optimize.py +120 -0
  58. baseltest/declarative/_runner/_optimize_loop.py +297 -0
  59. baseltest/declarative/_runner/_report.py +57 -0
  60. baseltest/declarative/_runner/_run.py +207 -0
  61. baseltest/declarative/_runner/_shared.py +56 -0
  62. baseltest/declarative/_schema_walk.py +355 -0
  63. baseltest/declarative/_services/__init__.py +47 -0
  64. baseltest/declarative/_services/_language_model.py +315 -0
  65. baseltest/declarative/_services/_model.py +78 -0
  66. baseltest/declarative/_services/_parse.py +180 -0
  67. baseltest/declarative/_signatures.py +42 -0
  68. baseltest/declarative/_sizing/__init__.py +34 -0
  69. baseltest/declarative/_sizing/_criteria.py +115 -0
  70. baseltest/declarative/_sizing/_flags.py +79 -0
  71. baseltest/declarative/_sizing/_model.py +62 -0
  72. baseltest/declarative/_sizing/_modes.py +195 -0
  73. baseltest/declarative/_sizing/_pricing.py +66 -0
  74. baseltest/declarative/_sizing/_prompts.py +92 -0
  75. baseltest/declarative/_sizing/_rates.py +43 -0
  76. baseltest/declarative/_sizing/_render.py +108 -0
  77. baseltest/declarative/_sizing/_resolve.py +161 -0
  78. baseltest/declarative/_steppers/__init__.py +74 -0
  79. baseltest/declarative/_steppers/_builtins.py +34 -0
  80. baseltest/declarative/_steppers/_context.py +114 -0
  81. baseltest/declarative/_steppers/_contract.py +148 -0
  82. baseltest/declarative/_steppers/_linear_sweep.py +43 -0
  83. baseltest/declarative/_steppers/_numeric.py +30 -0
  84. baseltest/declarative/_steppers/_prompt_engineer.py +112 -0
  85. baseltest/declarative/_steppers/_refining_grid.py +240 -0
  86. baseltest/declarative/_structured.py +219 -0
  87. baseltest/declarative/_types.py +84 -0
  88. baseltest/engine/__init__.py +65 -0
  89. baseltest/engine/artefact.py +68 -0
  90. baseltest/engine/defect.py +68 -0
  91. baseltest/engine/latency.py +225 -0
  92. baseltest/engine/naming.py +62 -0
  93. baseltest/engine/run/__init__.py +36 -0
  94. baseltest/engine/run/attainment.py +48 -0
  95. baseltest/engine/run/execute.py +138 -0
  96. baseltest/engine/run/feasibility.py +77 -0
  97. baseltest/engine/run/identity.py +44 -0
  98. baseltest/engine/run/judge.py +29 -0
  99. baseltest/engine/run/model.py +165 -0
  100. baseltest/engine/run/sample.py +171 -0
  101. baseltest/exploration/__init__.py +21 -0
  102. baseltest/exploration/writer.py +167 -0
  103. baseltest/observation/__init__.py +19 -0
  104. baseltest/observation/emit.py +80 -0
  105. baseltest/observation/record.py +189 -0
  106. baseltest/optimization/__init__.py +29 -0
  107. baseltest/optimization/record.py +114 -0
  108. baseltest/optimization/writer.py +96 -0
  109. baseltest/py.typed +0 -0
  110. baseltest/reporting/__init__.py +51 -0
  111. baseltest/reporting/console.py +352 -0
  112. baseltest/reporting/report_html.py +233 -0
  113. baseltest/reporting/run_design.py +86 -0
  114. baseltest/reporting/test_report.py +277 -0
  115. baseltest/reporting/verdict_reader.py +253 -0
  116. baseltest/reporting/verdict_xml.py +204 -0
  117. baseltest/statistics/__init__.py +94 -0
  118. baseltest/statistics/_constants.py +19 -0
  119. baseltest/statistics/_validation.py +32 -0
  120. baseltest/statistics/feasibility.py +93 -0
  121. baseltest/statistics/latency.py +166 -0
  122. baseltest/statistics/power.py +111 -0
  123. baseltest/statistics/proportion.py +33 -0
  124. baseltest/statistics/sizing.py +210 -0
  125. baseltest/statistics/summary.py +74 -0
  126. baseltest/statistics/threshold.py +269 -0
  127. baseltest/statistics/verdict.py +168 -0
  128. baseltest/statistics/wilson.py +168 -0
  129. baseltest-0.7.0.dist-info/METADATA +141 -0
  130. baseltest-0.7.0.dist-info/RECORD +134 -0
  131. baseltest-0.7.0.dist-info/WHEEL +4 -0
  132. baseltest-0.7.0.dist-info/entry_points.txt +2 -0
  133. baseltest-0.7.0.dist-info/licenses/LICENSE +202 -0
  134. 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