@hviana/sema 0.7.2 → 0.7.3

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.
@@ -570,9 +570,6 @@ export declare abstract class AbstractStore implements Store {
570
570
  nodeCount(): number;
571
571
  size(): Promise<number>;
572
572
  get(id: NodeId): NodeRec | null;
573
- /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
574
- * Iterative post-order on an explicit stack — the call stack never sees the
575
- * tree depth, so even an adversarial chain of nodes stays safe. */
576
573
  /** How many reads hit a MISSING node record this session (a dangling edge
577
574
  * or kid id). Zero in a healthy store; a growing count means references
578
575
  * outlive their records — the read degrades safely to empty bytes, this
@@ -582,6 +579,25 @@ export declare abstract class AbstractStore implements Store {
582
579
  * nothing is profiling. Every read below bumps it through `?.`, so an
583
580
  * unprofiled store pays one null check per read and allocates nothing. */
584
581
  meter: Meter | null;
582
+ /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
583
+ * Iterative post-order on an explicit stack — the call stack never sees the
584
+ * tree depth, so even an adversarial chain of nodes stays safe.
585
+ *
586
+ * TERMINATION. The walk memoizes into a LOCAL map, and `_bytesCache` is
587
+ * consulted only as a warm hint whose hit is immediately promoted into that
588
+ * map. It used to use `_bytesCache` itself as the memo, which is not a
589
+ * memo at all: it EVICTS, and its `"smallest"` policy prefers precisely the
590
+ * freshly-resolved small children that the pending parents on the stack are
591
+ * waiting for. A parent then finds them uncached again, re-pushes them,
592
+ * they are re-resolved, re-inserted, re-evicted — the loop makes no
593
+ * progress and never exits. Latent until the cache saturates, then
594
+ * unconditional: observed in the wild at 19.9M nodes with the 20 MB cache
595
+ * pinned at 19,999,962/20,000,000 bytes, spinning 8h45m on a node whose
596
+ * whole content was 124 bytes (5 kids, 2 of them perpetually re-evicted).
597
+ * Because the loop is synchronous, no timer could fire — the trainer's stall
598
+ * watchdog never got a turn either. A local map resolves each node at most
599
+ * once per call, so the walk terminates by construction and `_bytesCache`
600
+ * goes back to being a pure speed hint. */
585
601
  bytes(id: NodeId): Uint8Array;
586
602
  /** First `maxLen` bytes of a node. Walks only the leftmost branch,
587
603
  * stopping at `maxLen` — so a 1 MB document root costs the same as a
@@ -665,7 +681,20 @@ export declare abstract class AbstractStore implements Store {
665
681
  * common-prefix / common-suffix trim: whatever remains after both trims is
666
682
  * the single differing span (substitution, insertion or deletion), and both
667
683
  * remainders must fit the budget. Scattered differences leave a wide
668
- * middle and are rejected. */
684
+ * middle and are rejected.
685
+ *
686
+ * Every read here is CAPPED (§2.8). It used to open with
687
+ * `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, i.e. the
688
+ * full materialising `bytes()` read — on the deposit hot path, and only
689
+ * then compare lengths. So a candidate the length test was about to reject
690
+ * had already been reconstructed byte for byte. The LENGTHS decide first
691
+ * instead, from the `contentLen` memo the interning order has already built
692
+ * bottom-up, and the target's length is itself read under a cap: a target
693
+ * longer than `la + W` is rejected without touching one of its bytes.
694
+ * Same semantics — the old capped `b` read would have produced
695
+ * `a.length + W + 1` here and failed the very same test — strictly fewer
696
+ * byte reads. The `+ 1` on each byte cap keeps `_prefix`'s
697
+ * "complete reconstruction" test true, so the results still cache. */
669
698
  private differsByOneWindow;
670
699
  putLeaf(bytes: Uint8Array, gist: Vec): Promise<NodeId>;
671
700
  putBranch(kids: NodeId[], gist: Vec): Promise<NodeId>;
package/dist/src/store.js CHANGED
@@ -591,9 +591,6 @@ export class AbstractStore {
591
591
  this._recCache.set(id, rec);
592
592
  return rec;
593
593
  }
594
- /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
595
- * Iterative post-order on an explicit stack — the call stack never sees the
596
- * tree depth, so even an adversarial chain of nodes stays safe. */
597
594
  /** How many reads hit a MISSING node record this session (a dangling edge
598
595
  * or kid id). Zero in a healthy store; a growing count means references
599
596
  * outlive their records — the read degrades safely to empty bytes, this
@@ -603,6 +600,25 @@ export class AbstractStore {
603
600
  * nothing is profiling. Every read below bumps it through `?.`, so an
604
601
  * unprofiled store pays one null check per read and allocates nothing. */
605
602
  meter = null;
603
+ /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
604
+ * Iterative post-order on an explicit stack — the call stack never sees the
605
+ * tree depth, so even an adversarial chain of nodes stays safe.
606
+ *
607
+ * TERMINATION. The walk memoizes into a LOCAL map, and `_bytesCache` is
608
+ * consulted only as a warm hint whose hit is immediately promoted into that
609
+ * map. It used to use `_bytesCache` itself as the memo, which is not a
610
+ * memo at all: it EVICTS, and its `"smallest"` policy prefers precisely the
611
+ * freshly-resolved small children that the pending parents on the stack are
612
+ * waiting for. A parent then finds them uncached again, re-pushes them,
613
+ * they are re-resolved, re-inserted, re-evicted — the loop makes no
614
+ * progress and never exits. Latent until the cache saturates, then
615
+ * unconditional: observed in the wild at 19.9M nodes with the 20 MB cache
616
+ * pinned at 19,999,962/20,000,000 bytes, spinning 8h45m on a node whose
617
+ * whole content was 124 bytes (5 kids, 2 of them perpetually re-evicted).
618
+ * Because the loop is synchronous, no timer could fire — the trainer's stall
619
+ * watchdog never got a turn either. A local map resolves each node at most
620
+ * once per call, so the walk terminates by construction and `_bytesCache`
621
+ * goes back to being a pure speed hint. */
606
622
  bytes(id) {
607
623
  if (this.meter) {
608
624
  this.meter.byteReads++;
@@ -619,10 +635,22 @@ export class AbstractStore {
619
635
  return hit;
620
636
  const stack = [id];
621
637
  const cache = this._bytesCache;
638
+ // The walk's own memo. Entries are the same shared arrays `_bytesCache`
639
+ // holds (no extra copy), and it lives exactly as long as this call.
640
+ const done = new Map();
622
641
  while (stack.length > 0) {
623
642
  const nid = stack[stack.length - 1]; // peek
624
- // Already resolved by an earlier traversal.
625
- if (cache.get(nid)) {
643
+ // Already resolved by this walk — the ONLY authority the readiness test
644
+ // below trusts, because it cannot be evicted underneath us.
645
+ if (done.has(nid)) {
646
+ stack.pop();
647
+ continue;
648
+ }
649
+ // Warm hint: a hit is promoted into `done` in the same step, so from
650
+ // here on the entry is pinned for the rest of the walk.
651
+ const warm = cache.get(nid);
652
+ if (warm !== undefined) {
653
+ done.set(nid, warm);
626
654
  stack.pop();
627
655
  continue;
628
656
  }
@@ -634,21 +662,26 @@ export class AbstractStore {
634
662
  // The cache makes the empty read permanent for the session; the
635
663
  // counter survives as the visible trace.
636
664
  this.danglingReads++;
665
+ done.set(nid, _ZERO);
637
666
  cache.set(nid, _ZERO);
638
667
  stack.pop();
639
668
  continue;
640
669
  }
641
670
  if (rec.leaf) {
642
- cache.set(nid, new Uint8Array(rec.leaf));
671
+ // COPY before caching: rec.leaf is the node record's own buffer, and
672
+ // handing it out would let one mutating caller corrupt the record.
673
+ const leaf = new Uint8Array(rec.leaf);
674
+ done.set(nid, leaf);
675
+ cache.set(nid, leaf);
643
676
  stack.pop();
644
677
  continue;
645
678
  }
646
- // Branch — push any uncached children (reverse order so they resolve
647
- // left-to-right). If every child is already cached, concatenate now.
679
+ // Branch — push any unresolved children (reverse order so they resolve
680
+ // left-to-right). If every child is resolved, concatenate now.
648
681
  const kids = rec.kids ?? [];
649
682
  let ready = true;
650
683
  for (let i = kids.length - 1; i >= 0; i--) {
651
- if (!cache.get(kids[i])) {
684
+ if (!done.has(kids[i])) {
652
685
  stack.push(kids[i]);
653
686
  ready = false;
654
687
  }
@@ -656,10 +689,11 @@ export class AbstractStore {
656
689
  if (!ready)
657
690
  continue;
658
691
  stack.pop();
659
- const out = concat(kids.map((k) => cache.get(k)));
692
+ const out = concat(kids.map((k) => done.get(k)));
693
+ done.set(nid, out);
660
694
  cache.set(nid, out);
661
695
  }
662
- const out = cache.get(id) ?? _ZERO;
696
+ const out = done.get(id) ?? _ZERO;
663
697
  if (this.meter)
664
698
  this.meter.bytesRead += out.length;
665
699
  return out;
@@ -1189,10 +1223,33 @@ export class AbstractStore {
1189
1223
  * common-prefix / common-suffix trim: whatever remains after both trims is
1190
1224
  * the single differing span (substitution, insertion or deletion), and both
1191
1225
  * remainders must fit the budget. Scattered differences leave a wide
1192
- * middle and are rejected. */
1226
+ * middle and are rejected.
1227
+ *
1228
+ * Every read here is CAPPED (§2.8). It used to open with
1229
+ * `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, i.e. the
1230
+ * full materialising `bytes()` read — on the deposit hot path, and only
1231
+ * then compare lengths. So a candidate the length test was about to reject
1232
+ * had already been reconstructed byte for byte. The LENGTHS decide first
1233
+ * instead, from the `contentLen` memo the interning order has already built
1234
+ * bottom-up, and the target's length is itself read under a cap: a target
1235
+ * longer than `la + W` is rejected without touching one of its bytes.
1236
+ * Same semantics — the old capped `b` read would have produced
1237
+ * `a.length + W + 1` here and failed the very same test — strictly fewer
1238
+ * byte reads. The `+ 1` on each byte cap keeps `_prefix`'s
1239
+ * "complete reconstruction" test true, so the results still cache. */
1193
1240
  differsByOneWindow(kids, targetId, W) {
1194
- const a = concat(kids.map((k) => this.bytesPrefix(k, Number.MAX_SAFE_INTEGER)));
1195
- const b = this.bytesPrefix(targetId, a.length + W + 1);
1241
+ const lens = kids.map((k) => this.contentLen(k));
1242
+ let la = 0;
1243
+ for (const n of lens)
1244
+ la += n;
1245
+ const cap = la + W + 1;
1246
+ // `contentLen` under a cap returns a clamped LOWER BOUND once the partial
1247
+ // sum reaches it, so `>= cap` is exactly "longer than la + W".
1248
+ const lb = this.contentLen(targetId, cap);
1249
+ if (lb >= cap || Math.abs(la - lb) > W)
1250
+ return false;
1251
+ const a = concat(kids.map((k, i) => this.bytesPrefix(k, lens[i] + 1)));
1252
+ const b = this.bytesPrefix(targetId, lb + 1);
1196
1253
  if (Math.abs(a.length - b.length) > W)
1197
1254
  return false;
1198
1255
  const n = Math.min(a.length, b.length);
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://jsr.io/schema/config-file.v1.json",
3
3
  "name": "@hviana/sema",
4
- "version": "0.7.2",
4
+ "version": "0.7.3",
5
5
  "exports": "./src/index.ts"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/store.ts CHANGED
@@ -1133,9 +1133,6 @@ export abstract class AbstractStore implements Store {
1133
1133
  return rec;
1134
1134
  }
1135
1135
 
1136
- /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
1137
- * Iterative post-order on an explicit stack — the call stack never sees the
1138
- * tree depth, so even an adversarial chain of nodes stays safe. */
1139
1136
  /** How many reads hit a MISSING node record this session (a dangling edge
1140
1137
  * or kid id). Zero in a healthy store; a growing count means references
1141
1138
  * outlive their records — the read degrades safely to empty bytes, this
@@ -1147,6 +1144,25 @@ export abstract class AbstractStore implements Store {
1147
1144
  * unprofiled store pays one null check per read and allocates nothing. */
1148
1145
  meter: Meter | null = null;
1149
1146
 
1147
+ /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
1148
+ * Iterative post-order on an explicit stack — the call stack never sees the
1149
+ * tree depth, so even an adversarial chain of nodes stays safe.
1150
+ *
1151
+ * TERMINATION. The walk memoizes into a LOCAL map, and `_bytesCache` is
1152
+ * consulted only as a warm hint whose hit is immediately promoted into that
1153
+ * map. It used to use `_bytesCache` itself as the memo, which is not a
1154
+ * memo at all: it EVICTS, and its `"smallest"` policy prefers precisely the
1155
+ * freshly-resolved small children that the pending parents on the stack are
1156
+ * waiting for. A parent then finds them uncached again, re-pushes them,
1157
+ * they are re-resolved, re-inserted, re-evicted — the loop makes no
1158
+ * progress and never exits. Latent until the cache saturates, then
1159
+ * unconditional: observed in the wild at 19.9M nodes with the 20 MB cache
1160
+ * pinned at 19,999,962/20,000,000 bytes, spinning 8h45m on a node whose
1161
+ * whole content was 124 bytes (5 kids, 2 of them perpetually re-evicted).
1162
+ * Because the loop is synchronous, no timer could fire — the trainer's stall
1163
+ * watchdog never got a turn either. A local map resolves each node at most
1164
+ * once per call, so the walk terminates by construction and `_bytesCache`
1165
+ * goes back to being a pure speed hint. */
1150
1166
  bytes(id: NodeId): Uint8Array {
1151
1167
  if (this.meter) {
1152
1168
  this.meter.byteReads++;
@@ -1162,12 +1178,25 @@ export abstract class AbstractStore implements Store {
1162
1178
 
1163
1179
  const stack: NodeId[] = [id];
1164
1180
  const cache = this._bytesCache;
1181
+ // The walk's own memo. Entries are the same shared arrays `_bytesCache`
1182
+ // holds (no extra copy), and it lives exactly as long as this call.
1183
+ const done = new Map<NodeId, Uint8Array>();
1165
1184
 
1166
1185
  while (stack.length > 0) {
1167
1186
  const nid = stack[stack.length - 1]; // peek
1168
1187
 
1169
- // Already resolved by an earlier traversal.
1170
- if (cache.get(nid)) {
1188
+ // Already resolved by this walk — the ONLY authority the readiness test
1189
+ // below trusts, because it cannot be evicted underneath us.
1190
+ if (done.has(nid)) {
1191
+ stack.pop();
1192
+ continue;
1193
+ }
1194
+
1195
+ // Warm hint: a hit is promoted into `done` in the same step, so from
1196
+ // here on the entry is pinned for the rest of the walk.
1197
+ const warm = cache.get(nid);
1198
+ if (warm !== undefined) {
1199
+ done.set(nid, warm);
1171
1200
  stack.pop();
1172
1201
  continue;
1173
1202
  }
@@ -1180,22 +1209,27 @@ export abstract class AbstractStore implements Store {
1180
1209
  // The cache makes the empty read permanent for the session; the
1181
1210
  // counter survives as the visible trace.
1182
1211
  this.danglingReads++;
1212
+ done.set(nid, _ZERO);
1183
1213
  cache.set(nid, _ZERO);
1184
1214
  stack.pop();
1185
1215
  continue;
1186
1216
  }
1187
1217
  if (rec.leaf) {
1188
- cache.set(nid, new Uint8Array(rec.leaf));
1218
+ // COPY before caching: rec.leaf is the node record's own buffer, and
1219
+ // handing it out would let one mutating caller corrupt the record.
1220
+ const leaf = new Uint8Array(rec.leaf);
1221
+ done.set(nid, leaf);
1222
+ cache.set(nid, leaf);
1189
1223
  stack.pop();
1190
1224
  continue;
1191
1225
  }
1192
1226
 
1193
- // Branch — push any uncached children (reverse order so they resolve
1194
- // left-to-right). If every child is already cached, concatenate now.
1227
+ // Branch — push any unresolved children (reverse order so they resolve
1228
+ // left-to-right). If every child is resolved, concatenate now.
1195
1229
  const kids = rec.kids ?? [];
1196
1230
  let ready = true;
1197
1231
  for (let i = kids.length - 1; i >= 0; i--) {
1198
- if (!cache.get(kids[i])) {
1232
+ if (!done.has(kids[i])) {
1199
1233
  stack.push(kids[i]);
1200
1234
  ready = false;
1201
1235
  }
@@ -1203,11 +1237,12 @@ export abstract class AbstractStore implements Store {
1203
1237
  if (!ready) continue;
1204
1238
 
1205
1239
  stack.pop();
1206
- const out = concat(kids.map((k) => cache.get(k)!));
1240
+ const out = concat(kids.map((k) => done.get(k)!));
1241
+ done.set(nid, out);
1207
1242
  cache.set(nid, out);
1208
1243
  }
1209
1244
 
1210
- const out = cache.get(id) ?? _ZERO;
1245
+ const out = done.get(id) ?? _ZERO;
1211
1246
  if (this.meter) this.meter.bytesRead += out.length;
1212
1247
  return out;
1213
1248
  }
@@ -1730,16 +1765,35 @@ export abstract class AbstractStore implements Store {
1730
1765
  * common-prefix / common-suffix trim: whatever remains after both trims is
1731
1766
  * the single differing span (substitution, insertion or deletion), and both
1732
1767
  * remainders must fit the budget. Scattered differences leave a wide
1733
- * middle and are rejected. */
1768
+ * middle and are rejected.
1769
+ *
1770
+ * Every read here is CAPPED (§2.8). It used to open with
1771
+ * `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, i.e. the
1772
+ * full materialising `bytes()` read — on the deposit hot path, and only
1773
+ * then compare lengths. So a candidate the length test was about to reject
1774
+ * had already been reconstructed byte for byte. The LENGTHS decide first
1775
+ * instead, from the `contentLen` memo the interning order has already built
1776
+ * bottom-up, and the target's length is itself read under a cap: a target
1777
+ * longer than `la + W` is rejected without touching one of its bytes.
1778
+ * Same semantics — the old capped `b` read would have produced
1779
+ * `a.length + W + 1` here and failed the very same test — strictly fewer
1780
+ * byte reads. The `+ 1` on each byte cap keeps `_prefix`'s
1781
+ * "complete reconstruction" test true, so the results still cache. */
1734
1782
  private differsByOneWindow(
1735
1783
  kids: NodeId[],
1736
1784
  targetId: NodeId,
1737
1785
  W: number,
1738
1786
  ): boolean {
1739
- const a = concat(
1740
- kids.map((k) => this.bytesPrefix(k, Number.MAX_SAFE_INTEGER)),
1741
- );
1742
- const b = this.bytesPrefix(targetId, a.length + W + 1);
1787
+ const lens = kids.map((k) => this.contentLen(k));
1788
+ let la = 0;
1789
+ for (const n of lens) la += n;
1790
+ const cap = la + W + 1;
1791
+ // `contentLen` under a cap returns a clamped LOWER BOUND once the partial
1792
+ // sum reaches it, so `>= cap` is exactly "longer than la + W".
1793
+ const lb = this.contentLen(targetId, cap);
1794
+ if (lb >= cap || Math.abs(la - lb) > W) return false;
1795
+ const a = concat(kids.map((k, i) => this.bytesPrefix(k, lens[i] + 1)));
1796
+ const b = this.bytesPrefix(targetId, lb + 1);
1743
1797
  if (Math.abs(a.length - b.length) > W) return false;
1744
1798
  const n = Math.min(a.length, b.length);
1745
1799
  let i = 0;
@@ -0,0 +1,115 @@
1
+ // 96-bytes-walk-termination.test.mjs — `bytes()` must TERMINATE when its
2
+ // traversal memo cannot hold the reconstruction's working set.
3
+ //
4
+ // `bytes()` is an iterative post-order walk: it peeks the top of an explicit
5
+ // stack, pushes any child that is not yet resolved, and concatenates once every
6
+ // child is. Its termination argument needs a memo that only ever GROWS. It
7
+ // used `_bytesCache` — a byte-accounted BoundedMap that EVICTS, and whose
8
+ // `"smallest"` policy deliberately prefers the cheapest-to-rebuild entries,
9
+ // i.e. exactly the small, freshly-resolved children the parents still sitting
10
+ // on the stack are waiting for. The parent finds them unresolved again,
11
+ // re-pushes them, they are re-resolved, re-inserted and re-evicted. No
12
+ // progress. The loop never exits.
13
+ //
14
+ // It stayed hidden because it needs the cache to be SATURATED, which only a
15
+ // long run reaches. OBSERVED IN THE FIELD: a training run at 19.9M nodes with
16
+ // the 20 MB cache pinned at 19,999,962 bytes spun for 8h45m of 100% CPU on one
17
+ // node — whose entire content was 124 bytes, with 5 kids, 2 of them
18
+ // perpetually re-evicted. Sampled 45s apart through the V8 inspector, the
19
+ // walk's root id, the node's length and the cache's byte count were all
20
+ // identical; only the stack depth oscillated between 1 and 2.
21
+ //
22
+ // And the loop is SYNCHRONOUS, so nothing could observe it: the trainer's
23
+ // 15-minute stall watchdog is a timer, and a timer cannot fire while the
24
+ // microtask/JS stack is occupied. The run looked alive for 9 hours.
25
+ //
26
+ // The fix makes the walk memoize into a LOCAL map (`_bytesCache` demoted to a
27
+ // warm hint whose hit is promoted into that map immediately), so each node
28
+ // resolves at most once per call and termination is structural.
29
+ //
30
+ // This test does NOT depend on the eviction cursor's position — the field case
31
+ // reached the defect stochastically, via the cursor sweeping the map's
32
+ // recently-inserted tail. Here the subtree's children simply sum to more bytes
33
+ // than the ceiling, so no cursor position can save it: pre-fix this file hangs
34
+ // forever, and `node --test` reports it only as a timeout.
35
+ import { test } from "node:test";
36
+ import assert from "node:assert/strict";
37
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
38
+
39
+ const D = 64;
40
+ const gist = () => {
41
+ const v = new Float32Array(D);
42
+ v[0] = 1;
43
+ return v;
44
+ };
45
+
46
+ test("bytes() terminates when its memo cannot hold the working set", async () => {
47
+ // A ceiling small enough to saturate in milliseconds. The mechanism is
48
+ // scale-free: this is the only thing scaled down from the field case.
49
+ const CEILING = 4096;
50
+ const store = new SQliteStore({
51
+ D,
52
+ bytesCacheMax: CEILING,
53
+ path: ":memory:",
54
+ });
55
+
56
+ // One ordinary 9,600-byte form: 300 distinct 32-byte children under a root.
57
+ const KIDS = 300, WIDTH = 32;
58
+ const kids = [];
59
+ for (let b = 0; b < KIDS; b++) {
60
+ const buf = new Uint8Array(WIDTH);
61
+ for (let i = 0; i < WIDTH; i++) buf[i] = (b * 131 + i * 17) & 0xff;
62
+ kids.push(await store.putLeaf(buf, gist()));
63
+ }
64
+ const root = await store.putBranch(kids, gist());
65
+ const expected = store.contentLen(root);
66
+ assert.equal(expected, KIDS * WIDTH);
67
+
68
+ // Churn the subtree out of the memo and leave it AT its ceiling — the steady
69
+ // state every long run reaches.
70
+ for (let k = 0; k < 4000; k++) {
71
+ const buf = new Uint8Array(48);
72
+ for (let i = 0; i < 48; i++) buf[i] = (k * 7919 + i * 251) & 0xff;
73
+ await store.putLeaf(buf, gist());
74
+ }
75
+
76
+ // The premise the guard rests on: the memo genuinely cannot hold the working
77
+ // set. If a future change grows the ceiling or shrinks the fixture, this
78
+ // assertion fails LOUDLY rather than letting the test pass vacuously.
79
+ assert.ok(
80
+ KIDS * WIDTH > CEILING,
81
+ `fixture must exceed the memo ceiling (${KIDS * WIDTH} vs ${CEILING})`,
82
+ );
83
+
84
+ // Pre-fix this call never returns. Post-fix it is sub-millisecond.
85
+ const out = store.bytes(root);
86
+ assert.equal(out.length, expected);
87
+ for (let b = 0; b < KIDS; b++) {
88
+ for (let i = 0; i < WIDTH; i++) {
89
+ assert.equal(out[b * WIDTH + i], (b * 131 + i * 17) & 0xff);
90
+ }
91
+ }
92
+ });
93
+
94
+ test("differsByOneWindow's reads are capped — no ALL-sentinel read on deposit", async () => {
95
+ // §2.8: the near-dedup byte check used to open with
96
+ // `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, which routes
97
+ // to the full materialising `bytes()` — and only THEN compare lengths. A
98
+ // candidate the length test was about to reject had already been rebuilt byte
99
+ // for byte, and that read is what dragged the deposit path into the walk
100
+ // above. Lengths now decide first, from the `contentLen` memo.
101
+ const src = await import("node:fs").then((fs) =>
102
+ fs.readFileSync(new URL("../src/store.ts", import.meta.url), "utf8")
103
+ );
104
+ const body = src.slice(src.indexOf("private differsByOneWindow"));
105
+ const end = body.indexOf("\n }\n");
106
+ const fn = body.slice(0, end);
107
+ assert.ok(
108
+ !fn.includes("MAX_SAFE_INTEGER"),
109
+ "differsByOneWindow must not read with the ALL sentinel",
110
+ );
111
+ assert.ok(
112
+ fn.includes("this.contentLen("),
113
+ "differsByOneWindow must decide on lengths before reading bytes",
114
+ );
115
+ });