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,172 @@
1
+ """Namespaced, typed extension data on a `Node`.
2
+
3
+ Settled in G5. A pack declares a model that owns a namespace; the kernel
4
+ validates it on write through ordinary Pydantic construction, and
5
+ `Node.with_ext` / `Node.ext_as` are the typed read/write pair — namespace
6
+ strings never appear in plugin code. See `docs/02-extension-model.md`
7
+ section 1.
8
+
9
+ `__transient__` marks a namespace the kernel strips before the node is
10
+ persisted (`Node.without_transient`). This is deliberately a class-level,
11
+ type-checked declaration rather than a maintained tuple of key-name strings a
12
+ stage author has to remember to extend. A namespace's transience is a fact
13
+ about its type, checked once, at the declaration, never re-decided at every
14
+ call site.
15
+
16
+ **Built in Phase 5 task 5.2c.** `__schema_version__` is a second mandatory
17
+ declaration, checked in the same `__pydantic_init_subclass__` seam as
18
+ `__namespace__` and for the identical reason — G9's ruling (`docs/README.md`
19
+ decision log, `docs/02-extension-model.md` §1): a contract version cannot
20
+ stand in for it because it is not available at the read site, so an
21
+ `ExtModel` carries its own version, and a missing one fails loudly at class
22
+ definition rather than silently at first read. `SCHEMA_VERSION_KEY` is
23
+ written into every dumped namespace by `_dump` below — *in the data*, never
24
+ as a `ClassVar` a serialiser would drop, which is the exact defect `Filter.
25
+ version` demonstrates. `upgrade` is the classmethod a reader calls when a
26
+ stored version disagrees with the current one; its default refuses, naming
27
+ the namespace, the stored version and the current one, because silence here
28
+ is a contaminated fallback in another costume — the exact shape CLAUDE.md's
29
+ rule against silent fallbacks exists to forbid.
30
+ """
31
+
32
+ from collections.abc import Mapping
33
+ from types import MappingProxyType
34
+ from typing import Annotated, ClassVar, Final
35
+
36
+ from pydantic import AfterValidator, BaseModel, ConfigDict, PlainSerializer, SerializeAsAny
37
+
38
+ from weft_kernel.errors import WeftError
39
+
40
+ #: The key `_dump` writes a namespace's `__schema_version__` under, inside the plain dict
41
+ #: `ExtModel.model_dump()` otherwise produces — chosen dunder-shaped so it reads as
42
+ #: metadata about the namespace rather than one of the subclass's own fields, the same
43
+ #: visual distinction `__namespace__` itself makes at the Python level.
44
+ SCHEMA_VERSION_KEY: Final[str] = "__schema_version__"
45
+
46
+
47
+ class SchemaVersionRefusedError(WeftError):
48
+ """A stored namespace's schema version disagrees with the class reading it, and
49
+ `upgrade` was not overridden to reconcile the difference.
50
+
51
+ `docs/02-extension-model.md` §1: "A reader upgrades or refuses... Silence is
52
+ refusal." `stored_version` is `None` when the data was written before this task —
53
+ every row this store held before 5.2c — rather than assumed to be the current
54
+ version, which would be exactly the silent fallback CLAUDE.md forbids.
55
+ """
56
+
57
+ def __init__(self, *, namespace: str, stored_version: str | None, current_version: str) -> None:
58
+ stored = "no version at all (written before schema versioning existed)"
59
+ if stored_version is not None:
60
+ stored = f"version {stored_version!r}"
61
+ message = (
62
+ f"'{namespace}' data was written at {stored}, but the installed class is at "
63
+ f"{current_version!r} and declares no upgrade path from it. Override "
64
+ f"{namespace}'s ExtModel.upgrade(data, from_version) to migrate this shape, "
65
+ f"or reindex the corpus so this namespace is rewritten at the current version."
66
+ )
67
+ super().__init__(message)
68
+ self.namespace = namespace
69
+ self.stored_version = stored_version
70
+ self.current_version = current_version
71
+
72
+
73
+ class ExtModel(BaseModel):
74
+ """Base for a pack's namespaced extension data.
75
+
76
+ A subclass must declare a non-empty `__namespace__` and a non-empty
77
+ `__schema_version__` — both enforced at class definition, not at first
78
+ use, so a missing declaration fails at import rather than at the first
79
+ `with_ext` call or the first read off a store.
80
+ """
81
+
82
+ model_config = ConfigDict(frozen=True, extra="forbid")
83
+
84
+ __namespace__: ClassVar[str] = ""
85
+ __schema_version__: ClassVar[str] = ""
86
+ __transient__: ClassVar[bool] = False
87
+
88
+ @classmethod
89
+ def __pydantic_init_subclass__(cls, **kwargs: object) -> None:
90
+ super().__pydantic_init_subclass__(**kwargs)
91
+ if not cls.__namespace__:
92
+ raise TypeError(
93
+ f"{cls.__name__} must declare a non-empty __namespace__ — the "
94
+ f"distribution name that owns this extension data"
95
+ )
96
+ if not cls.__schema_version__:
97
+ raise TypeError(
98
+ f"{cls.__name__} must declare a non-empty __schema_version__ — the "
99
+ f"version of this namespace's own shape, carried in every dumped "
100
+ f"instance so a reader can tell an old row from a current one even "
101
+ f"when the pack that wrote it is not installed (docs/02-extension-model.md §1)"
102
+ )
103
+
104
+ @classmethod
105
+ def upgrade(cls, data: Mapping[str, object], from_version: str | None) -> Mapping[str, object]:
106
+ """Migrate `data`, stored at `from_version`, to this class's current schema.
107
+
108
+ Refuses by default — `SchemaVersionRefusedError`, naming the namespace, the
109
+ stored version and the current one. A pack that can actually reconcile an
110
+ older shape overrides this; the base class assumes nothing is compatible,
111
+ because guessing wrong here means silently misreading a user's own data.
112
+ """
113
+ raise SchemaVersionRefusedError(
114
+ namespace=cls.__namespace__,
115
+ stored_version=from_version,
116
+ current_version=cls.__schema_version__,
117
+ )
118
+
119
+
120
+ def _freeze(value: Mapping[str, ExtModel]) -> Mapping[str, ExtModel]:
121
+ """Wrap a validated ext mapping in an immutable view.
122
+
123
+ `Node`'s `frozen=True` blocks attribute *assignment* only — it does not
124
+ stop `node.ext['x'] = y` from mutating the plain `dict` a bare
125
+ `Mapping[str, ExtModel]` field would validate to. `MappingProxyType`
126
+ closes that door: `node.ext[...] = ...` raises `TypeError`, so
127
+ `Node.with_ext` is the only way to change what a node's `ext` holds.
128
+ """
129
+ return MappingProxyType(dict(value))
130
+
131
+
132
+ def _dump(value: Mapping[str, ExtModel]) -> dict[str, object]:
133
+ """Serialise each namespace's model by its own (sub)class, not `ExtModel`.
134
+
135
+ Two problems, both invisible without a test that actually serialises a
136
+ `Node`. First, `MappingProxyType` has no serializer pydantic-core knows,
137
+ so `model_dump_json()` raises on it outright — this function's plain
138
+ `dict` return sidesteps that. Second, declaring the value type as the
139
+ base `ExtModel` makes pydantic serialise every entry *as* `ExtModel`:
140
+ a subclass's own fields (e.g. `entities` on a pack's model) are silently
141
+ dropped, not warned about, because serialisation follows the declared
142
+ type, not the runtime one. Calling each value's own `model_dump` here is
143
+ what stops both today; the `SerializeAsAny` on `ExtMap` below is a second
144
+ line, covering the declared-type path if this serialiser is ever narrowed
145
+ or removed. Only the loss of `model_dump_json` support is loud, so the
146
+ test in `tests/unit/weft_kernel/payload/test_node.py` asserts on the
147
+ subclass's field values, not merely that dumping did not raise.
148
+
149
+ Note for step 8 (the store): this is the write side only. A dumped `ext`
150
+ is plain dicts, so `Node.model_validate(node.model_dump())` does *not*
151
+ round-trip — rehydrating a namespace string back to the owning model
152
+ class needs a registry that does not exist yet.
153
+
154
+ **Task 5.2c adds `SCHEMA_VERSION_KEY`** to each namespace's dumped dict,
155
+ read off the *class*, not the instance — there is nowhere else in this
156
+ function that the version could come from, since it is a `ClassVar`, not
157
+ a field `model_dump()` itself would ever emit. This is the one place a
158
+ dumped namespace's bytes are assembled, so it is the one place the
159
+ version can travel in them rather than being dropped the way `Filter.
160
+ version` was.
161
+ """
162
+ return {
163
+ namespace: {**model.model_dump(), SCHEMA_VERSION_KEY: type(model).__schema_version__}
164
+ for namespace, model in value.items()
165
+ }
166
+
167
+
168
+ type ExtMap = Annotated[
169
+ Mapping[str, SerializeAsAny[ExtModel]],
170
+ AfterValidator(_freeze),
171
+ PlainSerializer(_dump, return_type=dict),
172
+ ]
@@ -0,0 +1,22 @@
1
+ """The two identifier types the payload model needs.
2
+
3
+ `NodeId` is a content-addressed digest — see `node.py` for how it is computed.
4
+ `SourceId` identifies the document a node's lineage traces back to; it is
5
+ assigned by whichever component owns the record of that source document.
6
+ Both live in the kernel because `Lineage`, a kernel type, names them in its
7
+ own fields, and a pack downstream of the kernel cannot be the type a kernel
8
+ field is declared against.
9
+
10
+ Each is a `typing.NewType` over `str` rather than a real subclass: a
11
+ `NodeId`/`SourceId` compares, hashes and sorts exactly like the string it is,
12
+ with no unwrapping at call sites, while a signature can still require the
13
+ specific type rather than any string. Pydantic has built-in, dependency-free
14
+ support for `NewType` fields — unlike a genuine `str` subclass, which needs a
15
+ custom `__get_pydantic_core_schema__` that imports `pydantic_core` directly,
16
+ an import the kernel does not declare and fitness function 1 refuses.
17
+ """
18
+
19
+ from typing import NewType
20
+
21
+ NodeId = NewType("NodeId", str)
22
+ SourceId = NewType("SourceId", str)
@@ -0,0 +1,90 @@
1
+ """Where a `Node` came from.
2
+
3
+ Settled in G5. `sources` is computed by the kernel as the union of the
4
+ parents' sources — never authored directly, except at a true root, where
5
+ there are no parents to derive it from (see `Node.synthetic`). That rule is
6
+ what makes cascade delete reach every descendant: `docs/02-extension-model.md`
7
+ section 1, "Deleting a source cascades."
8
+
9
+ `Lineage` can be constructed directly by pack or pipeline code — `Lineage()`
10
+ and `Lineage(sources=...)` at a root are both ordinary, validated calls. The
11
+ one shape it refuses is `sources` supplied alongside non-empty `parents`: a
12
+ `Lineage` holds parent ids, not parent nodes, so it cannot check that
13
+ `sources` is *correctly* derived from them, only that it was not authored
14
+ out of nothing. `Node.derive` and `Node.combine` compute `sources` as the
15
+ union of the actual parent nodes' own sources, then reach `Lineage.derived`
16
+ to build the result — that classmethod still goes through the ordinary
17
+ validated constructor, so `parents` and `sources` remain type-checked, and
18
+ carries the fact that the check already happened through pydantic's
19
+ validation context rather than skipping validation (`model_construct`) to
20
+ get past it.
21
+
22
+ The trust signal has to survive one more step than the context argument
23
+ alone reaches: `Node.derive` and `Node.combine` embed the `Lineage` they
24
+ build into a `Node`, and pydantic re-runs a nested model's
25
+ `model_validator`s against that same object — same identity, verified by
26
+ `id()` — when the outer `Node` is constructed, but *without* replaying the
27
+ context the inner value was originally validated with. So the validator
28
+ below also stamps a private, non-field flag onto the object the one time
29
+ context says it may, and reads that flag on every later re-run — the
30
+ context argument is what makes the *first* validation honest (a caller
31
+ cannot get the flag set without going through full field validation), the
32
+ flag is what makes it durable.
33
+ """
34
+
35
+ from pydantic import BaseModel, ConfigDict, PrivateAttr, ValidationInfo, model_validator
36
+
37
+ from weft_kernel.payload.ids import NodeId, SourceId
38
+
39
+
40
+ class Lineage(BaseModel):
41
+ """A node's ancestry: the nodes it was built from, and the documents it traces to."""
42
+
43
+ model_config = ConfigDict(frozen=True, extra="forbid")
44
+
45
+ parents: tuple[NodeId, ...] = ()
46
+ sources: frozenset[SourceId] = frozenset()
47
+
48
+ _derived: bool = PrivateAttr(default=False)
49
+
50
+ @model_validator(mode="after")
51
+ def _sources_are_derived_not_authored(self, info: ValidationInfo) -> "Lineage":
52
+ """Refuse `sources` authored alongside non-empty `parents`.
53
+
54
+ The only legal place to author `sources` directly is a root with no
55
+ parents (reached through `Node.synthetic`). Everywhere else,
56
+ `sources` must come from `Lineage.derived`, which the ordinary
57
+ constructor cannot be used for — this is what stops a node claiming
58
+ a source its parents do not, which would make cascade delete either
59
+ miss it or delete it under the wrong document.
60
+ """
61
+ context: dict[str, object] = info.context or {}
62
+ trusted = self._derived or bool(context.get("derived"))
63
+ if self.parents and self.sources and not trusted:
64
+ raise ValueError(
65
+ "Lineage.sources is derived from parents' sources, never authored "
66
+ "directly, except at a root with no parents (Node.synthetic) — got "
67
+ "both non-empty parents and non-empty sources; use Lineage.derived "
68
+ "if sources was genuinely computed from the parents' own sources"
69
+ )
70
+ if trusted and not self._derived:
71
+ object.__setattr__(self, "_derived", True)
72
+ return self
73
+
74
+ @classmethod
75
+ def derived(cls, *, parents: tuple[NodeId, ...], sources: frozenset[SourceId]) -> "Lineage":
76
+ """Build a lineage whose `sources` was already correctly computed from `parents`.
77
+
78
+ The only callers are `Node.derive` and `Node.combine`, which compute
79
+ `sources` as the union of the actual parent nodes' own sources
80
+ before calling this. Goes through the ordinary validated
81
+ constructor — `parents` and `sources` are still type-checked — and
82
+ passes the trust signal through pydantic's validation context so
83
+ `_sources_are_derived_not_authored` above admits this one call
84
+ without skipping validation to do it; that validator then stamps
85
+ the result so embedding it in a `Node` (which re-runs this
86
+ validator with no context) still passes.
87
+ """
88
+ return cls.model_validate(
89
+ {"parents": parents, "sources": sources}, context={"derived": True}
90
+ )
@@ -0,0 +1,18 @@
1
+ """The closed vocabulary of what content a `Node` carries."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class MediaType(StrEnum):
7
+ """What kind of content a `Node` carries.
8
+
9
+ A core field under G5's admission rule: every store and every retrieval
10
+ strategy must understand it to function. That is why it is a closed enum
11
+ rather than an open, pack-declared vocabulary — unlike a plugin name,
12
+ there is no registry a third party extends here, and per the project's
13
+ string-constant rule this is `Enum`, never `Literal[...]`.
14
+ """
15
+
16
+ TEXT = "text"
17
+ IMAGE = "image"
18
+ TABLE = "table"
@@ -0,0 +1,260 @@
1
+ """The `Node` type, and the three ways to build one.
2
+
3
+ Settled in G5, specified in `docs/02-extension-model.md` section 1 ("The
4
+ payload model"). Six core fields, admitted because every store and every
5
+ retrieval strategy must understand them to function: `id`, `lineage`,
6
+ `content`, `media_type`, `embedding`, `ext`. Everything else a pack wants to
7
+ attach is namespaced extension data — see `ext.py` — never a seventh core
8
+ field.
9
+
10
+ Three defect shapes become unrepresentable here rather than merely guarded, as
11
+ `docs/06-phase-0-build.md` step 1 requires:
12
+
13
+ * **RAPTOR's unreachable summaries.** `Node.combine` refuses an empty
14
+ `members` sequence, and a `model_validator` on `Node` itself refuses any
15
+ parentless node that does not carry `SyntheticOrigin` — so a summary with
16
+ no members and no stated reason for its absent lineage cannot be built by
17
+ any path, not only through `combine`. A summary node built with an empty
18
+ relationships mapping to signal that it has no single source document
19
+ carries no `ref_doc_id` either way, so no deletion path can ever reach it.
20
+ Under `combine`, parents are explicit and `Lineage.sources` is the union of
21
+ the members' sources — a summary with no members has no sources to derive.
22
+ * **A node claiming a source its parents do not.** `Lineage` itself refuses
23
+ `sources` authored alongside non-empty `parents` (see `lineage.py`), so a
24
+ node cannot claim ancestry to a document unrelated to what it was actually
25
+ built from — the same defect class, one field over.
26
+ * **The multi-MB base64 blob reaching JSONB.** Guarding this with a
27
+ maintained tuple of key names that one pipeline stage has to remember to
28
+ check is exactly the kind of rule an author forgets. Here, transience is
29
+ declared once on the `ExtModel`
30
+ subclass that owns the namespace (`ExtModel.__transient__`) and
31
+ `Node.without_transient` strips by that declaration — a type-level fact
32
+ the registration seam (step 3) will apply automatically, not a list an
33
+ author extends. `ext` is also validated into an immutable mapping (see
34
+ `ext.py`), so a transient blob cannot be reinserted by mutating `node.ext`
35
+ in place after stripping — only `with_ext` can put one back.
36
+ """
37
+
38
+ import hashlib
39
+ from collections.abc import Sequence
40
+
41
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
42
+
43
+ from weft_kernel.payload.ext import ExtMap, ExtModel
44
+ from weft_kernel.payload.ids import NodeId, SourceId
45
+ from weft_kernel.payload.lineage import Lineage
46
+ from weft_kernel.payload.media_type import MediaType
47
+ from weft_kernel.payload.vector import Vector
48
+
49
+
50
+ class SyntheticOrigin(ExtModel):
51
+ """Attached automatically by `Node.synthetic`, carrying why the node has no lineage.
52
+
53
+ This is what makes a synthetic node's absence of lineage "explicit,
54
+ greppable, doctor-reportable" rather than merely a node with empty
55
+ `lineage.parents` indistinguishable from a bug: `node.ext_as(SyntheticOrigin)`
56
+ finds it, and a future `weft plugins doctor` can enumerate every one.
57
+ """
58
+
59
+ __namespace__ = "weft-kernel"
60
+ __schema_version__ = "1.0.0"
61
+
62
+ reason: str
63
+
64
+
65
+ class Node(BaseModel):
66
+ """A unit of content flowing through a pipeline.
67
+
68
+ Frozen: every change is a new `Node`, produced by `derive`, `with_ext`,
69
+ `with_embedding` or `without_transient`, never by mutating this one.
70
+ """
71
+
72
+ model_config = ConfigDict(frozen=True, extra="forbid")
73
+
74
+ id: NodeId
75
+ lineage: Lineage
76
+ content: str
77
+ media_type: MediaType
78
+ embedding: Vector | None = None
79
+ # `validate_default` sends even the omitted-`ext` case through the field's
80
+ # validator, so a freshly built `MappingProxyType` comes out of the
81
+ # `default_factory` too — `default=MappingProxyType({})` would hand every
82
+ # node the *same* proxy instance, which `copy.deepcopy` (pydantic's guard
83
+ # against shared mutable defaults) cannot copy, since `mappingproxy` has
84
+ # no `__deepcopy__` and is not picklable.
85
+ ext: ExtMap = Field(default_factory=dict, validate_default=True)
86
+
87
+ @model_validator(mode="after")
88
+ def _lineage_requires_parents_or_synthetic_origin(self) -> "Node":
89
+ """Refuse a parentless node unless it carries `SyntheticOrigin`.
90
+
91
+ This is what makes the unreachable-summary bug described in the module
92
+ docstring unrepresentable by construction rather than only by the three factories being
93
+ well-behaved: a plain `Node(...)` call with empty `lineage.parents`
94
+ and no stated reason is refused here, no matter how it was built.
95
+ """
96
+ if self.lineage.parents:
97
+ return self
98
+ origin = self.ext.get(SyntheticOrigin.__namespace__)
99
+ if not isinstance(origin, SyntheticOrigin):
100
+ raise ValueError(
101
+ "a node with no lineage parents must carry SyntheticOrigin in ext, "
102
+ "stating why — construct it through Node.synthetic, which stamps "
103
+ "this automatically; an unexplained parentless node is unreachable by "
104
+ "any cascade delete, so it outlives the document it describes"
105
+ )
106
+ return self
107
+
108
+ def derive(
109
+ self, *, content: str, media_type: MediaType | None = None, ordinal: int = 0
110
+ ) -> "Node":
111
+ """A child produced from this node. Lineage is carried; `ext` and `embedding` are not.
112
+
113
+ The child is different content, so the parent's extension data and
114
+ embedding do not silently carry over — later stages attach their own.
115
+ """
116
+ effective_media_type = self.media_type if media_type is None else media_type
117
+ lineage = Lineage.derived(parents=(self.id,), sources=self.lineage.sources)
118
+ node_id = _content_digest(
119
+ media_type=effective_media_type,
120
+ content=content,
121
+ parent_ids=lineage.parents,
122
+ ordinal=ordinal,
123
+ )
124
+ return type(self)(
125
+ id=node_id, lineage=lineage, content=content, media_type=effective_media_type
126
+ )
127
+
128
+ @classmethod
129
+ def combine(
130
+ cls, members: Sequence["Node"], *, content: str, media_type: MediaType, ordinal: int = 0
131
+ ) -> "Node":
132
+ """A summary built from `members`. Parents are explicit and never empty.
133
+
134
+ Raises if `members` is empty — see the module docstring for why that
135
+ refusal is the fix for the unreachable-summary bug described there.
136
+ """
137
+ if not members:
138
+ raise ValueError(
139
+ "Node.combine requires at least one member. An empty set would produce "
140
+ "a summary with no derivable sources, unreachable by any cascade delete: "
141
+ "delete every document it summarised and it stays retrievable forever, "
142
+ "describing content that is gone. Refused by construction."
143
+ )
144
+
145
+ parents = tuple(member.id for member in members)
146
+ sources = frozenset[SourceId]().union(*(member.lineage.sources for member in members))
147
+ lineage = Lineage.derived(parents=parents, sources=sources)
148
+ node_id = _content_digest(
149
+ media_type=media_type, content=content, parent_ids=parents, ordinal=ordinal
150
+ )
151
+ return cls(id=node_id, lineage=lineage, content=content, media_type=media_type)
152
+
153
+ @classmethod
154
+ def synthetic(
155
+ cls,
156
+ *,
157
+ content: str,
158
+ media_type: MediaType,
159
+ reason: str,
160
+ sources: frozenset[SourceId] = frozenset(),
161
+ ordinal: int = 0,
162
+ ) -> "Node":
163
+ """A node with no real lineage — its absence stated rather than implied.
164
+
165
+ `sources` is accepted directly here, and only here, because there are
166
+ no parents to derive it from. This is also the root of a document's
167
+ very first node: extraction has no upstream `Node`, only a source
168
+ document, so it states that document's id as `sources` explicitly.
169
+
170
+ Built in one constructor call, with `SyntheticOrigin` already in
171
+ `ext`: the model-level invariant above rejects a parentless node
172
+ that does not carry it, and `model_copy` (which `with_ext` uses)
173
+ skips validation, so a two-step "build, then attach" sequence would
174
+ raise on the intermediate object.
175
+ """
176
+ if not reason:
177
+ raise ValueError("Node.synthetic requires a non-empty reason, so it stays greppable")
178
+
179
+ lineage = Lineage(parents=(), sources=sources)
180
+ node_id = _content_digest(
181
+ media_type=media_type, content=content, parent_ids=(), ordinal=ordinal
182
+ )
183
+ return cls(
184
+ id=node_id,
185
+ lineage=lineage,
186
+ content=content,
187
+ media_type=media_type,
188
+ ext={SyntheticOrigin.__namespace__: SyntheticOrigin(reason=reason)},
189
+ )
190
+
191
+ def _replace(self, **updates: object) -> "Node":
192
+ """Rebuild this node through full validation.
193
+
194
+ `model_copy(update=...)` skips validation entirely, so it would let a
195
+ caller smuggle an invalid `content`, `media_type` or `ext` past every
196
+ guard this model declares — including the lineage invariant above.
197
+ Revalidating the whole node closes that: `with_ext`, `with_embedding`
198
+ and `without_transient` cannot become a second, unchecked way to
199
+ build one.
200
+ """
201
+ return type(self).model_validate({**self.__dict__, **updates})
202
+
203
+ def with_ext(self, model: ExtModel) -> "Node":
204
+ """Attach namespaced extension data, replacing any prior value in that namespace."""
205
+ namespace = type(model).__namespace__
206
+ return self._replace(ext={**self.ext, namespace: model})
207
+
208
+ def ext_as[T: ExtModel](self, kind: type[T]) -> T | None:
209
+ """This node's data for `kind`'s namespace, or `None` if absent.
210
+
211
+ `None` is legitimate — the producing stage may have run and produced
212
+ nothing for this node. A namespace occupied by a different type than
213
+ requested raises: that is not a legitimate absence, it is two packs
214
+ disagreeing about what one namespace means.
215
+ """
216
+ value = self.ext.get(kind.__namespace__)
217
+ if value is None:
218
+ return None
219
+ if not isinstance(value, kind):
220
+ raise TypeError(
221
+ f"namespace '{kind.__namespace__}' holds {type(value).__name__}, "
222
+ f"not {kind.__name__}"
223
+ )
224
+ return value
225
+
226
+ def without_transient(self) -> "Node":
227
+ """This node with every `__transient__` namespace stripped."""
228
+ kept = {ns: model for ns, model in self.ext.items() if not type(model).__transient__}
229
+ if len(kept) == len(self.ext):
230
+ return self
231
+ return self._replace(ext=kept)
232
+
233
+ def with_embedding(self, embedding: Vector) -> "Node":
234
+ """This node with its embedding set. Identity is unaffected — the digest excludes it."""
235
+ return self._replace(embedding=embedding)
236
+
237
+
238
+ def _content_digest(
239
+ *, media_type: MediaType, content: str, parent_ids: Sequence[NodeId], ordinal: int
240
+ ) -> NodeId:
241
+ """The content-addressed identity: media type, content, sorted parents, an ordinal.
242
+
243
+ Excludes the embedding (derived from content, and it would bind ids to a
244
+ model) and any stage configuration, so two pipelines producing
245
+ byte-identical output produce one node. The ordinal disambiguates
246
+ byte-identical content under the same parent set: without it, two
247
+ structurally identical siblings — the same content, the same parents —
248
+ would collide on identity and silently merge into one node; a caller
249
+ passing distinct ordinals for siblings avoids that here.
250
+
251
+ Parts are length-prefixed before hashing so that no concatenation of
252
+ variable-length strings can collide across a different split of the same
253
+ bytes.
254
+ """
255
+ digest = hashlib.sha256()
256
+ for part in (media_type.value, content, *sorted(parent_ids), str(ordinal)):
257
+ encoded = part.encode("utf-8")
258
+ digest.update(len(encoded).to_bytes(8, byteorder="big"))
259
+ digest.update(encoded)
260
+ return NodeId(digest.hexdigest())
@@ -0,0 +1,40 @@
1
+ """The result every contract method returns.
2
+
3
+ Settled in G5. Never a bare value: `Produced` / `NothingToProduce` / `Failed`.
4
+ This is what lets the kernel own a fallback combinator over any contract
5
+ without inspecting payload content — it matches on the outcome's type and
6
+ never looks inside it — and it is the fix for the worst extraction trap a
7
+ `fail_silently` path opens: an empty result indistinguishable downstream
8
+ from a successfully-parsed empty document. A backend that
9
+ legitimately produced nothing now has `NothingToProduce` to say so, distinct
10
+ from `Failed`. See `docs/02-extension-model.md` section 1.
11
+ """
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+
16
+ class Produced[T](BaseModel):
17
+ """The stage ran and produced a value."""
18
+
19
+ model_config = ConfigDict(frozen=True, extra="forbid")
20
+
21
+ value: T
22
+
23
+
24
+ class NothingToProduce(BaseModel):
25
+ """The stage ran, found nothing to produce, and that is a legitimate result."""
26
+
27
+ model_config = ConfigDict(frozen=True, extra="forbid")
28
+
29
+ reason: str
30
+
31
+
32
+ class Failed(BaseModel):
33
+ """The stage did not complete."""
34
+
35
+ model_config = ConfigDict(frozen=True, extra="forbid")
36
+
37
+ reason: str
38
+
39
+
40
+ type Outcome[T] = Produced[T] | NothingToProduce | Failed
@@ -0,0 +1,60 @@
1
+ """`Property` — a namespaced marker for a fact about a stage's payload that ordering cares about.
2
+
3
+ Settled in G2, `docs/02-extension-model.md` §3 → *Ordering constraints —
4
+ `intact` and `destroys`*: G5's `requires`/`provides` solve ordering by **data
5
+ dependency** — a stage that reads what an earlier one produced — and that
6
+ cannot express a constraint that exists for the opposite reason: hyphenation
7
+ repair must run before whitespace normalization not because anything reads
8
+ its output, but because whitespace normalization is **destructive** to a fact
9
+ no dependency graph can see. `intact`/`destroys` are the mirror G2 added, and
10
+ this is what they carry: "The representation is the mirror of `requires` /
11
+ `provides`... The properties are published by whichever pack publishes the
12
+ contract — namespaced marker classes exactly as G5 gives ext models their
13
+ namespaces."
14
+
15
+ **Never instantiated, and never a `Node`'s data.** `weft_kernel.runner.Stage`'s
16
+ `requires`/`provides` already demonstrate the shape ordering borrows: an
17
+ `ExtModel` *subclass* is a tag a stage's tuple carries, never an instance the
18
+ stage constructs. `intact`/`destroys` need that same tagging shape, but not
19
+ an `ExtModel`'s own machinery — a property is not payload data a `Node`
20
+ carries, is never written, validated or persisted, and gets no `ext`
21
+ namespace of its own — so it gets a lightweight base rather than reusing
22
+ `ExtModel` for a job it was never shaped for.
23
+
24
+ **`__namespace__` is mandatory, and enforced the same way `ExtModel`
25
+ enforces it** — at class definition, via `__init_subclass__`, never at first
26
+ use — for the identical reason: "no plugin ever names another plugin." A
27
+ hyphenation fixer's `intact = (WordBoundaries,)` and a whitespace
28
+ normalizer's `destroys = (WordBoundaries,)` compare by **type identity**,
29
+ never by a string either side had to agree on — see
30
+ `weft_kernel.runner.Runner.resolve`, which is what actually walks a stage
31
+ list accumulating what earlier stages destroyed. The namespace is what stops
32
+ two unrelated packs from silently colliding on the same class name and
33
+ having their unrelated properties compare as equal by accident; it plays no
34
+ role in the comparison itself, exactly as `ExtModel.__namespace__` plays no
35
+ role in `requires`/`provides` matching by model type.
36
+ """
37
+
38
+ from typing import ClassVar
39
+
40
+
41
+ class Property:
42
+ """Base for a namespaced marker declaring a fact about a stage's payload.
43
+
44
+ A subclass carries no fields and is never instantiated — see the module
45
+ docstring. `intact`/`destroys` on a plugin class name `Property`
46
+ subclasses exactly the way `requires`/`provides` name `ExtModel`
47
+ subclasses: the type itself is the whole payload, so two packs can
48
+ publish their own properties with no coordination and no shared registry
49
+ of names.
50
+ """
51
+
52
+ __namespace__: ClassVar[str] = ""
53
+
54
+ def __init_subclass__(cls, **kwargs: object) -> None:
55
+ super().__init_subclass__(**kwargs)
56
+ if not cls.__namespace__:
57
+ raise TypeError(
58
+ f"{cls.__name__} must declare a non-empty __namespace__ — the "
59
+ f"distribution name that owns this property"
60
+ )