@hviana/sema 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/src/geometry.d.ts +26 -0
  2. package/dist/src/geometry.js +32 -3
  3. package/dist/src/mind/attention.d.ts +246 -7
  4. package/dist/src/mind/attention.js +771 -173
  5. package/dist/src/mind/bridge.d.ts +30 -0
  6. package/dist/src/mind/bridge.js +569 -0
  7. package/dist/src/mind/index.d.ts +2 -0
  8. package/dist/src/mind/junction.d.ts +30 -1
  9. package/dist/src/mind/junction.js +73 -18
  10. package/dist/src/mind/match.d.ts +15 -2
  11. package/dist/src/mind/match.js +3 -8
  12. package/dist/src/mind/mechanisms/cast.d.ts +54 -0
  13. package/dist/src/mind/mechanisms/cast.js +268 -37
  14. package/dist/src/mind/mechanisms/cover.js +16 -31
  15. package/dist/src/mind/mechanisms/recall.js +66 -0
  16. package/dist/src/mind/mind.d.ts +2 -0
  17. package/dist/src/mind/rationale.d.ts +7 -2
  18. package/dist/src/mind/rationale.js +6 -5
  19. package/dist/src/mind/reasoning.d.ts +11 -0
  20. package/dist/src/mind/reasoning.js +58 -2
  21. package/dist/src/mind/recognition.js +127 -7
  22. package/dist/src/mind/traverse.js +90 -25
  23. package/dist/src/mind/types.d.ts +57 -2
  24. package/dist/src/mind/types.js +38 -7
  25. package/dist/src/store.d.ts +12 -3
  26. package/dist/src/store.js +9 -3
  27. package/package.json +1 -1
  28. package/src/geometry.ts +52 -3
  29. package/src/mind/attention.ts +1199 -128
  30. package/src/mind/bridge.ts +596 -0
  31. package/src/mind/index.ts +16 -0
  32. package/src/mind/junction.ts +125 -16
  33. package/src/mind/match.ts +19 -5
  34. package/src/mind/mechanisms/cast.ts +290 -38
  35. package/src/mind/mechanisms/cover.ts +23 -36
  36. package/src/mind/mechanisms/recall.ts +79 -0
  37. package/src/mind/mind.ts +15 -0
  38. package/src/mind/rationale.ts +12 -4
  39. package/src/mind/reasoning.ts +71 -2
  40. package/src/mind/recognition.ts +132 -7
  41. package/src/mind/traverse.ts +91 -24
  42. package/src/mind/types.ts +95 -6
  43. package/src/store.ts +19 -5
  44. package/test/36-already-answered-fusion.test.mjs +128 -0
  45. package/test/37-cluster-dispersion-fusion.test.mjs +190 -0
  46. package/test/38-reason-restate-guard.test.mjs +94 -0
  47. package/test/39-cast-restate-guard.test.mjs +102 -0
  48. package/test/40-choosenext-scale-guard.test.mjs +75 -0
  49. package/test/41-seatofnode-direction.test.mjs +85 -0
  50. package/test/42-recognise-trace-idempotence.test.mjs +106 -0
  51. package/test/43-cast-analog-seat.test.mjs +244 -0
  52. package/test/44-recognise-edge-whitespace.test.mjs +63 -0
  53. package/test/45-liftanswer-restated-trim.test.mjs +60 -0
  54. package/test/46-recognise-multibyte-edge.test.mjs +85 -0
  55. package/test/47-cast-comparison-coverage.test.mjs +134 -0
  56. package/test/48-recognise-turn-connective.test.mjs +125 -0
  57. package/test/49-natural-units-synonym-bridge.test.mjs +175 -0
  58. package/test/50-cast-analog-consensus-floor.test.mjs +179 -0
  59. package/test/51-structural-resonance-ladder.test.mjs +552 -0
  60. package/test/52-climb-consensus-instrumentation.test.mjs +324 -0
@@ -25,23 +25,288 @@ import type {
25
25
  Region,
26
26
  RegionVote,
27
27
  SaturationInfo,
28
+ SaturationStop,
28
29
  } from "./types.js";
29
- import { consensusFloor, dominates, estimatorNoise } from "../geometry.js";
30
+ import {
31
+ composeStructuralGist,
32
+ consensusFloor,
33
+ dominates,
34
+ estimatorNoise,
35
+ type StructuralPart,
36
+ } from "../geometry.js";
30
37
  import { foldTree, gistOf, latin1Key, perceive, read } from "./primitives.js";
31
38
  import { recognise } from "./recognition.js";
32
39
  import { leafIdRun } from "./canonical.js";
33
- import { corpusN, edgeAncestors } from "./traverse.js";
40
+ import { corpusN, edgeAncestors, hubBound } from "./traverse.js";
34
41
  import {
35
42
  cachedRead,
43
+ type Junction,
36
44
  junctionContainersFrom,
37
45
  junctionSeeds,
38
46
  junctionSynonyms,
47
+ type JunctionSynonymSides,
48
+ loadJunctionSynonymSides,
49
+ type SynonymJunction,
39
50
  walkCache,
40
51
  } from "./junction.js";
52
+ import type { Vec } from "../vec.js";
41
53
  import { indexOf } from "../bytes.js";
42
54
  import type { RationaleItem } from "./rationale.js";
43
55
  import { rDeriv, rItem, rNode, traceDerivation } from "./trace.js";
44
56
 
57
+ // ═══════════════════════════════════════════════════════════════════════════
58
+ // climbConsensus / inspectRationale instrumentation.
59
+ //
60
+ // Purely additive tracing over the consensus climb: it never changes an
61
+ // inference result or the human-readable rationale text traceAttention
62
+ // already produces (see below) — it only exposes, as one structured `data`
63
+ // payload on the SAME "climbConsensus" step, the machinery that produced
64
+ // that text: every structural saturation stop, candidate breadth versus
65
+ // evidence that actually contributed, and the decisions that removed or
66
+ // accepted evidence (§ objective of the instrumentation spec).
67
+ //
68
+ // The mutable collection buffers (the `TraceDraft` below) are allocated ONLY
69
+ // when `ctx.trace` is set — every call site that would otherwise push onto
70
+ // one of these arrays or sets is gated by `td?.` / `if (td)`, so a plain
71
+ // (untraced) climb pays exactly zero allocation for this instrumentation.
72
+ // ═══════════════════════════════════════════════════════════════════════════
73
+
74
+ /** How the newly-added graded junction ladder (junction.ts / attention.ts's
75
+ * {@link CrossRegionTier}) is reported on a `junctionVotes` entry. The
76
+ * instrumentation spec this implements predates that ladder and only knew
77
+ * two tiers ("exact" | "synonym"); with the richer `CrossRegionTier` now
78
+ * the real shape of a junction vote's provenance, `junctionVotes[].tier`
79
+ * reports it DIRECTLY (the tier as-is: "exact" | "single-synonym" |
80
+ * "double-synonym" | "structural-resonance") rather than collapsing every
81
+ * non-exact tier into a lossy "synonym" bucket — the whole point of
82
+ * exposing `tier` here is to let a debugger tell a halo-sibling
83
+ * substitution apart from a structural-resonance ANN guess, which the
84
+ * spec's original two-value type cannot do. */
85
+ export type ClimbConsensusJunctionTier = CrossRegionTier;
86
+
87
+ export type RegionOutcome =
88
+ | "voted"
89
+ | "no-ann-hit"
90
+ | "no-structural-reach"
91
+ | "saturated-abstention"
92
+ | "nonpositive-df-weight"
93
+ | "contrastive-margin-rejection";
94
+
95
+ export interface ConsensusRegionTrace {
96
+ index: number;
97
+ source: "perceived" | "recognised";
98
+ span: [number, number];
99
+ chunk: boolean;
100
+ known: boolean;
101
+
102
+ canonicalId?: number;
103
+ canonicalUsable: boolean;
104
+ canonicalFailed: boolean;
105
+
106
+ annQueried: boolean;
107
+ annHitsReturned: number;
108
+ annHitsExamined: number;
109
+
110
+ selected?: {
111
+ source: "canonical" | "ann";
112
+ node: number;
113
+ rank?: number;
114
+ score: number;
115
+ fallback?: "orphan" | "saturated-tie";
116
+ };
117
+
118
+ reachNode?: number;
119
+ outcome: RegionOutcome;
120
+
121
+ idf?: number;
122
+ dfWeight?: number;
123
+
124
+ contrastiveMargin?: number;
125
+ contrastiveNoiseFloor?: number;
126
+
127
+ mutualWeight?: number;
128
+ voteWeightPerRoot?: number;
129
+ focusWeightPerRoot?: number;
130
+
131
+ ordinaryVoteProduced: boolean;
132
+ superseded: boolean;
133
+ }
134
+
135
+ export interface ConsensusReachTrace {
136
+ node: number;
137
+ roots: number[];
138
+ contextsReached: number;
139
+ saturated: boolean;
140
+ saturation?: SaturationStop;
141
+ }
142
+
143
+ export type AnchorRejectionReason =
144
+ | "below-natural-break"
145
+ | "below-consensus-floor"
146
+ | "leading-saturation";
147
+
148
+ export interface ConsensusAnchorTrace {
149
+ anchor: number;
150
+ rank: number;
151
+
152
+ pooledVote: number;
153
+ idfVote: number;
154
+
155
+ candidateBreadth: number;
156
+ contributingVotes: number;
157
+ contributingEvidence: number;
158
+ breadth: number;
159
+ contributingSpans: Array<[number, number]>;
160
+ clusters: number;
161
+
162
+ commit: {
163
+ status: "root" | "overlap" | "rejected";
164
+ dominant: boolean;
165
+ passesNaturalBreak?: boolean;
166
+ passesConsensusFloor?: boolean;
167
+ pastLeadingSaturation?: boolean;
168
+ rejectionReasons: AnchorRejectionReason[];
169
+ };
170
+ }
171
+
172
+ export interface JunctionVoteTrace {
173
+ container: number;
174
+ span: [number, number];
175
+ roots: number[];
176
+ sourceRegionIndices: number[];
177
+ explainedAwayRegionIndices: number[];
178
+ absorbed: number;
179
+ tier?: ClimbConsensusJunctionTier;
180
+ }
181
+
182
+ export interface ClimbConsensusData {
183
+ version: 1;
184
+
185
+ cache: {
186
+ hit: boolean;
187
+ detailAvailable: boolean;
188
+ };
189
+
190
+ config: {
191
+ annK: number;
192
+ crossRegionProbeLimit: number;
193
+ mode: DFMode;
194
+ corpusN?: number;
195
+ dimension?: number;
196
+ hubBound?: number;
197
+ estimatorNoise?: number;
198
+ naturalBreak?: number;
199
+ consensusFloor?: number;
200
+ };
201
+
202
+ candidates: {
203
+ perceived: number;
204
+ recognised: number;
205
+ total: number;
206
+ };
207
+
208
+ regions?: ConsensusRegionTrace[];
209
+ reaches?: ConsensusReachTrace[];
210
+
211
+ crossRegion?: {
212
+ eligibleRegions: number;
213
+ maximalRegions: number;
214
+ probeLimit: number;
215
+ probesAttempted: number;
216
+ junctionVotes: JunctionVoteTrace[];
217
+ supersededOrdinaryVotes: number;
218
+ };
219
+
220
+ saturation?: {
221
+ regionIntervals: Array<{ start: number; end: number }>;
222
+ hasLeading: boolean;
223
+ leadingEnd: number;
224
+ };
225
+
226
+ pooling?: {
227
+ inputVotes: number;
228
+ eligibleVotes: number;
229
+ saturationMaskedVotes: number;
230
+ };
231
+
232
+ anchors?: ConsensusAnchorTrace[];
233
+
234
+ result: AttentionRead;
235
+ }
236
+
237
+ /** The mutable collection buffers threaded through one traced consensus
238
+ * climb — allocated exactly once, in {@link computeAttention}, only when
239
+ * `ctx.trace` is set. Every field mirrors a `ClimbConsensusData` array/map,
240
+ * built incrementally as the pipeline runs so commit-time decisions (in
241
+ * particular) are recorded LIVE, not reconstructed afterward. */
242
+ interface TraceDraft {
243
+ perceivedCount: number;
244
+ regions: ConsensusRegionTrace[];
245
+ crossRegionJunctionVotes: JunctionVoteTrace[];
246
+ crossRegionSummary?: {
247
+ eligibleRegions: number;
248
+ maximalRegions: number;
249
+ probeLimit: number;
250
+ probesAttempted: number;
251
+ };
252
+ supersededOrdinaryVotes: number;
253
+ saturation?: {
254
+ regionIntervals: Array<{ start: number; end: number }>;
255
+ hasLeading: boolean;
256
+ leadingEnd: number;
257
+ };
258
+ pooling?: {
259
+ inputVotes: number;
260
+ eligibleVotes: number;
261
+ saturationMaskedVotes: number;
262
+ };
263
+ anchors: ConsensusAnchorTrace[];
264
+ }
265
+
266
+ /** The config/corpus context {@link traceAttention} needs to fill in
267
+ * `ClimbConsensusData.config` and `.result` at whichever exit fires —
268
+ * threaded down from {@link computeAttention} rather than re-derived, so
269
+ * every emission point reports the SAME numbers the real climb used. */
270
+ interface ClimbConsensusCfg {
271
+ k: number;
272
+ mode: DFMode;
273
+ perceivedCount: number;
274
+ totalRegions: number;
275
+ N?: number;
276
+ reachMemo?: ReadonlyMap<number, AncestorReach>;
277
+ naturalBreak?: number;
278
+ consensusFloor?: number;
279
+ }
280
+
281
+ function newTraceDraft(perceivedCount: number): TraceDraft {
282
+ return {
283
+ perceivedCount,
284
+ regions: [],
285
+ crossRegionJunctionVotes: [],
286
+ supersededOrdinaryVotes: 0,
287
+ anchors: [],
288
+ };
289
+ }
290
+
291
+ /** Serialise the shared `reachMemo` into the plain, authoritative saturation
292
+ * profile (spec §5) — every distinct node any tier's `edgeAncestors` call
293
+ * climbed from during this response, in insertion (first-consulted) order. */
294
+ function serialiseReaches(
295
+ reachMemo: ReadonlyMap<number, AncestorReach>,
296
+ ): ConsensusReachTrace[] {
297
+ const out: ConsensusReachTrace[] = [];
298
+ for (const [node, r] of reachMemo) {
299
+ out.push({
300
+ node,
301
+ roots: [...r.roots],
302
+ contextsReached: r.contextsReached,
303
+ saturated: r.saturated,
304
+ ...(r.saturation ? { saturation: r.saturation } : {}),
305
+ });
306
+ }
307
+ return out;
308
+ }
309
+
45
310
  // ── Public entry points ───────────────────────────────────────────────────
46
311
 
47
312
  /** Climb the query's perceived byte regions up the structural DAG via
@@ -57,8 +322,17 @@ export async function climbAttention(
57
322
  }
58
323
 
59
324
  /** Full read-out of one consensus climb: both the roots (dominant points of
60
- * attention) and the entire ranked list. Cached via ctx.climbMemo when
61
- * ctx.trace is null. */
325
+ * attention) and the entire ranked list. Cached via ctx.climbMemo, ALWAYS —
326
+ * see {@link recognise} for why this memo (and recognise()'s own) must
327
+ * never be skipped while tracing: computeAttention's collectRegions walks
328
+ * the query's perceived tree via the same foldTree whose subtree-resolution
329
+ * fast path makes a second call on identical bytes non-idempotent once
330
+ * ctx._resolvedSubtrees is warm (which a multi-turn conversation's shared
331
+ * prefix subtrees guarantee by the second turn). A cache hit still emits
332
+ * a trace step — abbreviated, since the full per-sub-region voting detail
333
+ * {@link traceAttention} builds isn't preserved by the cached read-out —
334
+ * so a traced response is never silently blacked out for a repeated
335
+ * query. */
62
336
  export async function climbAttentionAll(
63
337
  ctx: MindContext,
64
338
  query: Uint8Array,
@@ -66,8 +340,8 @@ export async function climbAttentionAll(
66
340
  mode: DFMode = "inverse",
67
341
  ): Promise<AttentionRead> {
68
342
  // Content-keyed memo — works for both single-turn respond() and multi-turn
69
- // respondTurn(). Skipped while tracing.
70
- if (ctx.climbMemo && !ctx.trace) {
343
+ // respondTurn().
344
+ if (ctx.climbMemo) {
71
345
  const contentKey = latin1Key(query);
72
346
  const modeKey = `${k}:${mode}`;
73
347
  let byRead = ctx.climbMemo.get(contentKey);
@@ -75,7 +349,31 @@ export async function climbAttentionAll(
75
349
  ctx.climbMemo.set(contentKey, byRead = new Map());
76
350
  }
77
351
  const hit = byRead.get(modeKey);
78
- if (hit !== undefined) return hit;
352
+ if (hit !== undefined) {
353
+ // Cache-hit exit (spec §9): the abbreviated payload shape — only what
354
+ // is actually stored in the cached AttentionRead is reported. No
355
+ // candidate, reach, saturation, pooling or anchor detail is fabricated
356
+ // (that per-region detail was never retained by the memo).
357
+ const data: ClimbConsensusData | undefined = ctx.trace
358
+ ? {
359
+ version: 1,
360
+ cache: { hit: true, detailAvailable: false },
361
+ config: { annK: k, crossRegionProbeLimit: k, mode },
362
+ candidates: { perceived: 0, recognised: 0, total: 0 },
363
+ result: hit,
364
+ }
365
+ : undefined;
366
+ ctx.trace?.step(
367
+ "climbConsensus",
368
+ [rItem(query, "query")],
369
+ hit.roots.map((r) => rNode(ctx, r.anchor, "anchor", r.vote)),
370
+ `(cached) consensus already computed for this query — ` +
371
+ `${hit.roots.length} point(s) of attention`,
372
+ undefined,
373
+ data,
374
+ );
375
+ return hit;
376
+ }
79
377
  const read = await computeAttention(ctx, query, k, mode);
80
378
  byRead.set(modeKey, read);
81
379
  return read;
@@ -92,6 +390,7 @@ export async function computeAttention(
92
390
  mode: DFMode,
93
391
  ): Promise<AttentionRead> {
94
392
  const regions = collectRegions(ctx, query);
393
+ const perceivedCount = regions.length;
95
394
 
96
395
  // Recognised sites carry structural evidence that perceived sub-regions
97
396
  // miss: a word crossing a W-boundary is split into chunks whose partial
@@ -112,14 +411,30 @@ export async function computeAttention(
112
411
  });
113
412
  }
114
413
 
115
- if (regions.length === 0) return { roots: [], ranked: [] };
414
+ // The trace draft (spec §9): allocated ONLY when a trace was requested —
415
+ // every downstream consumer gates its own writes on `td?` / `if (td)`, so
416
+ // an untraced climb pays zero allocation for this instrumentation.
417
+ const td: TraceDraft | undefined = ctx.trace
418
+ ? newTraceDraft(perceivedCount)
419
+ : undefined;
420
+ const cfg0: ClimbConsensusCfg = {
421
+ k,
422
+ mode,
423
+ perceivedCount,
424
+ totalRegions: regions.length,
425
+ };
426
+
427
+ if (regions.length === 0) {
428
+ traceAttention(ctx, [], [], [], undefined, td, cfg0);
429
+ return { roots: [], ranked: [] };
430
+ }
116
431
 
117
432
  const N = corpusN(ctx);
118
433
  // One climb per distinct anchor for the WHOLE query: regions sharing a
119
434
  // chunk, and canonicalChunkId's prefix probes, all hit this memo instead of
120
435
  // re-reading the anchor's full edge fan-out from the store.
121
436
  const reachMemo = new Map<number, AncestorReach>();
122
- const rvs = await voteRegions(ctx, query, regions, k, mode, N, reachMemo);
437
+ const rvs = await voteRegions(ctx, query, regions, k, mode, N, reachMemo, td);
123
438
 
124
439
  // ── Cross-region: DIRECT region-to-region interaction ─────────────────
125
440
  // Two regions whose individual climbs land on DIFFERENT contexts leave
@@ -134,6 +449,7 @@ export async function computeAttention(
134
449
  k,
135
450
  N,
136
451
  reachMemo,
452
+ td,
137
453
  );
138
454
  // A vote SUPERSEDED by exact joint evidence (its bytes literally live
139
455
  // inside the joint container, yet it climbed elsewhere — grid aliasing)
@@ -144,16 +460,37 @@ export async function computeAttention(
144
460
  ...cross.votes,
145
461
  ]
146
462
  : rvs.votes;
463
+ // Mark, on the per-region trace, the source region of every superseded
464
+ // ordinary vote (spec §4's final rule) — an explicit pass over the exact
465
+ // set crossRegionVotes' explaining-away logic removed, never inferred
466
+ // from `absorbed`.
467
+ if (td && cross.superseded.size > 0) {
468
+ for (const rv of cross.superseded) {
469
+ const region = td.regions.find(
470
+ (r) => r.span[0] === rv.start && r.span[1] === rv.end,
471
+ );
472
+ if (region) region.superseded = true;
473
+ }
474
+ }
147
475
  // ──────────────────────────────────────────────────────────────────────
148
476
 
477
+ const cfg: ClimbConsensusCfg = { ...cfg0, N, reachMemo };
478
+
149
479
  if (allVotes.length === 0) {
150
- traceAttention(ctx, regions, rvs.voters, []);
480
+ traceAttention(ctx, regions, rvs.voters, [], undefined, td, cfg);
151
481
  return { roots: [], ranked: [] };
152
482
  }
153
483
 
154
484
  const sat = detectSaturated(ctx, regions, rvs.saturated);
155
- const pooled = poolVotes(ctx, allVotes, sat, N);
156
- return commitVotes(ctx, pooled, sat, regions, rvs.voters, N);
485
+ if (td) {
486
+ td.saturation = {
487
+ regionIntervals: sat.intervals.map((iv) => ({ ...iv })),
488
+ hasLeading: sat.hasLeading,
489
+ leadingEnd: sat.leadingEnd,
490
+ };
491
+ }
492
+ const pooled = poolVotes(ctx, allVotes, sat, N, td);
493
+ return commitVotes(ctx, pooled, sat, regions, rvs.voters, N, td, cfg);
157
494
  }
158
495
 
159
496
  export function collectRegions(ctx: MindContext, query: Uint8Array): Region[] {
@@ -194,6 +531,7 @@ export async function voteRegions(
194
531
  mode: DFMode,
195
532
  N: number,
196
533
  reachMemo?: Map<number, AncestorReach>,
534
+ td?: TraceDraft,
197
535
  ): Promise<{
198
536
  votes: RegionVote[];
199
537
  saturated: boolean[];
@@ -206,6 +544,37 @@ export async function voteRegions(
206
544
 
207
545
  for (let ri = 0; ri < regions.length; ri++) {
208
546
  const { v, start, end, chunk, known } = regions[ri];
547
+ // Trace-only bookkeeping for this region — allocated only under `td`
548
+ // (i.e. only when ctx.trace is set); see ConsensusRegionTrace/
549
+ // RegionOutcome (spec §4). `examinedIds` tracks distinct ANN hits
550
+ // whose edgeAncestors reach was actually CONSULTED here (not merely
551
+ // returned by resonate) — the fallback/margin loops below add to it.
552
+ const examinedIds = td ? new Set<number>() : undefined;
553
+ let annQueried = false;
554
+ let fallbackKind: "orphan" | "saturated-tie" | undefined;
555
+ const recordRegion = (
556
+ outcome: RegionOutcome,
557
+ extra: Partial<ConsensusRegionTrace> = {},
558
+ ) => {
559
+ if (!td) return;
560
+ td.regions[ri] = {
561
+ index: ri,
562
+ source: ri < td.perceivedCount ? "perceived" : "recognised",
563
+ span: [start, end],
564
+ chunk,
565
+ known,
566
+ canonicalId: canonicalId ?? undefined,
567
+ canonicalUsable,
568
+ canonicalFailed,
569
+ annQueried,
570
+ annHitsReturned: hits ? hits.length : 0,
571
+ annHitsExamined: examinedIds ? examinedIds.size : 0,
572
+ outcome,
573
+ ordinaryVoteProduced: outcome === "voted",
574
+ superseded: false,
575
+ ...extra,
576
+ };
577
+ };
209
578
 
210
579
  // EXACT-FIRST: a chunk whose canonical anchor is content-addressed needs
211
580
  // no estimator — identity is exact, so its score is 1 BY DEFINITION (the
@@ -225,23 +594,35 @@ export async function voteRegions(
225
594
  (ctx.store.hasParents(canonicalId) ||
226
595
  ctx.store.hasContainers(canonicalId));
227
596
  let hits: readonly Hit[] | null = null;
228
- const ensureHits = async (): Promise<readonly Hit[]> =>
229
- hits ??= await ctx.store.resonate(v, k);
597
+ const ensureHits = async (): Promise<readonly Hit[]> => {
598
+ if (hits === null) {
599
+ hits = await ctx.store.resonate(v, k);
600
+ annQueried = true;
601
+ }
602
+ return hits;
603
+ };
230
604
 
231
605
  const canonicalFailed = chunk && canonicalId === null;
232
606
  let voterId: number;
233
607
  let score: number;
234
608
  let scoreId: number; // the node the score was measured against
609
+ let selectedSource: "canonical" | "ann";
235
610
  if (canonicalUsable) {
236
611
  voterId = canonicalId!;
237
612
  score = 1;
238
613
  scoreId = canonicalId!;
614
+ selectedSource = "canonical";
239
615
  } else {
240
616
  const h = await ensureHits();
241
- if (h.length === 0) continue;
617
+ if (h.length === 0) {
618
+ recordRegion("no-ann-hit");
619
+ continue;
620
+ }
242
621
  voterId = h[0].id;
243
622
  score = h[0].score;
244
623
  scoreId = h[0].id;
624
+ selectedSource = "ann";
625
+ examinedIds?.add(voterId);
245
626
  }
246
627
 
247
628
  let reach = edgeAncestors(ctx, voterId, N, reachMemo);
@@ -255,6 +636,7 @@ export async function voteRegions(
255
636
  for (const h of await ensureHits()) {
256
637
  if (h.id === voterId) continue;
257
638
  const r2 = edgeAncestors(ctx, h.id, N, reachMemo);
639
+ examinedIds?.add(h.id);
258
640
  if (r2.saturated || r2.roots.length > 0) {
259
641
  ctx.trace?.step(
260
642
  "anchorFallback",
@@ -266,6 +648,8 @@ export async function voteRegions(
266
648
  voterId = h.id;
267
649
  score = h.score;
268
650
  scoreId = h.id;
651
+ selectedSource = "ann";
652
+ fallbackKind = "orphan";
269
653
  break;
270
654
  }
271
655
  }
@@ -289,6 +673,7 @@ export async function voteRegions(
289
673
  if (h.id === voterId) continue;
290
674
  if (h.score < score - band) break; // hits are nearest-first
291
675
  const r2 = edgeAncestors(ctx, h.id, N, reachMemo);
676
+ examinedIds?.add(h.id);
292
677
  if (!r2.saturated && r2.roots.length > 0) {
293
678
  ctx.trace?.step(
294
679
  "anchorFallback",
@@ -300,20 +685,52 @@ export async function voteRegions(
300
685
  voterId = h.id;
301
686
  score = h.score;
302
687
  scoreId = h.id;
688
+ selectedSource = "ann";
689
+ fallbackKind = "saturated-tie";
303
690
  break;
304
691
  }
305
692
  }
306
693
  }
307
694
  regionSaturated[ri] = reach.saturated;
308
- if (reach.roots.length === 0) continue;
309
- if (reach.saturated) continue;
695
+ const selected: ConsensusRegionTrace["selected"] | undefined = !td
696
+ ? undefined
697
+ : (() => {
698
+ const rank: number | undefined = selectedSource === "ann"
699
+ ? (hits as readonly Hit[] | null)?.findIndex((h: Hit) =>
700
+ h.id === voterId
701
+ )
702
+ : undefined;
703
+ return {
704
+ source: selectedSource,
705
+ node: voterId,
706
+ score,
707
+ ...(rank !== undefined ? { rank } : {}),
708
+ ...(fallbackKind ? { fallback: fallbackKind } : {}),
709
+ };
710
+ })();
711
+ if (reach.roots.length === 0) {
712
+ recordRegion("no-structural-reach", { selected, reachNode: voterId });
713
+ continue;
714
+ }
715
+ if (reach.saturated) {
716
+ recordRegion("saturated-abstention", { selected, reachNode: voterId });
717
+ continue;
718
+ }
310
719
 
311
720
  // One IDF per region — dfWeight() and the focus weight used to compute
312
721
  // the same logarithm independently.
313
722
  const idf = Math.log(N / Math.max(1, reach.contextsReached));
314
723
  const df = Math.log(1 + reach.contextsReached);
315
724
  const wf = mode === "direct" ? df : mode === "combined" ? idf + df : idf;
316
- if (wf <= 0) continue;
725
+ if (wf <= 0) {
726
+ recordRegion("nonpositive-df-weight", {
727
+ selected,
728
+ reachNode: voterId,
729
+ idf,
730
+ dfWeight: wf,
731
+ });
732
+ continue;
733
+ }
317
734
 
318
735
  // CONTRASTIVE-MARGIN GATE — the compensation the linear (byte-proportional)
319
736
  // fold demands, applied to APPROXIMATE evidence only. Under the linear
@@ -338,17 +755,31 @@ export async function voteRegions(
338
755
  // (reordered / paraphrased queries) below the floor so they grounded
339
756
  // nothing. Gating at the noise floor keeps frame-echo suppression (a frame
340
757
  // region's margin ≈ 0 is gated out) without penalising honest evidence.
758
+ let contrastiveMargin: number | undefined;
341
759
  if (!known) {
342
760
  let margin = score;
343
761
  for (const h of await ensureHits()) {
344
762
  if (h.id === voterId) continue;
345
763
  const r2 = edgeAncestors(ctx, h.id, N, reachMemo);
764
+ examinedIds?.add(h.id);
346
765
  if (r2.saturated || r2.roots.length === 0) continue; // concludes nothing
347
766
  if (sameRoots(r2.roots, reach.roots)) continue; // same conclusion
348
767
  margin = score - h.score; // hits are nearest-first: the best rival
349
768
  break;
350
769
  }
351
- if (margin <= estimatorNoise(ctx.store.D)) continue;
770
+ contrastiveMargin = margin;
771
+ const noiseFloor = estimatorNoise(ctx.store.D);
772
+ if (margin <= noiseFloor) {
773
+ recordRegion("contrastive-margin-rejection", {
774
+ selected,
775
+ reachNode: voterId,
776
+ idf,
777
+ dfWeight: wf,
778
+ contrastiveMargin: margin,
779
+ contrastiveNoiseFloor: noiseFloor,
780
+ });
781
+ continue;
782
+ }
352
783
  }
353
784
 
354
785
  // MUTUAL-EXPLANATION WEIGHT (angle + magnitude). Under the linear fold
@@ -388,6 +819,21 @@ export async function voteRegions(
388
819
  if (ctx.trace) {
389
820
  regionVoter[ri] = { id: voterId, score, w: wf };
390
821
  }
822
+ recordRegion("voted", {
823
+ selected,
824
+ reachNode: voterId,
825
+ idf,
826
+ dfWeight: wf,
827
+ ...(contrastiveMargin !== undefined
828
+ ? {
829
+ contrastiveMargin,
830
+ contrastiveNoiseFloor: estimatorNoise(ctx.store.D),
831
+ }
832
+ : {}),
833
+ mutualWeight: mutual,
834
+ voteWeightPerRoot: w,
835
+ focusWeightPerRoot: wFocus,
836
+ });
391
837
  }
392
838
  return {
393
839
  votes: regionVotes,
@@ -413,6 +859,7 @@ export function poolVotes(
413
859
  regionVotes: readonly RegionVote[],
414
860
  sat: SaturationInfo,
415
861
  N: number,
862
+ td?: TraceDraft,
416
863
  ): {
417
864
  votes: Map<number, number>;
418
865
  votesIdf: Map<number, number>;
@@ -420,6 +867,8 @@ export function poolVotes(
420
867
  /** Per-anchor SCALE-INVARIANT support: Σ RegionVote.absorbed over the
421
868
  * distinct contributing regions — see Attention.breadth. */
422
869
  regionSupport: Map<number, number>;
870
+ /** Per-anchor contributing region spans — see Attention.clusters. */
871
+ regionSpans: Map<number, Array<[number, number]>>;
423
872
  steps: DerivationStep[];
424
873
  } {
425
874
  const eligible: number[] = [];
@@ -433,6 +882,13 @@ export function poolVotes(
433
882
  }
434
883
  eligible.push(ri);
435
884
  }
885
+ if (td) {
886
+ td.pooling = {
887
+ inputVotes: regionVotes.length,
888
+ eligibleVotes: eligible.length,
889
+ saturationMaskedVotes: regionVotes.length - eligible.length,
890
+ };
891
+ }
436
892
 
437
893
  const key = (it: AItem) =>
438
894
  it.kind === "region"
@@ -500,6 +956,7 @@ export function poolVotes(
500
956
  { start: number; end: number; w: number }
501
957
  >();
502
958
  const regionSupport = new Map<number, number>();
959
+ const regionSpans = new Map<number, Array<[number, number]>>();
503
960
  const steps: DerivationStep[] = [];
504
961
  let order = 0;
505
962
  for (const pc of pool.values()) {
@@ -508,6 +965,7 @@ export function poolVotes(
508
965
  const premises: DerivationItem[] = [];
509
966
  const seenRi = new Set<number>();
510
967
  let breadthSum = 0;
968
+ const spans: Array<[number, number]> = [];
511
969
  for (const c of pc.contributions) {
512
970
  const p0 = c.premises[0].item;
513
971
  if (p0.kind !== "region" || seenRi.has(p0.ri)) continue;
@@ -515,8 +973,10 @@ export function poolVotes(
515
973
  const rv = regionVotes[p0.ri];
516
974
  breadthSum += rv.absorbed ?? 1;
517
975
  premises.push({ kind: "form", span: [rv.start, rv.end] });
976
+ spans.push([rv.start, rv.end]);
518
977
  }
519
978
  regionSupport.set(pc.item.id, breadthSum);
979
+ regionSpans.set(pc.item.id, spans);
520
980
  steps.push({
521
981
  order: order++,
522
982
  move: "pool-vote",
@@ -543,7 +1003,35 @@ export function poolVotes(
543
1003
  }
544
1004
  }
545
1005
  }
546
- return { votes, votesIdf, support, regionSupport, steps };
1006
+ return { votes, votesIdf, support, regionSupport, regionSpans, steps };
1007
+ }
1008
+
1009
+ /** The number of DISTINCT clusters a root's contributing regions form —
1010
+ * see Attention.clusters. Two regions belong to the same cluster iff the
1011
+ * gap between them is strictly less than one river-fold quantum W: at
1012
+ * that distance there is no room for a genuinely separate, independently
1013
+ * perceivable unit of content between them (the same "smallest meaningful
1014
+ * distinction" quantum {@link reachThreshold}'s own doc invokes). A gap
1015
+ * of a full quantum or more means real, separate structure could sit
1016
+ * between the two spans, so they count as independent corroboration.
1017
+ * Strict `<` (not `<=`): verified against gap 3.1's own "gender equality"
1018
+ * root, whose two genuine clusters sit EXACTLY W bytes apart — `<= W`
1019
+ * would wrongly merge them into one and break that pinned requirement. */
1020
+ function countClusters(spans: readonly [number, number][], W: number): number {
1021
+ if (spans.length === 0) return 0;
1022
+ const sorted = [...spans].sort((a, b) => a[0] - b[0]);
1023
+ let clusters = 1;
1024
+ let curEnd = sorted[0][1];
1025
+ for (let i = 1; i < sorted.length; i++) {
1026
+ const [s, e] = sorted[i];
1027
+ if (s - curEnd < W) {
1028
+ curEnd = Math.max(curEnd, e);
1029
+ } else {
1030
+ clusters++;
1031
+ curEnd = e;
1032
+ }
1033
+ }
1034
+ return clusters;
547
1035
  }
548
1036
 
549
1037
  export function commitVotes(
@@ -553,16 +1041,20 @@ export function commitVotes(
553
1041
  votesIdf: Map<number, number>;
554
1042
  support: Map<number, { start: number; end: number; w: number }>;
555
1043
  regionSupport: Map<number, number>;
1044
+ regionSpans: Map<number, Array<[number, number]>>;
556
1045
  steps: DerivationStep[];
557
1046
  },
558
1047
  sat: SaturationInfo,
559
1048
  regions: readonly Region[],
560
1049
  regionVoter: ReadonlyArray<{ id: number; score: number; w: number } | null>,
561
1050
  N: number,
1051
+ td?: TraceDraft,
1052
+ cfg?: ClimbConsensusCfg,
562
1053
  ): AttentionRead {
563
- const { votes, votesIdf, support, regionSupport, steps } = pooled;
1054
+ const { votes, votesIdf, support, regionSupport, regionSpans, steps } =
1055
+ pooled;
564
1056
  if (votes.size === 0) {
565
- traceAttention(ctx, regions, regionVoter, [], steps);
1057
+ traceAttention(ctx, regions, regionVoter, [], steps, td, cfg);
566
1058
  return { roots: [], ranked: [] };
567
1059
  }
568
1060
 
@@ -580,6 +1072,10 @@ export function commitVotes(
580
1072
  start: s.start,
581
1073
  end: s.end,
582
1074
  breadth: (regionSupport.get(anchor) ?? 0) / totalRegions,
1075
+ clusters: countClusters(
1076
+ regionSpans.get(anchor) ?? [],
1077
+ ctx.space.maxGroup,
1078
+ ),
583
1079
  };
584
1080
  })
585
1081
  .sort((a, b) => b.vote - a.vote);
@@ -600,25 +1096,121 @@ export function commitVotes(
600
1096
  const floor = consensusFloor(N);
601
1097
  const placed: Attention[] = [];
602
1098
  const roots: Attention[] = [];
603
- for (const point of ranked) {
1099
+ const recordAnchor = (
1100
+ point: Attention,
1101
+ rank: number,
1102
+ status: "root" | "overlap" | "rejected",
1103
+ dominant: boolean,
1104
+ passesNaturalBreak: boolean | undefined,
1105
+ passesConsensusFloor: boolean | undefined,
1106
+ pastLeadingSaturation: boolean | undefined,
1107
+ rejectionReasons: AnchorRejectionReason[],
1108
+ ) => {
1109
+ if (!td) return;
1110
+ td.anchors.push({
1111
+ anchor: point.anchor,
1112
+ rank,
1113
+ pooledVote: point.vote,
1114
+ idfVote: votesIdf.get(point.anchor) ?? 0,
1115
+ candidateBreadth: regions.length,
1116
+ contributingVotes: regionSpans.get(point.anchor)?.length ?? 0,
1117
+ contributingEvidence: regionSupport.get(point.anchor) ?? 0,
1118
+ breadth: point.breadth,
1119
+ contributingSpans: regionSpans.get(point.anchor) ?? [],
1120
+ clusters: point.clusters,
1121
+ commit: {
1122
+ status,
1123
+ dominant,
1124
+ passesNaturalBreak,
1125
+ passesConsensusFloor,
1126
+ pastLeadingSaturation,
1127
+ rejectionReasons,
1128
+ },
1129
+ });
1130
+ };
1131
+ for (let rank = 0; rank < ranked.length; rank++) {
1132
+ const point = ranked[rank];
604
1133
  const absorbed = placed.some((p) => overlaps(point, p));
605
- if (!absorbed) {
1134
+ // Commit decisions are recorded LIVE, inside this loop, in the exact
1135
+ // shape the gates below apply them — never reconstructed afterward from
1136
+ // the final `roots` (spec §8's explicit requirement).
1137
+ let status: "root" | "overlap" | "rejected";
1138
+ let dominant = false;
1139
+ let passesNaturalBreak: boolean | undefined;
1140
+ let passesConsensusFloor: boolean | undefined;
1141
+ let pastLeadingSaturation: boolean | undefined;
1142
+ const rejectionReasons: AnchorRejectionReason[] = [];
1143
+ if (absorbed) {
1144
+ status = "overlap";
1145
+ } else {
606
1146
  const pastLeading = !sat.hasLeading ||
607
1147
  roots.length === 0 || point.start >= sat.leadingEnd;
1148
+ pastLeadingSaturation = pastLeading;
608
1149
  const vote = votesIdf.get(point.anchor) ?? 0;
609
- if (
610
- (roots.length === 0 || (vote >= rootCut && vote >= floor)) &&
611
- pastLeading
612
- ) {
1150
+ if (roots.length === 0) {
1151
+ // The first non-overlapping root is DOMINANT and bypasses the two
1152
+ // vote thresholds (it always grounds) — only the leading-saturation
1153
+ // gate still applies to it.
1154
+ dominant = true;
1155
+ if (pastLeading) {
1156
+ status = "root";
1157
+ } else {
1158
+ status = "rejected";
1159
+ rejectionReasons.push("leading-saturation");
1160
+ }
1161
+ } else {
1162
+ passesNaturalBreak = vote >= rootCut;
1163
+ passesConsensusFloor = vote >= floor;
1164
+ if (passesNaturalBreak && passesConsensusFloor && pastLeading) {
1165
+ status = "root";
1166
+ } else {
1167
+ status = "rejected";
1168
+ if (!passesNaturalBreak) rejectionReasons.push("below-natural-break");
1169
+ if (!passesConsensusFloor) {
1170
+ rejectionReasons.push("below-consensus-floor");
1171
+ }
1172
+ if (!pastLeading) rejectionReasons.push("leading-saturation");
1173
+ }
1174
+ }
1175
+ if (status === "root") {
613
1176
  roots.push(point);
614
1177
  } else {
1178
+ recordAnchor(
1179
+ point,
1180
+ rank,
1181
+ status,
1182
+ dominant,
1183
+ passesNaturalBreak,
1184
+ passesConsensusFloor,
1185
+ pastLeadingSaturation,
1186
+ rejectionReasons,
1187
+ );
615
1188
  continue;
616
1189
  }
617
1190
  }
1191
+ recordAnchor(
1192
+ point,
1193
+ rank,
1194
+ status,
1195
+ dominant,
1196
+ passesNaturalBreak,
1197
+ passesConsensusFloor,
1198
+ pastLeadingSaturation,
1199
+ rejectionReasons,
1200
+ );
618
1201
  placed.push(point);
619
1202
  }
620
1203
 
621
- traceAttention(ctx, regions, regionVoter, roots, steps);
1204
+ traceAttention(
1205
+ ctx,
1206
+ regions,
1207
+ regionVoter,
1208
+ roots,
1209
+ steps,
1210
+ td,
1211
+ cfg ? { ...cfg, naturalBreak: rootCut, consensusFloor: floor } : undefined,
1212
+ ranked,
1213
+ );
622
1214
  return { roots, ranked };
623
1215
  }
624
1216
 
@@ -785,6 +1377,266 @@ export function naturalBreak(votes: number[]): number {
785
1377
  // container does not hold (a genuine second topic) are untouched.
786
1378
  // ═══════════════════════════════════════════════════════════════════════════
787
1379
 
1380
+ // ── Structural-resonance — the FINAL approximate tier ──────────────────────
1381
+ //
1382
+ // Reached only when every DAG junction tier (exact, single-synonym, double-
1383
+ // synonym) found no container. Composes a hypothetical structural gist from
1384
+ // ALREADY-EXISTING structural vectors — the two endpoint regions' own gists
1385
+ // (or, per variant, a halo sibling's stored gist occupying the same slot)
1386
+ // plus the REAL middle-query structure between them — and asks the ANN index
1387
+ // what already-learnt whole resembles that composition. It never perceives
1388
+ // concatenated endpoint bytes and never fabricates a rewritten query string;
1389
+ // see {@link composeStructuralGist}.
1390
+
1391
+ export type CrossRegionTier =
1392
+ | "exact"
1393
+ | "single-synonym"
1394
+ | "double-synonym"
1395
+ | "structural-resonance";
1396
+
1397
+ export interface StructuralVariant {
1398
+ left: StructuralPart;
1399
+ right: StructuralPart;
1400
+ kind:
1401
+ | "exact-exact"
1402
+ | "left-synonym"
1403
+ | "right-synonym"
1404
+ | "double-synonym";
1405
+ semanticConfidence: number;
1406
+ leftSiblingId?: number;
1407
+ rightSiblingId?: number;
1408
+ }
1409
+
1410
+ export interface StructuralResonanceProposal {
1411
+ id: number;
1412
+ annScore: number;
1413
+ semanticConfidence: number;
1414
+ effectiveScore: number;
1415
+ variant: StructuralVariant["kind"];
1416
+ leftSiblingId?: number;
1417
+ rightSiblingId?: number;
1418
+ }
1419
+
1420
+ const VARIANT_KIND_ORDER: Record<StructuralVariant["kind"], number> = {
1421
+ "exact-exact": -1,
1422
+ "left-synonym": 0,
1423
+ "right-synonym": 1,
1424
+ "double-synonym": 2,
1425
+ };
1426
+
1427
+ /** A node's structural gist, read directly from its own stored bytes — the
1428
+ * repository's node → gist accessor: content is immutable and perception is
1429
+ * a pure function of bytes, so re-perceiving a node's full stored bytes
1430
+ * reproduces exactly the gist it was interned with. Never concatenates
1431
+ * the sibling's bytes with anything else. */
1432
+ function storedNodeGist(ctx: MindContext, id: number): Vec {
1433
+ return gistOf(ctx, read(ctx, id));
1434
+ }
1435
+
1436
+ /** A halo sibling's structural part, occupying the ORIGINAL query-region
1437
+ * slot length — the sibling replaces only the DIRECTION, never the query's
1438
+ * own bytes, position, or gap (see §6 of the spec this implements). */
1439
+ function siblingPart(
1440
+ ctx: MindContext,
1441
+ sibling: Hit,
1442
+ originalRegion: { start: number; end: number },
1443
+ ): StructuralPart {
1444
+ return {
1445
+ v: storedNodeGist(ctx, sibling.id),
1446
+ len: originalRegion.end - originalRegion.start,
1447
+ };
1448
+ }
1449
+
1450
+ /** Build, bound and order every mandatory structural variant (§7-8): the
1451
+ * exact/exact composition is always kept; up to `ctx.cfg.haloQueryK`
1452
+ * synonym variants (single- and double-synonym combined, one shared
1453
+ * budget) are appended, ordered by confidence, then kind, then sibling id. */
1454
+ export function buildStructuralVariants(
1455
+ ctx: MindContext,
1456
+ ra: Region,
1457
+ rb: Region,
1458
+ sides: JunctionSynonymSides,
1459
+ ): {
1460
+ variants: StructuralVariant[];
1461
+ exactLeft: StructuralPart;
1462
+ exactRight: StructuralPart;
1463
+ } {
1464
+ const exactLeft: StructuralPart = { v: ra.v, len: ra.end - ra.start };
1465
+ const exactRight: StructuralPart = { v: rb.v, len: rb.end - rb.start };
1466
+
1467
+ const synonymVariants: StructuralVariant[] = [];
1468
+ for (const ls of sides.leftSiblings) {
1469
+ synonymVariants.push({
1470
+ left: siblingPart(ctx, ls, ra),
1471
+ right: exactRight,
1472
+ kind: "left-synonym",
1473
+ semanticConfidence: ls.score,
1474
+ leftSiblingId: ls.id,
1475
+ });
1476
+ }
1477
+ for (const rs of sides.rightSiblings) {
1478
+ synonymVariants.push({
1479
+ left: exactLeft,
1480
+ right: siblingPart(ctx, rs, rb),
1481
+ kind: "right-synonym",
1482
+ semanticConfidence: rs.score,
1483
+ rightSiblingId: rs.id,
1484
+ });
1485
+ }
1486
+ for (const ls of sides.leftSiblings) {
1487
+ for (const rs of sides.rightSiblings) {
1488
+ synonymVariants.push({
1489
+ left: siblingPart(ctx, ls, ra),
1490
+ right: siblingPart(ctx, rs, rb),
1491
+ kind: "double-synonym",
1492
+ semanticConfidence: Math.min(ls.score, rs.score),
1493
+ leftSiblingId: ls.id,
1494
+ rightSiblingId: rs.id,
1495
+ });
1496
+ }
1497
+ }
1498
+
1499
+ // §8: semantic confidence desc, then kind (left-synonym, right-synonym,
1500
+ // double-synonym), then left sibling id asc, then right sibling id asc.
1501
+ synonymVariants.sort((a, b) =>
1502
+ b.semanticConfidence - a.semanticConfidence ||
1503
+ VARIANT_KIND_ORDER[a.kind] - VARIANT_KIND_ORDER[b.kind] ||
1504
+ (a.leftSiblingId ?? -1) - (b.leftSiblingId ?? -1) ||
1505
+ (a.rightSiblingId ?? -1) - (b.rightSiblingId ?? -1)
1506
+ );
1507
+
1508
+ const variants: StructuralVariant[] = [
1509
+ {
1510
+ left: exactLeft,
1511
+ right: exactRight,
1512
+ kind: "exact-exact",
1513
+ semanticConfidence: 1,
1514
+ },
1515
+ ...synonymVariants.slice(0, ctx.cfg.haloQueryK),
1516
+ ];
1517
+ return { variants, exactLeft, exactRight };
1518
+ }
1519
+
1520
+ /** Deterministic best-of tie-break for two proposals ranked for the SAME
1521
+ * candidate id — effectiveScore, then annScore, then semanticConfidence,
1522
+ * then variant kind, then sibling ids (§10). */
1523
+ function betterProposal(
1524
+ a: StructuralResonanceProposal,
1525
+ b: StructuralResonanceProposal,
1526
+ ): boolean {
1527
+ if (a.effectiveScore !== b.effectiveScore) {
1528
+ return a.effectiveScore > b.effectiveScore;
1529
+ }
1530
+ if (a.annScore !== b.annScore) return a.annScore > b.annScore;
1531
+ if (a.semanticConfidence !== b.semanticConfidence) {
1532
+ return a.semanticConfidence > b.semanticConfidence;
1533
+ }
1534
+ if (VARIANT_KIND_ORDER[a.variant] !== VARIANT_KIND_ORDER[b.variant]) {
1535
+ return VARIANT_KIND_ORDER[a.variant] < VARIANT_KIND_ORDER[b.variant];
1536
+ }
1537
+ if ((a.leftSiblingId ?? -1) !== (b.leftSiblingId ?? -1)) {
1538
+ return (a.leftSiblingId ?? -1) < (b.leftSiblingId ?? -1);
1539
+ }
1540
+ return (a.rightSiblingId ?? -1) < (b.rightSiblingId ?? -1);
1541
+ }
1542
+
1543
+ /** The final approximate tier: compose every retained structural variant,
1544
+ * ANN-query each, merge proposals by candidate id, and validate the winner
1545
+ * through the SAME structural gates every other tier answers to (saturation,
1546
+ * roots, IDF, contrastive margin). Returns null when nothing survives. */
1547
+ export async function structuralResonance(
1548
+ ctx: MindContext,
1549
+ query: Uint8Array,
1550
+ ra: Region,
1551
+ rb: Region,
1552
+ sides: JunctionSynonymSides,
1553
+ k: number,
1554
+ N: number,
1555
+ reachMemo: Map<number, AncestorReach>,
1556
+ /** Each side's OWN individual climb roots (from voteRegions), when it cast
1557
+ * one — the self-evidence backstop structural-resonance needs and the
1558
+ * exact tier gets for free from literal byte containment (§11's whole
1559
+ * premise: recover a JOINT context neither side votes for alone). A
1560
+ * candidate whose reach is exactly one side's own conclusion is not new
1561
+ * evidence of a joint whole; it is that side's resonance rediscovering
1562
+ * itself through a synthetic gist still dominated by its own direction. */
1563
+ ownRootsA: readonly number[] | undefined,
1564
+ ownRootsB: readonly number[] | undefined,
1565
+ ): Promise<
1566
+ | { proposal: StructuralResonanceProposal; reach: AncestorReach; idf: number }
1567
+ | null
1568
+ > {
1569
+ const { variants } = buildStructuralVariants(ctx, ra, rb, sides);
1570
+
1571
+ const middleBytes = query.subarray(ra.end, rb.start);
1572
+ const middlePart: StructuralPart | null = middleBytes.length === 0
1573
+ ? null
1574
+ : { v: perceive(ctx, middleBytes).v, len: middleBytes.length };
1575
+
1576
+ const proposals = new Map<number, StructuralResonanceProposal>();
1577
+ for (const variant of variants) {
1578
+ const parts: StructuralPart[] = [variant.left];
1579
+ if (middlePart) parts.push(middlePart);
1580
+ parts.push(variant.right);
1581
+ const synthetic = composeStructuralGist(ctx.space, parts);
1582
+ const hits = await ctx.store.resonate(synthetic, k);
1583
+ for (const hit of hits) {
1584
+ const candidate: StructuralResonanceProposal = {
1585
+ id: hit.id,
1586
+ annScore: hit.score,
1587
+ semanticConfidence: variant.semanticConfidence,
1588
+ effectiveScore: hit.score * variant.semanticConfidence,
1589
+ variant: variant.kind,
1590
+ leftSiblingId: variant.leftSiblingId,
1591
+ rightSiblingId: variant.rightSiblingId,
1592
+ };
1593
+ const prev = proposals.get(hit.id);
1594
+ if (prev === undefined || betterProposal(candidate, prev)) {
1595
+ proposals.set(hit.id, candidate);
1596
+ }
1597
+ }
1598
+ }
1599
+ if (proposals.size === 0) return null;
1600
+
1601
+ const sorted = [...proposals.values()].sort((a, b) =>
1602
+ b.effectiveScore - a.effectiveScore || a.id - b.id
1603
+ );
1604
+
1605
+ let selected: StructuralResonanceProposal | null = null;
1606
+ let selectedReach: AncestorReach | null = null;
1607
+ let selectedIdf = 0;
1608
+ let rival: StructuralResonanceProposal | null = null;
1609
+ for (const p of sorted) {
1610
+ const reach = edgeAncestors(ctx, p.id, N, reachMemo);
1611
+ if (reach.saturated || reach.roots.length === 0) continue;
1612
+ const idf = Math.log(N / Math.max(1, reach.contextsReached));
1613
+ if (idf <= 0) continue;
1614
+ // Self-evidence backstop (see the param doc above): a candidate that is
1615
+ // exactly one side's own already-voted conclusion carries no JOINT
1616
+ // evidence — skip it as if it never survived.
1617
+ if (
1618
+ (ownRootsA && sameRoots(reach.roots, ownRootsA)) ||
1619
+ (ownRootsB && sameRoots(reach.roots, ownRootsB))
1620
+ ) continue;
1621
+ if (selected === null) {
1622
+ selected = p;
1623
+ selectedReach = reach;
1624
+ selectedIdf = idf;
1625
+ } else if (!sameRoots(reach.roots, selectedReach!.roots)) {
1626
+ rival = p;
1627
+ break;
1628
+ }
1629
+ }
1630
+ if (selected === null || selectedReach === null) return null;
1631
+
1632
+ const margin = rival
1633
+ ? selected.effectiveScore - rival.effectiveScore
1634
+ : selected.effectiveScore;
1635
+ if (margin <= estimatorNoise(ctx.store.D)) return null;
1636
+
1637
+ return { proposal: selected, reach: selectedReach, idf: selectedIdf };
1638
+ }
1639
+
788
1640
  async function crossRegionVotes(
789
1641
  ctx: MindContext,
790
1642
  query: Uint8Array,
@@ -793,6 +1645,7 @@ async function crossRegionVotes(
793
1645
  k: number,
794
1646
  N: number,
795
1647
  reachMemo: Map<number, AncestorReach>,
1648
+ td?: TraceDraft,
796
1649
  ): Promise<{ votes: RegionVote[]; superseded: Set<RegionVote> }> {
797
1650
  // Candidate regions: every region that ALREADY CAST ITS OWN VOTE in
798
1651
  // voteRegions — individually idf > 0, genuinely discriminative on its own,
@@ -844,6 +1697,14 @@ async function crossRegionVotes(
844
1697
  )
845
1698
  );
846
1699
  const none = { votes: [], superseded: new Set<RegionVote>() };
1700
+ if (td) {
1701
+ td.crossRegionSummary = {
1702
+ eligibleRegions: eligible.length,
1703
+ maximalRegions: cand.length,
1704
+ probeLimit: k,
1705
+ probesAttempted: 0, // updated below as probes accrue
1706
+ };
1707
+ }
847
1708
  if (cand.length < 2) return none;
848
1709
  cand.sort((x, y) =>
849
1710
  regions[x].start - regions[y].start || regions[x].end - regions[y].end
@@ -914,6 +1775,9 @@ async function crossRegionVotes(
914
1775
  regions.some((r) => r.known && r.start <= ra.start && rb.end <= r.end)
915
1776
  ) continue;
916
1777
  probes++;
1778
+ if (td?.crossRegionSummary) {
1779
+ td.crossRegionSummary.probesAttempted = probes;
1780
+ }
917
1781
 
918
1782
  const left = query.subarray(ra.start, ra.end);
919
1783
  const right = query.subarray(rb.start, rb.end);
@@ -922,112 +1786,218 @@ async function crossRegionVotes(
922
1786
  const maxInterior = (left.length + right.length) * ctx.space.maxGroup;
923
1787
  const cap = left.length + right.length + maxInterior;
924
1788
 
925
- // Tier 1 exact containers (both forms as substrings, either order, by
926
- // DAG ascent). Exact evidence first; only falls through to synonyms
927
- // when the exact ascent finds nothing the SAME graded ladder the
928
- // bridge uses.
929
- let containers = junctionContainersFrom(
930
- ctx,
931
- left,
932
- right,
933
- cap,
934
- seedsOf(cand[a]),
935
- seedsOf(cand[b]),
936
- undefined,
937
- true,
938
- );
1789
+ // The graded ladder (spec §1): exact DAG junction, then single-synonym,
1790
+ // then double-synonym, then only when every DAG tier found nothing —
1791
+ // structural-resonance. `sides` (the two halo sibling lists) is loaded
1792
+ // ONCE and reused by junctionSynonyms AND structural-resonance, so no
1793
+ // ladder rung repeats a halo ANN query an earlier rung already paid for.
1794
+ const sides = await loadJunctionSynonymSides(ctx, left, right);
1795
+
1796
+ let tier: CrossRegionTier = "exact";
1797
+ let containers: Array<Junction | SynonymJunction> =
1798
+ junctionContainersFrom(
1799
+ ctx,
1800
+ left,
1801
+ right,
1802
+ cap,
1803
+ seedsOf(cand[a]),
1804
+ seedsOf(cand[b]),
1805
+ undefined,
1806
+ true,
1807
+ );
939
1808
  if (containers.length === 0) {
940
- // Tier 2.5 — synonym containers (halo sibling of one side + the other).
941
- containers = await junctionSynonyms(
1809
+ // Tiers 2-4 — synonym containers (junctionSynonyms itself runs
1810
+ // single-synonym first, falling to double-synonym only when
1811
+ // single-synonym found nothing — see junction.ts).
1812
+ const syn = await junctionSynonyms(
942
1813
  ctx,
943
1814
  left,
944
1815
  right,
945
1816
  maxInterior,
946
1817
  true,
1818
+ sides,
947
1819
  );
1820
+ if (syn.length > 0) {
1821
+ containers = syn;
1822
+ tier = syn[0].tier;
1823
+ }
948
1824
  }
949
- if (containers.length === 0) continue;
950
-
951
- // N-ARY selection: the container covering the MOST remaining candidate
952
- // forms wins (then tightest interior, then lowest id). Reads are
953
- // cache hits every container's bytes were already read by the walk.
954
- //
955
- // SELF-EVIDENCE GUARD: a junction is BINDING evidence only when the
956
- // container joins forms the query mentions APART. When the container's
957
- // own joined occurrence (left..right including its interior) is a
958
- // literal substring of the query, the query already spells that phrase
959
- // out contiguously — perception already voted with it, and grid shards
960
- // of one phrase pairing "around" a gap chunk would merely rediscover
961
- // the phrase they are shards of, then explain away its rivals.
962
- let best: (typeof containers)[number] | null = null;
963
- let bestExtras: number[] = [];
964
- let bestCov = -1;
965
- for (const c of containers) {
966
- const bytes = cachedRead(ctx, cache, c.id, cap);
967
- const li = indexOf(bytes, left, 0);
968
- const ri = indexOf(bytes, right, 0);
969
- if (li >= 0 && ri >= 0) {
970
- const joined = bytes.subarray(
971
- Math.min(li, ri),
972
- Math.max(li + left.length, ri + right.length),
973
- );
974
- if (indexOf(query, joined, 0) >= 0) continue; // query says it itself
1825
+
1826
+ // Tier 5 — structural-resonance ANN, the FINAL approximate proposal
1827
+ // path. Only reached when every DAG tier found NOTHING, and only when
1828
+ // there is no already-corroborated region between the endpoints (a
1829
+ // between-region with its own vote is evidence the gap already means
1830
+ // something specific — an ANN guess must not override it).
1831
+ let structuralPick:
1832
+ | {
1833
+ proposal: StructuralResonanceProposal;
1834
+ reach: AncestorReach;
1835
+ idf: number;
975
1836
  }
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.
1837
+ | null = null;
1838
+ if (containers.length === 0) {
1839
+ // Structural-resonance composes each side's OWN gist directly (no
1840
+ // byte-containment truth backs it, unlike the DAG tiers) so, unlike
1841
+ // the DAG ladder (which tolerates one approximate side because byte
1842
+ // containment cannot lie), the ANN tier requires BOTH sides to be
1843
+ // KNOWN (content-addressed, exact identities): an approximate chunk
1844
+ // fragment's own resonance is noise at any tier, and composing noise
1845
+ // into a synthetic gist only manufactures a plausible-looking but
1846
+ // spurious ANN neighbour, not evidence of a genuine joint whole.
1847
+ // PHRASE-SCALE CONTRACT — the same one the DAG tiers hold their glue
1848
+ // to (see maxInterior above): a junction, exact or approximate, is a
1849
+ // whole the two forms nearly exhaust, not two arbitrary landmarks
1850
+ // anywhere in a long, multi-topic query. Without this, structural-
1851
+ // resonance would pair opposite ends of an unrelated scaffolding-
1852
+ // dominated query and manufacture a plausible-looking ANN neighbour
1853
+ // for a "gap" that never was a phrase.
1854
+ // BOTH sides must be independently DISCRIMINATIVE (individually
1855
+ // voted — `strong`, not merely a content-addressed `known` chunk):
1856
+ // a shared, non-discriminative scaffolding run (a repeated system
1857
+ // preamble) can be `known` without ever being distinctive evidence
1858
+ // of anything, and composing its own gist into a synthetic query
1859
+ // manufactures a plausible-looking but spurious ANN neighbour. The
1860
+ // DAG tiers can tolerate one merely-`known` side because byte
1861
+ // containment cannot lie; structural-resonance has no such
1862
+ // backstop, so both sides earn their place here the same way an
1863
+ // ordinary approximate region earns its individual vote.
1864
+ const gap = rb.start - ra.end;
981
1865
  if (
982
- between.some((bi) =>
983
- indexOf(
984
- bytes,
985
- query.subarray(regions[bi].start, regions[bi].end),
986
- 0,
987
- ) < 0
988
- )
1866
+ between.length === 0 &&
1867
+ strong.has(cand[a]) && strong.has(cand[b]) &&
1868
+ ra.known && rb.known &&
1869
+ gap <= maxInterior
989
1870
  ) {
990
- continue;
1871
+ const ownRootsA = rvs.votes.find((v) =>
1872
+ v.start === ra.start && v.end === ra.end
1873
+ )?.roots;
1874
+ const ownRootsB = rvs.votes.find((v) =>
1875
+ v.start === rb.start && v.end === rb.end
1876
+ )?.roots;
1877
+ structuralPick = await structuralResonance(
1878
+ ctx,
1879
+ query,
1880
+ ra,
1881
+ rb,
1882
+ sides,
1883
+ k,
1884
+ N,
1885
+ reachMemo,
1886
+ ownRootsA,
1887
+ ownRootsB,
1888
+ );
991
1889
  }
992
- let cov = left.length + right.length;
993
- const extras: number[] = [];
994
- for (const ei of cand) {
995
- if (ei === cand[a] || ei === cand[b] || consumed.has(ei)) continue;
996
- const e = regions[ei];
997
- if (overlapsSpan(e, ra) || overlapsSpan(e, rb)) continue;
998
- const eb = query.subarray(e.start, e.end);
999
- if (indexOf(bytes, eb, 0) >= 0) {
1000
- extras.push(ei);
1001
- cov += eb.length;
1890
+ if (structuralPick === null) continue;
1891
+ tier = "structural-resonance";
1892
+ }
1893
+
1894
+ let best: (Junction | SynonymJunction) | null = null;
1895
+ let bestExtras: number[] = [];
1896
+ let bestCov = -1;
1897
+ let reach: AncestorReach;
1898
+ let idf: number;
1899
+ let confidence: number;
1900
+
1901
+ if (structuralPick !== null) {
1902
+ // A resonance proposal is NOT a Junction — there is no container to
1903
+ // read bytes from, so the self-evidence/contradiction/N-ary
1904
+ // machinery below (byte-verified against a real container) does not
1905
+ // apply; per spec §13, no N-ary extra-region coverage for resonance
1906
+ // proposals.
1907
+ best = { id: structuralPick.proposal.id, interior: new Uint8Array(0) };
1908
+ bestExtras = [];
1909
+ bestCov = rb.end - ra.start;
1910
+ reach = structuralPick.reach;
1911
+ idf = structuralPick.idf;
1912
+ confidence = structuralPick.proposal.effectiveScore;
1913
+ } else {
1914
+ // N-ARY selection: the container covering the MOST remaining candidate
1915
+ // forms wins (then tightest interior, then lowest id). Reads are
1916
+ // cache hits — every container's bytes were already read by the walk.
1917
+ //
1918
+ // SELF-EVIDENCE GUARD: a junction is BINDING evidence only when the
1919
+ // container joins forms the query mentions APART. When the container's
1920
+ // own joined occurrence (left..right including its interior) is a
1921
+ // literal substring of the query, the query already spells that phrase
1922
+ // out contiguously — perception already voted with it, and grid shards
1923
+ // of one phrase pairing "around" a gap chunk would merely rediscover
1924
+ // the phrase they are shards of, then explain away its rivals.
1925
+ for (const c of containers) {
1926
+ const bytes = cachedRead(ctx, cache, c.id, cap);
1927
+ const li = indexOf(bytes, left, 0);
1928
+ const ri = indexOf(bytes, right, 0);
1929
+ if (li >= 0 && ri >= 0) {
1930
+ const joined = bytes.subarray(
1931
+ Math.min(li, ri),
1932
+ Math.max(li + left.length, ri + right.length),
1933
+ );
1934
+ if (indexOf(query, joined, 0) >= 0) continue; // query says it itself
1935
+ }
1936
+ // CONTRADICTION GUARD: a between-region already carrying its own
1937
+ // vote must actually recur in this container's bytes — otherwise
1938
+ // the container is a different learnt whole that happens to share
1939
+ // ra/rb, and letting it stand in for the gap would silently
1940
+ // override evidence the query itself already resolved there.
1941
+ if (
1942
+ between.some((bi) =>
1943
+ indexOf(
1944
+ bytes,
1945
+ query.subarray(regions[bi].start, regions[bi].end),
1946
+ 0,
1947
+ ) < 0
1948
+ )
1949
+ ) {
1950
+ continue;
1951
+ }
1952
+ let cov = left.length + right.length;
1953
+ const extras: number[] = [];
1954
+ for (const ei of cand) {
1955
+ if (ei === cand[a] || ei === cand[b] || consumed.has(ei)) continue;
1956
+ const e = regions[ei];
1957
+ if (overlapsSpan(e, ra) || overlapsSpan(e, rb)) continue;
1958
+ const eb = query.subarray(e.start, e.end);
1959
+ if (indexOf(bytes, eb, 0) >= 0) {
1960
+ extras.push(ei);
1961
+ cov += eb.length;
1962
+ }
1963
+ }
1964
+ if (
1965
+ cov > bestCov ||
1966
+ (cov === bestCov && best !== null &&
1967
+ (c.interior.length < best.interior.length ||
1968
+ (c.interior.length === best.interior.length && c.id < best.id)))
1969
+ ) {
1970
+ best = c;
1971
+ bestExtras = extras;
1972
+ bestCov = cov;
1002
1973
  }
1003
1974
  }
1004
- if (
1005
- cov > bestCov ||
1006
- (cov === bestCov && best !== null &&
1007
- (c.interior.length < best.interior.length ||
1008
- (c.interior.length === best.interior.length && c.id < best.id)))
1009
- ) {
1010
- best = c;
1011
- bestExtras = extras;
1012
- bestCov = cov;
1013
- }
1014
- }
1015
- if (best === null) continue; // every container was self-evidence
1975
+ if (best === null) continue; // every container was self-evidence
1016
1976
 
1017
- const reach = edgeAncestors(ctx, best.id, N, reachMemo);
1018
- if (reach.saturated || reach.roots.length === 0) continue;
1019
- const idf = Math.log(N / Math.max(1, reach.contextsReached));
1020
- if (idf <= 0) continue;
1977
+ const r = edgeAncestors(ctx, best.id, N, reachMemo);
1978
+ if (r.saturated || r.roots.length === 0) continue;
1979
+ const df = Math.log(N / Math.max(1, r.contextsReached));
1980
+ if (df <= 0) continue;
1981
+ reach = r;
1982
+ idf = df;
1983
+ // Confidence used by voting (spec §13): exact junction = 1;
1984
+ // single/double-synonym = the sibling(s)' score(s), carried on the
1985
+ // SynonymJunction the ladder selected.
1986
+ confidence = "confidence" in best ? best.confidence : 1;
1987
+ }
1021
1988
 
1022
- // EXACT joint evidence (score = 1): the container literally contains
1023
- // every composed form. Mutual-explanation weight over their COMBINED
1024
- // byte length the same magnitude reading voteRegions uses, here with
1025
- // the estimator collapsed to certainty, so mutual = min(ratio, 1/ratio).
1989
+ // MUTUAL-EXPLANATION WEIGHT the same formula for every tier, with
1990
+ // `confidence` collapsed to certainty (1) for exact evidence: under
1991
+ // that collapse this is byte-for-byte the old exact-only formula
1992
+ // (min(1,ratio)·min(1,1/ratio)). For structural-resonance,
1993
+ // `confidence` is already annScore·semanticConfidence — never
1994
+ // multiplied a second time.
1026
1995
  const lenR = Math.max(1, bestCov);
1027
1996
  const ratio = Math.sqrt(
1028
1997
  Math.max(1, ctx.store.contentLen(best.id, lenR * ctx.store.D)) / lenR,
1029
1998
  );
1030
- const mutual = Math.min(1, ratio) * Math.min(1, 1 / ratio);
1999
+ const mutual = Math.min(1, confidence * ratio) *
2000
+ Math.min(1, confidence / ratio);
1031
2001
  const w = (mutual * idf) / reach.roots.length;
1032
2002
  let spanStart = ra.start;
1033
2003
  let spanEnd = rb.end;
@@ -1049,15 +2019,33 @@ async function crossRegionVotes(
1049
2019
  // for, not evidence lost — `absorbed` (RegionVote's breadth-accounting
1050
2020
  // field) must credit the junction with all of it, not just the ONE
1051
2021
  // pooled axiom it collapses to.
1052
- const containerBytes = cachedRead(ctx, cache, best.id, cap);
1053
- const jointRoots = new Set(reach.roots);
2022
+ // Only EXACT DAG evidence may explain away ordinary votes (spec §15).
2023
+ // Single-synonym, double-synonym, and structural-resonance may ADD
2024
+ // supporting evidence but never remove it: their evidence is itself
2025
+ // approximate (a sibling substitution, or an ANN guess), so treating
2026
+ // their byte-containment the way exact containment is treated would
2027
+ // let an approximation override a genuine, independently-voted region.
1054
2028
  let explainedAway = 0;
1055
- for (const rv of rvs.votes) {
1056
- if (rv.roots.some((r) => jointRoots.has(r))) continue;
1057
- const bytes = query.subarray(rv.start, rv.end);
1058
- if (indexOf(containerBytes, bytes, 0) >= 0 && !superseded.has(rv)) {
1059
- superseded.add(rv);
1060
- explainedAway++;
2029
+ // Exact set of ORIGINAL region indices this junction explained away —
2030
+ // recorded live as `superseded.add` fires (spec §3's explicit rule:
2031
+ // never inferred from `absorbed` afterward).
2032
+ const explainedAwayIndices: number[] = [];
2033
+ if (tier === "exact") {
2034
+ const containerBytes = cachedRead(ctx, cache, best.id, cap);
2035
+ const jointRoots = new Set(reach.roots);
2036
+ for (const rv of rvs.votes) {
2037
+ if (rv.roots.some((r) => jointRoots.has(r))) continue;
2038
+ const bytes = query.subarray(rv.start, rv.end);
2039
+ if (indexOf(containerBytes, bytes, 0) >= 0 && !superseded.has(rv)) {
2040
+ superseded.add(rv);
2041
+ explainedAway++;
2042
+ if (td) {
2043
+ const idx = regions.findIndex((r) =>
2044
+ r.start === rv.start && r.end === rv.end
2045
+ );
2046
+ if (idx >= 0) explainedAwayIndices.push(idx);
2047
+ }
2048
+ }
1061
2049
  }
1062
2050
  }
1063
2051
 
@@ -1070,11 +2058,44 @@ async function crossRegionVotes(
1070
2058
  wFocus: w,
1071
2059
  absorbed: 1 + explainedAway,
1072
2060
  });
2061
+ if (td) {
2062
+ td.crossRegionJunctionVotes.push({
2063
+ container: best.id,
2064
+ span: [spanStart, spanEnd],
2065
+ roots: [...reach.roots],
2066
+ sourceRegionIndices: [cand[a], cand[b], ...bestExtras],
2067
+ explainedAwayRegionIndices: explainedAwayIndices,
2068
+ absorbed: 1 + explainedAway,
2069
+ tier,
2070
+ });
2071
+ }
1073
2072
 
1074
2073
  const label = [cand[a], cand[b], ...bestExtras]
1075
2074
  .sort((x, y) => regions[x].start - regions[y].start)
1076
2075
  .map((ri) => dec(query.subarray(regions[ri].start, regions[ri].end)))
1077
2076
  .join(" ▸ ");
2077
+ const tierNote = tier === "exact"
2078
+ ? `junction node ${best.id}` +
2079
+ (best.interior.length === 0
2080
+ ? " (adjacent)"
2081
+ : ` (interior "${dec(best.interior)}")`) +
2082
+ ", by content-addressed ascent"
2083
+ : tier === "structural-resonance"
2084
+ ? `structurally-composed ANN proposal, node ${best.id} — the query ` +
2085
+ `structurally composed the endpoint regions, the real middle-` +
2086
+ `query structure, and the selected halo-sibling endpoint ` +
2087
+ `direction(s) (variant ${structuralPick!.proposal.variant}, ` +
2088
+ `annScore ${structuralPick!.proposal.annScore.toFixed(3)} × ` +
2089
+ `semanticConfidence ${
2090
+ structuralPick!.proposal.semanticConfidence.toFixed(3)
2091
+ } = effectiveScore ${
2092
+ structuralPick!.proposal.effectiveScore.toFixed(3)
2093
+ }); it did not concatenate endpoint bytes or rewrite the query`
2094
+ : `${tier} junction node ${best.id}` +
2095
+ (best.interior.length === 0
2096
+ ? " (adjacent)"
2097
+ : ` (interior "${dec(best.interior)}")`) +
2098
+ `, by halo-sibling DAG ascent (confidence ${confidence.toFixed(3)})`;
1078
2099
  ctx.trace?.step(
1079
2100
  "crossRegion",
1080
2101
  [{ text: label, role: "pair" }],
@@ -1083,11 +2104,7 @@ async function crossRegionVotes(
1083
2104
  node: r,
1084
2105
  role: "joint-context",
1085
2106
  })),
1086
- `${label} → junction node ${best.id}` +
1087
- (best.interior.length === 0
1088
- ? " (adjacent)"
1089
- : ` (interior "${dec(best.interior)}")`) +
1090
- ` → ${reach.roots.length} context(s), by content-addressed ascent` +
2107
+ `${label} → ${tierNote} ${reach.roots.length} context(s)` +
1091
2108
  (superseded.size > 0
1092
2109
  ? `; ${superseded.size} aliasing vote(s) explained away`
1093
2110
  : ""),
@@ -1096,15 +2113,25 @@ async function crossRegionVotes(
1096
2113
  }
1097
2114
  }
1098
2115
 
2116
+ if (td) td.supersededOrdinaryVotes = superseded.size;
1099
2117
  return { votes: out, superseded };
1100
2118
  }
1101
2119
 
2120
+ /** Emit the "climbConsensus" step — the human-readable note this always
2121
+ * produced, now paired (when `ctx.trace` and `cfg` are both present) with
2122
+ * the structured {@link ClimbConsensusData} payload on the SAME step's
2123
+ * `data` field. Every exit of {@link computeAttention} funnels through
2124
+ * here, so instrumentation and the existing rationale text can never drift
2125
+ * apart — see the instrumentation spec's §9 "every exit path". */
1102
2126
  export function traceAttention(
1103
2127
  ctx: MindContext,
1104
2128
  regions: ReadonlyArray<{ start: number; end: number }>,
1105
2129
  regionVoter: ReadonlyArray<{ id: number; score: number; w: number } | null>,
1106
2130
  roots: ReadonlyArray<Attention>,
1107
2131
  steps: ReadonlyArray<DerivationStep> = [],
2132
+ td?: TraceDraft,
2133
+ cfg?: ClimbConsensusCfg,
2134
+ ranked: ReadonlyArray<Attention> = roots,
1108
2135
  ): void {
1109
2136
  if (!ctx.trace) return;
1110
2137
  const voters: RationaleItem[] = [];
@@ -1119,6 +2146,49 @@ export function traceAttention(
1119
2146
  // The pooled-evidence decision, one DerivationStep per anchor — the same
1120
2147
  // shape {@link GraphSearch}'s own cover steps take (see traceDerivation).
1121
2148
  if (steps.length > 0) traceDerivation(ctx, steps);
2149
+
2150
+ const data: ClimbConsensusData | undefined = (td && cfg)
2151
+ ? {
2152
+ version: 1,
2153
+ cache: { hit: false, detailAvailable: true },
2154
+ config: {
2155
+ annK: cfg.k,
2156
+ crossRegionProbeLimit: cfg.k,
2157
+ mode: cfg.mode,
2158
+ ...(cfg.N !== undefined ? { corpusN: cfg.N } : {}),
2159
+ dimension: ctx.store.D,
2160
+ ...(cfg.N !== undefined ? { hubBound: hubBound(ctx) } : {}),
2161
+ estimatorNoise: estimatorNoise(ctx.store.D),
2162
+ ...(cfg.naturalBreak !== undefined
2163
+ ? { naturalBreak: cfg.naturalBreak }
2164
+ : {}),
2165
+ ...(cfg.consensusFloor !== undefined
2166
+ ? { consensusFloor: cfg.consensusFloor }
2167
+ : {}),
2168
+ },
2169
+ candidates: {
2170
+ perceived: cfg.perceivedCount,
2171
+ recognised: cfg.totalRegions - cfg.perceivedCount,
2172
+ total: cfg.totalRegions,
2173
+ },
2174
+ ...(td.regions.length > 0 ? { regions: td.regions } : {}),
2175
+ ...(cfg.reachMemo ? { reaches: serialiseReaches(cfg.reachMemo) } : {}),
2176
+ ...(td.crossRegionSummary
2177
+ ? {
2178
+ crossRegion: {
2179
+ ...td.crossRegionSummary,
2180
+ junctionVotes: td.crossRegionJunctionVotes,
2181
+ supersededOrdinaryVotes: td.supersededOrdinaryVotes,
2182
+ },
2183
+ }
2184
+ : {}),
2185
+ ...(td.saturation ? { saturation: td.saturation } : {}),
2186
+ ...(td.pooling ? { pooling: td.pooling } : {}),
2187
+ ...(td.anchors.length > 0 ? { anchors: td.anchors } : {}),
2188
+ result: { roots: [...roots], ranked: [...ranked] },
2189
+ }
2190
+ : undefined;
2191
+
1122
2192
  t.done(
1123
2193
  roots.map((r) => rNode(ctx, r.anchor, "anchor", r.vote)),
1124
2194
  roots.length === 0
@@ -1130,5 +2200,6 @@ export function traceAttention(
1130
2200
  : `${voters.length} of ${regions.length} sub-regions voted; consensus ordered ${roots.length} INDEPENDENT points of attention (votes ${
1131
2201
  roots.map((r) => r.vote.toFixed(2)).join(", ")
1132
2202
  })`,
2203
+ data,
1133
2204
  );
1134
2205
  }