@hviana/sema 0.2.2 → 0.2.4

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/mind/articulation.js +1 -1
  2. package/dist/src/mind/attention.d.ts +18 -2
  3. package/dist/src/mind/attention.js +88 -18
  4. package/dist/src/mind/bridge.d.ts +30 -0
  5. package/dist/src/mind/bridge.js +569 -0
  6. package/dist/src/mind/graph-search.d.ts +16 -1
  7. package/dist/src/mind/graph-search.js +34 -5
  8. package/dist/src/mind/match.d.ts +15 -2
  9. package/dist/src/mind/match.js +3 -8
  10. package/dist/src/mind/mechanisms/alu.js +8 -1
  11. package/dist/src/mind/mechanisms/cast.d.ts +54 -0
  12. package/dist/src/mind/mechanisms/cast.js +303 -48
  13. package/dist/src/mind/mechanisms/cover.js +24 -32
  14. package/dist/src/mind/mechanisms/extraction.js +75 -30
  15. package/dist/src/mind/mechanisms/recall.js +66 -0
  16. package/dist/src/mind/mind.d.ts +1 -0
  17. package/dist/src/mind/mind.js +6 -1
  18. package/dist/src/mind/pipeline.js +34 -2
  19. package/dist/src/mind/reasoning.d.ts +20 -1
  20. package/dist/src/mind/reasoning.js +84 -6
  21. package/dist/src/mind/recognition.js +157 -13
  22. package/dist/src/mind/traverse.js +16 -0
  23. package/dist/src/mind/types.d.ts +65 -2
  24. package/dist/src/mind/types.js +53 -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/mind/articulation.ts +1 -0
  29. package/src/mind/attention.ts +105 -17
  30. package/src/mind/bridge.ts +596 -0
  31. package/src/mind/graph-search.ts +59 -2
  32. package/src/mind/match.ts +19 -5
  33. package/src/mind/mechanisms/alu.ts +8 -1
  34. package/src/mind/mechanisms/cast.ts +336 -46
  35. package/src/mind/mechanisms/cover.ts +31 -36
  36. package/src/mind/mechanisms/extraction.ts +101 -40
  37. package/src/mind/mechanisms/recall.ts +79 -0
  38. package/src/mind/mind.ts +7 -1
  39. package/src/mind/pipeline.ts +37 -2
  40. package/src/mind/reasoning.ts +97 -5
  41. package/src/mind/recognition.ts +160 -12
  42. package/src/mind/traverse.ts +17 -0
  43. package/src/mind/types.ts +110 -6
  44. package/src/store.ts +19 -5
  45. package/test/35-attention-confidence.test.mjs +139 -0
  46. package/test/36-already-answered-fusion.test.mjs +128 -0
  47. package/test/37-cluster-dispersion-fusion.test.mjs +190 -0
  48. package/test/38-reason-restate-guard.test.mjs +94 -0
  49. package/test/39-cast-restate-guard.test.mjs +102 -0
  50. package/test/40-choosenext-scale-guard.test.mjs +75 -0
  51. package/test/41-seatofnode-direction.test.mjs +85 -0
  52. package/test/42-recognise-trace-idempotence.test.mjs +106 -0
  53. package/test/43-cast-analog-seat.test.mjs +244 -0
  54. package/test/44-recognise-edge-whitespace.test.mjs +63 -0
  55. package/test/45-liftanswer-restated-trim.test.mjs +60 -0
  56. package/test/46-recognise-multibyte-edge.test.mjs +85 -0
  57. package/test/47-cast-comparison-coverage.test.mjs +134 -0
  58. package/test/48-recognise-turn-connective.test.mjs +125 -0
  59. package/test/49-natural-units-synonym-bridge.test.mjs +175 -0
  60. package/test/50-cast-analog-consensus-floor.test.mjs +179 -0
@@ -0,0 +1,85 @@
1
+ // 41-seatofnode-direction.test.mjs — CAST's seatOfNode (comparison schema)
2
+ // must seat a node by its own FORWARD identity when it has one, not by an
3
+ // incidental predecessor.
4
+ //
5
+ // Traced live (analyze_training.ts, dialogue D geography thread): "And what
6
+ // is the capital of Spain?" answered with "Create an example of a types of
7
+ // questions a GPT model can answer.?And what is the capital of the Moon?"
8
+ // — CAST's comparison schema correctly identified "What is the capital of
9
+ // France?" as the dominant structure and a genuine analog ("capital of
10
+ // Japan"), but seated the dominant by REVERSE context instead of forward:
11
+ // seatOfNode's old test (`prevCount(id) === 0`) skipped the forward branch
12
+ // because that exact question happens to have ONE coincidental predecessor
13
+ // elsewhere in the corpus (a generic "generate example questions"
14
+ // meta-prompt) — even though its own forward edge unambiguously resolves
15
+ // to "The capital of France is Paris."
16
+ //
17
+ // A broad sample of this store's own question-shaped nodes showed this
18
+ // isn't rare: ~71% have at least one predecessor, the large majority from
19
+ // a handful of generic, high-fan-out sentences that recur as incidental
20
+ // neighbours to dozens of unrelated destinations — a SmolSent-style
21
+ // adjacency artifact, not a real identity-establishing lead-in. There is
22
+ // no reliable way to tell a meaningful predecessor from an incidental one
23
+ // by count alone (the same category error already found and removed from
24
+ // chooseNext), so the fix restores the ONE priority every other consumer
25
+ // of follow()/reverseContext() already uses (project(), pivotInto, cast's
26
+ // own substitution schema): forward first, reverse only as a fallback when
27
+ // forward doesn't resolve.
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
+ import { seatOfNode } from "../dist/src/mind/mechanisms/cast.js";
34
+ import { gistOf, resolve } from "../dist/src/mind/primitives.js";
35
+
36
+ const enc = (s) => new TextEncoder().encode(s);
37
+ const dec = (b) => new TextDecoder().decode(b);
38
+
39
+ const mk = (seed) =>
40
+ new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
41
+
42
+ test("seatOfNode: a node with a strong forward identity is seated forward despite a coincidental predecessor", async () => {
43
+ const m = mk(7);
44
+ // "the question" has both a genuine forward answer AND one coincidental
45
+ // predecessor — exactly the live shape (prevCount > 0, hasNext true).
46
+ await m.ingest([
47
+ ["the question", "the real answer"],
48
+ ["a generic template prompt", "the question"],
49
+ ]);
50
+
51
+ const id = resolve(m, enc("the question"));
52
+ assert.ok(id !== null, "corpus must resolve");
53
+ assert.ok(m.store.prevCount(id) > 0, "must have a coincidental predecessor");
54
+ assert.ok(m.store.hasNext(id), "must have a forward continuation");
55
+
56
+ const guide = gistOf(m, enc("the question"));
57
+ const seat = await seatOfNode(m, id, guide, enc("fallback"));
58
+ assert.equal(
59
+ dec(seat),
60
+ "the real answer",
61
+ `must seat by forward identity, not the coincidental predecessor, got "${
62
+ dec(seat)
63
+ }"`,
64
+ );
65
+ await m.store.close();
66
+ });
67
+
68
+ test("seatOfNode: a node with NO forward identity still falls back to its reverse context", async () => {
69
+ const m = mk(7);
70
+ await m.ingest([["known as", "the entity"]]);
71
+
72
+ const id = resolve(m, enc("the entity"));
73
+ assert.ok(id !== null, "corpus must resolve");
74
+ assert.equal(m.store.hasNext(id), false, "must have no forward continuation");
75
+
76
+ const guide = gistOf(m, enc("the entity"));
77
+ const seat = await seatOfNode(m, id, guide, enc("fallback"));
78
+ assert.equal(
79
+ dec(seat),
80
+ "known as",
81
+ `an entity with no forward edge must still fall back to its reverse ` +
82
+ `context, got "${dec(seat)}"`,
83
+ );
84
+ await m.store.close();
85
+ });
@@ -0,0 +1,106 @@
1
+ // 42-recognise-trace-idempotence.test.mjs — recognise() (and, by the same
2
+ // defect, climbAttentionAll()) must return the SAME result whether or not
3
+ // inspectRationale is attached — tracing must never change what the
4
+ // system decides, only whether it explains itself.
5
+ //
6
+ // Traced live (analyze_training.ts): the same 4-turn dialogue produced a
7
+ // DIFFERENT final answer depending only on whether inspectRationale was
8
+ // attached to the last turn — a deterministic, reproducible divergence,
9
+ // not cross-process randomness (verified: 5 untraced runs, byte-identical;
10
+ // traced vs untraced, consistently different).
11
+ //
12
+ // Root cause, isolated directly: recogniseImpl walks the query's perceived
13
+ // tree via foldTree(ctx, tree, 0, visit) — and foldTree's subtree-resolution
14
+ // fast path (primitives.ts) returns immediately for any subtree already
15
+ // cached in ctx._resolvedSubtrees, WITHOUT recursing into its children, so
16
+ // `visit` never fires for anything below that point. A multi-turn
17
+ // conversation's stable-prefix fold deliberately shares node OBJECTS
18
+ // across turns and within a turn's own walk, so by a SECOND call on the
19
+ // exact same bytes, large parts of the tree are already cached and
20
+ // recogniseImpl silently finds FEWER sites than the first call — it is not
21
+ // safe to call twice on the same input once `_resolvedSubtrees` is warm.
22
+ //
23
+ // Under ordinary (untraced) operation this never surfaces: recogniseMemo
24
+ // (keyed by exact byte content) ensures recogniseImpl only ever runs ONCE
25
+ // per distinct query string per conversation. But the memo was previously
26
+ // SKIPPED whenever `ctx.trace` was truthy ("so every call still emits its
27
+ // rationale step") — meaning a traced turn re-ran recogniseImpl from
28
+ // scratch at every one of the many call sites that recognise the same
29
+ // query within one response (cover, reason, articulate...), each
30
+ // subsequent call more incomplete than the last, silently changing which
31
+ // mechanism grounds the answer. Fix: consult (and populate) the memo
32
+ // unconditionally — matching perceive()'s own memo, which never had a
33
+ // trace gate — and emit the trace step from the cache-hit path directly,
34
+ // so tracing stays fully observable without ever bypassing correctness.
35
+ // climbAttentionAll() had the identical `!ctx.trace` gate over the same
36
+ // class of foldTree-based computation (collectRegions) and got the same
37
+ // fix, on the same reasoning, in the same change.
38
+
39
+ import { test } from "node:test";
40
+ import assert from "node:assert/strict";
41
+ import { Mind } from "../dist/src/index.js";
42
+ import { recognise } from "../dist/src/mind/recognition.js";
43
+
44
+ const enc = (s) => new TextEncoder().encode(s);
45
+
46
+ test("recognise(): a traced call returns the identical cached result, not a degraded recompute", async () => {
47
+ const m = new Mind({ seed: 7 });
48
+ // A long, deeply-chunked query (enough leaf-parents for foldTree's
49
+ // subtree cache to matter) that also recurs as a learnt SOURCE, so its
50
+ // own subtree — and many of its word-level sub-leaves — resolve and get
51
+ // cached into ctx._resolvedSubtrees on the first walk.
52
+ const words = [
53
+ "hello",
54
+ "world",
55
+ "foo",
56
+ "bar",
57
+ "baz",
58
+ "qux",
59
+ "quux",
60
+ "corge",
61
+ "grault",
62
+ "garply",
63
+ "waldo",
64
+ "fred",
65
+ "plugh",
66
+ "xyzzy",
67
+ "thud",
68
+ ];
69
+ const long = words.join(" ") + " " + words.slice().reverse().join(" ");
70
+ await m.ingest([
71
+ [long, "some reply text here"],
72
+ ...words.map((w, i) => [w, `word ${i}`]),
73
+ ]);
74
+
75
+ // _resolvedSubtrees is only ever populated during respondTurn's
76
+ // conversation machinery (a plain respond() leaves it null, immune to
77
+ // this defect) — reproduce that shape directly for a controlled,
78
+ // deterministic unit test.
79
+ m.perceiveMemo = new Map();
80
+ m.recogniseMemo = new Map();
81
+ m._resolvedSubtrees = new WeakMap();
82
+ m.trace = null;
83
+
84
+ const bytes = enc(long);
85
+ const untraced = recognise(m, bytes);
86
+ assert.ok(
87
+ untraced.sites.length > 0,
88
+ "sanity: the untraced call must find sites",
89
+ );
90
+
91
+ m.trace = { enter: () => undefined, step: () => undefined };
92
+ const traced = recognise(m, bytes);
93
+
94
+ assert.equal(
95
+ traced,
96
+ untraced,
97
+ "a traced call must return the SAME cached Recognition object, not " +
98
+ "recompute (and potentially degrade) it",
99
+ );
100
+ assert.equal(
101
+ traced.sites.length,
102
+ untraced.sites.length,
103
+ `traced and untraced site counts must match, got ${traced.sites.length} ` +
104
+ `vs ${untraced.sites.length}`,
105
+ );
106
+ });
@@ -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
+ });