@hviana/sema 0.2.3 → 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 (45) hide show
  1. package/dist/src/mind/attention.d.ts +14 -2
  2. package/dist/src/mind/attention.js +54 -8
  3. package/dist/src/mind/bridge.d.ts +30 -0
  4. package/dist/src/mind/bridge.js +569 -0
  5. package/dist/src/mind/match.d.ts +15 -2
  6. package/dist/src/mind/match.js +3 -8
  7. package/dist/src/mind/mechanisms/cast.d.ts +54 -0
  8. package/dist/src/mind/mechanisms/cast.js +268 -37
  9. package/dist/src/mind/mechanisms/cover.js +16 -31
  10. package/dist/src/mind/mechanisms/recall.js +66 -0
  11. package/dist/src/mind/reasoning.d.ts +11 -0
  12. package/dist/src/mind/reasoning.js +58 -2
  13. package/dist/src/mind/recognition.js +127 -7
  14. package/dist/src/mind/traverse.js +16 -15
  15. package/dist/src/mind/types.d.ts +39 -2
  16. package/dist/src/mind/types.js +38 -7
  17. package/dist/src/store.d.ts +12 -3
  18. package/dist/src/store.js +9 -3
  19. package/package.json +1 -1
  20. package/src/mind/attention.ts +65 -7
  21. package/src/mind/bridge.ts +596 -0
  22. package/src/mind/match.ts +19 -5
  23. package/src/mind/mechanisms/cast.ts +290 -38
  24. package/src/mind/mechanisms/cover.ts +23 -36
  25. package/src/mind/mechanisms/recall.ts +79 -0
  26. package/src/mind/reasoning.ts +71 -2
  27. package/src/mind/recognition.ts +132 -7
  28. package/src/mind/traverse.ts +16 -17
  29. package/src/mind/types.ts +70 -6
  30. package/src/store.ts +19 -5
  31. package/test/36-already-answered-fusion.test.mjs +128 -0
  32. package/test/37-cluster-dispersion-fusion.test.mjs +190 -0
  33. package/test/38-reason-restate-guard.test.mjs +94 -0
  34. package/test/39-cast-restate-guard.test.mjs +102 -0
  35. package/test/40-choosenext-scale-guard.test.mjs +75 -0
  36. package/test/41-seatofnode-direction.test.mjs +85 -0
  37. package/test/42-recognise-trace-idempotence.test.mjs +106 -0
  38. package/test/43-cast-analog-seat.test.mjs +244 -0
  39. package/test/44-recognise-edge-whitespace.test.mjs +63 -0
  40. package/test/45-liftanswer-restated-trim.test.mjs +60 -0
  41. package/test/46-recognise-multibyte-edge.test.mjs +85 -0
  42. package/test/47-cast-comparison-coverage.test.mjs +134 -0
  43. package/test/48-recognise-turn-connective.test.mjs +125 -0
  44. package/test/49-natural-units-synonym-bridge.test.mjs +175 -0
  45. package/test/50-cast-analog-consensus-floor.test.mjs +179 -0
@@ -0,0 +1,190 @@
1
+ // 37-cluster-dispersion-fusion.test.mjs — Category B of the multi-turn
2
+ // garbling investigation: a further point of attention corroborated from
3
+ // only ONE local neighbourhood of the query must not be trusted to fuse in,
4
+ // regardless of how strong its vote is.
5
+ //
6
+ // Two measures were tried and falsified before this one:
7
+ // - Attention.breadth (dominance-fraction) starves a genuine, evenly-split
8
+ // multi-topic query — no root in a real N-way split can exceed half the
9
+ // vote (test/24 gap 3.1).
10
+ // - raw regionSupport (corroboration COUNT, summed) doesn't separate a
11
+ // genuine topic from a coincidental echo either — a short, structurally
12
+ // simple echo racks up as many corroborating regions as a real topic
13
+ // does (test/35's arithmetic echo: 4 regions, the same range as gap
14
+ // 3.1's real topics: 5-6).
15
+ //
16
+ // Both are MAGNITUDE measures over the SAME span. The live defect (traced
17
+ // directly against the trained store) was different in kind: an unrelated
18
+ // node.js answer got fused in because the query's own short closing phrase
19
+ // ("Thank you very much!") happened to be byte-identical to how ONE other,
20
+ // wholly unrelated training conversation also ended — a single coincidental
21
+ // match, corroborated from exactly ONE place in the query. Compared
22
+ // against gap 3.1's two GENUINE topics, each of which is corroborated from
23
+ // TWO distinct, widely separated clusters of the query (verified directly:
24
+ // "Dream Team" from spans [80,96] and [132,148]; "gender equality" from
25
+ // [96,112] and [116,120] — the query's own SYS-scaffolding regions plus the
26
+ // topic's own distinctive wording, never just one).
27
+ //
28
+ // The discriminator is therefore not "how much" evidence but "how many
29
+ // separate PLACES in the query" corroborate it: cluster the contributing
30
+ // regions by merging any two whose gap is strictly less than one river-fold
31
+ // quantum W (ctx.space.maxGroup — the same quantum thinBar, identityBar and
32
+ // reachThreshold already derive from; never a new tuned number) and require
33
+ // at least 2 clusters. Verified against the boundary case directly: gap
34
+ // 3.1's "gender equality" clusters are exactly W apart, and `gap < W`
35
+ // (strict) is what keeps them separate — `gap <= W` would collapse them
36
+ // into one and break the pinned gap-3.1 requirement.
37
+
38
+ import { test } from "node:test";
39
+ import assert from "node:assert/strict";
40
+ import { Mind } from "../dist/src/index.js";
41
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
42
+ import { fuseAttention } from "../dist/src/mind/reasoning.js";
43
+ import { gistOf } from "../dist/src/mind/primitives.js";
44
+
45
+ const enc = (s) => new TextEncoder().encode(s);
46
+ const dec = (b) =>
47
+ new TextDecoder().decode(b.filter((x) => x !== 0)).replace(/\s+/g, " ")
48
+ .trim();
49
+
50
+ // A genuine cross-region binding (test/34's corpus) mixed with a
51
+ // coincidental arithmetic echo (test/35's corpus) in the SAME climb —
52
+ // reusing the exact same real anchors test/36 verified project to
53
+ // "answer alpha" and "2" respectively. The echo root's `end` is widened
54
+ // past the full "2+2" expression below (a harmless adjustment to a
55
+ // synthetic Attention object we already fabricate) so Fix A's own
56
+ // already-answered scan does not trip over the SECOND "2" digit sitting
57
+ // immediately after the echo's single-byte match — an unrelated collision
58
+ // specific to this corpus, not the mechanism under test here.
59
+ const CORPUS = [
60
+ ["red", "is a color"],
61
+ ["blue", "is a color"],
62
+ ["circle", "is a shape"],
63
+ ["square", "is a shape"],
64
+ ["red circle", "answer alpha"],
65
+ ["red square", "answer beta"],
66
+ ["blue circle", "answer gamma"],
67
+ ["blue square", "answer delta"],
68
+ ["1+2", "3"],
69
+ ["2+2", "4"],
70
+ ["2+3", "5"],
71
+ ["3+3", "6"],
72
+ ["3+5", "8"],
73
+ ["4+3", "7"],
74
+ ["2+5", "7"],
75
+ ["1+5", "6"],
76
+ ["6+1", "7"],
77
+ ["4+1", "5"],
78
+ ["3+4", "7"],
79
+ ["5+2", "7"],
80
+ ["1+1", "2"],
81
+ ["5+3", "8"],
82
+ ["7+1", "8"],
83
+ ];
84
+
85
+ const mk = (seed) =>
86
+ new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
87
+
88
+ async function setup() {
89
+ const m = mk(1);
90
+ await m.ingest(CORPUS);
91
+ const q = enc("red circle then 2+2 equals what");
92
+ const roots = await m.climbAttention(q, 24);
93
+ const binding = roots.find((r) => r.start === 0); // "red circle" [0,10)
94
+ const echo = roots.find((r) => r.start === 16); // "1+1" [16,17)
95
+ assert.ok(binding, "expected the 'red circle' binding root");
96
+ assert.ok(echo, "expected the '1+1' echo root");
97
+ const guide = gistOf(m, q);
98
+ return { m, q, binding, echo, guide };
99
+ }
100
+
101
+ // The gate is an OR of two independent measures (verified against the full
102
+ // suite: a plain AND-only cluster requirement broke test/34's own joint
103
+ // binding, whose crossRegionVotes conclusion is pooled from a single
104
+ // synthetic region — one cluster by construction — despite already, by
105
+ // design, accounting for BOTH original query mentions; `breadth` already
106
+ // recognises that case correctly, since a genuine joint binding dominates
107
+ // the query on its own). These three tests cover the three quadrants a
108
+ // root can land in; breadth is set explicitly (these are already fabricated
109
+ // synthetic Attention objects) so each quadrant is tested precisely rather
110
+ // than relying on incidental corpus values.
111
+
112
+ test("fuseAttention: 2 clusters, low breadth — dispersion alone is enough (gap 3.1's shape)", async () => {
113
+ const { m, q, binding, echo, guide } = await setup();
114
+ const primary = enc("PRIMARYANCHORTEXT");
115
+ const dispersedLowBreadth = { ...binding, clusters: 2, breadth: 0.12 };
116
+ const placeholder = {
117
+ ...binding,
118
+ start: q.length,
119
+ end: q.length,
120
+ clusters: 1,
121
+ breadth: 0.12,
122
+ };
123
+ const pre = {
124
+ attention: async () => ({
125
+ roots: [placeholder, dispersedLowBreadth],
126
+ ranked: [placeholder, dispersedLowBreadth],
127
+ }),
128
+ guide,
129
+ };
130
+ const out = dec(await fuseAttention(m, q, primary, pre));
131
+ assert.equal(
132
+ out,
133
+ "answer alpha" + dec(primary),
134
+ `2 clusters must fuse in even with low breadth, got "${out}"`,
135
+ );
136
+ await m.store.close();
137
+ });
138
+
139
+ test("fuseAttention: 1 cluster, dominant breadth — dominance alone is enough (test/34's joint-binding shape)", async () => {
140
+ const { m, q, binding, guide } = await setup();
141
+ const primary = enc("PRIMARYANCHORTEXT");
142
+ const tightButDominant = { ...binding, clusters: 1, breadth: 0.857 };
143
+ const placeholder = {
144
+ ...binding,
145
+ start: q.length,
146
+ end: q.length,
147
+ clusters: 1,
148
+ breadth: 0.857,
149
+ };
150
+ const pre = {
151
+ attention: async () => ({
152
+ roots: [placeholder, tightButDominant],
153
+ ranked: [placeholder, tightButDominant],
154
+ }),
155
+ guide,
156
+ };
157
+ const out = dec(await fuseAttention(m, q, primary, pre));
158
+ assert.equal(
159
+ out,
160
+ "answer alpha" + dec(primary),
161
+ `dominant breadth must fuse in even with 1 cluster, got "${out}"`,
162
+ );
163
+ await m.store.close();
164
+ });
165
+
166
+ test("fuseAttention: 1 cluster, low breadth — neither measure saves it (the live coincidental-echo shape)", async () => {
167
+ const { m, q, echo, guide } = await setup();
168
+ const primary = enc("PRIMARYANCHORTEXT");
169
+ const tightAndWeak = {
170
+ ...echo,
171
+ end: echo.end + 2,
172
+ clusters: 1,
173
+ breadth: 0.211,
174
+ };
175
+ const placeholder = { ...tightAndWeak, start: q.length, end: q.length };
176
+ const pre = {
177
+ attention: async () => ({
178
+ roots: [placeholder, tightAndWeak],
179
+ ranked: [placeholder, tightAndWeak],
180
+ }),
181
+ guide,
182
+ };
183
+ const out = dec(await fuseAttention(m, q, primary, pre));
184
+ assert.equal(
185
+ out,
186
+ dec(primary),
187
+ `1 cluster AND low breadth must not fuse in, got "${out}"`,
188
+ );
189
+ await m.store.close();
190
+ });
@@ -0,0 +1,94 @@
1
+ // 38-reason-restate-guard.test.mjs — reason()'s multi-hop chain
2
+ // (absorbForward/pivotStep) must not walk onto a fixpoint whose bytes are
3
+ // ALREADY PRESENT in the query.
4
+ //
5
+ // Traced live (analyze_training.ts, dialogue D): a scaffolding-dominated
6
+ // query ("Can you help me with something?", accumulated on top of "Hello,
7
+ // how are you today?Hello! How can I assist you today?") legitimately
8
+ // grounds (recallByResonance tier 2, consensus-floor-gated) on an unrelated
9
+ // but well-corroborated anchor. reason() then hops FORWARD from that
10
+ // grounding across two further, ungated steps and lands on the literal
11
+ // bytes "Hello" — content already sitting in the query's own first turn.
12
+ // The final answer is that bare, contextless echo.
13
+ //
14
+ // recallByResonance already refuses exactly this shape at grounding time —
15
+ // three separate guards (`restates`, tier 2's subspan check, tier 0b's
16
+ // argument-binding subspan check) all reject a candidate whose bytes are a
17
+ // proper byte-subspan of the query, because voicing them back only restates
18
+ // part of the question. reason() is the one place that walks MULTIPLE
19
+ // additional hops past the initial (already-vetted) grounding, and it had
20
+ // no equivalent guard — every hop only checked structural novelty
21
+ // (`consumed`), never whether the result had drifted onto something the
22
+ // query already contains. Fix: apply the same restates/subspan guard,
23
+ // verbatim, to reason()'s own hop candidates — no new tuned constant, just
24
+ // the existing convention applied where it was missing.
25
+
26
+ import { test } from "node:test";
27
+ import assert from "node:assert/strict";
28
+ import { Mind } from "../dist/src/index.js";
29
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
30
+ import { reason } from "../dist/src/mind/reasoning.js";
31
+ import { gistOf, resolve } from "../dist/src/mind/primitives.js";
32
+
33
+ const enc = (s) => new TextEncoder().encode(s);
34
+ const dec = (b) => new TextDecoder().decode(b);
35
+
36
+ const mk = (seed) =>
37
+ new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
38
+
39
+ test("reason(): a forward hop landing on bytes already present in the query is refused", async () => {
40
+ const m = mk(1);
41
+ // "bridge context" is a learnt fact SOURCE whose continuation is "Hello" —
42
+ // the multi-hop chain's next, and only, forward step.
43
+ await m.ingest([["ask topic", "bridge context"], [
44
+ "bridge context",
45
+ "Hello",
46
+ ]]);
47
+
48
+ // The query already contains "Hello" verbatim — e.g. a prior turn's own
49
+ // greeting, exactly the accumulated-conversation shape from the live
50
+ // trace. The grounded starting answer is "bridge context" (as if some
51
+ // upstream mechanism already grounded it) — reason()'s job is only to
52
+ // decide whether to hop FURTHER from it.
53
+ const query = enc("Hello, ask topic what happens next");
54
+ const answer = enc("bridge context");
55
+ const pre = {
56
+ guide: gistOf(m, query),
57
+ queryResolved: resolve(m, query),
58
+ };
59
+
60
+ const out = await reason(m, query, answer, new Set(), pre);
61
+ assert.equal(
62
+ dec(out),
63
+ "bridge context",
64
+ `must not hop onto "Hello" — it already restates content the query itself contains, got "${
65
+ dec(out)
66
+ }"`,
67
+ );
68
+ await m.store.close();
69
+ });
70
+
71
+ test("reason(): an ordinary forward hop onto genuinely new content is unaffected", async () => {
72
+ const m = mk(1);
73
+ await m.ingest([["ask topic", "bridge context"], [
74
+ "bridge context",
75
+ "a wholly new fact never mentioned in any query",
76
+ ]]);
77
+
78
+ const query = enc("Hello, ask topic what happens next");
79
+ const answer = enc("bridge context");
80
+ const pre = {
81
+ guide: gistOf(m, query),
82
+ queryResolved: resolve(m, query),
83
+ };
84
+
85
+ const out = await reason(m, query, answer, new Set(), pre);
86
+ assert.equal(
87
+ dec(out),
88
+ "a wholly new fact never mentioned in any query",
89
+ `an ordinary hop onto genuinely new content must still fire, got "${
90
+ dec(out)
91
+ }"`,
92
+ );
93
+ await m.store.close();
94
+ });
@@ -0,0 +1,102 @@
1
+ // 39-cast-restate-guard.test.mjs — CAST's substitution schema
2
+ // (projectCounterfactual, cast.ts) must not append a forward-followed edge
3
+ // whose bytes are ALREADY PRESENT in the query — the same defect test/38
4
+ // fixed in reason(), found again in a sibling mechanism.
5
+ //
6
+ // Traced live (analyze_training.ts, dialogue D, turn 4): CAST's substitution
7
+ // schema transfers a displaced structure onto a filler ("Bitte spiele...");
8
+ // after building the substitution, it separately follows the displaced
9
+ // structure's OWN learnt forward-continuation edge and appends whatever
10
+ // that turns up — in the live trace, the literal bytes "Hello", because in
11
+ // the training corpus that structure happens to be followed, in some
12
+ // wholly unrelated conversation, by another dialogue opening with "Hello".
13
+ // The accumulated query's own first turn is "Hello, how are you today?", so
14
+ // the appended "Hello" restates content the query already contains — the
15
+ // SAME defect class as test/38, just reached through cast.ts's own
16
+ // `follow()` call (line ~299) instead of reason()'s hop loop. The old
17
+ // guard there only checked the candidate wasn't already inside the
18
+ // PARTIALLY-BUILT ANSWER (`indexOf(answer, fwd, 0) < 0`) — never that it
19
+ // wasn't already in the QUERY. Fix: reuse `restatesQuery` (exported from
20
+ // reasoning.ts, the exact function test/38 introduced) at the same call
21
+ // site — no new tuned constant, the existing convention extended to a
22
+ // second place it was missing.
23
+
24
+ import { test } from "node:test";
25
+ import assert from "node:assert/strict";
26
+ import { Mind } from "../dist/src/index.js";
27
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
28
+
29
+ const mk = (seed) =>
30
+ new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
31
+
32
+ test("CAST substitution: a followed edge landing on bytes already present in the query is refused", async () => {
33
+ const m = mk(7);
34
+ // "Ice is cold" is CAST's displaced structure for "What if steel were
35
+ // cold?" (mirrors test/33's own substitution corpus). A second edge from
36
+ // the SAME source, corroborated by a second, unrelated predecessor, makes
37
+ // "Water is wet" — and, one further hop, "wet" — its resonance-preferred
38
+ // forward continuation, exactly the "some other conversation continues
39
+ // from the same node" shape the live trace showed.
40
+ await m.ingest([
41
+ ["Ice is cold", "cold"],
42
+ ["Fire is hot", "hot"],
43
+ ["Steel is hard", "hard"],
44
+ ["Water is wet", "wet"],
45
+ ["Ice is cold", "Water is wet"],
46
+ ["Something else entirely", "Water is wet"],
47
+ ]);
48
+
49
+ const steps = [];
50
+ // The query already contains "wet" verbatim (as if some earlier turn had
51
+ // already mentioned it) — reason() and CAST both must recognise the
52
+ // followed edge restates it, not append it a second time.
53
+ const r = await m.respond(
54
+ "What if steel were cold? Not wet.",
55
+ (s) => steps.push(s),
56
+ );
57
+ const got = new TextDecoder().decode(r.bytes).replace(/\0/g, "");
58
+ assert.ok(
59
+ !got.includes("wet") || got.includes("Not wet"),
60
+ `substitution must not append "wet" a second time — it already restates ` +
61
+ `the query, got "${got}"`,
62
+ );
63
+
64
+ const proj = steps.find((s) =>
65
+ s.mechanism.at(-1) === "projectCounterfactual" &&
66
+ s.inputs.some((i) => i.role === "filler")
67
+ );
68
+ assert.ok(
69
+ proj,
70
+ "expected the substitution schema's projectCounterfactual step",
71
+ );
72
+ const projected = new TextDecoder().decode(
73
+ new TextEncoder().encode(proj.outputs[0].text),
74
+ );
75
+ assert.ok(
76
+ !projected.includes("wet"),
77
+ `the substitution's own projection must stop before the restating hop, got "${projected}"`,
78
+ );
79
+ await m.store.close();
80
+ });
81
+
82
+ test("CAST substitution: an ordinary followed edge onto genuinely new content is unaffected", async () => {
83
+ const m = mk(7);
84
+ await m.ingest([
85
+ ["Ice is cold", "cold"],
86
+ ["Fire is hot", "hot"],
87
+ ["Steel is hard", "hard"],
88
+ ["Water is wet", "wet"],
89
+ ["Ice is cold", "Water is wet"],
90
+ ["Something else entirely", "Water is wet"],
91
+ ]);
92
+
93
+ const steps = [];
94
+ const r = await m.respond("What if steel were cold?", (s) => steps.push(s));
95
+ const got = new TextDecoder().decode(r.bytes).replace(/\0/g, "");
96
+ assert.ok(
97
+ got.includes("wet"),
98
+ `an ordinary followed edge onto content the query never mentioned must ` +
99
+ `still fire, got "${got}"`,
100
+ );
101
+ await m.store.close();
102
+ });
@@ -0,0 +1,75 @@
1
+ // 40-choosenext-scale-guard.test.mjs — chooseNext's disambiguation must not
2
+ // be gated by consensusFloor(N) at corpus scale.
3
+ //
4
+ // Traced live (analyze_training.ts, robustness probe "typo in query"):
5
+ // "What is the capitol of France?" (typo: capitol→capital) correctly
6
+ // resonates onto the learnt node "What is the capital of France?", whose
7
+ // forward edges are UNAMBIGUOUSLY dominated by "The capital of France is
8
+ // Paris." (prevCount 2, vs 1/1/1 for three other edges) — a genuine,
9
+ // structural winner by chooseNext's own loop (traverse.ts:517-529), which
10
+ // already requires strict dominance; a tie leaves first-inserted as the
11
+ // pick, exactly the "no real winner" case a floor would matter for.
12
+ //
13
+ // But chooseNext ALSO gated this pick on `bestSupport < consensusFloor(N)`
14
+ // once the corpus scale crosses atomIsHub's threshold (traverse.ts:541-546)
15
+ // — reusing the SAME ln(N)+0.5 floor recallByResonance and commitVotes use
16
+ // for POOLED, IDF-weighted CLIMB VOTES (each region worth up to ln N, so a
17
+ // sum exceeding ln N + 0.5 is more than any one region could say alone —
18
+ // HOW_IT_WORKS.md §8.6). `prevCount(candidate)` is a different kind of
19
+ // quantity: a raw count of how many training contexts independently
20
+ // predicted ONE destination, bounded by how many times that specific fact
21
+ // was retold — NOT by corpus size N. Gating an N-invariant count against
22
+ // an N-growing threshold guarantees failure once N is large enough
23
+ // (verified live: N≈325K gives a floor of ≈13.19, so a genuinely dominant
24
+ // but only-doubly-attested fact like "capital of France → Paris" was
25
+ // refused, falling back to a noisy concept-hop that produced the wrong
26
+ // answer). HOW_IT_WORKS.md's own canonical chooseNext pseudocode (§25)
27
+ // has NO such floor — it's undocumented implementation drift, not a
28
+ // deliberate design surface. Fix: remove the gate; chooseNext's existing
29
+ // strict-dominance loop already IS the "genuinely competing" test.
30
+
31
+ import { test } from "node:test";
32
+ import assert from "node:assert/strict";
33
+ import { Mind } from "../dist/src/index.js";
34
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
35
+ import { chooseNext } from "../dist/src/mind/traverse.js";
36
+ import { gistOf, resolve } from "../dist/src/mind/primitives.js";
37
+
38
+ const enc = (s) => new TextEncoder().encode(s);
39
+
40
+ test("chooseNext: a genuinely-dominant edge is trusted at large corpus scale", async () => {
41
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
42
+
43
+ // Push edgeSourceCount() well past atomIsHub's crossover (N > ~4096 at
44
+ // the default maxGroup=4) with cheap, unrelated filler facts — this is
45
+ // the ONLY way to exercise the scale-gated branch at all; every existing
46
+ // chooseNext test (test/30) stays at small N, where the gate was already
47
+ // inert (consensusFloor(N) < 2 for N < 4.5), so none of them cover this.
48
+ const filler = [];
49
+ for (let i = 0; i < 4300; i++) filler.push([`filler-${i}`, `f${i}`]);
50
+ await m.ingest(filler);
51
+
52
+ // "trigger" has three continuations: "winner" is corroborated by TWO
53
+ // distinct contexts ("trigger" itself and "also trigger"); the other two
54
+ // by one each — an unambiguous, strictly-dominant winner.
55
+ await m.ingest([
56
+ ["trigger", "winner"],
57
+ ["trigger", "loserA"],
58
+ ["trigger", "loserB"],
59
+ ["also trigger", "winner"],
60
+ ]);
61
+
62
+ const triggerId = resolve(m, enc("trigger"));
63
+ const winnerId = resolve(m, enc("winner"));
64
+ assert.ok(triggerId !== null && winnerId !== null, "corpus must resolve");
65
+
66
+ const guide = gistOf(m, enc("trigger"));
67
+ const picked = chooseNext(m, triggerId, guide);
68
+ assert.equal(
69
+ picked,
70
+ winnerId,
71
+ `expected chooseNext to trust the strictly-dominant edge (prevCount 2 ` +
72
+ `vs 1/1/1) even at large corpus scale, got ${picked}`,
73
+ );
74
+ await m.store.close();
75
+ });
@@ -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
+ });