@hviana/sema 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/src/geometry.d.ts +26 -0
  2. package/dist/src/geometry.js +32 -3
  3. package/dist/src/mind/attention.d.ts +246 -7
  4. package/dist/src/mind/attention.js +771 -173
  5. package/dist/src/mind/bridge.d.ts +30 -0
  6. package/dist/src/mind/bridge.js +569 -0
  7. package/dist/src/mind/index.d.ts +2 -0
  8. package/dist/src/mind/junction.d.ts +30 -1
  9. package/dist/src/mind/junction.js +73 -18
  10. package/dist/src/mind/match.d.ts +15 -2
  11. package/dist/src/mind/match.js +3 -8
  12. package/dist/src/mind/mechanisms/cast.d.ts +54 -0
  13. package/dist/src/mind/mechanisms/cast.js +268 -37
  14. package/dist/src/mind/mechanisms/cover.js +16 -31
  15. package/dist/src/mind/mechanisms/recall.js +66 -0
  16. package/dist/src/mind/mind.d.ts +2 -0
  17. package/dist/src/mind/rationale.d.ts +7 -2
  18. package/dist/src/mind/rationale.js +6 -5
  19. package/dist/src/mind/reasoning.d.ts +11 -0
  20. package/dist/src/mind/reasoning.js +58 -2
  21. package/dist/src/mind/recognition.js +127 -7
  22. package/dist/src/mind/traverse.js +90 -25
  23. package/dist/src/mind/types.d.ts +57 -2
  24. package/dist/src/mind/types.js +38 -7
  25. package/dist/src/store.d.ts +12 -3
  26. package/dist/src/store.js +9 -3
  27. package/package.json +1 -1
  28. package/src/geometry.ts +52 -3
  29. package/src/mind/attention.ts +1199 -128
  30. package/src/mind/bridge.ts +596 -0
  31. package/src/mind/index.ts +16 -0
  32. package/src/mind/junction.ts +125 -16
  33. package/src/mind/match.ts +19 -5
  34. package/src/mind/mechanisms/cast.ts +290 -38
  35. package/src/mind/mechanisms/cover.ts +23 -36
  36. package/src/mind/mechanisms/recall.ts +79 -0
  37. package/src/mind/mind.ts +15 -0
  38. package/src/mind/rationale.ts +12 -4
  39. package/src/mind/reasoning.ts +71 -2
  40. package/src/mind/recognition.ts +132 -7
  41. package/src/mind/traverse.ts +91 -24
  42. package/src/mind/types.ts +95 -6
  43. package/src/store.ts +19 -5
  44. package/test/36-already-answered-fusion.test.mjs +128 -0
  45. package/test/37-cluster-dispersion-fusion.test.mjs +190 -0
  46. package/test/38-reason-restate-guard.test.mjs +94 -0
  47. package/test/39-cast-restate-guard.test.mjs +102 -0
  48. package/test/40-choosenext-scale-guard.test.mjs +75 -0
  49. package/test/41-seatofnode-direction.test.mjs +85 -0
  50. package/test/42-recognise-trace-idempotence.test.mjs +106 -0
  51. package/test/43-cast-analog-seat.test.mjs +244 -0
  52. package/test/44-recognise-edge-whitespace.test.mjs +63 -0
  53. package/test/45-liftanswer-restated-trim.test.mjs +60 -0
  54. package/test/46-recognise-multibyte-edge.test.mjs +85 -0
  55. package/test/47-cast-comparison-coverage.test.mjs +134 -0
  56. package/test/48-recognise-turn-connective.test.mjs +125 -0
  57. package/test/49-natural-units-synonym-bridge.test.mjs +175 -0
  58. package/test/50-cast-analog-consensus-floor.test.mjs +179 -0
  59. package/test/51-structural-resonance-ladder.test.mjs +552 -0
  60. package/test/52-climb-consensus-instrumentation.test.mjs +324 -0
@@ -7,8 +7,7 @@
7
7
  // project) live in match.ts — the elementary match-and-project operation.
8
8
 
9
9
  import { cosine, Vec } from "../vec.js";
10
- import { consensusFloor } from "../geometry.js";
11
- import type { AncestorReach, MindContext } from "./types.js";
10
+ import type { AncestorReach, MindContext, SaturationStop } from "./types.js";
12
11
  import { gistOf, read } from "./primitives.js";
13
12
 
14
13
  // ── Per-response structural memo ────────────────────────────────────────
@@ -135,7 +134,22 @@ export function edgeAncestors(
135
134
  // is withdrawn. On a small store the floor stays ≤ √N and the atom
136
135
  // climbs exactly as before, so single-letter facts keep working.
137
136
  if (id < 0 && atomIsHub(ctx, contextCount)) {
138
- const reach = { roots: [], contextsReached: 0, saturated: true };
137
+ const bound0 = Math.ceil(Math.sqrt(Math.max(2, contextCount)));
138
+ const reach: AncestorReach = {
139
+ roots: [],
140
+ contextsReached: 0,
141
+ saturated: true,
142
+ ...(ctx.trace
143
+ ? {
144
+ saturation: {
145
+ reason: "byte-atom-commonality" as const,
146
+ node: id,
147
+ observed: atomReach(ctx, contextCount),
148
+ limit: bound0,
149
+ },
150
+ }
151
+ : {}),
152
+ };
139
153
  memo?.set(id, reach);
140
154
  return reach;
141
155
  }
@@ -145,6 +159,10 @@ export function edgeAncestors(
145
159
  const seen = new Set<number>([id]);
146
160
  const ctxSeen = new Set<number>();
147
161
  let saturated = false;
162
+ // Provenance of the FIRST decision that saturated this climb — allocated
163
+ // only when a trace is requested (see AncestorReach.saturation's doc); the
164
+ // climb itself never reads it back.
165
+ let satStop: SaturationStop | undefined;
148
166
 
149
167
  // EXPAND-UNTIL-DECIDED: a reach is consumed either as a VOTE (which needs
150
168
  // contextsReached exactly, and only while ≤ √N — beyond that the region is
@@ -189,12 +207,45 @@ export function edgeAncestors(
189
207
  if (hasNx || pc > 0) {
190
208
  roots.push(x);
191
209
  if (hasNx) ctxSeen.add(x);
192
- if (pc > bound) return false; // decided: ≥ pc > √N distinct contexts
210
+ if (pc > bound) {
211
+ // decided: ≥ pc > √N distinct contexts
212
+ if (ctx.trace) {
213
+ satStop = {
214
+ reason: "predecessor-fan-in",
215
+ node: x,
216
+ observed: pc,
217
+ limit: bound,
218
+ };
219
+ }
220
+ return false;
221
+ }
193
222
  for (const p of ctx.store.prevFirst(x, bound)) ctxSeen.add(p);
194
- if (ctxSeen.size > bound) return false; // decided
223
+ if (ctxSeen.size > bound) {
224
+ // decided
225
+ if (ctx.trace) {
226
+ satStop = {
227
+ reason: "distinct-context-limit",
228
+ node: x,
229
+ observed: ctxSeen.size,
230
+ limit: bound,
231
+ };
232
+ }
233
+ return false;
234
+ }
195
235
  }
196
236
  const parents = ctx.store.parentsFirst(x, bound + 1);
197
- if (parents.length > bound) return false; // decided: hub
237
+ if (parents.length > bound) {
238
+ // decided: hub
239
+ if (ctx.trace) {
240
+ satStop = {
241
+ reason: "parent-fan-out",
242
+ node: x,
243
+ observed: parents.length,
244
+ limit: bound,
245
+ };
246
+ }
247
+ return false;
248
+ }
198
249
  let fresh = 0;
199
250
  for (const p of parents) {
200
251
  if (!seen.has(p)) {
@@ -205,7 +256,18 @@ export function edgeAncestors(
205
256
  }
206
257
  if (fresh > 1) {
207
258
  lateral += fresh - 1;
208
- if (lateral > bound) return false; // decided: cone-wide hub
259
+ if (lateral > bound) {
260
+ // decided: cone-wide hub
261
+ if (ctx.trace) {
262
+ satStop = {
263
+ reason: "lateral-cone-limit",
264
+ node: x,
265
+ observed: lateral,
266
+ limit: bound,
267
+ };
268
+ }
269
+ return false;
270
+ }
209
271
  }
210
272
  return true;
211
273
  };
@@ -267,7 +329,12 @@ export function edgeAncestors(
267
329
  }
268
330
  }
269
331
 
270
- const reach = { roots, contextsReached: ctxSeen.size, saturated };
332
+ const reach: AncestorReach = {
333
+ roots,
334
+ contextsReached: ctxSeen.size,
335
+ saturated,
336
+ ...(saturated && satStop ? { saturation: satStop } : {}),
337
+ };
271
338
  memo?.set(id, reach);
272
339
  return reach;
273
340
  }
@@ -528,22 +595,22 @@ export function chooseNext(
528
595
  }
529
596
  }
530
597
 
531
- // A pick among GENUINELY competing continuations still needs to clear the
532
- // same genuine-corroboration floor {@link consensusFloor} draws for every
533
- // other consumer that turns distinct-context support into a vote (the
534
- // climb's recallByResonance/commitVotes) below it, "most corroborated"
535
- // is only the least-thin echo among several, not real evidence. Gated to
536
- // corpus scale large enough that this echo is even possible (the same
537
- // scale {@link atomIsHub} already switches on for the identical reason:
538
- // at small N every edge IS the evidence there is). A hub id has no
539
- // meaningful "distinct context" reading at all (id < 0 atoms are excluded
540
- // from this path already, since chooseNext only ever sees real edges).
541
- const N = corpusN(ctx);
542
- if (
543
- capped.length > 1 && atomIsHub(ctx, N) && bestSupport < consensusFloor(N)
544
- ) {
545
- return undefined;
546
- }
598
+ // NO consensusFloor gate here (tried and reverted see
599
+ // test/40-choosenext-scale-guard.test.mjs): that floor is calibrated for
600
+ // POOLED, IDF-weighted CLIMB VOTES (recallByResonance, commitVotes), where
601
+ // each corroborating region contributes at most ln N and the floor grows
602
+ // with N exactly as that per-region ceiling does (HOW_IT_WORKS.md §8.6).
603
+ // `bestSupport` here is a different kind of quantity a raw prevCount of
604
+ // how many training contexts predicted ONE destination, bounded by how
605
+ // often that specific fact was retold, never by corpus size N. Gating an
606
+ // N-invariant count against an N-growing threshold guarantees failure
607
+ // once N is large enough, discarding genuinely, structurally dominant
608
+ // edges (observed: a fact corroborated 2-to-1-1-1 refused at N≈325K,
609
+ // falling back to a noisy concept-hop). The loop above already IS the
610
+ // "genuinely competing" test: a tie leaves first-inserted as the pick
611
+ // (test/30's own pinned behaviour); a strict winner is real evidence
612
+ // regardless of corpus scale. Matches HOW_IT_WORKS.md §25's own
613
+ // chooseNext pseudocode, which has no such floor.
547
614
 
548
615
  // Trace is built lazily — the filter + map below only execute when a
549
616
  // trace listener is attached, so the common (no-trace) path pays only
package/src/mind/types.ts CHANGED
@@ -39,7 +39,7 @@ export interface DepositCacheEntry {
39
39
  * its own suffix bytes-equal this exactly. */
40
40
  nextBytes?: Uint8Array;
41
41
  }
42
- import { concatBytes } from "../bytes.js";
42
+ import { bytesEqual, concatBytes, indexOf } from "../bytes.js";
43
43
  import { dominates } from "../geometry.js";
44
44
 
45
45
  // ═══════════════════════════════════════════════════════════════════════════
@@ -111,6 +111,23 @@ export interface Attention {
111
111
  * consensus; one that does not is a coincidental single-region echo —
112
112
  * see test/35-attention-confidence.test.mjs. */
113
113
  breadth: number;
114
+ /** DISPERSION: the number of distinct clusters this point's contributing
115
+ * regions form, merging any two whose gap is under one river-fold
116
+ * quantum W. Neither breadth NOR raw region count discriminates a
117
+ * genuine further topic from a coincidental echo (both were tried and
118
+ * falsified — breadth starves a genuine, evenly-split multi-topic query,
119
+ * since no root in a real N-way split can exceed half the vote; raw
120
+ * count doesn't separate them either, since a short, structurally simple
121
+ * echo racks up as many corroborating regions as a real topic does).
122
+ * Dispersion asks a different question: not how MUCH evidence, but how
123
+ * many separate PLACES in the query corroborate it. A coincidental
124
+ * match — one local phrase resonating with an unrelated stored form —
125
+ * is structurally confined to ONE cluster no matter how strong its vote;
126
+ * a genuine further topic is named in its own distinctive wording
127
+ * somewhere the query's scaffolding does not reach, always a SEPARATE
128
+ * cluster from whatever else corroborates it. See
129
+ * test/37-cluster-dispersion-fusion.test.mjs. */
130
+ clusters: number;
114
131
  }
115
132
 
116
133
  /** Both read-outs of one consensus climb. */
@@ -158,11 +175,36 @@ export interface RegionVote {
158
175
  absorbed?: number;
159
176
  }
160
177
 
178
+ /** The structural gate that first decided an {@link edgeAncestors} climb was
179
+ * saturated (an abstention, not a discriminative conclusion) — pure
180
+ * instrumentation for {@link ClimbConsensusData}'s reach trace; it never
181
+ * feeds back into the climb itself. */
182
+ export type SaturationReason =
183
+ | "byte-atom-commonality"
184
+ | "predecessor-fan-in"
185
+ | "distinct-context-limit"
186
+ | "parent-fan-out"
187
+ | "lateral-cone-limit";
188
+
189
+ /** One saturation stop's provenance: which reason fired, at which node, the
190
+ * observed count against the bound that decided it. */
191
+ export interface SaturationStop {
192
+ reason: SaturationReason;
193
+ node: number;
194
+ observed: number;
195
+ limit: number;
196
+ }
197
+
161
198
  /** The edge-bearing contexts reached by climbing from a node, plus saturation info. */
162
199
  export interface AncestorReach {
163
200
  roots: number[];
164
201
  contextsReached: number;
165
202
  saturated: boolean;
203
+ /** The saturation gate that stopped this climb, when {@link saturated} is
204
+ * true and a trace was requested — see {@link edgeAncestors}. Absent for
205
+ * a non-saturated reach, and absent (even when saturated) when no trace
206
+ * was requested — instrumentation must not allocate when tracing is off. */
207
+ saturation?: SaturationStop;
166
208
  }
167
209
 
168
210
  /** Saturated-interval information for the noise-drop gate. */
@@ -273,11 +315,52 @@ export function spliceAll(segs: Seg[]): Uint8Array | null {
273
315
  return concatBytes(segs.map((s) => s.bytes));
274
316
  }
275
317
 
318
+ /** Whether a chosen span RESTATES the query rather than answering it: its
319
+ * SUBSTITUTED bytes (an edge followed from a recognised site, not the
320
+ * site's own literal text read back) already occur elsewhere in the query
321
+ * — the same principle recall.ts's tiers apply to a whole-query projection
322
+ * ("a projection that is a proper byte-subspan of the query restates part
323
+ * of the question"). A LITERAL span (the site's own bytes, unchanged) is
324
+ * exempt: naming what's already there at its OWN position is not a
325
+ * substitution. A recognised site that is itself an entire PRIOR TURN of
326
+ * a multi-turn query is exactly this shape: it carries a genuine learnt
327
+ * continuation, but that continuation is something the asker already said
328
+ * moments later in the SAME query, not a new answer. Below one river
329
+ * window, byte overlap is chance, not evidence — the same floor
330
+ * identityBar and reachThreshold hold every other structural-overlap claim
331
+ * to. */
332
+ export function segRestatesQuery(
333
+ s: Seg,
334
+ query: Uint8Array,
335
+ queryLen: number,
336
+ W: number,
337
+ ): boolean {
338
+ if (!s.rec) return false;
339
+ const literal = s.j - s.i === s.bytes.length &&
340
+ bytesEqual(s.bytes, query.subarray(s.i, s.j));
341
+ if (literal) return false;
342
+ return s.bytes.length >= W && s.bytes.length < queryLen &&
343
+ indexOf(query, s.bytes, 0) >= 0;
344
+ }
345
+
276
346
  /** Lift the answer out of the cover for think: the recognised region, free of
277
- * the asker's surrounding (unrecognised) framing. */
278
- export function liftAnswer(segs: Seg[], queryLen: number): Uint8Array | null {
347
+ * the asker's surrounding (unrecognised) framing — and free of any chosen
348
+ * span that only RESTATES content the query already contains (see {@link
349
+ * segRestatesQuery}). A restating span is excluded from both the framing
350
+ * (lo/hi) decision and the final concatenation: it is stale, not a second
351
+ * answer, but the OTHER spans a derivation chose are independent evidence
352
+ * and must not be discarded along with it. */
353
+ export function liftAnswer(
354
+ segs: Seg[],
355
+ queryLen: number,
356
+ query: Uint8Array,
357
+ W: number,
358
+ ): Uint8Array | null {
359
+ const restated = segs.map((s) => segRestatesQuery(s, query, queryLen, W));
279
360
  const recognised: number[] = [];
280
- for (let k = 0; k < segs.length; k++) if (segs[k].rec) recognised.push(k);
361
+ for (let k = 0; k < segs.length; k++) {
362
+ if (segs[k].rec && !restated[k]) recognised.push(k);
363
+ }
281
364
  if (recognised.length === 0) return null;
282
365
 
283
366
  if (recognised.length === 1) {
@@ -297,13 +380,19 @@ export function liftAnswer(segs: Seg[], queryLen: number): Uint8Array | null {
297
380
  // trailing glue byte ("2+2." → "4.", the span dominates a 4-byte query).
298
381
  if (s.computed && s.i > 0) return s.bytes;
299
382
  if (dominates(s.j - s.i, queryLen)) {
300
- return concatBytes(segs.map((x) => x.bytes));
383
+ return concatBytes(
384
+ segs.filter((_, k) => !restated[k]).map((x) => x.bytes),
385
+ );
301
386
  }
302
387
  return s.bytes;
303
388
  }
304
389
  const lo = recognised[0];
305
390
  const hi = recognised[recognised.length - 1];
306
- return concatBytes(segs.slice(lo, hi + 1).map((x) => x.bytes));
391
+ return concatBytes(
392
+ segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]).map((x) =>
393
+ x.bytes
394
+ ),
395
+ );
307
396
  }
308
397
 
309
398
  /** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
package/src/store.ts CHANGED
@@ -306,8 +306,17 @@ export interface Store {
306
306
  nodeCount(): number;
307
307
 
308
308
  // ── soft resonance over node gists ─────────────────────────────────────
309
- /** The k nodes whose gist resonates most with v. */
310
- resonate(v: Vec, k: number): Promise<Hit[]>;
309
+ /** The k nodes whose gist resonates most with v. `exhaustive` widens the
310
+ * IVF probe to every cluster (see {@link AbstractStore.efFor}'s doc)
311
+ * for refusal-path-only callers where an approximate top-√C-clusters
312
+ * search is not the same discriminator as the caller's own byte-exact
313
+ * verification (the substitution bridge's proposal channel): a rarer
314
+ * paraphrase can score lower than hundreds of unrelated hits by pure
315
+ * fold-geometry structural distance (a middle-of-string mismatch
316
+ * perturbs the tree hash far more than a tail mismatch of the same
317
+ * byte length) and so never even reach a probed cluster, no matter how
318
+ * large k is — k only reorders WITHIN the clusters already probed. */
319
+ resonate(v: Vec, k: number, exhaustive?: boolean): Promise<Hit[]>;
311
320
  /** Mark a node as a RESONANCE TARGET — promote its gist into the content
312
321
  * index so {@link resonate} can find it. A node's gist is captured at intern
313
322
  * but indexed LAZILY (only targets are indexed; the ~99.5% intermediate DAG
@@ -1636,7 +1645,7 @@ export abstract class AbstractStore implements Store {
1636
1645
 
1637
1646
  // ── Soft resonance ─────────────────────────────────────────────────────
1638
1647
 
1639
- async resonate(v: Vec, k: number): Promise<Hit[]> {
1648
+ async resonate(v: Vec, k: number, exhaustive = false): Promise<Hit[]> {
1640
1649
  await this._ensureReady();
1641
1650
  // Synchronous flush of any buffered index writes: the FIRST resonance
1642
1651
  // after a large ingest pays that flush here, so it shows up in respond
@@ -1649,17 +1658,22 @@ export abstract class AbstractStore implements Store {
1649
1658
  // same values still hits. Lazy-init: null after any index write; the
1650
1659
  // first miss after a flush recreates it. When voteRegions resonates
1651
1660
  // identical perceived sub-regions, only the first call descends the ANN.
1652
- const rk = vecKey(v) + ":" + k;
1661
+ const rk = vecKey(v) + ":" + k + (exhaustive ? ":x" : "");
1653
1662
  const cache = this._resonateCache;
1654
1663
  if (cache) {
1655
1664
  const hit = cache.get(rk);
1656
1665
  if (hit !== undefined) return hit;
1657
1666
  }
1658
1667
 
1668
+ const clusters = this._vecContentClusterCount();
1659
1669
  const results = this._vecContentQuery(
1660
1670
  normalize(copy(v)),
1661
1671
  k * this.overfetch,
1662
- this.efFor(this._vecContentClusterCount()),
1672
+ // Exhaustive: probe every cluster (ef ≥ 4·clusters guarantees the
1673
+ // IVF's own ef→nprobe=ceil(ef/4) mapping reaches all of them) — the
1674
+ // natural ceiling for a search that is ALREADY refusal-path-only and
1675
+ // must not miss a candidate hiding in an unprobed cluster.
1676
+ exhaustive ? 4 * clusters : this.efFor(clusters),
1663
1677
  );
1664
1678
  const out: Hit[] = [];
1665
1679
  for (const r of results) {
@@ -0,0 +1,128 @@
1
+ // 36-already-answered-fusion.test.mjs — a point of attention whose own
2
+ // learnt continuation is ALREADY present later in the query must not be
3
+ // fused in again.
4
+ //
5
+ // This is Category A of a two-part defect found while investigating a
6
+ // multi-turn dialogue's garbled fusion ("Thank you very much!" fused with
7
+ // unrelated points of attention). One of those points (root "Hello",
8
+ // anchor of the greeting) traced back to substantial, genuine regions —
9
+ // not noise — and its learnt continuation ("Hello! How can I assist you
10
+ // today?") was found to be VERBATIM present earlier in the query, because
11
+ // it was Sema's own prior reply, appended by addTurn the same way any turn
12
+ // is appended. Re-surfacing it is redundant: the query has already spoken
13
+ // its own answer.
14
+ //
15
+ // Deliberately turn-agnostic and Mind-bookkeeping-free: Mind's multi-turn
16
+ // API is strictly a computational optimization (incremental fold reuse) —
17
+ // it must never be the thing inference depends on for correctness. This
18
+ // check uses only what already exists for ANY accumulated byte stream,
19
+ // single-shot or multi-turn: `follow()` (the same content-addressed
20
+ // continuation walk `reason()`'s own echo guard already uses,
21
+ // `ctx.store.prevCount(qId) > 0`, just applied per-candidate-root instead
22
+ // of to the whole query) and plain byte containment. A query that embeds
23
+ // its own prior exchange — via real respondTurn(), or a caller manually
24
+ // pasting a transcript into one respond() call — is caught identically.
25
+ //
26
+ // (Category B — coincidental echo of a short, generic CURRENT-turn phrase
27
+ // against unrelated corpus content, unrelated to staleness — is a SEPARATE
28
+ // defect, not addressed here; see the investigation notes for why breadth
29
+ // and regionSupport both fail to discriminate it from genuine fusion.)
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 { fuseAttention } from "../dist/src/mind/reasoning.js";
36
+ import { gistOf } from "../dist/src/mind/primitives.js";
37
+
38
+ const enc = (s) => new TextEncoder().encode(s);
39
+ const dec = (b) =>
40
+ new TextDecoder().decode(b.filter((x) => x !== 0)).replace(/\s+/g, " ")
41
+ .trim();
42
+
43
+ // "greet" leads to "reply-greet" — a learnt exchange the query embeds BOTH
44
+ // halves of, exactly the shape addTurn produces (ask, then the system's own
45
+ // reply, concatenated raw). "red circle" is a genuine, unrelated second
46
+ // topic (test/34's own binding corpus) whose own continuation is nowhere
47
+ // in the query — the ordinary multi-topic case, which must still fuse.
48
+ const CORPUS = [
49
+ ["greet", "reply-greet"],
50
+ ["red", "is a color"],
51
+ ["blue", "is a color"],
52
+ ["circle", "is a shape"],
53
+ ["square", "is a shape"],
54
+ ["red circle", "answer alpha"],
55
+ ["red square", "answer beta"],
56
+ ["blue circle", "answer gamma"],
57
+ ["blue square", "answer delta"],
58
+ ];
59
+
60
+ const mk = (seed) =>
61
+ new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
62
+
63
+ test("fuseAttention: a root whose continuation is already answered in the query is excluded", async () => {
64
+ const m = mk(1);
65
+ await m.ingest(CORPUS);
66
+ // "greet"'s own continuation, "reply-greet", is embedded right in the
67
+ // query — the ask and its answer both present, exactly like a
68
+ // conversation's own turn + reply. "red circle" is a genuine further
69
+ // topic with no such embedded answer.
70
+ const q = enc("greet reply-greet then red then circle");
71
+ const roots = await m.climbAttention(q, 24);
72
+ const greet = roots.find((r) => r.start === 0);
73
+ const redCircle = roots.find((r) => r.start !== 0);
74
+ assert.ok(greet, "expected the 'greet' root at query offset 0");
75
+ assert.ok(redCircle, "expected the 'red circle' binding root");
76
+
77
+ const guide = gistOf(m, q);
78
+ const primary = enc("PRIMARYANCHORTEXT");
79
+ // forest[0] is never independently projected (see reasoning.ts: it is
80
+ // treated as already primary's own source) — a placeholder keeps BOTH
81
+ // real roots in `rest`, where this filter actually applies.
82
+ const placeholder = { ...greet, start: q.length, end: q.length };
83
+ const pre = {
84
+ attention: async () => ({
85
+ roots: [placeholder, greet, redCircle],
86
+ ranked: [placeholder, greet, redCircle],
87
+ }),
88
+ guide,
89
+ };
90
+ const out = dec(await fuseAttention(m, q, primary, pre));
91
+ assert.ok(
92
+ !out.includes("reply-greet"),
93
+ `a root whose continuation is already answered in the query must not fuse in, got "${out}"`,
94
+ );
95
+ assert.ok(
96
+ out.includes("answer alpha"),
97
+ `a genuine further topic with no embedded answer must still fuse in, got "${out}"`,
98
+ );
99
+ await m.store.close();
100
+ });
101
+
102
+ test("fuseAttention: ordinary multi-topic fusion (no embedded answers) is completely unaffected", async () => {
103
+ const m = mk(1);
104
+ await m.ingest(CORPUS);
105
+ const q = enc("red then circle");
106
+ const roots = await m.climbAttention(q, 24);
107
+ assert.equal(roots.length, 1, "expected the single joint binding root");
108
+ // Force a second, independent (unclimbed-style) inclusion path by
109
+ // reusing the SAME root twice at different synthetic positions, neither
110
+ // of which has an embedded answer — a pure regression check that the
111
+ // new filter doesn't fire when there is nothing to catch.
112
+ const guide = gistOf(m, q);
113
+ const primary = enc("PRIMARYANCHORTEXT");
114
+ const placeholder = { ...roots[0], start: q.length, end: q.length };
115
+ const pre = {
116
+ attention: async () => ({
117
+ roots: [placeholder, roots[0]],
118
+ ranked: [placeholder, roots[0]],
119
+ }),
120
+ guide,
121
+ };
122
+ const out = dec(await fuseAttention(m, q, primary, pre));
123
+ assert.ok(
124
+ out.includes("answer alpha"),
125
+ `an ordinary further topic must still fuse in, got "${out}"`,
126
+ );
127
+ await m.store.close();
128
+ });