@hviana/sema 0.5.7 → 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 (44) hide show
  1. package/AGENTS.md +23 -0
  2. package/DATASETS.md +159 -0
  3. package/HOW_IT_WORKS.md +74 -0
  4. package/README.md +12 -0
  5. package/dist/example/train_base.d.ts +73 -3
  6. package/dist/example/train_base.js +1000 -49
  7. package/dist/src/geometry.d.ts +20 -0
  8. package/dist/src/geometry.js +22 -0
  9. package/dist/src/mind/articulation.js +15 -2
  10. package/dist/src/mind/attention.d.ts +6 -0
  11. package/dist/src/mind/attention.js +44 -4
  12. package/dist/src/mind/learning.js +250 -3
  13. package/dist/src/mind/mechanisms/cast.js +45 -1
  14. package/dist/src/mind/mind.d.ts +6 -1
  15. package/dist/src/mind/mind.js +14 -2
  16. package/dist/src/mind/reasoning.js +59 -5
  17. package/dist/src/mind/recognition.js +29 -3
  18. package/dist/src/mind/traverse.d.ts +34 -0
  19. package/dist/src/mind/traverse.js +42 -0
  20. package/dist/src/store-sqlite.d.ts +4 -0
  21. package/dist/src/store-sqlite.js +47 -0
  22. package/dist/src/store.d.ts +7 -0
  23. package/example/train_base.ts +1193 -46
  24. package/jsr.json +1 -1
  25. package/package.json +1 -1
  26. package/src/geometry.ts +23 -0
  27. package/src/mind/articulation.ts +16 -2
  28. package/src/mind/attention.ts +54 -1
  29. package/src/mind/learning.ts +253 -4
  30. package/src/mind/mechanisms/cast.ts +48 -1
  31. package/src/mind/mind.ts +12 -1
  32. package/src/mind/reasoning.ts +64 -5
  33. package/src/mind/recognition.ts +29 -3
  34. package/src/mind/traverse.ts +48 -0
  35. package/src/store-sqlite.ts +53 -0
  36. package/src/store.ts +28 -0
  37. package/test/29-counterfactual.test.mjs +43 -6
  38. package/test/76-type-level-company.test.mjs +342 -0
  39. package/test/77-company-saturation.test.mjs +302 -0
  40. package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
  41. package/test/84-composed-answer-honesty.test.mjs +136 -0
  42. package/test/85-answered-directly.test.mjs +126 -0
  43. package/test/86-cast-voices-committed.test.mjs +164 -0
  44. 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.7",
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.7",
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
  }
@@ -7,7 +7,7 @@ import { Vec } from "../vec.js";
7
7
  import type { MindContext } from "./types.js";
8
8
  import { spliceAll } from "./types.js";
9
9
  import { recognise } from "./recognition.js";
10
- import { contains } from "./traverse.js";
10
+ import { answers, contains } from "./traverse.js";
11
11
  import { bestHaloMate } from "./match.js";
12
12
  import type { Site } from "./graph-search.js";
13
13
  import type { CandidateSpan } from "../derive/src/index.js";
@@ -75,9 +75,23 @@ export async function articulate(
75
75
  const found = bestHaloMate(ctx, h, voices, (v) => v.halo);
76
76
  if (!found) continue;
77
77
  const voice = found.item;
78
- if (voice.node === s.payload || contains(ctx, voice.node, s.payload)) {
78
+ if (
79
+ voice.node === s.payload || contains(ctx, voice.node, s.payload) ||
80
+ answers(ctx, voice.node, s.payload)
81
+ ) {
79
82
  continue;
80
83
  }
84
+ // A form spanning the WHOLE answer is not a concept inside the answer to
85
+ // revoice — substituting it discards the answer and emits the asker's own
86
+ // words back, which is what a conversational store makes tempting: an
87
+ // answer and the question it answers keep maximal company, so the whole
88
+ // answer resonates with the whole query above any concept threshold
89
+ // (measured on the CONV fixture at 0.809 against 0.516). Articulation
90
+ // splices the asker's wording INTO an answer where the same concept
91
+ // appears; when the "concept" is the entire answer there is nothing left
92
+ // of it, and "where is it kept now" comes back in place of "it hangs in
93
+ // madrid". §5's contract is re-voicing, never replacement.
94
+ if (s.start === 0 && s.end === answer.length) continue;
81
95
  substitutions.set(s.payload, voice.bytes);
82
96
  }
83
97
  if (substitutions.size === 0) {
@@ -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);
@@ -3,7 +3,7 @@
3
3
  // Learning is DEPOSITION: perceive a stream into a tree and intern every
4
4
  // node. A fact is an EDGE between node ids; recall traverses edges.
5
5
 
6
- import { Vec } from "../vec.js";
6
+ import { addInto, normalize, Vec, zeros } from "../vec.js";
7
7
  import { bindSeat, companySignature, isChunk, Sema } from "../sema.js";
8
8
  import type { Input, MindContext } from "./types.js";
9
9
  import { changedNodes } from "./types.js";
@@ -15,6 +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";
19
+ import { hubBound } from "./traverse.js";
20
+ import { dominates, estimatorNoise, profileCapacity } from "../geometry.js";
18
21
  import { fold as foldVecs } from "../sema.js";
19
22
 
20
23
  /** Intern a perceived tree into node ids, bottom-up, sharing equal subtrees.
@@ -233,6 +236,249 @@ export interface DepositReport {
233
236
  continuationId?: number;
234
237
  }
235
238
 
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
+ }
323
+
324
+ /** The COMPANY PROFILE of a partner: its own identity signature superposed
325
+ * with the signatures of its RECURRING content-defined constituents.
326
+ *
327
+ * WHY THE WHOLE-PARTNER SIGNATURE ALONE IS NOT ENOUGH. The distributional
328
+ * hypothesis is a claim about TYPES ("occurs near a city name"), but a
329
+ * signature keyed on the whole partner's node id records a TOKEN ("occurred
330
+ * near node #4711992"). Two nodes are then distributional siblings only when
331
+ * their partners are the very same node — and a content-addressed store of
332
+ * natural language almost never repeats a whole deposit (measured on the
333
+ * trained store: whole-span dedup 0.98×, i.e. effectively none). So the
334
+ * halos of genuine synonyms came out quasi-orthogonal BY CONSTRUCTION: the
335
+ * best distributional sibling of "Eiffel Tower" scored 0.146 against a
336
+ * concept threshold of 0.516, with its own attested translations absent
337
+ * entirely, and the whole concept-hop / articulation / analogy layer was
338
+ * inert at corpus scale. (Re-verified under the store's OWN training seed:
339
+ * company signatures key on NODE ID, not the alphabet, so this reading is
340
+ * seed-independent and the figures are identical either way. Worth stating
341
+ * because a Mind built with a seed other than the store's makes every GIST
342
+ * comparison meaningless while leaving halo comparisons untouched.)
343
+ *
344
+ * WHY THE DESCENT MUST NOT STOP AT DEPTH 1. Reading only `rec.kids` does
345
+ * NOT deliver this. Cuts are content-defined over a rolling window, so a
346
+ * chunk boundary depends on the bytes AROUND a unit: "The Eiffel Tower is in
347
+ * Paris" folds to "The Eiffel " + "Tower is in Paris", and "Tour Eiffel dia
348
+ * any Paris" to "Tour Eiffel " + "dia any Paris". The shared unit "Paris"
349
+ * is a node in NEITHER — depth-1 profiles of that pair intersect in the
350
+ * EMPTY SET, and their halos measured 0.0319 against 0.0416 for an unrelated
351
+ * control: no signal at all. A depth-1 read merely moves the token problem
352
+ * from whole-partner identity down to top-level-chunk identity, which for
353
+ * full sentences is nearly as rare. Descending, the same pair shares
354
+ * " Paris" and "ffel " while the control still shares nothing — the units
355
+ * the distributional hypothesis is actually about.
356
+ *
357
+ * EVERY DEPTH CONTRIBUTES, AND THE RULE MUST NOT DEPEND ON ARRIVAL ORDER.
358
+ * The tempting stop rule — descend only while a constituent is corpus-unique,
359
+ * stop at the first unit attested in ≥ 2 forms — is wrong, and measurably so.
360
+ * Recurrence is a property of the corpus SO FAR: when the first of a pair is
361
+ * deposited its shared unit has fan-in 1, so the descent runs past it, and
362
+ * only the second partner ever profiles it. The pair then never meets
363
+ * (measured on the fixture above: 0.0165 against a 0.0375 control — still
364
+ * nothing). Whether two synonyms become distributional siblings cannot be
365
+ * allowed to depend on which was trained first. So the walk descends through
366
+ * EVERY constituent within its budget and superposes each one that is not a
367
+ * hub, at whatever depth it sits. A partner's own unique chunks contribute
368
+ * terms unique to that partner, which dilute but never mislead; the shared
369
+ * units contribute the signal.
370
+ *
371
+ * HUBS ARE THE ONE EXCLUSION, read LIMITed as `parentsFirst(n, bound+1)` —
372
+ * the store's own exact hub-or-not probe (a result longer than the bound
373
+ * means MORE than the bound), never a fan-in-sized read. A constituent with
374
+ * more than √N structural parents is scaffolding by §8.8's bound: " is ",
375
+ * "the ". Superposing it would put a term shared by every deposit into every
376
+ * profile, ALL halos would correlate, and the concept threshold's null model
377
+ * (unrelated halos at 0 ± 1/√D) that §4.1's hygiene note protects would
378
+ * collapse. It is still DESCENDED into — a hub chunk can contain a rare
379
+ * unit — but contributes nothing itself.
380
+ *
381
+ * Byte atoms are skipped in BOTH representations (a negative id and a stored
382
+ * kid-less node): an atom's fan-in is the alphabet's, so it can only ever
383
+ * read as a hub, and a short partner folding FLAT would otherwise put a
384
+ * handful of alphabet signatures into every profile — which is what silenced
385
+ * CAST's analogy gate in the first version of this function (measured:
386
+ * analogy strength 0.3636 -> 0.2004, "no halo-tier company evidence",
387
+ * test/29 C1).
388
+ *
389
+ * A FUNCTION OF THE NODE AND THE CORPUS STATE — stated precisely, because
390
+ * the weaker claim is the true one. The constituents are read from the
391
+ * STORE, never from the depositing tree's id map: that map holds only the
392
+ * nodes THIS deposit newly interned, so a partner met a second time yielded a
393
+ * profile missing exactly those constituents, the exact-partner case fell
394
+ * from cosine 1 to 1/√(1+k), and the geometry stopped meaning anything.
395
+ * Reading the store fixes that. It does NOT make the profile permanent: the
396
+ * hub test reads fan-in against √N and both grow with training, so a partner
397
+ * poured early and again late can profile differently. That residue is
398
+ * confined to the hub EXCLUSION — which terms are dropped as scaffolding —
399
+ * and never to which units are found, because the descent itself is now
400
+ * order-independent. The drift is one-directional and benign: a term can
401
+ * only ever go from contributing to being excluded as scaffolding. Replay of
402
+ * a fixed training order is bit-identical, so §2.1 holds. What must not be
403
+ * claimed is that a node's profile is fixed for all time; it is fixed given
404
+ * the corpus that has been seen.
405
+ *
406
+ * THE NULL MODEL IS OTHERWISE UNTOUCHED (§4.1). Every term is still a seeded
407
+ * function of a NODE IDENTITY, never a gist, so no byte-similarity between
408
+ * partners can leak content similarity into distributional similarity. The
409
+ * result is normalized, so ONE episode still pours ONE unit of mass:
410
+ * {@link Store.haloMass} keeps counting episodes and every mass-based
411
+ * reading is unchanged. Two partners sharing j of k discriminating
412
+ * constituents meet at j/(1+k) — graded evidence, above the 1/√D noise floor
413
+ * and below conceptThreshold until the overlap is most of the content, which
414
+ * is the semantics "same company" should have.
415
+ *
416
+ * Bounded: at most {@link PROFILE_VISITS} constituents are classified, each
417
+ * by ONE LIMITed structural-parent read, so a pour costs O(1) reads in the
418
+ * partner's size and performs no scan. */
419
+ function companyProfile(ctx: MindContext, id: number): Vec {
420
+ const acc = zeros(ctx.space.D);
421
+ addInto(acc, companySignature(ctx.space, id));
422
+ const bound = hubBound(ctx);
423
+ const k = profileCapacity(ctx.space.D);
424
+ const whole = Math.max(1, ctx.store.contentLen(id));
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) {
432
+ const len = ctx.store.contentLen(n, whole);
433
+ if (dominates(len, whole)) {
434
+ dominating++;
435
+ continue;
436
+ }
437
+ if (ctx.store.parentsFirst(n, bound + 1).length > bound) {
438
+ hubDropped++;
439
+ continue;
440
+ }
441
+ addInto(acc, companySignature(ctx.space, n));
442
+ accepted++;
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
+ );
479
+ return normalize(acc);
480
+ }
481
+
236
482
  /** Ingest a pair (context, continuation) — learn an edge and pour halos.
237
483
  * Returns the deposited root ids (context, continuation) — a pure
238
484
  * read-out; callers that ignore it behave exactly as before. */
@@ -255,14 +501,17 @@ export async function ingestPair(
255
501
  await propagateSuffixes(ctx, ctxId, contId);
256
502
 
257
503
  // Halos pour company SIGNATURES (identity), not gists (content) — see
258
- // companySignature in sema.ts.
259
- const contSeat = bindSeat(ctx.space, companySignature(ctx.space, contId), 1);
504
+ // companySignature in sema.ts — as a TYPE-level profile: the partner's own
505
+ // signature superposed with its discriminating constituents' (see
506
+ // companyProfile), so company is shared by what partners are MADE OF and
507
+ // not only by partner identity.
508
+ const contSeat = bindSeat(ctx.space, companyProfile(ctx, contId), 1);
260
509
  for (const part of c.changed) {
261
510
  const partId = c.ids.get(part)!;
262
511
  await ctx.store.pourHalo(partId, contSeat);
263
512
  await ctx.store.pourHalo(
264
513
  contId,
265
- bindSeat(ctx.space, companySignature(ctx.space, partId), 0),
514
+ bindSeat(ctx.space, companyProfile(ctx, partId), 0),
266
515
  );
267
516
  }
268
517
  return { ctxId, contId };
@@ -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