weft-kernel 0.1.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,748 @@
1
+ """The pipeline as data — the authored document, published by the kernel.
2
+
3
+ Settled in G2 and specified in `docs/02-extension-model.md` §3 → *One model,
4
+ two directions*: "The pipeline **is** a frozen Pydantic model published by the
5
+ kernel. YAML is a serialisation of it; Python code constructs the same model
6
+ directly. That is 'both' with one implementation — one validator, one error
7
+ set, one resolved form, no builder DSL and therefore no second grammar to keep
8
+ in step."
9
+
10
+ **Two directions, one model, and no third way in.** `Pipeline.model_validate`
11
+ takes the mapping a YAML loader hands back; `Pipeline(...)` takes the same
12
+ thing as arguments. There is no builder, no `PipelineBuilder.add_stage()`, no
13
+ `from_yaml` classmethod — a second construction path is a second grammar, and
14
+ a second grammar is a place for the two to disagree about what a pipeline is.
15
+ For the same reason **the kernel parses no YAML**: G1 fixes its dependencies at
16
+ `pydantic` and `opentelemetry-api`, so whoever opens the file brings the loader
17
+ and hands a mapping in, exactly as `weft-cli` already does for `weft.toml`.
18
+
19
+ **The error set is `pydantic.ValidationError`, deliberately, and it is not
20
+ `PipelineResolutionError`.** `02` §3 → *When resolution fails* gives every
21
+ resolution failure its own `WeftError` subclass carrying the pipeline, the
22
+ stage ids, the distributions in conflict and the remedy. None of that exists
23
+ yet at this point: a document that will not validate has no resolved parent, no
24
+ registry lookup and no distributions to name — it is malformed text, not a
25
+ pipeline that could not be resolved. Keeping the two apart is what stops the
26
+ resolution family from becoming the place every failure is filed, which is the
27
+ shape `02` §3 rules out for the failure-mode ratchet's sake.
28
+
29
+ **Loud is still required of it.** `extra="forbid"` alone names the key it
30
+ refused but never the keys that exist, which fails `01`'s rule that an unknown
31
+ name says what the valid options are. `_document_keys_only` runs first and says
32
+ both, so a mistyped `steps:` is answered with `stages`.
33
+
34
+ **What this module deliberately does not carry.** The declarations behind ordering
35
+ constraints (task 1.2 — `intact`/`destroys`) live on a *plugin class*, never on a
36
+ document: a pipeline names a plugin, it does not restate what that plugin declares, so
37
+ there is nothing of task 1.2's for this module to hold. `extends` and `vars` are here as
38
+ *data* — resolution gives them meaning in tasks 1.3 and 1.14 — because they are what `02`
39
+ §3's own `base-de.yaml` is made of, and a model that could not hold the specification's
40
+ smallest example would not be the model the specification describes.
41
+
42
+ **Slots — task 1.11, `02` §3 → *Slots*.** `Pipeline.slots` is where the root opens a
43
+ named position for a pack's contribution: "a pipeline may contribute into a slot a
44
+ pipeline opted into. It may never rewrite a pipeline that did not ask." Declaring one is
45
+ the opt-in; `SlotDeclaration.after`/`before` position it against the root's own stages,
46
+ on `InsertOperator`'s own terms. What this module refuses, rather than half-reads: a
47
+ slot id wearing a pack's qualified spelling (`_refuse_qualified_id`, shared with
48
+ `StageDeclaration.id` and `remove`'s own targets), a slot colliding with a stage id or
49
+ another slot, and `slots:` alongside `extends` — a slot is a position in *this*
50
+ pipeline's own list, so it belongs where `stages:` does. What this module does **not**
51
+ do: fill a slot. That needs a registry, a caller's contributions and the resolved chain
52
+ `stages:` alone cannot see — `weft_kernel.resolution.resolve` (task 1.11's other half) is
53
+ where a slot actually gets a contribution, or is recorded as landing nowhere.
54
+
55
+ **The four derivation operators — task 1.4, `02` §3 → the operator table.**
56
+ `insert`, `replace`, `remove` and `set` are **four keyed blocks**, exactly as
57
+ `02` §3's `specific.yaml` prints them, never flattened into one tagged
58
+ sequence: a document says `insert:` and `remove:` as its own top-level keys,
59
+ and a model that instead carried one `operators: list[Operator]` field would
60
+ have to invent a discriminator the specification never asks an author to
61
+ write, purely to get back to the shape already on the page.
62
+
63
+ **Four separate fields cannot, by themselves, say what order they apply in.**
64
+ `02` §3: "Operators apply in written order" — and a `remove` undoing an
65
+ `insert` at the same id, or the reverse, are different pipelines with
66
+ different resolved forms. A pydantic model's *field* order is fixed once for
67
+ every document — declaration order, not the order a particular author's
68
+ mapping happened to list keys in — so if application order were read off
69
+ field order, one of *remove-then-insert* and *insert-then-remove* would be
70
+ permanently unwritable no matter which order the four fields were declared
71
+ in. Application order is therefore read from the *document* (or the *call*),
72
+ not assumed from the schema: `_operator_order` is a private attribute filled
73
+ by `_track_operator_order`, a `mode="wrap"` validator that inspects the raw
74
+ mapping's key order before delegating to ordinary field validation, and
75
+ `operator_order` is its public, read-only face — `weft_kernel.resolution`
76
+ reads it to decide which block to apply first. `Pipeline(remove=..., insert=
77
+ ...)` gets the identical treatment: Python's own keyword-argument evaluation
78
+ already preserves call order into the `**data` dict `BaseModel.__init__`
79
+ builds, which is the same dict a document's loader hands to
80
+ `model_validate`, so there is exactly one code path computing "the order",
81
+ not one per direction. This is what task 1.4 settles from the note 1.1 left
82
+ open in `docs/build-ledger.md`.
83
+
84
+ **`fallback` is data here too.** `02` §1 gives the kernel a fallback combinator
85
+ over any contract, and `11` §4 keeps `fallback:` a per-stage list "tried in
86
+ order until one produces". This module records the list; nothing in it runs —
87
+ `weft_kernel.fallback.try_in_order` is what walks it, reached through
88
+ `Runner._invoke_stage` at task 2.28, which is two steps further on than any
89
+ document model has business knowing about.
90
+
91
+ **Two stage-shaped models now live in the kernel, and 1.3 reconciles them.**
92
+ `StageDeclaration` here is a stage as *written* — a bare `use:` name and an
93
+ unvalidated `with:` block; `weft_kernel.runner.StageSpec` is a stage as
94
+ *resolved enough to run* — a contract type, a plugin name and that plugin's
95
+ own config object. They are the two ends of resolution, which is the step
96
+ neither of them performs, and the resolved form task 1.3 builds is what turns
97
+ one into the other. Until it exists they are deliberately not related by
98
+ inheritance or conversion, because a converter written before resolution
99
+ exists would have to guess at the contract a bare name belongs to — which is
100
+ exactly the lookup resolution does against a registry.
101
+ """
102
+
103
+ from __future__ import annotations
104
+
105
+ from collections.abc import Mapping
106
+ from types import MappingProxyType
107
+ from typing import Annotated, Final, Self, cast
108
+
109
+ from pydantic import (
110
+ BaseModel,
111
+ ConfigDict,
112
+ Field,
113
+ ModelWrapValidatorHandler,
114
+ PlainSerializer,
115
+ PrivateAttr,
116
+ SerializerFunctionWrapHandler,
117
+ field_validator,
118
+ model_serializer,
119
+ model_validator,
120
+ )
121
+ from pydantic.fields import FieldInfo
122
+
123
+ type Scalar = str | int | float | bool
124
+ """What a `vars:` value may be. `02` §3: "Scalars only; no var may reference another"."""
125
+
126
+ _QUALIFIER: Final[str] = ":"
127
+ """What separates a distribution from a stage id in a pack's contribution. `02` §3 → *Slots*."""
128
+
129
+
130
+ def _refuse_qualified_id(value: str, *, subject: str) -> str:
131
+ """Refuse a `:`-qualified spelling wherever an author, not a pack, is naming something.
132
+
133
+ Shared by every place task 1.11 draws the same line: a stage id
134
+ (`StageDeclaration.id`), a slot id (`SlotDeclaration.id`), and a `remove` target
135
+ (`Pipeline._remove_targets_are_not_a_packs_to_name`). All three read the identical
136
+ reserved spelling `weft-kg:entities` and refuse it for the identical reason — the
137
+ qualifier belongs to a pack's contribution, never to anything an author writes,
138
+ including the string an author writes to *remove* one. `subject` only changes the
139
+ message's noun, never the check.
140
+ """
141
+ if _QUALIFIER in value:
142
+ raise ValueError(
143
+ f"{subject} '{value}' contains '{_QUALIFIER}', which is reserved: a pack's "
144
+ f"contribution into a slot is qualified by its distribution name "
145
+ f"(weft-kg{_QUALIFIER}entities) so that it can never collide with anything "
146
+ f"you write. Name this {subject} without it."
147
+ )
148
+ return value
149
+
150
+
151
+ type ConfigBlock = Annotated[
152
+ Mapping[str, object], PlainSerializer(dict, return_type=dict[str, object], when_used="always")
153
+ ]
154
+ """A `with:` block: read-only in memory (see `_read_only`), a plain mapping in a document."""
155
+
156
+ type VarBlock = Annotated[
157
+ Mapping[str, Scalar], PlainSerializer(dict, return_type=dict[str, Scalar], when_used="always")
158
+ ]
159
+ """A `vars:` block, on the same terms as `ConfigBlock`."""
160
+
161
+ _NO_CONFIG: Final[Mapping[str, object]] = MappingProxyType({})
162
+ _NO_VARS: Final[Mapping[str, Scalar]] = MappingProxyType({})
163
+ """The empty blocks, shared rather than built per instance — safe precisely because `_read_only`
164
+ makes every mapping on these models un-writable, so there is nothing an instance could do to a
165
+ shared default. It is also what keeps `model_dump(exclude_defaults=True)` able to recognise an
166
+ untouched block and leave it out of the document it writes back."""
167
+
168
+
169
+ class StageDeclaration(BaseModel):
170
+ """One stage as written in a pipeline document: a position, a plugin name, its configuration.
171
+
172
+ `use` is a **bare** plugin name, which G3 settled and `02` §2 records:
173
+ "Pipelines select plugins by bare name (§3 — `use: docling`)". It is not
174
+ qualified by contract or by distribution, and a collision between two packs
175
+ claiming it is refused at registration and pinned in `weft.toml` — never
176
+ disambiguated here, because that would put operator policy in a document
177
+ that has to stay portable between machines.
178
+
179
+ `config` is spelled `with` in the document and `config` in Python, which is
180
+ the one place the two directions cannot use the same word: `with` is a
181
+ Python keyword, so a field literally named for it could never be passed by
182
+ a Python caller at all. It is a `validation_alias`/`serialization_alias`
183
+ pair rather than a plain `alias` for exactly that reason — a plain `alias`
184
+ is what a type checker reads as the constructor's parameter name, and it
185
+ would report every `StageDeclaration(config=...)` call as an error while
186
+ offering a keyword in its place. `populate_by_name` accepts either
187
+ spelling, and `model_dump(by_alias=True)` writes the document's back.
188
+
189
+ **That has a cost worth naming in a module whose thesis is "no second
190
+ grammar": a document writing `config:` validates too.** `populate_by_name`
191
+ cannot tell a YAML mapping from a Python call — both arrive here as a
192
+ mapping — so accepting `config` for the Python direction accepts it for
193
+ the document as well, and a document that used it would be written back as
194
+ `with:`. The alternative is worse: refusing the field name would make the
195
+ Python direction unwritable, since `with=` is a syntax error. `with` is the
196
+ spelling this module names, teaches and writes.
197
+ """
198
+
199
+ model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
200
+
201
+ id: str = Field(min_length=1)
202
+ use: str = Field(min_length=1)
203
+ config: ConfigBlock = Field(
204
+ default_factory=lambda: _NO_CONFIG,
205
+ validation_alias="with",
206
+ serialization_alias="with",
207
+ )
208
+ fallback: tuple[str, ...] = ()
209
+
210
+ @model_validator(mode="before")
211
+ @classmethod
212
+ def _stage_keys_only(cls, value: object) -> object:
213
+ return _known_keys_only(cls, value, subject="stage")
214
+
215
+ @field_validator("id", mode="after")
216
+ @classmethod
217
+ def _id_is_not_a_pack_s_to_give(cls, value: str) -> str:
218
+ """`weft-kg:entities` is a contributed id, and an author may not write one.
219
+
220
+ `02` §3 → *Slots*: a contributed stage id "is qualified by
221
+ distribution (`weft-kg:entities`) so they cannot collide with the
222
+ author's". Nothing makes that true unless the qualified spelling is
223
+ reserved — an author free to use it could collide with a pack that is
224
+ not installed yet, and the collision would arrive with the
225
+ installation rather than with the edit that caused it. This is an
226
+ invariant that holds with no registry present, which is what puts it
227
+ in the authored form rather than in slot filling.
228
+ """
229
+ return _refuse_qualified_id(value, subject="stage id")
230
+
231
+ @field_validator("config", mode="after")
232
+ @classmethod
233
+ def _config_is_read_only(cls, value: Mapping[str, object]) -> Mapping[str, object]:
234
+ return _read_only(value)
235
+
236
+
237
+ PIPELINE_OPERATOR_MARK: Final[str] = "pipeline_operator"
238
+ """The `json_schema_extra` key task 1.15's ratchet reads off `Pipeline.model_fields`.
239
+
240
+ `tests/architecture/test_ff11_pipeline_integrity.py` pins the closed operator set as a
241
+ ratchet, and `01` -> *Fitness functions* item 11(a) requires its "actual" side to be
242
+ "derived from the code (the actual operator fields on the model), never a hand-written
243
+ list" — a second tuple of the same four strings, sitting in the test file with no
244
+ structural link back to this class, is exactly the drift `docs/README.md` opens by
245
+ describing. Each of the four operator fields below carries `Field(...,
246
+ json_schema_extra={PIPELINE_OPERATOR_MARK: True})` for exactly that reason: it is the
247
+ one place in the tree that *decides* a field is an operator block, so the ratchet reads
248
+ `Pipeline.model_fields` itself rather than retyping the decision. `_OPERATOR_KEYS`
249
+ immediately below is the first reader of that mark, not a second one — see its own
250
+ docstring.
251
+ """
252
+
253
+
254
+ def _operator_key_order(value: object) -> tuple[str, ...]:
255
+ """Which of `_OPERATOR_KEYS` a document (or call) wrote, in the order it wrote them.
256
+
257
+ Reads the **raw** value `Pipeline` was handed — a `Mapping` for both a YAML loader's
258
+ result and a Python call's `**kwargs`, per the module docstring — before any field
259
+ validation reorders or drops anything. A `Pipeline` passed back in (pydantic re-
260
+ validating an already-built instance, e.g. through a nested model) carries its own
261
+ `operator_order` forward instead of losing it to a fresh, order-blind read of `dict(
262
+ instance)`. Anything else — a mapping missing entirely, or garbage that later
263
+ validation will refuse anyway — resolves to no operators written, which is exactly
264
+ what an empty `_OPERATOR_KEYS` intersection already means.
265
+ """
266
+ if isinstance(value, Pipeline):
267
+ return value.operator_order
268
+ if isinstance(value, Mapping):
269
+ written = cast("Mapping[object, object]", value)
270
+ return tuple(str(key) for key in written if str(key) in _OPERATOR_KEYS)
271
+ return ()
272
+
273
+
274
+ def _reorder_operator_keys(dumped: dict[str, object], order: tuple[str, ...]) -> dict[str, object]:
275
+ """Rewrite `dumped`'s operator keys into `order`, everything else exactly where it was.
276
+
277
+ Pydantic's default serializer walks fields in **declaration** order — `insert` before
278
+ `replace` before `remove` before `set`, always — which is precisely the field-order
279
+ assumption the module docstring rules out for *reading* a document, and round-
280
+ tripping would silently reintroduce it on the way back out: a document that wrote
281
+ `remove:` above `insert:` would serialise with `insert:` first, indistinguishable from
282
+ a document that never made that choice. This runs after `handler(self)` has already
283
+ applied `by_alias`/`exclude_defaults`/`mode`, so it only ever reorders keys that
284
+ survived those — an operator block excluded by `exclude_defaults=True` because it was
285
+ never written is not reinserted here either. Non-operator keys keep their original
286
+ relative order and position; the four operator keys, wherever they were, are emitted
287
+ together at the position the first of them held, in `order`.
288
+ """
289
+ present = [key for key in order if key in dumped]
290
+ if not present:
291
+ return dumped
292
+ reordered: dict[str, object] = {}
293
+ operators_placed = False
294
+ for key, value in dumped.items():
295
+ if key in _OPERATOR_KEYS:
296
+ if not operators_placed:
297
+ for operator_key in present:
298
+ reordered[operator_key] = dumped[operator_key]
299
+ operators_placed = True
300
+ continue
301
+ reordered[key] = value
302
+ return reordered
303
+
304
+
305
+ class SlotDeclaration(BaseModel):
306
+ """A named position the root opens for a pack to fill — `02` §3 → *Slots*.
307
+
308
+ "A pack may ship complete named pipelines, and it may contribute into a slot a
309
+ pipeline opted into. It may never rewrite a pipeline that did not ask." A slot is that
310
+ opt-in, made data: declaring one is choosing, deliberately, that some installed pack's
311
+ contribution may land at this exact position — never a stage id, which `replace` and
312
+ `remove` already exist to change, and never ambient just because a pack happens to be
313
+ on the machine. `after`/`before` position it against one of the pipeline's own stages,
314
+ on the identical terms `InsertOperator` already gives a newly inserted stage — the
315
+ validator below is that one's twin for exactly that reason, not a coincidence of
316
+ shape.
317
+
318
+ `id` is checked against the same reserved-qualifier rule `StageDeclaration.id` already
319
+ carries: a slot is the *author's* name for a position, so the qualified spelling a
320
+ pack's contribution wears (`weft-kg:entities`) can never be it — the two vocabularies
321
+ (an author's slot names, a pack's contributed ids) must never collide, or a document
322
+ could accidentally "declare" a slot that is actually the ghost of some other pack's
323
+ contribution.
324
+ """
325
+
326
+ model_config = ConfigDict(frozen=True, extra="forbid")
327
+
328
+ id: str = Field(min_length=1)
329
+ after: str | None = None
330
+ before: str | None = None
331
+
332
+ @field_validator("id", mode="after")
333
+ @classmethod
334
+ def _id_is_the_authors_not_a_packs(cls, value: str) -> str:
335
+ return _refuse_qualified_id(value, subject="slot")
336
+
337
+ @model_validator(mode="after")
338
+ def _exactly_one_position(self) -> Self:
339
+ if (self.after is None) == (self.before is None):
340
+ got = "neither" if self.after is None else "both 'after' and 'before'"
341
+ raise ValueError(
342
+ f"slot '{self.id}' names {got}. A slot names exactly one existing stage id "
343
+ f"to position against — 'after:' or 'before:', never both, never neither."
344
+ )
345
+ return self
346
+
347
+
348
+ class InsertOperator(BaseModel):
349
+ """One `insert:` entry — `02` §3's operator table: add `stage`, `after:` or `before:` an id.
350
+
351
+ Exactly one of `after`/`before` is required, never both and never neither: a
352
+ position with two anchors or none is not a position, and refusing it here — before a
353
+ parent exists to check the anchor against — keeps that check as cheap as
354
+ `StageDeclaration`'s own invariants are.
355
+ """
356
+
357
+ model_config = ConfigDict(frozen=True, extra="forbid")
358
+
359
+ after: str | None = None
360
+ before: str | None = None
361
+ stage: StageDeclaration
362
+
363
+ @model_validator(mode="after")
364
+ def _exactly_one_position(self) -> Self:
365
+ if (self.after is None) == (self.before is None):
366
+ got = "neither" if self.after is None else "both 'after' and 'before'"
367
+ raise ValueError(
368
+ f"insert operator for stage '{self.stage.id}' names {got}. An insert names "
369
+ f"exactly one existing stage id to position against — 'after:' or 'before:', "
370
+ f"never both, never neither."
371
+ )
372
+ return self
373
+
374
+
375
+ class SetOperator(BaseModel):
376
+ """One `set:` entry — `02` §3's operator table: override `id`'s configuration in place.
377
+
378
+ No `use:` field on purpose: `set` is the operator that "override[s] configuration of
379
+ an existing stage **without changing the plugin**" — a document that wants a
380
+ different plugin at that id writes `replace`, and this model refusing `use:` by name
381
+ (`extra="forbid"`) is what makes the distinction a checked one rather than a naming
382
+ convention an author has to remember.
383
+
384
+ `config`'s `with`/`config` split is `StageDeclaration.config`'s own, for the same
385
+ reason: `with` is a Python keyword, so `populate_by_name=True` alongside the alias is
386
+ what keeps `SetOperator(config=...)` constructible from Python at all.
387
+ """
388
+
389
+ model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
390
+
391
+ id: str = Field(min_length=1)
392
+ config: ConfigBlock = Field(
393
+ default_factory=lambda: _NO_CONFIG,
394
+ validation_alias="with",
395
+ serialization_alias="with",
396
+ )
397
+
398
+ @field_validator("config", mode="after")
399
+ @classmethod
400
+ def _config_is_read_only(cls, value: Mapping[str, object]) -> Mapping[str, object]:
401
+ return _read_only(value)
402
+
403
+
404
+ class Pipeline(BaseModel):
405
+ """A pipeline document as its author wrote it — frozen, checked, not yet resolved.
406
+
407
+ This is the *authored* form, distinct from the resolved one: `extends` is
408
+ unfollowed, `vars` are unsubstituted, and no plugin named in `use` has been
409
+ looked up. Everything here can be true of a pipeline whose parent does not
410
+ exist and whose plugins are not installed, which is precisely why
411
+ resolution is a separate step that produces a separate frozen value.
412
+
413
+ The document's invariants are the ones that hold without a registry: a
414
+ pipeline is named, its keys are keys this model has, its stage ids are
415
+ unique, and its vars are scalars.
416
+ """
417
+
418
+ model_config = ConfigDict(frozen=True, extra="forbid")
419
+
420
+ name: str = Field(min_length=1)
421
+ extends: str | None = None
422
+ vars: VarBlock = Field(default_factory=lambda: _NO_VARS)
423
+ stages: tuple[StageDeclaration, ...] = ()
424
+ slots: tuple[SlotDeclaration, ...] = ()
425
+ insert: tuple[InsertOperator, ...] = Field(
426
+ default=(), json_schema_extra={PIPELINE_OPERATOR_MARK: True}
427
+ )
428
+ replace: tuple[StageDeclaration, ...] = Field(
429
+ default=(), json_schema_extra={PIPELINE_OPERATOR_MARK: True}
430
+ )
431
+ remove: tuple[str, ...] = Field(default=(), json_schema_extra={PIPELINE_OPERATOR_MARK: True})
432
+ set: tuple[SetOperator, ...] = Field(
433
+ default=(), json_schema_extra={PIPELINE_OPERATOR_MARK: True}
434
+ )
435
+
436
+ _operator_order: tuple[str, ...] = PrivateAttr(default=())
437
+
438
+ @property
439
+ def operator_order(self) -> tuple[str, ...]:
440
+ """Which of `insert`/`replace`/`remove`/`set` this pipeline wrote, in written order.
441
+
442
+ See the module docstring's *The four derivation operators* section for why this
443
+ cannot be read off field declaration order. `weft_kernel.resolution` is the one
444
+ caller: it applies this pipeline's operator blocks against its resolved parent in
445
+ exactly this order, which is what makes `remove` written above `insert` a move and
446
+ the reverse a collision — `02` §3, settled by task 1.4.
447
+ """
448
+ return self._operator_order
449
+
450
+ @model_validator(mode="wrap")
451
+ @classmethod
452
+ def _track_operator_order(cls, value: object, handler: ModelWrapValidatorHandler[Self]) -> Self:
453
+ """Read operator key order off the raw input, before delegating to field validation.
454
+
455
+ `mode="wrap"` is the one validator kind that sees both ends: the value exactly as
456
+ `model_validate`/`__init__` received it, and the constructed instance `handler`
457
+ returns. Every other `model_validator` here only ever sees one side — `mode=
458
+ "before"` transforms the raw value onward but builds nothing, `mode="after"` gets
459
+ the built instance but never the mapping that built it — so key order, which only
460
+ the raw value carries, would already be gone by the time any of them ran. Stamped
461
+ onto `_operator_order` through an ordinary attribute assignment, not `object.
462
+ __setattr__`: `BaseModel.__setattr__` already special-cases a declared private
463
+ attribute so the write lands in `__pydantic_private__` (where equality and
464
+ serialization actually look), and `frozen=True` never enters into it either way —
465
+ a private attribute is not a field, so it sits outside the rebinding check that
466
+ blocks `pipeline.name = ...`. `object.__setattr__` would instead bypass that
467
+ override and land the value in the instance's own `__dict__`, which every *later*
468
+ plain read of `instance._operator_order` would still see (attribute lookup finds
469
+ it before falling through to `__pydantic_private__`) while `==` — which compares
470
+ `__pydantic_private__` directly, never the shadow — would not: two pipelines built
471
+ with their operators in opposite written order would then wrongly compare equal.
472
+ """
473
+ order = _operator_key_order(value)
474
+ instance = handler(value)
475
+ instance._operator_order = order
476
+ return instance
477
+
478
+ @model_serializer(mode="wrap")
479
+ def _serialise_operators_in_written_order(
480
+ self, handler: SerializerFunctionWrapHandler
481
+ ) -> object:
482
+ """Undo pydantic's field-declaration serialisation order for the four operator keys.
483
+
484
+ `handler(self)` already applied whatever `by_alias`/`exclude_defaults`/`mode` the
485
+ caller asked `model_dump` for; this only reshuffles the operator keys that
486
+ survived that call, back into `operator_order` — the order this pipeline's own
487
+ document (or constructor call) actually wrote them in. Without this, round-
488
+ tripping a document that wrote `remove:` above `insert:` would come back with
489
+ `insert:` first every time, because pydantic serialises fields in the order they
490
+ are *declared* on the class, and that is exactly the assumption the rest of this
491
+ module goes to the trouble of not making. See `_reorder_operator_keys`.
492
+ """
493
+ dumped = handler(self)
494
+ if not isinstance(dumped, dict):
495
+ return dumped
496
+ return _reorder_operator_keys(cast("dict[str, object]", dumped), self.operator_order)
497
+
498
+ @model_validator(mode="before")
499
+ @classmethod
500
+ def _document_keys_only(cls, value: object) -> object:
501
+ return _known_keys_only(cls, value, subject="pipeline")
502
+
503
+ @field_validator("vars", mode="before")
504
+ @classmethod
505
+ def _vars_are_scalars(cls, value: object) -> object:
506
+ """Refuse a structured var in one sentence, rather than in four union branches.
507
+
508
+ Pydantic would refuse it anyway — `Mapping[str, Scalar]` sees to that —
509
+ but its answer is one error per branch of the union and none of them
510
+ says what a var is for. This one does.
511
+ """
512
+ if not isinstance(value, Mapping):
513
+ return value
514
+
515
+ declared = cast("Mapping[object, object]", value)
516
+ for key, item in declared.items():
517
+ if not isinstance(item, str | int | float | bool):
518
+ raise ValueError(
519
+ f"var '{key}' is a {type(item).__name__}; a var is a scalar — a "
520
+ f"string, an integer, a float or a boolean. A var carries a decision "
521
+ f"several stages must agree on and is substituted into a stage's "
522
+ f"`with:` value, so there is nothing structured for it to be."
523
+ )
524
+ return declared
525
+
526
+ @field_validator("vars", mode="after")
527
+ @classmethod
528
+ def _vars_are_read_only(cls, value: Mapping[str, Scalar]) -> Mapping[str, Scalar]:
529
+ return _read_only(value)
530
+
531
+ @model_validator(mode="after")
532
+ def _extends_and_stages_are_mutually_exclusive_with_operators(self) -> Self:
533
+ """A pipeline is either a root that lists `stages:`, or a child that operates on one.
534
+
535
+ `02` §3 → *Derivation*: a child changes its parent "by operator and never by
536
+ copy" — `insert`, `replace`, `remove`, `set` (task 1.4). That splits this model's
537
+ two "what do I run" surfaces cleanly by whether `extends` is set: `extends` plus a
538
+ non-empty `stages:` still has no meaning — a document that both names a parent and
539
+ lists its own stages has not said whether that list is the whole pipeline or a
540
+ change to hand the parent, and guessing either answer is exactly the silent
541
+ fallback `01` rules out. The new half, now that operators exist to guess with: no
542
+ `extends` plus a non-empty operator block is refused too, because `insert`/
543
+ `replace`/`remove`/`set` change a **parent**, and a pipeline with none named has
544
+ nothing for them to change — silently ignoring the block would be the same
545
+ species of silent fallback in the other direction. This is an invariant that holds
546
+ with no registry and no parent lookup present, which is what keeps it in the
547
+ authored form rather than in resolution (task 1.3): resolving a parent this
548
+ pipeline may not even name yet would be answering a question that has not been
549
+ asked.
550
+
551
+ Task 1.11 gives `slots:` the identical restriction `stages:` already has, for the
552
+ identical reason: a slot is a position in *this* pipeline's own stage list, exactly
553
+ as a stage is, so it belongs on the root that owns that list — never on a pipeline
554
+ whose own contribution to the chain is a set of operators. A child that wants to
555
+ refuse a slot it inherited writes `remove: <slot>` (below); it does not declare a
556
+ second, competing one.
557
+ """
558
+ if self.extends is not None and self.stages:
559
+ raise ValueError(
560
+ f"pipeline '{self.name}' sets 'extends: {self.extends}' and also lists its own "
561
+ f"'stages:'. A pipeline that extends a parent expresses what changes with an "
562
+ f"operator (insert, replace, remove, set), never with its own 'stages:' list — "
563
+ f"drop 'stages:', or drop 'extends' and author this as a standalone pipeline."
564
+ )
565
+ if self.extends is not None and self.slots:
566
+ raise ValueError(
567
+ f"pipeline '{self.name}' sets 'extends: {self.extends}' and also declares its "
568
+ f"own 'slots:'. A slot is a position in the root's own stage list — declare it "
569
+ f"there, or drop 'extends' and author this as a standalone pipeline. A pipeline "
570
+ f"that inherited a slot and wants to refuse it writes 'remove: <slot-id>'."
571
+ )
572
+ if self.extends is None and self._writes_an_operator():
573
+ raise ValueError(
574
+ f"pipeline '{self.name}' writes an operator (insert, replace, remove, set) but "
575
+ f"sets no 'extends:'. An operator changes a parent pipeline, and this pipeline "
576
+ f"names none to change — add 'extends: <parent>', or drop the operator and "
577
+ f"author this pipeline's own 'stages:' directly."
578
+ )
579
+ return self
580
+
581
+ def _writes_an_operator(self) -> bool:
582
+ return bool(self.insert or self.replace or self.remove or self.set)
583
+
584
+ @model_validator(mode="after")
585
+ def _stage_ids_are_unique(self) -> Self:
586
+ """A stage id is the only handle anything else has on a position, so it names one.
587
+
588
+ Operators address stages by id, slots and resolution errors report
589
+ them by id, and `02` §3 makes every operator strict about the id it
590
+ names. Two stages answering to `chunk` would make `insert: {after:
591
+ chunk}` mean two different things depending on which one resolution
592
+ happened to reach first.
593
+ """
594
+ seen: set[str] = set()
595
+ for stage in self.stages:
596
+ if stage.id in seen:
597
+ raise ValueError(
598
+ f"two stages share the id '{stage.id}'. A stage id is how an operator, a "
599
+ f"slot and a resolution error each name one position in this pipeline, so "
600
+ f"it is unique within it."
601
+ )
602
+ seen.add(stage.id)
603
+ return self
604
+
605
+ @field_validator("remove", mode="after")
606
+ @classmethod
607
+ def _remove_targets_are_not_a_packs_to_name(cls, value: tuple[str, ...]) -> tuple[str, ...]:
608
+ """`02` §3 → *Slots*: a contributed stage "may be `set` but never `replaced` or
609
+ `removed`". `remove`'s targets are a bare `tuple[str, ...]` — unlike `replace`'s
610
+ (`StageDeclaration.id`, already refused by `_id_is_not_a_pack_s_to_give`) — so
611
+ without this, `remove: [weft-kg:entities]` would validate as a document today
612
+ and only fail later, as an ordinary `StaleOperatorTargetError`, indistinguishable
613
+ from a typo. Refusing it here says what it actually is: a pack's contribution can
614
+ never be named away, only the slot that admits it — `remove: <slot-id>` is the
615
+ document's way to refuse every contribution to a slot without naming any pack.
616
+ """
617
+ for target in value:
618
+ _refuse_qualified_id(target, subject="remove target")
619
+ return value
620
+
621
+ @model_validator(mode="after")
622
+ def _slot_ids_are_unique_and_free(self) -> Self:
623
+ """A slot id is a position name, on the identical footing a stage id already is.
624
+
625
+ Checked here, alongside `_stage_ids_are_unique`, because both only ever run
626
+ against the root's own lists — `slots:` carries the same `extends`-exclusivity
627
+ `stages:` does, so a pipeline with either non-empty is always the pipeline that
628
+ owns both. A slot sharing a stage's id would make `remove: <that-id>` genuinely
629
+ ambiguous between the two `02` §3 gives it the power to drop; a slot sharing
630
+ another slot's id would make the same operator target two positions at once.
631
+ """
632
+ stage_ids = {stage.id for stage in self.stages}
633
+ seen: set[str] = set()
634
+ for slot in self.slots:
635
+ if slot.id in stage_ids:
636
+ raise ValueError(
637
+ f"slot '{slot.id}' shares its id with a stage in this pipeline. A slot id "
638
+ f"and a stage id are both names 'remove' can target, so the two "
639
+ f"vocabularies must stay disjoint — rename the slot or the stage."
640
+ )
641
+ if slot.id in seen:
642
+ raise ValueError(
643
+ f"two slots share the id '{slot.id}'. A slot id is how 'remove' and a "
644
+ f"pack's contribution both name one position in this pipeline, so it is "
645
+ f"unique within it."
646
+ )
647
+ seen.add(slot.id)
648
+ return self
649
+
650
+
651
+ def is_pipeline_operator_field(field: FieldInfo) -> bool:
652
+ """Whether `field` carries `PIPELINE_OPERATOR_MARK` — the one thing task 1.15's ratchet
653
+ (`_OPERATOR_KEYS` just below, and `tests/architecture/test_ff11_pipeline_integrity.py`,
654
+ which imports this function rather than re-deriving the same narrowing itself) needs to
655
+ know about a `Pipeline` field. Public for exactly that reason: the ratchet's "actual"
656
+ side has to read this off the model somehow, and a second, private copy of the
657
+ `json_schema_extra` narrowing below would be the same two-lists risk one function away.
658
+
659
+ Pydantic types `FieldInfo.json_schema_extra` as `dict[str, JsonValue] | JsonSchemaExtraCallable
660
+ | None` — a union `pyright` cannot narrow `.get(...)` through cleanly even after `isinstance
661
+ (..., dict)`, since a plain-`dict` branch of that union is still typed as `dict[Unknown,
662
+ Unknown]` in strict mode. The `cast` below states the one fact this module already relies on:
663
+ every `json_schema_extra` it ever sets is a `dict[str, object]` literal, never the callable
664
+ form pydantic also allows.
665
+ """
666
+ extra = field.json_schema_extra
667
+ if not isinstance(extra, dict):
668
+ return False
669
+ return cast("dict[str, object]", extra).get(PIPELINE_OPERATOR_MARK) is True
670
+
671
+
672
+ _OPERATOR_KEYS: Final[tuple[str, ...]] = tuple(
673
+ name for name, field in Pipeline.model_fields.items() if is_pipeline_operator_field(field)
674
+ )
675
+ """`02` §3's closed operator table's four names — **read off `Pipeline.model_fields`
676
+ itself**, never retyped. Defined only now, after the class body that carries the marks
677
+ `PIPELINE_OPERATOR_MARK` documents, because a module-level constant evaluates
678
+ immediately at import time and `Pipeline.model_fields` does not exist until the class
679
+ statement above has finished running; the functions that consume this tuple
680
+ (`_operator_key_order`, `_reorder_operator_keys`) are declared earlier in the file but,
681
+ being function bodies, read the name only when *called* — well after the whole module,
682
+ this constant included, has finished loading. Not itself an ordering claim — it is only
683
+ the vocabulary `_operator_key_order` filters a document's own keys against, and the
684
+ sequence `_reorder_operator_keys` reorders a serialised document's keys back into, once
685
+ the actual written order is known (`Pipeline.operator_order`, per-instance). The set
686
+ stays closed at the ratchet in `tests/architecture/test_ff11_pipeline_integrity.py`
687
+ (`01` -> *Fitness functions* item 11(a)): a fifth field carrying
688
+ `PIPELINE_OPERATOR_MARK` changes what this tuple *contains*, which is exactly what
689
+ that ratchet compares against its own pinned expectation.
690
+ """
691
+
692
+
693
+ def _known_keys_only(model: type[BaseModel], value: object, *, subject: str) -> object:
694
+ """Refuse an unknown key by naming it *and* the keys that exist. `01`'s loud-failure rule.
695
+
696
+ Runs before validation proper, so the answer to a mistyped key is the list
697
+ of real ones rather than `extra="forbid"`'s bare "Extra inputs are not
698
+ permitted" — which tells an author that the word they wrote is wrong and
699
+ nothing about which word is right. `extra="forbid"` stays on underneath as
700
+ the backstop for anything that reaches the model another way.
701
+
702
+ Keys are reported in the document's spelling, so `with` rather than
703
+ `config`; both are accepted, per `StageDeclaration`.
704
+ """
705
+ if not isinstance(value, Mapping):
706
+ return value
707
+
708
+ written = cast("Mapping[object, object]", value)
709
+ known = {_document_spelling(name, field) for name, field in model.model_fields.items()}
710
+ accepted = known | set(model.model_fields)
711
+ unknown = sorted(str(key) for key in written if key not in accepted)
712
+ if unknown:
713
+ raise ValueError(
714
+ f"unknown {subject} key(s): {', '.join(repr(key) for key in unknown)}. "
715
+ f"A {subject} accepts {', '.join(sorted(known))}."
716
+ )
717
+ return written
718
+
719
+
720
+ def _read_only[K, V](value: Mapping[K, V]) -> Mapping[K, V]:
721
+ """Make a mapping field as frozen as the model that carries it.
722
+
723
+ `frozen=True` stops a field being rebound; it does nothing about what a
724
+ field *holds*, and pydantic validates `Mapping[...]` into an ordinary
725
+ mutable `dict`. That gap matters here rather than being a nicety: `02` §3
726
+ makes derivation "the parent is referenced, never copied", so a child's
727
+ resolved stages share their parent's `with:` mapping — and an operator
728
+ implemented as an in-place update would edit the parent through it,
729
+ silently, for every other child too. A read-only view makes that a
730
+ `TypeError` at the moment of the write instead of a mystery two pipelines
731
+ away.
732
+
733
+ The cost is one annotation each: pydantic knows how to write a `dict` back
734
+ to a document and refuses a `mappingproxy`, so `ConfigBlock` and `VarBlock`
735
+ carry a `PlainSerializer` that unwraps the view. It has to be an annotated
736
+ serializer rather than a `field_serializer` method, because a
737
+ field-serializer method also switches off `exclude_defaults` — and an
738
+ untouched `with:` block reappearing in every document written back is
739
+ exactly the kind of drift between the two directions this module exists to
740
+ prevent. The view is the model's own protection, not part of what a
741
+ pipeline document says.
742
+ """
743
+ return MappingProxyType(dict(value))
744
+
745
+
746
+ def _document_spelling(name: str, field: FieldInfo) -> str:
747
+ """The key a field wears in a document: its alias where it has one, its own name otherwise."""
748
+ return field.serialization_alias or name