@hviana/sema 0.2.3 → 0.2.5

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 (60) hide show
  1. package/dist/src/geometry.d.ts +26 -0
  2. package/dist/src/geometry.js +32 -3
  3. package/dist/src/mind/attention.d.ts +246 -7
  4. package/dist/src/mind/attention.js +771 -173
  5. package/dist/src/mind/bridge.d.ts +30 -0
  6. package/dist/src/mind/bridge.js +569 -0
  7. package/dist/src/mind/index.d.ts +2 -0
  8. package/dist/src/mind/junction.d.ts +30 -1
  9. package/dist/src/mind/junction.js +73 -18
  10. package/dist/src/mind/match.d.ts +15 -2
  11. package/dist/src/mind/match.js +3 -8
  12. package/dist/src/mind/mechanisms/cast.d.ts +54 -0
  13. package/dist/src/mind/mechanisms/cast.js +268 -37
  14. package/dist/src/mind/mechanisms/cover.js +16 -31
  15. package/dist/src/mind/mechanisms/recall.js +66 -0
  16. package/dist/src/mind/mind.d.ts +2 -0
  17. package/dist/src/mind/rationale.d.ts +7 -2
  18. package/dist/src/mind/rationale.js +6 -5
  19. package/dist/src/mind/reasoning.d.ts +11 -0
  20. package/dist/src/mind/reasoning.js +58 -2
  21. package/dist/src/mind/recognition.js +127 -7
  22. package/dist/src/mind/traverse.js +90 -25
  23. package/dist/src/mind/types.d.ts +57 -2
  24. package/dist/src/mind/types.js +38 -7
  25. package/dist/src/store.d.ts +12 -3
  26. package/dist/src/store.js +9 -3
  27. package/package.json +1 -1
  28. package/src/geometry.ts +52 -3
  29. package/src/mind/attention.ts +1199 -128
  30. package/src/mind/bridge.ts +596 -0
  31. package/src/mind/index.ts +16 -0
  32. package/src/mind/junction.ts +125 -16
  33. package/src/mind/match.ts +19 -5
  34. package/src/mind/mechanisms/cast.ts +290 -38
  35. package/src/mind/mechanisms/cover.ts +23 -36
  36. package/src/mind/mechanisms/recall.ts +79 -0
  37. package/src/mind/mind.ts +15 -0
  38. package/src/mind/rationale.ts +12 -4
  39. package/src/mind/reasoning.ts +71 -2
  40. package/src/mind/recognition.ts +132 -7
  41. package/src/mind/traverse.ts +91 -24
  42. package/src/mind/types.ts +95 -6
  43. package/src/store.ts +19 -5
  44. package/test/36-already-answered-fusion.test.mjs +128 -0
  45. package/test/37-cluster-dispersion-fusion.test.mjs +190 -0
  46. package/test/38-reason-restate-guard.test.mjs +94 -0
  47. package/test/39-cast-restate-guard.test.mjs +102 -0
  48. package/test/40-choosenext-scale-guard.test.mjs +75 -0
  49. package/test/41-seatofnode-direction.test.mjs +85 -0
  50. package/test/42-recognise-trace-idempotence.test.mjs +106 -0
  51. package/test/43-cast-analog-seat.test.mjs +244 -0
  52. package/test/44-recognise-edge-whitespace.test.mjs +63 -0
  53. package/test/45-liftanswer-restated-trim.test.mjs +60 -0
  54. package/test/46-recognise-multibyte-edge.test.mjs +85 -0
  55. package/test/47-cast-comparison-coverage.test.mjs +134 -0
  56. package/test/48-recognise-turn-connective.test.mjs +125 -0
  57. package/test/49-natural-units-synonym-bridge.test.mjs +175 -0
  58. package/test/50-cast-analog-consensus-floor.test.mjs +179 -0
  59. package/test/51-structural-resonance-ladder.test.mjs +552 -0
  60. package/test/52-climb-consensus-instrumentation.test.mjs +324 -0
@@ -0,0 +1,244 @@
1
+ // 43-cast-analog-seat.test.mjs — CAST's analogical-comparison schema must
2
+ // never chase an analog's own FORWARD continuation, whether the analog is
3
+ // a nextOf descendant or a directly aligned point.
4
+ //
5
+ // Traced live (analyze_training.ts, dialogue D geography thread): "And what
6
+ // is the capital of Spain?" answered "The capital of France is Paris.And
7
+ // what is the capital of the Moon?" — the comparison schema correctly
8
+ // identified "What is the capital of France?" as dominant and correctly
9
+ // found a genuine analog, "What is the capital of Japan?\nTokyo is the
10
+ // capital of Japan." (validated via analogyStrength, 0.7667) — a DIRECTLY
11
+ // aligned point (found in alignStructures' own output), not a nextOf
12
+ // descendant. seatOfNode's forward branch fired on it: the analog node is
13
+ // already a complete, self-answering unit with prevCount 0 (no
14
+ // establishing predecessor either), and its SOLE forward edge is a wholly
15
+ // unrelated quiz question that happens to follow it in one training
16
+ // document ("And what is the capital of the Moon?") — landing one hop past
17
+ // the informative content that made it a genuine analog in the first
18
+ // place.
19
+ //
20
+ // Two related shapes, two fixes:
21
+ // 1. A nextOf DESCENDANT (AnalogCandidate.point === null, reached by
22
+ // following another aligned point's own continuation edge — never
23
+ // matched in the query) — its own bytes ARE its seat directly, no
24
+ // projection at all (the module's own doc comment on the alignment
25
+ // loop: "the hub's own [...] context will be the seat").
26
+ // 2. A DIRECTLY aligned point (point !== null, the live shape above) —
27
+ // still goes through seatOfNode for its reverse-establishing check
28
+ // (a bare entity NAME like "Leonardo da Vinci" needs it — test/29's
29
+ // C2/C3), but with `allowForward: false`: the analog is only being
30
+ // CITED for comparison, the query never asked about it, so chasing
31
+ // its own further continuation is never appropriate — only reverse
32
+ // context or its own bytes.
33
+
34
+ import { test } from "node:test";
35
+ import assert from "node:assert/strict";
36
+ import { Mind } from "../dist/src/index.js";
37
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
38
+ import { counterfactualTransfer } from "../dist/src/mind/mechanisms/cast.js";
39
+ import { gistOf, resolve } from "../dist/src/mind/primitives.js";
40
+
41
+ const enc = (s) => new TextEncoder().encode(s);
42
+ const dec = (b) => new TextDecoder().decode(b);
43
+
44
+ test("CAST comparison: a nextOf-descendant analog is seated by its own bytes, not re-projected", async () => {
45
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
46
+ await m.ingest([
47
+ ["What is the capital of France?", "The capital of France is Paris."],
48
+ // "some other prompt" stands in for whatever real aligned point's own
49
+ // continuation edge reaches the self-contained analog below — in the
50
+ // live trace this was reached through the query's own weave, not a
51
+ // direct textual match, hence AnalogCandidate.point === null.
52
+ ["some other prompt", "Japan capital is Tokyo."],
53
+ // The coincidental further edge: the analog's OWN forward continuation,
54
+ // unrelated to the comparison, mirroring "...capital of Japan?" being
55
+ // followed by "...capital of the Moon?" in one training document.
56
+ ["Japan capital is Tokyo.", "Unrelated next quiz question."],
57
+ ]);
58
+
59
+ const query = enc(
60
+ "What is the capital of France? What is the capital of Spain?",
61
+ );
62
+ const franceId = resolve(m, enc("What is the capital of France?"));
63
+ const spainAnalogSrcId = resolve(m, enc("some other prompt"));
64
+
65
+ const dominant = {
66
+ anchor: franceId,
67
+ vote: 100,
68
+ ctx: enc("What is the capital of France?"),
69
+ runs: [{ qs: 0, qe: 30, cs: 0, weight: 1 }],
70
+ };
71
+ // The "Spain" point aligned in the query, whose OWN nextOf reaches the
72
+ // self-contained Japan analog (via a real learnt edge) — this is what
73
+ // makes the Japan node a nextOf descendant (point === null) rather than
74
+ // a directly aligned point.
75
+ const spainPoint = {
76
+ anchor: spainAnalogSrcId,
77
+ vote: 50,
78
+ ctx: enc("some other prompt"),
79
+ runs: [{ qs: 32, qe: 62, cs: 0, weight: 1 }],
80
+ };
81
+
82
+ const pre = {
83
+ attention: async () => ({
84
+ roots: [
85
+ {
86
+ anchor: franceId,
87
+ vote: 100,
88
+ start: 0,
89
+ end: 30,
90
+ breadth: 1,
91
+ clusters: 1,
92
+ },
93
+ ],
94
+ ranked: [
95
+ {
96
+ anchor: franceId,
97
+ vote: 100,
98
+ start: 0,
99
+ end: 30,
100
+ breadth: 1,
101
+ clusters: 1,
102
+ },
103
+ {
104
+ anchor: spainAnalogSrcId,
105
+ vote: 50,
106
+ start: 32,
107
+ end: 62,
108
+ breadth: 0.5,
109
+ clusters: 1,
110
+ },
111
+ ],
112
+ }),
113
+ weave: async () => ({
114
+ points: [dominant, spainPoint],
115
+ depth: new Float64Array(query.length),
116
+ }),
117
+ rec: { sites: [] },
118
+ guide: gistOf(m, query),
119
+ k: 24,
120
+ };
121
+
122
+ const results = await counterfactualTransfer(m, query, pre);
123
+ const comparison = results.find((r) =>
124
+ dec(r.bytes).includes("Japan capital is Tokyo.")
125
+ );
126
+ assert.ok(
127
+ comparison,
128
+ `expected the comparison schema's own candidate among the results, got ` +
129
+ results.map((r) => dec(r.bytes)),
130
+ );
131
+ assert.ok(
132
+ !dec(comparison.bytes).includes("Unrelated next quiz question"),
133
+ `the nextOf-descendant analog must not be re-projected past its own ` +
134
+ `bytes, got "${dec(comparison.bytes)}"`,
135
+ );
136
+ await m.store.close();
137
+ });
138
+
139
+ test("CAST comparison: a DIRECTLY aligned analog is never re-projected past its own bytes", async () => {
140
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
141
+ await m.ingest([
142
+ ["What is the capital of France?", "The capital of France is Paris."],
143
+ // The analog itself: an already-complete Q+A unit, mirroring the live
144
+ // "What is the capital of Japan?\nTokyo is the capital of Japan." node
145
+ // — matched DIRECTLY in the query this time (point !== null), not via
146
+ // nextOf.
147
+ [
148
+ "some other prompt",
149
+ "What is the capital of Japan? Tokyo is the capital of Japan.",
150
+ ],
151
+ // The analog's own coincidental further edge — an unrelated quiz
152
+ // question, exactly the "capital of the Moon" shape.
153
+ [
154
+ "What is the capital of Japan? Tokyo is the capital of Japan.",
155
+ "Unrelated next quiz question.",
156
+ ],
157
+ ]);
158
+
159
+ // The query itself never mentions Japan at all — exactly the live shape:
160
+ // "And what is the capital of Spain?" never contains Japan's text either;
161
+ // the analog is found by STRUCTURAL (halo) similarity to the dominant,
162
+ // not by a literal substring match — hence bestAnalog.point !== null
163
+ // (a genuinely ALIGNED point, just not aligned by literal quotation).
164
+ const query = enc(
165
+ "What is the capital of France? What is the capital of Spain?",
166
+ );
167
+ const franceId = resolve(m, enc("What is the capital of France?"));
168
+ const japanId = resolve(
169
+ m,
170
+ enc("What is the capital of Japan? Tokyo is the capital of Japan."),
171
+ );
172
+ assert.ok(franceId !== null && japanId !== null, "corpus must resolve");
173
+
174
+ const dominant = {
175
+ anchor: franceId,
176
+ vote: 100,
177
+ ctx: enc("What is the capital of France?"),
178
+ runs: [{ qs: 0, qe: 30, cs: 0, weight: 1 }],
179
+ };
180
+ // Aligned to the "Spain" span of the query by halo similarity (not literal
181
+ // quotation) — a directly aligned point, so bestAnalog.point !== null.
182
+ const japanPoint = {
183
+ anchor: japanId,
184
+ vote: 50,
185
+ ctx: enc("What is the capital of Japan? Tokyo is the capital of Japan."),
186
+ runs: [{ qs: 32, qe: 62, cs: 0, weight: 1 }],
187
+ };
188
+
189
+ const pre = {
190
+ attention: async () => ({
191
+ roots: [
192
+ {
193
+ anchor: franceId,
194
+ vote: 100,
195
+ start: 0,
196
+ end: 30,
197
+ breadth: 1,
198
+ clusters: 1,
199
+ },
200
+ ],
201
+ ranked: [
202
+ {
203
+ anchor: franceId,
204
+ vote: 100,
205
+ start: 0,
206
+ end: 30,
207
+ breadth: 1,
208
+ clusters: 1,
209
+ },
210
+ {
211
+ anchor: japanId,
212
+ vote: 50,
213
+ start: 32,
214
+ end: 62,
215
+ breadth: 0.5,
216
+ clusters: 1,
217
+ },
218
+ ],
219
+ }),
220
+ weave: async () => ({
221
+ points: [dominant, japanPoint],
222
+ depth: new Float64Array(query.length),
223
+ }),
224
+ rec: { sites: [] },
225
+ guide: gistOf(m, query),
226
+ k: 24,
227
+ };
228
+
229
+ const results = await counterfactualTransfer(m, query, pre);
230
+ const comparison = results.find((r) =>
231
+ dec(r.bytes).includes("Tokyo is the capital of Japan")
232
+ );
233
+ assert.ok(
234
+ comparison,
235
+ `expected the comparison schema's own candidate among the results, got ` +
236
+ results.map((r) => dec(r.bytes)),
237
+ );
238
+ assert.ok(
239
+ !dec(comparison.bytes).includes("Unrelated next quiz question"),
240
+ `a directly aligned analog must not be re-projected past its own ` +
241
+ `bytes, got "${dec(comparison.bytes)}"`,
242
+ );
243
+ await m.store.close();
244
+ });
@@ -0,0 +1,63 @@
1
+ // 44-recognise-edge-whitespace.test.mjs — recognise() must still find a
2
+ // stored form when the query's own fold chunk swallows a leading/trailing
3
+ // separator byte the trained form never had.
4
+ //
5
+ // Traced live (analyze_training.ts, dialogue D geography thread): the
6
+ // accumulated query "...And what is the capital of Spain?" recognises
7
+ // nothing near "what is the capital of Spain?" even though the exact fact
8
+ // is trained and well-formed ("What is the capital of Spain?", resolves
9
+ // cleanly in isolation). Root-caused directly: the query's OWN fold draws
10
+ // a chunk boundary at " what is the capital of Spain?" (WITH the leading
11
+ // space folded in from the preceding "And "), and canon.ts's own
12
+ // documented contract says edge whitespace belongs between forms, never to
13
+ // one — so canonResolve correctly refuses that padded span. The one-byte
14
+ // trimmed span DOES canon-resolve to the trained fact (verified directly:
15
+ // canonResolve on bytes[65,94) returns the same id as resolving "What is
16
+ // the capital of Spain?" standalone) — but nothing tried the trimmed span.
17
+ // Fix: when a chunk's canon fallback misses, retry the two one-byte-
18
+ // shorter edge variants via resolve() (self-verifying, like every
19
+ // content-addressed lookup here) before giving up — mind/recognition.ts
20
+ // has no notion of "whitespace" at all, it just trusts the store's own
21
+ // hash-then-verify discipline. Two extra probes, only on the
22
+ // already-failed miss path.
23
+
24
+ import { test } from "node:test";
25
+ import assert from "node:assert/strict";
26
+ import { Mind, SQliteStore } from "../dist/src/index.js";
27
+ import { textCanon } from "../dist/src/canon.js";
28
+ import { recognise } from "../dist/src/mind/recognition.js";
29
+ import { resolve } from "../dist/src/mind/primitives.js";
30
+
31
+ const enc = (s) => new TextEncoder().encode(s);
32
+
33
+ test("recognise(): a chunk that swallows a leading separator still finds the trained form after trimming", async () => {
34
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
35
+ await m.ingest([
36
+ ["What is the capital of France?", "The capital of France is Paris."],
37
+ ["What is the capital of Spain?", "Madrid is the capital of Spain."],
38
+ ]);
39
+ await m.buildCanonIndex(textCanon);
40
+ m.canon = m._canonFor(textCanon);
41
+ m.canonMemo = new Map();
42
+
43
+ const query = enc(
44
+ "What is the capital of France?The capital of France is Paris." +
45
+ "And what is the capital of Spain?",
46
+ );
47
+ const expected = resolve(m, enc("What is the capital of Spain?"));
48
+ assert.ok(
49
+ expected !== null,
50
+ "sanity: the trained fact must resolve standalone",
51
+ );
52
+
53
+ const rec = recognise(m, query);
54
+ const hit = rec.sites.find((s) => s.payload === expected);
55
+ assert.ok(
56
+ hit,
57
+ `expected a recognised site for the trained Spain fact, got sites: ` +
58
+ JSON.stringify(
59
+ rec.sites.map((s) => [s.start, s.end, s.payload]),
60
+ ),
61
+ );
62
+ await m.store.close();
63
+ });
@@ -0,0 +1,60 @@
1
+ // 45-liftanswer-restated-trim.test.mjs — cover's derivation must not be
2
+ // discarded WHOLESALE just because one of its segments restates content the
3
+ // query already contains; liftAnswer should TRIM the restating segment and
4
+ // keep the rest.
5
+ //
6
+ // Traced live (analyze_training.ts, dialogue D geography thread) and
7
+ // reproduced minimally here: in a multi-turn conversation, turn 1's own
8
+ // reply ("The capital of France is Paris.") becomes part of turn 2's
9
+ // accumulated query verbatim. cover's search correctly answers turn 1's
10
+ // embedded question by FOLLOWING its edge to that same reply text — which
11
+ // now duplicates content already sitting later in the query (the prior
12
+ // turn's own answer, echoed back). The OLD behaviour (cover.ts's
13
+ // `restatedSpan` check) detected this correctly but responded by discarding
14
+ // the ENTIRE candidate — throwing away the genuinely NEW answer (turn 2's
15
+ // own question) along with the stale restated one. Root cause: node
16
+ // "The capital of France is Paris." has no forward edge and no concept
17
+ // sibling (a plain declarative answer, not itself a question), so cover's
18
+ // search also has to fall back to a very expensive byte-by-byte cover for
19
+ // its own re-occurrence — but that is a SEPARATE, already-documented
20
+ // concern; this test's fix is specifically about not discarding good
21
+ // content alongside restated content once a derivation IS found.
22
+ //
23
+ // Fix: liftAnswer (types.ts) now takes the query bytes and the geometry's
24
+ // quantum W, and excludes any recognised segment whose SUBSTITUTED bytes
25
+ // (not the segment's own literal text) already occur elsewhere in the
26
+ // query — from both the framing (lo/hi) decision AND the final
27
+ // concatenation — instead of cover.ts rejecting the whole derivation.
28
+
29
+ import { test } from "node:test";
30
+ import assert from "node:assert/strict";
31
+ import { Mind } from "../dist/src/index.js";
32
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
33
+
34
+ const dec = (b) => new TextDecoder().decode(b).replace(/\0+$/, "");
35
+
36
+ test("cover: a restated segment is trimmed from the answer, not discarded wholesale", async () => {
37
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
38
+ await m.ingest([
39
+ ["What is the capital of France?", "The capital of France is Paris."],
40
+ ["What is the capital of Spain?", "Madrid is the capital of Spain."],
41
+ ]);
42
+
43
+ const conv = m.beginConversation();
44
+ await m.respondTurn(conv, "What is the capital of France?");
45
+ const r2 = await m.respondTurn(conv, "What is the capital of Spain?");
46
+ const got = dec(r2.response.bytes);
47
+
48
+ assert.ok(
49
+ got.length > 0,
50
+ "expected a non-empty answer — the genuinely new Spain answer must not " +
51
+ "be discarded just because the France answer restates itself",
52
+ );
53
+ assert.ok(
54
+ got.includes("Madrid") || got.includes("Spain"),
55
+ `expected the NEW answer (Spain) to survive, got ${JSON.stringify(got)}`,
56
+ );
57
+
58
+ m.endConversation(conv);
59
+ await m.store.close();
60
+ });
@@ -0,0 +1,85 @@
1
+ // 46-recognise-multibyte-edge.test.mjs — recognise()'s canon-miss fallback
2
+ // must recover a trained form whose edge misalignment is MORE than one byte
3
+ // (the shipped ±1 fix only covers a single stray edge byte).
4
+ //
5
+ // bytesToTree's plain fold is RADIX-ALIGNED: a chunk's own boundary is a
6
+ // multiple of the geometry's group width W from the LOCAL start of whatever
7
+ // it was folded from (see geometry.ts's FoldPyramid comment). A query whose
8
+ // recognised span sits at a different local offset than the trained deposit
9
+ // did — e.g. extra leading whitespace, which canon deliberately preserves
10
+ // verbatim at the edges, only collapsing INTERIOR whitespace — shifts every
11
+ // chunk boundary inside it by that many bytes, which can exceed 1.
12
+ //
13
+ // Fix: widen the miss-fallback with bounded W-quantum edge trims, each
14
+ // gated by the SAME cheap store.findBranch(leafIds) pre-filter the
15
+ // canonical pass (tryChain) already uses — so the (rare) miss path pays for
16
+ // a real resolve() fold only when a branch could plausibly exist there —
17
+ // and capped to nodes no larger than chainReach(W) = W² (a chunk-scale
18
+ // bound, not a whole-query one): widening at ROOT scale can rediscover
19
+ // content the structural walk's own finer recursion already owns as a
20
+ // SEPARATE, duplicate site, and downstream derivation can then stitch a
21
+ // wrong answer out of the two overlapping sites (see the second test here).
22
+
23
+ import { test } from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { Mind, SQliteStore } from "../dist/src/index.js";
26
+ import { recognise } from "../dist/src/mind/recognition.js";
27
+ import { resolve } from "../dist/src/mind/primitives.js";
28
+
29
+ const enc = (s) => new TextEncoder().encode(s);
30
+ const dec = (b) => new TextDecoder().decode(b).replace(/\0+$/, "");
31
+
32
+ test("recognise(): a >1-byte edge misalignment still finds the trained form", async () => {
33
+ // A whole-query-scale offset (the entire query is nothing but the trained
34
+ // fact plus a swallowed prefix) is a DIFFERENT, still-open problem — see
35
+ // the "does not corrupt" test below for why widening this fix to root
36
+ // scale is exactly what must NOT happen. What this fix targets is a
37
+ // canon-miss node bounded to chunk scale (<= W², chainReach's own bound)
38
+ // whose trim offset is more than the shipped ±1 fallback reaches.
39
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
40
+ await m.ingest([["cats meow", "yes they do"]]);
41
+
42
+ const expected = resolve(m, enc("cats meow"));
43
+ assert.ok(
44
+ expected !== null,
45
+ "sanity: the trained fact must resolve standalone",
46
+ );
47
+
48
+ // Two EXTRA leading spaces relative to the trained form — canon only
49
+ // collapses interior whitespace, so this is a 2-byte edge offset, wider
50
+ // than the shipped ±1 fallback covers.
51
+ const query = enc(" cats meow");
52
+ const rec = recognise(m, query);
53
+ const hit = rec.sites.find((s) => s.payload === expected);
54
+ assert.ok(
55
+ hit,
56
+ `expected a recognised site for the trained fact despite the 2-byte ` +
57
+ `edge offset, got sites: ` +
58
+ JSON.stringify(rec.sites.map((s) => [s.start, s.end, s.payload])),
59
+ );
60
+ await m.store.close();
61
+ });
62
+
63
+ test("recognise(): a wide edge trim does not corrupt an unrelated short-form answer", async () => {
64
+ // Regression guard for the root-scale false positive: widening the
65
+ // fallback's reach must not let it re-derive a smaller subtree's own
66
+ // content as a SEPARATE site at the whole-query node, which previously
67
+ // let cover's derivation stitch two overlapping sites into a wrong
68
+ // answer ("4+15" instead of "4").
69
+ const TABLE = [
70
+ ["1+2", "3"],
71
+ ["2+2", "4"],
72
+ ["2+3", "5"],
73
+ ["3+3", "6"],
74
+ ["3+5", "8"],
75
+ ["4+3", "7"],
76
+ ["2+5", "7"],
77
+ ["1+5", "6"],
78
+ ["6+1", "7"],
79
+ ["4+1", "5"],
80
+ ];
81
+ const m = new Mind({ seed: 42 });
82
+ await m.ingest(TABLE);
83
+ const r = await m.respond("2+2 は何ですか");
84
+ assert.equal(dec(r.bytes), "4");
85
+ });
@@ -0,0 +1,134 @@
1
+ // 47-cast-comparison-coverage.test.mjs — CAST's analogical-comparison schema
2
+ // must not fire when its own accounted evidence leaves a substantial,
3
+ // contiguous stretch of the query untouched — the same half-dominance bar
4
+ // CAST's own frame gate and liftAnswer already apply, PLUS a topic-scale
5
+ // bar (see cast.ts's comparison-gate comment for why one alone isn't enough).
6
+ //
7
+ // Traced live (analyze_training.ts, dialogue D geography thread): "And what
8
+ // is the capital of Spain?" answered "The capital of France is Paris.What is
9
+ // the capital of Japan?\nTokyo is the capital of Japan." — a wrong analog.
10
+ // Root-caused directly against the live store: recognise() never finds a
11
+ // site for "capital of Spain" at all (a genuine extra word, "And ", not
12
+ // boundary noise — see the session's own investigation), so the consensus
13
+ // climb commits only ONE root (France) and CAST's comparison schema treats
14
+ // the query as "about one thing."
15
+ //
16
+ // The live numbers are the important part of this test: comparison's own
17
+ // accounted spans (dominant [0,30) + analog [77,88)) leave a 47-byte gap in
18
+ // the 94-byte query — exactly HALF, so the query-relative bar alone
19
+ // (dominates: strictly more than half) does NOT decisively refuse it; only
20
+ // the topic-relative bar (47 ≥ dominant's own 30-byte context) does. This
21
+ // test pins the EXACT borderline shape, not an easier, more comfortably
22
+ // over-half one — a fix that only handled a clear majority-gap would still
23
+ // leave the live bug live.
24
+
25
+ import { test } from "node:test";
26
+ import assert from "node:assert/strict";
27
+ import { Mind } from "../dist/src/index.js";
28
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
29
+ import { counterfactualTransfer } from "../dist/src/mind/mechanisms/cast.js";
30
+ import { gistOf, resolve } from "../dist/src/mind/primitives.js";
31
+
32
+ const enc = (s) => new TextEncoder().encode(s);
33
+ const dec = (b) => new TextDecoder().decode(b);
34
+
35
+ test("CAST comparison: refuses to fire when its own accounted evidence leaves an exactly-half, topic-scale gap", async () => {
36
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
37
+ await m.ingest([
38
+ ["What is the capital of France?", "The capital of France is Paris."],
39
+ [
40
+ "some other prompt",
41
+ "What is the capital of Japan? Tokyo is the capital of Japan.",
42
+ ],
43
+ [
44
+ "What is the capital of Japan? Tokyo is the capital of Japan.",
45
+ "Unrelated next quiz question.",
46
+ ],
47
+ ]);
48
+
49
+ // The real accumulated multi-turn shape: dominant covers only the FIRST
50
+ // 30 bytes (the France sentence); the rest of the query — including the
51
+ // whole "And what is the capital of Spain?" tail — is never aligned to
52
+ // anything beyond a bare 5-byte "Spain" token, mirroring the live case
53
+ // where recognise() found nothing bigger for that span.
54
+ const query = enc(
55
+ "What is the capital of France?The capital of France is Paris." +
56
+ "And what is the capital of Spain?",
57
+ );
58
+ const franceId = resolve(m, enc("What is the capital of France?"));
59
+ const japanId = resolve(
60
+ m,
61
+ enc("What is the capital of Japan? Tokyo is the capital of Japan."),
62
+ );
63
+ assert.ok(franceId !== null && japanId !== null, "corpus must resolve");
64
+
65
+ const dominant = {
66
+ anchor: franceId,
67
+ vote: 100,
68
+ ctx: enc("What is the capital of France?"),
69
+ runs: [{ qs: 0, qe: 30, cs: 0, weight: 1 }],
70
+ };
71
+ // Directly aligned, but its OWN evidence is just "capital of" (bytes
72
+ // 77..88 of the 94-byte query) — the exact live span — not the whole
73
+ // clause and not even the word "Spain" itself.
74
+ const japanPoint = {
75
+ anchor: japanId,
76
+ vote: 50,
77
+ ctx: enc("What is the capital of Japan? Tokyo is the capital of Japan."),
78
+ runs: [{ qs: 77, qe: 88, cs: 0, weight: 1 }],
79
+ };
80
+
81
+ const pre = {
82
+ attention: async () => ({
83
+ roots: [
84
+ {
85
+ anchor: franceId,
86
+ vote: 100,
87
+ start: 0,
88
+ end: 30,
89
+ breadth: 1,
90
+ clusters: 1,
91
+ },
92
+ ],
93
+ ranked: [
94
+ {
95
+ anchor: franceId,
96
+ vote: 100,
97
+ start: 0,
98
+ end: 30,
99
+ breadth: 1,
100
+ clusters: 1,
101
+ },
102
+ {
103
+ anchor: japanId,
104
+ vote: 50,
105
+ start: 77,
106
+ end: 88,
107
+ breadth: 0.5,
108
+ clusters: 1,
109
+ },
110
+ ],
111
+ }),
112
+ weave: async () => ({
113
+ points: [dominant, japanPoint],
114
+ depth: new Float64Array(query.length),
115
+ }),
116
+ rec: { sites: [] },
117
+ guide: gistOf(m, query),
118
+ k: 24,
119
+ };
120
+
121
+ const results = await counterfactualTransfer(m, query, pre);
122
+ const comparison = results.find((r) =>
123
+ dec(r.bytes).includes("Tokyo is the capital of Japan")
124
+ );
125
+ assert.ok(
126
+ !comparison,
127
+ `comparison should refuse when the largest gap (47 bytes) is exactly ` +
128
+ `half the 94-byte query and at least as large as the 30-byte ` +
129
+ `dominant, but fired: ${
130
+ comparison ? JSON.stringify(dec(comparison.bytes)) : ""
131
+ }`,
132
+ );
133
+ await m.store.close();
134
+ });