@hviana/sema 0.4.7 → 0.5.1

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 (66) 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/attention.d.ts +15 -10
  10. package/dist/src/mind/attention.js +15 -10
  11. package/dist/src/mind/bridge.js +27 -1
  12. package/dist/src/mind/frame-filler.d.ts +15 -0
  13. package/dist/src/mind/frame-filler.js +535 -0
  14. package/dist/src/mind/learning.js +6 -11
  15. package/dist/src/mind/mechanisms/cast.js +72 -2
  16. package/dist/src/mind/mechanisms/cover.js +6 -1
  17. package/dist/src/mind/mechanisms/extraction.js +27 -0
  18. package/dist/src/mind/mechanisms/recall.js +214 -34
  19. package/dist/src/mind/mind.d.ts +52 -3
  20. package/dist/src/mind/mind.js +140 -12
  21. package/dist/src/mind/pipeline-mechanism.d.ts +7 -0
  22. package/dist/src/mind/pipeline.js +29 -1
  23. package/dist/src/mind/prefix-completion.d.ts +59 -0
  24. package/dist/src/mind/prefix-completion.js +270 -0
  25. package/dist/src/mind/primitives.d.ts +29 -10
  26. package/dist/src/mind/primitives.js +98 -71
  27. package/dist/src/mind/recognition.js +153 -26
  28. package/dist/src/mind/traverse.d.ts +32 -0
  29. package/dist/src/mind/traverse.js +52 -0
  30. package/dist/src/mind/types.d.ts +61 -18
  31. package/dist/src/mind/types.js +68 -19
  32. package/dist/src/store.d.ts +21 -0
  33. package/dist/src/store.js +21 -0
  34. package/example/train_base.ts +21 -4
  35. package/package.json +1 -1
  36. package/src/canon.ts +28 -0
  37. package/src/geometry.ts +100 -1
  38. package/src/mind/attention.ts +15 -10
  39. package/src/mind/bridge.ts +34 -0
  40. package/src/mind/frame-filler.ts +604 -0
  41. package/src/mind/learning.ts +5 -9
  42. package/src/mind/mechanisms/cast.ts +70 -2
  43. package/src/mind/mechanisms/cover.ts +6 -1
  44. package/src/mind/mechanisms/extraction.ts +27 -0
  45. package/src/mind/mechanisms/recall.ts +236 -37
  46. package/src/mind/mind.ts +166 -18
  47. package/src/mind/pipeline-mechanism.ts +7 -0
  48. package/src/mind/pipeline.ts +33 -1
  49. package/src/mind/prefix-completion.ts +314 -0
  50. package/src/mind/primitives.ts +105 -80
  51. package/src/mind/recognition.ts +151 -23
  52. package/src/mind/traverse.ts +52 -0
  53. package/src/mind/types.ts +104 -44
  54. package/src/store.ts +25 -0
  55. package/test/13-conversation.test.mjs +13 -0
  56. package/test/57-fusion-order.test.mjs +65 -0
  57. package/test/66-query-edge-whitespace.test.mjs +99 -0
  58. package/test/67-climb-anchor-breadth.test.mjs +113 -0
  59. package/test/68-extraction-unanchored.test.mjs +79 -0
  60. package/test/69-frame-filler.test.mjs +115 -0
  61. package/test/70-prefix-completion.test.mjs +170 -0
  62. package/test/71-embedded-canon-equivalence.test.mjs +121 -0
  63. package/test/72-prefix-candidate-supply.test.mjs +114 -0
  64. package/test/73-scaffolding-only-bridge-abstains.test.mjs +178 -0
  65. package/test/74-prefix-trap-not-sprung-early.test.mjs +114 -0
  66. package/test/75-multiturn-context-optimisation.test.mjs +1334 -0
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";
@@ -336,9 +331,23 @@ export interface MindContext extends GraphSearchHost {
336
331
  /** Subtree-resolution cache: Sema node → its store id and byte length.
337
332
  * Populated by {@link foldTree} during inference; checked before
338
333
  * walking children. When a conversation's pyramid reuses prefix
339
- * subtrees, this cache lets {@link recognise} skip them entirely
340
- * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
341
- * the Sema objects the pyramid keeps alive). */
334
+ * subtrees, this cache names them without a store probe. It does NOT let
335
+ * {@link recognise} skip them: recognise walks with a `visit` callback and
336
+ * emits its sites from it, so a skipped descent would mean fewer sites on
337
+ * a warm cache than a cold one. foldTree short-circuits only for
338
+ * visitor-less walks (O(suffix) there); a visiting walk stays O(context)
339
+ * and banks the elided probes. Mind-lifetime (WeakMap keys are the Sema
340
+ * objects the pyramid keeps alive).
341
+ *
342
+ * THAT REUSE IS A PRECONDITION, NOT A GIVEN: the keys are node IDENTITIES,
343
+ * so it hits only while the conversation's fold hands back the SAME Sema
344
+ * objects for the unchanged prefix. `_growContext` rebuilt the whole tree
345
+ * with `bytesToTree` on every turn, so every key was fresh and this cache
346
+ * could not hit even once — the O(suffix) claim above described an
347
+ * intention rather than the code. It now grows the context through
348
+ * {@link stablePrefixFoldIncremental}, which reuses each already-folded
349
+ * segment: measured over four turns, turn 4 shared 69 of its 95 nodes with
350
+ * turn 3 (26 new ≈ the new turn's own size). */
342
351
  _resolvedSubtrees: WeakMap<Sema, { id: number; len: number }> | null;
343
352
  /** Completed assistant-turn byte spans in the current cumulative query.
344
353
  * Empty for ordinary respond(); response-scoped structural context for
@@ -436,49 +445,100 @@ export function segRestatesQuery(
436
445
  * (lo/hi) decision and the final concatenation: it is stale, not a second
437
446
  * answer, but the OTHER spans a derivation chose are independent evidence
438
447
  * and must not be discarded along with it. */
439
- export function liftAnswer(
448
+ /** The spans {@link liftAnswer} actually concatenates, in order — the answer
449
+ * before it is joined. Exposed so a caller can ask what the lifted answer is
450
+ * MADE OF without re-deriving the selection: in particular how much of it is
451
+ * SCAFFOLDING (a `rec: false` span — query bytes carried through verbatim
452
+ * because nothing explained them, the same spans the liftAnswer trace labels
453
+ * "scaffolding" rather than "chosen").
454
+ *
455
+ * That quantity is load-bearing for the grounding decision. Two candidates
456
+ * can leave the SAME number of query bytes unaccounted and therefore grade
457
+ * identically, while one of them pads its answer with those bytes and the
458
+ * other does not — measured on test/22's two-fact chain, cover and recall
459
+ * both graded 11001 with 11 bytes unexplained, and cover won the tie only on
460
+ * consideration order, answering "The capital of France is Paris famous for"
461
+ * where recall had crossed the hop. Carrying an unexplained span into the
462
+ * answer is strictly weaker than not explaining it: it manufactures fluency
463
+ * out of the asker's own words. See the tie-break in pipeline.ts. */
464
+ export function liftAnswerParts(
440
465
  segs: Seg[],
441
466
  queryLen: number,
442
467
  query: Uint8Array,
443
468
  W: number,
444
- ): Uint8Array | null {
469
+ ): Seg[] {
445
470
  const restated = segs.map((s) => segRestatesQuery(s, query, queryLen, W));
446
471
  const recognised: number[] = [];
447
472
  for (let k = 0; k < segs.length; k++) {
448
473
  if (segs[k].rec && !restated[k]) recognised.push(k);
449
474
  }
450
- if (recognised.length === 0) return null;
475
+ if (recognised.length === 0) return [];
451
476
 
452
477
  if (recognised.length === 1) {
453
478
  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;
479
+ if (s.computed && s.i > 0) return [s];
468
480
  if (dominates(s.j - s.i, queryLen)) {
469
- return concatBytes(
470
- segs.filter((_, k) => !restated[k]).map((x) => x.bytes),
471
- );
481
+ return segs.filter((_, k) => !restated[k]);
472
482
  }
473
- return s.bytes;
483
+ return [s];
474
484
  }
475
485
  const lo = recognised[0];
476
486
  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
- );
487
+ return segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]);
488
+ }
489
+
490
+ /** The SCAFFOLDING byte count of a lifted answer: how many of its bytes come
491
+ * from spans nothing recognised (see {@link liftAnswerParts}).
492
+ *
493
+ * ONLY RUNS OF AT LEAST ONE RIVER WINDOW COUNT. Not all carried-through
494
+ * bytes are a failure to explain: a period, a question mark, the space
495
+ * between two fused topics are GLUE — they belong to the answer's surface,
496
+ * and dropping them to look better-derived would be a worse answer, not a
497
+ * more honest one. A substantive phrase the derivation never explained
498
+ * ("famous for") is a different claim entirely.
499
+ *
500
+ * W is the line between them, and it is the same line the rest of the mind
501
+ * already draws: below one river window byte overlap is chance, not evidence
502
+ * (see identityBar, the bridge's attestedQ, and recognition's site floor).
503
+ * Counting every scaffolding byte instead — which is what this did first —
504
+ * made punctuation preservation lose a tie it should win, and test/00's
505
+ * "period preserved" / "question mark preserved" caught it immediately. */
506
+ export function liftedScaffolding(
507
+ segs: Seg[],
508
+ queryLen: number,
509
+ query: Uint8Array,
510
+ W: number,
511
+ ): number {
512
+ // MEASURED PER CONTIGUOUS RUN, not per span. A PASS span is one BYTE — the
513
+ // cover charges unrecognised bytes individually — so asking whether a single
514
+ // span reaches W would find no run ever, whatever the query. " famous for"
515
+ // arrives as eleven one-byte spans in a row and is one eleven-byte run.
516
+ let n = 0;
517
+ let run = 0;
518
+ const close = () => {
519
+ if (run >= W) n += run;
520
+ run = 0;
521
+ };
522
+ for (const s of liftAnswerParts(segs, queryLen, query, W)) {
523
+ if (s.rec) close();
524
+ else run += s.bytes.length;
525
+ }
526
+ close();
527
+ return n;
528
+ }
529
+
530
+ export function liftAnswer(
531
+ segs: Seg[],
532
+ queryLen: number,
533
+ query: Uint8Array,
534
+ W: number,
535
+ ): Uint8Array | null {
536
+ // ONE selection rule, in {@link liftAnswerParts} — this is its join. The
537
+ // two used to be separate copies of the same lo/hi/restated reasoning, which
538
+ // is exactly how an answer and the accounting OF that answer drift apart.
539
+ const parts = liftAnswerParts(segs, queryLen, query, W);
540
+ if (parts.length === 0) return null;
541
+ return concatBytes(parts.map((x) => x.bytes));
482
542
  }
483
543
 
484
544
  /** 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";
@@ -82,6 +82,71 @@ test("2. reversing the question reverses the fused answer", async () => {
82
82
  await mind.store.close();
83
83
  });
84
84
 
85
+ test("2b. a topic is never ECHOED back instead of answered", async () => {
86
+ // The failure this pins: "What is the capital of France? And what is the
87
+ // largest planet?" answered "The capital of France is Paris.What is the
88
+ // largest planet?" — one topic answered, the other repeated verbatim.
89
+ //
90
+ // It hinged on CASE. The comparison schema seats a directly-aligned analog
91
+ // by its own bytes rather than chasing a forward edge, which is correct when
92
+ // those bytes are an answer (test/43 pins that) and an echo when they are
93
+ // the question the asker just asked. The guard against that is a restatement
94
+ // check, and a BYTE-EXACT one missed here: the trained node is "What is the
95
+ // largest planet?" while the query says "And what is the largest planet?" —
96
+ // the same words, one capital apart. The check now reads the response's own
97
+ // injected canon, so it sees what the rest of the mind sees.
98
+ //
99
+ // SCOPE: this asserts only that nothing is echoed. Whether BOTH topics get
100
+ // fused is a separate, corpus- and seed-dependent property of the consensus
101
+ // climb — at this file's seed the second point is sometimes not committed at
102
+ // all, which is why test 1 above guards its ordering assertion on both names
103
+ // being present. Answering one topic and staying silent about the other is a
104
+ // coverage limit; answering one and parroting the other is a defect.
105
+ //
106
+ // Asserted in BOTH orders because the echo appeared in only one: which topic
107
+ // got echoed depended on whether the climb landed on the question node or
108
+ // the answer node, so a single-order test passes while the bug is live.
109
+ // ITS OWN CORPUS, DELIBERATELY. The file's shared `trained()` fixture cannot
110
+ // reproduce this: with five same-frame facts the climb often commits only
111
+ // ONE point, so there is no second topic to echo and the test would pass
112
+ // against the unfixed code (verified — it did). The echo needs exactly two
113
+ // topics, each a bare question node whose answer hangs off a forward edge.
114
+ const mind = new Mind({
115
+ seed: 7,
116
+ store: new SQliteStore({ path: ":memory:" }),
117
+ });
118
+ await mind.ingest([
119
+ ["What is the capital of France?", "The capital of France is Paris."],
120
+ ["What is the largest planet?", "The largest planet is Jupiter."],
121
+ ]);
122
+ for (
123
+ const q of [
124
+ "What is the capital of France? And what is the largest planet?",
125
+ "What is the largest planet? And what is the capital of France?",
126
+ ]
127
+ ) {
128
+ const a = await mind.respondText(q);
129
+ assert.ok(
130
+ !/And what is/i.test(a),
131
+ `the query was echoed rather than answered: ${JSON.stringify(a)} for ${
132
+ JSON.stringify(q)
133
+ }`,
134
+ );
135
+ assert.ok(
136
+ a.includes("Paris") && a.includes("Jupiter"),
137
+ `both topics must be ANSWERED, got ${JSON.stringify(a)} for ${
138
+ JSON.stringify(q)
139
+ }`,
140
+ );
141
+ // Nor may an answer be a bare restatement of one of the asked questions.
142
+ assert.ok(
143
+ !/^\s*What is the (largest planet|capital of France)\?\s*$/i.test(a),
144
+ `the answer is just the question restated: ${JSON.stringify(a)}`,
145
+ );
146
+ }
147
+ await mind.store.close();
148
+ });
149
+
85
150
  test("3. a single-topic answer is unchanged by the ordering rule", async () => {
86
151
  const mind = await trained();
87
152
  assert.match(
@@ -0,0 +1,99 @@
1
+ // 66-query-edge-whitespace.test.mjs — a query's leading/trailing whitespace is
2
+ // presentation, not part of the question, and must not decide whether a trained
3
+ // fact is reachable.
4
+ //
5
+ // canon.ts's contract: "a span's leading or trailing separator belongs BETWEEN
6
+ // forms, not to the form". canon itself PRESERVES edge whitespace, and must,
7
+ // because the hazard it cites is a recognised SUB-span swallowing the boundary
8
+ // byte that separates it from its neighbour ("ice " matching the stored "ice").
9
+ // At the outer edges of a WHOLE input there is no neighbour, so that hazard
10
+ // cannot arise — which is why respond() may trim there and canon may not.
11
+ // test/44 already relies on the same reading for recognise()'s miss path.
12
+ //
13
+ // THE GAP THIS CLOSES (measured on the 15.7M-node trained store): ONE leading
14
+ // space took `Who wrote Romeo and Juliet?` and `What is the chemical symbol for
15
+ // water?` from answered to silent, because a shift re-seats every fold boundary
16
+ // (cos(query, query shifted 1 byte) = 0.68 against a 0.875 reach bar). That was
17
+ // the whole of analyze_training.ts's K2 phase-robustness gap: 15/18 → 18/18.
18
+ //
19
+ // WHY A RETRY AND NOT A PRE-FILTER — the regression this file pins. Trimming
20
+ // the query up front is ASYMMETRIC: it normalises the query but not the stored
21
+ // forms, so it breaks byte-exact identity for a form trained WITH edge
22
+ // whitespace. Verified: pre-filtering broke test/04's [" ice ", "cold"] case.
23
+ // The exact bytes are therefore tried FIRST and the trim is reached only when
24
+ // they grounded nothing — which also means the retry costs nothing on any
25
+ // answering path.
26
+ //
27
+ // NOTE ON WHAT IS AND IS NOT TESTABLE HERE. The padded-query WIN cannot be
28
+ // reproduced in a miniature fixture: a small store answers a padded query on the
29
+ // first pass anyway (an earlier tier catches it), so the retry never fires and
30
+ // an end-to-end assertion passes with or without the fix — verified, an earlier
31
+ // version of this file did exactly that and guarded nothing. What a fixture CAN
32
+ // pin is the trim's own contract and the asymmetry regression, which is what
33
+ // these tests do; the win itself is evidenced on the real store.
34
+
35
+ import { test } from "node:test";
36
+ import assert from "node:assert/strict";
37
+ import { Mind } from "../dist/src/index.js";
38
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
39
+ import { textEdgeTrim } from "../dist/src/canon.js";
40
+
41
+ const enc = (s) => new TextEncoder().encode(s);
42
+ const dec = new TextDecoder();
43
+ const trim = (s) => dec.decode(textEdgeTrim(enc(s)));
44
+
45
+ test("1. textEdgeTrim drops only the outer spacing run", () => {
46
+ assert.equal(trim(" ice "), "ice");
47
+ assert.equal(trim("\tice\n"), "ice");
48
+ assert.equal(trim("ice"), "ice");
49
+ // INTERIOR whitespace is content and is never touched.
50
+ assert.equal(trim(" a b "), "a b");
51
+ // All-separator and empty inputs collapse to empty rather than throwing.
52
+ assert.equal(trim(" "), "");
53
+ assert.equal(trim(""), "");
54
+ // The untouched case must return the SAME object (no copy on the hot path).
55
+ const b = enc("ice");
56
+ assert.equal(textEdgeTrim(b), b);
57
+ });
58
+
59
+ test("2. a form trained WITH edge whitespace still answers when asked exactly", async () => {
60
+ // The asymmetry regression: trimming the query but not the store would make
61
+ // this query miss its own deposited form.
62
+ const m = new Mind({ seed: 7 });
63
+ await m.ingest([[" ice ", "cold"]]);
64
+ assert.equal(await m.respondText(" ice "), "cold");
65
+ });
66
+
67
+ test("3. whitespace-only and empty queries are silent, not errors", async () => {
68
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
69
+ await m.ingest([["what is ice?", "ice is frozen water"]]);
70
+ for (const q of ["", " ", " ", "\t\n"]) {
71
+ assert.equal(
72
+ await m.respondText(q),
73
+ "",
74
+ `expected silence for ${JSON.stringify(q)}`,
75
+ );
76
+ }
77
+ await m.store.close();
78
+ });
79
+
80
+ test("4. a padded query never answers something the unpadded one would not", async () => {
81
+ // The retry may add REACH, never licence: whatever padding does, it must not
82
+ // ground a fact for a question the store cannot answer.
83
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
84
+ await m.ingest([
85
+ ["what is the capital of France?", "The capital of France is Paris."],
86
+ ["what is the capital of Spain?", "Madrid is the capital of Spain."],
87
+ ]);
88
+ for (const q of [" Who wrote the Iliad? ", " xyzzy plugh quux "]) {
89
+ const a = await m.respondText(q);
90
+ assert.doesNotMatch(
91
+ a,
92
+ /Paris|Madrid/,
93
+ `padding manufactured an answer for ${JSON.stringify(q)}: ${
94
+ JSON.stringify(a)
95
+ }`,
96
+ );
97
+ }
98
+ await m.store.close();
99
+ });
@@ -0,0 +1,113 @@
1
+ // 67-climb-anchor-breadth.test.mjs — recall's scaffolding-dominated tier
2
+ // trusts a consensus-climb anchor on its SCALE-INVARIANT breadth as well as on
3
+ // its absolute IDF vote, and a breadth-qualified anchor must also be
4
+ // DISCRIMINATIVE.
5
+ //
6
+ // WHY THE ABSOLUTE VOTE IS NOT ENOUGH. Attention.breadth's own contract
7
+ // (types.ts) already says it: the IDF vote is "an absolute, ln(N)-scaled
8
+ // quantity that means 'strong' on a small store and 'weak' on a large one for
9
+ // the SAME degree of genuine consensus", while breadth is "the fraction of the
10
+ // query's OWN regions whose evidence this point accounts for" and "a point
11
+ // whose breadth clears `dominates` … is real consensus". Attention.peak's
12
+ // contract makes the same point from the other side: a floor that prices ONE
13
+ // region's evidence may not be compared against a POOLED SUM.
14
+ //
15
+ // Measured on the 15.7M-node trained store (N=325,615, floor = ln N + ½ =
16
+ // 13.19). The climb picked the RIGHT context and the floor discarded it, while
17
+ // a junk attractor for a query that must stay SILENT outvoted every correct
18
+ // anchor:
19
+ //
20
+ // anchor the climb picked vote breadth correct?
21
+ // "What is the chemical formula …" 10.60 0.556 RIGHT
22
+ // "Qual é a capital de França?" 8.19 0.667 RIGHT
23
+ // "Who wrote the play Romeo …?" 8.25 0.833 RIGHT
24
+ // "How do you say "good morning" …" 10.77 0.800 RIGHT
25
+ // "What is the commercial capital …" 12.69 0.333 Zamunda — MUST be silent
26
+ // "Menene sunan ginin mafi tsayi …" 12.79 0.214 wrong (Hausa)
27
+ //
28
+ // No vote threshold separates those; breadth > ½ separates them exactly. On
29
+ // that store the old floor was never cleared at all, so the tier was dead code
30
+ // and 12 probes fell through to silence.
31
+ //
32
+ // WHAT MUST NOT REGRESS, and why the gate is an OR of two guarded readings:
33
+ //
34
+ // • REPLACING the vote test with the breadth test broke 7 tests. On a small
35
+ // store ln(N) is low, so the vote bar is the reading that legitimately
36
+ // fires there; and Attention.clusters' contract warns that "breadth starves
37
+ // a genuine, evenly-split multi-topic query, since no root in a real N-way
38
+ // split can exceed half the vote" — the two-topic fusion tests are exactly
39
+ // that shape. Each reading is sufficient on its own evidence.
40
+ // • BREADTH ALONE fabricates. On a one-context store every region trivially
41
+ // corroborates the only anchor there is, so breadth is 1 while the anchor's
42
+ // IDF is 0 — test/31 A2 answered a lone cat fact for "explain quantum
43
+ // chromodynamics". Hence the companion condition: a region's IDF for an
44
+ // anchor reached through c of N contexts is ln(N/c), so requiring it past
45
+ // ln 2 requires c·2 < N — the same half-dominance reading in IDF units.
46
+
47
+ import { test } from "node:test";
48
+ import assert from "node:assert/strict";
49
+ import { Mind } from "../dist/src/index.js";
50
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
51
+
52
+ const mk = () =>
53
+ new Mind({ seed: 1, store: new SQliteStore({ path: ":memory:" }) });
54
+
55
+ test("1. a one-context store never grounds an unrelated query (breadth alone must not decide)", async () => {
56
+ // Breadth is trivially 1 when there is only one anchor to corroborate, but
57
+ // that anchor's IDF is 0 — it says nothing. VERIFIED to bite: dropping the
58
+ // `peak > ln 2` companion makes this fail (and test/31 A2 with it). The
59
+ // fixture matches A2's exactly — `new Mind({ seed: 7 })`, the default store —
60
+ // because the same shape over a SQliteStore did NOT reproduce it.
61
+ const m = new Mind({ seed: 7 });
62
+ await m.ingest([["what is a cat?", "a cat is a small feline"]]);
63
+ const r = await m.respond("explain quantum chromodynamics");
64
+ assert.equal(
65
+ r.v,
66
+ null,
67
+ "a lone low-IDF anchor must not ground a foreign query",
68
+ );
69
+ assert.equal(r.provenance, undefined);
70
+ });
71
+
72
+ test("2. an evenly-split multi-topic query still fuses (breadth must not be required)", async () => {
73
+ // The shape Attention.clusters' contract says breadth starves: no root in a
74
+ // real N-way split can hold more than half the query's regions, so a
75
+ // breadth-only gate would refuse both topics.
76
+ const m = mk();
77
+ await m.ingest([
78
+ ["ice", "cold"],
79
+ ["fire", "hot"],
80
+ ["what is ice?", "ice is frozen water"],
81
+ ["what is fire?", "fire is rapid oxidation"],
82
+ ]);
83
+ const a = await m.respondText("ice fire");
84
+ assert.ok(a.length > 0, "a two-topic query must still ground something");
85
+ await m.store.close();
86
+ });
87
+
88
+ test("3. honest silence survives on an unrelated corpus", async () => {
89
+ const m = mk();
90
+ await m.ingest([
91
+ ["what is the capital of France?", "The capital of France is Paris."],
92
+ ["what is the capital of Spain?", "Madrid is the capital of Spain."],
93
+ ["what is the capital of Italy?", "Rome is the capital of Italy."],
94
+ ]);
95
+ for (const q of ["xyzzy plugh quux baz?", "qq8f3kz9 vv2m1x7w?"]) {
96
+ const a = await m.respondText(q);
97
+ assert.equal(a, "", `gibberish must stay silent, got ${JSON.stringify(a)}`);
98
+ }
99
+ await m.store.close();
100
+ });
101
+
102
+ test("4. a trained fact still answers (the tier did not displace an earlier one)", async () => {
103
+ const m = mk();
104
+ await m.ingest([
105
+ ["what is the capital of France?", "The capital of France is Paris."],
106
+ ["what is the capital of Spain?", "Madrid is the capital of Spain."],
107
+ ]);
108
+ assert.match(
109
+ await m.respondText("what is the capital of France?"),
110
+ /Paris/,
111
+ );
112
+ await m.store.close();
113
+ });
@@ -0,0 +1,79 @@
1
+ // 68-extraction-unanchored.test.mjs — an extraction that located NO frame of its
2
+ // exemplar in the query is not an extraction, and must not answer.
3
+ //
4
+ // extractBySkill's own contract says `accounted` carries "the located frames AND
5
+ // any read span BOUNDED by located frames on both sides", while an open-ended
6
+ // read "remains a guess about where the span stops — it stays unaccounted".
7
+ // EMPTY accounted is the degenerate case: no frame was located at all, so
8
+ // nothing ties the bytes just read to this question. isSpanShaped is
9
+ // deliberately permissive (a sparse-subsequence check) and will accept an
10
+ // exemplar whose relation to the query is coincidental gap-matching; requiring
11
+ // at least one located frame is the structural evidence it leaves out.
12
+ //
13
+ // THE CASE (analyze_training.ts F, the battery's ONLY wrong non-silent answer,
14
+ // on the 15.7M-node store): "Which city is France's seat of government?"
15
+ // answered "Which ci" — a fragment of the query itself — from the exemplar
16
+ // "What is dll", with accounted=[] and pieces=1. A/B verified: without the gate
17
+ // the answer is "Which ci"; with it, silence. The battery went from
18
+ // 31✓/1 weak/10 empty to 31✓/0 weak/11 empty, and `extract` left the provenance
19
+ // census entirely — no wrong answers remain anywhere in it.
20
+ //
21
+ // WHY THE GATE LIVES HERE AND NOT IN THE PIPELINE. The same test at the
22
+ // pipeline's post-grounding density check was tried and REVERTED: `accounted` is
23
+ // passed empty BY CONVENTION on recall's own tiers (recall.ts ground(…, [], …)),
24
+ // so a density veto there refused six legitimate reverse-recall groundings
25
+ // (seat symmetry, bidirectional chain, E9 turn parity, C1 reverse-recall …).
26
+ // Inside extraction the field is this mechanism's own output and carries its
27
+ // documented meaning, so the test is sound exactly where the convention cannot
28
+ // reach it.
29
+ //
30
+ // NOT REPRODUCIBLE IN A FIXTURE: a miniature corpus yields ANCHORED extractions
31
+ // (a frame IS located, accounted non-empty), which this gate correctly permits —
32
+ // verified across seeds 1/7/42. So what this file pins is the other side: the
33
+ // gate must not block a located-frame extraction. The wrong-answer fix itself is
34
+ // evidenced by the real-store A/B above.
35
+
36
+ import { test } from "node:test";
37
+ import assert from "node:assert/strict";
38
+ import { Mind } from "../dist/src/index.js";
39
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
40
+
41
+ /** Span-shaped exemplars: the answer is a subsequence of its context, so the
42
+ * learnt skill is "read the thing the frame wraps". */
43
+ const TRAIN = [
44
+ ["What is dll?", "dll"],
45
+ ["What is api?", "api"],
46
+ ["What is ram?", "ram"],
47
+ ["What is cpu?", "cpu"],
48
+ ["What is gpu?", "gpu"],
49
+ ["What is ssd?", "ssd"],
50
+ ];
51
+
52
+ test("1. an extraction whose frame IS located still answers (gate must not over-block)", async () => {
53
+ for (const seed of [1, 7, 42]) {
54
+ const m = new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
55
+ await m.ingest(TRAIN);
56
+ const a = await m.respondText("Which colour is the deepest ocean?");
57
+ assert.ok(
58
+ a.length > 0,
59
+ `seed ${seed}: a located-frame extraction must survive the unanchored gate`,
60
+ );
61
+ await m.store.close();
62
+ }
63
+ });
64
+
65
+ test("2. the learnt skill still reads its own trained frame", async () => {
66
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
67
+ await m.ingest(TRAIN);
68
+ assert.match(await m.respondText("What is dll?"), /dll/);
69
+ await m.store.close();
70
+ });
71
+
72
+ test("3. gibberish stays silent — the gate adds refusal, never licence", async () => {
73
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
74
+ await m.ingest(TRAIN);
75
+ for (const q of ["qq8f3kz9 vv2m1x7w?", "xyzzy plugh quux baz?"]) {
76
+ assert.equal(await m.respondText(q), "", `expected silence for ${q}`);
77
+ }
78
+ await m.store.close();
79
+ });