@hviana/sema 0.8.0 → 0.8.2

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 (119) hide show
  1. package/AGENTS.md +22 -1
  2. package/DATASETS.md +1 -1
  3. package/dist/example/train_base/config.js +2 -2
  4. package/dist/example/train_base/corpora/massive.js +1 -1
  5. package/dist/example/train_base/readers.js +1 -1
  6. package/dist/src/config.d.ts +17 -0
  7. package/dist/src/config.js +18 -0
  8. package/dist/src/geometry.d.ts +10 -10
  9. package/dist/src/geometry.js +25 -24
  10. package/dist/src/meter.d.ts +29 -12
  11. package/dist/src/meter.js +58 -14
  12. package/dist/src/mind/attention.js +12 -12
  13. package/dist/src/mind/bridge.d.ts +8 -8
  14. package/dist/src/mind/bridge.js +33 -32
  15. package/dist/src/mind/corpus.d.ts +40 -0
  16. package/dist/src/mind/corpus.js +149 -0
  17. package/dist/src/mind/graph-search.d.ts +7 -8
  18. package/dist/src/mind/graph-search.js +244 -32
  19. package/dist/src/mind/index.d.ts +3 -1
  20. package/dist/src/mind/index.js +1 -0
  21. package/dist/src/mind/junction.d.ts +1 -1
  22. package/dist/src/mind/junction.js +8 -8
  23. package/dist/src/mind/learning.js +36 -35
  24. package/dist/src/mind/match.d.ts +8 -3
  25. package/dist/src/mind/match.js +156 -71
  26. package/dist/src/mind/mechanisms/cast.js +18 -2
  27. package/dist/src/mind/mechanisms/cover.js +19 -12
  28. package/dist/src/mind/mechanisms/prefix-completion.js +24 -24
  29. package/dist/src/mind/mechanisms/recall.js +38 -40
  30. package/dist/src/mind/mechanisms/reference.js +16 -16
  31. package/dist/src/mind/mind.d.ts +61 -7
  32. package/dist/src/mind/mind.js +72 -2
  33. package/dist/src/mind/pipeline-mechanism.d.ts +10 -8
  34. package/dist/src/mind/pipeline-mechanism.js +25 -21
  35. package/dist/src/mind/pipeline.d.ts +9 -9
  36. package/dist/src/mind/pipeline.js +49 -29
  37. package/dist/src/mind/primitives.d.ts +5 -5
  38. package/dist/src/mind/primitives.js +5 -5
  39. package/dist/src/mind/reasoning.d.ts +5 -1
  40. package/dist/src/mind/reasoning.js +54 -1
  41. package/dist/src/mind/recognition.d.ts +14 -13
  42. package/dist/src/mind/recognition.js +23 -23
  43. package/dist/src/mind/resonance.js +21 -21
  44. package/dist/src/mind/traverse.d.ts +54 -52
  45. package/dist/src/mind/traverse.js +83 -73
  46. package/dist/src/mind/types.d.ts +26 -4
  47. package/dist/src/store.d.ts +12 -12
  48. package/dist/src/store.js +12 -12
  49. package/docs/INDEX.md +2 -2
  50. package/docs/architecture/exact-vs-approximate.md +2 -1
  51. package/docs/architecture/fold-contract.md +1 -1
  52. package/docs/failures/tempting-but-wrong.md +33 -5
  53. package/docs/harness/gates.md +7 -7
  54. package/example/train_base/config.ts +2 -2
  55. package/example/train_base/corpora/massive.ts +1 -1
  56. package/example/train_base/readers.ts +1 -1
  57. package/jsr.json +1 -1
  58. package/package.json +1 -1
  59. package/src/config.ts +35 -0
  60. package/src/geometry.ts +25 -24
  61. package/src/meter.ts +61 -14
  62. package/src/mind/attention.ts +12 -12
  63. package/src/mind/bridge.ts +33 -32
  64. package/src/mind/corpus.ts +202 -0
  65. package/src/mind/graph-search.ts +261 -31
  66. package/src/mind/index.ts +8 -1
  67. package/src/mind/junction.ts +8 -8
  68. package/src/mind/learning.ts +36 -35
  69. package/src/mind/match.ts +163 -73
  70. package/src/mind/mechanisms/cast.ts +17 -1
  71. package/src/mind/mechanisms/cover.ts +18 -12
  72. package/src/mind/mechanisms/prefix-completion.ts +24 -24
  73. package/src/mind/mechanisms/recall.ts +38 -40
  74. package/src/mind/mechanisms/reference.ts +16 -16
  75. package/src/mind/mind.ts +129 -7
  76. package/src/mind/pipeline-mechanism.ts +25 -21
  77. package/src/mind/pipeline.ts +63 -38
  78. package/src/mind/primitives.ts +5 -5
  79. package/src/mind/reasoning.ts +55 -0
  80. package/src/mind/recognition.ts +23 -23
  81. package/src/mind/resonance.ts +21 -21
  82. package/src/mind/traverse.ts +83 -73
  83. package/src/mind/types.ts +30 -4
  84. package/src/store.ts +20 -20
  85. package/test/08-storage.test.mjs +1 -1
  86. package/test/100-complete-grounding-trace.test.mjs +109 -0
  87. package/test/101-alignment-gap-bound.test.mjs +106 -0
  88. package/test/102-production-composes-at-scale.test.mjs +110 -0
  89. package/test/103-alignment-gap-budget.test.mjs +89 -0
  90. package/test/104-composition-is-reported.test.mjs +90 -0
  91. package/test/105-derive-through-reports-its-refusal.test.mjs +113 -0
  92. package/test/106-the-join-fires.test.mjs +94 -0
  93. package/test/107-the-join-is-counted.test.mjs +81 -0
  94. package/test/108-the-join-chains.test.mjs +78 -0
  95. package/test/109-the-pivot-is-counted.test.mjs +60 -0
  96. package/test/110-the-reasoner-stops-when-the-question-is-answered.test.mjs +91 -0
  97. package/test/111-the-cover-assembly-is-counted.test.mjs +74 -0
  98. package/test/112-the-exploration-does-not-grow-with-the-hub.test.mjs +89 -0
  99. package/test/113-the-rationale-payload-is-bounded.test.mjs +84 -0
  100. package/test/114-alignment-budget-is-per-sweep.test.mjs +93 -0
  101. package/test/116-the-extension-is-gated-by-the-pipelines-own-remainder.test.mjs +100 -0
  102. package/test/117-corpus-search.test.mjs +171 -0
  103. package/test/14-scaling.test.mjs +10 -7
  104. package/test/35-prefix-edge.test.mjs +1 -1
  105. package/test/40-choosenext-scale-guard.test.mjs +16 -17
  106. package/test/56-bridge-identity-admission.test.mjs +6 -6
  107. package/test/70-prefix-completion.test.mjs +4 -3
  108. package/test/72-prefix-candidate-supply.test.mjs +3 -3
  109. package/test/73-scaffolding-only-bridge-abstains.test.mjs +6 -6
  110. package/test/75-multiturn-context-optimisation.test.mjs +5 -5
  111. package/test/76-reference-binding.test.mjs +6 -1
  112. package/test/84-composed-answer-honesty.test.mjs +5 -6
  113. package/test/88-dependency-footprint.test.mjs +1 -1
  114. package/test/89-completion-recursion.test.mjs +47 -19
  115. package/test/90-connector-read-cap.test.mjs +10 -8
  116. package/test/93-regime-prediction.test.mjs +10 -10
  117. package/test/94-cross-region-budget.test.mjs +2 -2
  118. package/test/95-wide-resonance-removed.test.mjs +8 -7
  119. package/test/96-bytes-walk-termination.test.mjs +3 -3
@@ -0,0 +1,110 @@
1
+ // 102-production-composes-at-scale.test.mjs — a produced composite is
2
+ // decomposed by ITS OWN TREE, at every corpus size.
3
+ //
4
+ // THE GAP THIS CLOSES. `recompleteNode` re-covers a node an edge produced, and
5
+ // it seeds that re-cover from `recogniseSpan(bytes)` filtered to the node's own
6
+ // kids. Above the `atomIsHub` flip the recognition of a produced span returns
7
+ // the WHOLE and deliberately suppresses its atoms (the off-boundary
8
+ // suppression), so the kid filter admitted nothing and the chain ended at the
9
+ // intermediate composite. Measured on
10
+ //
11
+ // seed → "p q" → (p→r, q→s) → "r s" → "m n"
12
+ //
13
+ // below the flip it reached "m n" with `fuse`+`recompose`; above it stopped at
14
+ // "p q" with neither, and the trace showed `recognise("p q") ⇒ form "p q"`
15
+ // alone. The node's own kids ARE its decomposition — the machine that built
16
+ // the composite stated its parts — so they seed the re-cover directly, and a
17
+ // kid recognition already offers is skipped (below the flip the seed set is
18
+ // byte-identical to what it was).
19
+ //
20
+ // WHY THE FIXTURE CROSSES THE FLIP. The property was never false below it: the
21
+ // whole point is that composition must not depend on corpus size. The flip sits
22
+ // between N = 3 658 (composes) and N = 4 174 (did not), so the fixture ingests
23
+ // 4 400 filler pairs, exactly as test/99 does for the join.
24
+ //
25
+ // WHAT IS PINNED.
26
+ // 1. the answer above the flip EQUALS the answer below it (scale invariance,
27
+ // asserted as one comparison rather than two magic strings);
28
+ // 2. above the flip the composition is reported — `fuse` and `recompose`
29
+ // appear in the rationale, so the fix is visible in the rationale and not
30
+ // just in the bytes;
31
+ // 3. the chain still ends where the graph ends ("m n"), so the fix does not
32
+ // invent depth.
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
+
39
+ const CHAIN = [
40
+ ["seed", "p q"],
41
+ ["p", "r"],
42
+ ["q", "s"],
43
+ ["r s", "m n"],
44
+ ];
45
+
46
+ const WORDS =
47
+ ("alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima " +
48
+ "mike november oscar papa quebec romeo sierra tango uniform victor whiskey " +
49
+ "xray yankee zulu amber bronze copper dahlia ember fjord gossamer harbour " +
50
+ "indigo jasmine kestrel lantern marigold nectar opal pewter quartz ripple " +
51
+ "saffron thistle umber violet willow xenon yarrow").split(" ");
52
+
53
+ /** One store, ingested past the atomIsHub flip (N > 4 174). */
54
+ async function atScale(fillers = 4400) {
55
+ const store = new SQliteStore({ path: ":memory:", D: 1024 });
56
+ const mind = new Mind({ seed: 7, store });
57
+ const w = (i, n) => WORDS[(i * 7 + n * 13) % WORDS.length];
58
+ await mind.ingest([
59
+ ...CHAIN,
60
+ ...Array.from({ length: fillers }, (_, i) => [
61
+ `${w(i, 1)} ${w(i, 2)} ${w(i, 3)} ${i}`,
62
+ `${w(i, 4)} ${w(i, 5)} ${w(i, 6)} ${w(i, 7)} ${i}`,
63
+ ]),
64
+ ]);
65
+ return { store, mind };
66
+ }
67
+
68
+ /** The same chain BELOW the flip — the behaviour the scaled store must match. */
69
+ async function belowFlip() {
70
+ const store = new SQliteStore({ path: ":memory:", D: 1024 });
71
+ const mind = new Mind({ seed: 7, store });
72
+ await mind.ingest(CHAIN);
73
+ return { store, mind };
74
+ }
75
+
76
+ const text = (b) => b.replace(/\0+/g, "").trim();
77
+
78
+ test("a produced composite composes identically below and above the flip", async () => {
79
+ const small = await belowFlip();
80
+ const big = await atScale();
81
+ const deep = text(await big.mind.respondText("seed"));
82
+ const shallow = text(await small.mind.respondText("seed"));
83
+
84
+ // The property: depth is a fact about the graph, not about the corpus size.
85
+ assert.equal(deep, shallow, "scale must not change how deep the chain goes");
86
+ assert.equal(
87
+ deep,
88
+ "m n",
89
+ "and the graph ends at the composite's continuation",
90
+ );
91
+ });
92
+
93
+ test("above the flip the composition is reported, not just performed", async () => {
94
+ const { mind } = await atScale();
95
+ const steps = [];
96
+ await mind.respondText("seed", (s) => steps.push(s));
97
+ const moves = new Set(steps.map((s) => s.mechanism.at(-1)));
98
+ assert.ok(moves.has("fuse"), "the fused pair must appear in the rationale");
99
+ assert.ok(
100
+ moves.has("recompose"),
101
+ "and the recomposition that names the deeper form",
102
+ );
103
+ });
104
+
105
+ test("the fix does not invent depth: the chain still ends at the graph's end", async () => {
106
+ const { mind } = await atScale();
107
+ const answer = text(await mind.respondText("seed"));
108
+ assert.notEqual(answer, "r s", "it must not stop at the parts it followed");
109
+ assert.notEqual(answer, "z", "nor reach a form the graph never licensed");
110
+ });
@@ -0,0 +1,89 @@
1
+ // 103-alignment-gap-budget.test.mjs — a learned frame's slot is bounded by the
2
+ // PAIR, not by the write side's arity; the WORK is bounded by a declared budget.
3
+ //
4
+ // THE GAP THIS CLOSES. `alignAround` bounded each side's gap by
5
+ // `chainReach(W)` = W². A learnt frame whose slot is longer than that could not
6
+ // be aligned, so the frame binding produced no candidate and a cheaper
7
+ // mechanism answered instead — measured on a
8
+ // `Book a table at <filler> tonight.` → `Your table at <filler> is booked.`
9
+ // frame: at 18, 24, 30 and 36 bytes `bindReference` reported the cap and the
10
+ // answer came back carrying ANOTHER instance's filler ("bravo1").
11
+ //
12
+ // Bounding the work instead is the fix, and the two questions are genuinely
13
+ // different: the gap's LENGTH is how far the two byte strings actually diverge
14
+ // (a fact about the pair), while the WORK is how many (queryGap, contextGap)
15
+ // pairs the sweep examines, which is what a cost bound must limit — reaching a
16
+ // gap of size G costs about G²/2 pairs, so a length bound is a work bound only
17
+ // by accident, and at the wrong scale.
18
+ //
19
+ // WHAT IS PINNED: the asker's OWN filler comes back. The old defect used to be
20
+ // reproduced by injecting a tiny budget; that knob is gone because there is no
21
+ // budget any more — the sweep indexes the context's windows and walks the
22
+ // query's, so its work is proportional to the bytes a run spans and its reach is
23
+ // the bytes' own. There is nothing to make artificially small, and nothing that
24
+ // silently drops a far continuation:
25
+ //
26
+ // filler 18/20/24 B → the asker's own filler (the pair's own extent spans it)
27
+ //
28
+ // Beyond what the frame's own instances can span (measured: from ~30 bytes) a
29
+ // DIFFERENT cause takes over — the frame inventory drops to a single instance,
30
+ // and the licence correctly refuses to voice from one exemplar. That is the
31
+ // reference mechanism's own gate, not this bound, and it is not asserted here.
32
+
33
+ import { test } from "node:test";
34
+ import assert from "node:assert/strict";
35
+ import { Mind } from "../dist/src/index.js";
36
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
37
+
38
+ const WORDS =
39
+ ("alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima " +
40
+ "mike november oscar papa quebec romeo sierra tango uniform victor whiskey " +
41
+ "xray yankee zulu amber bronze copper dahlia ember fjord gossamer harbour " +
42
+ "indigo jasmine kestrel lantern marigold nectar opal pewter quartz ripple " +
43
+ "saffron thistle umber violet willow xenon yarrow").split(" ");
44
+
45
+ const short = (i) => `${WORDS[i % 50]}${i}`;
46
+ const long = (i) =>
47
+ `${WORDS[i % 50]} ${WORDS[(i * 3) % 50]} ${WORDS[(i * 7) % 50]} street ${i}`;
48
+
49
+ /** A frame whose continuation quotes its filler, plus the Mind options. */
50
+ async function frame(opts = {}, n = 60) {
51
+ const store = new SQliteStore({ path: ":memory:", D: 1024 });
52
+ const mind = new Mind({ seed: 7, store, ...opts });
53
+ const pairs = [];
54
+ for (let i = 0; i < n; i++) {
55
+ const f = i % 3 === 0 ? long(i) : short(i);
56
+ pairs.push([
57
+ `Book a table at ${f} tonight.`,
58
+ `Your table at ${f} is booked.`,
59
+ ]);
60
+ }
61
+ await mind.ingest(pairs);
62
+ return { store, mind };
63
+ }
64
+
65
+ /** A novel filler of exactly `len` bytes, never ingested. */
66
+ const novel = (len) => "zephyr quartz lantern ".repeat(4).slice(0, len).trim();
67
+
68
+ const answer = async (mind, filler) =>
69
+ (await mind.respondText(`Book a table at ${filler} tonight.`))
70
+ .replace(/\0+/g, "").trim();
71
+
72
+ test("the default budget spans it, and the answer quotes the ASKER's filler", async () => {
73
+ const filler = novel(24);
74
+ const { mind } = await frame();
75
+ const got = await answer(mind, filler);
76
+ assert.ok(
77
+ got.includes(filler),
78
+ "the pair's own extent must be reachable: the asker's bytes, not the corpus's",
79
+ );
80
+ });
81
+
82
+ test("an 18-byte slot is spanned too — the boundary moved off W²", async () => {
83
+ const { mind } = await frame();
84
+ for (const len of [18, 20, 24]) {
85
+ const filler = novel(len);
86
+ const got = await answer(mind, filler);
87
+ assert.ok(got.includes(filler), `${len} B must be spanned`);
88
+ }
89
+ });
@@ -0,0 +1,90 @@
1
+ // 104-composition-is-reported.test.mjs — every composition of an answer is
2
+ // VISIBLE in the rationale.
3
+ //
4
+ // THE GAP THIS CLOSES. `joinWithBridge` (resonance.ts) is the composition step
5
+ // every out-of-search assembly shares: it asks the corpus for a learnt
6
+ // connector between two pieces, and on a miss it joins them BARE and emits a
7
+ // `bridgeMiss` step — never silent. CAST's projection-to-continuation join
8
+ // bypassed it with a bare `concat2`, and that single bypass is the whole of the
9
+ // gluing the study measured: `"Steel is hard"` + `"wet"` came back as
10
+ // `"hardwet"`, `"eva director father"` + `"The father of Gustaf Molander…"` as
11
+ // `"fatherThe"`, the real store's `"Dutch.na"`. The house rule is NOT "never
12
+ // join bare" — it is "joined bare, and REPORTED", so that a reader of the
13
+ // rationale can see the seam and its pieces. The bypass made the seam
14
+ // invisible, which is why the gluing survived an audit that read rationales.
15
+ //
16
+ // WHAT IS PINNED. On the fixture below the join IS a miss (this tiny corpus
17
+ // attests no connector between the two pieces), so the step must exist and must
18
+ // name BOTH pieces. The answer must still USE the followed content — test/39
19
+ // pins that the follow is unaffected, and a fix that refuses the join instead
20
+ // of reporting it would silently drop the evidence.
21
+
22
+ import { test } from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import { Mind } from "../dist/src/index.js";
25
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
26
+
27
+ /** test/39's corpus: a projection that must follow onto genuinely new content. */
28
+ async function fixture() {
29
+ const store = new SQliteStore({ path: ":memory:" });
30
+ const mind = new Mind({ seed: 7, store });
31
+ await mind.ingest([
32
+ ["Ice is cold", "cold"],
33
+ ["Fire is hot", "hot"],
34
+ ["Steel is hard", "hard"],
35
+ ["Water is wet", "wet"],
36
+ ["Ice is cold", "Water is wet"],
37
+ ["Something else entirely", "Water is wet"],
38
+ ]);
39
+ return mind;
40
+ }
41
+
42
+ const text = (resp) => new TextDecoder().decode(resp.bytes).replace(/\0+/g, "");
43
+
44
+ test("a composed seam is reported: the miss names its two pieces", async () => {
45
+ const mind = await fixture();
46
+ const steps = [];
47
+ const answer = text(
48
+ await mind.respond("What if steel were cold?", (s) => steps.push(s)),
49
+ );
50
+
51
+ const misses = steps.filter((s) => s.mechanism.at(-1) === "bridgeMiss");
52
+ assert.ok(
53
+ misses.length > 0,
54
+ "the composition must be visible: the shared joiner reports a miss",
55
+ );
56
+ // The seam this lot exists for: the followed continuation is joined on.
57
+ // The INTERNAL seam — the join the mechanism itself made — names the two
58
+ // pieces as they were BEFORE the join: the projection and the followed
59
+ // continuation, side by side. The answer-level assembly can only report the
60
+ // bytes it is handed, so this step is what makes the gluing auditable.
61
+ const internal = misses.some((s) => {
62
+ const parts = (s.inputs ?? []).map((i) => String(i.text));
63
+ return parts.some((t) => t.trim() === "wet") &&
64
+ parts.some((t) => t.includes("cold"));
65
+ });
66
+ assert.ok(internal, "the internal seam must be named, pre-join");
67
+ // Reporting, not refusing: the follow's content is still used (test/39).
68
+ assert.ok(
69
+ answer.includes("wet"),
70
+ `the followed content must still be used, got "${answer}"`,
71
+ );
72
+ await mind.store.close();
73
+ });
74
+
75
+ test("an in-search composition keeps its own trace (no report invented)", async () => {
76
+ // A query the corpus answers directly composes nothing that needs a bridge:
77
+ // cover's own steps are the report, so no `bridgeMiss` is fabricated.
78
+ const store = new SQliteStore({ path: ":memory:" });
79
+ const mind = new Mind({ seed: 7, store });
80
+ await mind.ingest([["Ice is cold", "Water is wet"]]);
81
+ const steps = [];
82
+ await mind.respond("Ice is cold", (s) => steps.push(s));
83
+ const misses = steps.filter((s) => s.mechanism.at(-1) === "bridgeMiss");
84
+ assert.equal(misses.length, 0, "a stored answer is not a composition");
85
+ assert.ok(
86
+ steps.some((s) => s.mechanism.at(-1) === "ground"),
87
+ "and the derivation that did answer is still traced",
88
+ );
89
+ await mind.store.close();
90
+ });
@@ -0,0 +1,113 @@
1
+ // 105-derive-through-reports-its-refusal.test.mjs — the join rule says WHY it
2
+ // did not join.
3
+ //
4
+ // THE GAP THIS CLOSES. DIRECTION (the study's gap 2): a produced fact carries
5
+ // the subject the query never wrote, and the query's remaining tail names the
6
+ // relation to follow FROM it — but on the measured chain the rule never fired
7
+ // and nothing said why. `deriveThrough` yields no rule when it refuses, and a
8
+ // rule that yields nothing leaves no step, so the gate was invisible from
9
+ // outside: four fixtures changed the SYMPTOM (`"eva director country"` answered
10
+ // with the intermediate KEY's bytes glued to the fact) without ever reaching the
11
+ // rule. AGENTS §6: a gap in instrumentation is a defect IN the instrumentation
12
+ // — close it there, once, through the rationale, never a channel of its own.
13
+ //
14
+ // WHAT IS PINNED.
15
+ // 1. the refusal is REPORTED, and it names the two pieces it tried (the fact
16
+ // and the tail), so the gate is readable in the rationale;
17
+ // 2. it is reported ONLY for a form: the search asks this rule for every
18
+ // finalized out with a node, one-byte outs included — measured, 68
19
+ // refusals for a single 3-relation query, all letters. W is the line the
20
+ // mind already draws between a chance overlap and a form, and a report per
21
+ // letter is noise, not instrumentation;
22
+ // 3. a fully covered query (no tail) is not a refusal at all — the rule does
23
+ // not apply, and it must not be reported as a miss.
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
+
30
+ const F1 = "The director of Eva is Gustaf Molander.";
31
+ const F2 = "The country of Gustaf Molander is Sweden.";
32
+ const F3 = "The capital of Sweden is Stockholm.";
33
+
34
+ /** The measured chain: every relation filed under BOTH the entity and its key,
35
+ * which is how the real store files a fact (the study's round-3 dump). */
36
+ async function chain() {
37
+ const store = new SQliteStore({ path: ":memory:" });
38
+ const mind = new Mind({ seed: 7, store });
39
+ await mind.ingest([
40
+ ["eva", F1],
41
+ ["eva director", F1],
42
+ ["gustaf molander", F2],
43
+ ["gustaf molander country", F2],
44
+ ["sweden", F3],
45
+ ["sweden capital", F3],
46
+ ]);
47
+ return mind;
48
+ }
49
+
50
+ const misses = (steps) =>
51
+ steps.filter((s) => s.mechanism.at(-1) === "deriveThroughMiss");
52
+
53
+ test("the join's refusal is reported, naming the candidate and tail it tried", async () => {
54
+ const mind = await chain();
55
+ // The THREE-relation query: the second join is still refused (the rule
56
+ // concludes terminal — the study's other half), so this is where the refusal
57
+ // is observable. The two-relation one now JOINS (measured, and pinned by
58
+ // test/99's spec), which is why this test moved here.
59
+ const steps = [];
60
+ await mind.respond("eva director country capital", (s) => steps.push(s));
61
+ const got = misses(steps);
62
+ assert.ok(got.length > 0, "the join must say why it did not join");
63
+ const named = got.some((s) => {
64
+ const parts = (s.inputs ?? []).map((i) => String(i.text));
65
+ return parts.some((t) => t.includes("gustaf molander")) &&
66
+ parts.some((t) => t.includes("country"));
67
+ });
68
+ assert.ok(named, "the report must name the candidate and the tail it tried");
69
+ // …and WHERE the candidate came from: a refusal that names only bytes leaves
70
+ // the next reader guessing which proposal path produced them (three chained-
71
+ // join attempts were spent fixing paths that never proposed the offender).
72
+ assert.ok(
73
+ got.some((s) => /#\d+, from the .* source/.test(String(s.note))),
74
+ "the report must name the candidate's node and its source",
75
+ );
76
+ await mind.store.close();
77
+ });
78
+
79
+ test("a report per LETTER is noise: only forms are reported", async () => {
80
+ const mind = await chain();
81
+ const steps = [];
82
+ await mind.respond("eva director country capital", (s) => steps.push(s));
83
+ const got = misses(steps);
84
+ assert.ok(got.length > 0, "the real fact's refusal is still reported");
85
+ // STRUCTURAL, not a count: a report is only made for a FORM, so every report
86
+ // carries a piece at least one window long — no one-byte out reports a miss.
87
+ // `W` is the fixture's own geometry (the Mind's default maxGroup), the same
88
+ // line the rule uses.
89
+ const W = 4;
90
+ for (const m of got) {
91
+ const longest = Math.max(
92
+ 0,
93
+ ...(m.inputs ?? []).map((i) => String(i.text ?? "").length),
94
+ );
95
+ assert.ok(
96
+ longest >= W,
97
+ `a report must be about a form, got pieces of at most ${longest} bytes`,
98
+ );
99
+ }
100
+ await mind.store.close();
101
+ });
102
+
103
+ test("a fully covered query is not a refusal", async () => {
104
+ const mind = await chain();
105
+ const steps = [];
106
+ await mind.respond("eva director", (s) => steps.push(s));
107
+ assert.equal(
108
+ misses(steps).length,
109
+ 0,
110
+ "with no tail there is nothing to join through — not a miss",
111
+ );
112
+ await mind.store.close();
113
+ });
@@ -0,0 +1,94 @@
1
+ // 106-the-join-fires.test.mjs — DIRECTION: the join reaches the subject the
2
+ // query never wrote.
3
+ //
4
+ // THE GAP THIS CLOSES. `deriveThrough` derives the answer THROUGH a produced
5
+ // fact: the fact carries the subject the query never named, and the query's
6
+ // remaining tail names the relation to follow FROM that subject. Measured, the
7
+ // rule never fired — and the report added for it (test/105) named the gate:
8
+ // `leading.length === 0`. The cause was then measured exactly: the recognition
9
+ // of the fact's bytes returns EXACTLY ONE site — the fact itself — because a
10
+ // STORED WHOLE stops the sweep, so the interior entity the join exists for was
11
+ // never proposed, and the query was answered by gluing the intermediate KEY to
12
+ // the fact (`The director of Eva is Gustaf Molander.gustaf molander country`).
13
+ //
14
+ // WHAT IS PINNED.
15
+ // 1. the join FIRES: the answer is the second fact, reached through the
16
+ // subject the query never wrote (`The country of Gustaf Molander is
17
+ // Sweden.`);
18
+ // 2. the rationale says so (`derive-through` is the move that reached it);
19
+ // 3. the intermediate KEY never appears in the answer — the glued reading is
20
+ // what the query got before, and it is not an answer;
21
+ // 4. a query with no tail is not a join at all: it is answered directly, and
22
+ // the move must not be claimed.
23
+ //
24
+ // The `canonicalQueryNodes` exclusion (the query's OWN subject, canonically) is
25
+ // what keeps test/99's TRAP refused: its `Eiffel Tower country` is a deposited
26
+ // node in canonical form while the query writes it capitalised, so a raw byte
27
+ // test re-admitted it and the answer came back about Eiffel Tower.
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 F1 = "The director of Eva is Gustaf Molander.";
35
+ const F2 = "The country of Gustaf Molander is Sweden.";
36
+
37
+ /** The measured chain, filed the way the real store files a fact: under BOTH
38
+ * the entity and the key it was learnt with. */
39
+ async function chain() {
40
+ const store = new SQliteStore({ path: ":memory:" });
41
+ const mind = new Mind({ seed: 7, store });
42
+ await mind.ingest([
43
+ ["eva", F1],
44
+ ["eva director", F1],
45
+ ["gustaf molander", F2],
46
+ ["gustaf molander country", F2],
47
+ ]);
48
+ return mind;
49
+ }
50
+
51
+ const text = (resp) => new TextDecoder().decode(resp.bytes).replace(/\0+/g, "");
52
+
53
+ test("the join reaches the subject the query never wrote", async () => {
54
+ const mind = await chain();
55
+ const moves = [];
56
+ const out = text(
57
+ await mind.respond(
58
+ "eva director country",
59
+ (s) => moves.push(s.mechanism.at(-1)),
60
+ ),
61
+ );
62
+ assert.equal(out.trim(), F2);
63
+ assert.ok(
64
+ moves.includes("derive-through"),
65
+ `expected the join to be the move that reached it, got ${
66
+ [...new Set(moves)].join(", ")
67
+ }`,
68
+ );
69
+ await mind.store.close();
70
+ });
71
+
72
+ test("the intermediate key is never the answer", async () => {
73
+ const mind = await chain();
74
+ const out = text(await mind.respond("eva director country"));
75
+ assert.ok(
76
+ !out.includes("gustaf molander country"),
77
+ `the key is not an answer, got ${JSON.stringify(out)}`,
78
+ );
79
+ await mind.store.close();
80
+ });
81
+
82
+ test("a query with no tail is answered directly, not by a join", async () => {
83
+ const mind = await chain();
84
+ const moves = [];
85
+ const out = text(
86
+ await mind.respond("eva director", (s) => moves.push(s.mechanism.at(-1))),
87
+ );
88
+ assert.equal(out.trim(), F1);
89
+ assert.ok(
90
+ !moves.includes("derive-through"),
91
+ "there is nothing to join through when the query ends at the fact",
92
+ );
93
+ await mind.store.close();
94
+ });
@@ -0,0 +1,81 @@
1
+ // 107-the-join-is-counted.test.mjs — the join's outcome is observable WITHOUT a
2
+ // rationale.
3
+ //
4
+ // WHY THIS EXISTS. Everything known about the fact join's refusals came from
5
+ // the rationale, and the rationale PERTURBS the search: appending text to a
6
+ // refusal note changed the answer of a traced response (measured, 386293e), and
7
+ // the "key leads nowhere" refusal chased for four rounds turned out to be a
8
+ // trace artifact — untraced, every refusal is a missing KEY, never a key whose
9
+ // continuation is missing: a key that leads nowhere is not the relation at all,
10
+ // so the scan moves on and there is no counter for it (the branch that reported
11
+ // it was unreachable, and an adversarial review said so).
12
+ // A work counter is the untraced view, and `meter.ts` is its one home
13
+ // (AGENTS §6: a counter name exists in exactly one place).
14
+ //
15
+ // WHAT IS PINNED. The four outcomes are published in the cost report with no
16
+ // rationale attached. The assertions stay TRUE when the join is fixed — they
17
+ // check that the counters exist, that a join that fires is counted as fired, and
18
+ // that a query whose join does not fire reports WHY in the counters — never that
19
+ // a particular refusal still happens.
20
+
21
+ import { test } from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import { Mind } from "../dist/src/index.js";
24
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
25
+
26
+ const F1 = "The director of Eva is Gustaf Molander.";
27
+ const F2 = "The country of Gustaf Molander is Sweden.";
28
+ const F3 = "The capital of Sweden is Stockholm.";
29
+
30
+ async function chain() {
31
+ const store = new SQliteStore({ path: ":memory:" });
32
+ const mind = new Mind({ seed: 7, store, profile: true });
33
+ await mind.ingest([
34
+ ["eva", F1],
35
+ ["eva director", F1],
36
+ ["gustaf molander", F2],
37
+ ["gustaf molander country", F2],
38
+ ["sweden", F3],
39
+ ["sweden capital", F3],
40
+ ]);
41
+ return mind;
42
+ }
43
+
44
+ const counters = (mind) => mind.lastCost?.counters ?? {};
45
+
46
+ test("a join that fires is counted, with no rationale attached", async () => {
47
+ const mind = await chain();
48
+ await mind.respondText("eva director country"); // no inspectRationale
49
+ const c = counters(mind);
50
+ assert.ok(
51
+ (c.joinFired ?? 0) > 0,
52
+ "the join reached the second fact, so it must be counted as fired",
53
+ );
54
+ await mind.store.close();
55
+ });
56
+
57
+ test("a query whose join does not fire reports WHY, untraced", async () => {
58
+ const mind = await chain();
59
+ await mind.respondText("eva director country capital"); // no rationale
60
+ const c = counters(mind);
61
+ const refusals = (c.joinNoKey ?? 0) + (c.joinNoEntity ?? 0);
62
+ assert.ok(
63
+ refusals > 0,
64
+ "a join that did not fire must say so in the counters",
65
+ );
66
+ await mind.store.close();
67
+ });
68
+
69
+ test("the counters are deterministic across identical calls", async () => {
70
+ const a = await chain();
71
+ const b = await chain();
72
+ await a.respondText("eva director country capital");
73
+ await b.respondText("eva director country capital");
74
+ assert.deepEqual(
75
+ counters(a),
76
+ counters(b),
77
+ "same query + same store ⇒ same counted work",
78
+ );
79
+ await a.store.close();
80
+ await b.store.close();
81
+ });
@@ -0,0 +1,78 @@
1
+ // 108-the-join-chains.test.mjs — DIRECTION, the other half: the result of one
2
+ // join feeds the next.
3
+ //
4
+ // THE GAP THIS CLOSES. The join's conclusion covered the WHOLE remaining tail
5
+ // (`j = queryLen`), so it consumed everything and could only ever conclude: a
6
+ // three-relation query got one join and stopped. The whole-tail key was the
7
+ // same defect seen from the key side — a two-relation tail names no learnt key
8
+ // at all.
9
+ //
10
+ // THE FIX, and the measurement that found the last piece of it. The key is the
11
+ // SHORTEST tail prefix that BOTH resolves and leads somewhere, and the
12
+ // conclusion covers only that prefix, leaving the rest of the tail for the next
13
+ // step. Resolving is not enough: a dry run of these very primitives showed the
14
+ // first prefix that resolves for the candidate `Sweden` is the key `Sweden `
15
+ // (trailing space) — which leads NOWHERE — while the key that names the fact
16
+ // (`sweden capital`) sat one prefix further; accepting the first resolving key
17
+ // refused the join.
18
+ //
19
+ // WHAT IS PINNED, untraced (a rationale perturbs the search — test/107):
20
+ // 1. the answer is the THIRD fact, reached through two joins;
21
+ // 2. `joinFired` counts exactly two — the counters are the untraced view;
22
+ // 3. the two-relation query still counts exactly one, so chaining did not
23
+ // turn every join into a chain.
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
+
30
+ const F1 = "The director of Eva is Gustaf Molander.";
31
+ const F2 = "The country of Gustaf Molander is Sweden.";
32
+ const F3 = "The capital of Sweden is Stockholm.";
33
+
34
+ async function chain() {
35
+ const store = new SQliteStore({ path: ":memory:" });
36
+ const mind = new Mind({ seed: 7, store, profile: true });
37
+ await mind.ingest([
38
+ ["eva", F1],
39
+ ["eva director", F1],
40
+ ["gustaf molander", F2],
41
+ ["gustaf molander country", F2],
42
+ ["sweden", F3],
43
+ ["sweden capital", F3],
44
+ ]);
45
+ return mind;
46
+ }
47
+
48
+ const text = (resp) => new TextDecoder().decode(resp.bytes).replace(/\0+/g, "");
49
+
50
+ test("a three-relation query reaches the third fact, through two joins", async () => {
51
+ const mind = await chain();
52
+ const out = text(await mind.respond("eva director country capital"));
53
+ assert.equal(out.trim(), F3, "the chain must reach the capital fact");
54
+ await mind.store.close();
55
+ });
56
+
57
+ test("the counters agree, with no rationale attached", async () => {
58
+ const mind = await chain();
59
+ await mind.respond("eva director country capital");
60
+ assert.equal(
61
+ mind.lastCost?.counters.joinFired ?? 0,
62
+ 2,
63
+ "two joins reached it — the untraced view must agree with the answer",
64
+ );
65
+ await mind.store.close();
66
+ });
67
+
68
+ test("a two-relation query still joins exactly once", async () => {
69
+ const mind = await chain();
70
+ const out = text(await mind.respond("eva director country"));
71
+ assert.equal(out.trim(), F2);
72
+ assert.equal(
73
+ mind.lastCost?.counters.joinFired ?? 0,
74
+ 1,
75
+ "chaining must not turn a single join into a chain",
76
+ );
77
+ await mind.store.close();
78
+ });