@hviana/sema 0.4.3 → 0.4.6

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 (55) hide show
  1. package/AUTHORS.md +0 -1
  2. package/LICENSE.md +1 -1
  3. package/README.md +2 -2
  4. package/dist/src/geometry.d.ts +6 -0
  5. package/dist/src/geometry.js +224 -44
  6. package/dist/src/mind/attention.d.ts +11 -0
  7. package/dist/src/mind/attention.js +344 -13
  8. package/dist/src/mind/bridge.js +46 -21
  9. package/dist/src/mind/junction.js +18 -2
  10. package/dist/src/mind/match.d.ts +11 -0
  11. package/dist/src/mind/match.js +13 -2
  12. package/dist/src/mind/mechanisms/cast.js +366 -34
  13. package/dist/src/mind/mechanisms/confluence.js +17 -1
  14. package/dist/src/mind/mechanisms/recall.js +17 -3
  15. package/dist/src/mind/mind.js +11 -2
  16. package/dist/src/mind/pipeline-mechanism.d.ts +4 -0
  17. package/dist/src/mind/pipeline-mechanism.js +96 -40
  18. package/dist/src/mind/pipeline.js +31 -3
  19. package/dist/src/mind/reasoning.d.ts +4 -2
  20. package/dist/src/mind/reasoning.js +29 -4
  21. package/dist/src/mind/recognition.js +67 -2
  22. package/dist/src/mind/resonance.d.ts +14 -2
  23. package/dist/src/mind/resonance.js +0 -0
  24. package/dist/src/mind/types.d.ts +43 -1
  25. package/dist/src/sema.d.ts +11 -1
  26. package/dist/src/sema.js +16 -2
  27. package/dist/src/store.d.ts +64 -1
  28. package/dist/src/store.js +107 -8
  29. package/index.html +2 -3
  30. package/package.json +1 -1
  31. package/src/geometry.ts +231 -43
  32. package/src/mind/attention.ts +366 -15
  33. package/src/mind/bridge.ts +55 -18
  34. package/src/mind/junction.ts +18 -2
  35. package/src/mind/match.ts +18 -2
  36. package/src/mind/mechanisms/cast.ts +376 -43
  37. package/src/mind/mechanisms/confluence.ts +16 -1
  38. package/src/mind/mechanisms/recall.ts +17 -2
  39. package/src/mind/mind.ts +11 -2
  40. package/src/mind/pipeline-mechanism.ts +96 -36
  41. package/src/mind/pipeline.ts +33 -3
  42. package/src/mind/reasoning.ts +31 -4
  43. package/src/mind/recognition.ts +65 -2
  44. package/src/mind/resonance.ts +0 -0
  45. package/src/mind/types.ts +43 -1
  46. package/src/sema.ts +21 -2
  47. package/src/store.ts +106 -5
  48. package/test/00-extract.test.mjs +28 -0
  49. package/test/15-decomposition-gap.test.mjs +0 -0
  50. package/test/24-generalization.test.mjs +67 -19
  51. package/test/29-counterfactual.test.mjs +106 -42
  52. package/test/33-multi-candidate.test.mjs +56 -12
  53. package/test/53-cross-region-probe-instrumentation.test.mjs +16 -1
  54. package/test/63-fold-invariants.test.mjs +489 -0
  55. package/test/64-two-ended-thresholds.test.mjs +76 -0
@@ -34,7 +34,14 @@ import {
34
34
  estimatorNoise,
35
35
  type StructuralPart,
36
36
  } from "../geometry.js";
37
- import { foldTree, gistOf, latin1Key, perceive, read } from "./primitives.js";
37
+ import {
38
+ foldTree,
39
+ gistOf,
40
+ latin1Key,
41
+ perceive,
42
+ read,
43
+ resolve,
44
+ } from "./primitives.js";
38
45
  import { recognise } from "./recognition.js";
39
46
  import { leafIdRun } from "./canonical.js";
40
47
  import {
@@ -556,6 +563,18 @@ export async function computeAttention(
556
563
  // positional accident this work exists to remove.
557
564
  chunk: false,
558
565
  known: true, // a recognised site IS a stored form
566
+ // …and CARRY WHICH ONE. `known: true` claimed exactness while the
567
+ // identity itself was dropped, leaving the climb to re-derive it from
568
+ // the gist through the ANN — so which stored node an exact site voted
569
+ // with turned on approximate rank. Measured on test/34: the site
570
+ // "square" ([10,16), payload 40) resonated to "quare" (119) instead;
571
+ // the exact junction tier then found no container holding both "blue"
572
+ // and 119, fell through to the single-synonym tier, and "blue then
573
+ // square" attended to "red square" — a context NEITHER attribute
574
+ // attends to alone. This is the same exact-first economy chunks
575
+ // already get from canonicalChunkId, and it REMOVES an ANN query
576
+ // rather than adding one.
577
+ id: s.payload,
559
578
  });
560
579
  }
561
580
 
@@ -704,6 +723,98 @@ export function collectRegions(ctx: MindContext, query: Uint8Array): Region[] {
704
723
  // left the sentences CAST needs with no free run at all (C2, C3). A
705
724
  // region must come from the fold, not from a stride over it.
706
725
  });
726
+
727
+ // ─── FORMS THE QUERY'S OWN CUT SPLIT ────────────────────────────────────
728
+ // The walk above enumerates FOLD NODES ONLY, so a stored form the query's
729
+ // content-defined cut happens to split is not addressable at all — however
730
+ // discriminative it is. Measured: `request_id=1042` against a 200-record
731
+ // log, the query's best match, cut as `...uest_id=|10|42 and r`; "1042"
732
+ // reaches exactly ONE context of 205 (maximal IDF) and cast no vote, while
733
+ // the scaffolding "=10" — which matches every record 1000–1099 — did. The
734
+ // climb was voting on the only evidence it could address, and that was the
735
+ // non-discriminative kind.
736
+ //
737
+ // The WRITE path already made these reachable: canonicalWindows interns a
738
+ // form at both lengths precisely so one straddling a cut resolves from
739
+ // either side. The read path simply never used the guarantee. So this is
740
+ // recovered here by lookup — the fold, its invariants and the write path
741
+ // are untouched.
742
+ //
743
+ // Admitting every resolvable window is REFUTED (it is the ascent-sites
744
+ // failure): on a 5-context corpus a 26-byte query yielded 17 "unique"
745
+ // windows that were all fragments of ONE word (" pai" "pain" "aint" …).
746
+ // No per-window threshold separates that from the log case — the two need
747
+ // the same windows ADMITTED and COLLAPSED at identical per-window IDF. It
748
+ // is a REDUNDANCY problem, so overlapping admitted windows are COALESCED
749
+ // into maximal spans: the log query then yields the two disjoint records it
750
+ // names, and the 17 fragments yield the one span " painted the Mona Lisa".
751
+ const W = ctx.space.maxGroup;
752
+ if (query.length > W) {
753
+ const N = corpusN(ctx);
754
+ const reachMemo = sharedReachMemo(ctx);
755
+ // Coalesce while sweeping left to right: a window overlapping (or just
756
+ // touching) the span under construction extends it. Merging cannot
757
+ // inflate what the climb pays for this evidence — the merged span votes
758
+ // as ITSELF, and a longer span is at least as discriminative as its most
759
+ // discriminative part, i.e. its reach is bounded by the MIN over the
760
+ // windows that built it.
761
+ const spans: Array<{ start: number; end: number }> = [];
762
+ // COVERAGE BY PREFIX MAXIMUM, NOT BY RESCANNING THE REGIONS.
763
+ // The containment test below is the loop's hot path — it rejects 97% of
764
+ // windows — and asking it as `regions.some(...)` re-walked every region
765
+ // at every offset: O(|query| · |regions|). That is quadratic in the
766
+ // input, and the region count grows with it — measured 1,510 regions on
767
+ // an 8,195-byte query, i.e. ~12.4M predicate evaluations in ONE call,
768
+ // against the constant-KB/s law test/14 asserts.
769
+ //
770
+ // A region contains the window [o, o+W) exactly when it starts at or
771
+ // before `o` and ends at or after `o+W`. So the only thing the test
772
+ // needs from the regions is, per offset, the FARTHEST end among those
773
+ // starting at or before it — a prefix maximum, built in one pass and
774
+ // read in O(1). Identical verdict by construction, no behaviour change.
775
+ const maxEndFrom = new Int32Array(query.length + 1);
776
+ for (const r of regions) {
777
+ if (r.start <= query.length && r.end > maxEndFrom[r.start]) {
778
+ maxEndFrom[r.start] = r.end;
779
+ }
780
+ }
781
+ for (let i = 1; i <= query.length; i++) {
782
+ if (maxEndFrom[i - 1] > maxEndFrom[i]) maxEndFrom[i] = maxEndFrom[i - 1];
783
+ }
784
+ for (let o = 0; o + W <= query.length; o++) {
785
+ // A window some fold region wholly contains offers no address the walk
786
+ // above did not already offer.
787
+ if (maxEndFrom[o] >= o + W) continue;
788
+ const ids = leafIdRun(ctx, query, o, o + W);
789
+ if (ids === null) continue;
790
+ const wid = ctx.store.findBranch(ids);
791
+ if (wid === null) continue;
792
+ const reach = edgeAncestors(ctx, wid, N, reachMemo);
793
+ // Saturated = the climb ABSTAINED; no roots = it reached nothing that
794
+ // could corroborate anything. Neither is evidence.
795
+ if (reach.saturated || reach.roots.length === 0) continue;
796
+ const last = spans[spans.length - 1];
797
+ if (last && o <= last.end) last.end = o + W;
798
+ else spans.push({ start: o, end: o + W });
799
+ }
800
+ for (const { start, end } of spans) {
801
+ // The same wrapper filter the fold regions pass through.
802
+ if (dominates(end - start, query.length) && regions.length > 0) continue;
803
+ regions.push({
804
+ v: gistOf(ctx, query.subarray(start, end)),
805
+ start,
806
+ end,
807
+ // NOT a chunk: `chunk` means "a smallest grouped unit the FOLD
808
+ // produced", and this span was assembled here. Setting it is
809
+ // REFUTED — it cost 5 tests (honest silence, fusion direction, both
810
+ // test/50 probes) where chunk:false costs none.
811
+ chunk: false,
812
+ known: true,
813
+ // EVIDENCE, NOT A POINT OF ATTENTION — see Region.corroborating.
814
+ corroborating: true,
815
+ });
816
+ }
817
+ }
707
818
  return regions;
708
819
  }
709
820
 
@@ -732,7 +843,87 @@ export async function voteRegions(
732
843
  // `v`/`start`/`end` are rebindable: a long approximate segment may vote
733
844
  // with the sub-span that actually carries its evidence — see below.
734
845
  let { v, start, end } = regions[ri];
735
- const { chunk, known } = regions[ri];
846
+ const { chunk } = regions[ri];
847
+ // BELOW ONE RIVER WINDOW, BYTE IDENTITY IS NOT EVIDENCE. The same
848
+ // principle identityBar states and recognition's own `emit` already
849
+ // enforces on sites ("below one river window, byte overlap is chance"),
850
+ // applied to what the climb calls EXACT. It was unnecessary while the
851
+ // fold grouped at fixed arity — every chunk was then exactly W bytes —
852
+ // but content-defined cuts run from W-1 up to the keyring's seat count,
853
+ // so sub-window segments now exist, and a 3-byte string is interned by
854
+ // triviality rather than by evidence.
855
+ //
856
+ // Such a region is NOT dropped: it still votes on its gist, through the
857
+ // contrastive-margin gate every approximate region pays. Dropping them
858
+ // outright was measured and REFUTED — the suite fell 441 -> 406, because
859
+ // short regions do carry real evidence; what they must not carry is the
860
+ // EXACT tier's full mutual weight and its exemption from the margin.
861
+ //
862
+ // Measured on test/50's junk query: the 3-byte chunk "of " voted exact
863
+ // at mutual 1.00 with idf 4.22, and an unrelated haiku exemplar's pooled
864
+ // vote went 1.13 -> 5.94 — past consensusFloor (5.82), making a junk root
865
+ // TRUSTED and licensing CAST to compare content the query never named.
866
+ // consensusFloor did not drift; what fed it stopped being evidence.
867
+ //
868
+ // A region spanning the WHOLE query is exempt, exactly as the site rule
869
+ // exempts a whole-query span: it is then not a fragment of something
870
+ // longer, it is the question ("red" asked on its own — test/34).
871
+ const subWindow = end - start < W &&
872
+ !(start === 0 && end === query.length);
873
+ // EXACTNESS IS A PROPERTY OF THE CONTENT, NOT OF THIS QUERY'S GROUPING.
874
+ // `known` used to mean "these bytes resolve to ONE stored node", which
875
+ // conflates two different things: whether the store has seen the content,
876
+ // and whether this query's cut happened to group it the same way the
877
+ // deposit did. Under fixed-arity folding those coincided; under
878
+ // content-defined cuts they routinely do not.
879
+ //
880
+ // Measured over 42 voting regions (attributes / capitals / artists),
881
+ // against a graded reading — what fraction of the region's river windows
882
+ // are content-addressed:
883
+ //
884
+ // known=true cov=1.0 90% cov=0 0% 0<cov<1 10%
885
+ // known=false cov=1.0 5% cov=0 52% 0<cov<1 43%
886
+ //
887
+ // The 5% are regions where EVERY window resolves — the content is
888
+ // entirely in the store — yet the region was called unknown purely
889
+ // because this cut grouped it differently, so it paid the contrastive
890
+ // margin as though it were an approximate gist. That is grouping churn
891
+ // taxed as uncertainty.
892
+ //
893
+ // Only the fully-addressed case is promoted here: every window resolving
894
+ // is exact evidence about the content by the same content-addressing the
895
+ // whole-region test uses, just read at the window scale identityBar
896
+ // already calls the floor below which overlap is chance. The partial band
897
+ // (43%) is deliberately NOT promoted — it is genuinely mixed evidence and
898
+ // the margin is the right price for it. Paid only when the cheap whole-
899
+ // region test already failed, and bounded by the region's own length.
900
+ // HOW MUCH OF THIS REGION IS CONTENT-ADDRESSED — a fraction, not a bit.
901
+ // Measured over 42 voting regions, `known` as a boolean loses a wide band:
902
+ // 43% of "unknown" regions are PARTIALLY addressed and 5% are fully
903
+ // addressed while failing the whole-region test (grouping churn). Promoting
904
+ // the partial band wholesale was measured too and is over-crediting — it
905
+ // buys test/00 with a region attested 1 window in 5, granting a 20%-attested
906
+ // region the same full exemption a fully-attested one gets.
907
+ //
908
+ // So the coverage SCALES the bar instead of switching it: a region pays the
909
+ // estimator's noise floor in proportion to how much of it is NOT
910
+ // content-addressed. cov=1 pays nothing (identical to the old exemption),
911
+ // cov=0 pays the full floor (identical to the old gate), cov=0.2 pays 0.8
912
+ // of it. No new constant — estimatorNoise is unchanged and the coverage is
913
+ // read off the store by the same content addressing `known` already used.
914
+ const windowCoverage = (): number => {
915
+ if (end - start < W) return 0;
916
+ let tot = 0, hit = 0;
917
+ for (let o = start; o + W <= end; o++) {
918
+ tot++;
919
+ if (resolve(ctx, query.subarray(o, o + W)) !== null) hit++;
920
+ }
921
+ return tot === 0 ? 0 : hit / tot;
922
+ };
923
+ // A sub-window region pays in full: below one river window its byte
924
+ // identity is chance, so it has no coverage to claim.
925
+ const cov = subWindow ? 0 : (regions[ri].known ? 1 : windowCoverage());
926
+ const known = cov >= 1 && !subWindow;
736
927
  // Trace-only bookkeeping for this region — allocated only under `td`
737
928
  // (i.e. only when ctx.trace is set); see ConsensusRegionTrace/
738
929
  // RegionOutcome (spec §4). `examinedIds` tracks distinct ANN hits
@@ -776,9 +967,11 @@ export async function voteRegions(
776
967
  // the resonate() call for most exact regions — the single largest
777
968
  // remaining inference sink — with the anchor choice unchanged (the
778
969
  // canonical branch already ignored hits[0]).
779
- let canonicalId = chunk
780
- ? canonicalChunkId(ctx, query.subarray(start, end), N, reachMemo)
781
- : null;
970
+ let canonicalId = subWindow
971
+ ? null
972
+ : (chunk
973
+ ? canonicalChunkId(ctx, query.subarray(start, end), N, reachMemo)
974
+ : (regions[ri].id ?? null));
782
975
  let canonicalUsable = canonicalId !== null &&
783
976
  (ctx.store.hasParents(canonicalId) ||
784
977
  ctx.store.hasContainers(canonicalId));
@@ -1037,7 +1230,8 @@ export async function voteRegions(
1037
1230
  break;
1038
1231
  }
1039
1232
  contrastiveMargin = margin;
1040
- const noiseFloor = estimatorNoise(ctx.store.D);
1233
+ // Scaled by what this region does NOT address — see `cov` above.
1234
+ const noiseFloor = estimatorNoise(ctx.store.D) * (1 - cov);
1041
1235
  if (margin <= noiseFloor) {
1042
1236
  recordRegion("contrastive-margin-rejection", {
1043
1237
  selected,
@@ -1085,6 +1279,8 @@ export async function voteRegions(
1085
1279
  roots: reach.roots,
1086
1280
  w,
1087
1281
  wFocus,
1282
+ // The pool sees VOTES, not regions — carry the region's standing with it.
1283
+ ...(regions[ri].corroborating ? { corroborating: true } : {}),
1088
1284
  });
1089
1285
  if (ctx.trace) {
1090
1286
  regionVoter[ri] = { id: voterId, score, w: wf };
@@ -1140,6 +1336,14 @@ export function poolVotes(
1140
1336
  regionSupport: Map<number, number>;
1141
1337
  /** Per-anchor contributing region spans — see Attention.clusters. */
1142
1338
  regionSpans: Map<number, Array<[number, number]>>;
1339
+ /** Per-anchor count of contributing region VOTES (pooled axioms), which is
1340
+ * not the length of `regionSpans`: a joint binding is one vote sitting in
1341
+ * several places. */
1342
+ regionAxioms: Map<number, number>;
1343
+ /** Per-anchor LARGEST single-region contribution — see Attention.peak. */
1344
+ regionPeak: Map<number, number>;
1345
+ /** Anchors with support from at least one NON-corroborating region. */
1346
+ anchored: Set<number>;
1143
1347
  steps: DerivationStep[];
1144
1348
  } {
1145
1349
  const eligible: number[] = [];
@@ -1232,6 +1436,41 @@ export function poolVotes(
1232
1436
  >();
1233
1437
  const regionSupport = new Map<number, number>();
1234
1438
  const regionSpans = new Map<number, Array<[number, number]>>();
1439
+ // ONE POOLED AXIOM = ONE REGION VOTE. Counted separately from the spans
1440
+ // below because the two are different quantities: a JOINT binding is a
1441
+ // single vote whose evidence sits in several separate places (RegionVote.
1442
+ // parts), so its span count exceeds its axiom count. Reading the axiom
1443
+ // count off `regionSpans.length` conflated them and broke the accounting
1444
+ // both ways — contributingEvidence (absorbed-weighted, one term per
1445
+ // REGION) could read below it, and it could exceed the query's whole
1446
+ // candidate-region count.
1447
+ const regionAxioms = new Map<number, number>();
1448
+ // ANCHORS THE QUERY ITSELF POINTED AT. votesIdf is keyed by anchor node,
1449
+ // but root election has to know something about the REGIONS underneath it:
1450
+ // whether at least one of them is a structure the query wove, rather than a
1451
+ // form its cut split and collectRegions recovered (Region.corroborating).
1452
+ // An anchor standing on corroborating evidence ALONE is a real, well-priced
1453
+ // vote — it just is not a point of attention the query made, so it must not
1454
+ // enter the distribution the root cut is read from, nor the breadth ratio.
1455
+ //
1456
+ // REFUTED: barring such anchors from ROOT CANDIDACY outright. It defeats
1457
+ // the purpose — in the log case the CORRECT record (request_id=1042, one
1458
+ // context of 205) is addressable ONLY through the form the cut split, so
1459
+ // rejecting it handed the answer back to the near-miss 1050 (vote 5.60 ->
1460
+ // 3.81). Grounding follows where the evidence points; what a corroborating
1461
+ // region must not do is make the query look like it wove one more topic
1462
+ // than it did.
1463
+ const anchored = new Set<number>();
1464
+ // The LARGEST single region's contribution to this anchor's pooled vote.
1465
+ // The pool is a SUM (deliberately — see the pooling note above), so it says
1466
+ // how much evidence there is in total, never whether any ONE place in the
1467
+ // query carries evidence on its own. Consumers that hold an anchor to
1468
+ // consensusFloor(N) = ln(N) + 1/2 need the latter: that bar prices ONE
1469
+ // region's maximally-discriminative evidence (ln N is the IDF of content
1470
+ // reaching a single context), so comparing a six-region sum against it is a
1471
+ // dimensional error. Recorded here, beside the count, because this is the
1472
+ // only place the per-region contributions are still separable.
1473
+ const regionPeak = new Map<number, number>();
1235
1474
  const steps: DerivationStep[] = [];
1236
1475
  let order = 0;
1237
1476
  for (const pc of pool.values()) {
@@ -1246,7 +1485,16 @@ export function poolVotes(
1246
1485
  if (p0.kind !== "region" || seenRi.has(p0.ri)) continue;
1247
1486
  seenRi.add(p0.ri);
1248
1487
  const rv = regionVotes[p0.ri];
1249
- breadthSum += rv.absorbed ?? 1;
1488
+ // Breadth is a ratio over the query's OWN candidate points of
1489
+ // attention (see below), and a corroborating region is not one of
1490
+ // those — it enters neither side of that ratio, so breadth reads
1491
+ // exactly as it did before such regions existed. Its evidence still
1492
+ // counts everywhere else: it is a premise, and it is a separate
1493
+ // PLACE for cluster counting — corroborating is what it is for.
1494
+ if (!rv.corroborating) {
1495
+ breadthSum += rv.absorbed ?? 1;
1496
+ anchored.add(pc.item.id);
1497
+ }
1250
1498
  premises.push({ kind: "form", span: [rv.start, rv.end] });
1251
1499
  // A vote knows where its own evidence sits: `parts` when it stands on
1252
1500
  // several separate places (a joint binding), the merged span
@@ -1256,7 +1504,31 @@ export function poolVotes(
1256
1504
  } else spans.push([rv.start, rv.end]);
1257
1505
  }
1258
1506
  regionSupport.set(pc.item.id, breadthSum);
1259
- regionSpans.set(pc.item.id, spans);
1507
+ // A span is a PLACE, and the same place reached through two different
1508
+ // votes (a standalone region and one part of a joint binding) is still
1509
+ // one place — listing it twice reports evidence the query does not
1510
+ // separately hold. Measured on test/50's fixture: span [18,21)
1511
+ // appeared twice among the top anchor's five.
1512
+ const seenSpan = new Set<string>();
1513
+ regionSpans.set(
1514
+ pc.item.id,
1515
+ spans.filter((sp) => {
1516
+ const key = `${sp[0]}:${sp[1]}`;
1517
+ if (seenSpan.has(key)) return false;
1518
+ seenSpan.add(key);
1519
+ return true;
1520
+ }),
1521
+ );
1522
+ regionAxioms.set(pc.item.id, seenRi.size);
1523
+ let peak = 0;
1524
+ for (const c of pc.contributions) {
1525
+ const p0 = c.premises[0].item;
1526
+ if (p0.kind !== "region") continue;
1527
+ const rv = regionVotes[p0.ri];
1528
+ const own = rv.wFocus ?? rv.w;
1529
+ if (own > peak) peak = own;
1530
+ }
1531
+ regionPeak.set(pc.item.id, peak);
1260
1532
  steps.push({
1261
1533
  order: order++,
1262
1534
  move: "pool-vote",
@@ -1283,7 +1555,17 @@ export function poolVotes(
1283
1555
  }
1284
1556
  }
1285
1557
  }
1286
- return { votes, votesIdf, support, regionSupport, regionSpans, steps };
1558
+ return {
1559
+ votes,
1560
+ votesIdf,
1561
+ support,
1562
+ regionSupport,
1563
+ regionSpans,
1564
+ regionAxioms,
1565
+ regionPeak,
1566
+ anchored,
1567
+ steps,
1568
+ };
1287
1569
  }
1288
1570
 
1289
1571
  /** The number of DISTINCT clusters a root's contributing regions form —
@@ -1322,6 +1604,9 @@ export function commitVotes(
1322
1604
  support: Map<number, { start: number; end: number; w: number }>;
1323
1605
  regionSupport: Map<number, number>;
1324
1606
  regionSpans: Map<number, Array<[number, number]>>;
1607
+ regionAxioms: Map<number, number>;
1608
+ regionPeak: Map<number, number>;
1609
+ anchored: Set<number>;
1325
1610
  steps: DerivationStep[];
1326
1611
  },
1327
1612
  sat: SaturationInfo,
@@ -1331,8 +1616,17 @@ export function commitVotes(
1331
1616
  td?: TraceDraft,
1332
1617
  cfg?: ClimbConsensusCfg,
1333
1618
  ): AttentionRead {
1334
- const { votes, votesIdf, support, regionSupport, regionSpans, steps } =
1335
- pooled;
1619
+ const {
1620
+ votes,
1621
+ votesIdf,
1622
+ support,
1623
+ regionSupport,
1624
+ regionSpans,
1625
+ regionAxioms,
1626
+ regionPeak,
1627
+ anchored,
1628
+ steps,
1629
+ } = pooled;
1336
1630
  if (votes.size === 0) {
1337
1631
  traceAttention(ctx, regions, regionVoter, [], steps, td, cfg);
1338
1632
  return { roots: [], ranked: [] };
@@ -1342,13 +1636,23 @@ export function commitVotes(
1342
1636
  // is the query's OWN full candidate count (most never vote at all), the
1343
1637
  // same denominator the "N of M sub-regions voted" rationale text already
1344
1638
  // reports; regionSupport is that same accounting read PER ANCHOR.
1345
- const totalRegions = Math.max(1, regions.length);
1639
+ // Corroborating regions are excluded from the denominator for the same
1640
+ // reason they are excluded from the numerator (see poolVotes): they are
1641
+ // not candidate points of attention the query wove, so counting them would
1642
+ // silently shrink every anchor's breadth — measured: test/36's genuine
1643
+ // second topic fell 6/11 -> 6/12 and fusion's dispersion gate dropped it,
1644
+ // with nothing else about the climb changed.
1645
+ const totalRegions = Math.max(
1646
+ 1,
1647
+ regions.filter((r) => !r.corroborating).length,
1648
+ );
1346
1649
  const ranked = [...votes.entries()]
1347
1650
  .map(([anchor, vote]) => {
1348
1651
  const s = support.get(anchor)!;
1349
1652
  return {
1350
1653
  anchor,
1351
1654
  vote,
1655
+ peak: regionPeak.get(anchor) ?? 0,
1352
1656
  start: s.start,
1353
1657
  end: s.end,
1354
1658
  breadth: (regionSupport.get(anchor) ?? 0) / totalRegions,
@@ -1359,10 +1663,20 @@ export function commitVotes(
1359
1663
  };
1360
1664
  })
1361
1665
  .sort((a, b) => b.vote - a.vote);
1362
-
1363
1666
  const overlaps = (a: Attention, b: Attention) =>
1364
1667
  a.start < b.end && b.start < a.end;
1365
- const idfDesc = [...votesIdf.values()].sort((a, b) => b - a);
1668
+ // Read the root cut from the anchors the QUERY pointed at. A vote standing
1669
+ // only on corroborating evidence (a form the query's cut split, recovered
1670
+ // by lookup — Region.corroborating) is evidence for someone else's anchor,
1671
+ // never a point of attention of its own: the query never wove it as an
1672
+ // independent structure, the fold did that. Letting such votes into
1673
+ // idfDesc shifts naturalBreak — they are exact, hence high-IDF, hence they
1674
+ // land at the top of the distribution — and a 2-topic query then elects 3
1675
+ // roots (test/24:404, and the answered-continuation exclusion probe).
1676
+ const idfDesc = [...votesIdf.entries()]
1677
+ .filter(([anchor]) => anchored.has(anchor))
1678
+ .map(([, v]) => v)
1679
+ .sort((a, b) => b - a);
1366
1680
  const rootCut = naturalBreak(idfDesc);
1367
1681
  // A FURTHER point of attention (beyond the dominant one, which always
1368
1682
  // grounds) must clear the same absolute significance floor
@@ -1393,7 +1707,7 @@ export function commitVotes(
1393
1707
  pooledVote: point.vote,
1394
1708
  idfVote: votesIdf.get(point.anchor) ?? 0,
1395
1709
  candidateBreadth: regions.length,
1396
- contributingVotes: regionSpans.get(point.anchor)?.length ?? 0,
1710
+ contributingVotes: regionAxioms.get(point.anchor) ?? 0,
1397
1711
  contributingEvidence: regionSupport.get(point.anchor) ?? 0,
1398
1712
  breadth: point.breadth,
1399
1713
  contributingSpans: regionSpans.get(point.anchor) ?? [],
@@ -2281,6 +2595,18 @@ async function crossRegionVotes(
2281
2595
  // cumulative dialogue multiplies bounded work into tens of seconds. Small
2282
2596
  // corpora retain exhaustive exact traversal: below this same scale the
2283
2597
  // budget would be smaller than the structures the tests deliberately build.
2598
+ //
2599
+ // MEASURED 2026-07-29, NOT YET RESOLVED. This gate never engages at real
2600
+ // scale: on the trained store N = 325,608 with k = 24 and W = 4, so the
2601
+ // threshold is 96³ = 884,736 and a third of a million contexts still runs
2602
+ // unbudgeted at hubBound·W = 2,280 pops PER PAIR — 160,210 junction pops,
2603
+ // 5.9s, 31% of think. Sharing one hubBound·W allowance across all pairs
2604
+ // instead cuts that to 22,418 pops and 2.6s (think −19%), but is measurably
2605
+ // too tight below ~10³ contexts: test/36 (N = 8, budget 8) loses the
2606
+ // `red circle` binding root and test/14 (N = 120, budget 40) recalls 39/40.
2607
+ // The sharing is the right shape; hubBound·W is the wrong size for it, and
2608
+ // fitting a size to those two points would repeat the mistake the cube
2609
+ // already makes — pricing the gate on the synthetic corpora.
2284
2610
  const marketScale = k * ctx.space.maxGroup;
2285
2611
  const corpusScale = N > marketScale ** 3;
2286
2612
  const exactBudget = corpusScale ? { n: k * ctx.space.maxGroup } : undefined;
@@ -2750,6 +3076,30 @@ async function crossRegionVotes(
2750
3076
  }
2751
3077
  }
2752
3078
 
3079
+ // COMPOSING TWO SPLIT FORMS DOES NOT WEAVE A POINT OF ATTENTION.
3080
+ // Region.corroborating marks a form the query's own cut SPLIT and
3081
+ // collectRegions recovered by lookup; poolVotes and commitVotes keep
3082
+ // such evidence out of the breadth ratio and out of the root cut's
3083
+ // distribution. This path bypassed both: a junction vote is minted
3084
+ // fresh here and carried nothing, so evidence the query never wove
3085
+ // re-entered the root election as a first-class anchor.
3086
+ //
3087
+ // Measured over the suite: 130 accepted junctions, 44 standing on at
3088
+ // least one corroborating region and 12 standing on NOTHING ELSE (both
3089
+ // endpoints corroborating, all structural-resonance tier). Those 12
3090
+ // are precisely the leak — the query wove neither endpoint.
3091
+ //
3092
+ // The flag is inherited only when EVERY part is corroborating. One
3093
+ // genuine fold region among the parts means the query did point here,
3094
+ // and the junction anchors on it; that also preserves the case
3095
+ // Region.corroborating's doc calls out as REFUTED to bar (the correct
3096
+ // log record reachable only through a split form still grounds, because
3097
+ // it grounds as evidence for an anchor, not as a topic of its own).
3098
+ // Safe against the explaining-away accounting because that is EXACT
3099
+ // tier only (spec §15) and an all-corroborating junction has no exact
3100
+ // ordinary vote to absorb.
3101
+ const jointCorroborating = [cand[a], cand[b], ...bestExtras]
3102
+ .every((ri) => regions[ri].corroborating === true);
2753
3103
  out.push({
2754
3104
  start: spanStart,
2755
3105
  end: spanEnd,
@@ -2758,6 +3108,7 @@ async function crossRegionVotes(
2758
3108
  w,
2759
3109
  wFocus: w,
2760
3110
  absorbed: 1 + explainedAway,
3111
+ ...(jointCorroborating ? { corroborating: true } : {}),
2761
3112
  // The places this junction actually stands on — its two endpoints and
2762
3113
  // any N-ary extras, NOT the merged span [spanStart, spanEnd], which
2763
3114
  // swallows the gap and reads as one neighbourhood. See
@@ -594,27 +594,62 @@ async function bridgeImpl(
594
594
  // hundreds of roots), so this is where its proposals are first sized.
595
595
  //
596
596
  // Candidate bytes are read LAZILY — on first access during the seed
597
- // check — not eagerly for every collected id. On a 325K-context store
598
- // the climb channel alone can propose hundreds of edge-bearing ancestors
599
- // (hubBound = 571), most of which will never contain a picked anchor
600
- // window and would be discarded at the seed check without their bytes
601
- // ever being consulted. Eager reads for 500+ candidates each traversing
602
- // the DAG (profiled at 12K node records and 73KB of bytes read per
603
- // refusing query) is the dominant remaining bridge cost after the ANN
604
- // gate. A Map stays available for the frame-unanimity scan below, which
605
- // only needs bytes of candidates that actually seeded.
606
- const seededBytes = new Map<number, Uint8Array>();
607
- /** Read a candidate's bytes once; cache for the seed check AND for the
608
- * frame-unanimity scan that follows alignment. Returns null when the
609
- * candidate exceeds the phrase-scale cap or has no content. */
597
+ // check — not eagerly for every collected id. Most climb-proposed
598
+ // candidates fail the seed check and never reach the expensive identity
599
+ // and frame-consensus gates.
600
+ //
601
+ // Frame unanimity is different: once any candidate reaches that gate, it
602
+ // must be evaluated against the COMPLETE collected candidate population,
603
+ // not only the prefix whose bytes happened to be loaded earlier. The full
604
+ // phrase-scale population is therefore materialised once, lazily, on the
605
+ // first unanimous() call and reused afterward.
606
+ //
607
+ // Null results are memoised too, so an empty or over-cap candidate is never
608
+ // read repeatedly by the candidate loop and the population materialiser.
609
+ const candidateByteMemo = new Map<number, Uint8Array | null>();
610
+
611
+ /** Read one candidate at most once. Returns null when it exceeds the
612
+ * phrase-scale cap or has no content. */
610
613
  const bytesOfCandidate = (sid: number): Uint8Array | null => {
611
- const hit = seededBytes.get(sid);
612
- if (hit !== undefined) return hit;
614
+ const cached = candidateByteMemo.get(sid);
615
+ if (cached !== undefined) return cached;
616
+
613
617
  const b = candidateBytes(sid);
614
- if (b !== null) seededBytes.set(sid, b);
618
+ candidateByteMemo.set(sid, b);
615
619
  return b;
616
620
  };
617
- if (diagnostics) diagnostics.phraseScale = seededBytes.size;
621
+
622
+ let framePopulation: Map<number, Uint8Array> | null = null;
623
+
624
+ /** Return the complete phrase-scale candidate population.
625
+ *
626
+ * This is intentionally lazy: queries that never reach frame unanimity
627
+ * keep the cheap per-candidate seed path. Once required, every candidate
628
+ * is bounded by candidateBytes(), loaded at most once, and all subsequent
629
+ * unanimity checks observe the same order-independent population.
630
+ */
631
+ const ensureFramePopulation = (): ReadonlyMap<number, Uint8Array> => {
632
+ if (framePopulation !== null) {
633
+ return framePopulation;
634
+ }
635
+
636
+ const complete = new Map<number, Uint8Array>();
637
+
638
+ for (const sid of candidates) {
639
+ const bytes = bytesOfCandidate(sid);
640
+ if (bytes !== null) {
641
+ complete.set(sid, bytes);
642
+ }
643
+ }
644
+
645
+ framePopulation = complete;
646
+
647
+ if (diagnostics) {
648
+ diagnostics.phraseScale = complete.size;
649
+ }
650
+
651
+ return complete;
652
+ };
618
653
 
619
654
  // FRAME UNANIMITY: a substitution U → C inside the frame (Lf, Rf) is
620
655
  // groundable only when the collected candidates — the store's own sample
@@ -639,7 +674,9 @@ async function bridgeImpl(
639
674
  lf: Uint8Array,
640
675
  rf: Uint8Array,
641
676
  ): boolean => {
642
- for (const bytes of seededBytes.values()) {
677
+ const population = ensureFramePopulation();
678
+
679
+ for (const bytes of population.values()) {
643
680
  let from = 0;
644
681
  for (;;) {
645
682
  const i = indexOf(bytes, lf, from);
@@ -307,8 +307,24 @@ export function junctionContainersFrom(
307
307
  // abstains here in a handful of pops and falls through to the resonance
308
308
  // tier. Below the page bound the read IS the full container list, so
309
309
  // the walk stays exact exactly where identity evidence discriminates.
310
- const containers = cachedContainers(ctx, cache, x, bound);
311
- if (containers.length < bound) {
310
+ // READ ONE PAST THE PAGE TO TELL "FULL" FROM "SATURATED". The guard
311
+ // below means "this node's containers fill a whole page, so its
312
+ // containment ancestry is a non-discriminative slice of the corpus".
313
+ // But the read itself is CAPPED at the page size, so `length` can never
314
+ // exceed it and `length < bound` really asks "did the capped read come
315
+ // back full?" — which is the same answer for a genuine hub and for a
316
+ // node that has exactly `bound` containers and not one more. Reading
317
+ // bound + 1 separates them: only a node with MORE than a page is a hub.
318
+ //
319
+ // Measured on test/34 (8 deposits, so the page is √N = 3): "blue" has
320
+ // exactly 3 containers and was suppressed, while "red" has 2 and was
321
+ // expanded — so "red then circle" composed and "blue then square" fell
322
+ // through to the synonym tier, which substituted "red" and answered
323
+ // "red square", a context NEITHER attribute attends to alone. The two
324
+ // cross-cuts are structurally identical; only the container count
325
+ // differed, and only by one.
326
+ const containers = cachedContainers(ctx, cache, x, bound + 1);
327
+ if (containers.length <= bound) {
312
328
  for (const c of containers) {
313
329
  if (!seen.has(c)) {
314
330
  seen.add(c);