weft-kernel 0.1.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (28) hide show
  1. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/.gitignore +12 -3
  2. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/PKG-INFO +1 -1
  3. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/pyproject.toml +1 -1
  4. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/context.py +18 -7
  5. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/discovery.py +46 -7
  6. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/__init__.py +2 -1
  7. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/ext.py +1 -1
  8. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/node.py +28 -0
  9. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/pipeline.py +2 -2
  10. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/resolution.py +1 -1
  11. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/seam.py +77 -10
  12. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/LICENSE +0 -0
  13. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/NOTICE +0 -0
  14. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/README.md +0 -0
  15. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/__init__.py +0 -0
  16. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/blocking.py +0 -0
  17. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/errors.py +0 -0
  18. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/fallback.py +0 -0
  19. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/applicability.py +0 -0
  20. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/ids.py +0 -0
  21. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/lineage.py +0 -0
  22. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/media_type.py +0 -0
  23. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/outcome.py +0 -0
  24. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/property.py +0 -0
  25. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/payload/vector.py +0 -0
  26. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/py.typed +0 -0
  27. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/registry.py +0 -0
  28. {weft_kernel-0.1.0 → weft_kernel-0.2.1}/src/weft_kernel/runner.py +0 -0
@@ -46,10 +46,12 @@ build/
46
46
  # so that scaling the corpus up cannot silently start tracking a paper.
47
47
  /corpus/*/
48
48
 
49
- # Where a baseline run stages the corpus it indexes and writes the `weft.toml` it measures
50
- # through (`eval/run_baseline.py`). The staged copies are the same untracked papers one
51
- # directory over, and the configuration is reproduced by the harness rather than kept — what
49
+ # Where `weft eval baseline` stages the corpus it indexes (`--workdir`, default
50
+ # `.weft-baseline/`). The staged copies are the same untracked papers one directory over; what
52
51
  # is tracked is the run it produced, under `eval/baselines/`.
52
+ /.weft-baseline/
53
+ # The deleted `eval/run_baseline.py`'s own default `--workdir` — still ignored so a checkout
54
+ # that ran it before this repair does not show its leftovers as untracked noise.
53
55
  /.baseline-run/
54
56
 
55
57
  # Working artefacts of a build session — a generated map of the codebase and a design
@@ -83,3 +85,10 @@ build/
83
85
  /docs/_external-reading/
84
86
 
85
87
  /tmp/
88
+
89
+ # The internal process record — how this project is worked, not what it ships. The build ledger,
90
+ # the lessons queue and its archive, the grilling sessions, the product direction, the roadmap past
91
+ # Phase 11, the control file that holds project state, and the owner's own list. Untracked
92
+ # 2026-09-11 at the owner's decision. The numbered reference documents stay tracked: 650 citations
93
+ # from `packages/` point at them and those pointers ship inside the wheel.
94
+ /docs/internal/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: weft-kernel
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: The Weft kernel: registry, discovery, pipeline model, payload types.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "weft-kernel"
3
- version = "0.1.0"
3
+ version = "0.2.1"
4
4
  description = "The Weft kernel: registry, discovery, pipeline model, payload types."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -38,7 +38,7 @@ language axis it already has. A locale-keyed message store with one locale is
38
38
  a dict with a constant key, so the catalogue, `Context.messages`, `t()` and
39
39
  the three error classes they brought (`UnknownMessageError`,
40
40
  `DuplicateMessageError`, `MessageFormatError`) are gone, taking this kernel
41
- from 33 error classes to 30. `docs/05-grilling-sessions.md` → G11 holds the
41
+ from 33 error classes to 30. `docs/internal/05-grilling-sessions.md` → G11 holds the
42
42
  session; `docs/02-extension-model.md` §1 owns what replaced it — an English
43
43
  literal at the raise site, whose explanation surface is
44
44
  `manual/troubleshooting.md`'s coverage ratchet, and whose *quality* is
@@ -70,7 +70,7 @@ import asyncio
70
70
  from dataclasses import dataclass, field
71
71
  from typing import cast
72
72
 
73
- from pydantic import BaseModel, ConfigDict, Field
73
+ from pydantic import BaseModel, ConfigDict, Field, field_serializer
74
74
 
75
75
  from weft_kernel.errors import UnresolvedNameError, WeftError
76
76
 
@@ -108,11 +108,11 @@ class ServiceRole(BaseModel):
108
108
  """A pack's declaration, beside the contract it publishes, that `[services].<key>`
109
109
  selects an implementation of that contract for one run.
110
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
111
+ Ledger task **9.0**, closing the hole `docs/02-extension-model.md` §1 named in its own Phase 0
112
+ narrowing: a service is populated into a `ServiceRegistry` by whatever assembles a run, but
113
+ nothing let a pack *name* which of its contracts is selectable that way, or under what
114
+ `[services]` key. `ServiceRole` is that declaration — "one constant beside the Protocol"
115
+ (`docs/internal/build-ledger.md:5054 'exists becaus'`, `:5370`), never a member on the Protocol
116
116
  itself.
117
117
 
118
118
  It is a plain constant rather than a `ClassVar` written into the contract's own body,
@@ -138,6 +138,17 @@ class ServiceRole(BaseModel):
138
138
  key: str = Field(min_length=1)
139
139
  contract: type[object]
140
140
 
141
+ @field_serializer("contract")
142
+ def _contract_as_name(self, contract: type[object]) -> str:
143
+ """The contract's qualified name — ledger task **24.5**.
144
+
145
+ A role names a live Protocol because `ctx.require(...)` resolves against the class
146
+ itself; what a reader of `weft --json plugins list` can act on is which contract, which
147
+ is what a qualified name says. Same rule as `PackReport.ext_models` and
148
+ `RendererOffer.result_type`: what leaves is an identity, never the object.
149
+ """
150
+ return f"{contract.__module__}.{contract.__qualname__}"
151
+
141
152
 
142
153
  class ServiceRegistry:
143
154
  """Per-run map of `contract -> the one resolved instance a stage gets back.`
@@ -85,7 +85,7 @@ about stores: it is inert data until something that *does* know what a store
85
85
  is reads `PackReport.ext_models` back off every report and registers each
86
86
  class with the namespace-to-class registry that actually rehydrates one —
87
87
  `weft_store.rehydrate.register_from_reports`, called once, generically, by
88
- whatever already calls `discover()` (`weft_cli.registry_bootstrap.
88
+ whatever already calls `discover()` (`weft_engine.registry_bootstrap.
89
89
  build_dependencies`). No pack-specific knowledge sits in that call site: it
90
90
  walks whatever `PackReport.ext_models` any report carries, so a future pack
91
91
  shipping a new `ExtModel` needs no edit here and no edit in `weft-cli` at all.
@@ -100,7 +100,7 @@ value the kernel already owns (`weft_kernel.resolution` is this same distributio
100
100
  not a capability, so this teaches the kernel nothing new either: it stops at "this
101
101
  pack offered this contribution," and `PackReport.contributions` is read back off
102
102
  every report by whatever assembles a `resolve()` call's own `contributions=` tuple —
103
- `weft_cli.registry_bootstrap.build_dependencies`, the same caller `Contribution`'s
103
+ `weft_engine.registry_bootstrap.build_dependencies`, the same caller `Contribution`'s
104
104
  own docstring names, now real. No pack-specific knowledge sits there either: it
105
105
  concatenates whatever `PackReport.contributions` every report carries, so a future
106
106
  pack contributing into a slot needs no edit here and no edit in `weft-cli` at all.
@@ -130,7 +130,7 @@ from enum import StrEnum
130
130
  from importlib import metadata
131
131
  from typing import Protocol, cast, get_type_hints
132
132
 
133
- from pydantic import BaseModel, ConfigDict, ValidationError
133
+ from pydantic import BaseModel, ConfigDict, ValidationError, field_serializer
134
134
 
135
135
  from weft_kernel.context import ServiceRole
136
136
  from weft_kernel.errors import UnresolvedNameError, WeftError
@@ -223,6 +223,26 @@ class RendererOffer(BaseModel):
223
223
  result_type: type[object]
224
224
  render: Callable[[object], object]
225
225
 
226
+ @field_serializer("result_type")
227
+ def _result_type_as_name(self, result_type: type[object]) -> str:
228
+ """The type's qualified name — ledger task **24.5**, and the same rule as
229
+ `PackReport.ext_models`: what leaves is an identity, never the live object.
230
+
231
+ This field holds a real class because `weft_cli.render`'s dispatch keys on it, and a
232
+ string could not do that. `weft --json plugins list` asked it to serialise and pydantic
233
+ refused the whole report, so the command exited 1 printing nothing — which is how a
234
+ field nobody had ever written down announces itself.
235
+ """
236
+ return f"{result_type.__module__}.{result_type.__qualname__}"
237
+
238
+ @field_serializer("render")
239
+ def _render_as_name(self, render: Callable[[object], object]) -> str:
240
+ """Likewise, for the callable. A function has no wire form; what a reader of the JSON
241
+ can act on is which one it is, which is what a qualified name says."""
242
+ module = getattr(render, "__module__", "?")
243
+ name = getattr(render, "__qualname__", repr(render))
244
+ return f"{module}.{name}"
245
+
226
246
 
227
247
  class ServiceRoleOffer(BaseModel):
228
248
  """One `ServiceRole` a pack declared, attributed to the pack that declared it.
@@ -288,7 +308,7 @@ class PackReport(BaseModel):
288
308
 
289
309
  `contributions` — task 5.3a — is every `weft_kernel.resolution.Contribution` the pack
290
310
  buffered through `PackRegistrar.add_contribution`, empty for any pack that offers no
291
- slot contribution (most packs). `weft_cli.registry_bootstrap.build_dependencies` is the
311
+ slot contribution (most packs). `weft_engine.registry_bootstrap.build_dependencies` is the
292
312
  one place every report's own tuple is concatenated into the `contributions=` argument
293
313
  every `weft_kernel.resolution.resolve` call site now passes — `02` §3 → *Slots*: "a pack
294
314
  may... contribute into a slot a pipeline opted into."
@@ -321,6 +341,25 @@ class PackReport(BaseModel):
321
341
  deprecations: tuple[Deprecation, ...] = ()
322
342
  unavailable: tuple[Unavailable, ...] = ()
323
343
  ext_models: tuple[type[ExtModel], ...] = ()
344
+
345
+ @field_serializer("ext_models")
346
+ def _ext_models_as_namespaces(self, models: tuple[type[ExtModel], ...]) -> tuple[str, ...]:
347
+ """The namespaces, not the classes — ledger task **24.5**.
348
+
349
+ This field holds live classes on purpose: `weft_store.rehydrate.register_from_reports`
350
+ reads them back to teach a store how to rehydrate a pack's own `ext` namespaces, and a
351
+ string could not do that. What a class cannot do is *serialise*, and until 24.5 nothing
352
+ ever asked it to — `weft --json plugins list` then did, and pydantic refused the whole
353
+ report with `PydanticSerializationError: Unable to serialize unknown type:
354
+ ModelMetaclass`, taking the command down with it. Found by running the binary; the 2,749
355
+ tests could not see it, because none of them dumps a `PackReport`.
356
+
357
+ `__namespace__` is the identity a reader of the JSON wants and the one `02` § *The
358
+ payload model* says is collision-free by construction. The in-memory value is untouched:
359
+ a serializer decides what leaves, never what the field holds.
360
+ """
361
+ return tuple(model.__namespace__ for model in models)
362
+
324
363
  contributions: tuple[Contribution, ...] = ()
325
364
  renderers: tuple[RendererOffer, ...] = ()
326
365
  service_roles: tuple[ServiceRoleOffer, ...] = ()
@@ -371,7 +410,7 @@ class InertPluginPinError(WeftError):
371
410
  parameter is what a diagnostic caller sets `False` to see every
372
411
  `PackReport` anyway; `weft_cli.cli.dispatch` does this for `plugins
373
412
  list`/`plugins doctor` and no other command, on the same reasoning
374
- `weft_cli.registry_bootstrap`'s own module docstring already gives for
413
+ `weft_engine.registry_bootstrap`'s own module docstring already gives for
375
414
  `WEFT_DATABASE_URL`: "a bare crash on every registry-needing command —
376
415
  including `plugins doctor`, the one command meant to diagnose exactly
377
416
  this — would be worse than" a report.
@@ -969,7 +1008,7 @@ def _activate(
969
1008
  identical terms, for the identical reason: a pack that raises must never look like it
970
1009
  offered a slot contribution it never actually committed. This function does nothing else
971
1010
  with it either — assembling every report's own tuple into one `contributions=` argument
972
- for `weft_kernel.resolution.resolve` is `weft_cli.registry_bootstrap.build_dependencies`'s
1011
+ for `weft_kernel.resolution.resolve` is `weft_engine.registry_bootstrap.build_dependencies`'s
973
1012
  job, the one caller `weft_kernel.resolution.Contribution`'s own docstring names.
974
1013
 
975
1014
  **Task 6.20** — `registrar.renderers` reaches `PackReport.renderers` on the identical
@@ -1089,7 +1128,7 @@ def _read_service_roles(
1089
1128
  declaration been buffered through `register()`, `[services] store = "qdrant"` would then be
1090
1129
  refused as an *unknown key*, naming the wrong problem entirely, on exactly the machine where
1091
1130
  an operator is trying to configure their way out of it. The pre-9.0 behaviour — the key
1092
- parses, and the plugin name fails later through `weft_cli.registry_bootstrap.require_plugin`,
1131
+ parses, and the plugin name fails later through `weft_engine.registry_bootstrap.require_plugin`,
1093
1132
  which names the pack and its reason — is the better error, and reading the declaration here
1094
1133
  is what preserves it.
1095
1134
 
@@ -17,7 +17,7 @@ from weft_kernel.payload.ext import SCHEMA_VERSION_KEY, ExtMap, ExtModel, Schema
17
17
  from weft_kernel.payload.ids import NodeId, SourceId
18
18
  from weft_kernel.payload.lineage import Lineage
19
19
  from weft_kernel.payload.media_type import MediaType
20
- from weft_kernel.payload.node import Node, SyntheticOrigin
20
+ from weft_kernel.payload.node import Node, SyntheticOrigin, carry_forward
21
21
  from weft_kernel.payload.outcome import Failed, NothingToProduce, Outcome, Produced
22
22
  from weft_kernel.payload.property import Property
23
23
  from weft_kernel.payload.vector import Vector
@@ -40,4 +40,5 @@ __all__ = [
40
40
  "SourceId",
41
41
  "SyntheticOrigin",
42
42
  "Vector",
43
+ "carry_forward",
43
44
  ]
@@ -15,7 +15,7 @@ call site.
15
15
 
16
16
  **Built in Phase 5 task 5.2c.** `__schema_version__` is a second mandatory
17
17
  declaration, checked in the same `__pydantic_init_subclass__` seam as
18
- `__namespace__` and for the identical reason — G9's ruling (`docs/README.md`
18
+ `__namespace__` and for the identical reason — G9's ruling (`docs/internal/README.md`
19
19
  decision log, `docs/02-extension-model.md` §1): a contract version cannot
20
20
  stand in for it because it is not available at the read site, so an
21
21
  `ExtModel` carries its own version, and a missing one fails loudly at class
@@ -235,6 +235,34 @@ class Node(BaseModel):
235
235
  return self._replace(embedding=embedding)
236
236
 
237
237
 
238
+ def carry_forward(child: Node, *, parent: Node) -> Node:
239
+ """`child`, plus every namespace `parent.ext` carries except its root-origin marker.
240
+
241
+ `SyntheticOrigin` is excluded by name: it states that a node has no real lineage, and
242
+ `child` was just given one — by `Node.derive`, whichever stage called this — so copying
243
+ it forward would attach a claim about `child` that is false the moment it is read.
244
+ Every other namespace is copied verbatim, last-write-wins is never a concern here since
245
+ `child` carries no `ext` of its own yet (`derive` starts it empty); a caller that wants
246
+ its own fact to win over a carried-forward one — `weft_chunk.table_rows` does, for its
247
+ row's own `TableGrid` — attaches it with `with_ext` *after* calling this.
248
+
249
+ **G17, settled 2026-09-12.** Moved here from `weft_chunk.carry` — with a byte-identical
250
+ private copy in `weft_vision.describe_figure` whose own docstring cited
251
+ `weft_chunk.fixed_size._carry_forward`, a function that has not existed since task `9.14`
252
+ lifted it — because G17 adds a third consumer: every `weft_clean` cleaner rebuilds its
253
+ node with `Node.derive`, which drops `ext`, and `R9.1` is the property that a `TEXT`
254
+ node's extraction-time facts survive that. `weft_clean`, `weft_chunk` and `weft_vision`
255
+ import none of each other, so the only module all three already depend on is the kernel,
256
+ and the function belongs there on `01`'s own test: it names no capability. It is an
257
+ operation on `Node.ext` and `SyntheticOrigin`, both of them kernel types.
258
+ """
259
+ for namespace, model in parent.ext.items():
260
+ if namespace == SyntheticOrigin.__namespace__:
261
+ continue
262
+ child = child.with_ext(model)
263
+ return child
264
+
265
+
238
266
  def _content_digest(
239
267
  *, media_type: MediaType, content: str, parent_ids: Sequence[NodeId], ordinal: int
240
268
  ) -> NodeId:
@@ -79,7 +79,7 @@ already preserves call order into the `**data` dict `BaseModel.__init__`
79
79
  builds, which is the same dict a document's loader hands to
80
80
  `model_validate`, so there is exactly one code path computing "the order",
81
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`.
82
+ open in `docs/internal/build-ledger.md`.
83
83
 
84
84
  **`fallback` is data here too.** `02` §1 gives the kernel a fallback combinator
85
85
  over any contract, and `11` §4 keeps `fallback:` a per-stage list "tried in
@@ -241,7 +241,7 @@ PIPELINE_OPERATOR_MARK: Final[str] = "pipeline_operator"
241
241
  ratchet, and `01` -> *Fitness functions* item 11(a) requires its "actual" side to be
242
242
  "derived from the code (the actual operator fields on the model), never a hand-written
243
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
244
+ structural link back to this class, is exactly the drift `docs/internal/README.md` opens by
245
245
  describing. Each of the four operator fields below carries `Field(...,
246
246
  json_schema_extra={PIPELINE_OPERATOR_MARK: True})` for exactly that reason: it is the
247
247
  one place in the tree that *decides* a field is an operator block, so the ratchet reads
@@ -112,7 +112,7 @@ so a leaf's override reaches a var referenced in a `with:` block the root itself
112
112
  **Operators apply against the *running* stage list, never against the original root.**
113
113
  `02` §3: "Operators apply in written order, each validated against the running result."
114
114
  That is what makes `remove` followed by `insert` on one id a move rather than a
115
- collision — task 1.4 settles the question `docs/build-ledger.md` left open after 1.1:
115
+ collision — task 1.4 settles the question `docs/internal/build-ledger.md` left open after 1.1:
116
116
  "the order in which those keys appear in the document is the order they apply."
117
117
  `weft_kernel.pipeline.Pipeline.operator_order` is read off the document (or the call)
118
118
  that built the pipeline, never assumed from field order, and `_apply_operators` below
@@ -43,7 +43,7 @@ The four concerns:
43
43
  — is out of scope by construction, not by an exclusion list.
44
44
  5. **The NUL-byte sanitiser** — see `_sanitize_control_bytes` below, riding
45
45
  the same `Produced` → `Node` / `tuple` / `list` walk `_strip_transient`
46
- already performs, immediately after it. `docs/build-ledger.md` → **2.34**
46
+ already performs, immediately after it. `docs/internal/build-ledger.md` → **2.34**
47
47
  settles where this lives, against two alternatives, with evidence:
48
48
 
49
49
  - **Not in an extractor pack.** Eight sites across `packages/` build a
@@ -51,7 +51,7 @@ The four concerns:
51
51
  text.py:80 'Node.syn'`, `weft_pdf/document.py:205 'rows: tu'`,
52
52
  `weft_chunk/fixed_size.py:117 'destroys'
53
53
  'destroys'`,
54
- `weft_clean/dictionary_spacing.py:108-109 'intact: '`,
54
+ `weft_clean/dictionary_spacing.py:116-117 'intact: '`,
55
55
  `weft_clean/hyphenation.py:71-72 'intact: '
56
56
  'intact:'`,
57
57
  `weft_clean/whitespace.py:64-65 'intact: t'`, `weft_clean/table_linearizer.py:79 'destroys:'`,
@@ -74,11 +74,17 @@ The four concerns:
74
74
  structural here, read off the span's own attributes, rather than four
75
75
  keyword arguments an author has to remember to pass at every call site.
76
76
 
77
- **NUL becomes a space, never a deletion**, because `weft_chunk.payload.ChunkOffset` records a
78
- character offset into a parent's content, so deleting a byte would
79
- silently shift every offset recorded downstream of the node being
80
- cleaned. A space is one character for one character; every offset already
81
- recorded against this content stays correct.
77
+ **NUL becomes a space, never a deletion**, because this seam rewrites a
78
+ node's content *after* whatever produced it has already described it, and
79
+ a length-changing edit silently invalidates any description that indexes
80
+ into that content by position. A space is one character for one
81
+ character, so every such fact stays correct. **The reason is structural
82
+ rather than live as of 2026-09-12**: `weft_chunk.payload.ChunkOffset` was
83
+ the one shipped `ExtModel` recording a character offset, and repair
84
+ `R17.1` withdrew it once **G17** left it with no reader. Nothing in the
85
+ tree indexes content by position today — which is an argument for
86
+ preserving length cheaply while that is true, not for spending the
87
+ invariant.
82
88
 
83
89
  **Scope is `Node.content` and the `str`-typed fields of whatever
84
90
  `ExtModel`s `Node.ext` carries** — `weft_store/pgvector_store.py`'s
@@ -129,14 +135,14 @@ three concerns still apply: a span, the blocking-call guard, and, for a bare
129
135
  gives `run()`.
130
136
 
131
137
  **`guard_blocking_calls`, added by `weft-cli` task 3.4 for a caller outside
132
- `Runner`'s own reach.** `docs/build-ledger.md` 3.2 tried running a
138
+ `Runner`'s own reach.** `docs/internal/build-ledger.md` 3.2 tried running a
133
139
  `weft_command.contract.Command` invocation through this function unchanged
134
140
  and reverted: `weft index`'s synchronous filesystem walk tripped concern 4,
135
141
  which exists because a blocking `Stage` starves an event loop *other stages
136
142
  share* — a `Command` is CLI orchestration invoked once per invocation or
137
143
  REPL turn, with nothing else scheduled on that loop to starve, so the guard
138
144
  was a false positive for it rather than a caught defect. 3.4's own analysis
139
- (recorded in full in its `docs/build-ledger.md` entry) rejected two other
145
+ (recorded in full in its `docs/internal/build-ledger.md` entry) rejected two other
140
146
  shapes for this fact: a second, hand-written span-and-attribution wrapper in
141
147
  `weft_cli` (a second implementation of a concern this module already owns,
142
148
  free to drift from it) and applying the guard unconditionally (the reverted
@@ -677,7 +683,7 @@ def _sanitize_ext_model(model: ExtModel) -> tuple[ExtModel, int]:
677
683
 
678
684
  Scoped to a field whose *runtime value* is a `str` — not `tuple[str, ...]`
679
685
  or `list[str]`, checked by `isinstance` rather than by the field's
680
- annotation. `docs/build-ledger.md` → 2.34 scopes this task to "`Node.content`
686
+ annotation. `docs/internal/build-ledger.md` → 2.34 scopes this task to "`Node.content`
681
687
  and the `str`-typed fields of the `ExtModel`s in `Node.ext`" literally; no
682
688
  shipped `ExtModel` as of this task holds a string collection built from
683
689
  verbatim extractor text, so widening to collections has no motivating case
@@ -723,3 +729,64 @@ def _clean_str(value: str) -> tuple[str, int]:
723
729
  if "\x00" not in value:
724
730
  return value, 0
725
731
  return value.replace("\x00", " "), value.count("\x00")
732
+
733
+
734
+ async def aclose(
735
+ instance: object,
736
+ *,
737
+ distribution: str,
738
+ contract: str,
739
+ plugin: str,
740
+ stage: str | None = None,
741
+ ) -> None:
742
+ """Close `instance` if it has an `aclose`, with the same span, guard and attribution
743
+ `wrap_flush` gives `flush`.
744
+
745
+ `aclose` is a fact read off the instance, never a method any contract publishes — an
746
+ in-memory store and every third-party pack that keeps no socket has nothing to close, and
747
+ requiring the method would be a line each of them has to write in order to be reaped. So
748
+ the defensive read and the call are one function rather than two: a caller that still had
749
+ to ask *is there one?* before calling a separate helper would still be the caller deciding,
750
+ and three call sites deciding it independently is exactly the copy this replaces.
751
+
752
+ This is a function callers reach for, never something the runner does on their behalf:
753
+ the instantiator owns the lifetime it opened, and a `Lifetime.PROCESS` instance outlives
754
+ any one run, so closing it is not the runner's to do.
755
+
756
+ `stage` is optional, defaulted to `f"{contract}:{plugin}"` exactly as `wrap` defaults it for
757
+ a caller with no pipeline concept — two of this function's three callers hold no pipeline
758
+ position at all, unlike `wrap_flush`'s one caller, which always has a resolved `StageSpec.id`.
759
+
760
+ No `_strip_transient` and no `_sanitize_control_bytes` — like `flush`, `aclose` returns
761
+ nothing an `Outcome` could decide.
762
+ """
763
+ found = getattr(instance, "aclose", None)
764
+ if found is None or not callable(found):
765
+ return
766
+ close = cast("Callable[[], Awaitable[None]]", found)
767
+
768
+ label = stage if stage is not None else f"{contract}:{plugin}"
769
+ with _tracer.start_as_current_span(f"{label}:aclose", kind=SpanKind.INTERNAL) as span:
770
+ span.set_attribute("weft.pack", distribution)
771
+ span.set_attribute("weft.contract", contract)
772
+ span.set_attribute("weft.plugin", plugin)
773
+ with blocking.guard(f"{label}:aclose"):
774
+ try:
775
+ await close()
776
+ except WeftError as exc:
777
+ _attribute(
778
+ exc,
779
+ distribution=distribution,
780
+ contract=contract,
781
+ plugin=plugin,
782
+ stage=label,
783
+ )
784
+ raise
785
+ except Exception as exc:
786
+ raise WeftError(
787
+ f"'{label}' close failed: {exc}",
788
+ pack=distribution,
789
+ contract=contract,
790
+ plugin=plugin,
791
+ stage=label,
792
+ ) from exc
File without changes
File without changes
File without changes