@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
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://jsr.io/schema/config-file.v1.json",
3
3
  "name": "@hviana/sema",
4
- "version": "0.5.8",
4
+ "version": "0.5.9",
5
5
  "exports": "./src/index.ts"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
package/src/geometry.ts CHANGED
@@ -132,6 +132,29 @@ export function dominates(partLen: number, wholeLen: number): boolean {
132
132
  * recallByResonance trusting a climb anchor, and commitVotes admitting a
133
133
  * further point of attention. Defined once here so the two can never
134
134
  * drift apart. Derived from N, never tuned. */
135
+ /** SUPERPOSITION CAPACITY — how many quasi-orthogonal terms one vector can
136
+ * carry before an individual term stops being readable. `√D`.
137
+ *
138
+ * A superposition of m unit signatures has ‖acc‖² ≈ m, so one term's
139
+ * contribution to any cosine taken against that vector is ≈ 1/m. Setting
140
+ * that against the representation's own floor {@link estimatorNoise} = 1/√D:
141
+ *
142
+ * 1/m < 1/√D ⟺ m > √D
143
+ *
144
+ * Past √D terms a single shared constituent can no longer move a halo cosine
145
+ * above quantisation noise — and because the result is normalized, each extra
146
+ * term also shrinks every ALREADY-accepted term toward that floor. So this is
147
+ * not a budget that trades accuracy for time: beyond capacity, more evidence
148
+ * makes the representation strictly worse. It composes with
149
+ * {@link significanceBar} (3/√D) as it should — three shared units of √D is
150
+ * exactly the significance bar.
151
+ *
152
+ * Consumer: `companyProfile` (mind/learning.ts), which sizes its constituent
153
+ * sketch at this capacity instead of a visit budget. */
154
+ export function profileCapacity(D: number): number {
155
+ return Math.max(1, Math.floor(Math.sqrt(D)));
156
+ }
157
+
135
158
  export function consensusFloor(N: number): number {
136
159
  return Math.log(N) + 1 / 2;
137
160
  }
@@ -193,6 +193,12 @@ export interface ConsensusAnchorTrace {
193
193
  passesNaturalBreak?: boolean;
194
194
  passesConsensusFloor?: boolean;
195
195
  pastLeadingSaturation?: boolean;
196
+ /** Committed because its margin from the dominant is inside the estimator's
197
+ * own resolution — the co-dominant band in {@link commitVotes}. Recorded
198
+ * because commit decisions are kept in the exact shape the gates applied
199
+ * them: a root admitted this way must never read, in the trace, as one
200
+ * that cleared the two vote gates. */
201
+ tiedWithDominant?: boolean;
196
202
  rejectionReasons: AnchorRejectionReason[];
197
203
  };
198
204
  }
@@ -1703,6 +1709,7 @@ export function commitVotes(
1703
1709
  passesNaturalBreak: boolean | undefined,
1704
1710
  passesConsensusFloor: boolean | undefined,
1705
1711
  pastLeadingSaturation: boolean | undefined,
1712
+ tiedWithDominant: boolean | undefined,
1706
1713
  rejectionReasons: AnchorRejectionReason[],
1707
1714
  ) => {
1708
1715
  if (!td) return;
@@ -1723,6 +1730,7 @@ export function commitVotes(
1723
1730
  passesNaturalBreak,
1724
1731
  passesConsensusFloor,
1725
1732
  pastLeadingSaturation,
1733
+ tiedWithDominant,
1726
1734
  rejectionReasons,
1727
1735
  },
1728
1736
  });
@@ -1738,6 +1746,7 @@ export function commitVotes(
1738
1746
  let passesNaturalBreak: boolean | undefined;
1739
1747
  let passesConsensusFloor: boolean | undefined;
1740
1748
  let pastLeadingSaturation: boolean | undefined;
1749
+ let tiedWithDominant: boolean | undefined;
1741
1750
  const rejectionReasons: AnchorRejectionReason[] = [];
1742
1751
  if (absorbed) {
1743
1752
  status = "overlap";
@@ -1760,7 +1769,49 @@ export function commitVotes(
1760
1769
  } else {
1761
1770
  passesNaturalBreak = vote >= rootCut;
1762
1771
  passesConsensusFloor = vote >= floor;
1763
- if (passesNaturalBreak && passesConsensusFloor && pastLeading) {
1772
+ // CO-DOMINANT an anchor the estimator cannot separate from the
1773
+ // dominant inherits the dominant's exemption, because that exemption's
1774
+ // only warrant is being TOP, and "top" is not a fact about the corpus
1775
+ // when the ordering moves with the seed.
1776
+ //
1777
+ // The dominant bypasses both vote gates ("it always grounds"); the
1778
+ // runner-up is held to an absolute ln(N)+1/2 floor the dominant never
1779
+ // had to clear. Which of them gets the exemption is then decided by a
1780
+ // sort over ESTIMATED quantities. Measured on test/29 D1's corpus,
1781
+ // 60 seeds per D — true separation 0.54s / 0.75s / 1.04s:
1782
+ //
1783
+ // D s=1/sqrt(D) vote SD (estimated anchor) SD/s flips
1784
+ // 256 0.0625 0.0561 0.90 19/60
1785
+ // 1024 0.0313 0.0268 0.86 12/60
1786
+ // 4096 0.0156 0.0074 0.48 2/60
1787
+ //
1788
+ // The SD tracks 1/sqrt(D) and the flip rate collapses with it, so the
1789
+ // reordering is the ESTIMATOR's, not the corpus's. The loser was then
1790
+ // refused by a floor at 1.599 that neither anchor could ever reach
1791
+ // (corpusN 3) — a coin flip decided which single structure the query
1792
+ // was allowed to have settled on.
1793
+ //
1794
+ // THE BAND IS sqrt(k)*s, NOT s. A vote is a SUM over the anchor's own
1795
+ // contributing regions, so its noise grows as sqrt(k); pricing a summed
1796
+ // margin against one s would be the category error chooseNext's comment
1797
+ // warns about. k is `regionAxioms`, already in hand; s is
1798
+ // `estimatorNoise(D)`, already derived. No constant is introduced.
1799
+ // Verified conservative: measured SD/(sqrt(k)*s) never exceeded 0.72.
1800
+ //
1801
+ // BOUNDED BY CONSTRUCTION: admission requires indistinguishability from
1802
+ // an anchor ALREADY admitted, so it can only admit what the ordinary
1803
+ // rule would have admitted had the noise fallen the other way. It is
1804
+ // N-independent for the same reason — a statement about the estimator,
1805
+ // not about corpus size.
1806
+ const tieBand = Math.sqrt(
1807
+ Math.max(1, regionAxioms.get(point.anchor) ?? 1),
1808
+ ) * estimatorNoise(ctx.store.D);
1809
+ const dominantVote = votesIdf.get(roots[0].anchor) ?? 0;
1810
+ tiedWithDominant = dominantVote - vote < tieBand;
1811
+ if (
1812
+ ((passesNaturalBreak && passesConsensusFloor) || tiedWithDominant) &&
1813
+ pastLeading
1814
+ ) {
1764
1815
  status = "root";
1765
1816
  } else {
1766
1817
  status = "rejected";
@@ -1782,6 +1833,7 @@ export function commitVotes(
1782
1833
  passesNaturalBreak,
1783
1834
  passesConsensusFloor,
1784
1835
  pastLeadingSaturation,
1836
+ tiedWithDominant,
1785
1837
  rejectionReasons,
1786
1838
  );
1787
1839
  continue;
@@ -1795,6 +1847,7 @@ export function commitVotes(
1795
1847
  passesNaturalBreak,
1796
1848
  passesConsensusFloor,
1797
1849
  pastLeadingSaturation,
1850
+ tiedWithDominant,
1798
1851
  rejectionReasons,
1799
1852
  );
1800
1853
  placed.push(point);
@@ -15,8 +15,9 @@ import {
15
15
  resolve,
16
16
  } from "./primitives.js";
17
17
  import { canonicalWindows, leafIdPrefix } from "./canonical.js";
18
+ import { rItem, rNode } from "./trace.js";
18
19
  import { hubBound } from "./traverse.js";
19
- import { dominates } from "../geometry.js";
20
+ import { dominates, estimatorNoise, profileCapacity } from "../geometry.js";
20
21
  import { fold as foldVecs } from "../sema.js";
21
22
 
22
23
  /** Intern a perceived tree into node ids, bottom-up, sharing equal subtrees.
@@ -235,14 +236,90 @@ export interface DepositReport {
235
236
  continuationId?: number;
236
237
  }
237
238
 
238
- /** How many constituents one profile may VISIT. A partner's constituent tree
239
- * is O(len/W) nodes, so an uncapped descent would make a pour cost grow with
240
- * the partner's LENGTH — and a partner is a whole deposit, which may be a
241
- * paragraph. The budget is what keeps a pour O(1) in the input, the property
242
- * that lets {@link companyProfile} claim no new cost class. It binds only on
243
- * long partners whose constituents are all corpus-unique; the descent's own
244
- * stop rule (below) reaches recurring units far sooner on a trained store. */
245
- const PROFILE_VISITS = 64;
239
+ /** Deterministic priority of a node for bottom-k selection a fixed integer
240
+ * mix of the node id, NOT a function of the config seed.
241
+ *
242
+ * Seed-independence is the point: the sketch is a property of the STORE, so
243
+ * two Minds over one store must agree on it, and a rebuilt sketch must match
244
+ * a stored one. (Contrast {@link companySignature}, which is seeded that is
245
+ * the VECTOR, this is only the CHOICE of which vectors to superpose.)
246
+ *
247
+ * Selecting the k smallest priorities makes the sketch a bottom-k sample keyed
248
+ * on each constituent's own identity, so a unit shared by two partners is kept
249
+ * by BOTH or neither, whatever its depth or position in either fold. That is
250
+ * what removes the traversal-order dependence a visit budget necessarily had. */
251
+ function unitPriority(id: number): number {
252
+ let h = (id ^ 0x9e3779b9) >>> 0;
253
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0;
254
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
255
+ return (h ^ (h >>> 16)) >>> 0;
256
+ }
257
+
258
+ /** Whether `n` is a MINIMAL UNIT: a stored branch, at least one fold window
259
+ * wide, no constituent of its own at or above W. Every clause is INTRINSIC —
260
+ * a property of the node's own subtree — which is what lets a sketch be stored
261
+ * once and stay valid as the corpus grows. The two corpus-relative readings
262
+ * (half-dominance of the PARTNER, and the hub test) are deliberately excluded
263
+ * and applied by {@link companyProfile} at pour time. */
264
+ function isMinimalUnit(ctx: MindContext, n: number, W: number): boolean {
265
+ if (n < 0) return false; // byte atom — fan-in is the alphabet's
266
+ const kids = ctx.store.get(n)?.kids;
267
+ if (kids == null) return false; // stored kid-less node: also an atom
268
+ if (ctx.store.contentLen(n, W) < W) return false;
269
+ for (const kid of kids) {
270
+ if (kid >= 0 && ctx.store.contentLen(kid, W) >= W) return false; // composite
271
+ }
272
+ return true;
273
+ }
274
+
275
+ /** The BOTTOM-K CONSTITUENT SKETCH of a node: the `k = profileCapacity(D)`
276
+ * minimal units of its subtree with the smallest {@link unitPriority}.
277
+ *
278
+ * COMPOSABLE, WHICH IS WHY IT COSTS NOTHING TWICE. Bottom-k of a union is
279
+ * the bottom-k of the children's bottom-k sets, so a node's sketch is built
280
+ * from its kids' sketches and each recursive result is stored on the way out.
281
+ * A partner met again reads O(k); an accumulated conversation, where turn k's
282
+ * context is a prefix of turn k+1's, reuses every unchanged child and pays
283
+ * O(changed) instead of O(context) — the quadratic that made a visit budget
284
+ * look necessary in the first place.
285
+ *
286
+ * It is DURABLE DERIVED STATE, not a cache (see Store.sketchGet): a miss must
287
+ * cost time only, and this decides which terms enter a halo. A backend
288
+ * without the capability recomputes per pour and loses only the amortisation.
289
+ *
290
+ * Recursion depth is the fold's, O(log_W len), and each level does O(k·arity)
291
+ * work, so construction is one pass over the subtree — the same pass the
292
+ * deposit that interned it already performed. */
293
+ function constituentSketch(ctx: MindContext, id: number, k: number): number[] {
294
+ const stored = ctx.store.sketchGet?.(id);
295
+ if (stored != null) return stored; // [] is a real answer; null is "unknown"
296
+ const W = ctx.space.maxGroup;
297
+ const kids = id < 0 ? null : ctx.store.get(id)?.kids;
298
+ let out: number[];
299
+ if (kids == null) {
300
+ out = [];
301
+ } else {
302
+ const pool: number[] = [];
303
+ for (const kid of kids) {
304
+ if (isMinimalUnit(ctx, kid, W)) pool.push(kid);
305
+ else if (kid >= 0) {
306
+ for (const g of constituentSketch(ctx, kid, k)) pool.push(g);
307
+ }
308
+ }
309
+ // Bottom-k by identity, then by id so ties are corpus-determined (§2.1).
310
+ pool.sort((a, b) => (unitPriority(a) - unitPriority(b)) || (a - b));
311
+ const seen = new Set<number>();
312
+ out = [];
313
+ for (const n of pool) {
314
+ if (seen.has(n)) continue;
315
+ seen.add(n);
316
+ out.push(n);
317
+ if (out.length >= k) break;
318
+ }
319
+ }
320
+ ctx.store.sketchPut?.(id, out);
321
+ return out;
322
+ }
246
323
 
247
324
  /** The COMPANY PROFILE of a partner: its own identity signature superposed
248
325
  * with the signatures of its RECURRING content-defined constituents.
@@ -343,45 +420,62 @@ function companyProfile(ctx: MindContext, id: number): Vec {
343
420
  const acc = zeros(ctx.space.D);
344
421
  addInto(acc, companySignature(ctx.space, id));
345
422
  const bound = hubBound(ctx);
346
- const W = ctx.space.maxGroup;
423
+ const k = profileCapacity(ctx.space.D);
347
424
  const whole = Math.max(1, ctx.store.contentLen(id));
348
- const frontier: number[] = [];
349
- const seen = new Set<number>([id]);
350
- const descend = (n: number) => {
351
- const kids = ctx.store.get(n)?.kids;
352
- if (!kids) return;
353
- for (const kid of kids) if (!seen.has(kid)) frontier.push(kid);
354
- };
355
- descend(id);
356
- for (let visits = 0; visits < PROFILE_VISITS && frontier.length > 0;) {
357
- const n = frontier.shift()!;
358
- if (seen.has(n)) continue;
359
- seen.add(n);
360
- visits++;
361
- // Atoms in both representations — negative id, or a stored kid-less node.
362
- if (n < 0 || ctx.store.get(n)?.kids == null) continue;
363
- descend(n);
425
+ const sketch = constituentSketch(ctx, id, k);
426
+
427
+ // The two CORPUS-RELATIVE readings, applied here and never stored: which
428
+ // terms count as scaffolding moves as N grows, which is the drift documented
429
+ // above, while the sketch itself must stay intrinsic to remain valid.
430
+ let accepted = 0, hubDropped = 0, dominating = 0;
431
+ for (const n of sketch) {
364
432
  const len = ctx.store.contentLen(n, whole);
365
- if (len < W || dominates(len, whole)) continue;
366
- // MINIMAL units only: a constituent that still has a constituent of its
367
- // own at or above W is a composite, and superposing it as well as its
368
- // parts would count the same content twice. Nested partners — an
369
- // accumulated conversation, where turn k's context is a prefix of turn
370
- // k+1's — share their large chunks structurally rather than
371
- // distributionally, so those composites are exactly the terms that make
372
- // adjacent turns read as synonyms (measured: consecutive turns at 0.809
373
- // and 0.740 against a 0.516 concept threshold). The smallest units at or
374
- // above the fold's own window are the word-sized types company should be
375
- // keyed at.
376
- const kids = ctx.store.get(n)!.kids!;
377
- let composite = false;
378
- for (const kid of kids) {
379
- if (kid >= 0 && ctx.store.contentLen(kid, W) >= W) composite = true;
433
+ if (dominates(len, whole)) {
434
+ dominating++;
435
+ continue;
436
+ }
437
+ if (ctx.store.parentsFirst(n, bound + 1).length > bound) {
438
+ hubDropped++;
439
+ continue;
380
440
  }
381
- if (composite) continue;
382
- if (ctx.store.parentsFirst(n, bound + 1).length > bound) continue;
383
441
  addInto(acc, companySignature(ctx.space, n));
442
+ accepted++;
384
443
  }
444
+
445
+ // FALSIFIABILITY. The claim this function makes is that it stops because the
446
+ // representation is FULL, never because a budget ran out — so the diagnostics
447
+ // report the capacity, the mass actually reached, and what the frontier still
448
+ // held. `residual` is the evidence NOT superposed; `marginal` is what one
449
+ // more term would have contributed to a downstream cosine (1/mass), and
450
+ // `saturated` says whether that had fallen to or below `noiseFloor`. A run
451
+ // that reports `saturated: false` with `residual > 0` is this design being
452
+ // WRONG, not tuning: it would mean readable evidence was dropped.
453
+ const mass = accepted + 1; // the node's own signature counts
454
+ const marginal = 1 / mass;
455
+ const noiseFloor = estimatorNoise(ctx.space.D);
456
+ ctx.trace?.step(
457
+ "companyProfile",
458
+ [rNode(ctx, id, "partner")],
459
+ [rItem(new Uint8Array(0), "profile", id)],
460
+ `superposed ${accepted} of ${sketch.length} sketched constituents ` +
461
+ `(capacity ${k}); marginal ${marginal.toFixed(4)} vs noise floor ` +
462
+ `${noiseFloor.toFixed(4)}`,
463
+ undefined,
464
+ {
465
+ capacity: k,
466
+ sketched: sketch.length,
467
+ accepted,
468
+ hubDropped,
469
+ dominating,
470
+ residual: sketch.length - accepted,
471
+ mass,
472
+ marginal,
473
+ noiseFloor,
474
+ saturated: sketch.length >= k,
475
+ stopReason: sketch.length >= k ? "capacity" : "constituents-exhausted",
476
+ wholeLen: whole,
477
+ },
478
+ );
385
479
  return normalize(acc);
386
480
  }
387
481
 
@@ -358,6 +358,38 @@ export async function counterfactualTransfer(
358
358
  }
359
359
  }
360
360
  const isRoot = (id: number) => roots.some((r) => r.anchor === id);
361
+ // VOICEABLE — a structure whose own learnt content a schema may SPEAK.
362
+ //
363
+ // The gate below asks only that the weave TOUCH a committed point. That is
364
+ // the right question for MEMBERSHIP — a weave needs uncommitted structure to
365
+ // compare against; that is what an analogy IS — and the wrong one for
366
+ // VOICING: satisfied by any committed bystander, it lets every OTHER aligned
367
+ // point put its own learnt content into the answer while a root that
368
+ // contributed nothing holds the door open. The refusal note below already
369
+ // states the principle — "CAST refuses to transfer through content the climb
370
+ // itself never settled on" — it was simply never asked of the structure a
371
+ // schema actually transfers THROUGH.
372
+ //
373
+ // Measured on a two-hop question over dialogue filler (N ~ 103,
374
+ // consensusFloor 5.13): the climb committed ONE root at vote 8.13, and
375
+ // substitution then voiced a filler deposit at vote 0.15 together with a
376
+ // second structure at 0.57 — neither committed, both an order of magnitude
377
+ // below the floor, while the licensing root supplied no bytes at all.
378
+ //
379
+ // OR THE QUERY NAMED IT. Commitment is not the only warrant: a structure the
380
+ // asker QUOTED is content the query did ask about, whoever the climb settled
381
+ // on. The naming test is the one redirection's own `named` list uses — an
382
+ // aligned run starting at the structure's OPENING bytes (`cs === 0`) — and
383
+ // NOT merely "has an aligned run", which every weave point has by
384
+ // construction. Without this disjunct the gate refuses test/29 B3 ("what if
385
+ // the capital of France were Lyon?" must answer about Lyon), where the
386
+ // substitute is named outright and the climb never commits it. This mirrors
387
+ // the pairing the comparison gate already makes with
388
+ // `!rootTrusted && !namedByQuery`.
389
+ const namedFromOpening = (p: Point): boolean =>
390
+ p.runs.some((r) => r.cs === 0 && usable(r.qs, r.qe));
391
+ const voiceable = (p: Point): boolean =>
392
+ isRoot(p.anchor) || namedFromOpening(p);
361
393
  // The weave must touch a COMMITTED point of attention: the dominant
362
394
  // structure itself, or another aligned point the climb committed to.
363
395
  if (!points.some((p) => isRoot(p.anchor))) {
@@ -540,6 +572,14 @@ export async function counterfactualTransfer(
540
572
  if (r.cs < quantum || !usable(r.qs, r.qe)) {
541
573
  return null;
542
574
  }
575
+ // The DISPLACED STRUCTURE is what this schema speaks — the answer is its
576
+ // tail past the seat plus its own continuation — so it must be
577
+ // voiceable. Filtered HERE rather than after the argmax so an eligible
578
+ // structure with less depth still fires the schema, instead of an
579
+ // ineligible deepest candidate suppressing it outright. The SUBJECT is
580
+ // deliberately not gated: `fillerOf` reads the QUERY's own bytes for it,
581
+ // so it contributes what the asker already said, not learnt content.
582
+ if (!voiceable(p)) return null;
543
583
  const before = beforeOf(p, r);
544
584
  if (before === undefined) return null;
545
585
  if (r.cs > fillerOf(before.point, before.run).length + quantum) {
@@ -640,7 +680,14 @@ export async function counterfactualTransfer(
640
680
  const domNext = ctx.store.nextFirst(dominant.anchor, hubBound(ctx));
641
681
  const displaced = domNext
642
682
  .every((n) => indexOf(query, read(ctx, n), 0) < 0);
643
- if (last !== undefined && last.point !== dominant && displaced) {
683
+ // The SUBSTITUTE is what redirection speaks the answer IS `project(last)`,
684
+ // its own fact — so the same bar applies. The displaced structure is only
685
+ // recognised as the slot being overridden and is never voiced, so it is
686
+ // deliberately not gated here.
687
+ if (
688
+ last !== undefined && last.point !== dominant && displaced &&
689
+ voiceable(last.point)
690
+ ) {
644
691
  const g = await project(ctx, last.point.anchor, qv);
645
692
  if (g !== null) {
646
693
  ctx.trace?.step(
package/src/mind/mind.ts CHANGED
@@ -1011,10 +1011,21 @@ export class Mind implements MindContext {
1011
1011
  input: Input | (Input | [Input, Input])[],
1012
1012
  second?: Input,
1013
1013
  onDeposit?: (report: import("./learning.js").DepositReport) => void,
1014
+ /** Witness the DEPOSIT path the way {@link respond}'s callback witnesses
1015
+ * inference — `companyProfile` reports its saturation diagnostics here.
1016
+ * Without it the tracer is never constructed and the emit sites cost
1017
+ * nothing (§ rationale.ts), exactly as on the inference path. */
1018
+ inspectRationale?: InspectRationale,
1014
1019
  ): Promise<(Sema & { id: number }) | undefined> {
1015
1020
  invalidateStructuralCaches(this);
1016
1021
  invalidateJunctionCache(this);
1017
- return ingest(this, input, second, onDeposit);
1022
+ const prevTrace = this.trace;
1023
+ this.trace = inspectRationale ? new Rationale(inspectRationale) : null;
1024
+ try {
1025
+ return await ingest(this, input, second, onDeposit);
1026
+ } finally {
1027
+ this.trace = prevTrace;
1028
+ }
1018
1029
  }
1019
1030
 
1020
1031
  // ── Extension Surface ────────────────────────────────────────────────────
@@ -52,7 +52,6 @@ export async function reason(
52
52
  const qId = pre.queryResolved;
53
53
  if (qId !== null && ctx.store.prevCount(qId) > 0) return answer;
54
54
 
55
- const consumed = new Set<number>();
56
55
  // Consume a node and its neighbours for pivot-cycle prevention — CAPPED at
57
56
  // the hub bound, via the store's LIMITed edge reads: a common continuation's
58
57
  // reverse fan-in (and a hub context's forward fan-out) is corpus-sized, and
@@ -61,10 +60,68 @@ export async function reason(
61
60
  // read order); a pivot suppressed only by a beyond-cap neighbour may now
62
61
  // fire — the same visibility trade chooseNext documents.
63
62
  const bound = hubBound(ctx);
64
- const consumeNode = (id: number | null) => {
63
+
64
+ // ANSWERED DIRECTLY — the echo guard's other half, and the same principle:
65
+ // the QUERY's own position in the graph, not the answer's content, says the
66
+ // read-out is complete. Above: the query is itself a learnt CONTINUATION.
67
+ // Here: the query is a learnt CONTEXT and the grounded answer is one of ITS
68
+ // OWN continuations. Either way the question was answered directly and there
69
+ // is nothing left to chain for.
70
+ //
71
+ // Every stopping condition in the loop below judges the ANSWER (`consumed` /
72
+ // `restatesQuery` / `bytesEqual`); none asks whether the QUESTION was
73
+ // satisfied. So a single-hop question whose answer happens to name another
74
+ // learnt context extends past a correct answer and REPLACES it:
75
+ //
76
+ // asked "<subj> father"
77
+ // hop 1 "The father of <subj> is Ernest I of Anhalt-Dessau." <- correct
78
+ // pivot "Ernest I of Anhalt-Dessau" <- a learnt context too
79
+ // got "The date of death of Ernest I of Anhalt-Dessau is 12 June 1516."
80
+ //
81
+ // Any store holding a bare-entity context alongside a relation fact has that
82
+ // shape; it is not exotic.
83
+ //
84
+ // Checked ONCE, before the loop, and ahead of BOTH extension branches:
85
+ // `absorbForward` extends the answer too, and nothing about the defect is
86
+ // specific to pivoting, so a guard between them would gate one arbitrary half
87
+ // of the same step. Hop 0 is also the only hop at which the question can be
88
+ // answered directly at all — after a hop, `cur` is no longer the query's own
89
+ // continuation, so re-testing per hop could only cost reads.
90
+ //
91
+ // Read from the ANSWER's side (`prevFirst`) rather than the query's
92
+ // (`nextFirst`). Same relation, but a CONTEXT's fan-out is hub-sized while
93
+ // this is one answer's establishing-context fan-in. Both the resolve and the
94
+ // reverse read are exactly what hop 0 of the loop below would perform, so
95
+ // they are computed ONCE here and handed down (`groundedId`, `groundedPrev`)
96
+ // — the guard then costs nothing when it does not fire. Stated because the
97
+ // naive placement does NOT: `resolve` re-folds the answer bytes on every call
98
+ // (no memo) and `prevFirst` is a direct read (no memo), so a guard that
99
+ // recomputed them would add one fold plus one √N-bounded read per ask.
100
+ // The √N cap carries the file-wide visibility trade, and fails SAFE in the
101
+ // direction that matters: a missed guard costs an over-extended answer, never
102
+ // a suppressed chain.
103
+ //
104
+ // A genuine multi-hop query is not a deposited context at all ("What is the
105
+ // capital of the country of Eiffel Tower?" resolves to nothing), so this can
106
+ // never gate a real chain.
107
+ const groundedId = resolve(ctx, answer);
108
+ const groundedPrev = groundedId === null
109
+ ? null
110
+ : ctx.store.prevFirst(groundedId, bound);
111
+ if (qId !== null && groundedPrev !== null && groundedPrev.includes(qId)) {
112
+ return answer;
113
+ }
114
+
115
+ const consumed = new Set<number>();
116
+ /** `prev` lets a caller hand in an already-read reverse-edge list — hop 0
117
+ * reuses the guard's, above, instead of re-reading it. */
118
+ const consumeNode = (
119
+ id: number | null,
120
+ prev?: readonly number[],
121
+ ) => {
65
122
  if (id === null) return;
66
123
  consumed.add(id);
67
- for (const p of ctx.store.prevFirst(id, bound)) consumed.add(p);
124
+ for (const p of prev ?? ctx.store.prevFirst(id, bound)) consumed.add(p);
68
125
  };
69
126
  const consumeAll = (id: number | null) => {
70
127
  if (id === null) return;
@@ -99,8 +156,10 @@ export async function reason(
99
156
  let t: ReturnType<Rationale["enter"]> | undefined;
100
157
  const startedFrom = answer;
101
158
  for (let hop = 0; hop < ctx.cfg.recallQueryK; hop++) {
102
- const curId = resolve(ctx, cur);
103
- consumeNode(curId);
159
+ // Hop 0's `cur` IS `answer`, so the guard above already resolved it and
160
+ // read its reverse edges — reuse both rather than repeat them.
161
+ const curId = hop === 0 ? groundedId : resolve(ctx, cur);
162
+ consumeNode(curId, hop === 0 ? groundedPrev ?? undefined : undefined);
104
163
 
105
164
  // Forward-absorb: follow only UNCONSUMED continuations. The gate below
106
165
  // checks an unconsumed edge EXISTS, but follow()'s chooseNext knows
@@ -15,7 +15,7 @@ import {
15
15
  perceive,
16
16
  resolve,
17
17
  } from "./primitives.js";
18
- import { atomIsHub, corpusN, leadsSomewhere } from "./traverse.js";
18
+ import { atomIsHub, bearsEdge, corpusN, leadsSomewhere } from "./traverse.js";
19
19
  import { chainReach, leafIdAt, leafIdRun } from "./canonical.js";
20
20
  import { canonHash } from "../canon.js";
21
21
  import { isChunk, type Sema } from "../sema.js";
@@ -594,6 +594,31 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
594
594
  // "Eiffel Tower" site vanished with it). The premise is wrong but the
595
595
  // trust it stood in for is real; a replacement signal is still open work.
596
596
  // See bench/README.md.
597
+ //
598
+ // THE REPLACEMENT SIGNAL (2026-08-13): `leadsSomewhere` on the BYTE-EXACT
599
+ // branch the chain already found. The blanket off-boundary suppression is
600
+ // a decision that CHANGES WITH CORPUS SIZE — `atomsAreHubs` flips at
601
+ // N = 4096 (atomReach = ⌈N·W/256⌉ exceeds √N there) — so a store crossing
602
+ // that point silently loses interior sites it used to have. Measured: with
603
+ // the two-hop chain deposited, `recognise("The country of Eiffel Tower is
604
+ // France.")` yields 4 sites including `France` at N = 3920 and 2 sites
605
+ // without it at N = 4227; the pivot dies with the site and multi-hop goes
606
+ // silent from there up (the trained store is N = 325,615).
607
+ //
608
+ // The honest gate is the one `emit` already applies, moved EARLIER and paid
609
+ // for with existence probes instead of a fold: `findBranch` has already
610
+ // proved these bytes are a stored branch, so the only remaining question is
611
+ // whether that branch is a deposited whole (bears an edge or a halo) or an
612
+ // interned fragment. "hi" out of "W[hi]ch" leads nowhere and is still
613
+ // suppressed; `France` bears both and is admitted. Structural, not scalar
614
+ // — no constant enters and nothing reads N, so the verdict no longer moves
615
+ // when the corpus grows.
616
+ //
617
+ // COST: `bearsEdge` is the response-MEMOISED edge probe, not the full
618
+ // `leadsSomewhere` — its uncached `hasHalo` tier took haloProbes from 922 to
619
+ // 9,144 on a nine-query battery over the trained store, which is not a price
620
+ // this pass may charge. `emit` still applies the full predicate, so this is
621
+ // a pre-filter that never widens what is admitted.
597
622
  const tryChain = (
598
623
  p: number,
599
624
  maxIds: number,
@@ -610,8 +635,9 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
610
635
  if (!nx) break;
611
636
  ids.push(nx.id);
612
637
  pos = nx.end;
613
- if (store.findBranch(ids) === null) continue;
614
- if (!boundary && atomsAreHubs) continue;
638
+ const branch = store.findBranch(ids);
639
+ if (branch === null) continue;
640
+ if (!boundary && atomsAreHubs && !bearsEdge(ctx, branch)) continue;
615
641
  const id = resolveSpan(p, pos);
616
642
  if (id === null || id === prevId) continue;
617
643
  prevId = id;
@@ -461,6 +461,25 @@ export function atomIsHub(ctx: MindContext, contextCount: number): boolean {
461
461
  return atomReach(ctx, contextCount) > boundFor(contextCount);
462
462
  }
463
463
 
464
+ /** Cached "does this node bear a continuation edge?" — the CHEAP half of
465
+ * {@link leadsSomewhere}, exported for hot paths that must PRE-FILTER a
466
+ * candidate before paying for a fold and cannot afford the halo tier.
467
+ *
468
+ * `leadsSomewhere`'s second tier (`hasHalo`) is deliberately uncached — one
469
+ * indexed point probe per candidate, which is right where candidates are
470
+ * already few. On recognition's off-boundary chain pass they are not few:
471
+ * using the full predicate there took haloProbes from 922 to 9,144 on a
472
+ * nine-query battery over the trained store. The edge tier alone is memoised
473
+ * for the response, so it is ~free, and a node bearing an edge is exactly the
474
+ * "deposited whole, not an interned fragment" claim that pass needs.
475
+ *
476
+ * Strictly NARROWER than `leadsSomewhere` — a halo-only node reads false — so
477
+ * it is sound as a pre-filter before a consumer that applies the full
478
+ * predicate, and never as a replacement for it. */
479
+ export function bearsEdge(ctx: MindContext, id: number): boolean {
480
+ return cachedHasNext(ctx, id, getStructCache(ctx));
481
+ }
482
+
464
483
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
465
484
  * The admission predicate recognition filters sites with (HOW_IT_WORKS
466
485
  * §15.3): a form that leads nowhere contributes nothing to any derivation.