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.
@@ -0,0 +1,330 @@
1
+ """Contract module — canonical JSON, the wire codecs, and the guards on untrusted input.
2
+
3
+ Imports pydantic and :mod:`baseaicore`; performs no I/O and reads no clock. This module owns the
4
+ three places where a Python value and its JSON form disagree, and it resolves each one exactly
5
+ once so that no payload model has to:
6
+
7
+ * a measurement that does not exist here is the string ``"unsupported"``, never ``null``, never
8
+ ``0`` (ADR-0016 §4);
9
+ * a timestamp is RFC 3339 UTC at millisecond precision, never naive
10
+ ([spec §11.6](../../docs/packages/setspec/spec.md));
11
+ * byte-identical output for equal input, which is what makes a hash of a payload comparable across
12
+ machines (spec §11.4, §16).
13
+
14
+ Canonical output is delegated to :func:`baseaicore.canonical_json` rather than reimplemented. That
15
+ is deliberate: a machine fingerprint, a runtime profile hash and an exported payload must agree
16
+ byte-for-byte, and two independent canonicalizers eventually disagree about a float or a
17
+ non-ASCII string.
18
+
19
+ Untrusted input is bounded before it is parsed, not after. :data:`MAX_PAYLOAD_BYTES` and
20
+ :data:`MAX_PAYLOAD_DEPTH` are module constants rather than configuration because the supported
21
+ limits of a build are part of what that build promises (spec §12), and a caller that needs a
22
+ tighter bound passes one per call.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from datetime import UTC, datetime
29
+ from typing import TYPE_CHECKING, Annotated, Any, Final
30
+
31
+ from baseaicore import (
32
+ UNSUPPORTED,
33
+ Measurement,
34
+ Unsupported,
35
+ ValidationError,
36
+ canonical_json,
37
+ from_rfc3339,
38
+ is_supported,
39
+ to_rfc3339,
40
+ )
41
+ from pydantic import (
42
+ BaseModel,
43
+ BeforeValidator,
44
+ GetCoreSchemaHandler,
45
+ GetJsonSchemaHandler,
46
+ PlainSerializer,
47
+ WithJsonSchema,
48
+ )
49
+ from pydantic.json_schema import JsonSchemaValue
50
+ from pydantic_core import core_schema
51
+
52
+ if TYPE_CHECKING:
53
+ from collections.abc import Mapping
54
+
55
+ __all__ = [
56
+ "MAX_PAYLOAD_BYTES",
57
+ "MAX_PAYLOAD_DEPTH",
58
+ "UNSUPPORTED_JSON",
59
+ "MeasurementField",
60
+ "TimestampField",
61
+ "canonical_dumps",
62
+ "parse_json",
63
+ ]
64
+
65
+ UNSUPPORTED_JSON: Final[str] = "unsupported"
66
+ """The JSON form of an unavailable measurement, fixed by ADR-0016 §4."""
67
+
68
+ MAX_PAYLOAD_DEPTH: Final[int] = 64
69
+ """Deepest container nesting accepted from untrusted JSON.
70
+
71
+ Chosen to sit far above any payload this suite defines — an evidence bundle nests about six deep,
72
+ and the most elaborate provenance block roughly ten — and far below CPython's default recursion
73
+ limit of 1000. The gap is the point: a hostile document is refused by a named limit with a useful
74
+ error, rather than by a ``RecursionError`` raised somewhere inside the JSON parser.
75
+ """
76
+
77
+ MAX_PAYLOAD_BYTES: Final[int] = 16 * 1024 * 1024
78
+ """Largest single document accepted from untrusted JSON, in bytes of UTF-8.
79
+
80
+ Matches the prompt-bearing request body limit in
81
+ API Standards §10, so a payload that a suite
82
+ API would accept is one this parser will also accept. Large exports are not meant to arrive as one
83
+ document: they are JSONL with one envelope per line (spec §15), which keeps both the parser and
84
+ the consumer's memory bounded regardless of how many results the file holds.
85
+ """
86
+
87
+ _MICROSECONDS_PER_MILLISECOND = 1_000
88
+
89
+
90
+ def _validate_measurement(value: Any) -> Measurement: # noqa: ANN401 — accepts raw JSON scalars
91
+ """Accept a number or ``"unsupported"``; refuse anything that would fabricate a measurement."""
92
+ # `isinstance` rather than `is UNSUPPORTED`: the sentinel is a singleton so the two are
93
+ # equivalent at runtime, but comparing an `Any` against a constant makes mypy widen the
94
+ # constant to `Any` too, and the narrowed form keeps the return type honest.
95
+ if isinstance(value, Unsupported):
96
+ return value
97
+ if isinstance(value, str):
98
+ if value == UNSUPPORTED_JSON:
99
+ return UNSUPPORTED
100
+ raise ValueError(
101
+ f"a measurement is a number or the string {UNSUPPORTED_JSON!r}; got the string "
102
+ f"{value!r}. A measurement is never parsed from text, because a string that looks "
103
+ "like a number is usually a bug upstream rather than a reading."
104
+ )
105
+ # bool is an int in Python, and `True` reaching a metric means a flag was wired to a value.
106
+ if isinstance(value, bool):
107
+ raise ValueError(
108
+ "a bool is not a measurement; use a number, or UNSUPPORTED if this environment "
109
+ "cannot provide the value (ADR-0016)"
110
+ )
111
+ if isinstance(value, int | float):
112
+ # Narrowed from `Any` by the isinstance above; the annotation restates it for mypy.
113
+ numeric: int | float = value
114
+ return numeric
115
+ raise ValueError(
116
+ f"a measurement is a number or the string {UNSUPPORTED_JSON!r}; got "
117
+ f"{type(value).__name__}. Never null and never 0 — an absent measurement is "
118
+ f"{UNSUPPORTED_JSON!r} (ADR-0016 §4)."
119
+ )
120
+
121
+
122
+ def _serialize_measurement(value: Measurement) -> int | float | str:
123
+ """Render a measurement as JSON: a number, or the string ``"unsupported"``."""
124
+ # `is_supported` is a TypeGuard, so this narrows for the type checker; a bare
125
+ # `value is UNSUPPORTED` test would not.
126
+ return value if is_supported(value) else UNSUPPORTED_JSON
127
+
128
+
129
+ class _MeasurementCodec:
130
+ """Pydantic hooks for :data:`MeasurementField`.
131
+
132
+ A full core schema rather than a stack of ``Annotated`` validators: the underlying type is
133
+ ``int | float | Unsupported``, and pydantic cannot build a schema for the sentinel class on its
134
+ own — it is a plain Python object with no fields. Replacing the schema outright also keeps the
135
+ generated JSON Schema honest, which matters because Phase 4 publishes it as package data.
136
+ """
137
+
138
+ @classmethod
139
+ def __get_pydantic_core_schema__(
140
+ cls, source: Any, handler: GetCoreSchemaHandler
141
+ ) -> core_schema.CoreSchema:
142
+ """Return the validate/serialize schema for a measurement."""
143
+ return core_schema.no_info_plain_validator_function(
144
+ _validate_measurement,
145
+ serialization=core_schema.plain_serializer_function_ser_schema(_serialize_measurement),
146
+ )
147
+
148
+ @classmethod
149
+ def __get_pydantic_json_schema__(
150
+ cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
151
+ ) -> JsonSchemaValue:
152
+ """Return the JSON Schema form: a number, or the string ``"unsupported"``."""
153
+ return {"anyOf": [{"type": "number"}, {"const": UNSUPPORTED_JSON}]}
154
+
155
+
156
+ MeasurementField = Annotated[Measurement, _MeasurementCodec]
157
+ """A :data:`baseaicore.Measurement` field with its wire codec attached.
158
+
159
+ Use this for any quantity a sensor, provider or benchmark might fail to report. It accepts a
160
+ number or the string ``"unsupported"`` on the way in, emits the same two forms on the way out, and
161
+ refuses ``null``, ``0``-as-absent, bools and numeric strings — the four ways a measurement that was
162
+ never taken gets into a chart.
163
+ """
164
+
165
+
166
+ def _validate_timestamp(value: Any) -> datetime: # noqa: ANN401 — accepts str or datetime
167
+ """Parse and normalize a timestamp to UTC at millisecond precision, refusing naive input."""
168
+ if isinstance(value, str):
169
+ try:
170
+ value = from_rfc3339(value)
171
+ except ValidationError as exc:
172
+ # `from_rfc3339` raises a SuiteError, which pydantic does not wrap. Letting it escape
173
+ # would mean a bad timestamp aborts validation immediately while every other bad field
174
+ # is reported together — so the caller would learn about one problem per attempt.
175
+ # Re-raised as ValueError, it joins the other field errors in one pydantic report.
176
+ raise ValueError(str(exc)) from exc
177
+ if not isinstance(value, datetime):
178
+ raise ValueError(
179
+ f"a timestamp is an RFC 3339 string or a timezone-aware datetime; got "
180
+ f"{type(value).__name__}"
181
+ )
182
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
183
+ raise ValueError(
184
+ "a naive datetime has no defensible UTC reading, and guessing one would shift every "
185
+ "downstream timestamp by the local offset; attach a timezone (ADR-0016 sibling rule, "
186
+ "coding standards §5)"
187
+ )
188
+ in_utc = value.astimezone(UTC)
189
+ # Truncate to the precision `to_rfc3339` emits, so `load(dump(x)) == x` holds for a datetime
190
+ # built from `utc_now()`, which carries microseconds. Without this the model would round-trip
191
+ # to a *different* instant and the round-trip contract (spec §11.3) would be false.
192
+ truncated = (
193
+ in_utc.microsecond // _MICROSECONDS_PER_MILLISECOND
194
+ ) * _MICROSECONDS_PER_MILLISECOND
195
+ return in_utc.replace(microsecond=truncated)
196
+
197
+
198
+ TimestampField = Annotated[
199
+ datetime,
200
+ BeforeValidator(_validate_timestamp),
201
+ PlainSerializer(to_rfc3339, return_type=str),
202
+ WithJsonSchema({"type": "string", "format": "date-time"}),
203
+ ]
204
+ """A timezone-aware UTC timestamp field, serialized as RFC 3339 with millisecond precision.
205
+
206
+ Accepts an RFC 3339 string or an aware :class:`~datetime.datetime` in any timezone, normalizes to
207
+ UTC, and truncates to milliseconds — the precision :func:`baseaicore.to_rfc3339` emits, so a value
208
+ survives a load/dump round trip unchanged. Naive datetimes are refused rather than assumed to be
209
+ UTC.
210
+ """
211
+
212
+
213
+ def canonical_dumps(value: BaseModel | Mapping[str, Any]) -> str:
214
+ """Serialize a model or mapping to canonical JSON.
215
+
216
+ Canonical means byte-identical for equal input: keys sorted by code point, minimal separators,
217
+ non-ASCII emitted as itself, floats in their shortest round-tripping form. That property is
218
+ what lets a payload be hashed, compared across machines and diffed between releases
219
+ (spec §11.4, §16).
220
+
221
+ Args:
222
+ value: A pydantic model — dumped through its own serializers first, so measurements and
223
+ timestamps take their wire forms — or an already-plain mapping.
224
+
225
+ Returns:
226
+ Canonical JSON text. Encode with UTF-8 to get the bytes that were promised.
227
+
228
+ Raises:
229
+ ValidationError: If the structure holds something canonical JSON cannot represent
230
+ unambiguously: a non-string mapping key, a non-finite float, a naive datetime, raw
231
+ bytes, a :class:`~decimal.Decimal`, or a reference cycle.
232
+ """
233
+ plain = value.model_dump() if isinstance(value, BaseModel) else value
234
+ return canonical_json(plain)
235
+
236
+
237
+ def parse_json(
238
+ data: bytes | str,
239
+ *,
240
+ max_bytes: int = MAX_PAYLOAD_BYTES,
241
+ max_depth: int = MAX_PAYLOAD_DEPTH,
242
+ ) -> Any: # noqa: ANN401 — returns whatever JSON structure the document held
243
+ """Parse untrusted JSON text with both guards applied before the parser runs.
244
+
245
+ Order matters. Size is checked first because it is O(1) and rejects the cheapest attack; depth
246
+ is measured by scanning the text, before :func:`json.loads` is given a chance to exhaust the
247
+ C stack on a document that is nothing but ten thousand open brackets.
248
+
249
+ Args:
250
+ data: JSON text, or its UTF-8 bytes.
251
+ max_bytes: Largest document accepted, in bytes of UTF-8. Defaults to
252
+ :data:`MAX_PAYLOAD_BYTES`; pass a smaller value when the caller knows its own bound
253
+ (spec §14 makes size limiting the caller's prerogative).
254
+ max_depth: Deepest container nesting accepted. Defaults to :data:`MAX_PAYLOAD_DEPTH`.
255
+
256
+ Returns:
257
+ The parsed structure: a mapping, list or scalar exactly as JSON defines it.
258
+
259
+ Raises:
260
+ ValidationError: If the document exceeds ``max_bytes``, nests deeper than ``max_depth``,
261
+ is not valid UTF-8, or is not parsable JSON. Every message names the limit or the
262
+ parse position, because "too big" without a number tells the caller nothing.
263
+ """
264
+ text = _decode(data)
265
+ size = len(text.encode("utf-8"))
266
+ if size > max_bytes:
267
+ raise ValidationError(
268
+ f"Payload is {size} bytes, over the {max_bytes}-byte limit. Large exports are "
269
+ "JSONL — one envelope per line — so that producer and consumer both stream "
270
+ "instead of holding the whole document in memory (spec §15).",
271
+ details={"size_bytes": size, "limit_bytes": max_bytes},
272
+ )
273
+ depth = _scan_depth(text)
274
+ if depth > max_depth:
275
+ raise ValidationError(
276
+ f"Payload nests {depth} containers deep, over the {max_depth}-level limit. No payload "
277
+ "this suite defines approaches that depth, so a document that does is either "
278
+ "corrupt or hostile.",
279
+ details={"depth": depth, "limit_depth": max_depth},
280
+ )
281
+ try:
282
+ return json.loads(text)
283
+ except json.JSONDecodeError as exc:
284
+ raise ValidationError(
285
+ f"Not parsable JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}.",
286
+ details={"line": exc.lineno, "column": exc.colno, "position": exc.pos},
287
+ ) from exc
288
+
289
+
290
+ def _decode(data: bytes | str) -> str:
291
+ """Return ``data`` as text, naming the byte offset when it is not valid UTF-8."""
292
+ if isinstance(data, str):
293
+ return data
294
+ try:
295
+ return data.decode("utf-8")
296
+ except UnicodeDecodeError as exc:
297
+ raise ValidationError(
298
+ f"Payload is not valid UTF-8 at byte {exc.start}: {exc.reason}. JSON on the wire is "
299
+ "UTF-8 (RFC 8259 §8.1).",
300
+ details={"position": exc.start, "reason": exc.reason},
301
+ ) from exc
302
+
303
+
304
+ def _scan_depth(text: str) -> int:
305
+ """Return the deepest container nesting in JSON text, counting brackets outside strings.
306
+
307
+ Scans rather than recurses so that measuring a hostile document cannot itself overflow the
308
+ stack — which is the whole point of checking depth before parsing.
309
+ """
310
+ depth = 0
311
+ deepest = 0
312
+ in_string = False
313
+ escaped = False
314
+ for character in text:
315
+ if in_string:
316
+ if escaped:
317
+ escaped = False
318
+ elif character == "\\":
319
+ escaped = True
320
+ elif character == '"':
321
+ in_string = False
322
+ continue
323
+ if character == '"':
324
+ in_string = True
325
+ elif character in "{[":
326
+ depth += 1
327
+ deepest = max(deepest, depth)
328
+ elif character in "}]":
329
+ depth -= 1
330
+ return deepest
setspec/vocabulary.py ADDED
@@ -0,0 +1,205 @@
1
+ """Contract module — the suite's capability vocabulary: which terms exist, and their version.
2
+
3
+ Imports :mod:`baseaicore` for :class:`~baseaicore.CapabilityId`'s syntax rules; performs no I/O.
4
+
5
+ BaseAiCore owns only the *shape* of a capability ID (dotted, lowercase, `[a-z][a-z0-9_]*` segments)
6
+ so that adding a term never forces a BaseAiCore release. This module owns the *contents*: which
7
+ terms are real, what a specialization must specialize, and how strictly an unrecognized term is
8
+ treated ([spec §4](../../docs/packages/setspec/spec.md), traceability matrix "who owns the
9
+ capability vocabulary").
10
+
11
+ **Versioning** ([spec §11.8](../../docs/packages/setspec/spec.md)): additions are minor, a removal
12
+ or a redefinition is major. ``CAPABILITY_VOCABULARY_VERSION`` reuses the same ``MAJOR.MINOR`` shape
13
+ as every schema version in this package, via :class:`~setspec.envelope.SchemaVersion`, so "is this
14
+ payload's vocabulary newer than mine" is the same comparison as everywhere else, not a second
15
+ version format to reason about.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Final
21
+
22
+ from baseaicore import CapabilityId
23
+ from baseaicore import ValidationError as SuiteValidationError
24
+
25
+ from setspec.envelope import SchemaVersion
26
+
27
+ __all__ = [
28
+ "CAPABILITIES",
29
+ "CAPABILITY_VOCABULARY_VERSION",
30
+ "RESERVED_ROOTS",
31
+ "is_known_capability",
32
+ "validate_capability",
33
+ ]
34
+
35
+ CAPABILITY_VOCABULARY_VERSION: Final[str] = "1.1"
36
+ """The vocabulary's own version, independent of every schema and package version in this build."""
37
+
38
+ CAPABILITIES: Final[frozenset[str]] = frozenset(
39
+ {
40
+ "reasoning",
41
+ "coding",
42
+ "code_review",
43
+ "auditing",
44
+ "debugging",
45
+ "instruction_following",
46
+ "structured_output",
47
+ "tool_use",
48
+ "agentic",
49
+ "summarization",
50
+ "creative_writing",
51
+ "judging",
52
+ "critiquing",
53
+ "long_context",
54
+ "speed",
55
+ "latency",
56
+ "memory_efficiency",
57
+ "token_efficiency",
58
+ "energy_efficiency",
59
+ "reliability",
60
+ # 1.1 — reserved; valid only as a specialization. See RESERVED_ROOTS.
61
+ "user",
62
+ }
63
+ )
64
+ """Every known **root** as of :data:`CAPABILITY_VOCABULARY_VERSION`, drawn from the capabilities
65
+ FreeWeight's benchmark catalog already maps results onto.
66
+
67
+ Roots only — never a specialization. A specialization is valid whenever its own root is a member
68
+ of this set: ``coding.python`` and ``coding.rust`` are both accepted the moment ``coding`` is
69
+ known, because :class:`~baseaicore.CapabilityId` already defines ``coding.rust`` as inheriting
70
+ from ``coding`` (its root), and asking a maintainer to pre-enumerate every language, framework or
71
+ task variant a root might be specialized into would make the vocabulary permanently incomplete by
72
+ construction. :func:`validate_capability` checks a candidate's *root* against this set, never the
73
+ full dotted string, so a root's specializations are open-ended while an unknown root is still
74
+ rejected outright.
75
+
76
+ This is a starting vocabulary, not a closed one — Phase 2's own risk note is "guessing the result
77
+ shape," and a root FreeWeight's real benchmarks need that is missing here is exactly the kind of
78
+ gap Phase 4's freeze against real output is meant to find and correct with a minor version bump.
79
+
80
+ ``user`` was added at ``1.1`` and is a member of this set only so that the root rule above accepts
81
+ its specializations; it is refused in its bare form. See :data:`RESERVED_ROOTS`.
82
+ """
83
+
84
+ RESERVED_ROOTS: Final[frozenset[str]] = frozenset({"user"})
85
+ """Roots that are valid **only** as a specialization; the bare root is refused.
86
+
87
+ ``user`` is the sole member. It carries FreeWeight's user-authored goal evidence as
88
+ ``user.<slug>`` (ADR-0032 §1), and one root added once closes the question permanently: the
89
+ open-ended specialization rule already accepts ``user.noir_tech_voice`` and every other goal any
90
+ user will ever write, so no future rubric is a vocabulary change.
91
+
92
+ The bare form is refused because it would mean nothing. Every other root in
93
+ :data:`CAPABILITIES` names a real, measurable capability that a benchmark maps onto; ``user``
94
+ names only the fact that a user defined something. A payload claiming ``capability_id: "user"``
95
+ has lost the identity that is the entire point of the namespace, and accepting it would let that
96
+ loss pass silently into a routing decision.
97
+
98
+ Membership in this set is checked *in addition to* :data:`CAPABILITIES`, not instead of it, so a
99
+ reserved root's specializations follow exactly the same rule as ``coding.rust``.
100
+ """
101
+
102
+
103
+ def is_known_capability(capability_id: str) -> bool:
104
+ """Report whether ``capability_id`` is both syntactically valid and in the current vocabulary.
105
+
106
+ Args:
107
+ capability_id: The candidate term.
108
+
109
+ Returns:
110
+ ``True`` iff ``capability_id`` parses as a :class:`~baseaicore.CapabilityId` and its root
111
+ is a member of :data:`CAPABILITIES` — so a specialization of a known root, even one never
112
+ explicitly enumerated, reports ``True``. A bare :data:`RESERVED_ROOTS` member is
113
+ ``False``: ``user`` is not itself a capability, only a namespace for them. A
114
+ syntactically invalid string is ``False``, not an exception — this function answers a
115
+ yes/no question; :func:`validate_capability` is the one that raises.
116
+ """
117
+ try:
118
+ parsed = CapabilityId(capability_id)
119
+ except SuiteValidationError:
120
+ return False
121
+ if parsed.root not in CAPABILITIES:
122
+ return False
123
+ return parsed.is_specialization or parsed.root not in RESERVED_ROOTS
124
+
125
+
126
+ def validate_capability(
127
+ capability_id: str,
128
+ *,
129
+ vocabulary_version: str | None = None,
130
+ ) -> CapabilityId:
131
+ """Validate a capability ID's syntax and, by default, its membership in the vocabulary.
132
+
133
+ Membership is enforced strictly unless ``vocabulary_version`` proves the payload comes from a
134
+ *newer minor* of the same major than this build knows — the forward-compatibility rule in
135
+ [spec §13](../../docs/packages/setspec/spec.md): "unknown capability ID: ``ValidationError``
136
+ when strict; a preserved warning when the payload's vocabulary version is newer." SetSpec has
137
+ no logging ([spec §17](../../docs/packages/setspec/spec.md)), so "preserved" is literal: the
138
+ term is accepted and returned rather than rejected, and a caller who wants to know whether
139
+ leniency was actually applied checks :func:`is_known_capability` itself.
140
+
141
+ A newer *major* is not given this leniency: rule 2 says a major vocabulary change may remove
142
+ or redefine a term, so an unrecognized ID under a newer major could just as easily be a
143
+ genuine removal as an addition, and treating it as forward-compatible would let the wrong case
144
+ through silently.
145
+
146
+ Args:
147
+ capability_id: The candidate term.
148
+ vocabulary_version: The vocabulary version the payload declares it was written against,
149
+ if known. Compared against :data:`CAPABILITY_VOCABULARY_VERSION` using the same
150
+ ``MAJOR.MINOR`` rules every schema version uses.
151
+
152
+ Returns:
153
+ The parsed, syntactically valid :class:`~baseaicore.CapabilityId`.
154
+
155
+ Raises:
156
+ ValidationError: If ``capability_id`` is not syntactically valid at all; if it is a bare
157
+ :data:`RESERVED_ROOTS` member such as ``"user"``, which is a namespace rather than a
158
+ capability; or if its root is unrecognized and no forward-compatibility exception
159
+ applies. A specialization of a known root — ``coding.rust`` when ``coding`` is known,
160
+ ``user.house_voice`` when ``user`` is — is never rejected on that basis alone, even
161
+ when the specialization itself was never explicitly enumerated.
162
+
163
+ A bare reserved root is refused **regardless of** ``vocabulary_version``: forward
164
+ compatibility exists so an older build accepts a term a newer one added, and no
165
+ future minor can turn ``user`` into a capability, because what it lacks is a
166
+ specialization rather than a vocabulary entry.
167
+ """
168
+ parsed = CapabilityId(capability_id) # raises baseaicore.ValidationError on bad syntax
169
+ if parsed.root in RESERVED_ROOTS and not parsed.is_specialization:
170
+ raise SuiteValidationError(
171
+ f"{capability_id!r} is a reserved namespace, not a capability. Use a specialization "
172
+ f"such as {capability_id}.my_goal — a bare {capability_id!r} carries no identity, "
173
+ "which is the whole reason the namespace exists.",
174
+ details={
175
+ "field": "capability_id",
176
+ "value": capability_id,
177
+ "reserved_roots": sorted(RESERVED_ROOTS),
178
+ },
179
+ )
180
+ if parsed.root in CAPABILITIES:
181
+ return parsed
182
+ if _is_forward_compatible(vocabulary_version):
183
+ return parsed
184
+ raise SuiteValidationError(
185
+ f"{capability_id!r} is not a known capability in vocabulary "
186
+ f"{CAPABILITY_VOCABULARY_VERSION}. Known roots and specializations are listed in "
187
+ "setspec.vocabulary.CAPABILITIES.",
188
+ details={
189
+ "field": "capability_id",
190
+ "value": capability_id,
191
+ "vocabulary_version": CAPABILITY_VOCABULARY_VERSION,
192
+ },
193
+ )
194
+
195
+
196
+ def _is_forward_compatible(vocabulary_version: str | None) -> bool:
197
+ """Report whether ``vocabulary_version`` is a newer minor of this build's known major."""
198
+ if vocabulary_version is None:
199
+ return False
200
+ try:
201
+ declared = SchemaVersion.parse(vocabulary_version)
202
+ known = SchemaVersion.parse(CAPABILITY_VOCABULARY_VERSION)
203
+ except SuiteValidationError:
204
+ return False
205
+ return declared.major == known.major and declared > known