@zakkster/lite-signal-decorators 1.3.0 → 1.5.0

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,227 @@ All notable changes to `@zakkster/lite-signal-decorators` are documented here.
4
4
  The format follows Keep a Changelog; this project adheres to Semantic
5
5
  Versioning.
6
6
 
7
+ ## [1.5.0] - 2026-08-30
8
+
9
+ The gamedev release -- decisions/0013 criterion (d), and the LADDER CLOSES. The
10
+ package already shipped the fleet primitives (`capacityFor` sizes a registry;
11
+ `createRegistry` builds it; `releaseReactive`/`reinitReactive` park and revive an
12
+ instance with zero engine nodes held while parked), and the demo hand-rolled a
13
+ pool over them. `createFleet(inventory, bind, opts?)` is that pool, extracted:
14
+ the flagship-audience helper that composes the shipped primitives into one
15
+ fixed-capacity fleet handle. The demo is the NAMED consumer (criterion (d): serve
16
+ the flagship audience on a shipped primitive with the demo as a live consumer
17
+ landing in the same release) -- its hand-rolled pool was DELETED for the helper,
18
+ and that diff went NET-NEGATIVE. Surface 22 -> 23, and the decisions/0013
19
+ strategic-admission ladder is closed at four rungs.
20
+
21
+ ### Added
22
+
23
+ - **`createFleet(inventory, bind, opts?) -> Fleet`** (23rd export) -- a
24
+ fixed-capacity pool of reactive instances over the shipped primitives. COLD
25
+ construction: `capacityFor(inventory, opts)` sizes a registry, `createRegistry`
26
+ builds it, `bind(registry)` binds the caller's decorated class to that registry
27
+ and returns it (PD-76 -- the helper never wraps or redefines the class; the demo
28
+ keeps its real `@reactiveHost` cycle), then one member per inventory unit is
29
+ EAGER-constructed and PARKED (PD-75 -- `acquire` never constructs). The handle
30
+ is `{ registry, Class, capacity, acquire(initials?), release(vm), at(i),
31
+ size(), stats(), dispose() }`. HOT: `acquire` pops an `Int32Array` free-list and
32
+ `reinitReactive`s a parked member (`initials` override the reset values);
33
+ `release` validates a per-fleet symbol slot stamp (a plain symbol-keyed integer
34
+ field, NOT a WeakMap -- PD-77's forcing condition, resolved) and
35
+ `releaseReactive`s the member back to the pool. Both hot bodies allocate ZERO.
36
+ SIX named fail-closed misuses: `FleetExhaustedError` (acquire at capacity;
37
+ pre-checked, the registry's own `CapacityError` is unreachable because all N are
38
+ prealloc'd), `FleetForeignMemberError` (release of a vm this fleet never handed
39
+ out -- the slot stamp is the check), `FleetDoubleReleaseError` (release of an
40
+ already-parked vm -- `releaseReactive` returning false is the check),
41
+ `FleetDisposedError` (any call after `dispose()`), a `RangeError` (`at(i)`
42
+ outside `[0, capacity)`), and a `TypeError` (a `bind` that is not a function or
43
+ does not return a constructor). These six are internal by design -- ONE exported
44
+ error class remains the surface law (`ReactiveDisposedError`); the fleet names
45
+ are message/name-level and pinned by test/20. Construction is ATOMIC: any
46
+ mid-prefill throw disposes the already-built members and destroys the registry
47
+ before rethrowing. `dispose()` disposes every member LIVE AND PARKED (park ->
48
+ dispose lands DISPOSED) then destroys the fleet-owned registry -- parked members
49
+ are disposed too, never leaked.
50
+ - **`test/20-fleet.test.mjs`** (37 cases) -- the API surface + the six fail-closed
51
+ laws + both emit lanes where meaningful + a buildless (`defineReactive`) class +
52
+ `initials` pass-through, and the 22 -> 23 export-count assertion.
53
+ - **`test/torture/fleet-torture.mjs`** (a NEW lane, 19 scenarios / 19 controls) --
54
+ proves the HELPER's bookkeeping (the primitive pooled-cycle proof stays in
55
+ `reinit-torture`): A1 4096 acquire/release cycles at zero-alloc budgets with a
56
+ `TORTURE_BREAK` control that catches a deliberately leaky variant; A2 1000
57
+ fleet lifecycles at N=512 returning `activeNodes` to exact baseline with
58
+ `poolGrowths` 0.
59
+ - **The demo consumer** (never cut -- the admission ground): `loop.ts`'s
60
+ hand-rolled pool (the `EntityShape` wall plumbing, slots/count, spawn/kill,
61
+ dispose-storm bodies) is DELETED in favor of one `createFleet` call;
62
+ `step`/`readPositions` use `at(i)`; the drift wall + HUD keep working. The
63
+ `EntityShape` twin is kept ONLY for pre-existence `capacityFor` sizing.
64
+
65
+ ### Changed
66
+
67
+ - **The export surface: 22 -> 23** -- an additive MINOR under the 1.0.0 semver
68
+ promise (new exports are minors). The 1.0.0 hot canon
69
+ (`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: `createFleet` is
70
+ cold construction over the shipped primitives and moves no accessor byte.
71
+ - Version sync to 1.5.0 across the FOUR sites: `package.json`, the `VERSION`
72
+ const, `llms.txt` line 3, and the `SignalDecorators.d.ts` VERSION literal.
73
+ - `test/15` surface-freeze recount 22 -> 23 (the CB-A2 sites); its four-place
74
+ VERSION leg reads live values and needed no edit.
75
+ - The demo's boot cost rises by ~7ms one-time for the 4096 eager constructions
76
+ (the eager prefill moves cost to load -- the honest number, reported not hidden;
77
+ it buys a zero-alloc steady state).
78
+
79
+ ### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
80
+
81
+ - A1 acquire/release, 4096 cycles at 8N: **1.649 B/cyc** vs the in-process
82
+ zero-alloc control **0.234 B/cyc** (+2 limit); `gc.major` **0**; minors **2**
83
+ vs a limit of 129; `maxPauseMs` **0.36**. The `TORTURE_BREAK` leaky variant is
84
+ caught at **42.881 B/cyc**.
85
+ - A2 1000 lifecycles x N=512 return `activeNodes` to the exact baseline;
86
+ `poolGrowths` delta **0**.
87
+ - Release frees exactly `-(P+L+D+E+1)` nodes -- on the demo `Entity` shape that
88
+ is **-8** node-delta per release.
89
+
90
+ ### Records
91
+
92
+ - decisions/0009 (the fifth admission candidate) and decisions/0011 (the
93
+ fleet-helpers section) each gain an ADMITTED stamp.
94
+ - decisions/0013 gains its COMPLETION ADDENDUM: the strategic-admission ladder is
95
+ CLOSED -- four rungs shipped same-day (v1.2.0 `@localTo` (a); v1.3.0
96
+ `snapshotOf` + `forEachReactive` (c); v1.4.0 `costOfInstance` (b); v1.5.0
97
+ `createFleet` (d)). Surface 18 -> 23 across the ladder; candidates 1 (`bump`)
98
+ and 7 (`onObserved` sugar) remain deferred behind the original real-consumer
99
+ bar, which resumes as the only track for anything new. The planned
100
+ decisions/0015 was CUT -- its fleet-contract facts fold into the 0013 addendum.
101
+
102
+ ### Gate output (section-10 chain, archived verbatim)
103
+
104
+ ```
105
+ fixtures OK exit 0 -- emit fixtures regenerated
106
+ test OK exit 0 -- 377 pass / 0 fail
107
+ test:gc OK exit 0 -- 377 pass / 0 fail
108
+ torture OK exit 0 -- 17 passed, 2 skipped, 0 warned, 0 failed in 35.5s
109
+ torture:controls OK exit 0 -- 19 passed, 0 skipped, 0 warned, 0 failed in 3.7s
110
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 19/0/0/0; canary 1.9.0-canary.1 SUITE-GREEN 19/0/0/0]
111
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
112
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.0s; controls 8/8 controls fail correctly in 4.9s
113
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
114
+ ----------------------------------------------------------------------
115
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
116
+ ```
117
+
118
+ ## [1.4.0] - 2026-08-30
119
+
120
+ The measured-instance pillar -- decisions/0013 criterion (b). `costOf(Factory)`
121
+ answers "what will an instance of this class cost" by probing with NO ctor args,
122
+ so a ctor-arg-dependent shape needs a measurement-twin class to size it;
123
+ decisions/0009 candidate 4 recorded that absence plainly (r9 and the fleet demo
124
+ both paid it). `costOfInstance(vm)` closes it: it measures a LIVE, wired instance
125
+ by walking its OWN graph -- no probe, no twin, no registry pollution. The demo is
126
+ the NAMED consumer that admits it under the 0009 bar (a new export needs a named
127
+ consumer, and a recipe is not one): the console's shape-drift wall now measures a
128
+ real `Entity` and the HUD reports a live fleet member's cost per tick. The
129
+ `EntityShape` twin is honestly retained -- its remaining job is `capacityFor`
130
+ sizing only (the world must be sized before it exists). Surface 21 -> 22.
131
+
132
+ ### Added
133
+
134
+ - **`costOfInstance(vm) -> { nodes, links, signals, locals, deriveds, effects }`**
135
+ (22nd export) -- the LIVE per-instance cost, walked from the instance's own
136
+ graph: `nodes = 1 (anchor) + plan.signals.length + plan.locals.length +
137
+ forEachOwned(rootOf(vm))` (the deriveds and user effects the anchor adopted --
138
+ signal/local boxes are built pre-anchor and unadopted, so they are never
139
+ owned); `links` is the un-deduped sum of `forEachSource` over the anchor, every
140
+ owned node, and every signal/local box (one edge per observer, matching
141
+ `costOf`'s activeLinks delta); kind counts are read from the plan arrays, never
142
+ walked. THE LIVE-VS-PROBE CONTRACT is the feature: `costOf` forces every derived
143
+ to the constructed CEILING, `costOfInstance` reports what THIS instance costs
144
+ right now -- an unforced lazy derived or an untaken dynamic branch shows FEWER
145
+ links until the graph is exercised (`nodes` matches regardless; read every
146
+ derived once and the two agree exactly). UNCACHED (PD-70) -- a live graph
147
+ mutates, so a cached number would lie. Needs no `stats()` ledger (PD-72), so it
148
+ measures instances on hand-rolled registries where `costOf` fails closed.
149
+ Allocates its frozen result by design (one object per call, cold like
150
+ `snapshotOf`; PD-69, no out-param variant). Fails closed on a disposed/parked
151
+ instance with a NAMED `ReactiveDisposedError` (PD-71 -- a parked vm holds ZERO
152
+ nodes and a silent `{ nodes: 0 }` is indistinguishable from a bug) and on
153
+ unwired/no-plan/prewired-member values.
154
+ - **`test/19-cost-instance.test.mjs`** (22 cases) -- both emit lanes + buildless:
155
+ A1 parity-when-forced (=== `costOf`, nodes/links/every kind count), A2
156
+ delta-when-lazy (links strictly lower, then monotonic toward the forced
157
+ number), a `@localTo` member counted in locals contributing ZERO graph links,
158
+ the frozen `{nodes,links,signals,locals,deriveds,effects}` shape, A3
159
+ registry-untouched over 10000 calls, PD-72 bound-registry + stats-less-facade
160
+ measurement (where `costOf` fails closed), PD-70 uncached/live across a branch
161
+ flip, and the A6 fail-closed matrix (plain/unwired/parked/disposed each a NAMED
162
+ throw, never a `{nodes:0}` report).
163
+ - **The `introspection-torture` lane extended** with two `costOfInstance`
164
+ blocks: A4 -- 1e4 calls at `maxMajor 0`, `maxPauseMs <= 4.0`, the per-call
165
+ frozen result the only allocation (REPORTED, never gated); A5 -- 1000
166
+ wire/measure/park/reinit/dispose cycles with `tracker.size()` 0, `activeNodes`
167
+ to exact baseline, pool growths 0.
168
+ - **The demo consumer** (never cut -- the admission ground): the console's
169
+ shape-drift wall measures a real live `Entity` via `costOfInstance` (its node
170
+ count === the sizing twin's) and the HUD reports one live fleet member's
171
+ `costOfInstance` per HUD tick (never per frame), so the live-vs-forced delta is
172
+ visible on screen. Cold boot / HUD-tick paths only; zero frame-loop cost.
173
+
174
+ ### Changed
175
+
176
+ - **The export surface: 21 -> 22** -- an additive MINOR under the 1.0.0 semver
177
+ promise (new exports are minors). The 1.0.0 hot canon
178
+ (`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: `costOfInstance`
179
+ is cold and moves no accessor byte.
180
+ - Version sync to 1.4.0 across FOUR sites now: `package.json`, the `VERSION`
181
+ const, `llms.txt` line 3, and the `SignalDecorators.d.ts` VERSION literal --
182
+ the last a NEW asserted sync site. The `.d.ts` literal had gone stale (it read
183
+ a prior version, escaping the three-place sweep since the d.ts VERSION line was
184
+ never gated); the owner caught it, so `test/15`'s VERSION-consistency test
185
+ gains a FOURTH leg that regexes the `.d.ts` literal and asserts it string-equals
186
+ `package.json`, killing that bug class.
187
+ - `test/15` surface-freeze recount 21 -> 22 (all CB-A2 sites).
188
+
189
+ ### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
190
+
191
+ - A1 parity (forced): `costOfInstance(vm)` === `costOf(Factory)` for the same
192
+ shape once every derived is read once -- demo `Entity` nodes **7**, links
193
+ **3** (P2/L1/D2/E1), both paths identical; kind counts identical.
194
+ - A2 delta (lazy): a fresh instance reads links **1 -> 2 -> 3** as its deriveds
195
+ are exercised, strictly below the forced number until the graph is exercised;
196
+ node counts equal throughout.
197
+ - A standalone `@localTo` member contributes **ZERO** graph links (the upstream
198
+ compare is a plain per-instance slot, not an edge) -- measured, not assumed; it
199
+ counts in `locals` only.
200
+ - 1e4 `costOfInstance` calls: **71.3 B/op** (the per-call frozen result), gc
201
+ major **0**, `maxPauseMs <= 4.0` -- REPORTED, never gated.
202
+ - A3: 10000 calls leave the registry `stats()` snapshot byte-identical
203
+ (`activeNodes`/`activeLinks`/`totalDisposals` unchanged) -- the walk never
204
+ mutates the registry.
205
+ - A5: 1000 wire/measure/park/reinit/dispose cycles -- `tracker.size()` 0,
206
+ `activeNodes` to exact baseline, pool growths 0.
207
+
208
+ Records: decisions/0013 (strategic-admission track, criterion (b)),
209
+ decisions/0009 (candidate 4, now stamped ADMITTED with the pre-admission absence
210
+ preserved).
211
+
212
+ ### Gate output (section-10 chain, archived verbatim)
213
+
214
+ ```
215
+ fixtures OK exit 0 -- emit fixtures regenerated
216
+ test OK exit 0 -- 335 pass / 0 fail
217
+ test:gc OK exit 0 -- 335 pass / 0 fail
218
+ torture OK exit 0 -- 16 passed, 2 skipped, 0 warned, 0 failed in 34.3s
219
+ torture:controls OK exit 0 -- 18 passed, 0 skipped, 0 warned, 0 failed in 3.6s
220
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 18/0/0/0; canary 1.9.0-canary.1 SUITE-GREEN 18/0/0/0]
221
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
222
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.1s
223
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
224
+ ----------------------------------------------------------------------
225
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
226
+ ```
227
+
7
228
  ## [1.3.0] - 2026-08-30
8
229
 
9
230
  The introspection/migration rung of the decisions/0013 strategic-admission
package/README.md CHANGED
@@ -147,6 +147,8 @@ const ReactivePlayer = defineReactive(Player, {
147
147
  - **Fail-closed everything** -- statics, private `#` members, unknown options, duplicate keys, orphaned members, invalid registries, half-valid specs: all named throws at decoration time, with a nearest-key did-you-mean where a typo is likely.
148
148
  - **Interop that stays raw** -- `boxOf(vm, key)` hands you the live engine box; `rootOf(vm)` hands the anchor descriptor to `forEachOwned` / lite-devtools. Decorated and hand-written signals share one graph.
149
149
  - **Introspection & migration (1.3.0)** -- `forEachReactive(vm, fn, arg)` walks every value-bearing member in plan order (`signal`/`local`/`derived`, effects excluded) with a zero-alloc `fn(key, box, kind, arg)` callback; `snapshotOf(vm)` returns a shallow plain-object copy read through the accessors under one `untrack` -- the native `toJS` this package now ships, safe to call inside an effect.
150
+ - **Live per-instance cost (1.4.0)** -- `costOfInstance(vm)` walks one wired instance's own graph and reports what it costs RIGHT NOW: `costOf(Factory)` answers "what will an instance of this class cost" (it forces every derived to the constructed ceiling), `costOfInstance` answers "what does THIS instance cost" -- an unforced lazy derived or an untaken branch shows fewer links until the graph is exercised, and reading every derived once makes the two agree exactly. Twin-free: it needs no stats() ledger, so it measures instances on hand-rolled registries where `costOf` fails closed.
151
+ - **One-call pooled fleets (1.5.0)** -- `createFleet(inventory, bind, opts?)` composes the shipped primitives (`capacityFor` sizes the registry, `createRegistry` builds it, `releaseReactive`/`reinitReactive` park and revive) into one fixed-capacity fleet handle `{ registry, Class, capacity, acquire, release, at, size, stats, dispose }`. It EAGER-prefills and parks every member at construction, so `acquire(initials?)` never constructs -- it pops an `Int32Array` free-list and revives a parked member with ZERO allocation, and `release(vm)` parks it back after a per-fleet slot-stamp check. Six named fail-closed misuses (exhausted, foreign vm, double release, use-after-dispose, out-of-range `at`, bad `bind`); `dispose()` tears every member LIVE and PARKED plus the fleet-owned registry. The gamedev release: spawn/kill worlds and respawn-heavy scenes on a zero-GC steady state (decisions/0013 criterion (d) -- the demo's hand-rolled pool was deleted for it).
150
152
 
151
153
  ---
152
154
 
@@ -270,10 +272,20 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
270
272
  | Export | Signature | Behavior |
271
273
  |---|---|---|
272
274
  | `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + L + D + E + 1; `links` is the first-full-read link count. |
275
+ | `costOfInstance` (1.4.0) | `(vm) => { nodes, links, signals, locals, deriveds, effects }` | The LIVE cost of one wired instance right now, walked from its own graph -- no probe, no ctor args, no registry pollution. The delta from `costOf` IS the feature: `costOf` forces every derived to the constructed CEILING ("what will an instance of this class cost"), `costOfInstance` reports "what does THIS instance cost right now" -- an unforced lazy derived or an untaken branch has formed no links, so `links` reads BELOW `costOf` until the graph is exercised (`nodes` matches regardless; read every derived once and the two agree exactly). UNCACHED -- a live graph mutates, so a cached number would lie. Needs no stats() ledger, so it measures instances on hand-rolled registries where `costOf` fails closed. Allocates its frozen result by design (one object per call, ~71 B/op -- reported, never gated). Fails closed on a disposed/parked instance with a NAMED throw (a parked vm holds zero nodes; a silent `{ nodes: 0 }` is indistinguishable from a bug) and on unwired/no-plan/prewired values. |
273
276
  | `capacityFor` | `(inventory, { headroom }?) => RegistryConfig` | Sizes a `createRegistry` config from `[Factory, count]` pairs: nodes exact, links x `headroom` (floored at the engine minimum of 1), `prealloc: "eager"`, `onCapacityExceeded: "throw"`. Fail-closed inventory and options validation. Link policy + caveats: [decisions/0007](decisions/0007-capacity-policy.md). |
274
277
  | `enableLabels` / `labelOf` | `(on)` / `(idOrHandle, registry?) => string \| undefined` | Opt-in devtools identity (default OFF): while on, wiring registers per-registry `nodeId -> "Class.prop"` / `"Class#method"` / `"Class@anchor"`; dispose unregisters. `labelOf` misses return `undefined`, never throw. |
275
278
  | `auditReactive` | `(on)` | Opt-in leak auditor (default OFF): a lazily-created `FinalizationRegistry` reports any instance collected WITHOUT `disposeReactive`, naming class and shape. Holds no instance references itself; zero cost and zero registrations while off. |
276
279
 
280
+ The `costOf`/`costOfInstance` split reads as a class-vs-instance pair:
281
+
282
+ ```js
283
+ const cls = costOf(Enemy); // ceiling: every derived forced
284
+ const fresh = costOfInstance(inst); // fresh.links < cls.links (a lazy derived unread)
285
+ inst.threat; inst.range; // exercise the deriveds, then re-measure
286
+ costOfInstance(inst).links === cls.links; // now exact
287
+ ```
288
+
277
289
  With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- the hot accessor canon is untouched by all four (review-diffed against the published 0.3.0 tarball).
278
290
 
279
291
  ### Introspection walk & snapshot (1.3.0)
@@ -283,12 +295,34 @@ With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- th
283
295
  | `forEachReactive` | `(vm, fn, arg) => count` | Cold value-member walk. Calls `fn(key, box, kind, arg)` once per value-bearing member and returns the visit count. `kind` is `"signal" \| "local" \| "derived"`; `@reactiveEffect`/`@batched` are EXCLUDED (non-value-bearing). Order is PLAN order -- signals, then locals, then deriveds, each declaration-ordered and ancestor-first (never `Reflect.ownKeys`, so it is stable across reinit). Four scalar args, zero descriptor object, and the `arg` pass-through kills the caller's closure: the walk is a gated zero-alloc body. Symbol keys are visited. Fails closed on a non-reactive, unwired, parked, or disposed value with the same named errors as `rootOf`. |
284
296
  | `snapshotOf` | `(vm) => object` | A shallow plain-object copy of every value-bearing member, keyed by member key. Values are read through the ACCESSOR `vm[key]`, NOT `box.get`, so a `@localTo` compare-on-read resets honestly and a `@derived` computes on read (PD-62: reading the box directly would show a stale local after an untracked upstream move -- the accessor is the documented read). The whole walk runs under ONE `untrack` when the caller is tracking, so `snapshotOf` inside an effect subscribes to nothing. SHALLOW by design: a nested VM is copied by reference, not recursed. Symbol keys included. Fails closed on parked/disposed (`ReactiveDisposedError`, parked vs disposed flavor) and non-reactive values. This export ALLOCATES the returned object by design (~96 B/op measured) -- reported, never gated; the walk under it stays zero-alloc. |
285
297
 
298
+ ### Fleet (1.5.0)
299
+
300
+ | Export | Signature | Behavior |
301
+ |---|---|---|
302
+ | `createFleet` | `(inventory, bind, opts?) => Fleet` | A fixed-capacity pool of reactive instances over the shipped primitives. COLD construction: `capacityFor(inventory, opts)` sizes a registry, `createRegistry` builds it, `bind(registry)` binds the caller's decorated class to it and returns it (the helper never wraps or redefines the class), then one member per inventory unit is EAGER-constructed and PARKED. The handle is `{ registry, Class, capacity, acquire(initials?), release(vm), at(i), size(), stats(), dispose() }`. HOT: `acquire` pops an `Int32Array` free-list and revives a parked member (`initials` override the reset values); `release` validates a per-fleet symbol slot stamp (a plain symbol-keyed integer field, NOT a WeakMap) and parks the member back. Both hot bodies allocate ZERO. Six named fail-closed misuses: `FleetExhaustedError` (acquire at capacity, pre-checked), `FleetForeignMemberError` (release of a vm this fleet never handed out), `FleetDoubleReleaseError` (release of an already-parked vm), `FleetDisposedError` (any call after `dispose()`), a `RangeError` (`at(i)` out of `[0, capacity)`), and a `TypeError` (a `bind` that is not a function or does not return a constructor). Construction is ATOMIC (a mid-prefill throw disposes what was built + destroys the registry). `dispose()` disposes every member LIVE and PARKED then destroys the fleet-owned registry. |
303
+
304
+ The fleet is the demo's pool, extracted -- one call for a spawn/kill world:
305
+
306
+ ```js
307
+ const fleet = createFleet([[Entity, 4096]], (reg) => {
308
+ @reactiveHost({ registry: reg })
309
+ class Bound extends Entity {}
310
+ return Bound;
311
+ });
312
+ const e = fleet.acquire({ x: 10, y: 20 }); // revive a parked member, zero alloc
313
+ fleet.at(0); // slot read (bounds-checked)
314
+ fleet.release(e); // park it back; nodes return to the pool
315
+ fleet.dispose(); // every member + the registry torn down
316
+ ```
317
+
318
+ `acquire` never constructs: all `capacity` members are built and parked at construction, so the steady state is allocation-free. That moves the construction cost to load (the demo pays ~7ms one-time to prefill 4096 members) in exchange for a zero-GC spawn/kill loop.
319
+
286
320
  ### Errors & constants
287
321
 
288
322
  | Export | Value |
289
323
  |---|---|
290
324
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
291
- | `VERSION` | `"1.3.0"` |
325
+ | `VERSION` | `"1.4.0"` |
292
326
 
293
327
  ### The rejection matrix
294
328
 
@@ -393,6 +427,8 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
393
427
 
394
428
  `P + L + D + E + 1` pool nodes -- one per signal, per local, per derived, per effect, plus the anchor. All of them return to the pool on dispose: conservation is node-exact (`activeNodes` to baseline, zero pool growths, allocations minus disposals reconciled) over 4096-cycle churn and a wall-clock soak.
395
429
 
430
+ **`createFleet` acquire/release (1.5.0).** The fleet's hot loop measures **1.649 B/cyc** at 8N against a **0.234 B/cyc** in-process zero-alloc control (a +2 limit); `gc.major 0`, 2 minors against a limit of 129, `maxPauseMs 0.36`. A `release(vm)` frees exactly `P + L + D + E + 1` nodes -- on the demo `Entity` shape that is a **-8** `activeNodes` delta per release. 1000 fleet lifecycles at N=512 return `activeNodes` to the exact baseline with `poolGrowths` 0, and the lane's `TORTURE_BREAK` control catches a leaky variant at 42.881 B/cyc.
431
+
396
432
  <details>
397
433
  <summary><strong>Zero-GC design notes: the allocation table + the gates</strong></summary>
398
434
 
@@ -408,12 +444,14 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
408
444
  | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
409
445
  | `forEachReactive` walk | none | gated: 1e6 hoisted-callback walks measure **0.002 B/walk** (vs the 0.000 B/op zero-alloc control -- within a +2-byte limit), `gc.major === 0`; the 4-scalar `fn(key, box, kind, arg)` carries no descriptor object and the `arg` pass-through kills the caller's closure |
410
446
  | `snapshotOf(vm)` | 1 plain object | **by design** -- the returned copy allocates (**95.8 B/op measured**, 1e5 cycles); REPORTED in the torture summary line, never gated. The walk *under* it stays zero-alloc; cold, off any frame path |
447
+ | `costOfInstance(vm)` | 1 frozen object | **by design** -- the per-call frozen result allocates (**71.3 B/op measured**, 1e4 calls); the measurement itself is `gc.major === 0` over those 1e4 calls -- REPORTED, never gated. The graph walk *under* it allocates nothing (module-slot visitors, no per-call closure); cold, off any frame path |
448
+ | `fleet.acquire` / `fleet.release` | none | gated: the fleet's hot loop measures **1.649 B/cyc** at 8N (vs a 0.234 B/cyc zero-alloc control, +2 limit), `gc.major 0`, `maxPauseMs 0.36`; `acquire` pops an `Int32Array` free-list + revives a parked member, `release` parks it after a symbol-slot-stamp check -- no WeakMap, no per-call closure |
411
449
 
412
- The gates that hold it (run on every change, all green at 1.3.0):
450
+ The gates that hold it (run on every change, all green at 1.5.0):
413
451
 
414
- - `npm test` / `npm run test:gc` -- **313/313** on both lanes.
452
+ - `npm test` / `npm run test:gc` -- **372/372** on both lanes.
415
453
  - Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
416
- - Torture: **18 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences; the `reinit-torture` acquire/release gate; the `localto-torture` zero-alloc read/write storm + ABA-stale interleave lattice + pooled park/reinit; the `introspection-torture` 1e6 hoisted-callback `forEachReactive` walk at `maxMajor 0` with the snapshot-allocates figure reported, never gated) -- **16 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **18/18 controls** prove each gate can actually fail.
454
+ - Torture: **19 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences; the `reinit-torture` acquire/release gate; the `localto-torture` zero-alloc read/write storm + ABA-stale interleave lattice + pooled park/reinit; the `introspection-torture` 1e6 hoisted-callback `forEachReactive` walk at `maxMajor 0` with the snapshot-allocates figure reported, never gated; the `fleet-torture` 4096 acquire/release cycles at zero-alloc budgets + 1000 lifecycles to exact baseline) -- **17 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **19/19 controls** prove each gate can actually fail.
417
455
  - `churn-soak` + `fleet-soak`: sustained construct/use/dispose and a 10s 2k-VM fleet tick; pools at floor and retained heap flat at every sample.
418
456
 
419
457
  The cross-framework matrix lives in `bench/` (private, never shipped): six engines -- both our tiers, the hand-written `lite-raw-boxes` baseline, MobX 7, signal-utils/signal-polyfill, and a hand-rolled alien-signals class -- across eight class-shaped scenarios (including the `churn-reuse` acquire/release lane, where the lite tiers pool with zero retained growth and MobX/signal-utils/alien-class are structurally `unsupported` -- no disposable instance lifecycle to pool), checksum-verified for identical work, stamped into `bench/results.txt`. The formal verdicts are in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md): the decorated path measured **0.94x** the hand-written baseline on vm-write and **1.10x** on a 10k-instance fleet read (the 2.0x kill line cleared with margin), and **0 major + 0 minor GC over 4096 construct/use/dispose cycles** with pools at floor -- while emitting ~12.6x less transient garbage per churn run than the hand-rolled class it replaces.
@@ -445,12 +483,12 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
445
483
  ## Testing (for clients & QA)
446
484
 
447
485
  ```bash
448
- npm test # node --test, 313 tests
449
- npm run test:gc # the same 313 with --expose-gc (enables the allocation assertions)
486
+ npm test # node --test, 372 tests
487
+ npm run test:gc # the same 372 with --expose-gc (enables the allocation assertions)
450
488
  npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
451
489
  ```
452
490
 
453
- **313 tests** across eighteen files, all green at 1.3.0. The decorator protocol is tested three times over: against a mock standard-decorators emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
491
+ **372 tests** across twenty files, all green at 1.5.0. The decorator protocol is tested three times over: against a mock standard-decorators emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
454
492
 
455
493
  | File | Tests | Covers |
456
494
  |---|---:|---|
@@ -468,10 +506,12 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
468
506
  | `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
469
507
  | `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
470
508
  | `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
471
- | `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 21 exports), citation allowlist, link law, static-cost probe |
509
+ | `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 23 exports), citation allowlist, link law, static-cost probe, and the four-place VERSION sync (module const === package.json === llms.txt === `SignalDecorators.d.ts` literal) |
472
510
  | `16-reinit` | 29 | Pooled-reinit lattice on both emit lanes: park/reinit/dispose transitions, the five `reinitReactive` fail-closed states, parked-touch throws by name, `initials` boundary matrix (0..N+1 keys, null/undefined, NaN/-0 verbatim), `Symbol.dispose` on a parked instance, accessor descriptors byte-identical across reinit, self-release re-entrancy, ledger conservation |
473
511
  | `17-localto` | 34 | `@localTo` on both emit lanes + buildless `locals`: the read/write/upstream-reset lattice, both initial flavors (follow-from-wiring vs reset-from-initial), the ABA stale-local contract, `equals` override survival, park/reinit box+seen reset, `costOf` = P+L+D+E+1, source-throw fail-closed, and the fail-closed option/source matrix |
474
512
  | `18-introspection` | 22 | `forEachReactive`/`snapshotOf` on both emit lanes + buildless: plan-order walk (signals, locals, deriveds; ancestor-first), symbol keys, the `signal`/`local`/`derived` kind tags, effect/batched exclusion, count return + `arg` pass-through, the untracked-read law (snapshotOf inside an effect fires once), r7 `{name,hp,mp,alive}` parity, the PD-62 accessor-read reset honesty, and the fail-closed non-reactive/unwired/parked/disposed matrix |
513
+ | `19-cost-instance` | 22 | `costOfInstance` on both emit lanes + buildless: A1 parity-when-forced (=== `costOf`, nodes/links/every kind count), A2 delta-when-lazy (links strictly lower, then monotonic toward the forced number), `@localTo` counted in locals with zero graph links, the frozen `{nodes,links,signals,locals,deriveds,effects}` shape, A3 registry-untouched over 10000 calls, PD-72 bound-registry + stats-less-facade measurement (where `costOf` fails closed), PD-70 uncached/live across a branch flip, and the A6 fail-closed matrix (plain/unwired/parked/disposed each a NAMED throw, never a `{nodes:0}` report) |
514
+ | `20-fleet` | 37 | `createFleet` on both emit lanes + a buildless class: the `{registry,Class,capacity,acquire,release,at,size,stats,dispose}` handle surface, eager-prefill (all members parked at construction, `acquire` never constructs), `initials` pass-through, the six fail-closed misuses (exhausted/foreign/double-release/use-after-dispose/`at` out-of-range/bad `bind`), atomic mid-prefill cleanup, `dispose()` tearing live AND parked members plus the registry, and the 22 -> 23 export-count freeze |
475
515
 
476
516
  ### Emit-support matrix
477
517
 
@@ -528,6 +568,10 @@ npm run demo:gc # headless GC-budget lane over the fleet core (maxMa
528
568
  npm run demo:storm # headless dispose-storm retention lane (lite-leak, size 0)
529
569
  ```
530
570
 
571
+ Since 1.4.0 the console is the named consumer of `costOfInstance`: its shape-drift wall measures a real live `Entity` (its node count === the `EntityShape` sizing twin's) and the HUD reports one live fleet member's `costOfInstance` per HUD tick, so the live-vs-`costOf` delta -- a forced ceiling against the lazy live cost -- is visible on screen. The `EntityShape` twin stays for `capacityFor` sizing only (the world must be sized before it exists).
572
+
573
+ Since 1.5.0 the demo's hand-rolled pool IS `createFleet`: the spawn/kill plumbing (the slot array, free-list, count, and dispose-storm bodies) was DELETED in favor of one fleet handle, and `step`/`readPositions` read members through `fleet.at(i)`. The diff went net-negative -- the demo is the named consumer that admitted the helper (decisions/0013 criterion (d)). The eager prefill moves construction to load (~7ms one-time for 4096 members), the honest cost of a zero-alloc steady state.
574
+
531
575
  The `demo/` directory is dev-only -- it never enters `package.json` `files[]` and never ships to consumers.
532
576
 
533
577
  ---
@@ -602,7 +646,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
602
646
 
603
647
  ### The cookbook
604
648
 
605
- [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 21-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
649
+ [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 22-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
606
650
 
607
651
  ---
608
652
 
@@ -12,6 +12,7 @@ import type {
12
12
  NodeDescriptor,
13
13
  Registry,
14
14
  RegistryConfig,
15
+ RegistryStats,
15
16
  ReactiveHandle,
16
17
  EffectScheduler,
17
18
  } from "@zakkster/lite-signal";
@@ -425,6 +426,34 @@ export interface ReactiveCost {
425
426
  */
426
427
  export function costOf(Factory: new (...args: any[]) => any): Readonly<ReactiveCost>;
427
428
 
429
+ /**
430
+ * Measure the cost of ONE live, wired instance right now -- no probe, no
431
+ * construction, no ctor args, no registry pollution. Returns a per-call frozen
432
+ * `ReactiveCost` in costOf's exact shape, WALKED from the live graph:
433
+ * `nodes = 1 + signals + locals + forEachOwned(rootOf(vm))` (the deriveds and
434
+ * user effects the anchor adopted), and `links` is the un-deduped sum of
435
+ * forEachSource over the anchor, every owned node, and every signal/local box.
436
+ *
437
+ * THE LIVE-VS-PROBE CONTRACT. This number is the truth NOW. costOf forces every
438
+ * derived to report the constructed CEILING; costOfInstance reports what THIS
439
+ * instance costs at this moment, so an unforced lazy derived or an untaken
440
+ * dynamic branch shows FEWER links than costOf for the same shape until the graph
441
+ * is exercised. `nodes` matches regardless. Read every derived once and the two
442
+ * agree exactly. The delta is the feature, not a bug.
443
+ *
444
+ * The frozen result allocates by design, one object per call (UNCACHED -- a live
445
+ * graph mutates, so a cached number would lie). The walk needs no stats() ledger,
446
+ * so costOfInstance measures instances on registries where costOf fails closed.
447
+ *
448
+ * @param vm a live, wired reactive instance.
449
+ * @throws {ReactiveDisposedError} if `vm` was disposed or parked (a parked vm
450
+ * holds zero nodes; a silent `{ nodes: 0 }` would be indistinguishable from a
451
+ * bug, so both fail closed).
452
+ * @throws if `vm` is not wired yet, has no reactive plan, or exposes a prewired
453
+ * member slot.
454
+ */
455
+ export function costOfInstance(vm: object): Readonly<ReactiveCost>;
456
+
428
457
  /** A `[Factory, count]` pair for {@link capacityFor}. */
429
458
  export type InventoryEntry = [new (...args: any[]) => any, number];
430
459
 
@@ -452,6 +481,70 @@ export function capacityFor(
452
481
  options?: CapacityForOptions,
453
482
  ): RegistryConfig;
454
483
 
484
+ // --- createFleet --------------------------------------------------------------
485
+
486
+ /** Options for {@link createFleet}. Forwarded to {@link capacityFor}. */
487
+ export interface CreateFleetOptions {
488
+ /** Link-budget multiplier (`>= 1`, default `1` = exact). See {@link CapacityForOptions.headroom}. */
489
+ headroom?: number;
490
+ }
491
+
492
+ /**
493
+ * A fixed-capacity pool of reactive instances built by {@link createFleet}. Owns
494
+ * its registry; `dispose()` tears both members and registry down.
495
+ */
496
+ export interface Fleet<T extends object = object> {
497
+ /** The fleet-owned `Registry` the members are bound to. */
498
+ readonly registry: Registry;
499
+ /** The bound class `bind(registry)` returned; every member is an instance of it. */
500
+ readonly Class: new (...args: any[]) => T;
501
+ /** The fixed member ceiling (sum of the inventory counts), sized eagerly at construction. */
502
+ readonly capacity: number;
503
+ /**
504
+ * Revive a parked member and return it. `initials` overrides its reset values
505
+ * (undefined = the plan's initials). Zero allocation.
506
+ * @throws a named `FleetExhaustedError` when all `capacity` members are live.
507
+ * @throws a named `FleetDisposedError` after `dispose()`.
508
+ */
509
+ acquire(initials?: Record<PropertyKey, unknown>): T;
510
+ /**
511
+ * Park a member back into the pool. Zero allocation.
512
+ * @throws a named `FleetForeignMemberError` if `vm` was not acquired from this fleet.
513
+ * @throws a named `FleetDoubleReleaseError` if `vm` is already parked.
514
+ * @throws a named `FleetDisposedError` after `dispose()`.
515
+ */
516
+ release(vm: T): T;
517
+ /**
518
+ * The member in slot `i` (live or parked).
519
+ * @throws a `RangeError` if `i` is out of `[0, capacity)`.
520
+ * @throws a named `FleetDisposedError` after `dispose()`.
521
+ */
522
+ at(i: number): T;
523
+ /** The live member count (`capacity` minus parked). */
524
+ size(): number;
525
+ /** The fleet-owned registry's stats ledger, passed through. */
526
+ stats(): RegistryStats;
527
+ /** Dispose every member (live and parked) and destroy the registry. Idempotent. */
528
+ dispose(): void;
529
+ }
530
+
531
+ /**
532
+ * Build a fixed-capacity {@link Fleet} of reactive instances over the shipped
533
+ * primitives: `capacityFor(inventory, opts)` sizes a registry, `createRegistry`
534
+ * builds it, `bind(registry)` binds the caller's decorated class to it and returns
535
+ * it, then one member per inventory unit is EAGER-constructed and parked (acquire
536
+ * never constructs). The returned handle acquires/releases members with zero
537
+ * allocation and fails closed (named throws) on every misuse.
538
+ *
539
+ * @throws {TypeError} if `bind` is not a function or does not return a constructor.
540
+ * @throws if `inventory`/`opts` are invalid (via {@link capacityFor}).
541
+ */
542
+ export function createFleet<T extends object = object>(
543
+ inventory: InventoryEntry[],
544
+ bind: (registry: Registry) => new (...args: any[]) => T,
545
+ opts?: CreateFleetOptions,
546
+ ): Fleet<T>;
547
+
455
548
  /**
456
549
  * Toggle devtools labels (default OFF). While ON, wiring registers a
457
550
  * `nodeId -> label` for every node an instance creates (`"Class.prop"`,
@@ -503,4 +596,4 @@ export class ReactiveDisposedError extends Error {
503
596
  // --- Version ------------------------------------------------------------------
504
597
 
505
598
  /** Package version. Kept in lockstep with package.json and llms.txt. */
506
- export const VERSION: "1.0.0";
599
+ export const VERSION: "1.5.0";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators v1.3.0
2
+ * @zakkster/lite-signal-decorators v1.5.0
3
3
  * --------------------
4
4
  * Standard-decorators layer over @zakkster/lite-signal. Turns a plain class into
5
5
  * a reactive view-model with measured per-instance cost and deterministic
@@ -45,6 +45,9 @@ import {
45
45
  batch,
46
46
  untrack,
47
47
  stats,
48
+ forEachOwned,
49
+ forEachSource,
50
+ createRegistry,
48
51
  } from "@zakkster/lite-signal";
49
52
 
50
53
  // --- Module state -------------------------------------------------------------
@@ -172,6 +175,14 @@ const DEFAULT_REG = Object.freeze({
172
175
  // wiring/dispose paths use); costOf reads it here for the default registry,
173
176
  // and every custom Registry from createRegistry() exposes it natively.
174
177
  stats,
178
+ // `forEachOwned`/`forEachSource` join the facade the same way (S10): each
179
+ // registry owns its NODE_PTR symbol, so a handle is walkable ONLY by the
180
+ // registry that minted it. costOfInstance routes its walk through a plan's
181
+ // own `reg`, so the default-registry path needs these two here; a custom
182
+ // Registry from createRegistry() carries them natively. Not in REG_METHODS
183
+ // (the wiring/dispose paths never walk).
184
+ forEachOwned,
185
+ forEachSource,
175
186
  });
176
187
 
177
188
  // The 11 method names a valid Registry must expose (duck-check set, PD-11).
@@ -2132,6 +2143,122 @@ export function costOf(Factory) {
2132
2143
  return result;
2133
2144
  }
2134
2145
 
2146
+ // Module-level walk accumulators for costOfInstance. forEachOwned/forEachSource
2147
+ // call fn(descriptor) with NO carrier arg, so the visitor cannot thread state
2148
+ // through a parameter the way forEachReactive's `arg` does. Reusing three module
2149
+ // slots (never a per-call closure) keeps the frozen result the ONLY allocation
2150
+ // (PD-69). Non-reentrant by construction: a cost walk never re-enters
2151
+ // costOfInstance, so the single-threaded ESM model makes the shared slots safe.
2152
+ let COST_INSTANCE_REG = null;
2153
+ let COST_INSTANCE_OWNED = 0;
2154
+ let COST_INSTANCE_LINKS = 0;
2155
+
2156
+ // Tally one source edge (called per forEachSource visit across anchor, owned
2157
+ // nodes, and signal/local boxes).
2158
+ function costInstanceLinkVisit(node) {
2159
+ COST_INSTANCE_LINKS++;
2160
+ }
2161
+
2162
+ // Tally one owned node (a derived or user effect adopted by the anchor) and fold
2163
+ // its source edges into the link total in the same pass.
2164
+ function costInstanceOwnedVisit(node) {
2165
+ COST_INSTANCE_OWNED++;
2166
+ COST_INSTANCE_REG.forEachSource(node, costInstanceLinkVisit);
2167
+ }
2168
+
2169
+ /**
2170
+ * Measure the cost of ONE live, wired instance right now -- no probe, no
2171
+ * construction, no ctor args, no registry pollution. Returns a per-call frozen
2172
+ * `{ nodes, links, signals, locals, deriveds, effects }` in costOf's exact shape.
2173
+ * `nodes` is WALKED: 1 (the anchor) + plan.signals.length + plan.locals.length +
2174
+ * every child forEachOwned(rootOf(vm)) yields (the deriveds and user effects the
2175
+ * anchor adopted -- signal/local boxes are built pre-anchor and unadopted, so
2176
+ * they are never owned). `links` is the sum of forEachSource over the anchor,
2177
+ * every owned node, and every signal/local box, WITHOUT dedupe -- one edge per
2178
+ * observer, matching costOf's activeLinks delta. Kind counts are read from the
2179
+ * plan arrays, never walked.
2180
+ *
2181
+ * THE LIVE-VS-PROBE CONTRACT. This number is the truth NOW. costOf constructs a
2182
+ * throwaway probe and FORCES every derived (:2079) to report the constructed
2183
+ * CEILING -- "what will an instance of this class cost". costOfInstance reports
2184
+ * what THIS instance costs at this moment: an unforced lazy derived and an
2185
+ * untaken dynamic branch have formed no links yet, so `links` reads BELOW
2186
+ * costOf's for the same shape until the graph is exercised. `nodes` matches
2187
+ * regardless (owned children exist whether or not their links have formed). Read
2188
+ * every derived once and the two agree exactly (A1 parity). The delta is the
2189
+ * feature, not a bug -- fewer links means the instance has not paid for a branch
2190
+ * it has not taken.
2191
+ *
2192
+ * ALLOCATION HONESTY. The frozen result allocates by design, one object per call,
2193
+ * exactly like snapshotOf -- this is a cold introspection call, never a gated hot
2194
+ * path (PD-69). There is no out-param variant; no consumer needs one. The walk
2195
+ * itself allocates nothing (module-slot visitors, no per-call closure).
2196
+ *
2197
+ * UNCACHED (PD-70). costOf caches per class because a class shape is frozen at
2198
+ * decoration; a live instance graph MUTATES (a derived forces, a branch flips),
2199
+ * so a cached number would lie. Every call re-walks.
2200
+ *
2201
+ * WORKS WHERE costOf CANNOT (PD-72). The walk needs no stats() ledger, so
2202
+ * costOfInstance measures an instance on a hand-rolled registry that carries the
2203
+ * introspection walkers but not stats -- exactly the case costOf fails closed on
2204
+ * (:2049).
2205
+ *
2206
+ * @throws {ReactiveDisposedError} if the instance was disposed or parked -- a
2207
+ * parked vm holds ZERO nodes, and a silent `{ nodes: 0 }` is indistinguishable
2208
+ * from a bug, so both states fail closed (PD-71).
2209
+ * @throws if `vm` is not wired yet, has no reactive plan, or exposes a prewired
2210
+ * member slot.
2211
+ */
2212
+ export function costOfInstance(vm) {
2213
+ const plan = planOf(vm);
2214
+ if (plan === undefined) throwNoPlan("costOfInstance");
2215
+ const a = vm[ANCHOR];
2216
+ if (a === undefined) throwNotWired("costOfInstance");
2217
+ if (a === DISPOSED) throw new ReactiveDisposedError(plan.ctorName, "<root>");
2218
+ if (a === PARKED) throw new ReactiveDisposedError(plan.ctorName, "<root>", true);
2219
+ const reg = plan.reg;
2220
+ COST_INSTANCE_REG = reg;
2221
+ COST_INSTANCE_OWNED = 0;
2222
+ COST_INSTANCE_LINKS = 0;
2223
+ // Owned nodes = deriveds + user effects the anchor adopted; each contributes
2224
+ // its source edges to the link tally as it is visited. Signals/locals are
2225
+ // built pre-anchor (unadopted), so forEachOwned never yields them (:1234-1248).
2226
+ reg.forEachOwned(a, costInstanceOwnedVisit);
2227
+ // The anchor's own source edges.
2228
+ reg.forEachSource(a, costInstanceLinkVisit);
2229
+ // Signal + local boxes are not owned -- read each from its slot (the walker
2230
+ // idiom from forEachReactive) and fold its source edges in. A prewired slot
2231
+ // is impossible past the wired guard above, but the check fails closed if a
2232
+ // partially-built instance is ever measured.
2233
+ const sigs = plan.signals;
2234
+ for (let i = 0; i < sigs.length; i++) {
2235
+ const h = vm[sigs[i].slot];
2236
+ if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, sigs[i].key);
2237
+ reg.forEachSource(h, costInstanceLinkVisit);
2238
+ }
2239
+ const locs = plan.locals;
2240
+ for (let i = 0; i < locs.length; i++) {
2241
+ const h = vm[locs[i].slot];
2242
+ if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, locs[i].key);
2243
+ reg.forEachSource(h, costInstanceLinkVisit);
2244
+ }
2245
+ const sig = sigs.length;
2246
+ const loc = locs.length;
2247
+ const der = plan.deriveds.length;
2248
+ const eff = plan.effects.length;
2249
+ const nodes = 1 + sig + loc + COST_INSTANCE_OWNED;
2250
+ const links = COST_INSTANCE_LINKS;
2251
+ COST_INSTANCE_REG = null; // drop the registry ref (cold)
2252
+ return Object.freeze({
2253
+ nodes: nodes,
2254
+ links: links,
2255
+ signals: sig,
2256
+ locals: loc,
2257
+ deriveds: der,
2258
+ effects: eff,
2259
+ });
2260
+ }
2261
+
2135
2262
  function throwCapInventory() {
2136
2263
  throw new TypeError(
2137
2264
  `${ERR}capacityFor(inventory) -- inventory must be a non-empty array of [Factory, count] pairs.`,
@@ -2217,6 +2344,222 @@ export function capacityFor(inventory, options) {
2217
2344
  };
2218
2345
  }
2219
2346
 
2347
+ // --- createFleet (S11; the fleet helper over the shipped primitives) ----------
2348
+ //
2349
+ // The flagship-audience helper (0013 (d)): capacityFor -> createRegistry -> bind
2350
+ // -> EAGER-prefill N parked members over a slot array + an Int32Array free-list.
2351
+ // The demo's hand-rolled pool (loop.ts) is the extracted spec, so acquire/release
2352
+ // are the extracted spawn/kill: reinitReactive/releaseReactive with zero-alloc
2353
+ // bookkeeping. Cold construction; the hot acquire/release/at bodies are prebuilt
2354
+ // closures over the fleet's arrays (zero allocation, fail-closed guards only).
2355
+ //
2356
+ // SLOT STAMP (PD-77 forcing condition resolved): ownership is proven by a
2357
+ // per-fleet Symbol stamped onto each vm at prefill carrying its slot index -- a
2358
+ // plain (symbol-keyed) integer field, NOT a WeakMap. A field read is one property
2359
+ // load (zero allocation, monomorphic); a WeakMap.get is a slower hash probe AND
2360
+ // retains a parallel table for the fleet's lifetime. The stamp is minted per
2361
+ // fleet, so a foreign vm (no stamp, or another fleet's stamp) reads `undefined`
2362
+ // for THIS fleet's symbol and fails closed. slots[i] === vm re-confirms identity;
2363
+ // releaseReactive() returning false (already parked) is the double-release check.
2364
+
2365
+ function throwFleetBind() {
2366
+ throw new TypeError(
2367
+ `${ERR}createFleet(inventory, bind, opts?) -- bind must be a function (registry) -> BoundClass.`,
2368
+ );
2369
+ }
2370
+
2371
+ function throwFleetBindReturn() {
2372
+ throw new TypeError(
2373
+ `${ERR}createFleet -- bind(registry) must return the bound class (a constructor); the fleet constructs its members from it.`,
2374
+ );
2375
+ }
2376
+
2377
+ function throwFleetExhausted(ctorName, capacity) {
2378
+ // Named so a caller can catch the capacity ceiling distinctly. The registry's
2379
+ // own CapacityError is unreachable (all N are prealloc'd), so this pre-check
2380
+ // is the ONLY exhaustion signal.
2381
+ const e = new Error(
2382
+ `${ERR}fleet<${ctorName}> is exhausted -- all ${capacity} members are live. release() one before acquire(), or size the fleet larger; capacity is fixed at construction (eager prefill).`,
2383
+ );
2384
+ e.name = "FleetExhaustedError";
2385
+ throw e;
2386
+ }
2387
+
2388
+ function throwFleetForeign(ctorName) {
2389
+ const e = new Error(
2390
+ `${ERR}release(vm) -- vm was not acquired from this fleet<${ctorName}> (no matching slot stamp). Release only members this fleet handed out.`,
2391
+ );
2392
+ e.name = "FleetForeignMemberError";
2393
+ throw e;
2394
+ }
2395
+
2396
+ function throwFleetDoubleRelease(ctorName) {
2397
+ const e = new Error(
2398
+ `${ERR}release(vm) -- vm is already parked in fleet<${ctorName}> (double release). Each acquire() pairs with exactly one release().`,
2399
+ );
2400
+ e.name = "FleetDoubleReleaseError";
2401
+ throw e;
2402
+ }
2403
+
2404
+ function throwFleetDead(ctorName, op) {
2405
+ const e = new Error(
2406
+ `${ERR}${op}() -- fleet<${ctorName}> was disposed; its members are torn down and its registry is destroyed. Construct a new fleet.`,
2407
+ );
2408
+ e.name = "FleetDisposedError";
2409
+ throw e;
2410
+ }
2411
+
2412
+ function throwFleetRange(ctorName, i, capacity) {
2413
+ throw new RangeError(
2414
+ `${ERR}at(${String(i)}) -- index out of bounds for fleet<${ctorName}> [0, ${capacity}).`,
2415
+ );
2416
+ }
2417
+
2418
+ /**
2419
+ * Build a fixed-capacity fleet of reactive instances over the shipped primitives.
2420
+ * COLD construction sizes a registry from `inventory` (capacityFor), builds it
2421
+ * (createRegistry), hands it to `bind(registry)` so the caller binds its decorated
2422
+ * class to that registry and returns it, then EAGER-constructs and PARKS one
2423
+ * member per inventory unit (PD-75: acquire never constructs). Returns a Fleet
2424
+ * handle `{ registry, Class, capacity, acquire, release, at, size, stats,
2425
+ * dispose }`.
2426
+ *
2427
+ * HOT: `acquire(initials?)` pops the free-list and reinitReactive()s a parked
2428
+ * member (throws `FleetExhaustedError` at capacity); `release(vm)` validates the
2429
+ * slot stamp, releaseReactive()s the member back to the pool (throws on a foreign
2430
+ * vm or a double release), and pushes its slot. `at(i)` is a bounds-checked
2431
+ * slot read; `size()` is the live count; `stats()` passes the registry ledger
2432
+ * through. Both hot bodies allocate nothing.
2433
+ *
2434
+ * `dispose()` disposes every member (live AND parked -> DISPOSED), destroys the
2435
+ * fleet-owned registry, and marks the fleet dead; every later call fails closed
2436
+ * with a named throw. Construction is atomic: any mid-prefill throw disposes the
2437
+ * already-built members and destroys the registry before rethrowing (fail closed).
2438
+ *
2439
+ * @throws {TypeError} if `bind` is not a function or does not return a constructor.
2440
+ * @throws if `inventory`/`opts` are invalid (via capacityFor's fail-closed checks).
2441
+ */
2442
+ export function createFleet(inventory, bind, opts) {
2443
+ if (typeof bind !== "function") throwFleetBind();
2444
+ // capacityFor validates inventory + opts (unknown-key did-you-mean, headroom)
2445
+ // and returns the eager/throw config; reuse its fail-closed checks wholesale.
2446
+ const config = capacityFor(inventory, opts);
2447
+ // Total prefill count = the sum of the inventory units (each pair[1] is a
2448
+ // validated positive integer by the time capacityFor returned).
2449
+ let capacity = 0;
2450
+ for (let i = 0; i < inventory.length; i++) capacity += inventory[i][1];
2451
+
2452
+ // The fleet OWNS this registry (dispose() destroys it). Any throw from here
2453
+ // on routes through the atomic-cleanup catch below.
2454
+ const registry = createRegistry(config);
2455
+ const STAMP = Symbol("lite-signal-decorators.fleet");
2456
+ const slots = new Array(capacity);
2457
+ const free = new Int32Array(capacity); // free-list: free[0..freeTop) = idle slots
2458
+ let Class;
2459
+ let ctorName;
2460
+ let built = 0;
2461
+ try {
2462
+ Class = bind(registry);
2463
+ if (typeof Class !== "function") throwFleetBindReturn();
2464
+ ctorName = Class.name || "fleet";
2465
+ for (let i = 0; i < capacity; i++) {
2466
+ const vm = new Class(); // eager construct on the fleet's registry
2467
+ vm[STAMP] = i; // slot stamp: ownership + slot index
2468
+ releaseReactive(vm); // park it (PD-75); nodes return to pool
2469
+ slots[i] = vm;
2470
+ free[i] = i;
2471
+ built = i + 1;
2472
+ }
2473
+ } catch (e) {
2474
+ for (let j = 0; j < built; j++) disposeReactive(slots[j]);
2475
+ registry.destroy(); // tear the fleet-owned registry down
2476
+ throw e; // atomic: nothing half-built survives
2477
+ }
2478
+
2479
+ let freeTop = capacity; // all slots idle (all parked)
2480
+ let dead = false;
2481
+
2482
+ // HOT: revive the head parked slot. reinitReactive resets its boxes to
2483
+ // `initials` (undefined = the plan's reset values) with zero closure alloc.
2484
+ // ORDERING (mirrors fleetRelease): PEEK the candidate via free[freeTop-1],
2485
+ // run the FALLIBLE reinit FIRST, and decrement freeTop only AFTER it succeeds.
2486
+ // reinit throws named on bad initials (e.g. acquire({typo:1})) and on an
2487
+ // out-of-band-disposed member; decrementing before it would strand the popped
2488
+ // slot above freeTop -- lost capacity, over-reported size(). Fail closed:
2489
+ // freeTop moves only on success, so a rejected acquire leaves the slot
2490
+ // acquirable. (a) The disposed case: a member disposed out of band via at()
2491
+ // wedges at the free-list head -- every acquire rethrows reinit's named
2492
+ // "disposed (terminal)" error, refusing loudly rather than eroding capacity
2493
+ // silently. Left as (a) not (b): reinit's disposed and bad-initials throws are
2494
+ // both plain Errors with no distinct code/class, so dropping only the disposed
2495
+ // slot would need brittle message-matching -- a fail-open hazard worse than an
2496
+ // honest, named refusal.
2497
+ function fleetAcquire(initials) {
2498
+ if (dead) throwFleetDead(ctorName, "acquire");
2499
+ if (freeTop === 0) throwFleetExhausted(ctorName, capacity);
2500
+ const i = free[freeTop - 1];
2501
+ const vm = slots[i];
2502
+ reinitReactive(vm, initials);
2503
+ freeTop = freeTop - 1;
2504
+ return vm;
2505
+ }
2506
+
2507
+ // HOT: validate ownership by the slot stamp, then park. `vm[STAMP]` is
2508
+ // `undefined` for a foreign or non-object vm (fail closed); slots[i] === vm
2509
+ // re-confirms identity; releaseReactive() false is the double-release signal.
2510
+ function fleetRelease(vm) {
2511
+ if (dead) throwFleetDead(ctorName, "release");
2512
+ const i = vm !== null && typeof vm === "object" ? vm[STAMP] : undefined;
2513
+ if (i === undefined || slots[i] !== vm) throwFleetForeign(ctorName);
2514
+ if (!releaseReactive(vm)) throwFleetDoubleRelease(ctorName);
2515
+ free[freeTop] = i;
2516
+ freeTop = freeTop + 1;
2517
+ return vm;
2518
+ }
2519
+
2520
+ // HOT: bounds-checked slot read (live OR parked). `i >>> 0` folds negative and
2521
+ // out-of-range into one unsigned compare.
2522
+ function fleetAt(i) {
2523
+ if (dead) throwFleetDead(ctorName, "at");
2524
+ if ((i >>> 0) >= capacity) throwFleetRange(ctorName, i, capacity);
2525
+ return slots[i];
2526
+ }
2527
+
2528
+ function fleetSize() {
2529
+ if (dead) throwFleetDead(ctorName, "size");
2530
+ return capacity - freeTop; // live = capacity - idle
2531
+ }
2532
+
2533
+ // The fleet-owned registry always comes from createRegistry(), which always
2534
+ // carries the stats ledger, so this is a straight pass-through.
2535
+ function fleetStats() {
2536
+ if (dead) throwFleetDead(ctorName, "stats");
2537
+ return registry.stats();
2538
+ }
2539
+
2540
+ // Dispose every member (live AND parked; disposeReactive on a parked member
2541
+ // lands it DISPOSED), then destroy the registry. Idempotent: a second call
2542
+ // no-ops. After dispose, every hot method fails closed via the `dead` guard.
2543
+ function fleetDispose() {
2544
+ if (dead) return;
2545
+ dead = true;
2546
+ for (let i = 0; i < capacity; i++) disposeReactive(slots[i]);
2547
+ registry.destroy();
2548
+ }
2549
+
2550
+ return {
2551
+ registry,
2552
+ Class,
2553
+ capacity,
2554
+ acquire: fleetAcquire,
2555
+ release: fleetRelease,
2556
+ at: fleetAt,
2557
+ size: fleetSize,
2558
+ stats: fleetStats,
2559
+ dispose: fleetDispose,
2560
+ };
2561
+ }
2562
+
2220
2563
  function throwFlagArg(what) {
2221
2564
  throw new TypeError(`${ERR}${what}(on) -- on must be a boolean.`);
2222
2565
  }
@@ -2359,4 +2702,4 @@ export function auditReactive(on) {
2359
2702
  // --- Version ------------------------------------------------------------------
2360
2703
 
2361
2704
  /** Package version. Kept in lockstep with package.json and llms.txt. */
2362
- export const VERSION = "1.3.0";
2705
+ export const VERSION = "1.5.0";
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-signal-decorators
2
2
 
3
- VERSION 1.3.0
3
+ VERSION 1.5.0
4
4
 
5
5
  > Standard-decorators layer over @zakkster/lite-signal, built on the TC39
6
6
  > decorators proposal (Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit
@@ -22,7 +22,7 @@ zero decorator syntax, sharing the SAME core by function identity.
22
22
  slot for a poison handle, so any later read/write throws a named
23
23
  `ReactiveDisposedError`.
24
24
 
25
- ## Exports (21)
25
+ ## Exports (23)
26
26
 
27
27
  - `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
28
28
  (factory). Declares a per-instance signal.
@@ -116,9 +116,52 @@ slot for a poison handle, so any later read/write throws a named
116
116
  cached). Double-probed: an inconclusive or polluted probe throws, never
117
117
  guesses. `nodes` = P+L+D+E+1; `links` = the first-full-read link count. See
118
118
  "Introspection & audit".
119
+ - `costOfInstance(vm) -> { nodes, links, signals, locals, deriveds, effects }` --
120
+ the LIVE measured cost of one wired instance right now, walked from its own
121
+ graph. The delta from `costOf` IS the feature: `costOf` answers "what will an
122
+ instance of this class cost" (it forces every derived to the constructed
123
+ ceiling), `costOfInstance` answers "what does THIS instance cost right now" --
124
+ an unforced lazy derived or an untaken dynamic branch has formed no links yet,
125
+ so `links` reads BELOW `costOf` for the same shape until the graph is
126
+ exercised; `nodes` matches regardless. Read every derived once and the two
127
+ agree exactly (forced parity === costOf). UNCACHED by design -- a live graph
128
+ mutates, so a cached number would lie; every call re-walks. Allocates its
129
+ frozen result by design (one object per call, ~71 B/op, cold like snapshotOf --
130
+ reported, never gated). Needs no stats() ledger, so it measures instances on
131
+ hand-rolled registries where `costOf` fails closed. Fails closed on a
132
+ disposed/parked instance with a NAMED throw -- a parked vm holds zero nodes and
133
+ a silent `{ nodes: 0 }` is indistinguishable from a bug -- and on unwired,
134
+ no-plan, or prewired-member values. See "Introspection & audit".
119
135
  - `capacityFor(inventory, { headroom }?) -> RegistryConfig` -- size a
120
136
  `createRegistry` config from `[Factory, count]` pairs. Nodes exact, links x
121
137
  `headroom` (default 1). Fail-closed inventory validation.
138
+ - `createFleet(inventory, bind, opts?) -> Fleet` -- the flagship-audience fleet
139
+ helper over the shipped primitives (`capacityFor` + `createRegistry` +
140
+ park/reinit; decisions/0013 criterion (d)). COLD construction sizes a registry
141
+ from `inventory`, builds it, hands it to `bind(registry)` (the caller binds its
142
+ decorated class to that registry and returns it -- the helper never wraps or
143
+ redefines the class), then EAGER-constructs and PARKS one member per inventory
144
+ unit. Returns a handle `{ registry, Class, capacity, acquire(initials?),
145
+ release(vm), at(i), size(), stats(), dispose() }`. Import in the exports block:
146
+ `import { createFleet } from "@zakkster/lite-signal-decorators"`. LAWS: eager
147
+ prefill is done at construction -- `acquire` never constructs, it pops a
148
+ free-list and `reinitReactive`s a parked member (`initials` override the reset
149
+ values); `release` validates a per-fleet slot stamp and `releaseReactive`s the
150
+ member back to the pool. Both hot bodies allocate ZERO (a slot array + an
151
+ `Int32Array` free-list + a per-vm symbol slot stamp -- never a WeakMap). SIX
152
+ named fail-closed misuses: `acquire` at capacity throws `FleetExhaustedError`
153
+ (pre-checked; the registry's own `CapacityError` is unreachable, all N are
154
+ prealloc'd); `release` of a foreign vm throws `FleetForeignMemberError` (the
155
+ slot stamp is the check); a double release throws `FleetDoubleReleaseError`;
156
+ any call after `dispose()` throws `FleetDisposedError`; `at(i)` out of
157
+ `[0, capacity)` throws a `RangeError`; a `bind` that is not a function or does
158
+ not return a constructor throws a `TypeError`. Construction is ATOMIC: any
159
+ mid-prefill throw disposes the already-built members and destroys the registry
160
+ before rethrowing. `dispose()` disposes every member LIVE AND PARKED (park ->
161
+ dispose lands DISPOSED), then destroys the fleet-owned registry -- parked
162
+ members are disposed too, not leaked. `null` is not zero at any gate. The demo's
163
+ hand-rolled pool is the extracted spec (its diff went net-negative when the
164
+ helper replaced it).
122
165
  - `enableLabels(on)` / `labelOf(idOrHandle, registry?) -> string | undefined` --
123
166
  opt-in devtools labels (default OFF); per-registry `nodeId -> "Class.prop" /
124
167
  "Class#method" / "Class@anchor"`.
@@ -126,7 +169,7 @@ slot for a poison handle, so any later read/write throws a named
126
169
  `FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
127
170
  - `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
128
171
  fields `className` and `key`.
129
- - `VERSION` -- `"1.3.0"`.
172
+ - `VERSION` -- `"1.5.0"`.
130
173
 
131
174
  ## Registry law (one registry per host chain)
132
175
 
@@ -270,7 +313,12 @@ an additive MINOR -> 19 exports; the 1.0.0 canon (`makeGet`/`makeSet`/
270
313
  and pays its own measured cost. 1.3.0 adds `forEachReactive` + `snapshotOf`
271
314
  (decisions/0013 ladder) as an additive MINOR -> 21 exports; `snapshotOf` is the
272
315
  named in-package consumer that admits `forEachReactive` under the 0009 bar, and
273
- the 1.0.0 canon stays byte-identical. The semver promise from here: any change to an existing export's
316
+ the 1.0.0 canon stays byte-identical. 1.4.0 adds `costOfInstance` (decisions/0013
317
+ criterion (b); the demo is the named consumer) as an additive MINOR -> 22
318
+ exports; the 1.0.0 canon stays byte-identical. 1.5.0 adds `createFleet`
319
+ (decisions/0013 criterion (d); the demo fleet is the named consumer, its
320
+ hand-rolled pool DELETED for the helper) as an additive MINOR -> 23 exports --
321
+ the 0013 ladder CLOSES at 23; the 1.0.0 canon stays byte-identical. The semver promise from here: any change to an existing export's
274
322
  signature or behavior is a MAJOR, recorded in a decision file; new exports are
275
323
  minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
276
324
  major. Also present since
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal-decorators",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Standard-decorators layer over @zakkster/lite-signal (TC39 decorators proposal, Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit unchanged). The reactive class layer where an instance has a measured cost, deterministic teardown, and a churn benchmark.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",