@hviana/sema 0.4.7 → 0.5.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 (63) hide show
  1. package/AGENTS.md +290 -77
  2. package/HOW_IT_WORKS.md +2170 -735
  3. package/dist/example/train_base.d.ts +9 -3
  4. package/dist/example/train_base.js +21 -4
  5. package/dist/src/canon.d.ts +19 -0
  6. package/dist/src/canon.js +28 -0
  7. package/dist/src/geometry.d.ts +52 -0
  8. package/dist/src/geometry.js +87 -1
  9. package/dist/src/mind/bridge.js +27 -1
  10. package/dist/src/mind/frame-filler.d.ts +15 -0
  11. package/dist/src/mind/frame-filler.js +535 -0
  12. package/dist/src/mind/learning.js +6 -11
  13. package/dist/src/mind/mechanisms/cast.js +72 -2
  14. package/dist/src/mind/mechanisms/cover.js +6 -1
  15. package/dist/src/mind/mechanisms/extraction.js +27 -0
  16. package/dist/src/mind/mechanisms/recall.js +214 -34
  17. package/dist/src/mind/mind.d.ts +49 -1
  18. package/dist/src/mind/mind.js +137 -10
  19. package/dist/src/mind/pipeline-mechanism.d.ts +7 -0
  20. package/dist/src/mind/pipeline.js +29 -1
  21. package/dist/src/mind/prefix-completion.d.ts +59 -0
  22. package/dist/src/mind/prefix-completion.js +270 -0
  23. package/dist/src/mind/primitives.d.ts +29 -10
  24. package/dist/src/mind/primitives.js +52 -61
  25. package/dist/src/mind/recognition.js +119 -9
  26. package/dist/src/mind/traverse.d.ts +32 -0
  27. package/dist/src/mind/traverse.js +52 -0
  28. package/dist/src/mind/types.d.ts +55 -16
  29. package/dist/src/mind/types.js +68 -19
  30. package/dist/src/store.d.ts +21 -0
  31. package/dist/src/store.js +21 -0
  32. package/example/train_base.ts +21 -4
  33. package/package.json +1 -1
  34. package/src/canon.ts +28 -0
  35. package/src/geometry.ts +100 -1
  36. package/src/mind/bridge.ts +34 -0
  37. package/src/mind/frame-filler.ts +604 -0
  38. package/src/mind/learning.ts +5 -9
  39. package/src/mind/mechanisms/cast.ts +70 -2
  40. package/src/mind/mechanisms/cover.ts +6 -1
  41. package/src/mind/mechanisms/extraction.ts +27 -0
  42. package/src/mind/mechanisms/recall.ts +236 -37
  43. package/src/mind/mind.ts +154 -14
  44. package/src/mind/pipeline-mechanism.ts +7 -0
  45. package/src/mind/pipeline.ts +33 -1
  46. package/src/mind/prefix-completion.ts +314 -0
  47. package/src/mind/primitives.ts +59 -70
  48. package/src/mind/recognition.ts +117 -6
  49. package/src/mind/traverse.ts +52 -0
  50. package/src/mind/types.ts +98 -42
  51. package/src/store.ts +25 -0
  52. package/test/13-conversation.test.mjs +13 -0
  53. package/test/57-fusion-order.test.mjs +65 -0
  54. package/test/66-query-edge-whitespace.test.mjs +99 -0
  55. package/test/67-climb-anchor-breadth.test.mjs +113 -0
  56. package/test/68-extraction-unanchored.test.mjs +79 -0
  57. package/test/69-frame-filler.test.mjs +115 -0
  58. package/test/70-prefix-completion.test.mjs +170 -0
  59. package/test/71-embedded-canon-equivalence.test.mjs +121 -0
  60. package/test/72-prefix-candidate-supply.test.mjs +114 -0
  61. package/test/73-scaffolding-only-bridge-abstains.test.mjs +178 -0
  62. package/test/74-prefix-trap-not-sprung-early.test.mjs +114 -0
  63. package/test/75-multiturn-context-optimisation.test.mjs +1082 -0
@@ -7,16 +7,17 @@ import { Vec } from "../vec.js";
7
7
  import { Sema } from "../sema.js";
8
8
  import {
9
9
  bytesToTree,
10
+ contentFoldIncremental,
10
11
  Grid,
11
12
  gridToTree,
12
13
  hilbertBytes,
13
- stablePrefixFoldIncremental,
14
14
  stackGrids,
15
15
  } from "../geometry.js";
16
16
  import { canonHash } from "../canon.js";
17
17
  import { bytesEqual } from "../bytes.js";
18
18
  import { ALL } from "./types.js";
19
- import type { DepositCacheEntry, Input, MindContext } from "./types.js";
19
+ import type { Input, MindContext } from "./types.js";
20
+ import type { ContentFold } from "../geometry.js";
20
21
 
21
22
  // ── Address: bytes → node ──────────────────────────────────────────────
22
23
 
@@ -35,6 +36,26 @@ export function latin1Key(bytes: Uint8Array): string {
35
36
  return s;
36
37
  }
37
38
 
39
+ /** The {@link perceive} memo key: the span's content PLUS the boundary set it
40
+ * was folded under. The tree is a function of BOTH — the same bytes fold
41
+ * plainly with no boundaries and into a left-nested stable-prefix shape with
42
+ * them — so a content-only key returns whichever shape was computed first.
43
+ * That is exactly what happened: a conversation seeded its cumulative context
44
+ * under the content key, and every later plain `perceive` of those bytes was
45
+ * served the boundary tree instead (measured: respondTurn answered where
46
+ * respond() on byte-identical input did not). NUL separates the two parts —
47
+ * the boundary rendering is digits and commas, so no content byte can forge
48
+ * the split. */
49
+ export function perceiveKey(
50
+ bytes: Uint8Array,
51
+ boundaries?: readonly number[],
52
+ ): string {
53
+ const k = latin1Key(bytes);
54
+ return boundaries === undefined || boundaries.length === 0
55
+ ? k
56
+ : k + "\u0000" + boundaries.join(",");
57
+ }
58
+
38
59
  /** Perceive input into a content-defined tree (the river fold).
39
60
  * Deterministic — identical bytes always produce an identical tree.
40
61
  *
@@ -61,7 +82,7 @@ export function perceive(
61
82
  // The tree is shared by reference; Sema nodes are never mutated.
62
83
  const memo = ctx.perceiveMemo;
63
84
  if (memo) {
64
- const key = latin1Key(bytes);
85
+ const key = perceiveKey(bytes, boundaries);
65
86
  const hit = memo.get(key);
66
87
  if (hit !== undefined) {
67
88
  if (ctx.meter) ctx.meter.perceiveHits++;
@@ -104,78 +125,46 @@ export function perceive(
104
125
  }
105
126
 
106
127
  /** The DEPOSIT-shaped perceive. Folds over the stream's own content cuts —
107
- * bit-identical to what inference computes for the same bytes, and that
108
- * train/inference agreement is load-bearing for exact recall. An input that
109
- * EXTENDS a previously deposited one is a conversation context grown by one
110
- * turn; the cached prefix length IS the turn boundary (derived from the deposit
111
- * sequence itself, never from a content convention) and joins the cut set, so
112
- * the trained context node and the query's context subtree are the SAME node.
113
- * Segment folds reuse across deposits ({@link stablePrefixFoldIncremental}) —
114
- * O(turn) instead of O(context) per turn. All of it is purely a cache: an
115
- * evicted chain loses only the turn boundaries, and since the content cuts do
116
- * not depend on the cache, the segments themselves are unchanged. */
128
+ * bit-identical to what inference computes for the same bytes. That
129
+ * train/inference agreement is the whole contract: the trained context node
130
+ * and the node `resolve(query)` reaches must be the SAME node, and the only
131
+ * way to guarantee it is to give this function nothing extra to say. It
132
+ * imposes no boundaries, knows nothing about turns, and reads no convention
133
+ * out of the bytes.
134
+ *
135
+ * An input that EXTENDS a previously deposited one a conversation context
136
+ * grown by a turn, or a resumed replay reuses that deposit's already-folded
137
+ * content segments ({@link contentFoldIncremental}), so it costs O(new bytes)
138
+ * instead of O(context). The reuse is TRANSPARENT by construction: a segment
139
+ * is a pure function of its own bytes, so a reused one is bit-identical to a
140
+ * refolded one. Nothing has to prove that the extending deposit is "really"
141
+ * a next turn — a coincidental byte prefix reuses the same segments and gets
142
+ * the same tree it would have got anyway. (It used to matter: while this
143
+ * path imposed turn BOUNDARIES, a wrong guess changed the tree, so the cache
144
+ * needed a continuation-bytes proof to gate it. Nothing is imposed now, so
145
+ * there is nothing to gate.) */
117
146
  export function perceiveDeposit(
118
147
  ctx: MindContext,
119
148
  bytes: Uint8Array,
120
149
  conversational = false,
121
150
  ): Sema {
122
- let prev: DepositCacheEntry | undefined;
123
- let prefixLen = 0;
124
- // Cache consult (both boundary lookup and stable-prefix reuse) is scoped
125
- // to conversational deposits only a bare, unrelated fact whose bytes
126
- // happen to extend an earlier deposit is NOT a conversation turn, and
127
- // must keep the plain fold so it shares structure with ITS OWN prior
128
- // deposits, not fragment against a coincidental byte-prefix.
129
- if (conversational) {
130
- // Longest cached PROPER prefix first.
131
- const lens = [...ctx._depositLens]
132
- .filter((L) => L >= 2 && L < bytes.length)
133
- .sort((a, b) => b - a);
134
- for (const L of lens) {
135
- const hit = ctx._depositTrees.get(latin1Key(bytes.subarray(0, L)));
136
- // The suffix must bytes-equal the hit's OWN recorded continuation —
137
- // proof this deposit is that turn's actual next turn, not a fact
138
- // that coincidentally shares its byte prefix.
139
- if (
140
- hit !== undefined && hit.nextBytes !== undefined &&
141
- bytesEqual(hit.nextBytes, bytes.subarray(L))
142
- ) {
143
- prev = hit;
144
- prefixLen = L;
145
- break;
146
- }
151
+ // Longest cached PROPER prefix first — the most segments to reuse.
152
+ let prev: ContentFold | undefined;
153
+ const lens = [...ctx._depositLens]
154
+ .filter((L) => L >= 2 && L < bytes.length)
155
+ .sort((a, b) => b - a);
156
+ for (const L of lens) {
157
+ const hit = ctx._depositTrees.get(latin1Key(bytes.subarray(0, L)));
158
+ if (hit !== undefined) {
159
+ prev = hit.content;
160
+ break;
147
161
  }
148
162
  }
149
- // ONLY turn boundaries belong here. The stream's own content cuts are NOT
150
- // passed in: `bytesToTree` and `stablePrefixFoldIncremental` both derive them
151
- // per span, at every level, and handing the level-0 cuts in as stable-prefix
152
- // boundaries instead produces a LEFT-NESTED join of flat segments a
153
- // different tree from the one inference builds for the same bytes. When that
154
- // happened, a deposit's context root and `resolve(question)` were different
155
- // nodes, so the trained edge hung off a node inference never reached and
156
- // recall went silent (test/44 caught it as a site that could not be emitted
157
- // because the resolved node led nowhere). Train and infer must fold
158
- // identically; the way to guarantee that is to give this function nothing
159
- // extra to say.
160
- const cuts = new Set<number>();
161
- if (prev !== undefined) {
162
- for (const b of prev.boundaries) cuts.add(b);
163
- cuts.add(prefixLen);
164
- }
165
- const boundaries = [...cuts].sort((a, b) => a - b);
166
- const folded = stablePrefixFoldIncremental(
167
- ctx.space,
168
- ctx.alphabet,
169
- bytes,
170
- boundaries,
171
- prev?.stable,
172
- );
173
- const tree = folded.tree;
174
- const entry: DepositCacheEntry = { boundaries, stable: folded.fold };
175
- // Only a conversational deposit writes the cache too — otherwise a bare
176
- // fact's plain fold could later be misread as a conversation's turn-zero
177
- // boundary by an unrelated conversational deposit that happens to extend
178
- // its bytes.
163
+ const folded = contentFoldIncremental(ctx.space, ctx.alphabet, bytes, prev);
164
+ // Only a CONVERSATIONAL deposit writes the cache: reuse is sound for any
165
+ // deposit, but the budget is 8 entries and a corpus of unrelated facts would
166
+ // evict the live chains for nothing. Purely a cost decision now, not a
167
+ // correctness one.
179
168
  if (conversational && bytes.length >= 2) {
180
169
  // The lengths set drifts as the map evicts; past the probe budget the
181
170
  // drift itself becomes the cost (each stale length is an O(len) key
@@ -184,10 +173,10 @@ export function perceiveDeposit(
184
173
  ctx._depositLens.clear();
185
174
  ctx._depositTrees.clear();
186
175
  }
187
- ctx._depositTrees.set(latin1Key(bytes), entry);
176
+ ctx._depositTrees.set(latin1Key(bytes), { content: folded.fold });
188
177
  ctx._depositLens.add(bytes.length);
189
178
  }
190
- return tree;
179
+ return folded.tree;
191
180
  }
192
181
 
193
182
  /** The raw bytes of an input — modality-neutral conversion. */
@@ -17,6 +17,7 @@ import {
17
17
  } from "./primitives.js";
18
18
  import { atomIsHub, corpusN, leadsSomewhere } from "./traverse.js";
19
19
  import { chainReach, leafIdAt, leafIdRun } from "./canonical.js";
20
+ import { canonHash } from "../canon.js";
20
21
  import { isChunk, type Sema } from "../sema.js";
21
22
  import type { Leaf, Site } from "./graph-search.js";
22
23
 
@@ -408,7 +409,21 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
408
409
  // before resolveSpan pays for a fold; approximate evidence never enters.
409
410
  // This tier is needed only where atom chains are suppressed. Small stores
410
411
  // retain their existing decomposition unchanged.
411
- if (atomsAreHubs) {
412
+ // ALWAYS ON, AND LINEAR. This used to be gated on `atomsAreHubs` — small
413
+ // stores were said to "retain their existing decomposition unchanged", which
414
+ // was true only while the query's fold was told where the turns were: every
415
+ // turn was then a NODE, so the structural walk found it and this tier had
416
+ // nothing to add. The fold no longer imposes turn boundaries (a turn start
417
+ // is an ordinary interior offset now), so a trained form embedded in a
418
+ // longer query is reachable ONLY here — the chain caps at chainReach(W)=W²
419
+ // bytes and cannot span one. Measured: with the fold imposing boundaries
420
+ // every turn is a node; without it, none is.
421
+ //
422
+ // Ungating it alone made inference QUADRATIC (test/14's constant-KB/s guard
423
+ // went to 41.8s): every offset near a cut is an endpoint, and each probe
424
+ // costs O(span) to slice the leaf-id run and hash it. The budget below is
425
+ // what makes it affordable — see `spend`.
426
+ {
412
427
  const allLeafIds = singleLeaf.map((x) => x?.id ?? null);
413
428
  if (allLeafIds.every((x): x is number => x !== null)) {
414
429
  const radius = ctx.space.seats.length;
@@ -421,15 +436,111 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
421
436
  ) endpoints.add(p);
422
437
  }
423
438
  const ordered = [...endpoints].sort((a, b) => a - b);
424
- const probe = (start: number, end: number): void => {
439
+ // The leaf-id run is BYTE-EXACT, while `resolveSpan` behind it resolves
440
+ // exactly OR canonically — so this gate was strictly narrower than its
441
+ // own resolver, and every embedded form differing from its deposit only
442
+ // by the response's equivalence (case, width) was dropped before the
443
+ // resolver ever saw it. Rebuilding the run over canonicalized bytes
444
+ // does NOT fix that: a differently-cased deposit's branch kid-ids are
445
+ // not the query's leaf-id run under ANY canonicalization of the query,
446
+ // so the second admission route has to be the canon INDEX itself — the
447
+ // same candidate proposal `canonResolve` makes, and the same
448
+ // cheap-probe-before-a-fold discipline the exact route already follows
449
+ // (a hash and an indexed lookup; no fold, no vector, no scan). Both
450
+ // routes only PROPOSE; `resolveSpan` still decides, so a hash-bucket
451
+ // collision costs one fold and can never emit a wrong site (test/71).
452
+ const canonAdmits = (start: number, end: number): boolean => {
453
+ const canon = ctx.canon;
454
+ if (canon === null || !store.canonFind) return false;
455
+ const key = canon(bytes.subarray(start, end));
456
+ if (key.length === 0) return false;
457
+ return store.canonFind(canonHash(key)).length > 0;
458
+ };
459
+ // The byte-exact route probes the SPAN ITSELF (see
460
+ // Store.findFlatBranch): for a run of single-byte leaves the flat-kid
461
+ // encoding is the identity, so the span's bytes ARE the branch key.
462
+ // `subarray` is a view — this allocates nothing per probe, and the
463
+ // bloom filter answers the misses without touching the database.
464
+ const flatProbe = (start: number, end: number): number | null =>
465
+ store.findFlatBranch
466
+ ? store.findFlatBranch(bytes.subarray(start, end))
467
+ : store.findBranch(allLeafIds.slice(start, end));
468
+ // THE TWO ROUTES COST DIFFERENT THINGS, SO THEY ARE PRICED SEPARATELY.
469
+ //
470
+ // The exact route is a bloom-gated hash over a subarray VIEW: no
471
+ // allocation, and a miss never reaches the database. It is cheap enough
472
+ // to run on every endpoint, and that is what makes this tier able to
473
+ // find a trained form embedded anywhere in the query.
474
+ //
475
+ // The canon route is not: it runs the canonicalizer over the span
476
+ // (NFKC, case-fold, whitespace) and allocates a fresh key for every
477
+ // probe. That is the O(span) cost with the heavy constant, and it is
478
+ // the one worth a budget. Sharing ONE budget between them made the
479
+ // cheap route starve on the expensive one's behalf — measured, test/71's
480
+ // embedded differently-cased form needed 64x the budget to be found,
481
+ // while the exact route it was competing with needed none of it.
482
+ const probe = (
483
+ start: number,
484
+ end: number,
485
+ canonBudget: boolean,
486
+ ): void => {
425
487
  if (end - start < W || end - start <= chainReach(W)) return;
426
- const ids = allLeafIds.slice(start, end);
427
- if (store.findBranch(ids) === null) return;
488
+ if (flatProbe(start, end) === null) {
489
+ if (!canonBudget) return;
490
+ if (!canonAdmits(start, end)) return;
491
+ }
428
492
  const id = resolveSpan(start, end);
429
493
  if (id !== null) emit(start, end, id);
430
494
  };
431
- for (const end of ordered) probe(0, end);
432
- for (const start of ordered) probe(start, bytes.length);
495
+ // A CUMULATIVE BYTE BUDGET, SPENT SHORTEST-SPAN-FIRST.
496
+ //
497
+ // Each probe costs O(span), and there are O(n) endpoints, so probing
498
+ // them all is O(n²) — the quadratic this tier was gated to avoid. The
499
+ // budget caps TOTAL probe bytes at a multiple of the query's own length,
500
+ // which is what keeps whole-query inference linear.
501
+ //
502
+ // Spending it shortest-first is what makes the cap a scale bound rather
503
+ // than a position bound: the tier recovers embedded forms up to roughly
504
+ // √(2·budget) bytes ANYWHERE in the endpoint set, instead of walking the
505
+ // endpoints in order and running out partway along the query. A form
506
+ // longer than that is out of this tier's reach — but so is a form the
507
+ // chain cannot span, and that is exactly the trade the budget prices.
508
+ // The factor is chainReach(W), the same W² scale the chain already
509
+ // trusts; no new constant.
510
+ // The factor is chainReach(W) — the same W² scale the chain itself
511
+ // trusts — so the cap is derived from the fold's geometry, never tuned.
512
+ // (It was briefly an environment variable while the cost was being
513
+ // measured; an env-read here would make inference non-reproducible,
514
+ // which the determinism contract forbids outright.)
515
+ // The factor is chainReach(W) — the same W² scale the chain itself
516
+ // trusts — so the cap is derived from the fold's geometry, never tuned.
517
+ // (It was briefly an environment variable while the cost was being
518
+ // measured; an env-read here would make inference non-reproducible,
519
+ // which the determinism contract forbids outright.)
520
+ //
521
+ // It now prices ONLY the canonicalizing route; the exact route runs on
522
+ // every endpoint regardless, so exhausting this budget narrows which
523
+ // equivalence-class forms are proposed, never which byte-exact ones.
524
+ let budget = bytes.length * chainReach(W) * chainReach(W);
525
+ const spend = (start: number, end: number): boolean => {
526
+ const span = end - start;
527
+ const afford = span <= budget;
528
+ if (afford) budget -= span;
529
+ probe(start, end, afford);
530
+ // Always keep walking: the exact route is unbudgeted, so running out
531
+ // of canon budget must not stop the scan.
532
+ return true;
533
+ };
534
+ const prefixes = ordered.filter((e) => e > 0).sort((a, b) => a - b);
535
+ const suffixes = ordered
536
+ .filter((s2) => s2 < bytes.length)
537
+ .sort((a, b) => b - a);
538
+ for (let i = 0; i < Math.max(prefixes.length, suffixes.length); i++) {
539
+ // Interleaved so neither edge starves the other when the budget runs
540
+ // out — a query can carry a trained form at either end.
541
+ if (i < prefixes.length && !spend(0, prefixes[i])) break;
542
+ if (i < suffixes.length && !spend(suffixes[i], bytes.length)) break;
543
+ }
433
544
  }
434
545
  }
435
546
 
@@ -9,6 +9,7 @@
9
9
  import { cosine, Vec } from "../vec.js";
10
10
  import type { AncestorReach, MindContext, SaturationStop } from "./types.js";
11
11
  import { gistOf, read } from "./primitives.js";
12
+ import { leafIdRun } from "./canonical.js";
12
13
 
13
14
  // ── Session structural memo ─────────────────────────────────────────────
14
15
  //
@@ -769,3 +770,54 @@ function rItemShort(
769
770
  score,
770
771
  };
771
772
  }
773
+
774
+ /** True when NO window of `query` discriminates anything — every stored
775
+ * W-window it spells is contained by more places than the hub bound allows,
776
+ * i.e. the whole query is corpus-global scaffolding.
777
+ *
778
+ * WHAT IT IS FOR. Several mechanisms ground a query through the literal
779
+ * spans it did NOT explain, and those spans are the whole of their evidence.
780
+ * When every one of them is a hub, the query says nothing the corpus can be
781
+ * held to, and grounding it means picking one of thousands of continuations
782
+ * it gives no evidence for — a fabrication whatever the answer happens to be.
783
+ * Answering with silence there is the honest degradation contract (§2.13).
784
+ *
785
+ * MEASURED SEPARATION (trained store, hubBound 571) — this is categorical,
786
+ * not marginal, and it is why the predicate lives here rather than being
787
+ * spelled twice:
788
+ * "What is the capital of" ALL saturated ("What":572) → fabricated
789
+ * "What is the capital " ALL saturated ("What":572) → fabricated
790
+ * "what is the capital of france" min "f fr":248 → correct
791
+ * "What is the capitol of France?" min "f Fr":114 → correct
792
+ * "WHAT IS THE CAPITAL OF FRANCE?" min "HE C":1 → correct
793
+ * "What is the capital of France?" min "t i":4 → correct
794
+ * "Who wrote Romeo and Juliet?" min "iet?":26 → correct
795
+ * "What is the capital of Zamunda?" min "Zamu":3 → silent anyway
796
+ * Note the last: the honest-silence probes are already refused on other
797
+ * evidence and sit on the SAME side as the correct ones, so this predicate
798
+ * is not what makes them silent and cannot be credited for them.
799
+ *
800
+ * NO NEW THRESHOLD (§2.2): `hubBound` is the √N reading of "hub" used
801
+ * everywhere, and the containment read is clamped to it exactly as every
802
+ * other fan-out read is (§2.8). A query with no stored window at all is NOT
803
+ * scaffolding-only — it has no evidence either way, and its callers already
804
+ * refuse it on their own terms. */
805
+ export function allWindowsAreScaffolding(
806
+ ctx: MindContext,
807
+ query: Uint8Array,
808
+ ): boolean {
809
+ const W = ctx.space.maxGroup;
810
+ const bound = hubBound(ctx);
811
+ let sawOne = false;
812
+ for (let o = 0; o + W <= query.length; o++) {
813
+ const ids = leafIdRun(ctx, query, o, o + W);
814
+ if (ids === null) continue;
815
+ const id = ctx.store.findBranch(ids);
816
+ if (id === null) continue;
817
+ const rarity = ctx.store.containersSlice(id, 0, bound + 1).length;
818
+ if (rarity === 0) continue;
819
+ if (rarity <= bound) return false;
820
+ sawOne = true;
821
+ }
822
+ return sawOne;
823
+ }
package/src/mind/types.ts CHANGED
@@ -20,23 +20,18 @@ import type {
20
20
  Site,
21
21
  } from "./graph-search.js";
22
22
  import type { Rationale } from "./rationale.js";
23
- import type { Grid, StableFold } from "../geometry.js";
23
+ import type { ContentFold, Grid } from "../geometry.js";
24
24
 
25
- /** One {@link MindContext._depositTrees} entry — see that field's doc. */
25
+ /** One {@link MindContext._depositTrees} entry — see that field's doc.
26
+ *
27
+ * A PURE WORK CACHE. It carries the already-folded content segments of a
28
+ * deposited stream so a longer stream sharing its byte prefix can skip
29
+ * refolding them. It holds no turn boundaries and no continuation proof
30
+ * because the deposit fold imposes nothing: reuse is bit-identical to a cold
31
+ * fold, so a hit can only save time, never change a tree. */
26
32
  export interface DepositCacheEntry {
27
- /** Turn boundaries accumulated over this content's deposit chain
28
- * strictly increasing proper offsets, each a previously-deposited
29
- * whole-context length. Empty for a first-seen (single-turn) input. */
30
- boundaries: number[];
31
- /** Stable-prefix segment folds (grown-context inputs only). */
32
- stable?: StableFold;
33
- /** The continuation bytes this ctxInput was paired with in ingestPair, if
34
- * any — the ONLY thing that makes a later, longer ctxInput a genuine next
35
- * TURN of the same conversation rather than an unrelated fact that
36
- * happens to share this one's byte prefix (e.g. "2+2" vs. "2+2=5"). A
37
- * later deposit only takes this entry as its stable-prefix `prev` when
38
- * its own suffix bytes-equal this exactly. */
39
- nextBytes?: Uint8Array;
33
+ /** The plain content fold's reusable segment state. */
34
+ content: ContentFold;
40
35
  }
41
36
  import { bytesEqual, concatBytes, indexOf } from "../bytes.js";
42
37
  import { dominates } from "../geometry.js";
@@ -338,7 +333,17 @@ export interface MindContext extends GraphSearchHost {
338
333
  * walking children. When a conversation's pyramid reuses prefix
339
334
  * subtrees, this cache lets {@link recognise} skip them entirely —
340
335
  * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
341
- * the Sema objects the pyramid keeps alive). */
336
+ * the Sema objects the pyramid keeps alive).
337
+ *
338
+ * THAT REUSE IS A PRECONDITION, NOT A GIVEN: the keys are node IDENTITIES,
339
+ * so it hits only while the conversation's fold hands back the SAME Sema
340
+ * objects for the unchanged prefix. `_growContext` rebuilt the whole tree
341
+ * with `bytesToTree` on every turn, so every key was fresh and this cache
342
+ * could not hit even once — the O(suffix) claim above described an
343
+ * intention rather than the code. It now grows the context through
344
+ * {@link stablePrefixFoldIncremental}, which reuses each already-folded
345
+ * segment: measured over four turns, turn 4 shared 69 of its 95 nodes with
346
+ * turn 3 (26 new ≈ the new turn's own size). */
342
347
  _resolvedSubtrees: WeakMap<Sema, { id: number; len: number }> | null;
343
348
  /** Completed assistant-turn byte spans in the current cumulative query.
344
349
  * Empty for ordinary respond(); response-scoped structural context for
@@ -436,49 +441,100 @@ export function segRestatesQuery(
436
441
  * (lo/hi) decision and the final concatenation: it is stale, not a second
437
442
  * answer, but the OTHER spans a derivation chose are independent evidence
438
443
  * and must not be discarded along with it. */
439
- export function liftAnswer(
444
+ /** The spans {@link liftAnswer} actually concatenates, in order — the answer
445
+ * before it is joined. Exposed so a caller can ask what the lifted answer is
446
+ * MADE OF without re-deriving the selection: in particular how much of it is
447
+ * SCAFFOLDING (a `rec: false` span — query bytes carried through verbatim
448
+ * because nothing explained them, the same spans the liftAnswer trace labels
449
+ * "scaffolding" rather than "chosen").
450
+ *
451
+ * That quantity is load-bearing for the grounding decision. Two candidates
452
+ * can leave the SAME number of query bytes unaccounted and therefore grade
453
+ * identically, while one of them pads its answer with those bytes and the
454
+ * other does not — measured on test/22's two-fact chain, cover and recall
455
+ * both graded 11001 with 11 bytes unexplained, and cover won the tie only on
456
+ * consideration order, answering "The capital of France is Paris famous for"
457
+ * where recall had crossed the hop. Carrying an unexplained span into the
458
+ * answer is strictly weaker than not explaining it: it manufactures fluency
459
+ * out of the asker's own words. See the tie-break in pipeline.ts. */
460
+ export function liftAnswerParts(
440
461
  segs: Seg[],
441
462
  queryLen: number,
442
463
  query: Uint8Array,
443
464
  W: number,
444
- ): Uint8Array | null {
465
+ ): Seg[] {
445
466
  const restated = segs.map((s) => segRestatesQuery(s, query, queryLen, W));
446
467
  const recognised: number[] = [];
447
468
  for (let k = 0; k < segs.length; k++) {
448
469
  if (segs[k].rec && !restated[k]) recognised.push(k);
449
470
  }
450
- if (recognised.length === 0) return null;
471
+ if (recognised.length === 0) return [];
451
472
 
452
473
  if (recognised.length === 1) {
453
474
  const s = segs[recognised[0]];
454
- // A COMPUTED span's query-side width is operand digit-count, not
455
- // evidence of how much of the query's meaning it accounts for — the
456
- // half-dominance check below (built for a genuinely RECOGNISED learned
457
- // form) is not a valid framing signal for it (see the `computed` field
458
- // doc on Seg/GItem): "1000 - 421" outweighs "what is …?" by width only
459
- // because the operands are big, not because the framing matters less.
460
- // A LITERAL PREFIX before a computed span is unambiguous framing
461
- // regardless of width — an arithmetic expression is never itself
462
- // preceded by more literal computed content, so anything literal before
463
- // it is question wording ("what is ", "compute ") to lift clear of.
464
- // With no prefix (s.i === 0) the span is judged by the ordinary
465
- // half-dominance rule below, which already correctly keeps a short
466
- // trailing glue byte ("2+2." → "4.", the span dominates a 4-byte query).
467
- if (s.computed && s.i > 0) return s.bytes;
475
+ if (s.computed && s.i > 0) return [s];
468
476
  if (dominates(s.j - s.i, queryLen)) {
469
- return concatBytes(
470
- segs.filter((_, k) => !restated[k]).map((x) => x.bytes),
471
- );
477
+ return segs.filter((_, k) => !restated[k]);
472
478
  }
473
- return s.bytes;
479
+ return [s];
474
480
  }
475
481
  const lo = recognised[0];
476
482
  const hi = recognised[recognised.length - 1];
477
- return concatBytes(
478
- segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]).map((x) =>
479
- x.bytes
480
- ),
481
- );
483
+ return segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]);
484
+ }
485
+
486
+ /** The SCAFFOLDING byte count of a lifted answer: how many of its bytes come
487
+ * from spans nothing recognised (see {@link liftAnswerParts}).
488
+ *
489
+ * ONLY RUNS OF AT LEAST ONE RIVER WINDOW COUNT. Not all carried-through
490
+ * bytes are a failure to explain: a period, a question mark, the space
491
+ * between two fused topics are GLUE — they belong to the answer's surface,
492
+ * and dropping them to look better-derived would be a worse answer, not a
493
+ * more honest one. A substantive phrase the derivation never explained
494
+ * ("famous for") is a different claim entirely.
495
+ *
496
+ * W is the line between them, and it is the same line the rest of the mind
497
+ * already draws: below one river window byte overlap is chance, not evidence
498
+ * (see identityBar, the bridge's attestedQ, and recognition's site floor).
499
+ * Counting every scaffolding byte instead — which is what this did first —
500
+ * made punctuation preservation lose a tie it should win, and test/00's
501
+ * "period preserved" / "question mark preserved" caught it immediately. */
502
+ export function liftedScaffolding(
503
+ segs: Seg[],
504
+ queryLen: number,
505
+ query: Uint8Array,
506
+ W: number,
507
+ ): number {
508
+ // MEASURED PER CONTIGUOUS RUN, not per span. A PASS span is one BYTE — the
509
+ // cover charges unrecognised bytes individually — so asking whether a single
510
+ // span reaches W would find no run ever, whatever the query. " famous for"
511
+ // arrives as eleven one-byte spans in a row and is one eleven-byte run.
512
+ let n = 0;
513
+ let run = 0;
514
+ const close = () => {
515
+ if (run >= W) n += run;
516
+ run = 0;
517
+ };
518
+ for (const s of liftAnswerParts(segs, queryLen, query, W)) {
519
+ if (s.rec) close();
520
+ else run += s.bytes.length;
521
+ }
522
+ close();
523
+ return n;
524
+ }
525
+
526
+ export function liftAnswer(
527
+ segs: Seg[],
528
+ queryLen: number,
529
+ query: Uint8Array,
530
+ W: number,
531
+ ): Uint8Array | null {
532
+ // ONE selection rule, in {@link liftAnswerParts} — this is its join. The
533
+ // two used to be separate copies of the same lo/hi/restated reasoning, which
534
+ // is exactly how an answer and the accounting OF that answer drift apart.
535
+ const parts = liftAnswerParts(segs, queryLen, query, W);
536
+ if (parts.length === 0) return null;
537
+ return concatBytes(parts.map((x) => x.bytes));
482
538
  }
483
539
 
484
540
  /** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
package/src/store.ts CHANGED
@@ -325,6 +325,10 @@ export interface Store {
325
325
  contentLen(id: NodeId, cap?: number): number;
326
326
  findLeaf(bytes: Uint8Array): NodeId | null;
327
327
  findBranch(kids: NodeId[]): NodeId | null;
328
+ /** {@link findBranch} for a run of single-byte leaves, addressed by the raw
329
+ * bytes — the allocation-free probe span scanners use. Optional: a store
330
+ * without it is simply probed through `findBranch`. */
331
+ findFlatBranch?(bytes: Uint8Array): NodeId | null;
328
332
  /** The branch nodes that list `id` among their children — the reverse of
329
333
  * `get(id).kids`. Lets the structural DAG be climbed upward, from a
330
334
  * recognised fragment to the larger learned forms that contain it. */
@@ -1269,6 +1273,27 @@ export abstract class AbstractStore implements Store {
1269
1273
  return id;
1270
1274
  }
1271
1275
 
1276
+ /** {@link findBranch} for a run of SINGLE-BYTE leaves, addressed by the
1277
+ * bytes themselves — no kid array, no key string, no copy.
1278
+ *
1279
+ * A flat branch stores its children as {@link flatKidsBytes}, and that
1280
+ * encoding is the identity on single-byte leaves: kid id −(b+1) IS byte b.
1281
+ * So for such a run the kid array and the byte span are the same object in
1282
+ * two spellings, and `findBranch(leafIds.slice(i, j))` and this call are
1283
+ * the same lookup — except that the array path allocates the slice, then
1284
+ * `kids.join(",")`, then the flat bytes, all O(span), for a probe whose
1285
+ * answer is usually "no". The bloom filter behind `_dbFindBranchByLeaf`
1286
+ * answers most of those with no I/O at all, so the allocations dominated.
1287
+ *
1288
+ * Pass a subarray: it is a view, so a caller scanning spans of a query
1289
+ * allocates nothing per probe. Deliberately NOT memoized — its callers
1290
+ * probe many spans that miss, and a key string per probe is the cost this
1291
+ * exists to remove. */
1292
+ findFlatBranch(bytes: Uint8Array): NodeId | null {
1293
+ if (this.meter) this.meter.branchLookups++;
1294
+ return this._dbFindBranchByLeaf(hashOf(bytes), bytes);
1295
+ }
1296
+
1272
1297
  findBranch(kids: NodeId[]): NodeId | null {
1273
1298
  if (this.meter) this.meter.branchLookups++;
1274
1299
  const key = kids.join(",");
@@ -18,6 +18,19 @@
18
18
  // accumulated bytes at inference. The Conversation API tracks turn-boundary
19
19
  // offsets explicitly so no separator character is needed — the geometry never
20
20
  // inspects content to find turn boundaries.
21
+ //
22
+ // "NO SEPARATOR IS NEEDED" ≠ "A SEPARATOR IS A PROBLEM". This file joins its
23
+ // turns with nothing; example/train_base.ts joins its oasst2 turns with "\n".
24
+ // Both are correct, and neither is a convention the other has to match: a
25
+ // separator is CORPUS CONTENT, folded like any other byte, while a turn
26
+ // boundary is an OFFSET the API carries beside the bytes. A harness replaying
27
+ // a "\n"-joined corpus simply passes `"\n" + turnText` to addTurn and gets the
28
+ // trained byte stream back exactly. See Mind.addTurn's "ON SEPARATORS" note
29
+ // for the full statement — it exists because a review read the mismatch
30
+ // between this file's join and the trainer's as an architectural
31
+ // incompatibility, and it is not one. If you are comparing this harness to a
32
+ // corpus and getting poor recall, check that you are feeding the bytes that
33
+ // were actually trained before concluding anything about the engine.
21
34
  // ─────────────────────────────────────────────────────────────────────────
22
35
 
23
36
  import { test } from "node:test";