@hviana/sema 0.2.1 → 0.2.3

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/dist/src/geometry.d.ts +28 -0
  2. package/dist/src/geometry.js +35 -0
  3. package/dist/src/mind/articulation.js +1 -1
  4. package/dist/src/mind/attention.d.ts +4 -0
  5. package/dist/src/mind/attention.js +65 -12
  6. package/dist/src/mind/graph-search.d.ts +16 -1
  7. package/dist/src/mind/graph-search.js +34 -5
  8. package/dist/src/mind/learning.d.ts +1 -1
  9. package/dist/src/mind/learning.js +20 -5
  10. package/dist/src/mind/mechanisms/alu.js +8 -1
  11. package/dist/src/mind/mechanisms/cast.js +35 -11
  12. package/dist/src/mind/mechanisms/cover.js +40 -1
  13. package/dist/src/mind/mechanisms/extraction.js +75 -30
  14. package/dist/src/mind/mechanisms/recall.js +26 -2
  15. package/dist/src/mind/mind.d.ts +3 -2
  16. package/dist/src/mind/mind.js +28 -54
  17. package/dist/src/mind/pipeline.js +34 -2
  18. package/dist/src/mind/primitives.d.ts +16 -9
  19. package/dist/src/mind/primitives.js +58 -22
  20. package/dist/src/mind/reasoning.d.ts +9 -1
  21. package/dist/src/mind/reasoning.js +26 -4
  22. package/dist/src/mind/recognition.js +30 -6
  23. package/dist/src/mind/traverse.js +15 -0
  24. package/dist/src/mind/types.d.ts +58 -10
  25. package/dist/src/mind/types.js +15 -0
  26. package/package.json +1 -1
  27. package/src/geometry.ts +61 -1
  28. package/src/mind/articulation.ts +1 -0
  29. package/src/mind/attention.ts +80 -12
  30. package/src/mind/graph-search.ts +59 -2
  31. package/src/mind/learning.ts +20 -3
  32. package/src/mind/mechanisms/alu.ts +8 -1
  33. package/src/mind/mechanisms/cast.ts +46 -8
  34. package/src/mind/mechanisms/cover.ts +46 -0
  35. package/src/mind/mechanisms/extraction.ts +101 -40
  36. package/src/mind/mechanisms/recall.ts +30 -2
  37. package/src/mind/mind.ts +38 -61
  38. package/src/mind/pipeline.ts +37 -2
  39. package/src/mind/primitives.ts +71 -29
  40. package/src/mind/reasoning.ts +26 -3
  41. package/src/mind/recognition.ts +28 -5
  42. package/src/mind/traverse.ts +18 -0
  43. package/src/mind/types.ts +73 -10
  44. package/test/35-attention-confidence.test.mjs +139 -0
@@ -41,8 +41,9 @@ function recogniseImpl(ctx, bytes) {
41
41
  const sites = [];
42
42
  const leaves = [];
43
43
  const splits = new Set();
44
+ const starts = new Set();
44
45
  if (bytes.length === 0)
45
- return { sites, leaves, splits };
46
+ return { sites, leaves, splits, starts };
46
47
  // Span-resolve memo for THIS call: the structural pass (sub-runs inside
47
48
  // leaf-parents) and the canonical pass (leaf-id chains) probe overlapping
48
49
  // spans, and each resolve() is a full fold of the sub-span (fresh subarray
@@ -78,7 +79,6 @@ function recogniseImpl(ctx, bytes) {
78
79
  }
79
80
  };
80
81
  // ── structural: the query's own perceived tree ──────────────────────
81
- const starts = new Set();
82
82
  starts.add(0);
83
83
  foldTree(ctx, perceive(ctx, bytes), 0, (n, start, end, node) => {
84
84
  if (n.kids === null) {
@@ -105,7 +105,16 @@ function recogniseImpl(ctx, bytes) {
105
105
  leafOffsets.push(off);
106
106
  off += k.leaf?.length ?? 0;
107
107
  }
108
+ // Sub-spans starting at i > 0 begin INSIDE the chunk, at an offset the
109
+ // query's own fold did not itself choose as a boundary — the same
110
+ // opportunistic byte-atom-chain risk `tryChain`'s `boundary` gate
111
+ // guards below (see its comment). Only the chunk's own left edge
112
+ // (i === 0, already registered in `starts` above) carries the fold's
113
+ // evidence; interior sub-starts are exempt from the guard only while
114
+ // atoms themselves still discriminate at this corpus scale.
108
115
  for (let i = 0; i < n.kids.length; i++) {
116
+ if (i > 0 && atomsAreHubs)
117
+ break;
109
118
  const subIds = [];
110
119
  for (let j = i; j < n.kids.length; j++) {
111
120
  const kj = n.kids[j];
@@ -148,7 +157,20 @@ function recogniseImpl(ctx, bytes) {
148
157
  chunkEnd[p] = chunkLimit;
149
158
  }
150
159
  }
151
- const tryChain = (p, maxIds) => {
160
+ // A chain rebuilt from a NON-boundary offset (the query's own perceived
161
+ // cut, `starts`, never chose to segment here) is opportunistic: the same
162
+ // byte-atom coincidence the hub guard above already exists for, just
163
+ // spelled over 2+ leaves instead of 1. At small corpus scale that's fine
164
+ // — coincidence is rare and every chain is real evidence (see `atomIsHub`).
165
+ // Past the scale where atoms themselves stop discriminating, the same
166
+ // uniform-expectation argument bounds a CHAIN'S commonality too: it is at
167
+ // least as rare as its rarest atom, so a store where atoms are hubs makes
168
+ // interior chain reconstructions no more trustworthy than the atoms they
169
+ // are built from ("hi" resolving out of "W[hi]ch" is exactly this: two
170
+ // hub-scale atoms, chained at an offset nothing in the query's own fold
171
+ // selected). Chains that start ON a boundary carry the fold's own
172
+ // evidence instead and are exempt.
173
+ const tryChain = (p, maxIds, boundary) => {
152
174
  const first = leafFrom(p);
153
175
  if (!first)
154
176
  return;
@@ -164,6 +186,8 @@ function recogniseImpl(ctx, bytes) {
164
186
  pos = nx.end;
165
187
  if (store.findBranch(ids) === null)
166
188
  continue;
189
+ if (!boundary && atomsAreHubs)
190
+ continue;
167
191
  const id = resolveSpan(p, pos);
168
192
  if (id === null || id === prevId)
169
193
  continue;
@@ -173,11 +197,11 @@ function recogniseImpl(ctx, bytes) {
173
197
  };
174
198
  for (let p = 0; p < bytes.length; p++) {
175
199
  if (starts.has(p)) {
176
- tryChain(p, chainReach(W)); // boundary start — full reach
200
+ tryChain(p, chainReach(W), true); // boundary start — full reach
177
201
  }
178
202
  else {
179
203
  const limit = chunkEnd[p] + W;
180
- tryChain(p, Math.min(limit - p, chainReach(W)));
204
+ tryChain(p, Math.min(limit - p, chainReach(W)), false);
181
205
  }
182
206
  }
183
207
  // ── splits: a form boundary that does not fall on a leaf edge ────────
@@ -195,7 +219,7 @@ function recogniseImpl(ctx, bytes) {
195
219
  s.end,
196
220
  ])), `decompose the query into ${sites.length} learnt form(s) that lead somewhere` +
197
221
  ` (over ${leaves.length} perceived leaves)`);
198
- return { sites, leaves, splits };
222
+ return { sites, leaves, splits, starts };
199
223
  }
200
224
  /** Segment bytes using the geometry's own groupings — leaf-parent
201
225
  * nodes from the perceived tree, with consecutive bare leaves merged
@@ -6,6 +6,7 @@
6
6
  // The PROJECTIONS built on these walks (follow, conceptHop, reverseContext,
7
7
  // project) live in match.ts — the elementary match-and-project operation.
8
8
  import { cosine } from "../vec.js";
9
+ import { consensusFloor } from "../geometry.js";
9
10
  import { gistOf, read } from "./primitives.js";
10
11
  const structCaches = new WeakMap();
11
12
  function getStructCache(ctx) {
@@ -439,6 +440,20 @@ export function chooseNext(ctx, id, guide) {
439
440
  bestMass = mass;
440
441
  }
441
442
  }
443
+ // A pick among GENUINELY competing continuations still needs to clear the
444
+ // same genuine-corroboration floor {@link consensusFloor} draws for every
445
+ // other consumer that turns distinct-context support into a vote (the
446
+ // climb's recallByResonance/commitVotes) — below it, "most corroborated"
447
+ // is only the least-thin echo among several, not real evidence. Gated to
448
+ // corpus scale large enough that this echo is even possible (the same
449
+ // scale {@link atomIsHub} already switches on for the identical reason:
450
+ // at small N every edge IS the evidence there is). A hub id has no
451
+ // meaningful "distinct context" reading at all (id < 0 atoms are excluded
452
+ // from this path already, since chooseNext only ever sees real edges).
453
+ const N = corpusN(ctx);
454
+ if (capped.length > 1 && atomIsHub(ctx, N) && bestSupport < consensusFloor(N)) {
455
+ return undefined;
456
+ }
442
457
  // Trace is built lazily — the filter + map below only execute when a
443
458
  // trace listener is attached, so the common (no-trace) path pays only
444
459
  // for the prevCount calls in the loop above, never for extra rItemShort
@@ -6,7 +6,25 @@ import type { Alphabet } from "../alphabet.js";
6
6
  import type { MindConfig } from "../config.js";
7
7
  import type { GraphSearch, Leaf, Seg, Site } from "./graph-search.js";
8
8
  import type { Rationale } from "./rationale.js";
9
- import type { FoldPyramid, Grid } from "../geometry.js";
9
+ import type { FoldPyramid, Grid, StableFold } from "../geometry.js";
10
+ /** One {@link MindContext._depositTrees} entry — see that field's doc. */
11
+ export interface DepositCacheEntry {
12
+ /** Turn boundaries accumulated over this content's deposit chain —
13
+ * strictly increasing proper offsets, each a previously-deposited
14
+ * whole-context length. Empty for a first-seen (single-turn) input. */
15
+ boundaries: number[];
16
+ /** Plain-fold pyramid (first-seen inputs only). */
17
+ pyramid?: FoldPyramid;
18
+ /** Stable-prefix segment folds (grown-context inputs only). */
19
+ stable?: StableFold;
20
+ /** The continuation bytes this ctxInput was paired with in ingestPair, if
21
+ * any — the ONLY thing that makes a later, longer ctxInput a genuine next
22
+ * TURN of the same conversation rather than an unrelated fact that
23
+ * happens to share this one's byte prefix (e.g. "2+2" vs. "2+2=5"). A
24
+ * later deposit only takes this entry as its stable-prefix `prev` when
25
+ * its own suffix bytes-equal this exactly. */
26
+ nextBytes?: Uint8Array;
27
+ }
10
28
  export type Input = string | Uint8Array | Grid | Grid[];
11
29
  /** The host capabilities GraphSearch consults during a cover. MindContext
12
30
  * extends this so the Mind can pass itself as the host. */
@@ -16,6 +34,7 @@ export interface GraphSearchHost {
16
34
  sites: ReadonlyArray<Site>;
17
35
  leaves: ReadonlyArray<Leaf>;
18
36
  splits: ReadonlySet<number>;
37
+ starts: ReadonlySet<number>;
19
38
  };
20
39
  chooseNext?(node: number): number | undefined;
21
40
  }
@@ -26,6 +45,13 @@ export interface Recognition {
26
45
  leaves: Leaf[];
27
46
  /** Sub-leaf positions where a form boundary falls between leaf edges. */
28
47
  splits: Set<number>;
48
+ /** Leaf-parent (chunk) start positions from the query's OWN perceived
49
+ * fold — the positions the fold itself chose as a grouping boundary, as
50
+ * opposed to an offset a byte-level scan merely happens to land on. The
51
+ * one boundary signal opportunistic cross-leaf recovery (recognition's
52
+ * own canonical chains, the search's `fuse`) can lean on instead of
53
+ * ASCII/word heuristics: see the `boundary` gate in recognition.ts. */
54
+ starts: Set<number>;
29
55
  }
30
56
  /** How the consensus climb weights a region's Document-Frequency reach. */
31
57
  export type DFMode = "inverse" | "direct" | "combined";
@@ -38,6 +64,16 @@ export interface Attention {
38
64
  /** The union of the query byte-spans whose evidence supports this point. */
39
65
  start: number;
40
66
  end: number;
67
+ /** SCALE-INVARIANT confidence: the fraction of the query's OWN regions
68
+ * whose evidence this point accounts for (Σ RegionVote.absorbed among
69
+ * its contributors, over the query's total region count) — read PER-
70
+ * ANCHOR, unlike the raw IDF vote (an absolute, ln(N)-scaled quantity
71
+ * that means "strong" on a small store and "weak" on a large one for
72
+ * the SAME degree of genuine consensus). A point whose breadth clears
73
+ * `dominates` (> half the query's regions corroborate it) is real
74
+ * consensus; one that does not is a coincidental single-region echo —
75
+ * see test/35-attention-confidence.test.mjs. */
76
+ breadth: number;
41
77
  }
42
78
  /** Both read-outs of one consensus climb. */
43
79
  export interface AttentionRead {
@@ -71,6 +107,14 @@ export interface RegionVote {
71
107
  roots: readonly number[];
72
108
  w: number;
73
109
  wFocus: number;
110
+ /** How many of the query's ORIGINAL regions this one vote's evidence
111
+ * accounts for. 1 for an ordinary per-region vote (itself); for a
112
+ * cross-region junction vote, 1 (itself) plus however many individual
113
+ * votes it explained away (see crossRegionVotes) — the junction speaks
114
+ * for all of them at once, and breadth accounting must not undercount it
115
+ * to "one region" just because it collapsed to one pooled axiom.
116
+ * Defaults to 1 when absent. */
117
+ absorbed?: number;
74
118
  }
75
119
  /** The edge-bearing contexts reached by climbing from a node, plus saturation info. */
76
120
  export interface AncestorReach {
@@ -153,15 +197,19 @@ export interface MindContext extends GraphSearchHost {
153
197
  * never invalidated. Bounded LRU (byte-sized); a miss only re-perceives,
154
198
  * never a correctness risk. */
155
199
  _gistCache: BoundedMap<number, Vec>;
156
- /** DEPOSIT-path stable-prefix tree cache: content key (latin1) of a
157
- * deposited input → its plain-fold level PYRAMID, so the NEXT deposit
158
- * whose prefix it is (a conversation's accumulated context) reuses every
159
- * full aligned block and refolds only the right edge of each level
160
- * O(turn) per deposit instead of O(context), with the tree BIT-IDENTICAL
161
- * to a from-scratch fold (see FoldPyramid). Purely a performance cache.
162
- * Entry-count bounded (a pyramid is ~KB/byte of content; only the few
163
- * live conversation chains matter). */
164
- _depositTrees: BoundedMap<string, FoldPyramid>;
200
+ /** DEPOSIT-path perception cache: content key (latin1) of a deposited
201
+ * input → its accumulated turn BOUNDARIES plus reusable fold state. A
202
+ * deposit whose content extends a cached entry IS a conversation context
203
+ * grown by one turn the cached length is the new boundary so it
204
+ * folds with the SAME stable-prefix fold query-time perception uses
205
+ * (structural train/inference agreement, load-bearing for recall),
206
+ * reusing every already-folded segment via `stable` (see StableFold)
207
+ * O(turn) per deposit instead of O(context). A first-seen input keeps
208
+ * the plain fold and caches its `pyramid` (see FoldPyramid). Purely a
209
+ * performance cache for the FOLD STATE; the boundaries are semantic but
210
+ * derived only from the deposit sequence itself (an evicted chain falls
211
+ * back to plain-fold behavior, exactly the pre-boundary shape). */
212
+ _depositTrees: BoundedMap<string, DepositCacheEntry>;
165
213
  /** The byte lengths present in {@link _depositTrees} — the candidate
166
214
  * prefix lengths probed (longest first). Drifts on eviction (a stale
167
215
  * length only costs a miss); cleared with the map when it outgrows the
@@ -26,6 +26,21 @@ export function liftAnswer(segs, queryLen) {
26
26
  return null;
27
27
  if (recognised.length === 1) {
28
28
  const s = segs[recognised[0]];
29
+ // A COMPUTED span's query-side width is operand digit-count, not
30
+ // evidence of how much of the query's meaning it accounts for — the
31
+ // half-dominance check below (built for a genuinely RECOGNISED learned
32
+ // form) is not a valid framing signal for it (see the `computed` field
33
+ // doc on Seg/GItem): "1000 - 421" outweighs "what is …?" by width only
34
+ // because the operands are big, not because the framing matters less.
35
+ // A LITERAL PREFIX before a computed span is unambiguous framing
36
+ // regardless of width — an arithmetic expression is never itself
37
+ // preceded by more literal computed content, so anything literal before
38
+ // it is question wording ("what is ", "compute ") to lift clear of.
39
+ // With no prefix (s.i === 0) the span is judged by the ordinary
40
+ // half-dominance rule below, which already correctly keeps a short
41
+ // trailing glue byte ("2+2." → "4.", the span dominates a 4-byte query).
42
+ if (s.computed && s.i > 0)
43
+ return s.bytes;
29
44
  if (dominates(s.j - s.i, queryLen)) {
30
45
  return concatBytes(segs.map((x) => x.bytes));
31
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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
@@ -145,7 +145,7 @@ export function coverageBar(_maxGroup: number, D: number): number {
145
145
 
146
146
  // ---- types ----
147
147
 
148
- interface Folded {
148
+ export interface Folded {
149
149
  tree: Sema;
150
150
  /** Byte length of the subtree — carried incrementally so the stable-prefix
151
151
  * boundary scan never re-walks subtrees (the old per-level walk was
@@ -383,6 +383,66 @@ function stablePrefixFold(
383
383
  return cur.tree;
384
384
  }
385
385
 
386
+ /** A stable-prefix fold's reusable state: the segment edge offsets and each
387
+ * segment's independently-folded root ({@link riverFoldRaw} output). A
388
+ * grown stream whose boundary set EXTENDS a previous fold's reuses every
389
+ * matching segment's Folded unchanged (segments fold independently by
390
+ * construction, so reuse is bit-identical to refolding) and folds only the
391
+ * new right-edge segment — O(turn) per extension, the stable-prefix
392
+ * counterpart of {@link FoldPyramid}. Purely a cache: the produced tree
393
+ * never depends on cache state. */
394
+ export interface StableFold {
395
+ edges: number[];
396
+ segs: Folded[];
397
+ }
398
+
399
+ /** {@link stablePrefixFold} with incremental segment reuse — same cuts, same
400
+ * segment folds, same left-nested join, same single root normalize; `prev`
401
+ * only elides recomputing segments whose [start,end) offsets it already
402
+ * folded over a byte-identical prefix (the caller keys the cache by
403
+ * content). Requires a non-empty effective boundary set. */
404
+ export function stablePrefixFoldIncremental(
405
+ space: Space,
406
+ alphabet: Alphabet,
407
+ bytes: Uint8Array,
408
+ boundaries: readonly number[],
409
+ prev?: StableFold,
410
+ ): { tree: Sema; fold: StableFold } {
411
+ const cuts: number[] = [];
412
+ let prevB = 0;
413
+ for (const b of boundaries) {
414
+ if (b > prevB && b < bytes.length) {
415
+ cuts.push(b);
416
+ prevB = b;
417
+ }
418
+ }
419
+ const edges = [0, ...cuts, bytes.length];
420
+ const segs: Folded[] = [];
421
+ for (let i = 0; i + 1 < edges.length; i++) {
422
+ const hit = prev !== undefined && prev.edges[i] === edges[i] &&
423
+ prev.edges[i + 1] === edges[i + 1]
424
+ ? prev.segs[i]
425
+ : undefined;
426
+ segs.push(
427
+ hit ??
428
+ riverFoldRaw(
429
+ space,
430
+ bytesToLeaves(alphabet, bytes.subarray(edges[i], edges[i + 1])),
431
+ ),
432
+ );
433
+ }
434
+ if (segs.length === 1) {
435
+ // Degenerate boundary set — the plain fold, as stablePrefixFold does.
436
+ const tree =
437
+ riverFold(space, bytesToLeaves(alphabet, bytes), bytes.length).tree;
438
+ return { tree, fold: { edges, segs } };
439
+ }
440
+ let cur = segs[0];
441
+ for (let i = 1; i < segs.length; i++) cur = fold2(space, cur, segs[i]);
442
+ normalize(cur.tree.v);
443
+ return { tree: cur.tree, fold: { edges, segs } };
444
+ }
445
+
386
446
  /** Join two folded items as one 2-kid branch — the top-level join of the
387
447
  * stable-prefix fold, identical FP ops to foldSlice's seat-bound
388
448
  * accumulation over a group of two. Unnormalized (interior). */
@@ -105,6 +105,7 @@ export async function articulate(
105
105
  new Map(),
106
106
  ans.leaves,
107
107
  ans.splits,
108
+ ans.starts,
108
109
  substitutions,
109
110
  undefined,
110
111
  undefined,
@@ -417,6 +417,9 @@ export function poolVotes(
417
417
  votes: Map<number, number>;
418
418
  votesIdf: Map<number, number>;
419
419
  support: Map<number, { start: number; end: number; w: number }>;
420
+ /** Per-anchor SCALE-INVARIANT support: Σ RegionVote.absorbed over the
421
+ * distinct contributing regions — see Attention.breadth. */
422
+ regionSupport: Map<number, number>;
420
423
  steps: DerivationStep[];
421
424
  } {
422
425
  const eligible: number[] = [];
@@ -496,6 +499,7 @@ export function poolVotes(
496
499
  number,
497
500
  { start: number; end: number; w: number }
498
501
  >();
502
+ const regionSupport = new Map<number, number>();
499
503
  const steps: DerivationStep[] = [];
500
504
  let order = 0;
501
505
  for (const pc of pool.values()) {
@@ -503,13 +507,16 @@ export function poolVotes(
503
507
  votes.set(pc.item.id, pc.cost);
504
508
  const premises: DerivationItem[] = [];
505
509
  const seenRi = new Set<number>();
510
+ let breadthSum = 0;
506
511
  for (const c of pc.contributions) {
507
512
  const p0 = c.premises[0].item;
508
513
  if (p0.kind !== "region" || seenRi.has(p0.ri)) continue;
509
514
  seenRi.add(p0.ri);
510
515
  const rv = regionVotes[p0.ri];
516
+ breadthSum += rv.absorbed ?? 1;
511
517
  premises.push({ kind: "form", span: [rv.start, rv.end] });
512
518
  }
519
+ regionSupport.set(pc.item.id, breadthSum);
513
520
  steps.push({
514
521
  order: order++,
515
522
  move: "pool-vote",
@@ -536,7 +543,7 @@ export function poolVotes(
536
543
  }
537
544
  }
538
545
  }
539
- return { votes, votesIdf, support, steps };
546
+ return { votes, votesIdf, support, regionSupport, steps };
540
547
  }
541
548
 
542
549
  export function commitVotes(
@@ -545,6 +552,7 @@ export function commitVotes(
545
552
  votes: Map<number, number>;
546
553
  votesIdf: Map<number, number>;
547
554
  support: Map<number, { start: number; end: number; w: number }>;
555
+ regionSupport: Map<number, number>;
548
556
  steps: DerivationStep[];
549
557
  },
550
558
  sat: SaturationInfo,
@@ -552,16 +560,27 @@ export function commitVotes(
552
560
  regionVoter: ReadonlyArray<{ id: number; score: number; w: number } | null>,
553
561
  N: number,
554
562
  ): AttentionRead {
555
- const { votes, votesIdf, support, steps } = pooled;
563
+ const { votes, votesIdf, support, regionSupport, steps } = pooled;
556
564
  if (votes.size === 0) {
557
565
  traceAttention(ctx, regions, regionVoter, [], steps);
558
566
  return { roots: [], ranked: [] };
559
567
  }
560
568
 
569
+ // SCALE-INVARIANT confidence — see Attention.breadth's doc. regions.length
570
+ // is the query's OWN full candidate count (most never vote at all), the
571
+ // same denominator the "N of M sub-regions voted" rationale text already
572
+ // reports; regionSupport is that same accounting read PER ANCHOR.
573
+ const totalRegions = Math.max(1, regions.length);
561
574
  const ranked = [...votes.entries()]
562
575
  .map(([anchor, vote]) => {
563
576
  const s = support.get(anchor)!;
564
- return { anchor, vote, start: s.start, end: s.end };
577
+ return {
578
+ anchor,
579
+ vote,
580
+ start: s.start,
581
+ end: s.end,
582
+ breadth: (regionSupport.get(anchor) ?? 0) / totalRegions,
583
+ };
565
584
  })
566
585
  .sort((a, b) => b.vote - a.vote);
567
586
 
@@ -867,6 +886,28 @@ async function crossRegionVotes(
867
886
  const rb = regions[cand[b]];
868
887
  if (!strong.has(cand[a]) && !strong.has(cand[b])) continue;
869
888
  if (ra.end >= rb.start) continue; // overlap or adjacent — nothing between
889
+ // Candidates strictly BETWEEN ra and rb (cand is sorted by start, so
890
+ // that is exactly cand[a+1 .. b-1]) that already cast their OWN vote —
891
+ // genuine, individually-corroborated evidence about what fills the gap
892
+ // — gate the container search below: a joint container is binding
893
+ // evidence only when it is CONSISTENT with that evidence, i.e. its own
894
+ // bytes actually contain what the between-region says. This is the
895
+ // n-ary composition's normal shape (a between-attribute's bytes DO
896
+ // recur inside the joint container, credited as an "extra" below) as
897
+ // opposed to a container that silently substitutes something else for
898
+ // it (e.g. bridging past "Italy" to a container whose interior is
899
+ // "Japan" — a different, contradicting learnt whole).
900
+ // Only a KNOWN (content-addressed, exact) between-region qualifies —
901
+ // an approximate region's resonance climbing "somewhere" is ordinary
902
+ // noise (any ANN query returns SOME nearest neighbour), not evidence
903
+ // this specific gap already means something specific.
904
+ const between: number[] = [];
905
+ for (let m = a + 1; m < b; m++) {
906
+ if (
907
+ strong.has(cand[m]) && !consumed.has(cand[m]) &&
908
+ regions[cand[m]].known
909
+ ) between.push(cand[m]);
910
+ }
870
911
  // A single KNOWN region covering both: the whole form is already a
871
912
  // stored identity that votes directly; its pieces add nothing.
872
913
  if (
@@ -932,6 +973,22 @@ async function crossRegionVotes(
932
973
  );
933
974
  if (indexOf(query, joined, 0) >= 0) continue; // query says it itself
934
975
  }
976
+ // CONTRADICTION GUARD: a between-region already carrying its own
977
+ // vote must actually recur in this container's bytes — otherwise
978
+ // the container is a different learnt whole that happens to share
979
+ // ra/rb, and letting it stand in for the gap would silently
980
+ // override evidence the query itself already resolved there.
981
+ if (
982
+ between.some((bi) =>
983
+ indexOf(
984
+ bytes,
985
+ query.subarray(regions[bi].start, regions[bi].end),
986
+ 0,
987
+ ) < 0
988
+ )
989
+ ) {
990
+ continue;
991
+ }
935
992
  let cov = left.length + right.length;
936
993
  const extras: number[] = [];
937
994
  for (const ei of cand) {
@@ -978,14 +1035,6 @@ async function crossRegionVotes(
978
1035
  spanStart = Math.min(spanStart, regions[ei].start);
979
1036
  spanEnd = Math.max(spanEnd, regions[ei].end);
980
1037
  }
981
- out.push({
982
- start: spanStart,
983
- end: spanEnd,
984
- canonicalFailed: false, // content-addressed: never saturation-masked
985
- roots: reach.roots,
986
- w,
987
- wFocus: w,
988
- });
989
1038
  consumed.add(cand[a]);
990
1039
  consumed.add(cand[b]);
991
1040
  for (const ei of bestExtras) consumed.add(ei);
@@ -995,14 +1044,33 @@ async function crossRegionVotes(
995
1044
  // vote's bytes are literally part of the learnt whole), and FULL root
996
1045
  // disjointness is the disagreement test: a vote sharing even one root
997
1046
  // with the junction corroborates it and keeps its say elsewhere.
1047
+ // Counted BEFORE pushing the junction's own vote below: each ORIGINAL
1048
+ // region this ascent explains away is evidence the junction speaks
1049
+ // for, not evidence lost — `absorbed` (RegionVote's breadth-accounting
1050
+ // field) must credit the junction with all of it, not just the ONE
1051
+ // pooled axiom it collapses to.
998
1052
  const containerBytes = cachedRead(ctx, cache, best.id, cap);
999
1053
  const jointRoots = new Set(reach.roots);
1054
+ let explainedAway = 0;
1000
1055
  for (const rv of rvs.votes) {
1001
1056
  if (rv.roots.some((r) => jointRoots.has(r))) continue;
1002
1057
  const bytes = query.subarray(rv.start, rv.end);
1003
- if (indexOf(containerBytes, bytes, 0) >= 0) superseded.add(rv);
1058
+ if (indexOf(containerBytes, bytes, 0) >= 0 && !superseded.has(rv)) {
1059
+ superseded.add(rv);
1060
+ explainedAway++;
1061
+ }
1004
1062
  }
1005
1063
 
1064
+ out.push({
1065
+ start: spanStart,
1066
+ end: spanEnd,
1067
+ canonicalFailed: false, // content-addressed: never saturation-masked
1068
+ roots: reach.roots,
1069
+ w,
1070
+ wFocus: w,
1071
+ absorbed: 1 + explainedAway,
1072
+ });
1073
+
1006
1074
  const label = [cand[a], cand[b], ...bestExtras]
1007
1075
  .sort((x, y) => regions[x].start - regions[y].start)
1008
1076
  .map((ri) => dec(query.subarray(regions[ri].start, regions[ri].end)))