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.
- weft_kernel/__init__.py +178 -0
- weft_kernel/blocking.py +370 -0
- weft_kernel/context.py +250 -0
- weft_kernel/discovery.py +1181 -0
- weft_kernel/errors.py +73 -0
- weft_kernel/fallback.py +159 -0
- weft_kernel/payload/__init__.py +43 -0
- weft_kernel/payload/applicability.py +298 -0
- weft_kernel/payload/ext.py +172 -0
- weft_kernel/payload/ids.py +22 -0
- weft_kernel/payload/lineage.py +90 -0
- weft_kernel/payload/media_type.py +18 -0
- weft_kernel/payload/node.py +260 -0
- weft_kernel/payload/outcome.py +40 -0
- weft_kernel/payload/property.py +60 -0
- weft_kernel/payload/vector.py +28 -0
- weft_kernel/pipeline.py +748 -0
- weft_kernel/py.typed +0 -0
- weft_kernel/registry.py +692 -0
- weft_kernel/resolution.py +1582 -0
- weft_kernel/runner.py +1436 -0
- weft_kernel/seam.py +725 -0
- weft_kernel-0.1.0.dist-info/METADATA +88 -0
- weft_kernel-0.1.0.dist-info/RECORD +27 -0
- weft_kernel-0.1.0.dist-info/WHEEL +4 -0
- weft_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
- weft_kernel-0.1.0.dist-info/licenses/NOTICE +77 -0
|
@@ -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
|
+
)
|