@lazily-hub/lazily-js 0.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.
@@ -0,0 +1,130 @@
1
+ // Memoized semantic tree (cell-model.md § Memoized semantic tree, #lzsemtree).
2
+ // One memo slot per node folds (node value, child derived values). Editing one
3
+ // node recomputes only its ANCESTOR CHAIN — a sibling subtree's derived slot
4
+ // stays cached (incremental, glitch-free). A node edit that does not change
5
+ // the folded result MUST NOT re-run a downstream consumer (memo equality guard).
6
+ // Cost is proportional to the diff, not the document.
7
+ //
8
+ // The tree is reactive: each node's value is a `Cell` and its child list is a
9
+ // `Cell`, so a SemTree composes over the owning `Context`. Structural growth
10
+ // (inserting a brand-new child) requires `build` again (the captured child-slot
11
+ // map is fixed at build time, per the lazily-rs/lazily-kt contract); removals
12
+ // are covered by incrementality because the parent's child-list cell changes.
13
+
14
+ import { Context, SlotHandle } from "./reactive.js";
15
+
16
+ class SemNode {
17
+ constructor(id, value, ctx) {
18
+ this.id = id;
19
+ this.valueCell = ctx.cell(value);
20
+ this.childKeysCell = ctx.cell([]); // array of child ids (reactive membership/order)
21
+ this.childSlots = new Map(); // child id -> SlotHandle (captured at build)
22
+ this.slot = null; // memo SlotHandle for this node's derived value
23
+ }
24
+ }
25
+
26
+ export class SemTree {
27
+ #ctx;
28
+ #nodes = new Map(); // id -> SemNode
29
+ #rootId;
30
+ #fold;
31
+
32
+ constructor(ctx, rootSpec, fold) {
33
+ this.#ctx = ctx;
34
+ this.#fold = fold;
35
+ this.#rootId = rootSpec.id;
36
+ this.#build(rootSpec);
37
+ }
38
+
39
+ static build(ctx, rootSpec, fold) {
40
+ return new SemTree(ctx, rootSpec, fold);
41
+ }
42
+
43
+ #build(spec) {
44
+ const node = new SemNode(spec.id, spec.value, this.#ctx);
45
+ this.#nodes.set(spec.id, node);
46
+ const childOrder = [];
47
+ const children = (spec.children && spec.children.values) ?? {};
48
+ const order = (spec.children && spec.children.order) ?? Object.keys(children);
49
+ for (const childKey of order) {
50
+ const childSpec = children[childKey];
51
+ if (!childSpec) {
52
+ continue;
53
+ }
54
+ const childNode = this.#build(childSpec);
55
+ childOrder.push(childSpec.id);
56
+ node.childSlots.set(childSpec.id, childNode.slot);
57
+ }
58
+ // Set the child-list cell BEFORE registering the memo so the memo observes it.
59
+ this.#ctx.setCell(node.childKeysCell, childOrder);
60
+ // Register the memo slot: subscribes to own value cell, own child-list cell,
61
+ // and each present child's derived slot.
62
+ const ctx = this.#ctx;
63
+ const fold = this.#fold;
64
+ const self = this;
65
+ node.slot = ctx.memo(() => {
66
+ const v = ctx.getCell(node.valueCell);
67
+ const kids = ctx.getCell(node.childKeysCell);
68
+ const ds = [];
69
+ for (const kid of kids) {
70
+ const childSlot = node.childSlots.get(kid);
71
+ if (childSlot) {
72
+ ds.push(ctx.get(childSlot));
73
+ }
74
+ }
75
+ return fold(v, ds);
76
+ });
77
+ return node;
78
+ }
79
+
80
+ // Edit a node's value cell. Only the ancestor chain recomputes.
81
+ setValue(id, value) {
82
+ const node = this.#nodes.get(id);
83
+ if (!node) {
84
+ throw new RangeError(`SemTree node not present: ${String(id)}`);
85
+ }
86
+ this.#ctx.setCell(node.valueCell, value);
87
+ }
88
+
89
+ // Remove a child from its parent's child-list cell. The parent re-folds over
90
+ // the remaining children (covered by incrementality).
91
+ removeChild(parentId, childId) {
92
+ const parent = this.#nodes.get(parentId);
93
+ if (!parent) {
94
+ throw new RangeError(`SemTree parent not present: ${String(parentId)}`);
95
+ }
96
+ const kids = this.#ctx.getCell(parent.childKeysCell).filter((k) => k !== childId);
97
+ this.#ctx.setCell(parent.childKeysCell, kids);
98
+ }
99
+
100
+ // Reactive read of the root derived value.
101
+ value() {
102
+ return this.#ctx.get(this.#nodes.get(this.#rootId).slot);
103
+ }
104
+
105
+ // Reactive read at a node id.
106
+ nodeValue(id) {
107
+ const node = this.#nodes.get(id);
108
+ if (!node) {
109
+ return undefined;
110
+ }
111
+ return this.#ctx.get(node.slot);
112
+ }
113
+
114
+ // Whether a node's derived slot currently has a fresh cached value (testing).
115
+ isCached(id) {
116
+ const node = this.#nodes.get(id);
117
+ if (!node) {
118
+ return false;
119
+ }
120
+ return this.#ctx.isSet(node.slot);
121
+ }
122
+
123
+ rootHandle() {
124
+ return this.#nodes.get(this.#rootId).slot;
125
+ }
126
+
127
+ nodeHandle(id) {
128
+ return this.#nodes.get(id)?.slot ?? null;
129
+ }
130
+ }
@@ -0,0 +1,44 @@
1
+ export class HlcStamp {
2
+ readonly wallTime: number;
3
+ readonly logical: number;
4
+ readonly peer: number;
5
+ constructor(wallTime: number, logical: number, peer: number);
6
+ compareTo(other: HlcStamp): number;
7
+ static max(a: HlcStamp, b: HlcStamp): HlcStamp;
8
+ }
9
+
10
+ export class Hlc {
11
+ constructor(peer: number);
12
+ send(nowMicros: number): HlcStamp;
13
+ recv(remote: HlcStamp, nowMicros: number): HlcStamp;
14
+ }
15
+
16
+ export class Position {
17
+ readonly frac: number[];
18
+ readonly peer: number;
19
+ constructor(frac: number[], peer: number);
20
+ compareTo(other: Position): number;
21
+ }
22
+
23
+ export class SeqCrdt<Id, V> {
24
+ constructor(peer: number);
25
+ insertBetween(id: Id, value: V, left: Id | null, right: Id | null, nowMicros: number): void;
26
+ insertBack(id: Id, value: V, nowMicros: number): void;
27
+ insertFront(id: Id, value: V, nowMicros: number): void;
28
+ setValue(id: Id, value: V, nowMicros: number): boolean;
29
+ moveBetween(id: Id, left: Id | null, right: Id | null, nowMicros: number): boolean;
30
+ moveAfter(id: Id, anchor: Id, nowMicros: number): boolean;
31
+ moveBefore(id: Id, anchor: Id, nowMicros: number): boolean;
32
+ remove(id: Id, nowMicros: number): boolean;
33
+ contains(id: Id): boolean;
34
+ get(id: Id): V | undefined;
35
+ order(): Id[];
36
+ values(): Array<[Id, V]>;
37
+ tombstoneCount(): number;
38
+ fork(peer: number): SeqCrdt<Id, V>;
39
+ clone(): SeqCrdt<Id, V>;
40
+ merge(other: SeqCrdt<Id, V>, nowMicros: number): boolean;
41
+ gcWith(isStable: (stamp: HlcStamp) => boolean): number;
42
+ gc(watermark: HlcStamp): number;
43
+ entryCount(): number;
44
+ }
@@ -0,0 +1,364 @@
1
+ // Move-aware sequence CRDT (cell-model.md § Move-aware sequence order,
2
+ // #lzseqcrdt). Each element is three independent LWW registers — value,
3
+ // position (fractional-index byte key + peer), deleted — each stamped by an
4
+ // HLC. A move is a SINGLE LWW reassignment of the position register (not
5
+ // delete + reinsert), so concurrent moves of the same element converge to the
6
+ // later stamp without duplication; a concurrent move + value-edit both apply
7
+ // (independent registers). Removal is an LWW tombstone. Order is the
8
+ // lexicographic total order on (frac, peer).
9
+
10
+ // Hybrid logical clock stamp: total order is (wall_time, logical, peer).
11
+ export class HlcStamp {
12
+ constructor(wallTime, logical, peer) {
13
+ this.wallTime = wallTime;
14
+ this.logical = logical;
15
+ this.peer = peer;
16
+ Object.freeze(this);
17
+ }
18
+ compareTo(other) {
19
+ if (this.wallTime !== other.wallTime) {
20
+ return this.wallTime < other.wallTime ? -1 : 1;
21
+ }
22
+ if (this.logical !== other.logical) {
23
+ return this.logical < other.logical ? -1 : 1;
24
+ }
25
+ if (this.peer !== other.peer) {
26
+ return this.peer < other.peer ? -1 : 1;
27
+ }
28
+ return 0;
29
+ }
30
+ static max(a, b) {
31
+ return a.compareTo(b) >= 0 ? a : b;
32
+ }
33
+ }
34
+
35
+ // HLC: guarantees strictly-increasing stamps on a given peer.
36
+ export class Hlc {
37
+ constructor(peer) {
38
+ this.peer = peer;
39
+ this.lastWall = 0;
40
+ this.lastLogical = 0;
41
+ }
42
+ send(nowMicros) {
43
+ if (nowMicros > this.lastWall) {
44
+ this.lastWall = nowMicros;
45
+ this.lastLogical = 0;
46
+ } else {
47
+ this.lastLogical += 1;
48
+ }
49
+ return new HlcStamp(this.lastWall, this.lastLogical, this.peer);
50
+ }
51
+ // Observe a remote stamp, advancing the local clock past it (return value
52
+ // unused by SeqCrdt.merge — only the side effect matters).
53
+ recv(remote, nowMicros) {
54
+ const wall = Math.max(this.lastWall, remote.wallTime, nowMicros);
55
+ if (wall === this.lastWall && wall === remote.wallTime) {
56
+ this.lastLogical = Math.max(this.lastLogical, remote.logical) + 1;
57
+ } else if (wall === this.lastWall) {
58
+ this.lastLogical += 1;
59
+ } else if (wall === remote.wallTime) {
60
+ this.lastLogical = remote.logical + 1;
61
+ } else {
62
+ this.lastLogical = 0;
63
+ }
64
+ this.lastWall = wall;
65
+ return new HlcStamp(this.lastWall, this.lastLogical, this.peer);
66
+ }
67
+ }
68
+
69
+ // LWW register: overwrite iff stamp is STRICTLY > current (incumbent wins ties).
70
+ export class LwwRegister {
71
+ constructor(value, stamp) {
72
+ this.value = value;
73
+ this.stamp = stamp;
74
+ }
75
+ set(value, stamp) {
76
+ if (stamp.compareTo(this.stamp) > 0) {
77
+ this.value = value;
78
+ this.stamp = stamp;
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ // Merge from another register: take other's (value, stamp) iff strictly newer.
84
+ // Returns true iff the VALUE changed.
85
+ mergeFrom(other) {
86
+ if (other.stamp.compareTo(this.stamp) > 0) {
87
+ const changed = !valuesEqual(this.value, other.value);
88
+ this.value = other.value;
89
+ this.stamp = other.stamp;
90
+ return changed;
91
+ }
92
+ return false;
93
+ }
94
+ }
95
+
96
+ function valuesEqual(a, b) {
97
+ if (a === b) {
98
+ return true;
99
+ }
100
+ if (a instanceof Position && b instanceof Position) {
101
+ return a.compareTo(b) === 0;
102
+ }
103
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
104
+ return a === b;
105
+ }
106
+ return JSON.stringify(a) === JSON.stringify(b);
107
+ }
108
+
109
+ // Position: fractional-index byte key + originating peer.
110
+ export class Position {
111
+ constructor(frac, peer) {
112
+ this.frac = frac; // number[] (bytes 0..255)
113
+ this.peer = peer;
114
+ Object.freeze(this);
115
+ }
116
+ compareTo(other) {
117
+ const len = Math.min(this.frac.length, other.frac.length);
118
+ for (let i = 0; i < len; i++) {
119
+ if (this.frac[i] !== other.frac[i]) {
120
+ return this.frac[i] < other.frac[i] ? -1 : 1;
121
+ }
122
+ }
123
+ if (this.frac.length !== other.frac.length) {
124
+ return this.frac.length < other.frac.length ? -1 : 1;
125
+ }
126
+ if (this.peer !== other.peer) {
127
+ return this.peer < other.peer ? -1 : 1;
128
+ }
129
+ return 0;
130
+ }
131
+ }
132
+
133
+ // Fractional-index midpoint: a key strictly between lo and hi (exclusive).
134
+ // Append-only — positions grow under repeated subdivision, never rebalanced.
135
+ function keyBetween(lo, hi) {
136
+ const result = [];
137
+ const cap = (lo ? lo.length : 0) + (hi ? hi.length : 0) + 2;
138
+ let i = 0;
139
+ while (i <= cap) {
140
+ const a = lo && i < lo.length ? lo[i] : 0;
141
+ const b = hi === null ? 256 : i < hi.length ? hi[i] : 0;
142
+ if (a + 1 < b) {
143
+ result.push(Math.floor((a + b) / 2));
144
+ return result;
145
+ }
146
+ result.push(a);
147
+ i++;
148
+ if (a < b) {
149
+ // Dropped strictly below hi: descend with open top.
150
+ const loTail = lo ? lo.slice(i) : [];
151
+ result.push(...keyBetween(loTail.length > 0 ? loTail : null, null));
152
+ return result;
153
+ }
154
+ // a === b: shared prefix, continue.
155
+ }
156
+ result.push(128);
157
+ return result;
158
+ }
159
+
160
+ class Entry {
161
+ constructor(value, position, deleted) {
162
+ this.value = value; // LwwRegister<V>
163
+ this.position = position; // LwwRegister<Position>
164
+ this.deleted = deleted; // LwwRegister<bool>
165
+ }
166
+ clone() {
167
+ return new Entry(
168
+ new LwwRegister(this.value.value, this.value.stamp),
169
+ new LwwRegister(this.position.value, this.position.stamp),
170
+ new LwwRegister(this.deleted.value, this.deleted.stamp),
171
+ );
172
+ }
173
+ maxStamp() {
174
+ let max = this.value.stamp;
175
+ if (this.position.stamp.compareTo(max) > 0) max = this.position.stamp;
176
+ if (this.deleted.stamp.compareTo(max) > 0) max = this.deleted.stamp;
177
+ return max;
178
+ }
179
+ }
180
+
181
+ export class SeqCrdt {
182
+ #entries = new Map();
183
+ #hlc;
184
+ #peer;
185
+
186
+ constructor(peer) {
187
+ this.#peer = peer;
188
+ this.#hlc = new Hlc(peer);
189
+ }
190
+
191
+ #fracOf(id) {
192
+ const e = this.#entries.get(id);
193
+ return e ? [...e.position.value.frac] : undefined;
194
+ }
195
+
196
+ insertBetween(id, value, left, right, nowMicros) {
197
+ if (this.#entries.has(id)) {
198
+ return; // no-op if already present (use moveBetween to relocate)
199
+ }
200
+ const lo = left !== null && left !== undefined ? this.#fracOf(left) : undefined;
201
+ const hi = right !== null && right !== undefined ? this.#fracOf(right) : undefined;
202
+ const frac = keyBetween(lo ?? null, hi ?? null);
203
+ const pos = new Position(frac, this.#peer);
204
+ const stamp = this.#hlc.send(nowMicros);
205
+ this.#entries.set(id, new Entry(
206
+ new LwwRegister(value, stamp),
207
+ new LwwRegister(pos, stamp),
208
+ new LwwRegister(false, stamp),
209
+ ));
210
+ }
211
+
212
+ insertBack(id, value, nowMicros) {
213
+ const order = this.order();
214
+ const left = order.length > 0 ? order[order.length - 1] : null;
215
+ this.insertBetween(id, value, left, null, nowMicros);
216
+ }
217
+
218
+ insertFront(id, value, nowMicros) {
219
+ const order = this.order();
220
+ const right = order.length > 0 ? order[0] : null;
221
+ this.insertBetween(id, value, null, right, nowMicros);
222
+ }
223
+
224
+ setValue(id, value, nowMicros) {
225
+ const e = this.#entries.get(id);
226
+ if (!e) {
227
+ return false;
228
+ }
229
+ return e.value.set(value, this.#hlc.send(nowMicros));
230
+ }
231
+
232
+ moveBetween(id, left, right, nowMicros) {
233
+ const e = this.#entries.get(id);
234
+ if (!e) {
235
+ return false;
236
+ }
237
+ const lo = left !== null && left !== undefined ? this.#fracOf(left) : undefined;
238
+ const hi = right !== null && right !== undefined ? this.#fracOf(right) : undefined;
239
+ const frac = keyBetween(lo ?? null, hi ?? null);
240
+ const pos = new Position(frac, this.#peer);
241
+ e.position.set(pos, this.#hlc.send(nowMicros));
242
+ return true;
243
+ }
244
+
245
+ moveAfter(id, anchor, nowMicros) {
246
+ const order = this.order();
247
+ const idx = order.indexOf(anchor);
248
+ if (idx === -1) {
249
+ return false;
250
+ }
251
+ const right = idx + 1 < order.length ? order[idx + 1] : null;
252
+ return this.moveBetween(id, anchor, right, nowMicros);
253
+ }
254
+
255
+ moveBefore(id, anchor, nowMicros) {
256
+ const order = this.order();
257
+ const idx = order.indexOf(anchor);
258
+ if (idx === -1) {
259
+ return false;
260
+ }
261
+ const left = idx > 0 ? order[idx - 1] : null;
262
+ return this.moveBetween(id, left, anchor, nowMicros);
263
+ }
264
+
265
+ remove(id, nowMicros) {
266
+ const e = this.#entries.get(id);
267
+ if (!e) {
268
+ return false;
269
+ }
270
+ return e.deleted.set(true, this.#hlc.send(nowMicros));
271
+ }
272
+
273
+ contains(id) {
274
+ const e = this.#entries.get(id);
275
+ return e !== undefined && !e.deleted.value;
276
+ }
277
+
278
+ get(id) {
279
+ const e = this.#entries.get(id);
280
+ return e && !e.deleted.value ? e.value.value : undefined;
281
+ }
282
+
283
+ order() {
284
+ const live = [];
285
+ for (const [id, e] of this.#entries) {
286
+ if (!e.deleted.value) {
287
+ live.push([id, e.position.value]);
288
+ }
289
+ }
290
+ live.sort((a, b) => a[1].compareTo(b[1]));
291
+ return live.map(([id]) => id);
292
+ }
293
+
294
+ values() {
295
+ return this.order().map((id) => [id, this.get(id)]);
296
+ }
297
+
298
+ tombstoneCount() {
299
+ let count = 0;
300
+ for (const e of this.#entries.values()) {
301
+ if (e.deleted.value) {
302
+ count++;
303
+ }
304
+ }
305
+ return count;
306
+ }
307
+
308
+ // Deep-copy entries, mint a fresh Hlc for a new peer (stamps live in registers).
309
+ fork(peer) {
310
+ const copy = new SeqCrdt(peer);
311
+ for (const [id, e] of this.#entries) {
312
+ copy.#entries.set(id, e.clone());
313
+ }
314
+ return copy;
315
+ }
316
+
317
+ clone() {
318
+ return this.fork(this.#peer);
319
+ }
320
+
321
+ // State-based merge: advance clock past everything observed, then per-element
322
+ // three-way LWW merge over each independent register. Returns whether anything
323
+ // changed. Commutative, associative, idempotent.
324
+ merge(other, nowMicros) {
325
+ for (const e of other.#entries.values()) {
326
+ this.#hlc.recv(e.maxStamp(), nowMicros);
327
+ }
328
+ let changed = false;
329
+ for (const [id, oe] of other.#entries) {
330
+ const existing = this.#entries.get(id);
331
+ if (existing) {
332
+ changed = existing.value.mergeFrom(oe.value) || changed;
333
+ changed = existing.position.mergeFrom(oe.position) || changed;
334
+ changed = existing.deleted.mergeFrom(oe.deleted) || changed;
335
+ } else {
336
+ this.#entries.set(id, oe.clone());
337
+ changed = true;
338
+ }
339
+ }
340
+ return changed;
341
+ }
342
+
343
+ // Tombstone GC: collect entries where deleted == true AND the delete stamp is
344
+ // causally stable (caller-supplied policy). Observationally inert: order() and
345
+ // contains() already skip tombstones.
346
+ gcWith(isStable) {
347
+ const before = this.#entries.size;
348
+ for (const [id, e] of this.#entries) {
349
+ if (e.deleted.value && isStable(e.deleted.stamp)) {
350
+ this.#entries.delete(id);
351
+ }
352
+ }
353
+ return before - this.#entries.size;
354
+ }
355
+
356
+ gc(watermark) {
357
+ return this.gcWith((s) => s.compareTo(watermark) <= 0);
358
+ }
359
+
360
+ // Test/fixture access to the internal entry map.
361
+ entryCount() {
362
+ return this.#entries.size;
363
+ }
364
+ }
@@ -0,0 +1,45 @@
1
+ export const EDIT_THRESHOLD: 0.5;
2
+ export const ANCHOR_PREFIX: "a:";
3
+ export const CONTENT_PREFIX: "c:";
4
+
5
+ export class Block {
6
+ readonly text: string;
7
+ readonly anchor: string | null;
8
+ constructor(text: string, anchor?: string | null);
9
+ static text(text: string): Block;
10
+ static anchored(anchor: string, text: string): Block;
11
+ }
12
+
13
+ export class BlockKey {
14
+ readonly kind: "anchored" | "content";
15
+ readonly value: string | bigint;
16
+ readonly isAnchored: boolean;
17
+ readonly isContent: boolean;
18
+ constructor(kind: "anchored" | "content", value: string | bigint);
19
+ equals(other: BlockKey): boolean;
20
+ asString(): string;
21
+ }
22
+
23
+ export function normalize(text: string): string;
24
+ export function contentHash(text: string): bigint;
25
+ export function blockKey(block: Block): BlockKey;
26
+ export function similarity(a: string, b: string): number;
27
+
28
+ export class Match {
29
+ readonly kind: "same" | "edited" | "inserted";
30
+ readonly oldIndex: number;
31
+ readonly similarity: number;
32
+ constructor(kind: "same" | "edited" | "inserted", oldIndex?: number, similarity?: number);
33
+ static same(oldIndex: number): Match;
34
+ static edited(oldIndex: number, similarity: number): Match;
35
+ static inserted(): Match;
36
+ }
37
+
38
+ export class Alignment {
39
+ readonly newMatches: Match[];
40
+ readonly removed: number[];
41
+ constructor(newMatches: Match[], removed: number[]);
42
+ }
43
+
44
+ export function align(oldBlocks: Block[], newBlocks: Block[]): Alignment;
45
+ export function assignStableKeys(oldBlocks: Block[], newBlocks: Block[]): string[];