bireactive 0.2.3 → 0.3.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.
Files changed (98) hide show
  1. package/dist/animation/anim.js +4 -0
  2. package/dist/coll.d.ts +7 -7
  3. package/dist/coll.js +3 -1
  4. package/dist/core/cell.d.ts +89 -66
  5. package/dist/core/cell.js +642 -401
  6. package/dist/core/index.d.ts +4 -14
  7. package/dist/core/index.js +4 -14
  8. package/dist/core/lenses/aggregates.d.ts +1 -1
  9. package/dist/core/lenses/aggregates.js +4 -3
  10. package/dist/core/lenses/closed-form-policies.js +6 -6
  11. package/dist/core/lenses/decompositions.js +3 -3
  12. package/dist/core/lenses/domain-aggregates.js +5 -5
  13. package/dist/core/lenses/geometry.d.ts +1 -1
  14. package/dist/core/lenses/geometry.js +6 -7
  15. package/dist/core/lenses/memory.d.ts +2 -2
  16. package/dist/core/lenses/memory.js +3 -3
  17. package/dist/core/lenses/typed-factor.js +4 -3
  18. package/dist/core/traits.d.ts +1 -0
  19. package/dist/core/values/box.js +7 -7
  20. package/dist/core/values/color.js +5 -5
  21. package/dist/core/values/field.d.ts +70 -0
  22. package/dist/core/values/field.js +230 -0
  23. package/dist/core/values/gpu.d.ts +4 -2
  24. package/dist/core/values/gpu.js +11 -4
  25. package/dist/core/values/matrix.js +7 -7
  26. package/dist/core/values/num.d.ts +1 -1
  27. package/dist/core/values/num.js +1 -1
  28. package/dist/core/values/pose.js +4 -4
  29. package/dist/core/values/range.js +6 -6
  30. package/dist/core/values/template.d.ts +1 -1
  31. package/dist/core/values/template.js +2 -1
  32. package/dist/core/values/transform.js +7 -7
  33. package/dist/core/values/tri.js +3 -3
  34. package/dist/core/values/vec.js +8 -12
  35. package/dist/ext/timeline.js +2 -2
  36. package/dist/formats/cst.d.ts +127 -0
  37. package/dist/formats/cst.js +280 -0
  38. package/dist/formats/edn.d.ts +2 -0
  39. package/dist/formats/edn.js +301 -0
  40. package/dist/formats/index.d.ts +6 -0
  41. package/dist/formats/index.js +8 -0
  42. package/dist/formats/json.d.ts +2 -0
  43. package/dist/formats/json.js +332 -0
  44. package/dist/formats/lens.d.ts +8 -0
  45. package/dist/formats/lens.js +54 -0
  46. package/dist/formats/toml.d.ts +2 -0
  47. package/dist/formats/toml.js +526 -0
  48. package/dist/formats/yaml.d.ts +2 -0
  49. package/dist/formats/yaml.js +661 -0
  50. package/dist/index.d.ts +10 -0
  51. package/dist/index.js +10 -0
  52. package/dist/learn/data.d.ts +49 -0
  53. package/dist/learn/data.js +181 -0
  54. package/dist/learn/index.d.ts +3 -0
  55. package/dist/learn/index.js +6 -0
  56. package/dist/learn/lens-net.d.ts +63 -0
  57. package/dist/learn/lens-net.js +219 -0
  58. package/dist/learn/mlp.d.ts +77 -0
  59. package/dist/learn/mlp.js +292 -0
  60. package/dist/propagators/csp.d.ts +13 -0
  61. package/dist/propagators/csp.js +52 -0
  62. package/dist/propagators/flex.d.ts +31 -0
  63. package/dist/propagators/flex.js +189 -0
  64. package/dist/propagators/graph.d.ts +73 -0
  65. package/dist/propagators/graph.js +543 -0
  66. package/dist/propagators/index.d.ts +8 -6
  67. package/dist/propagators/index.js +15 -6
  68. package/dist/propagators/lattice.d.ts +45 -0
  69. package/dist/propagators/lattice.js +113 -0
  70. package/dist/propagators/layout.d.ts +1 -27
  71. package/dist/propagators/layout.js +6 -175
  72. package/dist/propagators/numeric.d.ts +17 -0
  73. package/dist/propagators/numeric.js +93 -0
  74. package/dist/propagators/solver.d.ts +51 -0
  75. package/dist/propagators/solver.js +175 -0
  76. package/dist/schema/index.d.ts +1 -0
  77. package/dist/schema/index.js +3 -0
  78. package/dist/schema/lens.d.ts +121 -0
  79. package/dist/schema/lens.js +429 -0
  80. package/dist/shapes/annular-sector.js +4 -4
  81. package/dist/shapes/button.js +1 -1
  82. package/dist/shapes/circle.js +1 -1
  83. package/dist/shapes/handle.js +2 -2
  84. package/dist/shapes/label.js +1 -1
  85. package/dist/shapes/layout.js +2 -2
  86. package/dist/shapes/rect.js +7 -7
  87. package/dist/shapes/shape.js +8 -8
  88. package/dist/tex/tex.js +9 -2
  89. package/dist/web/diagram.js +2 -2
  90. package/package.json +9 -19
  91. package/dist/propagators/network.d.ts +0 -52
  92. package/dist/propagators/network.js +0 -185
  93. package/dist/propagators/propagator.d.ts +0 -12
  94. package/dist/propagators/propagator.js +0 -16
  95. package/dist/propagators/range.d.ts +0 -45
  96. package/dist/propagators/range.js +0 -147
  97. package/dist/propagators/relations.d.ts +0 -60
  98. package/dist/propagators/relations.js +0 -343
package/dist/core/cell.js CHANGED
@@ -2,26 +2,62 @@
2
2
  //
3
3
  // Forward propagation is alien-signals verbatim (link/propagate/
4
4
  // checkDirty/shallowPropagate, Dirty/Pending/Recursed flags, lazy pull).
5
- // Backward is not a second engine: a write "compiles" a view-edit into
6
- // source-edits by walking up `_bwdParent`, applying each lens's `put` to
7
- // compute what the source(s) must become, committing via the SAME
8
- // forward write path. So views are never sticky (a view is always
9
- // `get(source)`; lossy lenses snap), no-op deltas short-circuit for free
10
- // via equality, and backward cost ≤ forward cost.
11
5
  //
12
- // Duals:
13
- // * merge N→1 backward aggregation (dual of computed's N→1 forward).
14
- // Contributions land in a slot map keyed by contributor identity,
15
- // fold via a user policy, reset per settle.
16
- // * multi-parent lens a write that SPLITS across N parents
17
- // (`_put(target)` per-parent update array, `propagateSplit`); the
18
- // dual of a getter reading N parents. Covers coupled writables
19
- // (N→M, e.g. mean/diff). Info the source can't hold lives in a
20
- // stateful-lens complement, not a bespoke engine kind.
6
+ // Backward is the SAME shape as forward — a lazy push-pull — not a second
7
+ // engine, and it carries NO dynamic side tables: state lives on flags plus two
8
+ // STATIC adjacencies that mirror forward's `deps`/`subs`. The lens graph is
9
+ // stored both ways at construction: `_bwd.parent` is the down edge (a view's
10
+ // declared parents, the dual of `deps`) and `_lensSubs` is its transpose, the
11
+ // up edge (a cell's direct lens-children, the dual of `subs`). Backward is then
12
+ // the forward engine run on this reverse graph — one traversal each direction:
21
13
  //
22
- // Core asymmetry: forward deps are IMPLICIT (auto-tracked reads of
23
- // `.value` under `activeSub`); backward targets are EXPLICIT (declared
24
- // at construction in `_bwdParent`). Hence no `activeBwdWrite` global.
14
+ // role forward (source view) backward (view source)
15
+ // ---- ------------------------ ------------------------
16
+ // down edge subs (who reads me) _bwd.parent (my parents)
17
+ // up edge deps (my deps) _lensSubs (my lens-children)
18
+ // push (mark) propagate (down `subs`) markDown (down `_bwd.parent`)
19
+ // pull (resolve) checkDirty (up `deps`) resolveCone (up `_lensSubs`)
20
+ // commit/compute _update / getter writeBack
21
+ // "dirty" flag F.Dirty (source staged) BF.Dirty (view holds target)
22
+ // "pending" flag F.Pending (on the cone) BF.Pending (on the back-path)
23
+ //
24
+ // Forward flags live on `flags`, backward on a SEPARATE `bflags` word — the two
25
+ // engines never share a bit, so a forward reset can't disturb a back-write.
26
+ //
27
+ // Forward: a source write PUSHES dirtiness down the cone (`Pending`, cheap
28
+ // flagging) and the value is PULLED up on read (`checkDirty` ascends `deps` to
29
+ // the `Dirty` source). Backward is the exact dual. A write to a view PUSHES
30
+ // (`markDown`): stash the `target` on the view itself (`pendingValue` +
31
+ // `BF.Dirty`, the dual of a source's `F.Dirty`), descend the static backward
32
+ // path flagging each node down to its sources `BF.Pending` (the dual of
33
+ // `F.Pending`), and wake each source's forward cone so observers re-fire. No `put`
34
+ // runs, no source moves. The work is PULLED per read, source-CENTRIC: a read
35
+ // that reaches a back-marked cell `backResolve`s — for a source directly, or by
36
+ // DESCENDING a view's marked back-path to its sources first. Each source then
37
+ // `resolveCone`s: ASCEND `_lensSubs` through the `BF.Pending` cone to the armed
38
+ // views (the dual of `checkDirty` ascending `deps`) and `writeBack` each,
39
+ // applying its lens's `put` and staging the source via the SAME forward
40
+ // `_writeSource`. A source reflects ALL its writers (they compose into one
41
+ // committed value), so a source's cone resolves together and commits once.
42
+ // Because resolution follows only the sources the read DEPENDS on, reading one
43
+ // chain leaves unrelated sources armed (GRANULAR — does only the work a read
44
+ // demands); overlapping co-writers on a shared source all compose; an
45
+ // UNOBSERVED write does no backward work; re-writing a view before any read
46
+ // keeps only the last target. Reads pull at clean entry points
47
+ // (getter top, source `_update`/`_writeSource`, effect `_run`) — never
48
+ // mid-compute, so a source-reading `put` never re-enters a half-computed cell.
49
+ // Views are never sticky (a view is always `get(source)`; lossy lenses snap),
50
+ // no-op deltas short-circuit via equality (the GetPut law).
51
+ //
52
+ // The ONE irreducibly non-dual ingredient is fan-in accumulation: where the
53
+ // forward engine broadcasts one source to N readers, the backward engine
54
+ // ACCUMULATES N contributors into one value (the transpose of fan-out). A
55
+ // `merge` folds POST-ORDER inside `resolveCone`: its contributors are resolved
56
+ // first (each cascades a `put` into the node's `MergeNode`), then it folds ONCE
57
+ // by the node's policy (default last-writer-wins) and writes to its parent — the
58
+ // dual of a forward getter reading its deps then computing. Everything else —
59
+ // 1→1 chains, multi-parent splits (1→N / N→M, e.g. mean/diff), complement-
60
+ // carrying stateful lenses — resolves during the walk.
25
61
  //
26
62
  // Mode table — a cell's role is fully determined by which fields are set:
27
63
  // source getter undefined (truth in currentValue)
@@ -31,15 +67,12 @@
31
67
  // merge getter + _bwd{ merge } (N→1 backward fold)
32
68
  // stateful getter + _bwd{ put, parent, stateful } (complement-carrying)
33
69
  // A cell is writable iff `_bwd !== undefined` (the backward sidecar; see
34
- // `BwdSpec`). `pendingValue` is dual-keyed: a staged forward write for a
35
- // source, a deferred backward target for a getter cell (never both).
36
- //
37
- // Batching: outside a batch a write propagates backward eagerly and
38
- // flushes (alien's synchronous per-write semantics). Inside batch/flush,
39
- // lens writes deposit their latest value and queue (last-write-wins via
40
- // `_queueIdx`) and merge folds defer until all contributors land; the
41
- // flush loop alternates bwd-drain / effect-drain to a fixpoint.
42
- // Flag bits (alien-signals v2).
70
+ // `BwdSpec`). `pendingValue` is a source's staged forward write only; a getter
71
+ // cell never uses it.
72
+ // Forward flag bits (alien-signals v2), on `flags`. Backward state lives in a
73
+ // SEPARATE word (`bflags`, `BF` below) so the two engines never share a bit: a
74
+ // forward recompute that resets `flags` can't disturb a pending back-write, and
75
+ // vice versa the duals are fully decoupled (no preservation hacks).
43
76
  const F = {
44
77
  None: 0,
45
78
  Mutable: 1,
@@ -48,9 +81,74 @@ const F = {
48
81
  Recursed: 8,
49
82
  Dirty: 16,
50
83
  Pending: 32,
51
- /** Backward-only: cell has a pending backward contribution queued. */
52
- BwdQueued: 64,
53
84
  };
85
+ // Backward flag bits, on a Cell's own `bflags` word (the dual of `flags`).
86
+ const BF = {
87
+ None: 0,
88
+ /** Backward dual of `F.Dirty`: this VIEW holds an unresolved back-write target
89
+ * in `pendingValue` (a getter cell never uses that field forward). The root
90
+ * of a pending back-write; `writeBack` consumes the target and clears it. */
91
+ Dirty: 1,
92
+ /** Backward dual of `F.Pending`: this node lies on the back-path from a
93
+ * `BF.Dirty` view down to its sources. `markDown` sets it on descent; it gates
94
+ * `resolveCone`, which ascends `_lensSubs` only through `BF.Pending` nodes (so
95
+ * a read resolves only its own back-cone), and `writeBack` clears it on the
96
+ * way back down. */
97
+ Pending: 2,
98
+ };
99
+ // Named mask (legibility): a cell's whole backward state in one test.
100
+ /** Armed root OR on a back-path — i.e. a read must `backResolve` first. */
101
+ const BACK_MARKED = BF.Dirty | BF.Pending;
102
+ /** Multi-out / stateful back-write sentinel: "leave this parent untouched."
103
+ * A `bwd` returning per-parent updates yields `SKIP` for a parent it declines
104
+ * to write; every other slot value is written verbatim — INCLUDING `undefined`,
105
+ * which is a first-class cell value, not a hole. A SHORT array skips the trailing
106
+ * parents (so writing only the leading few needs no `SKIP` padding); `[]` skips
107
+ * all. (Single-out 1→1 `put` has no skip notion: it always writes its one parent,
108
+ * so it stays `undefined`-safe by construction.) */
109
+ export const SKIP = Symbol("bireactive.SKIP");
110
+ // Mode predicates — the single place a cell's role is read off its fields (see
111
+ // the mode table by `BwdSpec`). V8 inlines these; the backward walk reads them
112
+ // instead of duplicating `getter`/`_bwd` field probes.
113
+ /** Source (truth leaf): no forward derivation. */
114
+ function isSource(c) {
115
+ return c.getter === undefined;
116
+ }
117
+ /** Writable: carries a backward sidecar (lens / multi-out / merge / stateful / pin). */
118
+ function isWritable(c) {
119
+ return c._bwd !== undefined;
120
+ }
121
+ /** Read-only derived: a `derive` with no backward path. A split routes around
122
+ * it; a sole parent has nowhere to land (the back-walk throws). */
123
+ function isReadOnlyDerived(c) {
124
+ return !isSource(c) && !isWritable(c);
125
+ }
126
+ /** Forward primal a source-reading `bwd` linearizes at, WITHOUT a cascading
127
+ * recompute (the lazy dual of reverse-mode AD reusing a stored linearization
128
+ * point). Source or realized derived → its live/last-settled value (PutGet
129
+ * holds for any source state, so a stale primal still round-trips); unrealized
130
+ * derived (`Dirty`) → realize once via `.value`, seeding `currentValue`. */
131
+ function backPrimal(c) {
132
+ if (c.getter === undefined || c.flags & F.Dirty)
133
+ return c.value;
134
+ return c.currentValue;
135
+ }
136
+ /** Register `node` on each backward parent's `_lensSubs` (the edge `resolveCone`
137
+ * ascends), once, lazily on the first back-write — so a lens only ever read
138
+ * forward never allocates it. Idempotent via `_linkedBack`; persists for life. */
139
+ function linkBack(node) {
140
+ if (node._linkedBack)
141
+ return;
142
+ node._linkedBack = true;
143
+ const parent = node._bwd.parent; // set for every mode except `pin` (parentless)
144
+ if (parent === undefined)
145
+ return;
146
+ if (Array.isArray(parent))
147
+ for (let i = 0; i < parent.length; i++)
148
+ (parent[i]._lensSubs ??= []).push(node);
149
+ else
150
+ (parent._lensSubs ??= []).push(node);
151
+ }
54
152
  let cycle = 0;
55
153
  let runDepth = 0;
56
154
  let batchDepth = 0;
@@ -58,14 +156,26 @@ let notifyIndex = 0;
58
156
  let queuedLength = 0;
59
157
  let activeSub;
60
158
  let flushing = false;
159
+ /** A microtask is queued to flush the effect queue. Effects run ASYNCHRONOUSLY
160
+ * (end of the current microtask turn), so a burst of synchronous writes wakes
161
+ * each effect at most once — no `batch()` needed for coalescing. Reads stay
162
+ * synchronous and pull-based (a `.value` reflects writes immediately); only
163
+ * EFFECT side-effects defer. `settle()` forces a synchronous flush. */
164
+ let scheduled = false;
165
+ /** A `network` (constraint solver / explicit reactive sub-DAG) is in the queue.
166
+ * Networks are EAGER — a write that wakes one flushes the whole queue
167
+ * synchronously, so a read right after the write sees post-solve state (and any
168
+ * effect woken alongside runs after the network, in queue order). Only writes
169
+ * that wake effects ALONE defer to the microtask. */
170
+ let networkQueued = false;
61
171
  /** Network running its body, if any. Source writes self-exclude it so a
62
172
  * network reading+writing a cell doesn't re-trigger itself. */
63
173
  let activeNetwork;
64
174
  const queued = [];
175
+ /** Re-entrancy guard: while a back-resolve runs, a `put`'s source read commits
176
+ * normally (in-order composition) but must NOT trigger a nested resolve. */
177
+ let draining = false;
65
178
  const EMPTY_DIRTY = new Set();
66
- /** Backward worklist: lens cells with deferred writes, merge cells
67
- * awaiting fold. Drained to a fixpoint with effects by flush. */
68
- const bwdQueue = [];
69
179
  // Fires on every SOURCE value-change (the one place truth mutates).
70
180
  // Backward writes reach it via `_writeSource`, attributing lens edits to
71
181
  // the source they resolve to.
@@ -207,7 +317,15 @@ function checkDirty(startLink, startSub) {
207
317
  const flags = dep.flags;
208
318
  if (sub.flags & F.Dirty)
209
319
  dirty = true;
210
- else if ((flags & (F.Mutable | F.Dirty)) === (F.Mutable | F.Dirty)) {
320
+ else if ((flags & (F.Mutable | F.Dirty)) === (F.Mutable | F.Dirty) ||
321
+ // A back-`Pending` SOURCE (leaf: no getter) looks unchanged until its
322
+ // back-write resolves; `_update` resolves it (pulls its registered views,
323
+ // runs the `put`s), then reports whether it moved — like a forward-`Dirty`
324
+ // source. Intermediate views are also back-`Pending`, but they have a
325
+ // getter and fall through to the `Pending` recurse below.
326
+ (flags & F.Mutable &&
327
+ dep.bflags & BF.Pending &&
328
+ isSource(dep))) {
211
329
  const subs = dep.subs;
212
330
  if (dep._update()) {
213
331
  if (subs.nextSub !== undefined)
@@ -232,11 +350,18 @@ function checkDirty(startLink, startSub) {
232
350
  while (checkDepth--) {
233
351
  l = stack.value;
234
352
  stack = stack.prev;
235
- if (dirty) {
353
+ // `dirty` tracks change down THIS branch, but a node may have been marked
354
+ // `F.Dirty` independently — `shallowPropagate` fires when a lazy back-write
355
+ // commits its source mid-`checkDirty` (a hazard a forward-only engine never
356
+ // sees, since sources commit before the pull). Honor that bit too, else we'd
357
+ // clear its `F.Pending` below without recomputing — stranding a `Dirty` node
358
+ // whose observers never re-run.
359
+ if (dirty || sub.flags & F.Dirty) {
236
360
  const subs = sub.subs;
237
361
  if (sub._update()) {
238
362
  if (subs.nextSub !== undefined)
239
363
  shallowPropagate(subs);
364
+ dirty = true;
240
365
  sub = l.sub;
241
366
  continue;
242
367
  }
@@ -289,41 +414,15 @@ function disposeAllDepsInReverse(sub) {
289
414
  l = prev;
290
415
  }
291
416
  }
292
- export const DIRECT_SLOT = Symbol("merge:direct-slot");
293
417
  class MergeNode {
294
- parent;
295
- policy;
296
- slots = new Map();
297
- hasIncrementalAcc;
298
- acc;
299
- constructor(parent, policy) {
300
- this.parent = parent;
301
- this.policy = policy;
302
- this.hasIncrementalAcc = policy.remove !== undefined;
303
- this.acc = policy.identity;
304
- }
305
- receive(slot, next) {
306
- if (this.hasIncrementalAcc) {
307
- const remove = this.policy.remove;
308
- const prior = this.slots.get(slot);
309
- if (prior === undefined)
310
- this.acc = this.policy.combine(this.acc, next);
311
- else
312
- this.acc = this.policy.combine(remove(this.acc, prior), next);
313
- }
314
- this.slots.set(slot, next);
315
- }
316
- fold() {
317
- if (this.hasIncrementalAcc)
318
- return this.acc;
319
- let acc = this.policy.identity;
320
- for (const v of this.slots.values())
321
- acc = this.policy.combine(acc, v);
322
- return acc;
323
- }
324
- reset() {
325
- this.slots.clear();
326
- this.acc = this.policy.identity;
418
+ foldFn;
419
+ /** Contributions gathered as this merge's cone resolves; folded and cleared
420
+ * in `foldMerge` (the merge-owned buffer, mutated in place). The parent it
421
+ * writes to is just `b.parent` (a merge's `b.parent` IS its fold target), so
422
+ * this node carries only the policy + buffer — no duplicate edge. */
423
+ contributions = [];
424
+ constructor(fold) {
425
+ this.foldFn = fold;
327
426
  }
328
427
  }
329
428
  // BwdSpec — the backward sidecar.
@@ -340,23 +439,20 @@ class MergeNode {
340
439
  // stays distinctly typed: `merge` (the N→1 fold node) and `stateful` (the
341
440
  // complement machinery of a complement-carrying lens). Both are rare, so
342
441
  // a plain 1→1 / multi-out lens leaves them `undefined` and pays only
343
- // `parent` + `put` + `queueIdx`.
442
+ // `parent` + `put`.
344
443
  class BwdSpec {
345
444
  /** Backward target(s): one `Cell` (1→1 / merge) or `Cell[]` (multi-out). */
346
445
  parent = undefined;
347
- /** Lens `put` — backward derivation (dual of `getter`). Always called by
348
- * the engine in 1-arg form `put(target)`; a source-reading lens bakes
349
- * `settled(parent)` into this closure at build time. Multi-output:
350
- * returns a per-parent update array. Stateful: the spec's `bwd`. */
446
+ /** Lens `put` — backward derivation (dual of `getter`). A 1→1 / multi-out
447
+ * lens is called as `put(target)` (a source-reading lens reads the current
448
+ * parent(s) at walk time, untracked); multi-out returns a per-parent update
449
+ * array. Stateful is the spec's `bwd`, called `put(target, sources, c)`. */
351
450
  // biome-ignore lint/suspicious/noExplicitAny: put fn is opaque shape
352
451
  put = undefined;
353
452
  /** Backward aggregation node; presence IS the merge-mode discriminant. */
354
453
  merge = undefined;
355
454
  /** Complement machinery; presence IS the stateful-mode discriminant. */
356
455
  stateful = undefined;
357
- /** Index in `bwdQueue` of this cell's latest push; the drain skips stale
358
- * entries so each cell propagates backward once per flush, last-write. */
359
- queueIdx = -1;
360
456
  }
361
457
  /** Runtime state of a stateful (complement-carrying) lens — the rare
362
458
  * backward mode, kept off `BwdSpec` so plain lenses don't carry its slots.
@@ -365,22 +461,21 @@ class BwdSpec {
365
461
  class StatefulCore {
366
462
  /** Engine-owned memory the view discards. */
367
463
  complement;
368
- /** Forward projection `fwd(sources, complement) → view`. */
369
- // biome-ignore lint/suspicious/noExplicitAny: opaque fwd shape
370
- fwd;
371
- /** Advance the complement: `step(sources, complement, external)`. */
464
+ /** Advance the complement: `step(sources, complement, external)`. (The
465
+ * forward projection `fwd` is captured directly in the getter closure — it
466
+ * is only ever read there — so it costs no slot here.) */
372
467
  // biome-ignore lint/suspicious/noExplicitAny: opaque step shape
373
468
  step;
374
- /** Source values last written back (own-vs-external test); `undefined`
375
- * until the first back-write. */
376
- lastBwd = undefined;
469
+ /** Sources this lens last committed back (the own-vs-external test compares
470
+ * live sources against these); `undefined` until the first back-write. A
471
+ * back-write reads the live sources into a fresh array, builds the committed
472
+ * candidate in place, and keeps it as `last` for the next own-vs-external
473
+ * comparison. */
474
+ last = undefined;
377
475
  constructor(complement,
378
- // biome-ignore lint/suspicious/noExplicitAny: opaque fwd shape
379
- fwd,
380
476
  // biome-ignore lint/suspicious/noExplicitAny: opaque step shape
381
477
  step) {
382
478
  this.complement = complement;
383
- this.fwd = fwd;
384
479
  this.step = step;
385
480
  }
386
481
  }
@@ -414,34 +509,40 @@ export const isLens = (v) => v instanceof Cell && v.getter !== undefined && v._b
414
509
  /** Read-only mode: derived with no backward path. */
415
510
  export const isReadonly = (v) => v instanceof Cell && v.getter !== undefined && v._bwd === undefined;
416
511
  export class Cell {
417
- flags = F.Mutable;
512
+ /** @internal */
513
+ flags;
514
+ /** @internal */
418
515
  subs;
516
+ /** @internal */
419
517
  subsTail;
518
+ /** @internal */
420
519
  deps;
520
+ /** @internal */
421
521
  depsTail;
422
- /** Forward derivation (computed/lens/merge). `undefined` ⇒ source. */
522
+ /** @internal Forward derivation (computed/lens/merge). `undefined` ⇒ source. */
423
523
  getter;
424
- /** Per-instance equality, always defined (defaults to `Object.is` at
425
- * construction) so hot paths call it without an `undefined` branch. */
524
+ /** @internal Per-instance equality; always defined (defaults to `Object.is`). */
426
525
  _equals;
427
- /** First-subscriber / last-subscriber lifecycle hooks. */
526
+ /** @internal First-subscriber / last-subscriber lifecycle hooks. */
428
527
  _watched;
528
+ /** @internal */
429
529
  _unwatchedHook;
430
- /** Source: `currentValue` = committed, `pendingValue` = staged write.
431
- * Getter cell: `currentValue` = last derived cache, `pendingValue`
432
- * reused as the deferred backward target (see `set value`). The two
433
- * roles never coexist, so two fields suffice for four. */
530
+ /** @internal Source: committed value + staged write. */
434
531
  currentValue;
532
+ /** @internal */
435
533
  pendingValue;
436
- /** Backward sidecar: target(s) + lens closures + queue slot, or
437
- * `undefined` for a read-only cell (source or computed). Allocated only
438
- * for writable derived cells, keeping the common node lean. Writability
439
- * is exactly `_bwd !== undefined`. See `BwdSpec`. */
534
+ /** @internal Backward sidecar; `undefined` iff read-only. Writability is `_bwd !== undefined`. */
440
535
  _bwd;
536
+ /** @internal Backward dual of `subs`: direct lens-children for back-write cone traversal. */
537
+ _lensSubs;
538
+ /** @internal Backward flag word (`BF`), dual of forward `flags`. */
539
+ bflags;
540
+ /** @internal Guards against `linkBack` re-registering a duplicate in `_lensSubs`. */
541
+ _linkedBack;
542
+ // Every slot is assigned exactly once below, in declaration order, for a stable
543
+ // V8 hidden class across all variants (source / computed / lens / merge).
441
544
  constructor(initial, opts) {
442
- this.currentValue = initial;
443
- this.pendingValue = initial;
444
- // Pre-init every optional slot for a stable V8 hidden class across variants.
545
+ this.flags = F.Mutable;
445
546
  this.subs = undefined;
446
547
  this.subsTail = undefined;
447
548
  this.deps = undefined;
@@ -450,7 +551,12 @@ export class Cell {
450
551
  this._equals = Object.is;
451
552
  this._watched = undefined;
452
553
  this._unwatchedHook = undefined;
554
+ this.currentValue = initial;
555
+ this.pendingValue = initial;
453
556
  this._bwd = undefined;
557
+ this._lensSubs = undefined;
558
+ this.bflags = BF.None;
559
+ this._linkedBack = false;
454
560
  if (opts !== undefined) {
455
561
  if (opts.equals !== undefined)
456
562
  this._equals = opts.equals;
@@ -460,14 +566,12 @@ export class Cell {
460
566
  this._unwatchedHook = opts.unwatched;
461
567
  }
462
568
  }
463
- _enqueueBwd() {
464
- this.flags |= F.BwdQueued;
465
- this._bwd.queueIdx = bwdQueue.length;
466
- bwdQueue.push(this);
467
- }
468
- /** Source write (alien's signal setter). Self-excludes the active
469
- * network so a body writing its own dep doesn't re-trigger itself. */
569
+ /** @internal Single write-commit point; self-excludes the active network. */
470
570
  _writeSource(next) {
571
+ // A forward write to a source with an unresolved back-write demand resolves
572
+ // it FIRST, so the later forward write wins (LWW).
573
+ if (this.bflags & BF.Pending && !draining)
574
+ backResolve(this);
471
575
  const prev = this.pendingValue;
472
576
  this.pendingValue = next;
473
577
  if (!this._equals(prev, next)) {
@@ -475,15 +579,15 @@ export class Cell {
475
579
  if (writeHook !== undefined)
476
580
  writeHook(this);
477
581
  const subs = this.subs;
478
- if (subs !== undefined)
582
+ if (subs !== undefined) {
479
583
  propagate(subs, runDepth > 0, activeNetwork);
480
- if (batchDepth === 0 && !flushing && subs !== undefined)
481
- flush();
584
+ autoFlush();
585
+ }
482
586
  }
483
587
  }
588
+ /** @internal */
484
589
  _update() {
485
590
  if (this.getter !== undefined) {
486
- // Computed/lens/merge: re-run the forward derivation.
487
591
  this.depsTail = undefined;
488
592
  this.flags = F.Mutable | F.RecursedCheck;
489
593
  const prev = activeSub;
@@ -502,12 +606,20 @@ export class Cell {
502
606
  purgeDeps(this);
503
607
  }
504
608
  }
609
+ // A back-`Pending` source first resolves its armed back-write (`backResolve`
610
+ // pulls the views registered on it, runs the `put`s, stages it via
611
+ // `_writeSource`) — so its `pendingValue` reflects the back-write before we
612
+ // commit it.
613
+ if (this.bflags & BF.Pending && !draining)
614
+ backResolve(this);
505
615
  this.flags = F.Mutable;
506
616
  const prevV = this.currentValue;
507
617
  this.currentValue = this.pendingValue;
508
618
  return !this._equals(prevV, this.currentValue);
509
619
  }
620
+ /** @internal */
510
621
  _notify() { }
622
+ /** @internal */
511
623
  _unwatched() {
512
624
  if (this.getter !== undefined && this.depsTail !== undefined) {
513
625
  this.flags = F.Mutable | F.Dirty;
@@ -527,10 +639,6 @@ export class Cell {
527
639
  activeSub = prev;
528
640
  }
529
641
  }
530
- /** Guard: silent coercion to string/number is almost always a bug. */
531
- [Symbol.toPrimitive](hint) {
532
- throw new TypeError(`Cell cannot be coerced to ${hint} — use \`.value\``);
533
- }
534
642
  // Construction helpers build via `new this()` so a subclass static
535
643
  // (`Vec.lens(...)`) yields a `Vec` with its constructor-set equality.
536
644
  // Every lens has a structural backward target (`_bwd.parent`), which is
@@ -545,10 +653,11 @@ export class Cell {
545
653
  derive(fn) {
546
654
  return buildDerived(this.constructor, () => fn(this.value));
547
655
  }
548
- /** Backward-aggregating node bwd dual of computed. Forward, the
549
- * identity view of its parent; backward, folds contributions from
550
- * upstream lenses (slot-keyed) and direct writes (DIRECT_SLOT). */
551
- merge(policy) {
656
+ /** Backward fan-in node. Forward, the identity view of its parent;
657
+ * backward, the convergence point where N contributors (upstream lenses
658
+ * and direct writes) fold into one value for the parent. `fold` is handed
659
+ * every live push at once; omitted, it is last-writer-wins. */
660
+ merge(fold) {
552
661
  if (this.getter !== undefined && this._bwd === undefined) {
553
662
  throw new TypeError("merge: receiver is read-only");
554
663
  }
@@ -558,7 +667,7 @@ export class Cell {
558
667
  cell.getter = () => parent.value;
559
668
  const b = (cell._bwd = new BwdSpec());
560
669
  b.parent = parent;
561
- b.merge = new MergeNode(parent, policy);
670
+ b.merge = new MergeNode(fold);
562
671
  return cell;
563
672
  }
564
673
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
@@ -575,10 +684,10 @@ export class Cell {
575
684
  static is(v) {
576
685
  return v instanceof this;
577
686
  }
578
- /** Lift `Val<Inner<Cls>>` → `Cls`: instance → identity, RO cell →
687
+ /** Coerce `Val<Inner<Cls>>` → `Cls`: instance → identity, RO cell →
579
688
  * tracked `derive`, literal → fresh seed. */
580
689
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
581
- static from(v) {
690
+ static coerce(v) {
582
691
  if (v instanceof this)
583
692
  return v;
584
693
  if (v instanceof Cell) {
@@ -594,27 +703,25 @@ export class Cell {
594
703
  const cell = new this();
595
704
  cell.flags = F.Mutable | F.Dirty;
596
705
  cell.getter = () => v;
597
- const b = (cell._bwd = new BwdSpec());
598
- b.put = () => undefined; // absorb (no parent sink)
706
+ // Parentless `_bwd`: `writeBack` absorbs at `parent === undefined` before any
707
+ // `put`, so the sink needs no closure writability is just `_bwd !== undefined`.
708
+ cell._bwd = new BwdSpec();
599
709
  return cell;
600
710
  }
601
- /** Typed field lens onto `parent.value[key]`. A read-only computed
602
- * parent yields a RO derive view; any writable parent yields a
603
- * bidirectional field lens with spread-replace `put`. */
604
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
605
- static fieldOf(
606
- // biome-ignore lint/suspicious/noExplicitAny: parent is contravariant on put
607
- parent, key, Cls) {
608
- const ctor = Cls;
609
- const get = (s) => s[key];
610
- // Read-only computed: a getter with no backward sidecar.
611
- const ro = parent.getter !== undefined && parent._bwd === undefined;
612
- if (ro) {
613
- return buildDerived(ctor, () => get(parent.value));
614
- }
615
- // Spread-replace reads the current source ⇒ source-reading (lens) form.
616
- return buildLens1(ctor, parent, get, (v, s) => ({ ...s, [key]: v }), true);
711
+ }
712
+ /** Typed field lens onto `parent.value[key]`. RO parent RO derive;
713
+ * writable parent → bidirectional lens with spread-replace `put`. */
714
+ // biome-ignore lint/suspicious/noExplicitAny: variance escape
715
+ export function fieldOf(
716
+ // biome-ignore lint/suspicious/noExplicitAny: parent is contravariant on put
717
+ parent, key, Cls) {
718
+ const ctor = Cls;
719
+ const get = (s) => s[key];
720
+ const ro = parent.getter !== undefined && parent._bwd === undefined;
721
+ if (ro) {
722
+ return buildDerived(ctor, () => get(parent.value));
617
723
  }
724
+ return buildLens1(ctor, parent, get, (v, s) => ({ ...s, [key]: v }), true);
618
725
  }
619
726
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
620
727
  function buildDerived(Cls, getter) {
@@ -629,9 +736,11 @@ function buildLens1(Cls, parent, fwd, bwd, readsSource) {
629
736
  cell.flags = F.Mutable | F.Dirty;
630
737
  cell.getter = (() => fwd(parent.value));
631
738
  const b = (cell._bwd = new BwdSpec());
632
- // Source-reading lenses bake the (non-committing) current source into the
633
- // closure so the engine always calls the 1-arg form (no arity branch).
634
- b.put = readsSource ? (t) => bwd(t, settled(parent)) : bwd;
739
+ // Source-reading lenses linearize at the parent's primal (`backPrimal`: the
740
+ // last-settled value for a derived, the staged truth for a source) so the
741
+ // engine always calls the 1-arg form (no arity branch) and never recomputes a
742
+ // derived parent's cone just to read it back.
743
+ b.put = readsSource ? (t) => bwd(t, backPrimal(parent)) : bwd;
635
744
  b.parent = parent;
636
745
  return cell;
637
746
  }
@@ -650,13 +759,21 @@ function buildLensN(Cls, parents, fwd, bwd, readsSource) {
650
759
  return cell; // read-only derive-N
651
760
  const b = (cell._bwd = new BwdSpec());
652
761
  b.parent = parents;
653
- b.put = readsSource
654
- ? (target) => {
762
+ if (readsSource) {
763
+ // Own reused buffer, NOT the getter's `vals`: the walk reads each parent's
764
+ // primal (`backPrimal`), so a separate array avoids aliasing the getter's.
765
+ // `bwd` consumes it synchronously and must not retain it (same as `fwd`);
766
+ // re-entry through the same lens is impossible (the lens graph is a DAG).
767
+ const args = new Array(n);
768
+ b.put = (target) => {
655
769
  for (let i = 0; i < n; i++)
656
- vals[i] = parents[i].peek();
657
- return bwd(target, vals);
658
- }
659
- : (target) => bwd(target);
770
+ args[i] = backPrimal(parents[i]);
771
+ return bwd(target, args);
772
+ };
773
+ }
774
+ else {
775
+ b.put = (target) => bwd(target);
776
+ }
660
777
  return cell;
661
778
  }
662
779
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
@@ -671,7 +788,8 @@ spec) {
671
788
  const seed = new Array(n);
672
789
  for (let i = 0; i < n; i++)
673
790
  seed[i] = parents[i].peek();
674
- const sc = (b.stateful = new StatefulCore(spec.init(seed), spec.fwd, spec.step));
791
+ const sc = (b.stateful = new StatefulCore(spec.init(seed), spec.step));
792
+ const fwd = spec.fwd;
675
793
  b.put = spec.bwd;
676
794
  b.parent = parents;
677
795
  cell.getter = (() => {
@@ -680,7 +798,7 @@ spec) {
680
798
  // External unless the live sources still equal this lens's own last
681
799
  // back-write.
682
800
  let external = true;
683
- const lb = sc.lastBwd;
801
+ const lb = sc.last;
684
802
  if (lb !== undefined) {
685
803
  external = false;
686
804
  for (let i = 0; i < n; i++) {
@@ -691,7 +809,7 @@ spec) {
691
809
  }
692
810
  }
693
811
  sc.complement = sc.step(vals, sc.complement, external);
694
- return sc.fwd(vals, sc.complement);
812
+ return fwd(vals, sc.complement);
695
813
  });
696
814
  return cell;
697
815
  }
@@ -707,16 +825,17 @@ spec) {
707
825
  const cell = new Cls();
708
826
  cell.flags = F.Mutable | F.Dirty;
709
827
  const b = (cell._bwd = new BwdSpec());
710
- const sc = (b.stateful = new StatefulCore(spec.init([parent.peek()]), spec.fwd, spec.step));
828
+ const sc = (b.stateful = new StatefulCore(spec.init([parent.peek()]), spec.step));
829
+ const fwd = spec.fwd;
711
830
  b.put = spec.bwd;
712
831
  b.parent = [parent];
713
832
  const vals = [undefined];
714
833
  cell.getter = (() => {
715
834
  const v = (vals[0] = parent.value);
716
- const lb = sc.lastBwd;
835
+ const lb = sc.last;
717
836
  const external = lb === undefined || lb[0] !== v;
718
837
  sc.complement = sc.step(vals, sc.complement, external);
719
- return sc.fwd(vals, sc.complement);
838
+ return fwd(vals, sc.complement);
720
839
  });
721
840
  return cell;
722
841
  }
@@ -746,9 +865,17 @@ function dispatchLens(ctor, args) {
746
865
  return buildLensN(ctor, parent, a, b, readsSource);
747
866
  return buildLens1(ctor, parent, a, b, readsSource);
748
867
  }
749
- // Install `value` on the prototype (for silly TypeScript inference reasons I'd like to avoid if we can figure it out).
868
+ // Installed on the prototype (not a class accessor): V8 JITs a prototype getter
869
+ // better, and it keeps the field-only class shape for a stable hidden class.
750
870
  Object.defineProperty(Cell.prototype, "value", {
751
871
  get() {
872
+ // Reading is the PULL. A back-marked cell resolves at this clean entry,
873
+ // BEFORE its own compute, so a source-reading `put` never re-enters a
874
+ // half-computed cell. `backResolve` descends `_bwd` to the sources and pulls
875
+ // the writers registered there, touching only this cell's back-cone
876
+ // (granular — sibling chains untouched).
877
+ if (this.bflags & BACK_MARKED && !draining)
878
+ backResolve(this);
752
879
  const flags = this.flags;
753
880
  if (this.getter !== undefined) {
754
881
  if (flags & F.RecursedCheck) {
@@ -763,26 +890,11 @@ Object.defineProperty(Cell.prototype, "value", {
763
890
  shallowPropagate(subs);
764
891
  }
765
892
  }
766
- else if (!flags) {
767
- // First read: lazy init.
768
- this.flags = F.Mutable | F.RecursedCheck;
769
- const prev = activeSub;
770
- activeSub = this;
771
- let threw = true;
772
- try {
773
- this.currentValue = this.getter();
774
- threw = false;
775
- }
776
- finally {
777
- activeSub = prev;
778
- this.flags = threw ? F.Mutable | F.Dirty : this.flags & ~F.RecursedCheck;
779
- }
780
- }
781
893
  if (activeSub !== undefined)
782
894
  link(this, activeSub, cycle);
783
895
  return this.currentValue;
784
896
  }
785
- // Cell path.
897
+ // Source path.
786
898
  if (flags & F.Dirty) {
787
899
  this.flags = F.Mutable;
788
900
  const prevV = this.currentValue;
@@ -802,228 +914,339 @@ Object.defineProperty(Cell.prototype, "value", {
802
914
  this._writeSource(next);
803
915
  return;
804
916
  }
805
- // Backward write. Deferred while batching/flushing so repeated writes
806
- // coalesce (last-write-wins) and merge folds wait for all
807
- // contributors; eager + synchronous otherwise. Exception: inside a
808
- // network body writes are eager so the body's fixpoint loop observes
809
- // its own edits via `peek()` between steps.
810
917
  const b = this._bwd;
811
918
  if (b === undefined) {
812
919
  throw new TypeError("Cannot write to a computed");
813
920
  }
814
- const deferred = (batchDepth > 0 || flushing) && activeNetwork === undefined;
815
- if (b.merge !== undefined) {
816
- b.merge.receive(DIRECT_SLOT, next);
817
- if (deferred)
818
- this._enqueueBwd();
819
- else
820
- bwdUntracked(this, undefined, false);
921
+ // GetPut for a multi-parent split: its `put` may move sources even when the
922
+ // view is unchanged (a lossy redistribution that `_writeSource`'s per-source
923
+ // equality can't catch), so absorb a write that maps to the current view
924
+ // here. (Stateful excluded — peeking would step its complement.)
925
+ if (Array.isArray(b.parent) && b.stateful === undefined && this._equals(next, this.peek())) {
821
926
  return;
822
927
  }
823
- if (deferred && (Array.isArray(b.parent) || b.stateful !== undefined)) {
824
- // Multi-parent / stateful: defer to flush so a split coalesces, a
825
- // merge folds after all contributors land, and a complement steps
826
- // once. Reuse `pendingValue` (unused by a getter's forward path) as
827
- // the deferred backward target; drained by flush. Entry no-op vs the
828
- // current view (GetPut) skips the walk.
829
- if (this._equals(next, this.peek()))
830
- return;
831
- this.pendingValue = next;
832
- this._enqueueBwd();
833
- return;
834
- }
835
- // Single-parent lens: run the walk now. Inside a batch `_writeSource`
836
- // stages the source (Dirty + pending) and defers only the flush, so
837
- // the view reads back consistently and a later write supersedes via
838
- // the source's pending value — last-write-wins, no queue, no lost
839
- // revert. We must NOT peek the view here when batching: that would
840
- // commit the source's pending value and break net-zero revert
841
- // coalescing — `propagateBwd`'s `settled` no-op stop prunes a true
842
- // no-op without committing. Outside a batch, the O(1) GetPut check is
843
- // safe (no source is staged) and worth keeping.
844
- if (!deferred && this._equals(next, this.peek()))
845
- return;
846
- bwdUntracked(this, next, deferred);
928
+ arm(this, next);
847
929
  },
848
930
  enumerable: false,
849
931
  configurable: false,
850
932
  });
851
- // Backward pass (propagateBwd).
852
- //
853
- // Walk up `_bwdParent`, applying `put` at each lens / folding at each
854
- // merge, until a source is committed (via the forward write path) or a
855
- // parent merge is reached. `deferred` (inside batch/flush) stops at a
856
- // parent merge after depositing so it folds once all contributors land;
857
- // eager folds merges inline. Not a second engine: every path terminates
858
- // in `_writeSource`.
859
- // Backward evaluation runs UNTRACKED so `bwd`/`step`/`fwd` reads don't
860
- // establish forward deps on whatever `activeSub` is writing (e.g. an
861
- // effect that writes a lens). All backward entry points route through here.
862
- function bwdUntracked(cell, target, deferred) {
863
- const prev = activeSub;
864
- activeSub = undefined;
865
- try {
866
- propagateBwd(cell, target, deferred);
867
- }
868
- finally {
869
- activeSub = prev;
933
+ /** Backward push: arm a back-write of `target` on view `node` (the dual of a
934
+ * source `set`). A re-write of a still-armed view (an unobserved drag) keeps
935
+ * only the last target the path down to the sources is already marked, so
936
+ * skip the walk and coalesce. The trailing `schedule` wakes the effects the
937
+ * push woke (on the microtask turn); each effect's read pulls its own cone. */
938
+ function arm(node, target) {
939
+ if (!(node.bflags & BF.Dirty)) {
940
+ markDown(node); // flag path + wake cones, FIRST (a throw arms nothing)
941
+ node.bflags |= BF.Dirty;
870
942
  }
943
+ node.pendingValue = target; // the view holds its own demand (getters ignore this field)
944
+ autoFlush();
871
945
  }
872
- /** A cell's current value for the backward pass's internal no-op checks,
873
- * WITHOUT side effects. A source staged earlier in this batch reads its
874
- * pending value directly; reading it via `peek` would COMMIT the pending
875
- * value (`_update`: currentValue = pendingValue), so a later net-zero
876
- * revert would look like a real change and over-fire downstream. A
877
- * non-source (lens/computed) has no such hazard and recomputes via peek. */
878
- function settled(cell) {
879
- return cell.getter === undefined && (cell.flags & F.Dirty) !== 0
880
- ? cell.pendingValue
881
- : cell.peek();
882
- }
883
- function propagateBwd(start, target, deferred) {
884
- let cell = start;
885
- let v = target;
886
- while (true) {
887
- // Multi-parent lens: SPLIT the write into each parent (dual of a
888
- // getter reading N parents — the `put` yields N upstream values).
889
- const cb = cell._bwd;
890
- const parent = cb.parent;
891
- if (Array.isArray(parent)) {
892
- propagateSplit(cell, v, deferred);
893
- return;
946
+ /** MARK (push), dual of forward `propagate`: descend `start`'s static backward
947
+ * path down `_bwd.parent` to its sources, flag each node `BF.Pending`, register
948
+ * the reverse edge (`linkBack`), and wake every source's forward cone. Runs no
949
+ * `put` an over-approximation `resolveCone` later resolves precisely.
950
+ *
951
+ * `BF.Pending` is its own dedup: an already-marked node has its whole (static)
952
+ * subtree marked, so descent stops there (diamonds cost one visit). A merge
953
+ * relays to its parent; a sole read-only-derived parent has nowhere to land →
954
+ * throw. The 1→1 spine allocates nothing (`stack` is built only on a branch). */
955
+ function markDown(start) {
956
+ let node = start;
957
+ let stack;
958
+ for (;;) {
959
+ let next;
960
+ if (isSource(node)) {
961
+ // Leaf (dual of a `Dirty` source): wake its cone ONCE.
962
+ if (!(node.bflags & BF.Pending)) {
963
+ node.bflags |= BF.Pending;
964
+ const subs = node.subs;
965
+ if (subs !== undefined)
966
+ propagate(subs, runDepth > 0, activeNetwork);
967
+ }
894
968
  }
895
- let push;
896
- if (cb.merge !== undefined) {
897
- const node = cb.merge;
898
- push = node.fold();
899
- node.reset();
969
+ else if (node === start || !(node.bflags & BF.Pending)) {
970
+ // On the back-path (dual of a `Pending` intermediate). An already-marked
971
+ // intermediate (≠ start) has its subtree marked — stop (diamond dedup).
972
+ if (node !== start)
973
+ node.bflags |= BF.Pending;
974
+ linkBack(node); // register the reverse edge lazily, on first back-write
975
+ // `b.parent` is the back-target for EVERY mode (a merge's fold target is
976
+ // just its single, always-writable parent), so one descent covers all.
977
+ const parent = node._bwd.parent;
978
+ if (parent !== undefined) {
979
+ if (Array.isArray(parent)) {
980
+ const multi = parent.length > 1;
981
+ for (let i = 0; i < parent.length; i++) {
982
+ const p = parent[i];
983
+ if (isReadOnlyDerived(p)) {
984
+ // A split routes around it; a sole parent can't.
985
+ if (!multi)
986
+ throw new TypeError("Cannot write through to a computed");
987
+ }
988
+ else if (next === undefined)
989
+ next = p;
990
+ else
991
+ (stack ??= []).push(p);
992
+ }
993
+ }
994
+ else if (isReadOnlyDerived(parent)) {
995
+ throw new TypeError("Cannot write through to a computed");
996
+ }
997
+ else {
998
+ next = parent;
999
+ }
1000
+ }
900
1001
  }
901
- else {
902
- // Single-arg always: a source-reading lens baked `settled(parent)`
903
- // into `put` at build time (see `buildLens1`); `pin` ignores `v`.
904
- push = cb.put(v);
1002
+ if (next !== undefined) {
1003
+ node = next;
905
1004
  }
906
- // Parentless lens (e.g. `pin`): no upstream, write absorbed. Sink.
907
- if (parent === undefined)
908
- return;
909
- // Concrete no-op stop: if the parent already holds `push`, committing
910
- // changes nothing upstream, so the walk stops. Sound for ANY topology
911
- // (no speculation). A lossy lens hides an off-grid edit by returning
912
- // the current source from `put`. Merge parents fold instead. `settled`
913
- // reads a batched source's pending value WITHOUT committing it, so a
914
- // net-zero revert leaves the source unchanged and downstream un-fired.
915
- const pb = parent._bwd;
916
- const parentMerge = pb !== undefined ? pb.merge : undefined;
917
- if (parentMerge === undefined && parent._equals(push, settled(parent)))
918
- return;
919
- if (parentMerge !== undefined) {
920
- parentMerge.receive(cell, push);
921
- if (deferred) {
922
- if (!(parent.flags & F.BwdQueued))
923
- parent._enqueueBwd();
924
- return;
925
- }
926
- cell = parent;
927
- continue;
1005
+ else if (stack !== undefined && stack.length > 0) {
1006
+ node = stack.pop();
928
1007
  }
929
- if (parent.getter === undefined) {
930
- // Source: commit + forward-propagate (the forward write).
931
- parent._writeSource(push);
1008
+ else {
932
1009
  return;
933
1010
  }
934
- // Parent is a lens: keep walking, carrying its new view value.
935
- cell = parent;
936
- v = push;
937
1011
  }
938
1012
  }
939
- /** Split a multi-parent cell's write across its N parents. `_put(target)`
940
- * returns the per-parent update array (`undefined` leave parent);
941
- * each defined update recurses via `propagateBwd`. Eager splits coalesce
942
- * under one flush (same guarantee as `batch()`); the coalescing lives
943
- * here since such a cell may be reached as a write start or mid-chain. */
944
- function propagateSplit(cell, target, deferred) {
945
- const b = cell._bwd;
946
- const parents = b.parent;
947
- const n = parents.length;
948
- // STATEFUL lens: `bwd` reads the complement and returns per-parent
949
- // updates plus the post-write complement. We commit the stepped
950
- // complement and fork the source updates; absorption is the lens's job
951
- // (its `bwd` returns `undefined` updates, forked as no-ops).
952
- const sc = b.stateful;
953
- if (sc !== undefined) {
954
- // Bring the complement current with the sources before the back-write
955
- // (a source may have changed without the view being read, leaving
956
- // `step` un-run). Untracked, so reading `.value` adds no dependency.
957
- void cell.value;
958
- const vals = new Array(n);
959
- for (let i = 0; i < n; i++)
960
- vals[i] = parents[i].peek();
961
- const res = b.put(target, vals, sc.complement);
962
- const updates = res.updates;
963
- const cand = new Array(n);
964
- let anyWrite = false;
965
- for (let i = 0; i < n; i++) {
966
- const u = updates[i];
967
- if (u === undefined) {
968
- cand[i] = vals[i];
969
- }
970
- else {
971
- cand[i] = u;
972
- anyWrite = true;
973
- }
974
- }
975
- sc.complement = sc.step(cand, res.complement, false);
976
- if (!anyWrite) {
977
- // Complement-only change (no source moves): mark dirty for a correct next read.
978
- cell.flags = F.Mutable | F.Dirty;
979
- return;
1013
+ /** RESOLVE (pull), dual of forward `checkDirty`: resolve ONE node's whole
1014
+ * back-cone. Ascend the static reverse adjacency `_lensSubs` (only `BACK_MARKED`
1015
+ * children) to the armed views above, `writeBack`ing each. Source-CENTRIC — a
1016
+ * source must reflect ALL its writers (they compose into one committed value),
1017
+ * so a call on the source resolves every co-writer together and commits once.
1018
+ * `BF.Dirty` is the dedup.
1019
+ *
1020
+ * Recursive so a MERGE folds POST-ORDER (the one non-dual ingredient): resolve
1021
+ * its contributors first (each cascades a `put` into `contributions`), then fold
1022
+ * once on the way back up and write the parent, as a getter reads deps then
1023
+ * computes. */
1024
+ function resolveCone(node) {
1025
+ const b = node._bwd;
1026
+ const merge = b !== undefined ? b.merge : undefined;
1027
+ if (merge !== undefined)
1028
+ merge.contributions.length = 0; // self-heal contributions left by a prior throw
1029
+ // Drive this node's own armed target first (composes before child writes —
1030
+ // preserves last-writer-wins order among co-writers of a shared parent).
1031
+ if (node.bflags & BF.Dirty) {
1032
+ node.bflags &= ~BF.Dirty;
1033
+ writeBack(node, node.pendingValue);
1034
+ }
1035
+ const children = node._lensSubs;
1036
+ if (children !== undefined) {
1037
+ for (let i = 0; i < children.length; i++) {
1038
+ const c = children[i];
1039
+ if (c.bflags & BACK_MARKED)
1040
+ resolveCone(c);
980
1041
  }
981
- sc.lastBwd = cand;
982
- if (deferred) {
983
- forkInto(parents, updates, n);
984
- return;
1042
+ }
1043
+ node.bflags &= ~BF.Pending; // resolved (idempotent with `writeBack`'s clear)
1044
+ if (merge !== undefined)
1045
+ foldMerge(b.parent, merge); // contributors in → fold once
1046
+ }
1047
+ /** PULL entry for a back-marked `start`. Source-centric: a source read resolves
1048
+ * its own cone; a VIEW read first DESCENDS its marked back-path (`_bwd.parent`
1049
+ * through `BF.Pending`) to the sources, then resolves each source's whole cone
1050
+ * (so co-writers compose and the source commits once). The `draining` guard
1051
+ * stops a `put`'s source read from re-entering. */
1052
+ function backResolve(start) {
1053
+ draining = true;
1054
+ ++batchDepth;
1055
+ const prev = activeSub;
1056
+ activeSub = undefined;
1057
+ try {
1058
+ if (isSource(start)) {
1059
+ resolveCone(start);
985
1060
  }
986
- ++batchDepth;
987
- try {
988
- forkInto(parents, updates, n);
1061
+ else {
1062
+ // Descend the marked back-path to every source, resolving each source's
1063
+ // cone. A view with no source to land on (a `pin` sink) resolves itself.
1064
+ let node = start;
1065
+ let stack;
1066
+ let reached = false;
1067
+ for (;;) {
1068
+ let next;
1069
+ const b = node._bwd;
1070
+ const parent = b !== undefined ? b.parent : undefined; // merge's parent IS b.parent
1071
+ if (parent !== undefined) {
1072
+ if (Array.isArray(parent)) {
1073
+ for (let i = 0; i < parent.length; i++) {
1074
+ const p = parent[i];
1075
+ if (isSource(p)) {
1076
+ reached = true;
1077
+ if (p.bflags & BF.Pending)
1078
+ resolveCone(p);
1079
+ }
1080
+ else if (p.bflags & BF.Pending) {
1081
+ if (next === undefined)
1082
+ next = p;
1083
+ else
1084
+ (stack ??= []).push(p);
1085
+ }
1086
+ }
1087
+ }
1088
+ else if (isSource(parent)) {
1089
+ reached = true;
1090
+ if (parent.bflags & BF.Pending)
1091
+ resolveCone(parent);
1092
+ }
1093
+ else if (parent.bflags & BF.Pending) {
1094
+ next = parent;
1095
+ }
1096
+ }
1097
+ if (next !== undefined)
1098
+ node = next;
1099
+ else if (stack !== undefined && stack.length > 0)
1100
+ node = stack.pop();
1101
+ else
1102
+ break;
1103
+ }
1104
+ if (!reached && start.bflags & BF.Dirty) {
1105
+ start.bflags &= ~BF.Dirty;
1106
+ writeBack(start, start.pendingValue);
1107
+ }
989
1108
  }
990
- finally {
991
- if (!--batchDepth)
992
- flush();
1109
+ }
1110
+ finally {
1111
+ activeSub = prev;
1112
+ --batchDepth;
1113
+ draining = false;
1114
+ }
1115
+ }
1116
+ /** Resolve any back-write a woken reactive node reads DIRECTLY. `checkDirty`
1117
+ * alone catches back-writes that move a source (it ascends to the source),
1118
+ * but a stateful "stash" moves only the VIEW (the complement echoes a value
1119
+ * back, no source changes) — invisible to a source-based check. Resolving the
1120
+ * node's back-marked deps here makes that view-change visible before the
1121
+ * dirtiness check. Granular: only this node's own deps. */
1122
+ function resolveBackDeps(node) {
1123
+ for (let l = node.deps; l !== undefined; l = l.nextDep) {
1124
+ const d = l.dep;
1125
+ if (d.bflags & BACK_MARKED && !draining)
1126
+ backResolve(d);
1127
+ }
1128
+ }
1129
+ /** Backward commit/compute (dual of forward `_update`): drive a back-write of
1130
+ * `target` toward the sources, applying each lens's `put`, clearing `BF.Pending`
1131
+ * on descent, and staging each source as it's reached (so a later sibling reading
1132
+ * that SAME source composes rather than clobbers). A `SKIP` per-parent slot
1133
+ * prunes a branch; every other slot is written verbatim, `undefined` included. A
1134
+ * `merge` accumulates (folded post-order by `resolveCone`); a read-only parent
1135
+ * throws (already caught at MARK time). */
1136
+ function writeBack(node, target) {
1137
+ if (isSource(node)) {
1138
+ node._writeSource(target); // source — staged now, visible to later siblings
1139
+ // Clear this source's back-`Pending`, then re-assert it iff a lens-child is
1140
+ // STILL armed (an overlapping co-writer through this same source) — a later
1141
+ // read must still resolve them, else that write is lost. (Forward writes
1142
+ // live on `flags`, back-state on `bflags`, so the source-stage above no
1143
+ // longer touches `Pending`; we own clearing it here.) `_lensSubs` holds only
1144
+ // ever-back-written children, so this is a short flag scan, not a registry walk.
1145
+ node.bflags &= ~BF.Pending;
1146
+ const subs = node._lensSubs;
1147
+ if (subs !== undefined) {
1148
+ for (let i = 0; i < subs.length; i++)
1149
+ if (subs[i].bflags & BACK_MARKED) {
1150
+ node.bflags |= BF.Pending;
1151
+ break;
1152
+ }
993
1153
  }
994
1154
  return;
995
1155
  }
996
- const updates = b.put(target);
997
- // No speculation: each defined update forks to its parent, where
998
- // `_writeSource`'s equality check prunes no-op sources and the forward
999
- // pass prunes unchanged views. Absorption `undefined` updates.
1000
- if (deferred) {
1001
- forkInto(parents, updates, n);
1156
+ node.bflags &= ~BF.Pending; // passing through clears the path marker
1157
+ const b = node._bwd;
1158
+ if (b === undefined)
1159
+ throw new TypeError("Cannot write through to a computed");
1160
+ if (b.merge !== undefined) {
1161
+ b.merge.contributions.push(target); // gathered here; `resolveCone` folds post-order
1002
1162
  return;
1003
1163
  }
1004
- ++batchDepth;
1005
- try {
1006
- forkInto(parents, updates, n);
1007
- }
1008
- finally {
1009
- if (!--batchDepth)
1010
- flush();
1164
+ const parent = b.parent;
1165
+ if (parent === undefined)
1166
+ return; // pin sink: absorb
1167
+ if (Array.isArray(parent)) {
1168
+ const n = parent.length;
1169
+ let out;
1170
+ const sc = b.stateful;
1171
+ if (sc !== undefined) {
1172
+ const vals = new Array(n);
1173
+ for (let i = 0; i < n; i++)
1174
+ vals[i] = parent[i].value;
1175
+ // Bring the complement to the current sources before `bwd` (the dual of
1176
+ // the forward getter's step) so it measures devs/fracs from a prior
1177
+ // sibling write, not a stale snapshot. External unless the sources still
1178
+ // equal this lens's own last back-write.
1179
+ const last = sc.last;
1180
+ let external = last === undefined;
1181
+ if (last !== undefined) {
1182
+ for (let i = 0; i < n; i++)
1183
+ if (vals[i] !== last[i]) {
1184
+ external = true;
1185
+ break;
1186
+ }
1187
+ }
1188
+ sc.complement = sc.step(vals, sc.complement, external);
1189
+ const res = b.put(target, vals, sc.complement);
1190
+ const upd = res.updates;
1191
+ // Build the committed candidate IN `vals` (its source reads are spent) and
1192
+ // keep it as `last` for the next own-vs-external comparison. A `SKIP` slot —
1193
+ // or a slot past a short `upd` — leaves that parent at its current `vals[i]`.
1194
+ const um = upd.length < n ? upd.length : n;
1195
+ for (let i = 0; i < um; i++)
1196
+ if (upd[i] !== SKIP)
1197
+ vals[i] = upd[i];
1198
+ sc.complement = sc.step(vals, res.complement, false);
1199
+ sc.last = vals;
1200
+ out = upd;
1201
+ }
1202
+ else {
1203
+ out = b.put(target);
1204
+ }
1205
+ // A short `out` skips the trailing parents (the dual of a forward getter
1206
+ // reading fewer deps); `SKIP` skips a specific slot; a real `undefined` is
1207
+ // written verbatim.
1208
+ let wrote = false;
1209
+ const m = out.length < n ? out.length : n;
1210
+ for (let i = 0; i < m; i++) {
1211
+ const u = out[i];
1212
+ if (u !== SKIP) {
1213
+ wrote = true;
1214
+ writeBack(parent[i], u);
1215
+ }
1216
+ }
1217
+ // A stateful lens can change its VIEW through the complement alone, moving
1218
+ // no source (a degenerate "stash" — e.g. a collapsed axis remembering the
1219
+ // written angle, or a broken parse holding the typed text). No source write
1220
+ // means the forward cone never fires, so invalidate the node's own cache and
1221
+ // propagate to its observers here.
1222
+ if (!wrote && sc !== undefined) {
1223
+ node.flags |= F.Dirty;
1224
+ const subs = node.subs;
1225
+ if (subs !== undefined)
1226
+ propagate(subs, runDepth > 0, activeNetwork);
1227
+ }
1228
+ return;
1011
1229
  }
1230
+ // 1→1 lens.
1231
+ writeBack(parent, b.put(target));
1012
1232
  }
1013
- /** Route each defined update to its parent: a source commits directly, a
1014
- * lens/multi-parent/merge re-enters the backward pass. Always called
1015
- * under a bumped `batchDepth` so commits coalesce into one flush. */
1016
- function forkInto(parents, updates, n) {
1017
- for (let i = 0; i < n; i++) {
1018
- const u = updates[i];
1019
- if (u === undefined)
1020
- continue;
1021
- const parent = parents[i];
1022
- if (parent.getter === undefined)
1023
- parent._writeSource(u);
1024
- else
1025
- propagateBwd(parent, u, true);
1026
- }
1233
+ /** Fold one merge's gathered contributions ONCE (its policy; default
1234
+ * last-writer-wins) and write the result up to its parent. Called post-order
1235
+ * from `resolveCone` once every contributor has cascaded in — fan-in is the one
1236
+ * non-dual ingredient. Runs untracked (`backResolve` already cleared
1237
+ * `activeSub`). */
1238
+ function foldMerge(parent, mn) {
1239
+ const vals = mn.contributions;
1240
+ const fold = mn.foldFn;
1241
+ let folded;
1242
+ if (fold !== undefined)
1243
+ folded = fold(vals);
1244
+ else if (vals.length > 0)
1245
+ folded = vals[vals.length - 1];
1246
+ else
1247
+ return; // last-writer-wins with no contributor: leave the parent
1248
+ vals.length = 0; // reuse the merge-owned buffer in place (fold must not retain it)
1249
+ writeBack(parent, folded);
1027
1250
  }
1028
1251
  /** Writable source; passes an existing `Writable` through (idempotent). */
1029
1252
  export function cell(initial, opts) {
@@ -1101,6 +1324,10 @@ class Effect {
1101
1324
  this._runCleanup();
1102
1325
  }
1103
1326
  _run() {
1327
+ // Resolve back-writes this effect reads directly (incl. view-only stashes),
1328
+ // then let `checkDirty` resolve any back-`Pending` source reached deeper.
1329
+ if (this.deps !== undefined)
1330
+ resolveBackDeps(this);
1104
1331
  const flags = this.flags;
1105
1332
  if (flags & F.Dirty || (flags & F.Pending && checkDirty(this.deps, this))) {
1106
1333
  if (this.cleanup) {
@@ -1146,47 +1373,59 @@ export function effect(fn) {
1146
1373
  const e = new Effect(fn);
1147
1374
  return () => e._unwatched();
1148
1375
  }
1149
- // Alternates backward-drain and effect-drain to a fixpoint: backward
1150
- // commits sources (queuing effects); effects may write (more effects /
1151
- // more bwd entries). Loops until both queues are exhausted.
1376
+ /** Run effects woken by a write. Backward work is pulled lazily per read
1377
+ * (`resolveBackDeps` + `checkDirty` `backResolve`, which also folds merges),
1378
+ * so flush owns NO backward bookkeeping just the effect queue. */
1152
1379
  function flush() {
1153
1380
  if (flushing)
1154
1381
  return;
1155
1382
  flushing = true;
1156
- let bwdIndex = 0;
1157
1383
  try {
1158
- // Head-checked so the common already-drained case skips the body.
1159
- while (bwdIndex < bwdQueue.length || notifyIndex < queuedLength) {
1160
- while (bwdIndex < bwdQueue.length) {
1161
- const cell = bwdQueue[bwdIndex];
1162
- const cb = cell._bwd;
1163
- if (cb.queueIdx !== bwdIndex || !(cell.flags & F.BwdQueued)) {
1164
- bwdIndex++;
1165
- continue;
1166
- }
1167
- bwdIndex++;
1168
- cell.flags &= ~F.BwdQueued;
1169
- if (cb.merge !== undefined) {
1170
- bwdUntracked(cell, undefined, true);
1171
- }
1172
- else {
1173
- bwdUntracked(cell, cell.pendingValue, true);
1174
- }
1175
- }
1176
- while (notifyIndex < queuedLength) {
1177
- const e = queued[notifyIndex];
1178
- queued[notifyIndex++] = undefined;
1179
- e._run();
1180
- }
1384
+ while (notifyIndex < queuedLength) {
1385
+ const e = queued[notifyIndex];
1386
+ queued[notifyIndex++] = undefined;
1387
+ e._run();
1181
1388
  }
1182
1389
  }
1183
1390
  finally {
1184
- bwdQueue.length = 0;
1185
1391
  notifyIndex = 0;
1186
1392
  queuedLength = 0;
1393
+ networkQueued = false;
1187
1394
  flushing = false;
1188
1395
  }
1189
1396
  }
1397
+ /** Queue an effect flush for the end of the current microtask turn (idempotent).
1398
+ * A write wakes effects asynchronously; many writes in one turn coalesce. */
1399
+ function schedule() {
1400
+ if (scheduled)
1401
+ return;
1402
+ scheduled = true;
1403
+ queueMicrotask(() => {
1404
+ scheduled = false;
1405
+ flush();
1406
+ });
1407
+ }
1408
+ /** Resolve the queue after a write: no-op inside a `batch`/flush (the barrier
1409
+ * owns flushing), else synchronously if a network is waiting (eager solve) or
1410
+ * deferred to the microtask (coalesced effects). */
1411
+ function autoFlush() {
1412
+ if (batchDepth !== 0 || flushing)
1413
+ return;
1414
+ if (networkQueued)
1415
+ flush();
1416
+ else
1417
+ schedule();
1418
+ }
1419
+ /** Run all pending effects NOW, synchronously. The escape hatch for code (tests,
1420
+ * imperative call sites) that must observe effect side-effects before yielding
1421
+ * to the microtask queue. Reads never need it — they pull current values. */
1422
+ export function settle() {
1423
+ flush();
1424
+ }
1425
+ /** Group writes and flush effects SYNCHRONOUSLY at the end of `fn` — a sync
1426
+ * barrier. Effects coalesce on the microtask turn anyway (see `schedule`), so
1427
+ * `batch` is no longer needed for that; reach for it only when you must run the
1428
+ * woken effects before the call returns (and don't want a `settle()`). */
1190
1429
  export function batch(fn) {
1191
1430
  ++batchDepth;
1192
1431
  try {
@@ -1243,6 +1482,7 @@ class _NetworkNode {
1243
1482
  return;
1244
1483
  }
1245
1484
  queued[queuedLength++] = this;
1485
+ networkQueued = true; // eager: a queued network forces a synchronous flush
1246
1486
  this.flags &= ~F.Watching;
1247
1487
  }
1248
1488
  _unwatched() {
@@ -1257,6 +1497,8 @@ class _NetworkNode {
1257
1497
  _run() {
1258
1498
  if (this.disposed)
1259
1499
  return;
1500
+ if (this.deps !== undefined)
1501
+ resolveBackDeps(this);
1260
1502
  const flags = this.flags;
1261
1503
  if (flags & F.Dirty || (flags & F.Pending && checkDirty(this.deps, this))) {
1262
1504
  this._runBody(this._computeDirty());
@@ -1279,7 +1521,7 @@ class _NetworkNode {
1279
1521
  _runBody(dirty) {
1280
1522
  // RecursedCheck doubles as the "body running" guard (see flush()).
1281
1523
  this.flags = F.Watching | F.RecursedCheck;
1282
- const prevSettler = activeNetwork;
1524
+ const prevNetwork = activeNetwork;
1283
1525
  activeNetwork = this;
1284
1526
  try {
1285
1527
  ++cycle;
@@ -1295,7 +1537,7 @@ class _NetworkNode {
1295
1537
  }
1296
1538
  finally {
1297
1539
  --runDepth;
1298
- activeNetwork = prevSettler;
1540
+ activeNetwork = prevNetwork;
1299
1541
  this.flags &= ~F.RecursedCheck;
1300
1542
  this.lastValues.clear();
1301
1543
  let l = this.deps;
@@ -1374,10 +1616,9 @@ deps, body, opts) {
1374
1616
  node._initWithHandle(handle, deps);
1375
1617
  return handle;
1376
1618
  }
1377
- // MISC stuff used by a few places, to revisit...
1378
1619
  // ── value-class authoring helpers ──────────────────────────────────
1379
1620
  //
1380
- // `field`/`cachedDerive` are the two getter forms a value class declares.
1621
+ // `fieldLens`/`cachedDerive` are the two getter forms a value class declares.
1381
1622
  // The choice between them IS the local declaration of writability at each
1382
1623
  // getter (mirroring `: this` invertible method returns). For arbitrary
1383
1624
  // cached views, use `lazy()` directly.
@@ -1385,9 +1626,9 @@ deps, body, opts) {
1385
1626
  * replaces the composite. Cached per (instance, key). Return type is
1386
1627
  * conditional: `Writable<Cls>` on a writable parent, bare `Cls` on RO.
1387
1628
  *
1388
- * get x() { return field(this, "x", Num); } */
1389
- export function field(parent, key, Cls) {
1390
- return lazy(parent, key, () => Cell.fieldOf(parent, key, Cls));
1629
+ * get x() { return fieldLens(this, "x", Num); } */
1630
+ export function fieldLens(parent, key, Cls) {
1631
+ return lazy(parent, key, () => fieldOf(parent, key, Cls));
1391
1632
  }
1392
1633
  /** Read-only derived view via `Cls.derive(parent, fn)`, memoized per
1393
1634
  * (instance, key); always bare `Cls` (RO). The cache is the point — the