setspec 0.2.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.
setspec/__about__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of the package version, read by hatchling at build time."""
2
+
3
+ __version__ = "0.2.0"
setspec/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """setspec — every versioned data contract that crosses an application boundary.
2
+
3
+ Layer 2: contract types built on :mod:`baseaicore`'s vocabulary and pydantic. No I/O, no
4
+ configuration, no logging, no HTTP, and no behaviour beyond structural and range validation. This
5
+ package answers "is this a well-formed result?" — never "is this a *good* result?", which belongs
6
+ to the application that computed it ([spec §3](../../docs/packages/setspec/spec.md)).
7
+
8
+ What is exported below is every piece of **shared, non-versioned** infrastructure as of Phase 2
9
+ (``docs/packages/setspec/development-plan.md``): the schema envelope, version parsing and the
10
+ reader policy; canonical serialization with the ``Unsupported`` and RFC 3339 codecs; the strict and
11
+ preserving payload bases with the generator that pairs them; ``MetricValue``; and the capability
12
+ vocabulary. Anything not listed in ``__all__`` is private and may change without a version bump.
13
+
14
+ **Payload types are not re-exported here.** ``model.identity``, ``machine.profile``,
15
+ ``benchmark.result``, ``benchmark.run_summary``, ``capability.evidence`` and
16
+ ``benchmark.evidence_bundle`` (Phase 2), and ``benchmark.goal_pack`` /
17
+ ``benchmark.calibration_report`` (ADR-0031), live in their own versioned modules —
18
+ ``setspec.model.v1``, ``setspec.machine.v1``, ``setspec.benchmark.v1``, ``setspec.capability.v1``,
19
+ ``setspec.goal.v1`` — and are imported from there, e.g.
20
+ ``from setspec.capability.v1 import CapabilityEvidenceOut``.
21
+ This is not an oversight: ADR-0009 rule 6
22
+ requires a v1 payload to remain importable as ``setspec.benchmark.v1`` for a deprecation window
23
+ after ``benchmark.result 2.0`` ships, which only works if ``v1`` and ``v2`` are different modules
24
+ rather than two classes racing for one flat name. ``MetricValue`` and the vocabulary helpers below
25
+ are re-exported flatly because neither is itself a versioned wire payload — they are shared
26
+ infrastructure a payload's fields are built from, exactly like the envelope and serialization
27
+ helpers above them.
28
+
29
+ >>> from setspec import GeneratorInfo, SchemaVersion, dump_envelope
30
+ >>> generator = GeneratorInfo(name="freeweight", version="1.0.0")
31
+ >>> document = dump_envelope(
32
+ ... {"tokens_per_second": 42.0},
33
+ ... schema="benchmark.result",
34
+ ... version=SchemaVersion(1, 0),
35
+ ... generator=generator,
36
+ ... )
37
+
38
+ The two halves of every payload type are the design, not an accident: a writer uses the ``Out``
39
+ class and may emit only fields it knows; a reader uses the ``In`` class and keeps fields it does
40
+ not (ADR-0009 rule 4). That is what lets a v1.0
41
+ reader carry a v1.1 document through a re-export without destroying the parts it cannot interpret.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from setspec.__about__ import __version__
47
+ from setspec.base import (
48
+ PayloadDefinition,
49
+ PreservingPayload,
50
+ StrictPayload,
51
+ WireEnum,
52
+ payload_models,
53
+ )
54
+ from setspec.envelope import (
55
+ DRAFT_SCHEMAS,
56
+ SUPPORTED_SCHEMAS,
57
+ GeneratorInfo,
58
+ SchemaEnvelope,
59
+ SchemaVersion,
60
+ dump_envelope,
61
+ load_envelope,
62
+ )
63
+ from setspec.errors import SchemaVersionUnsupported, ValidationError
64
+ from setspec.metrics import Aggregation, MetricValueFields, MetricValueIn, MetricValueOut
65
+ from setspec.serialization import (
66
+ MAX_PAYLOAD_BYTES,
67
+ MAX_PAYLOAD_DEPTH,
68
+ UNSUPPORTED_JSON,
69
+ MeasurementField,
70
+ TimestampField,
71
+ canonical_dumps,
72
+ parse_json,
73
+ )
74
+ from setspec.vocabulary import (
75
+ CAPABILITIES,
76
+ CAPABILITY_VOCABULARY_VERSION,
77
+ RESERVED_ROOTS,
78
+ is_known_capability,
79
+ validate_capability,
80
+ )
81
+
82
+ __all__ = [
83
+ "CAPABILITIES",
84
+ "CAPABILITY_VOCABULARY_VERSION",
85
+ "DRAFT_SCHEMAS",
86
+ "MAX_PAYLOAD_BYTES",
87
+ "MAX_PAYLOAD_DEPTH",
88
+ "RESERVED_ROOTS",
89
+ "SUPPORTED_SCHEMAS",
90
+ "UNSUPPORTED_JSON",
91
+ "Aggregation",
92
+ "GeneratorInfo",
93
+ "MeasurementField",
94
+ "MetricValueFields",
95
+ "MetricValueIn",
96
+ "MetricValueOut",
97
+ "PayloadDefinition",
98
+ "PreservingPayload",
99
+ "SchemaEnvelope",
100
+ "SchemaVersion",
101
+ "SchemaVersionUnsupported",
102
+ "StrictPayload",
103
+ "TimestampField",
104
+ "ValidationError",
105
+ "WireEnum",
106
+ "__version__",
107
+ "canonical_dumps",
108
+ "dump_envelope",
109
+ "is_known_capability",
110
+ "load_envelope",
111
+ "parse_json",
112
+ "payload_models",
113
+ "validate_capability",
114
+ ]
setspec/artifacts.py ADDED
@@ -0,0 +1,4 @@
1
+ """setspec.artifacts.
2
+
3
+ TODO: implement per docs/packages/setspec/development-plan.md.
4
+ """
setspec/base.py ADDED
@@ -0,0 +1,256 @@
1
+ """Contract module — the two payload base classes and the generator that pairs them.
2
+
3
+ Imports pydantic; performs no I/O and reads no clock.
4
+
5
+ ADR-0009 rule 4 requires every payload type to
6
+ exist as **two** classes generated from one definition, and the reason is a specific failure this
7
+ suite refuses to ship:
8
+
9
+ * A writer must emit only fields it knows, or it silently exports garbage it cannot explain.
10
+ * A reader must not *strip* fields it does not know, or an old tool re-exporting a new document
11
+ quietly destroys data — the sort of loss nobody notices until the original is gone.
12
+
13
+ Those two rules contradict each other in one class, so there are two: :class:`StrictPayload`
14
+ refuses unknown keys, :class:`PreservingPayload` keeps them. The round-trip contract
15
+ (``load(dump(x)) == x``, spec §11.3) is asserted **per class** — never across the pair, because
16
+ the pair is not meant to agree about unknown keys. That is the whole design.
17
+
18
+ Both bases are strict about types. Pydantic's default coercion would read ``"5"`` as ``5`` and
19
+ ``5.0`` as ``5``, which is exactly the silent coercion spec §13 forbids: on a wire contract a
20
+ string where a number belongs is a producer bug, and hiding it makes the bug arrive somewhere
21
+ further away. Widening ``int`` to ``float`` is still allowed, because JSON writes ``1.0`` as ``1``
22
+ and refusing that would reject valid documents.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from enum import StrEnum
28
+ from types import MappingProxyType
29
+ from typing import TYPE_CHECKING, Annotated, Any, Final, cast
30
+
31
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Mapping
35
+
36
+ __all__ = [
37
+ "PayloadDefinition",
38
+ "PreservingPayload",
39
+ "StrictPayload",
40
+ "WireEnum",
41
+ "WireSequence",
42
+ "payload_models",
43
+ ]
44
+
45
+ _SHARED_CONFIG: Final[ConfigDict] = ConfigDict(
46
+ # No silent coercion: a wire contract that repairs its input hides the producer's defect
47
+ # (spec §13). `int` still widens to `float`, which JSON requires.
48
+ strict=True,
49
+ # Payloads are documents, not state. A loaded payload that some later code mutates is a
50
+ # document that no longer matches the bytes it came from.
51
+ frozen=True,
52
+ # Field order in the model is not the wire order — canonical JSON sorts keys — but a stable
53
+ # declaration order keeps generated JSON Schema diffs readable between releases.
54
+ populate_by_name=True,
55
+ )
56
+
57
+
58
+ class PayloadDefinition(BaseModel):
59
+ """Base for a payload's field declarations, and the common ancestor of both generated halves.
60
+
61
+ A definition class is not itself a payload: it carries the fields and says nothing about
62
+ unknown keys, which is the one thing the two generated halves must disagree about. Subclass it
63
+ to declare fields, then hand it to :func:`payload_models`::
64
+
65
+ class MetricValueFields(PayloadDefinition):
66
+ unit: str
67
+ value: MeasurementField
68
+
69
+ Both halves inherit from it as well, which is what lets :func:`payload_models` return a pair
70
+ typed as the definition itself. Without a common ancestor a type checker would see the
71
+ generated classes as bare bases with no fields, and every consumer of a payload — in this
72
+ repository and in the three applications — would need a cast to read one.
73
+
74
+ **Nesting one payload inside another.** A field can be typed directly as another definition —
75
+ ``model: ModelIdentityFields`` inside ``CapabilityEvidenceFields`` — rather than as that
76
+ definition's generated ``Out`` or ``In``. This is deliberate, not an oversight: the outer
77
+ payload's own Out/In split already governs whether *the document as a whole* may carry an
78
+ unknown field, and ADR-0009 rule 4's guarantee that matters most is that a reader never
79
+ *loses* data. A definition's own ``extra="allow"`` (below) makes every nested payload
80
+ preserving by default in both directions — a writer embedding a stray field one level down is
81
+ a narrower gap than a reader silently discarding one, and accepting that narrower gap avoids
82
+ generating a full, independently-versioned Out/In pair for every sub-structure that is never
83
+ transmitted on its own.
84
+ """
85
+
86
+ model_config = ConfigDict(**_SHARED_CONFIG, extra="allow")
87
+
88
+ @property
89
+ def extras(self) -> Mapping[str, Any]:
90
+ """Return the keys this build did not recognise, as a read-only mapping.
91
+
92
+ Returns:
93
+ Every key present in the source document that is not a declared field, in the order
94
+ the document listed them. Always empty on a :class:`StrictPayload`, which refuses
95
+ unknown keys outright — it is defined on both halves so that code reading a payload
96
+ need not know which half it was handed.
97
+
98
+ The mapping is a read-only view: the payload is frozen, and handing out a mutable
99
+ reference to its interior would make that promise false.
100
+ """
101
+ return MappingProxyType(self.__pydantic_extra__ or {})
102
+
103
+
104
+ class StrictPayload(PayloadDefinition):
105
+ """Base for the **outbound** half of a payload pair: writers use this.
106
+
107
+ Refuses unknown keys. A writer that hands this model a field the schema does not define has
108
+ either misspelled a name or is emitting a version it has not declared, and both are defects
109
+ worth failing on at the point of construction rather than discovering in an export months
110
+ later (ADR-0009 rule 5).
111
+
112
+ Invariants:
113
+ * Immutable after construction (``frozen``), so a serialized document always matches the
114
+ object it came from.
115
+ * Strictly typed: no string-to-number or float-to-int coercion.
116
+ * ``load(dump(x)) == x`` for every instance. There are no unknown keys to preserve, so the
117
+ contract is simply that nothing is lost or reinterpreted.
118
+
119
+ Not hashable in general: equality is by field value, and a field holding a mapping makes the
120
+ instance unhashable exactly as a frozen dataclass containing a dict would be.
121
+ """
122
+
123
+ model_config = ConfigDict(**_SHARED_CONFIG, extra="forbid")
124
+
125
+
126
+ class PreservingPayload(PayloadDefinition):
127
+ """Base for the **inbound** half of a payload pair: readers use this.
128
+
129
+ Keeps unknown keys instead of dropping them, and re-emits them on dump. This is what makes a
130
+ v1.0 reader safe to put in front of a v1.1 document: it validates the fields it knows, carries
131
+ the ones it does not, and a re-export loses nothing
132
+ (ADR-0009 rule 4).
133
+
134
+ The relaxation is bounded and deliberate. ``extra="allow"`` weakens strictness on input, which
135
+ is the documented cost of preservation across a version gap; every *known* field is still
136
+ validated strictly, so an unknown key can add information but can never change the meaning of
137
+ a field this build already understands.
138
+
139
+ Invariants:
140
+ * Immutable after construction (``frozen``).
141
+ * Known fields strictly typed; unknown keys kept verbatim and reachable through
142
+ :attr:`extras`.
143
+ * ``load(dump(x)) == x`` for every instance, unknown keys included.
144
+ """
145
+
146
+ model_config = ConfigDict(**_SHARED_CONFIG, extra="allow")
147
+
148
+
149
+ type WireEnum[EnumT: StrEnum] = Annotated[EnumT, Field(strict=False)]
150
+ """A :class:`~enum.StrEnum` field that accepts its own member values from the wire.
151
+
152
+ Needed because the bases validate strictly, and strict mode in Python mode wants an actual enum
153
+ *instance* — while a document that has just been parsed from JSON holds the plain string
154
+ ``"mean"``. Since :func:`~setspec.envelope.load_envelope` hands back exactly such a mapping, a
155
+ plainly annotated enum field would reject every real document while passing every hand-built test,
156
+ which is the worst possible place for a validation rule to be wrong.
157
+
158
+ Relaxing strictness here costs nothing: membership is still exact, so an unknown name, an integer
159
+ index and a bool are all still refused. Use it for every enum field in every payload::
160
+
161
+ aggregation: WireEnum[Aggregation]
162
+ """
163
+
164
+
165
+ def _coerce_to_tuple(value: object) -> object:
166
+ """Convert a list or tuple to a tuple; pass anything else through for pydantic to reject.
167
+
168
+ JSON has one sequence type, and it deserializes to a Python ``list``. Strict mode validates
169
+ the *input Python type* against the annotation, and a bare ``tuple[T, ...]`` under
170
+ ``strict=True`` accepts only an actual ``tuple`` — never the ``list`` every real document
171
+ supplies — so every ordered-collection field would reject every real document without this.
172
+ """
173
+ if isinstance(value, list | tuple):
174
+ return tuple(value)
175
+ return value
176
+
177
+
178
+ type WireSequence[T] = Annotated[tuple[T, ...], BeforeValidator(_coerce_to_tuple)]
179
+ """An ordered collection field that accepts a JSON array and stores it as an immutable tuple.
180
+
181
+ Needed for the same reason as :data:`WireEnum`, one layer down: JSON arrays deserialize to
182
+ ``list``, but a payload is a document (frozen, and meant to stay that way all the way down), so
183
+ the field itself is a ``tuple`` — a stored ``list`` would leave a mutable object reachable through
184
+ an otherwise-immutable model. Element types are still validated strictly and individually; only
185
+ the *container's own type* is relaxed, exactly as :data:`WireEnum` relaxes only the enum's own
186
+ strictness. Use it for every ordered-collection field in every payload::
187
+
188
+ gpus: WireSequence[GpuProfileFields] = ()
189
+ source_run_ids: WireSequence[str] = ()
190
+ """
191
+
192
+
193
+ def payload_models[DefinitionT: PayloadDefinition](
194
+ definition: type[DefinitionT],
195
+ *,
196
+ name: str | None = None,
197
+ ) -> tuple[type[DefinitionT], type[DefinitionT]]:
198
+ """Generate the ``Out``/``In`` class pair for one payload definition.
199
+
200
+ One definition, two classes, so the field list cannot drift between what writers emit and what
201
+ readers accept — the drift ADR-0009 rule 4 exists to prevent. Write the fields once::
202
+
203
+ class MetricValueFields(BaseModel):
204
+ unit: str
205
+ value: MeasurementField
206
+
207
+ MetricValueOut, MetricValueIn = payload_models(MetricValueFields)
208
+
209
+ ``MetricValueOut`` refuses unknown keys; ``MetricValueIn`` preserves them. Neither inherits the
210
+ other, and the definition itself is never used as a payload — it carries fields, not a policy
211
+ about unknown keys.
212
+
213
+ Args:
214
+ definition: A pydantic model holding the field declarations. Its own ``model_config`` is
215
+ honoured except where it would contradict the base's guarantees: ``extra``, ``strict``
216
+ and ``frozen`` always come from the generated class, since those three *are* the
217
+ contract being generated.
218
+ name: Stem for the generated class names, which become ``<stem>Out`` and ``<stem>In``.
219
+ Defaults to the definition's own name with a trailing ``Fields`` or ``Definition``
220
+ removed, so ``MetricValueFields`` yields ``MetricValueOut`` and ``MetricValueIn``.
221
+
222
+ Returns:
223
+ The ``(Out, In)`` pair, in that order — the order they are used in: a producer writes
224
+ ``Out``, a consumer reads ``In``. Both are typed as the definition, so a type checker sees
225
+ the declared fields; the halves differ in the one way a type cannot express, which is what
226
+ they do with a key the definition never declared.
227
+ """
228
+ stem = name if name is not None else _stem_of(definition.__name__)
229
+ return (
230
+ _generate(definition, StrictPayload, f"{stem}Out"),
231
+ _generate(definition, PreservingPayload, f"{stem}In"),
232
+ )
233
+
234
+
235
+ def _stem_of(class_name: str) -> str:
236
+ """Strip a definition class's naming suffix to get the payload's real name."""
237
+ for suffix in ("Fields", "Definition"):
238
+ if class_name.endswith(suffix) and len(class_name) > len(suffix):
239
+ return class_name[: -len(suffix)]
240
+ return class_name
241
+
242
+
243
+ def _generate[DefinitionT: PayloadDefinition](
244
+ definition: type[DefinitionT],
245
+ base: type[PayloadDefinition],
246
+ class_name: str,
247
+ ) -> type[DefinitionT]:
248
+ """Build one generated class from a definition and a base, with the base's config winning."""
249
+ # The definition's config is kept for anything cosmetic (title, json_schema_extra) but the
250
+ # base's `extra`/`strict`/`frozen` are re-applied on top: those three are the guarantee the
251
+ # base exists to make, and a definition must not be able to opt out of them by accident.
252
+ # `ConfigDict` is a TypedDict, so a runtime merge of two of them is a plain dict as far as
253
+ # the type checker is concerned; the cast restates what the merge preserves.
254
+ merged = cast("ConfigDict", {**dict(definition.model_config), **dict(base.model_config)})
255
+ generated = type(class_name, (definition, base), {"model_config": merged})
256
+ return cast("type[DefinitionT]", generated)