@zakkster/lite-signal-decorators 1.3.0 → 1.4.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,116 @@ 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.4.0] - 2026-08-30
8
+
9
+ The measured-instance pillar -- decisions/0013 criterion (b). `costOf(Factory)`
10
+ answers "what will an instance of this class cost" by probing with NO ctor args,
11
+ so a ctor-arg-dependent shape needs a measurement-twin class to size it;
12
+ decisions/0009 candidate 4 recorded that absence plainly (r9 and the fleet demo
13
+ both paid it). `costOfInstance(vm)` closes it: it measures a LIVE, wired instance
14
+ by walking its OWN graph -- no probe, no twin, no registry pollution. The demo is
15
+ the NAMED consumer that admits it under the 0009 bar (a new export needs a named
16
+ consumer, and a recipe is not one): the console's shape-drift wall now measures a
17
+ real `Entity` and the HUD reports a live fleet member's cost per tick. The
18
+ `EntityShape` twin is honestly retained -- its remaining job is `capacityFor`
19
+ sizing only (the world must be sized before it exists). Surface 21 -> 22.
20
+
21
+ ### Added
22
+
23
+ - **`costOfInstance(vm) -> { nodes, links, signals, locals, deriveds, effects }`**
24
+ (22nd export) -- the LIVE per-instance cost, walked from the instance's own
25
+ graph: `nodes = 1 (anchor) + plan.signals.length + plan.locals.length +
26
+ forEachOwned(rootOf(vm))` (the deriveds and user effects the anchor adopted --
27
+ signal/local boxes are built pre-anchor and unadopted, so they are never
28
+ owned); `links` is the un-deduped sum of `forEachSource` over the anchor, every
29
+ owned node, and every signal/local box (one edge per observer, matching
30
+ `costOf`'s activeLinks delta); kind counts are read from the plan arrays, never
31
+ walked. THE LIVE-VS-PROBE CONTRACT is the feature: `costOf` forces every derived
32
+ to the constructed CEILING, `costOfInstance` reports what THIS instance costs
33
+ right now -- an unforced lazy derived or an untaken dynamic branch shows FEWER
34
+ links until the graph is exercised (`nodes` matches regardless; read every
35
+ derived once and the two agree exactly). UNCACHED (PD-70) -- a live graph
36
+ mutates, so a cached number would lie. Needs no `stats()` ledger (PD-72), so it
37
+ measures instances on hand-rolled registries where `costOf` fails closed.
38
+ Allocates its frozen result by design (one object per call, cold like
39
+ `snapshotOf`; PD-69, no out-param variant). Fails closed on a disposed/parked
40
+ instance with a NAMED `ReactiveDisposedError` (PD-71 -- a parked vm holds ZERO
41
+ nodes and a silent `{ nodes: 0 }` is indistinguishable from a bug) and on
42
+ unwired/no-plan/prewired-member values.
43
+ - **`test/19-cost-instance.test.mjs`** (22 cases) -- both emit lanes + buildless:
44
+ A1 parity-when-forced (=== `costOf`, nodes/links/every kind count), A2
45
+ delta-when-lazy (links strictly lower, then monotonic toward the forced
46
+ number), a `@localTo` member counted in locals contributing ZERO graph links,
47
+ the frozen `{nodes,links,signals,locals,deriveds,effects}` shape, A3
48
+ registry-untouched over 10000 calls, PD-72 bound-registry + stats-less-facade
49
+ measurement (where `costOf` fails closed), PD-70 uncached/live across a branch
50
+ flip, and the A6 fail-closed matrix (plain/unwired/parked/disposed each a NAMED
51
+ throw, never a `{nodes:0}` report).
52
+ - **The `introspection-torture` lane extended** with two `costOfInstance`
53
+ blocks: A4 -- 1e4 calls at `maxMajor 0`, `maxPauseMs <= 4.0`, the per-call
54
+ frozen result the only allocation (REPORTED, never gated); A5 -- 1000
55
+ wire/measure/park/reinit/dispose cycles with `tracker.size()` 0, `activeNodes`
56
+ to exact baseline, pool growths 0.
57
+ - **The demo consumer** (never cut -- the admission ground): the console's
58
+ shape-drift wall measures a real live `Entity` via `costOfInstance` (its node
59
+ count === the sizing twin's) and the HUD reports one live fleet member's
60
+ `costOfInstance` per HUD tick (never per frame), so the live-vs-forced delta is
61
+ visible on screen. Cold boot / HUD-tick paths only; zero frame-loop cost.
62
+
63
+ ### Changed
64
+
65
+ - **The export surface: 21 -> 22** -- an additive MINOR under the 1.0.0 semver
66
+ promise (new exports are minors). The 1.0.0 hot canon
67
+ (`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: `costOfInstance`
68
+ is cold and moves no accessor byte.
69
+ - Version sync to 1.4.0 across FOUR sites now: `package.json`, the `VERSION`
70
+ const, `llms.txt` line 3, and the `SignalDecorators.d.ts` VERSION literal --
71
+ the last a NEW asserted sync site. The `.d.ts` literal had gone stale (it read
72
+ a prior version, escaping the three-place sweep since the d.ts VERSION line was
73
+ never gated); the owner caught it, so `test/15`'s VERSION-consistency test
74
+ gains a FOURTH leg that regexes the `.d.ts` literal and asserts it string-equals
75
+ `package.json`, killing that bug class.
76
+ - `test/15` surface-freeze recount 21 -> 22 (all CB-A2 sites).
77
+
78
+ ### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
79
+
80
+ - A1 parity (forced): `costOfInstance(vm)` === `costOf(Factory)` for the same
81
+ shape once every derived is read once -- demo `Entity` nodes **7**, links
82
+ **3** (P2/L1/D2/E1), both paths identical; kind counts identical.
83
+ - A2 delta (lazy): a fresh instance reads links **1 -> 2 -> 3** as its deriveds
84
+ are exercised, strictly below the forced number until the graph is exercised;
85
+ node counts equal throughout.
86
+ - A standalone `@localTo` member contributes **ZERO** graph links (the upstream
87
+ compare is a plain per-instance slot, not an edge) -- measured, not assumed; it
88
+ counts in `locals` only.
89
+ - 1e4 `costOfInstance` calls: **71.3 B/op** (the per-call frozen result), gc
90
+ major **0**, `maxPauseMs <= 4.0` -- REPORTED, never gated.
91
+ - A3: 10000 calls leave the registry `stats()` snapshot byte-identical
92
+ (`activeNodes`/`activeLinks`/`totalDisposals` unchanged) -- the walk never
93
+ mutates the registry.
94
+ - A5: 1000 wire/measure/park/reinit/dispose cycles -- `tracker.size()` 0,
95
+ `activeNodes` to exact baseline, pool growths 0.
96
+
97
+ Records: decisions/0013 (strategic-admission track, criterion (b)),
98
+ decisions/0009 (candidate 4, now stamped ADMITTED with the pre-admission absence
99
+ preserved).
100
+
101
+ ### Gate output (section-10 chain, archived verbatim)
102
+
103
+ ```
104
+ fixtures OK exit 0 -- emit fixtures regenerated
105
+ test OK exit 0 -- 335 pass / 0 fail
106
+ test:gc OK exit 0 -- 335 pass / 0 fail
107
+ torture OK exit 0 -- 16 passed, 2 skipped, 0 warned, 0 failed in 34.3s
108
+ torture:controls OK exit 0 -- 18 passed, 0 skipped, 0 warned, 0 failed in 3.6s
109
+ 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]
110
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
111
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.1s
112
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
113
+ ----------------------------------------------------------------------
114
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
115
+ ```
116
+
7
117
  ## [1.3.0] - 2026-08-30
8
118
 
9
119
  The introspection/migration rung of the decisions/0013 strategic-admission
package/README.md CHANGED
@@ -147,6 +147,7 @@ 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.
150
151
 
151
152
  ---
152
153
 
@@ -270,10 +271,20 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
270
271
  | Export | Signature | Behavior |
271
272
  |---|---|---|
272
273
  | `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. |
274
+ | `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
275
  | `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
276
  | `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
277
  | `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
278
 
279
+ The `costOf`/`costOfInstance` split reads as a class-vs-instance pair:
280
+
281
+ ```js
282
+ const cls = costOf(Enemy); // ceiling: every derived forced
283
+ const fresh = costOfInstance(inst); // fresh.links < cls.links (a lazy derived unread)
284
+ inst.threat; inst.range; // exercise the deriveds, then re-measure
285
+ costOfInstance(inst).links === cls.links; // now exact
286
+ ```
287
+
277
288
  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
289
 
279
290
  ### Introspection walk & snapshot (1.3.0)
@@ -288,7 +299,7 @@ With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- th
288
299
  | Export | Value |
289
300
  |---|---|
290
301
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
291
- | `VERSION` | `"1.3.0"` |
302
+ | `VERSION` | `"1.4.0"` |
292
303
 
293
304
  ### The rejection matrix
294
305
 
@@ -408,10 +419,11 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
408
419
  | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
409
420
  | `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
421
  | `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 |
422
+ | `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 |
411
423
 
412
- The gates that hold it (run on every change, all green at 1.3.0):
424
+ The gates that hold it (run on every change, all green at 1.4.0):
413
425
 
414
- - `npm test` / `npm run test:gc` -- **313/313** on both lanes.
426
+ - `npm test` / `npm run test:gc` -- **335/335** on both lanes.
415
427
  - 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
428
  - 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.
417
429
  - `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.
@@ -445,12 +457,12 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
445
457
  ## Testing (for clients & QA)
446
458
 
447
459
  ```bash
448
- npm test # node --test, 313 tests
449
- npm run test:gc # the same 313 with --expose-gc (enables the allocation assertions)
460
+ npm test # node --test, 335 tests
461
+ npm run test:gc # the same 335 with --expose-gc (enables the allocation assertions)
450
462
  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
463
  ```
452
464
 
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.
465
+ **335 tests** across nineteen files, all green at 1.4.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
466
 
455
467
  | File | Tests | Covers |
456
468
  |---|---:|---|
@@ -468,10 +480,11 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
468
480
  | `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
469
481
  | `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
470
482
  | `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 |
483
+ | `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 22 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
484
  | `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
485
  | `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
486
  | `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 |
487
+ | `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) |
475
488
 
476
489
  ### Emit-support matrix
477
490
 
@@ -528,6 +541,8 @@ npm run demo:gc # headless GC-budget lane over the fleet core (maxMa
528
541
  npm run demo:storm # headless dispose-storm retention lane (lite-leak, size 0)
529
542
  ```
530
543
 
544
+ 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).
545
+
531
546
  The `demo/` directory is dev-only -- it never enters `package.json` `files[]` and never ships to consumers.
532
547
 
533
548
  ---
@@ -602,7 +617,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
602
617
 
603
618
  ### The cookbook
604
619
 
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).
620
+ [`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
621
 
607
622
  ---
608
623
 
@@ -425,6 +425,34 @@ export interface ReactiveCost {
425
425
  */
426
426
  export function costOf(Factory: new (...args: any[]) => any): Readonly<ReactiveCost>;
427
427
 
428
+ /**
429
+ * Measure the cost of ONE live, wired instance right now -- no probe, no
430
+ * construction, no ctor args, no registry pollution. Returns a per-call frozen
431
+ * `ReactiveCost` in costOf's exact shape, WALKED from the live graph:
432
+ * `nodes = 1 + signals + locals + forEachOwned(rootOf(vm))` (the deriveds and
433
+ * user effects the anchor adopted), and `links` is the un-deduped sum of
434
+ * forEachSource over the anchor, every owned node, and every signal/local box.
435
+ *
436
+ * THE LIVE-VS-PROBE CONTRACT. This number is the truth NOW. costOf forces every
437
+ * derived to report the constructed CEILING; costOfInstance reports what THIS
438
+ * instance costs at this moment, so an unforced lazy derived or an untaken
439
+ * dynamic branch shows FEWER links than costOf for the same shape until the graph
440
+ * is exercised. `nodes` matches regardless. Read every derived once and the two
441
+ * agree exactly. The delta is the feature, not a bug.
442
+ *
443
+ * The frozen result allocates by design, one object per call (UNCACHED -- a live
444
+ * graph mutates, so a cached number would lie). The walk needs no stats() ledger,
445
+ * so costOfInstance measures instances on registries where costOf fails closed.
446
+ *
447
+ * @param vm a live, wired reactive instance.
448
+ * @throws {ReactiveDisposedError} if `vm` was disposed or parked (a parked vm
449
+ * holds zero nodes; a silent `{ nodes: 0 }` would be indistinguishable from a
450
+ * bug, so both fail closed).
451
+ * @throws if `vm` is not wired yet, has no reactive plan, or exposes a prewired
452
+ * member slot.
453
+ */
454
+ export function costOfInstance(vm: object): Readonly<ReactiveCost>;
455
+
428
456
  /** A `[Factory, count]` pair for {@link capacityFor}. */
429
457
  export type InventoryEntry = [new (...args: any[]) => any, number];
430
458
 
@@ -503,4 +531,4 @@ export class ReactiveDisposedError extends Error {
503
531
  // --- Version ------------------------------------------------------------------
504
532
 
505
533
  /** Package version. Kept in lockstep with package.json and llms.txt. */
506
- export const VERSION: "1.0.0";
534
+ export const VERSION: "1.4.0";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators v1.3.0
2
+ * @zakkster/lite-signal-decorators v1.4.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,8 @@ import {
45
45
  batch,
46
46
  untrack,
47
47
  stats,
48
+ forEachOwned,
49
+ forEachSource,
48
50
  } from "@zakkster/lite-signal";
49
51
 
50
52
  // --- Module state -------------------------------------------------------------
@@ -172,6 +174,14 @@ const DEFAULT_REG = Object.freeze({
172
174
  // wiring/dispose paths use); costOf reads it here for the default registry,
173
175
  // and every custom Registry from createRegistry() exposes it natively.
174
176
  stats,
177
+ // `forEachOwned`/`forEachSource` join the facade the same way (S10): each
178
+ // registry owns its NODE_PTR symbol, so a handle is walkable ONLY by the
179
+ // registry that minted it. costOfInstance routes its walk through a plan's
180
+ // own `reg`, so the default-registry path needs these two here; a custom
181
+ // Registry from createRegistry() carries them natively. Not in REG_METHODS
182
+ // (the wiring/dispose paths never walk).
183
+ forEachOwned,
184
+ forEachSource,
175
185
  });
176
186
 
177
187
  // The 11 method names a valid Registry must expose (duck-check set, PD-11).
@@ -2132,6 +2142,122 @@ export function costOf(Factory) {
2132
2142
  return result;
2133
2143
  }
2134
2144
 
2145
+ // Module-level walk accumulators for costOfInstance. forEachOwned/forEachSource
2146
+ // call fn(descriptor) with NO carrier arg, so the visitor cannot thread state
2147
+ // through a parameter the way forEachReactive's `arg` does. Reusing three module
2148
+ // slots (never a per-call closure) keeps the frozen result the ONLY allocation
2149
+ // (PD-69). Non-reentrant by construction: a cost walk never re-enters
2150
+ // costOfInstance, so the single-threaded ESM model makes the shared slots safe.
2151
+ let COST_INSTANCE_REG = null;
2152
+ let COST_INSTANCE_OWNED = 0;
2153
+ let COST_INSTANCE_LINKS = 0;
2154
+
2155
+ // Tally one source edge (called per forEachSource visit across anchor, owned
2156
+ // nodes, and signal/local boxes).
2157
+ function costInstanceLinkVisit(node) {
2158
+ COST_INSTANCE_LINKS++;
2159
+ }
2160
+
2161
+ // Tally one owned node (a derived or user effect adopted by the anchor) and fold
2162
+ // its source edges into the link total in the same pass.
2163
+ function costInstanceOwnedVisit(node) {
2164
+ COST_INSTANCE_OWNED++;
2165
+ COST_INSTANCE_REG.forEachSource(node, costInstanceLinkVisit);
2166
+ }
2167
+
2168
+ /**
2169
+ * Measure the cost of ONE live, wired instance right now -- no probe, no
2170
+ * construction, no ctor args, no registry pollution. Returns a per-call frozen
2171
+ * `{ nodes, links, signals, locals, deriveds, effects }` in costOf's exact shape.
2172
+ * `nodes` is WALKED: 1 (the anchor) + plan.signals.length + plan.locals.length +
2173
+ * every child forEachOwned(rootOf(vm)) yields (the deriveds and user effects the
2174
+ * anchor adopted -- signal/local boxes are built pre-anchor and unadopted, so
2175
+ * they are never owned). `links` is the sum of forEachSource over the anchor,
2176
+ * every owned node, and every signal/local box, WITHOUT dedupe -- one edge per
2177
+ * observer, matching costOf's activeLinks delta. Kind counts are read from the
2178
+ * plan arrays, never walked.
2179
+ *
2180
+ * THE LIVE-VS-PROBE CONTRACT. This number is the truth NOW. costOf constructs a
2181
+ * throwaway probe and FORCES every derived (:2079) to report the constructed
2182
+ * CEILING -- "what will an instance of this class cost". costOfInstance reports
2183
+ * what THIS instance costs at this moment: an unforced lazy derived and an
2184
+ * untaken dynamic branch have formed no links yet, so `links` reads BELOW
2185
+ * costOf's for the same shape until the graph is exercised. `nodes` matches
2186
+ * regardless (owned children exist whether or not their links have formed). Read
2187
+ * every derived once and the two agree exactly (A1 parity). The delta is the
2188
+ * feature, not a bug -- fewer links means the instance has not paid for a branch
2189
+ * it has not taken.
2190
+ *
2191
+ * ALLOCATION HONESTY. The frozen result allocates by design, one object per call,
2192
+ * exactly like snapshotOf -- this is a cold introspection call, never a gated hot
2193
+ * path (PD-69). There is no out-param variant; no consumer needs one. The walk
2194
+ * itself allocates nothing (module-slot visitors, no per-call closure).
2195
+ *
2196
+ * UNCACHED (PD-70). costOf caches per class because a class shape is frozen at
2197
+ * decoration; a live instance graph MUTATES (a derived forces, a branch flips),
2198
+ * so a cached number would lie. Every call re-walks.
2199
+ *
2200
+ * WORKS WHERE costOf CANNOT (PD-72). The walk needs no stats() ledger, so
2201
+ * costOfInstance measures an instance on a hand-rolled registry that carries the
2202
+ * introspection walkers but not stats -- exactly the case costOf fails closed on
2203
+ * (:2049).
2204
+ *
2205
+ * @throws {ReactiveDisposedError} if the instance was disposed or parked -- a
2206
+ * parked vm holds ZERO nodes, and a silent `{ nodes: 0 }` is indistinguishable
2207
+ * from a bug, so both states fail closed (PD-71).
2208
+ * @throws if `vm` is not wired yet, has no reactive plan, or exposes a prewired
2209
+ * member slot.
2210
+ */
2211
+ export function costOfInstance(vm) {
2212
+ const plan = planOf(vm);
2213
+ if (plan === undefined) throwNoPlan("costOfInstance");
2214
+ const a = vm[ANCHOR];
2215
+ if (a === undefined) throwNotWired("costOfInstance");
2216
+ if (a === DISPOSED) throw new ReactiveDisposedError(plan.ctorName, "<root>");
2217
+ if (a === PARKED) throw new ReactiveDisposedError(plan.ctorName, "<root>", true);
2218
+ const reg = plan.reg;
2219
+ COST_INSTANCE_REG = reg;
2220
+ COST_INSTANCE_OWNED = 0;
2221
+ COST_INSTANCE_LINKS = 0;
2222
+ // Owned nodes = deriveds + user effects the anchor adopted; each contributes
2223
+ // its source edges to the link tally as it is visited. Signals/locals are
2224
+ // built pre-anchor (unadopted), so forEachOwned never yields them (:1234-1248).
2225
+ reg.forEachOwned(a, costInstanceOwnedVisit);
2226
+ // The anchor's own source edges.
2227
+ reg.forEachSource(a, costInstanceLinkVisit);
2228
+ // Signal + local boxes are not owned -- read each from its slot (the walker
2229
+ // idiom from forEachReactive) and fold its source edges in. A prewired slot
2230
+ // is impossible past the wired guard above, but the check fails closed if a
2231
+ // partially-built instance is ever measured.
2232
+ const sigs = plan.signals;
2233
+ for (let i = 0; i < sigs.length; i++) {
2234
+ const h = vm[sigs[i].slot];
2235
+ if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, sigs[i].key);
2236
+ reg.forEachSource(h, costInstanceLinkVisit);
2237
+ }
2238
+ const locs = plan.locals;
2239
+ for (let i = 0; i < locs.length; i++) {
2240
+ const h = vm[locs[i].slot];
2241
+ if (h !== undefined && h[NONLIVE] === "prewired") throwPrewiredMember(plan.ctorName, locs[i].key);
2242
+ reg.forEachSource(h, costInstanceLinkVisit);
2243
+ }
2244
+ const sig = sigs.length;
2245
+ const loc = locs.length;
2246
+ const der = plan.deriveds.length;
2247
+ const eff = plan.effects.length;
2248
+ const nodes = 1 + sig + loc + COST_INSTANCE_OWNED;
2249
+ const links = COST_INSTANCE_LINKS;
2250
+ COST_INSTANCE_REG = null; // drop the registry ref (cold)
2251
+ return Object.freeze({
2252
+ nodes: nodes,
2253
+ links: links,
2254
+ signals: sig,
2255
+ locals: loc,
2256
+ deriveds: der,
2257
+ effects: eff,
2258
+ });
2259
+ }
2260
+
2135
2261
  function throwCapInventory() {
2136
2262
  throw new TypeError(
2137
2263
  `${ERR}capacityFor(inventory) -- inventory must be a non-empty array of [Factory, count] pairs.`,
@@ -2359,4 +2485,4 @@ export function auditReactive(on) {
2359
2485
  // --- Version ------------------------------------------------------------------
2360
2486
 
2361
2487
  /** Package version. Kept in lockstep with package.json and llms.txt. */
2362
- export const VERSION = "1.3.0";
2488
+ export const VERSION = "1.4.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.4.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 (22)
26
26
 
27
27
  - `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
28
28
  (factory). Declares a per-instance signal.
@@ -116,6 +116,22 @@ 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.
@@ -126,7 +142,7 @@ slot for a poison handle, so any later read/write throws a named
126
142
  `FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
127
143
  - `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
128
144
  fields `className` and `key`.
129
- - `VERSION` -- `"1.3.0"`.
145
+ - `VERSION` -- `"1.4.0"`.
130
146
 
131
147
  ## Registry law (one registry per host chain)
132
148
 
@@ -270,7 +286,9 @@ an additive MINOR -> 19 exports; the 1.0.0 canon (`makeGet`/`makeSet`/
270
286
  and pays its own measured cost. 1.3.0 adds `forEachReactive` + `snapshotOf`
271
287
  (decisions/0013 ladder) as an additive MINOR -> 21 exports; `snapshotOf` is the
272
288
  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
289
+ the 1.0.0 canon stays byte-identical. 1.4.0 adds `costOfInstance` (decisions/0013
290
+ criterion (b); the demo is the named consumer) as an additive MINOR -> 22
291
+ exports; the 1.0.0 canon stays byte-identical. The semver promise from here: any change to an existing export's
274
292
  signature or behavior is a MAJOR, recorded in a decision file; new exports are
275
293
  minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
276
294
  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.4.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",