@hviana/sema 0.5.8 → 0.5.9

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 (40) hide show
  1. package/AGENTS.md +23 -0
  2. package/DATASETS.md +159 -0
  3. package/README.md +12 -0
  4. package/dist/example/train_base.d.ts +73 -3
  5. package/dist/example/train_base.js +1000 -49
  6. package/dist/src/geometry.d.ts +20 -0
  7. package/dist/src/geometry.js +22 -0
  8. package/dist/src/mind/attention.d.ts +6 -0
  9. package/dist/src/mind/attention.js +44 -4
  10. package/dist/src/mind/learning.js +134 -50
  11. package/dist/src/mind/mechanisms/cast.js +45 -1
  12. package/dist/src/mind/mind.d.ts +6 -1
  13. package/dist/src/mind/mind.js +14 -2
  14. package/dist/src/mind/reasoning.js +59 -5
  15. package/dist/src/mind/recognition.js +29 -3
  16. package/dist/src/mind/traverse.d.ts +16 -0
  17. package/dist/src/mind/traverse.js +18 -0
  18. package/dist/src/store-sqlite.d.ts +4 -0
  19. package/dist/src/store-sqlite.js +47 -0
  20. package/dist/src/store.d.ts +7 -0
  21. package/example/train_base.ts +1193 -46
  22. package/jsr.json +1 -1
  23. package/package.json +1 -1
  24. package/src/geometry.ts +23 -0
  25. package/src/mind/attention.ts +54 -1
  26. package/src/mind/learning.ts +137 -43
  27. package/src/mind/mechanisms/cast.ts +48 -1
  28. package/src/mind/mind.ts +12 -1
  29. package/src/mind/reasoning.ts +64 -5
  30. package/src/mind/recognition.ts +29 -3
  31. package/src/mind/traverse.ts +19 -0
  32. package/src/store-sqlite.ts +53 -0
  33. package/src/store.ts +28 -0
  34. package/test/29-counterfactual.test.mjs +43 -6
  35. package/test/77-company-saturation.test.mjs +302 -0
  36. package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
  37. package/test/84-composed-answer-honesty.test.mjs +136 -0
  38. package/test/85-answered-directly.test.mjs +126 -0
  39. package/test/86-cast-voices-committed.test.mjs +164 -0
  40. package/test/87-codominant-commitment.test.mjs +250 -0
@@ -146,6 +146,20 @@ CREATE TABLE IF NOT EXISTS canon (
146
146
  id INTEGER NOT NULL,
147
147
  PRIMARY KEY (h, id)
148
148
  ) WITHOUT ROWID;
149
+ -- CONSTITUENT SKETCH (Store.sketchGet/sketchPut): the bottom-k minimal
150
+ -- constituents of a node's subtree, k = √D, chosen by identity hash. The blob is
151
+ -- a packed int32 little-endian run, already in hash order; an EMPTY blob is a
152
+ -- real answer (a minimal unit has no constituents) and a MISSING ROW means
153
+ -- "not yet computed" — the two must stay distinguishable, which is why absence
154
+ -- is a missing row rather than an empty blob sentinel. (node:sqlite binds a
155
+ -- zero-length Uint8Array as NULL, so the column is nullable and a NULL blob
156
+ -- reads back as the empty sketch — the ROW is what records "computed".)
157
+ -- Measured on the trained
158
+ -- store: 80.8% of nodes sketch EMPTY, mean 1.14 ids, ~72 MB over 15.7M nodes.
159
+ CREATE TABLE IF NOT EXISTS sketch (
160
+ id INTEGER PRIMARY KEY,
161
+ ids BLOB
162
+ );
149
163
  CREATE TABLE IF NOT EXISTS snapshot (
150
164
  id INTEGER PRIMARY KEY CHECK (id = 1),
151
165
  data BLOB NOT NULL
@@ -256,6 +270,8 @@ export class SQliteStore extends AbstractStore implements Store {
256
270
  private _insCanon: any = null;
257
271
  private _selCanon: any = null;
258
272
  private _cntCanon: any = null;
273
+ private _insSketch: any = null;
274
+ private _selSketch: any = null;
259
275
  private _selContentFrom: any = null;
260
276
  private _delMeta: any = null;
261
277
  private _insSnapshot: any = null;
@@ -1029,6 +1045,43 @@ export class SQliteStore extends AbstractStore implements Store {
1029
1045
  return (this._selCanon.all(h) as Array<{ id: number }>).map((r) => r.id);
1030
1046
  }
1031
1047
 
1048
+ // -- Constituent sketch (Store optional capability) --
1049
+
1050
+ sketchGet(id: number): number[] | null {
1051
+ if (!this._selSketch) {
1052
+ this._selSketch = this.sqlite!.prepare(
1053
+ "SELECT ids FROM sketch WHERE id = ?",
1054
+ );
1055
+ }
1056
+ const row = this._selSketch.get(id) as
1057
+ | { ids: Uint8Array | null }
1058
+ | undefined;
1059
+ if (row === undefined) return null; // never computed — NOT the same as []
1060
+ const b = row.ids;
1061
+ if (b === null || b.byteLength === 0) return []; // computed, no constituents
1062
+ const out: number[] = [];
1063
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
1064
+ for (let i = 0; i + 4 <= b.byteLength; i += 4) {
1065
+ out.push(dv.getInt32(i, true));
1066
+ }
1067
+ return out;
1068
+ }
1069
+
1070
+ sketchPut(id: number, ids: readonly number[]): void {
1071
+ if (!this._insSketch) {
1072
+ this._insSketch = this.sqlite!.prepare(
1073
+ "INSERT OR REPLACE INTO sketch (id, ids) VALUES (?, ?)",
1074
+ );
1075
+ }
1076
+ const b = new Uint8Array(ids.length * 4);
1077
+ const dv = new DataView(b.buffer);
1078
+ for (let i = 0; i < ids.length; i++) dv.setInt32(i * 4, ids[i], true);
1079
+ // Join the deferred write transaction (committed by flush/commit), like
1080
+ // canonAdd — a training run writes these in bulk.
1081
+ this._dbBeginTx();
1082
+ this._insSketch.run(id, b);
1083
+ }
1084
+
1032
1085
  canonCount(): number {
1033
1086
  if (!this._cntCanon) {
1034
1087
  this._cntCanon = this.sqlite!.prepare(
package/src/store.ts CHANGED
@@ -524,6 +524,34 @@ export interface Store {
524
524
  fromId?: NodeId,
525
525
  ): void;
526
526
 
527
+ // ── constituent sketch (optional capability) ───────────────────────────
528
+ // The bottom-k MINIMAL CONSTITUENTS of a node's subtree, k derived from the
529
+ // representation's own capacity (√D — see companyProfile in mind/learning.ts),
530
+ // selected by identity hash so the choice is a property of each constituent
531
+ // and never of where it sits in the fold.
532
+ //
533
+ // DURABLE DERIVED STATE, NOT A CACHE. §2.12 permits a cache to cost only
534
+ // speed; this decides which terms enter a halo — a learned relation — so an
535
+ // eviction would change the geometry rather than slow it down. It is
536
+ // therefore written like the canon index: computed once, kept, never
537
+ // budgeted. Soundness rests on the set being INTRINSIC — minimality,
538
+ // `len ≥ W` and non-domination are properties of the node's own subtree and
539
+ // do not move as the corpus grows. The one corpus-dependent reading, the
540
+ // hub exclusion, is deliberately NOT stored: it is applied by the caller at
541
+ // pour time over the ≤ k candidates, which is the drift companyProfile
542
+ // already documents as benign and one-directional.
543
+ //
544
+ // Backends that do not implement the pair leave both absent; companyProfile
545
+ // then recomputes the sketch per pour and simply loses the amortisation.
546
+
547
+ /** The stored sketch of `id`, or null when it has never been computed.
548
+ * An empty array is a REAL answer (a minimal unit has no constituents) and
549
+ * must be distinguished from null. */
550
+ sketchGet?(id: NodeId): NodeId[] | null;
551
+ /** Record `ids` as the sketch of `id`. Idempotent; ids are already sorted
552
+ * by the caller's identity hash. */
553
+ sketchPut?(id: NodeId, ids: readonly NodeId[]): void;
554
+
527
555
  // ── lifecycle ──────────────────────────────────────────────────────────
528
556
  size(): Promise<number>;
529
557
  saveSnapshot(bytes: Uint8Array): Promise<void>;
@@ -521,8 +521,27 @@ test("D1 — site-aware climb finds diverse anchors for CAST weave", async () =>
521
521
  await m.store.close();
522
522
  });
523
523
 
524
+ // D2 asserted `provenance === "cast"` — a PROXY, and it hid the very thing this
525
+ // test is named for. The climb here elects between two anchors whose votes sit
526
+ // 0.54σ–1.04σ apart, i.e. inside the estimator's own resolution: `steel is hard
527
+ // so steel is strong` scores 0.897–1.013 depending on the seed while `water is
528
+ // frigid so water is freezing` holds ~0.983, so the TOP FLIPS on 6 of 24 seeds
529
+ // (measured; the SD tracks 1/√D and the flip rate collapses 19/60 → 12/60 →
530
+ // 2/60 as D goes 256 → 1024 → 4096). CAST voiced the runner-up regardless of
531
+ // which anchor the climb had committed, so the proxy stayed green while the
532
+ // climb was demonstrably seed-dependent. See `test/87-codominant-commitment`.
533
+ //
534
+ // The strong form asserts the property in the title: every seed must produce
535
+ // the SAME OUTCOME — same provenance and same bytes. Uniformity alone is not
536
+ // enough (all seeds could agree on a wrong answer), so correctness is asserted
537
+ // too: the property transfer must still land on "freezing" via CAST.
538
+ //
539
+ // The seed set deliberately includes 1, 8, 18, 20, 22 and 23 — the seeds whose
540
+ // noise puts the OTHER anchor on top. A seed set that never flips would not
541
+ // exercise the defect at all.
524
542
  test("D2 — site-aware climb is seed-independent", async () => {
525
- for (const seed of [1, 7, 42, 99]) {
543
+ const outcomes = new Map();
544
+ for (const seed of [1, 7, 8, 18, 20, 22, 23, 42, 99]) {
526
545
  const m = mk(seed);
527
546
  await m.ingest([
528
547
  ["ice is cold so ice is brittle", "brittle"],
@@ -530,11 +549,29 @@ test("D2 — site-aware climb is seed-independent", async () => {
530
549
  ["water is frigid so water is freezing", "freezing"],
531
550
  ]);
532
551
  const r = await m.respond("steel is frigid");
533
- assert.equal(
534
- r.provenance,
535
- "cast",
536
- `seed ${seed}: CAST must fire — got ${r.provenance}`,
537
- );
552
+ const text = new TextDecoder().decode(r.bytes ?? new Uint8Array());
553
+ outcomes.set(seed, `${r.provenance}|${text}`);
538
554
  await m.store.close();
539
555
  }
556
+ const distinct = new Set(outcomes.values());
557
+ assert.equal(
558
+ distinct.size,
559
+ 1,
560
+ `the outcome depends on the seed — ${
561
+ [...outcomes].map(([s, o]) => `seed ${s}: ${JSON.stringify(o)}`).join(
562
+ "; ",
563
+ )
564
+ }`,
565
+ );
566
+ const [only] = distinct;
567
+ assert.ok(
568
+ only.startsWith("cast|"),
569
+ `seed-independent, but not through CAST: ${JSON.stringify(only)}`,
570
+ );
571
+ assert.ok(
572
+ /freezing/i.test(only),
573
+ `seed-independent, but the property transfer was lost: ${
574
+ JSON.stringify(only)
575
+ }`,
576
+ );
540
577
  });
@@ -0,0 +1,302 @@
1
+ // 77-company-saturation.test.mjs — companyProfile stops because the
2
+ // REPRESENTATION IS FULL, not because a budget ran out.
3
+ //
4
+ // The old rule was `PROFILE_VISITS = 64`: a constant, and one that decided
5
+ // which constituents entered a halo by where they sat in a BFS. It was also
6
+ // wrong in both directions on the trained store — it fired at 64 visits while
7
+ // capacity needed a median of 69 (dropping readable evidence), and an uncapped
8
+ // walk accepted ~50 terms where the representation holds √D = 32.
9
+ //
10
+ // The replacement is derived: a superposition of m unit signatures contributes
11
+ // 1/m per term to any cosine taken against it, so once m > √D one term moves
12
+ // nothing above estimatorNoise(D) = 1/√D. `profileCapacity(D) = √D` is that
13
+ // point (geometry.ts). The constituent set is stored as a bottom-k sketch keyed
14
+ // on each unit's own identity, so membership is a property of the UNIT and not
15
+ // of traversal order.
16
+ //
17
+ // Every check below reads the diagnostics companyProfile reports through
18
+ // ingest's inspectRationale — if the instrumentation were decorative, T8 fails.
19
+
20
+ import { test } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { Mind } from "../dist/src/index.js";
23
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
24
+ import { estimatorNoise, profileCapacity } from "../dist/src/geometry.js";
25
+
26
+ /** Ingest, collecting every companyProfile diagnostic payload. */
27
+ async function ingestTraced(mind, items) {
28
+ const seen = [];
29
+ await mind.ingest(items, undefined, undefined, (s) => {
30
+ if (s.mechanism[s.mechanism.length - 1] === "companyProfile") {
31
+ seen.push(s.data);
32
+ }
33
+ });
34
+ return seen;
35
+ }
36
+
37
+ /** A store that counts the reads companyProfile's stopping rule performs. */
38
+ function countingStore(opts) {
39
+ const store = new SQliteStore(opts);
40
+ const counts = { get: 0, sketchGet: 0, sketchPut: 0, parentsFirst: 0 };
41
+ for (const m of ["get", "sketchGet", "sketchPut", "parentsFirst"]) {
42
+ const orig = store[m].bind(store);
43
+ store[m] = (...a) => {
44
+ counts[m]++;
45
+ return orig(...a);
46
+ };
47
+ }
48
+ return { store, counts };
49
+ }
50
+
51
+ const mk = (D = 1024) => {
52
+ const store = new SQliteStore({ path: ":memory:", D });
53
+ return { store, mind: new Mind({ seed: 7, store }) };
54
+ };
55
+
56
+ // A long partner whose constituents are many and mostly unique.
57
+ const longText = (n, tail = "") =>
58
+ Array.from({ length: n }, (_, i) => `alpha${i} beta${i} gamma${i}`).join(
59
+ " ",
60
+ ) + tail;
61
+
62
+ // ── 1. deep type-level company still works ───────────────────────────────
63
+ // The motivating case: the shared unit sits BELOW the top of the fold, so a
64
+ // depth-1 profile would miss it entirely (test/76 T1 is the full fixture).
65
+ test("T1: a unit shared below depth 1 still enters both profiles", async () => {
66
+ const { store, mind } = mk();
67
+ await mind.ingest([
68
+ ["The Eiffel Tower is in Paris", "Tour Eiffel dia any Paris"],
69
+ [
70
+ "A completely unrelated control sentence",
71
+ "Another unrelated control string",
72
+ ],
73
+ ]);
74
+ // Both partners must have found constituents at all — an empty sketch on a
75
+ // full sentence is the depth-1 failure this design exists to prevent.
76
+ const ids = [];
77
+ for (let i = 0; i < store.nodeCount(); i++) {
78
+ const s = store.sketchGet?.(i);
79
+ if (s && s.length > 0) ids.push(i);
80
+ }
81
+ assert.ok(ids.length > 0, "no node acquired a non-empty constituent sketch");
82
+ });
83
+
84
+ // ── 2. training order ────────────────────────────────────────────────────
85
+ test("T2: forward and reverse training order give identical sketches", async () => {
86
+ const pairs = [
87
+ ["The Eiffel Tower is in Paris", "Tour Eiffel dia any Paris"],
88
+ ["Water freezes at zero degrees", "El agua se congela a cero grados"],
89
+ ["The capital of France is Paris", "La capitale de la France est Paris"],
90
+ ];
91
+ const read = async (items) => {
92
+ const { store, mind } = mk();
93
+ await mind.ingest(items);
94
+ // Key sketches by CONTENT, not id — ids depend on mint order by design.
95
+ const dec = new TextDecoder();
96
+ const out = new Map();
97
+ for (let i = 0; i < store.nodeCount(); i++) {
98
+ const s = store.sketchGet?.(i);
99
+ if (!s || s.length === 0) continue;
100
+ const key = dec.decode(store.bytes(i).filter((x) => x !== 0));
101
+ out.set(
102
+ key,
103
+ s.map((n) => dec.decode(store.bytes(n).filter((x) => x !== 0))).sort(),
104
+ );
105
+ }
106
+ return out;
107
+ };
108
+ const fwd = await read(pairs);
109
+ const rev = await read([...pairs].reverse());
110
+ // Every partner present in both must have the SAME constituent set.
111
+ let compared = 0;
112
+ for (const [k, v] of fwd) {
113
+ if (!rev.has(k)) continue;
114
+ compared++;
115
+ assert.deepEqual(
116
+ v,
117
+ rev.get(k),
118
+ `sketch differs by training order for ${JSON.stringify(k)}`,
119
+ );
120
+ }
121
+ assert.ok(compared > 0, "no partner was comparable across orders");
122
+ });
123
+
124
+ // ── 3. long partners stop by SATURATION, not by a budget ─────────────────
125
+ test("T3: a long partner reports capacity as the stop reason", async () => {
126
+ const { mind } = mk();
127
+ const diag = await ingestTraced(mind, [[
128
+ longText(40),
129
+ "a short continuation",
130
+ ]]);
131
+ const long = diag.filter((d) => d.wholeLen > 400);
132
+ assert.ok(long.length > 0, "expected at least one long partner");
133
+ for (const d of long) {
134
+ assert.equal(d.capacity, profileCapacity(1024), "capacity must be √D");
135
+ assert.equal(
136
+ d.stopReason,
137
+ "capacity",
138
+ "long partner must stop at capacity",
139
+ );
140
+ assert.ok(d.saturated, "long partner must report saturation");
141
+ assert.equal(
142
+ d.sketched,
143
+ d.capacity,
144
+ "sketch must be exactly capacity-sized",
145
+ );
146
+ }
147
+ });
148
+
149
+ // ── 4. a unit shared at DIFFERENT depths must not be systematically lost ──
150
+ test("T4: a unit shared at different fold depths enters both sketches", async () => {
151
+ // The motivating fixture. Content-defined cuts put " Paris" at a different
152
+ // depth in each sentence — "The Eiffel Tower is in Paris" folds to
153
+ // "The Eiffel " + "Tower is in Paris", "Tour Eiffel dia any Paris" to
154
+ // "Tour Eiffel " + "dia any Paris" — so the shared unit is a child of
155
+ // NEITHER. A selection keyed on traversal position reaches it in one partner
156
+ // and not the other; one keyed on the unit's own identity keeps it in both.
157
+ //
158
+ // NOTE the earlier version of this test compared "zzmarker " at the head
159
+ // against " zzmarker" at the tail. Those are DIFFERENT BYTES, hence different
160
+ // node identities, so the comparison could never have been about position.
161
+ const { store, mind } = mk();
162
+ const A = "The Eiffel Tower is in Paris";
163
+ const B = "Tour Eiffel dia any Paris";
164
+ await mind.ingest([[A, B]]);
165
+
166
+ const dec = new TextDecoder();
167
+ const enc = new TextEncoder();
168
+ const nodeOf = (text) => {
169
+ const want = enc.encode(text);
170
+ for (let i = 0; i < store.nodeCount(); i++) {
171
+ if (store.contentLen(i, want.length + 1) !== want.length) continue;
172
+ const b = store.bytes(i);
173
+ if (b.length === want.length && b.every((x, j) => x === want[j])) {
174
+ return i;
175
+ }
176
+ }
177
+ return null;
178
+ };
179
+ const a = nodeOf(A), b = nodeOf(B);
180
+ assert.ok(a !== null && b !== null, "both partners must be interned");
181
+ const sa = store.sketchGet(a) ?? [];
182
+ const sb = store.sketchGet(b) ?? [];
183
+ assert.ok(
184
+ sa.length > 0 && sb.length > 0,
185
+ "both partners must sketch something",
186
+ );
187
+ const shared = sa.filter((n) => sb.includes(n));
188
+ assert.ok(
189
+ shared.length > 0,
190
+ `no shared constituent: A=${
191
+ JSON.stringify(sa.map((n) => dec.decode(store.bytes(n))))
192
+ } ` +
193
+ `B=${JSON.stringify(sb.map((n) => dec.decode(store.bytes(n))))}`,
194
+ );
195
+ });
196
+
197
+ // ── 5. an irrelevant tail must not buy unbounded work ────────────────────
198
+ test("T5: work per profile does not grow with an irrelevant structural tail", async () => {
199
+ const work = [];
200
+ for (const n of [10, 40, 160]) {
201
+ const { store, counts } = countingStore({ path: ":memory:", D: 1024 });
202
+ const mind = new Mind({ seed: 7, store });
203
+ await mind.ingest([[longText(n), "continuation"]]);
204
+ work.push({ n, parentsFirst: counts.parentsFirst });
205
+ }
206
+ // parentsFirst is the hub probe — exactly one per SKETCHED constituent, so it
207
+ // measures the stopping rule's own cost. Capacity bounds it at √D per pour.
208
+ const cap = profileCapacity(1024);
209
+ for (const w of work) {
210
+ assert.ok(
211
+ w.parentsFirst <= cap * 8,
212
+ `hub probes ${w.parentsFirst} at n=${w.n} exceed a capacity-bounded budget`,
213
+ );
214
+ }
215
+ // 16x the tail must not cost 16x the stopping work.
216
+ const ratio = work[2].parentsFirst / Math.max(1, work[0].parentsFirst);
217
+ assert.ok(
218
+ ratio < 4,
219
+ `stopping work grew ${ratio.toFixed(1)}x for a 16x tail`,
220
+ );
221
+ });
222
+
223
+ // ── 6. corpus growth must not explode the stopping cost ──────────────────
224
+ test("T6: the same partner costs the same to profile as the corpus grows", async () => {
225
+ const probe = async (extra) => {
226
+ const { store, counts } = countingStore({ path: ":memory:", D: 1024 });
227
+ const mind = new Mind({ seed: 7, store });
228
+ const filler = Array.from(
229
+ { length: extra },
230
+ (_, i) => [`ctx ${i} alpha`, `ans ${i} beta`],
231
+ );
232
+ await mind.ingest(filler);
233
+ const before = counts.parentsFirst;
234
+ await mind.ingest([[longText(30), "continuation"]]);
235
+ return counts.parentsFirst - before;
236
+ };
237
+ const small = await probe(20);
238
+ const large = await probe(600);
239
+ assert.ok(
240
+ large <= small * 2 + 16,
241
+ `profiling cost grew with corpus size: ${small} -> ${large} hub probes`,
242
+ );
243
+ });
244
+
245
+ // ── 7. stopping happened only once evidence was insignificant ────────────
246
+ test("T7: saturated profiles report a marginal at or below the noise floor", async () => {
247
+ const { mind } = mk();
248
+ const diag = await ingestTraced(mind, [[
249
+ longText(40),
250
+ "a short continuation",
251
+ ]]);
252
+ const sat = diag.filter((d) => d.saturated);
253
+ assert.ok(sat.length > 0, "expected a saturated profile");
254
+ for (const d of sat) {
255
+ assert.equal(d.noiseFloor, estimatorNoise(1024));
256
+ // mass = accepted + 1, and capacity is the point where 1/mass reaches the
257
+ // floor. Accepting everything sketched puts marginal AT the floor; hub
258
+ // exclusion can leave it above, and the diagnostics must say which.
259
+ assert.ok(d.mass > 1, "a saturated profile superposed nothing");
260
+ assert.equal(d.residual, d.sketched - d.accepted);
261
+ assert.ok(
262
+ d.marginal <= d.noiseFloor || d.hubDropped + d.dominating === d.residual,
263
+ `marginal ${d.marginal} above floor ${d.noiseFloor} with unexplained residual`,
264
+ );
265
+ }
266
+ });
267
+
268
+ // ── 8. the instrumentation is not decorative ─────────────────────────────
269
+ test("T8: perturbing capacity measurably moves the diagnostics", async () => {
270
+ // profileCapacity is √D, so changing D perturbs the saturation point. If the
271
+ // reported numbers did not follow, the diagnostics would be describing
272
+ // something other than the rule that actually stops the walk.
273
+ const at = async (D) => {
274
+ const store = new SQliteStore({ path: ":memory:", D });
275
+ const mind = new Mind({ seed: 7, store });
276
+ const diag = await ingestTraced(mind, [[
277
+ longText(40),
278
+ "a short continuation",
279
+ ]]);
280
+ return diag.filter((d) => d.saturated);
281
+ };
282
+ const small = await at(256); // capacity 16
283
+ const large = await at(4096); // capacity 64
284
+ assert.ok(
285
+ small.length > 0 && large.length > 0,
286
+ "both D values must saturate",
287
+ );
288
+ assert.equal(small[0].capacity, profileCapacity(256));
289
+ assert.equal(large[0].capacity, profileCapacity(4096));
290
+ assert.ok(
291
+ large[0].sketched > small[0].sketched,
292
+ `sketch size did not follow capacity: ${small[0].sketched} vs ${
293
+ large[0].sketched
294
+ }`,
295
+ );
296
+ assert.ok(
297
+ large[0].marginal < small[0].marginal,
298
+ `marginal did not follow capacity: ${small[0].marginal} vs ${
299
+ large[0].marginal
300
+ }`,
301
+ );
302
+ });
@@ -0,0 +1,135 @@
1
+ // 78-atom-hub-recognition-cliff.test.mjs — recognition must not LOSE interior
2
+ // sites when the corpus crosses the atomIsHub threshold.
3
+ //
4
+ // THE BUG THIS PINS. `atomIsHub` flips at exactly N = 4096 edge sources:
5
+ // atomReach = ⌈N·W/256⌉ = ⌈N/64⌉ exceeds boundFor = √N there (W = 4).
6
+ // recogniseImpl gated FOUR things on that flip, and one of them —
7
+ // tryChain's `!boundary && atomsAreHubs` — blanket-suppressed every
8
+ // off-boundary chain. A store crossing 4096 therefore silently stopped
9
+ // recognising interior forms it had recognised at 4095, with no error and no
10
+ // failing test. Measured on the two-hop fixture below: 4 sites including
11
+ // "France" at N = 3920, 2 sites without it at N = 4227.
12
+ //
13
+ // The fix asks whether the byte-exact branch tryChain ALREADY found is a
14
+ // deposited whole (`bearsEdge`) instead of whether its offset happened to land
15
+ // on a fold cut. So the discriminating assertion is: at N > 4096, an interior
16
+ // form that BEARS A CONTINUATION EDGE is still a recognised site.
17
+ //
18
+ // WHY THIS IS SLOW AND MUST STAY SO. The threshold is a property of corpus
19
+ // scale, so the only honest fixture is one that actually crosses it — 4300
20
+ // deposits, ~6 s. A cheaper store would sit below the flip and pass under the
21
+ // old code too, which is exactly the hole this file exists to close.
22
+
23
+ import { test } from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { Mind } from "../dist/src/index.js";
26
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
27
+ import { recognise } from "../dist/src/mind/recognition.js";
28
+ import { atomIsHub, corpusN } from "../dist/src/mind/traverse.js";
29
+
30
+ const CHAIN = [
31
+ ["Eiffel Tower country", "The country of Eiffel Tower is France."],
32
+ ["France capital", "The capital of France is Paris."],
33
+ // The PIVOT: a bare entity that bears a continuation edge, and which occurs
34
+ // INSIDE hop 1's answer at an offset the fold did not choose as a cut.
35
+ ["France", "The capital of France is Paris."],
36
+ ];
37
+ const ANSWER = "The country of Eiffel Tower is France.";
38
+
39
+ // Lexically VARIED filler. A single repeated template ("filler 12 alpha") folds
40
+ // to a handful of shared chunks and leaves the query almost uncontested, which
41
+ // was enough to let the two-hop chain compose even with the gate in place —
42
+ // i.e. a templated corpus makes the behavioural test non-discriminating. Real
43
+ // corpora are lexically diverse, so the filler must be too.
44
+ const WORDS =
45
+ ("alpha bravo charlie delta echo foxtrot golf hotel india juliet " +
46
+ "kilo lima mike november oscar papa quebec romeo sierra tango uniform " +
47
+ "victor whiskey xray yankee zulu amber bronze copper dahlia ember fjord " +
48
+ "gossamer harbour indigo jasmine kestrel lantern marigold nectar opal " +
49
+ "pewter quartz ripple saffron thistle umber violet willow xenon yarrow")
50
+ .split(" ");
51
+ const filler = (i) => {
52
+ const w = (n) => WORDS[(i * 7 + n * 13) % WORDS.length];
53
+ return [
54
+ `${w(1)} ${w(2)} ${w(3)} ${i}`,
55
+ `${w(4)} ${w(5)} ${w(6)} ${w(7)} ${i}`,
56
+ ];
57
+ };
58
+
59
+ /** One store, ingested past the atomIsHub flip. */
60
+ async function pastTheFlip() {
61
+ const store = new SQliteStore({ path: ":memory:", D: 1024 });
62
+ const mind = new Mind({ seed: 7, store });
63
+ await mind.ingest(CHAIN);
64
+ await mind.ingest(Array.from({ length: 4300 }, (_, i) => filler(i)));
65
+ return { store, mind };
66
+ }
67
+
68
+ const dec = new TextDecoder();
69
+ const textOf = (store, id) =>
70
+ dec.decode(store.bytes(id).filter((x) => x !== 0));
71
+
72
+ test("the fixture really is past the atomIsHub threshold", async () => {
73
+ const { store, mind } = await pastTheFlip();
74
+ const n = corpusN(mind);
75
+ assert.ok(n > 4096, `fixture must cross N=4096, got ${n}`);
76
+ assert.equal(
77
+ atomIsHub(mind, n),
78
+ true,
79
+ "atoms must read as hubs, or this file tests nothing",
80
+ );
81
+ });
82
+
83
+ test("an edge-bearing interior form is still recognised past the flip", async () => {
84
+ const { store, mind } = await pastTheFlip();
85
+ mind.beginResponse?.();
86
+ const rec = recognise(mind, new TextEncoder().encode(ANSWER));
87
+ mind.endResponse?.();
88
+ const texts = rec.sites.map((s) => textOf(store, s.payload));
89
+ assert.ok(
90
+ texts.includes("France"),
91
+ `interior form "France" was not recognised past the flip; sites = ${
92
+ JSON.stringify(texts)
93
+ }`,
94
+ );
95
+ });
96
+
97
+ test("the interior form is reachable as a pivot, so the chain composes", async () => {
98
+ // The behavioural consequence: reason() pivots on the longest unconsumed
99
+ // learnt context the grounded answer CONTAINS. Lose the site and the hop is
100
+ // structurally unreachable, whatever the rest of the pipeline does.
101
+ const { mind } = await pastTheFlip();
102
+ const steps = [];
103
+ const out = await mind.respondText(
104
+ "What is the capital of the country of Eiffel Tower?",
105
+ (s) => steps.push(s.mechanism[s.mechanism.length - 1]),
106
+ );
107
+ assert.ok(
108
+ steps.includes("pivotStep"),
109
+ `no pivotStep past the flip; answer was ${JSON.stringify(out)}`,
110
+ );
111
+ assert.ok(
112
+ out.includes("Paris"),
113
+ `two-hop chain did not compose past the flip: ${JSON.stringify(out)}`,
114
+ );
115
+ });
116
+
117
+ test("a fragment that leads nowhere is still NOT recognised", async () => {
118
+ // The other half of the contract: the gate was protecting against
119
+ // opportunistic byte-atom chains, and the fix must not have opened that door.
120
+ // "owe" occurs inside "Eiffel Tower country" but was never deposited as a
121
+ // form of its own, so it bears no edge and no halo.
122
+ const { store, mind } = await pastTheFlip();
123
+ mind.beginResponse?.();
124
+ const rec = recognise(mind, new TextEncoder().encode(ANSWER));
125
+ mind.endResponse?.();
126
+ for (const s of rec.sites) {
127
+ const t = textOf(store, s.payload);
128
+ assert.ok(
129
+ t.length >= 4,
130
+ `sub-window fragment ${JSON.stringify(t)} was admitted as a site`,
131
+ );
132
+ }
133
+ // And pure noise must still ground to nothing.
134
+ assert.equal(await mind.respondText("qq8f3kz9 zzxq wvbn"), "");
135
+ });