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/context.py ADDED
@@ -0,0 +1,250 @@
1
+ """`Context` — the one passport every stage's `run()` receives, and its one seam.
2
+
3
+ Specified in `docs/06-phase-0-build.md` step 4 and `docs/02-extension-model.md`
4
+ section 1 ("What a plugin receives"): `tenant_id`, run and trace ids,
5
+ cancellation, locale and `require()`. **The admission rule for a new
6
+ field, quoted directly:** a field is admitted only if it is needed by *every*
7
+ plugin regardless of contract **and** is meaningless to resolve as a service.
8
+ Tuning knobs fail the second test — an unbounded context object that admits
9
+ them accumulates fields no plugin is obliged to honour, and callers route
10
+ around it by building a second, overlapping bag of ad-hoc state instead of
11
+ resolving through the one that already exists, which is a capability leaking
12
+ into the kernel by another name. This module is deliberately narrow, and G11
13
+ narrowed it further: four identity fields with no default, one collaborator,
14
+ one resolution method, and nothing else.
15
+
16
+ **One collaborator, distinct from the registry.** `require()` resolves against
17
+ a `ServiceRegistry`, which is separate from `weft_kernel.registry.Registry`
18
+ (step 2): that one maps `(contract, name) -> factory` for *plugins a pipeline
19
+ names in configuration*, and this one maps `contract -> an already-resolved
20
+ instance` for *ambient services every stage may need regardless of what
21
+ pipeline it runs in* — `docs/02-extension-model.md`'s example is
22
+ `ctx.require(LLM)` returning a typed handle with no name to disambiguate, and
23
+ `ctx.require(TokenSink)` for the one streaming service (`docs/03-cli.md` →
24
+ *Output*). It starts empty and is handed to `Context` already built, exactly
25
+ as `Registry.add` takes `distribution` as a parameter rather than discovering
26
+ it. Whatever assembles a run (the runner, step 6, or the CLI, step 9) builds
27
+ and populates it; this module only gives `Context` somewhere to resolve
28
+ against.
29
+
30
+ **There was a second seam, `t()`, and G11 retired it (2026-08-18).** A
31
+ `MessageCatalogue` and `Context.messages` lived here from step 4 onward, so a
32
+ kernel or pack error could resolve its text per locale. Three phases shipped
33
+ with **zero registered messages and zero `ctx.t()` call sites** — the
34
+ mechanism's own intended clientele, 51 first-party pack error classes, all
35
+ chose English literals too — and G11 settled that Weft's *interface* is
36
+ English-only as a product decision, investing instead in the **content**-
37
+ language axis it already has. A locale-keyed message store with one locale is
38
+ a dict with a constant key, so the catalogue, `Context.messages`, `t()` and
39
+ the three error classes they brought (`UnknownMessageError`,
40
+ `DuplicateMessageError`, `MessageFormatError`) are gone, taking this kernel
41
+ from 33 error classes to 30. `docs/05-grilling-sessions.md` → G11 holds the
42
+ session; `docs/02-extension-model.md` §1 owns what replaced it — an English
43
+ literal at the raise site, whose explanation surface is
44
+ `manual/troubleshooting.md`'s coverage ratchet, and whose *quality* is
45
+ fitness function 12 rather than a convention an author has to remember.
46
+
47
+ **Cancellation is not a field, on purpose.** `docs/02-extension-model.md`:
48
+ "cancellation is native, and under an async core that is task cancellation."
49
+ Storing a second, Weft-owned cancellation flag would be exactly the shadow
50
+ machinery G6 refuses — it could drift from the real `asyncio.Task` state, and
51
+ nothing would keep the two in sync. `Context.cancelled` is a computed view
52
+ onto the task actually running the stage: `asyncio.current_task().cancelling()
53
+ > 0` (Python 3.11+, native to `asyncio`, no new primitive). It exists so a
54
+ compute-heavy stage with no `await` in its inner loop — invisible to the
55
+ blocking-call detector by design, `blocking.py`'s closing note — has a
56
+ cooperative checkpoint; it is not how cancellation is *requested*. Requesting
57
+ it is `task.cancel()`, called by whoever holds the task, which is never this
58
+ module's job.
59
+
60
+ **Identity fields carry no default.** `tenant_id`, `run_id`, `trace_id` and
61
+ `locale` are supplied by the caller. A default locale, in particular, is a
62
+ policy choice — which language a tenant with no stated preference gets — and
63
+ the kernel names no capability and states no policy; the driver constructing
64
+ a `Context` states it instead.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import asyncio
70
+ from dataclasses import dataclass, field
71
+ from typing import cast
72
+
73
+ from pydantic import BaseModel, ConfigDict, Field
74
+
75
+ from weft_kernel.errors import UnresolvedNameError, WeftError
76
+
77
+
78
+ class UnresolvedServiceError(WeftError, UnresolvedNameError):
79
+ """`ctx.require()` was asked for a contract nothing registered for this run.
80
+
81
+ The message states the contract that was wanted and every contract that
82
+ *is* available, so a stage author who forgot to wire a service reads the
83
+ error as a wiring bug rather than a mystery — the same standard
84
+ `registry.py`'s `UnknownPluginError` sets for plugin lookup.
85
+
86
+ Fitness function 12's family: `valid_options` is every contract that
87
+ *is* registered on this run.
88
+ """
89
+
90
+ def __init__(self, message: str, *, valid_options: tuple[str, ...]) -> None:
91
+ super().__init__(message)
92
+ self.valid_options = valid_options
93
+
94
+
95
+ class DuplicateServiceError(WeftError):
96
+ """Two instances were registered for the same contract on one `ServiceRegistry`.
97
+
98
+ A service registry is populated once per run by whatever assembles it; a
99
+ second instance for a contract already resolved is not an update, it is
100
+ two callers disagreeing about which instance a stage should get. Refused
101
+ rather than silently overwritten — `registry.py` takes the same stance for
102
+ plugin names, for the same reason: a silent overwrite is a bug someone
103
+ eventually has to find.
104
+ """
105
+
106
+
107
+ class ServiceRole(BaseModel):
108
+ """A pack's declaration, beside the contract it publishes, that `[services].<key>`
109
+ selects an implementation of that contract for one run.
110
+
111
+ Ledger task **9.0**, closing the hole `docs/02-extension-model.md` §1 named in its own
112
+ Phase 0 narrowing: a service is populated into a `ServiceRegistry` by whatever assembles
113
+ a run, but nothing let a pack *name* which of its contracts is selectable that way, or
114
+ under what `[services]` key. `ServiceRole` is that declaration — "one constant beside the
115
+ Protocol" (`docs/build-ledger.md:5026 'exists becaus'`, `:5370`), never a member on the Protocol
116
+ itself.
117
+
118
+ It is a plain constant rather than a `ClassVar` written into the contract's own body,
119
+ for the reason `weft_extract.contract` (`:44-56`) already states for `Extractor.version`:
120
+ `typing.Protocol` computes `__protocol_attrs__` once, by walking every attribute present
121
+ in the class body at that moment, so a marker placed there would become a *required*
122
+ structural member — a third-party implementation that provides the real methods but
123
+ never restates the marker would then fail a capability check that has nothing to do with
124
+ capability. Holding the declaration beside the Protocol, not inside it, keeps
125
+ `isinstance` checking exactly what it always checked.
126
+
127
+ The kernel names no capability here: `contract` is a bare `type`, never `NodeStore`,
128
+ `Embedder` or any other capability name a pack might publish — the same restraint
129
+ `weft_kernel.discovery.RendererOffer` keeps for a result type it never names.
130
+
131
+ `key` and `contract` state the declaration only; resolving `[services].<key>`
132
+ against a running `weft.toml` and building the named plugin into a `ServiceRegistry`
133
+ entry is the job of whatever assembles a run, not this model's.
134
+ """
135
+
136
+ model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True)
137
+
138
+ key: str = Field(min_length=1)
139
+ contract: type[object]
140
+
141
+
142
+ class ServiceRegistry:
143
+ """Per-run map of `contract -> the one resolved instance a stage gets back.`
144
+
145
+ Distinct from `weft_kernel.registry.Registry`: that one holds *factories*,
146
+ keyed by `(contract, name)`, for plugins a pipeline names in
147
+ configuration. This one holds *already-built instances*, keyed by
148
+ contract alone, for services `docs/02-extension-model.md` says are
149
+ "meaningless to resolve" any other way — there is no name to disambiguate
150
+ because a run has exactly one answer for "the LLM" or "the token sink".
151
+ """
152
+
153
+ def __init__(self) -> None:
154
+ self._instances: dict[type[object], object] = {}
155
+
156
+ def add[T](self, contract: type[T], instance: T) -> None:
157
+ """Register `instance` as this run's answer for `contract`.
158
+
159
+ Refuses a second instance for a contract already registered — see
160
+ `DuplicateServiceError`.
161
+ """
162
+ if contract in self._instances:
163
+ raise DuplicateServiceError(
164
+ f"a service for {contract.__name__} is already registered on this run; "
165
+ f"a second registration would leave it ambiguous which instance a stage "
166
+ f"gets back. Refused rather than silently overwritten."
167
+ )
168
+ self._instances[contract] = instance
169
+
170
+ def resolve[T](self, contract: type[T]) -> T:
171
+ """This run's instance for `contract`.
172
+
173
+ Raises `UnresolvedServiceError`, naming `contract` and every contract
174
+ that *is* available, if nothing registered one.
175
+ """
176
+ if contract in self._instances:
177
+ # `add` only ever stores an instance under its own `contract` key,
178
+ # so this cast states exactly the invariant `resolve` relies on.
179
+ return cast(T, self._instances[contract])
180
+
181
+ options = tuple(sorted(c.__name__ for c in self._instances))
182
+ available = ", ".join(options) or "none"
183
+ raise UnresolvedServiceError(
184
+ f"no service is registered for {contract.__name__} on this run. It is "
185
+ f"unavailable because nothing resolved one before this stage ran. "
186
+ f"Services available on this run: {available}.",
187
+ valid_options=options,
188
+ )
189
+
190
+
191
+ @dataclass(frozen=True, slots=True, kw_only=True)
192
+ class Context:
193
+ """The one passport a stage's `run()` receives. There is exactly one per run.
194
+
195
+ `tenant_id`, `run_id`, `trace_id` and `locale` are ambient identity, fixed
196
+ for the lifetime of one run — `frozen=True` makes that a type-level fact
197
+ rather than a convention a stage could break by assigning `ctx.tenant_id`,
198
+ consistent with CLAUDE.md's "frozen where the value is a domain object"
199
+ and with `registry.RegistryEntry`'s precedent in this same layer. Freezing
200
+ `Context` does not freeze `services`: that collaborator is populated by
201
+ whatever assembles the run, not carried as identity.
202
+ `require()` is the one resolution seam — see the module docstring for why
203
+ nothing else lives here, and for what G11 retired.
204
+
205
+ **`locale` is the run's configured *content* language, never an interface
206
+ language** (G11, 2026-08-18). Weft's interface is English-only; what this
207
+ field selects is material a model reads or writes, and its only consumer
208
+ today is prompt text selection (`weft_prompts.typed_prompt`, exact locale →
209
+ primary subtag → `en`). It is deliberately distinct from a *query*'s own
210
+ locale: `weft_retrieve.payload.Query.locale` is "a fact about the ask", and
211
+ a question asked in Polish against an English corpus is a different thing
212
+ from a run configured for Polish. Nothing selects this yet — the CLI passes
213
+ `"en"` — and giving an operator a way to choose it is `docs/03-cli.md`'s,
214
+ hence Phase 3's.
215
+ """
216
+
217
+ tenant_id: str
218
+ run_id: str
219
+ trace_id: str
220
+ locale: str
221
+ services: ServiceRegistry = field(default_factory=ServiceRegistry)
222
+
223
+ @property
224
+ def cancelled(self) -> bool:
225
+ """Whether the task running this stage has a pending cancellation request.
226
+
227
+ A computed view onto `asyncio`'s own task state — see the module
228
+ docstring for why `Context` stores no cancellation flag of its own.
229
+ `False` outside a running task (for example, while unit-testing a
230
+ stage's pure logic with no event loop, or from inside an
231
+ `asyncio.to_thread` worker — the compute-heavy-stage offload path this
232
+ property exists to serve, which itself runs off the event loop), never
233
+ an error: the absence of a task is not a request to cancel.
234
+ `asyncio.current_task()` raises `RuntimeError` rather than returning
235
+ `None` when there is no running loop to ask, so that specific,
236
+ documented case is the one exception this property catches.
237
+ """
238
+ try:
239
+ task = asyncio.current_task()
240
+ except RuntimeError:
241
+ return False
242
+ return task is not None and task.cancelling() > 0
243
+
244
+ def require[T](self, contract: type[T]) -> T:
245
+ """This run's instance for `contract`, resolved by type — never by name.
246
+
247
+ Raises `UnresolvedServiceError`, naming what was wanted and what is
248
+ available, if nothing registered one — see `ServiceRegistry.resolve`.
249
+ """
250
+ return self.services.resolve(contract)