@hviana/sema 0.7.1 → 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.
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.1",
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.1",
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/meter.ts CHANGED
@@ -176,6 +176,13 @@ export class Meter {
176
176
  /** Nodes popped by those ascents, against their √N·W budget — the counter
177
177
  * that shows whether the walks are deciding early or burning the budget. */
178
178
  junctionPops = 0;
179
+ /** Ascents that ended by EXHAUSTING the expansion budget rather than by
180
+ * deciding — the walk abstained and the caller silently fell through to a
181
+ * lower tier of the ladder (§2.13: a degradation nothing else reports).
182
+ * It rises the moment a SHARED budget is drained by an earlier walk, which
183
+ * is what makes "this tier answered nothing" distinguishable from "this
184
+ * tier never got to look". */
185
+ junctionBudgetExhausted = 0;
179
186
  /** Arbitrary byte spans whose distributional company was VSA-bundled from
180
187
  * existing episode halos. */
181
188
  spanHalos = 0;
@@ -231,6 +238,25 @@ export class Meter {
231
238
  }
232
239
  }
233
240
 
241
+ /** Time one SYNCHRONOUS phase. The sync/async seam (§2.10) is a real
242
+ * contract — perception, recognition and the graph search are synchronous —
243
+ * so a synchronous layer must not be wrapped in `time`'s promise just to be
244
+ * measured: that would make the profiled path await where the unprofiled
245
+ * one does not, and a meter never changes what a layer computes. */
246
+ timeSync<T>(phase: string, fn: () => T): T {
247
+ const before = this.snapshot();
248
+ const t = performance.now();
249
+ try {
250
+ return fn();
251
+ } finally {
252
+ const ms = performance.now() - t;
253
+ const after = this.snapshot();
254
+ const delta: Record<string, number> = {};
255
+ for (const k of Object.keys(after)) delta[k] = after[k] - before[k];
256
+ this.charge(phase, ms, delta);
257
+ }
258
+ }
259
+
234
260
  /** Time one async phase and attribute the work done inside it. Returns
235
261
  * the awaited value untouched — a meter never changes what a layer
236
262
  * computes, only what is known about it. */
@@ -264,7 +264,18 @@ export function junctionContainersFrom(
264
264
  id,
265
265
  d: 0,
266
266
  }));
267
- while (stack.length > 0 && out.length < bound && b.n-- > 0) {
267
+ while (stack.length > 0 && out.length < bound) {
268
+ // BUDGET EXHAUSTION IS AN ABSTENTION, AND IT MUST BE VISIBLE (§2.13). The
269
+ // walk stops with work still on the stack, the caller reads "no container"
270
+ // and falls through to a lower ladder rung — indistinguishable, from the
271
+ // outside, from a walk that looked everywhere and found nothing. With a
272
+ // SHARED budget (cross-region's one k·W allowance per tier) an EARLIER
273
+ // pair can drain it, so a later pair's exact tier may never run at all;
274
+ // this counter is the only thing that says so.
275
+ if (b.n-- <= 0) {
276
+ if (ctx.meter) ctx.meter.junctionBudgetExhausted++;
277
+ break;
278
+ }
268
279
  const { id: x, d } = stack.pop()!;
269
280
  if (ctx.meter) ctx.meter.junctionPops++;
270
281
  const f = cachedRead(ctx, cache, x, maxContainer);
@@ -45,15 +45,23 @@
45
45
  // from `resonate(k)` at k = 24, 256 AND 2048 — while forms scoring LOWER
46
46
  // (Germany 0.5670, Yemen 0.5591) are returned. `k` only reorders WITHIN
47
47
  // the IVF clusters already probed, exactly as Store.resonate's doc warns,
48
- // so no k recovers it. With `exhaustive` it ranks 8.
48
+ // so no k recovers it.
49
49
  //
50
- // So this is a RETRIEVABILITY gap, not a semantic one, and it is repaired by
51
- // reading the candidate list recall's refusal path has ALREADY fetched
52
- // exhaustively for the substitution bridgenever by resonating on its own.
53
- // Measured cost of the scan over those 570 candidates: 2.9 ms warm, 20.4 ms
54
- // cold, against a ~700 ms refusal path. Issuing a FRESH exhaustive call would
55
- // cost 490 ms median against 13 ms non-exhaustive (36×), which is why this tier
56
- // takes the candidate list as an argument and adds nothing to it.
50
+ // So this is a RETRIEVABILITY gap, not a semantic one, and the ANN is the wrong
51
+ // instrument for it: a proper prefix's gist cannot rank its own continuation.
52
+ // The repair is CONTENT-ADDRESSED (§2.3)`formsOpenedBy` (traverse.ts) reads
53
+ // the leaf-id WINDOW index the write side already maintains and answers "which
54
+ // trained forms does this byte run open?" in a bounded √N walk. That is this
55
+ // mechanism's first supply. The response's memoised top-k `resonance()` is the
56
+ // second, for prefixes long enough that the gist still ranks the form; it is
57
+ // read, never re-issued.
58
+ //
59
+ // AN EXHAUSTIVE ANN LIST IS NOT A SUPPLY HERE, AND WAS REMOVED. This tier once
60
+ // read `Precomputed.wideResonance()` — a full-index `resonate(guide, √N,
61
+ // exhaustive)` — on the argument that the target "ranks 8 with `exhaustive`".
62
+ // It bought an O(k) need at O(index) cost (measured: 244K annVectorReads per
63
+ // refusing query, ~1.5 s) for candidates the window index proposes directly.
64
+ // See pipeline-mechanism.ts's REMOVED note; test/95 pins its absence.
57
65
  //
58
66
  // THREE GUARDS, each falsified into existence by measurement — do not drop any:
59
67
  //
@@ -114,7 +122,8 @@ export interface PrefixCompletion {
114
122
  * it, when the continuation is sub-quantum, when a candidate's continuation
115
123
  * cannot be read through, or when the candidates disagree.
116
124
  *
117
- * `ranked` must be a list the caller has ALREADY fetched; this mechanism never
125
+ * `ranked` must be a list the caller has ALREADY fetched (the write side's
126
+ * window index, or the response's memoised top-k); this mechanism never
118
127
  * resonates on its own (see the header's cost note). */
119
128
  export function prefixCompletion(
120
129
  ctx: MindContext,
@@ -264,16 +273,25 @@ export const prefixMechanism: PipelineMechanism = {
264
273
  return STEP;
265
274
  },
266
275
  async run(ctx, query, pre) {
267
- // The write side's window index proposes FIRST: a proper prefix's gist
268
- // cannot rank its own continuation (cos falls below reachThreshold at a
269
- // few bytes of truncation), so the content-addressed window walk is the
270
- // correct measure for this question (§2.3), and it is a bounded √N walk —
271
- // cheaper than an exhaustive ANN. The top-k resonance list is the SECOND
272
- // supply, for prefixes long enough that the gist still ranks the form. A
273
- // second SUPPLY, not a second mechanism the same three guards decide
274
- // either way.
275
- const completed = prefixCompletion(ctx, query, formsOpenedBy(ctx, query)) ??
276
- prefixCompletion(ctx, query, (await pre.resonance()).map((h) => h.id));
276
+ // ONE SUPPLY PASS, not a two-tier `??`. The window index (exact,
277
+ // content-addressed) and the response's memoised top-k (approximate) are
278
+ // concatenated and the three guards decide ONCE over the union. A
279
+ // first-then-fallback chain would let the APPROXIMATE tier override the
280
+ // EXACT one (§2.3): when formsOpenedBy finds two continuations, guard 3
281
+ // returns null and the fallback re-runs the guards on resonance's top-k
282
+ // alone — which, seeing only one of the two forms, would voice it. That is
283
+ // precisely the disagreement-suppression guard 3 exists to prevent, and it
284
+ // is the exact tier's ambiguity being washed away by the approximate tier.
285
+ // Evaluating the union means a disagreement the window index saw can never
286
+ // be hidden by what the ANN happens to rank. The ANN read is the
287
+ // response's ONE memoised top-k (§2.11), already paid by recall's refusal
288
+ // path on the queries where this mechanism fires, so reading it here is not
289
+ // a second index scan.
290
+ const ids = [
291
+ ...formsOpenedBy(ctx, query),
292
+ ...(await pre.resonance()).map((h) => h.id),
293
+ ];
294
+ const completed = prefixCompletion(ctx, query, ids);
277
295
  if (completed === null) return [];
278
296
  return [{
279
297
  bytes: completed.form,
@@ -144,8 +144,9 @@ export interface RegimePredictionData {
144
144
  /** retrieval | composition — the two regimes R1 measured as a ~100× cost
145
145
  * step. */
146
146
  regime: "retrieval" | "composition";
147
- /** The incumbent's grade right after the first mechanism ran, or null when
148
- * it grounded nothing (best === null composition, with no incumbent). */
147
+ /** The incumbent's grade once the first mechanism's turn is over (it ran, or
148
+ * it was skipped), or null when nothing has grounded `best === null`,
149
+ * which is composition with no incumbent. */
149
150
  incumbentGrade: number | null;
150
151
  /** The cheapest composition floor in grade units (`grade(2 * STEP)` = 2,
151
152
  * CAST's floor) — the bar the incumbent must sit at or below for the
@@ -190,8 +191,11 @@ export async function think(
190
191
  // own store work (perceive → foldTree → resolve), which used to land in
191
192
  // `think` and in nothing narrower — the meter's one accounting surface must
192
193
  // charge it to itself, exactly as attention/weave/resonance are charged.
194
+ // SYNCHRONOUS phase: recognition is on the sync side of §2.10's seam, so it
195
+ // is timed with `timeSync` — wrapping it in a promise would make a profiled
196
+ // response await where an unprofiled one does not.
193
197
  const rec = meter
194
- ? await meter.time("recognise", async () => recognise(ctx, query))
198
+ ? meter.timeSync("recognise", () => recognise(ctx, query))
195
199
  : recognise(ctx, query);
196
200
 
197
201
  // Phase 1: collect computed spans from mechanisms that implement parse()
@@ -294,12 +298,66 @@ export async function think(
294
298
  const worthRunning = (floor: number) =>
295
299
  best === null || grade(floor) < grade(best.weight);
296
300
 
301
+ // REGIME PREDICTION (R8) — observational only. Once the FIRST mechanism has
302
+ // had its turn (cover, which §2.6 places first and floors at 0), the market's
303
+ // outcome is already determined by the one cost ladder: the consensus climb
304
+ // runs exactly when `worthRunning(2 * STEP)` is true — CAST (floor 2·STEP) is
305
+ // the cheapest mechanism that first-touches it, so an incumbent at or below
306
+ // grade 2 prunes CAST and, with it, confluence (3·STEP) and extraction
307
+ // (CONCEPT+STEP) (retrieval); anything above — or no incumbent — runs the
308
+ // full market and the climb (composition). The predicate is `worthRunning`,
309
+ // the same function the loop itself uses — nothing is computed here that the
310
+ // engine had not already computed, and nothing is read back by inference.
311
+ //
312
+ // EMITTED BEFORE THE SECOND MECHANISM'S FLOOR, never after some mechanism's
313
+ // run: a "prediction" published after the fact could assert "the climb will
314
+ // not run" about a climb that already ran — which is what happens whenever
315
+ // the first mechanism is SKIPPED (null floor or pruned) and the block sits at
316
+ // the end of the first mechanism that actually ran. Emitting on entry to
317
+ // iteration 1 makes the claim true by construction, whatever the first
318
+ // mechanism did, and keeps the payload identical on the ordinary path (the
319
+ // incumbent cannot change between the two positions).
320
+ let regimeReported = false;
321
+ const reportRegime = () => {
322
+ if (regimeReported) return;
323
+ regimeReported = true;
324
+ const climbFloorGrade = grade(2 * STEP);
325
+ // TS narrows `best` to null in the outer flow (it cannot see the closure
326
+ // assignments in `consider`) — cast back, the same read-back as `decided`
327
+ // below.
328
+ const incumbent = best as Candidate | null;
329
+ const incumbentGrade = incumbent === null ? null : grade(incumbent.weight);
330
+ const regime: "retrieval" | "composition" = worthRunning(2 * STEP)
331
+ ? "composition"
332
+ : "retrieval";
333
+ ctx.trace?.step(
334
+ "regimePrediction",
335
+ [rItem(query, "query")],
336
+ [],
337
+ regime === "retrieval"
338
+ ? `retrieval regime — incumbent grade ${incumbentGrade} ≤ climb floor ${climbFloorGrade}, ` +
339
+ `so no mechanism floored above that grade runs; the consensus climb will not run`
340
+ : `composition regime — ${
341
+ incumbentGrade === null
342
+ ? "no incumbent (nothing grounded)"
343
+ : `incumbent grade ${incumbentGrade}`
344
+ } above climb floor ${climbFloorGrade}, so the full market and climb run`,
345
+ undefined,
346
+ {
347
+ version: 1,
348
+ regime,
349
+ incumbentGrade,
350
+ climbFloorGrade,
351
+ } satisfies RegimePredictionData,
352
+ );
353
+ };
297
354
  // Phase 3: grounding loop
298
355
  // Per-mechanism accounting (src/meter.ts). The market's whole premise is
299
356
  // that mechanisms compete on one cost scale — so the profiling read-out is
300
357
  // also per-mechanism, uniformly: the loop never asks which one it holds.
301
- let regimeReported = false;
302
- for (const mech of mechanisms) {
358
+ for (let mi = 0; mi < mechanisms.length; mi++) {
359
+ const mech = mechanisms[mi];
360
+ if (mi > 0) reportRegime();
303
361
  const floor = meter
304
362
  ? await meter.time(
305
363
  `${mech.name}.floor`,
@@ -347,52 +405,11 @@ export async function think(
347
405
  scaffolding: r.scaffolding,
348
406
  });
349
407
  }
350
- // REGIME PREDICTION (R8) — observational only. After the FIRST mechanism
351
- // runs (cover, which §2.6 places first and floors at 0), the market's
352
- // outcome is already determined: the consensus climb runs exactly when
353
- // `worthRunning(2 * STEP)` is true — CAST (floor 2·STEP) is the cheapest
354
- // mechanism that first-touches it, so an incumbent at or below grade 2
355
- // prunes CAST and, with it, confluence (3·STEP) and extraction
356
- // (CONCEPT+STEP) (retrieval); anything above — or no incumbent — runs the
357
- // full market and the climb (composition). The predicate is
358
- // `worthRunning`, the same function the loop just used — nothing is
359
- // computed here that the engine had not already computed, and nothing is
360
- // read back by inference.
361
- if (!regimeReported) {
362
- regimeReported = true;
363
- const climbFloorGrade = grade(2 * STEP);
364
- // TS narrows `best` to null in the outer flow (it cannot see the closure
365
- // assignments in `consider`) — cast back, the same read-back as `decided`
366
- // below.
367
- const incumbent = best as Candidate | null;
368
- const incumbentGrade = incumbent === null
369
- ? null
370
- : grade(incumbent.weight);
371
- const regime: "retrieval" | "composition" = worthRunning(2 * STEP)
372
- ? "composition"
373
- : "retrieval";
374
- ctx.trace?.step(
375
- "regimePrediction",
376
- [rItem(query, "query")],
377
- [],
378
- regime === "retrieval"
379
- ? `retrieval regime — incumbent grade ${incumbentGrade} ≤ climb floor ${climbFloorGrade}, so no composition mechanism runs; ` +
380
- `the consensus climb will not run`
381
- : `composition regime — ${
382
- incumbentGrade === null
383
- ? "no incumbent (nothing grounded)"
384
- : `incumbent grade ${incumbentGrade}`
385
- } above climb floor ${climbFloorGrade}, so the full market and climb run`,
386
- undefined,
387
- {
388
- version: 1,
389
- regime,
390
- incumbentGrade,
391
- climbFloorGrade,
392
- } satisfies RegimePredictionData,
393
- );
394
- }
395
408
  }
409
+ // A market of ONE mechanism never reaches iteration 1; the step is still
410
+ // emitted exactly once per think(), so a consumer never has to ask whether
411
+ // the list was long enough for the prediction to exist.
412
+ reportRegime();
396
413
 
397
414
  // (TS cannot see the closure assignments into `best` and narrows it to its
398
415
  // initial null, so the read-back needs the assertion.)
@@ -592,7 +609,7 @@ export async function think(
592
609
  reasoned,
593
610
  pre,
594
611
  unclimbed,
595
- decided.accounted,
612
+ primarySpans,
596
613
  );
597
614
 
598
615
  done(
@@ -32,16 +32,24 @@ import type { Leaf, Site } from "./graph-search.js";
32
32
  * the longest known leaf, chained into flat branches. Names forms the
33
33
  * query's own cut cannot, and records sub-leaf boundaries as `splits`.
34
34
  *
35
- * Both O(n · maxGroup) bounded O(1) probes — never a scan of the corpus. */
36
- /** Decompose `bytes` into the learnt forms it contains. `trimmed` skips the
37
- * edge-trim fallbacks (which recover misaligned FRAGMENTS) for callers whose
38
- * own gate rejects fragments anyway (the pivot), so the O(n·W²) trim search is
39
- * paid only where its output can be used. Byte-identical for every caller
40
- * that keeps only top-level forms. */
35
+ * Both O(n · maxGroup) bounded O(1) probes — never a scan of the corpus.
36
+ *
37
+ * ONE READING PER BYTE STREAM, deliberately: there is no "cheap mode" that
38
+ * skips the edge-trim fallbacks. A `trimmed` variant was tried and REFUTED
39
+ * twice over. Its premise "the trims only recover misaligned FRAGMENTS, so
40
+ * a consumer whose gate rejects fragments loses nothing" — is false: the
41
+ * left/right trim loops below exist precisely to find WHOLE trained forms
42
+ * embedded at an offset the query's own fold did not cut, and such a form has
43
+ * no structural parents or containers, so it passes the pivot's fragment gate
44
+ * and is exactly the candidate a multi-hop chain steps through. Skipping them
45
+ * narrows the pivot's evidence silently. And a per-caller variant has to key
46
+ * the memo by the variant, which breaks the "computed at most once" property
47
+ * (§2.11): the pipeline recognises a grounded answer untrimmed for
48
+ * `preConsumed`, and the pivot then recognises the same bytes again — the
49
+ * saving inverts into a doubling on the path it was measured for. */
41
50
  export function recognise(
42
51
  ctx: MindContext,
43
52
  bytes: Uint8Array,
44
- trimmed = false,
45
53
  ): Recognition {
46
54
  // Content-keyed memo — works for both single-turn respond() and multi-turn
47
55
  // respondTurn() (where the map persists across calls). ALWAYS consulted,
@@ -82,7 +90,7 @@ export function recognise(
82
90
  // not silent), so it is emitted here directly rather than only inside
83
91
  // recogniseImpl.
84
92
  if (ctx.recogniseMemo) {
85
- const key = (trimmed ? "t" : "f") + latin1Key(bytes);
93
+ const key = latin1Key(bytes);
86
94
  const hit = ctx.recogniseMemo.get(key);
87
95
  if (hit !== undefined) {
88
96
  if (ctx.meter) ctx.meter.recogniseHits++;
@@ -100,18 +108,14 @@ export function recognise(
100
108
  );
101
109
  return hit;
102
110
  }
103
- const fresh = recogniseImpl(ctx, bytes, trimmed);
111
+ const fresh = recogniseImpl(ctx, bytes);
104
112
  ctx.recogniseMemo.set(key, fresh);
105
113
  return fresh;
106
114
  }
107
- return recogniseImpl(ctx, bytes, trimmed);
115
+ return recogniseImpl(ctx, bytes);
108
116
  }
109
117
 
110
- function recogniseImpl(
111
- ctx: MindContext,
112
- bytes: Uint8Array,
113
- trimmed = false,
114
- ): Recognition {
118
+ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
115
119
  if (ctx.meter) {
116
120
  ctx.meter.recognitions++;
117
121
  ctx.meter.recognisedBytes += bytes.length;
@@ -224,7 +228,7 @@ function recogniseImpl(
224
228
  // n.kids !== null enforces above) rather than degenerate into
225
229
  // single-byte-atom territory, which atomIsHub already governs
226
230
  // separately.
227
- else if (!trimmed && end - start - 1 >= 2) {
231
+ else if (end - start - 1 >= 2) {
228
232
  // The chunk's own boundary is drawn by content geometry, not by
229
233
  // any notion of "form" — it can include one edge byte the query's
230
234
  // fold happened to attach here that the trained span never had
@@ -365,13 +365,13 @@ export async function pivotInto(
365
365
  }
366
366
  for (const c of n.kids) queue.push(c); // breadth-first: larger regions first
367
367
  }
368
- // TRIMMED recognition: the pivot's own filter below rejects fragments
369
- // (`hasParents || hasContainers -Infinity`), and recognition's edge-trim
370
- // fallbacks exist to find exactly those misaligned FRAGMENTS. Skipping them
371
- // (the structural pass + canonResolve still run) is byte-identical for every
372
- // pivot the fallbacks' output is discarded by the filter — and halves the
373
- // O(n·W²) recognition of a long answer (measured: 36KB recognise 4.0s → 2.0s).
374
- const rec = recognise(ctx, answer, true);
368
+ // THE FULL recognition, memo-shared with every other reader of these bytes.
369
+ // A "skip the edge trims here" variant was refuted (see recognise's own
370
+ // note): those trims are what find a WHOLE trained form embedded at an
371
+ // offset the answer's fold did not cut, and such a form is parentless,
372
+ // container-free and edge-bearing i.e. exactly what the filter below
373
+ // ADMITS as a pivot, not what it rejects.
374
+ const rec = recognise(ctx, answer);
375
375
  for (const s of rec.sites) {
376
376
  if (!consumed.has(s.payload) && ctx.store.hasNext(s.payload)) {
377
377
  scored.set(s.payload, Math.max(scored.get(s.payload) ?? 0, 1));
@@ -399,6 +399,12 @@ export async function pivotInto(
399
399
  let pivotId: number | null = null;
400
400
  for (const c of ranked) {
401
401
  const id = c.id;
402
+ // A ZERO-LENGTH candidate is not a pivot. `argmaxBy(…, 0, strict)` used to
403
+ // carry this floor in its threshold argument, and dropping it here would
404
+ // admit an empty node: `indexOf(answer, <empty>)` returns 0, so every
405
+ // filter below passes and the chain would hop through nothing (§2.13 —
406
+ // empty bytes are truthy).
407
+ if (c.len === 0) continue;
402
408
  // A PIVOT MUST BE A THING THE CORPUS DEPOSITED, NOT A PIECE OF ONE.
403
409
  // "Longest wins" ranks candidates but never asks whether the winner is
404
410
  // an entity at all, and by the time a chain reaches here `consumeAll`