@hviana/sema 0.2.4 → 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.
package/src/mind/index.ts CHANGED
@@ -16,3 +16,19 @@ export type {
16
16
  RationaleItem,
17
17
  RationaleStep,
18
18
  } from "./rationale.js";
19
+ export type {
20
+ AnchorRejectionReason,
21
+ ClimbConsensusData,
22
+ ConsensusAnchorTrace,
23
+ ConsensusReachTrace,
24
+ ConsensusRegionTrace,
25
+ CrossRegionTier,
26
+ JunctionVoteTrace,
27
+ RegionOutcome,
28
+ } from "./attention.js";
29
+ export type {
30
+ AncestorReach,
31
+ AttentionRead,
32
+ SaturationReason,
33
+ SaturationStop,
34
+ } from "./types.js";
@@ -12,6 +12,7 @@
12
12
  // adjacent ANSWER pieces) and cross-region attention (the joint CONTEXT of two
13
13
  // non-adjacent QUERY regions) ascend by the same disciplined, bounded walk.
14
14
 
15
+ import type { Hit } from "../store.js";
15
16
  import type { MindContext } from "./types.js";
16
17
  import { read, resolve } from "./primitives.js";
17
18
  import { windowIds } from "./canonical.js";
@@ -27,6 +28,52 @@ export interface Junction {
27
28
  interior: Uint8Array;
28
29
  }
29
30
 
31
+ /** Which relaxation produced a {@link SynonymJunction}: one side replaced by
32
+ * a distributional halo sibling (`single-synonym`), or both (`double-
33
+ * synonym`) — the two remaining rungs of the graded ladder below exact DAG
34
+ * containment (see the module doc atop {@link junctionSynonyms}). */
35
+ export type SynonymJunctionTier =
36
+ | "single-synonym"
37
+ | "double-synonym";
38
+
39
+ export interface SynonymJunction extends Junction {
40
+ tier: SynonymJunctionTier;
41
+ /** Sibling score for a single-synonym junction; min(left, right) sibling
42
+ * score for a double-synonym junction. */
43
+ confidence: number;
44
+ }
45
+
46
+ /** The exact node ids and halo siblings resolved for one junction call's two
47
+ * sides — computed ONCE and reused by every ladder rung that needs them
48
+ * (junctionSynonyms' two tiers, and the structural-resonance tier beyond
49
+ * it). A failed synonym junction means only "no common DAG container was
50
+ * proven" — it does NOT mean the loaded siblings stop being useful. */
51
+ export interface JunctionSynonymSides {
52
+ leftId: number | null;
53
+ rightId: number | null;
54
+ leftSiblings: Hit[];
55
+ rightSiblings: Hit[];
56
+ }
57
+
58
+ /** Resolve `left`/`right` to their exact node ids (when known) and load each
59
+ * resolved side's halo siblings once — deterministic (haloSiblings already
60
+ * ranks nearest-first) and shared by every ladder rung that consults
61
+ * siblings, so no ladder rung repeats a halo ANN query the previous one
62
+ * already paid for. */
63
+ export async function loadJunctionSynonymSides(
64
+ ctx: MindContext,
65
+ left: Uint8Array,
66
+ right: Uint8Array,
67
+ ): Promise<JunctionSynonymSides> {
68
+ const leftId = resolve(ctx, left);
69
+ const rightId = resolve(ctx, right);
70
+ const leftSiblings = leftId !== null ? await haloSiblings(ctx, leftId) : [];
71
+ const rightSiblings = rightId !== null
72
+ ? await haloSiblings(ctx, rightId)
73
+ : [];
74
+ return { leftId, rightId, leftSiblings, rightSiblings };
75
+ }
76
+
30
77
  /** Seed node ids to ascend from for one side of a junction: the side's own
31
78
  * node when it is a stored form, plus — when the node has no structural
32
79
  * parents — its canonical window ids. A non-W-aligned node may have no
@@ -292,20 +339,32 @@ export async function junctionSynonyms(
292
339
  right: Uint8Array,
293
340
  maxInterior: number,
294
341
  unordered = false,
295
- ): Promise<Junction[]> {
296
- const out: Junction[] = [];
297
-
298
- const lId = resolve(ctx, left);
299
- const rId = resolve(ctx, right);
300
- if (lId === null && rId === null) return out;
342
+ sides?: JunctionSynonymSides,
343
+ ): Promise<SynonymJunction[]> {
344
+ const s = sides ?? await loadJunctionSynonymSides(ctx, left, right);
345
+ if (s.leftId === null && s.rightId === null) return [];
301
346
 
302
- const budget = { n: hubBound(ctx) * ctx.space.maxGroup };
347
+ // ── Tier 2.5a: single-synonym one side replaced by a halo sibling ──────
348
+ // ONE shared expansion budget across BOTH directions of this tier.
349
+ const singleBudget = { n: hubBound(ctx) * ctx.space.maxGroup };
350
+ const singleOut = new Map<number, SynonymJunction>();
351
+ const keepBest = (
352
+ map: Map<number, SynonymJunction>,
353
+ j: Junction,
354
+ tier: SynonymJunctionTier,
355
+ confidence: number,
356
+ ) => {
357
+ const prev = map.get(j.id);
358
+ if (prev === undefined || confidence > prev.confidence) {
359
+ map.set(j.id, { ...j, tier, confidence });
360
+ }
361
+ };
303
362
 
304
363
  // Left-side synonyms: containers of sibling+right. `right`'s seeds are
305
364
  // FIXED across every sibling this loop tries.
306
- if (lId !== null) {
365
+ if (s.leftId !== null) {
307
366
  const rightSeeds = junctionSeeds(ctx, right);
308
- for (const sib of await haloSiblings(ctx, lId)) {
367
+ for (const sib of s.leftSiblings) {
309
368
  const sibBytes = read(ctx, sib.id, maxInterior + 1);
310
369
  if (sibBytes.length === 0 || sibBytes.length > maxInterior) continue;
311
370
  const containers = junctionContainersFrom(
@@ -315,18 +374,20 @@ export async function junctionSynonyms(
315
374
  sibBytes.length + right.length + maxInterior,
316
375
  junctionSeeds(ctx, sibBytes),
317
376
  rightSeeds,
318
- budget,
377
+ singleBudget,
319
378
  unordered,
320
379
  );
321
- for (const c of containers) out.push(c);
380
+ for (const c of containers) {
381
+ keepBest(singleOut, c, "single-synonym", sib.score);
382
+ }
322
383
  }
323
384
  }
324
385
 
325
386
  // Right-side synonyms: containers of left+sibling. `left`'s seeds are
326
387
  // likewise fixed across this loop.
327
- if (rId !== null) {
388
+ if (s.rightId !== null) {
328
389
  const leftSeeds = junctionSeeds(ctx, left);
329
- for (const sib of await haloSiblings(ctx, rId)) {
390
+ for (const sib of s.rightSiblings) {
330
391
  const sibBytes = read(ctx, sib.id, maxInterior + 1);
331
392
  if (sibBytes.length === 0 || sibBytes.length > maxInterior) continue;
332
393
  const containers = junctionContainersFrom(
@@ -336,12 +397,60 @@ export async function junctionSynonyms(
336
397
  left.length + sibBytes.length + maxInterior,
337
398
  leftSeeds,
338
399
  junctionSeeds(ctx, sibBytes),
339
- budget,
400
+ singleBudget,
340
401
  unordered,
341
402
  );
342
- for (const c of containers) out.push(c);
403
+ for (const c of containers) {
404
+ keepBest(singleOut, c, "single-synonym", sib.score);
405
+ }
343
406
  }
344
407
  }
345
408
 
346
- return out;
409
+ if (singleOut.size > 0) return [...singleOut.values()];
410
+
411
+ // ── Tier 2.5b: double-synonym — BOTH sides replaced, tried only when
412
+ // single-synonym found NOTHING. Every (leftSibling, rightSibling) pair,
413
+ // sorted deterministically, bounded to haloQueryK pairs total, ONE fresh
414
+ // shared budget for the whole tier. ─────────────────────────────────────
415
+ if (s.leftSiblings.length === 0 || s.rightSiblings.length === 0) return [];
416
+
417
+ const pairs: Array<{ l: Hit; r: Hit; confidence: number }> = [];
418
+ for (const l of s.leftSiblings) {
419
+ for (const r of s.rightSiblings) {
420
+ pairs.push({ l, r, confidence: Math.min(l.score, r.score) });
421
+ }
422
+ }
423
+ pairs.sort((a, b) =>
424
+ b.confidence - a.confidence ||
425
+ a.l.id - b.l.id ||
426
+ a.r.id - b.r.id
427
+ );
428
+
429
+ const doubleOut = new Map<number, SynonymJunction>();
430
+ const budget = { n: hubBound(ctx) * ctx.space.maxGroup };
431
+ const tries = Math.min(pairs.length, ctx.cfg.haloQueryK);
432
+ for (let i = 0; i < tries; i++) {
433
+ const { l, r, confidence } = pairs[i];
434
+ const lBytes = read(ctx, l.id, maxInterior + 1);
435
+ const rBytes = read(ctx, r.id, maxInterior + 1);
436
+ if (
437
+ lBytes.length === 0 || lBytes.length > maxInterior ||
438
+ rBytes.length === 0 || rBytes.length > maxInterior
439
+ ) continue;
440
+ const containers = junctionContainersFrom(
441
+ ctx,
442
+ lBytes,
443
+ rBytes,
444
+ lBytes.length + rBytes.length + maxInterior,
445
+ junctionSeeds(ctx, lBytes),
446
+ junctionSeeds(ctx, rBytes),
447
+ budget,
448
+ unordered,
449
+ );
450
+ for (const c of containers) {
451
+ keepBest(doubleOut, c, "double-synonym", confidence);
452
+ }
453
+ }
454
+
455
+ return [...doubleOut.values()];
347
456
  }
package/src/mind/mind.ts CHANGED
@@ -136,6 +136,21 @@ import {
136
136
  climbAttention as climbAttentionFn,
137
137
  naturalBreak as naturalBreakFn,
138
138
  } from "./attention.js";
139
+ export type {
140
+ AnchorRejectionReason,
141
+ ClimbConsensusData,
142
+ ConsensusAnchorTrace,
143
+ ConsensusReachTrace,
144
+ ConsensusRegionTrace,
145
+ CrossRegionTier,
146
+ JunctionVoteTrace,
147
+ RegionOutcome,
148
+ } from "./attention.js";
149
+ export type {
150
+ AncestorReach,
151
+ SaturationReason,
152
+ SaturationStop,
153
+ } from "./types.js";
139
154
  import { aluToMechanism, defaultMechanisms, think } from "./pipeline.js";
140
155
  import { articulate } from "./articulation.js";
141
156
  import { ingest } from "./learning.js";
@@ -83,6 +83,11 @@ export interface RationaleStep {
83
83
  /** A one-line, human account of what the mechanism did and why — the sentence
84
84
  * that turns the data into an explanation. */
85
85
  note?: string;
86
+ /** Optional structured payload — a mechanism-specific, plain-serialisable
87
+ * shape (no Map/Set/vectors/mutable internals) that carries more than the
88
+ * human-readable `note` can, for a debugger or downstream tool to consume
89
+ * programmatically. Never read by inference; purely additive. */
90
+ data?: unknown;
86
91
  }
87
92
 
88
93
  /** The callback {@link Mind.respond} / {@link Mind.respondText} accept. It is
@@ -142,7 +147,7 @@ export interface Scope {
142
147
  /** Close the mechanism: emit its step with these outputs and pop it off the
143
148
  * nesting stack. Idempotent — a second call is ignored, so a `finally` that
144
149
  * closes after an early return is safe. */
145
- done(outputs: RationaleItem[], note?: string): void;
150
+ done(outputs: RationaleItem[], note?: string, data?: unknown): void;
146
151
  }
147
152
 
148
153
  /** The live tracer: a stack of open mechanisms over one {@link Mind.respond}.
@@ -212,6 +217,7 @@ export class Rationale {
212
217
  outputs: RationaleItem[],
213
218
  deps: number[] | undefined,
214
219
  note: string | undefined,
220
+ data?: unknown,
215
221
  ): void {
216
222
  this.sink({
217
223
  index,
@@ -223,6 +229,7 @@ export class Rationale {
223
229
  inputs,
224
230
  outputs,
225
231
  note,
232
+ data,
226
233
  });
227
234
  }
228
235
 
@@ -248,11 +255,11 @@ export class Rationale {
248
255
  };
249
256
  return {
250
257
  index,
251
- done: (outputs, note) => {
258
+ done: (outputs, note, data) => {
252
259
  if (closed) return;
253
260
  closed = true;
254
261
  pop();
255
- emit(index, mechanism, inputs, outputs, resolvedDeps, note);
262
+ emit(index, mechanism, inputs, outputs, resolvedDeps, note, data);
256
263
  },
257
264
  };
258
265
  }
@@ -265,11 +272,12 @@ export class Rationale {
265
272
  outputs: RationaleItem[],
266
273
  note?: string,
267
274
  deps?: number[],
275
+ data?: unknown,
268
276
  ): number {
269
277
  const mechanism = this.path(name);
270
278
  const resolvedDeps = deps ?? this.defaultDeps();
271
279
  const index = this.reserve(name);
272
- this.emit(index, mechanism, inputs, outputs, resolvedDeps, note);
280
+ this.emit(index, mechanism, inputs, outputs, resolvedDeps, note, data);
273
281
  return index;
274
282
  }
275
283
  }
@@ -7,7 +7,7 @@
7
7
  // project) live in match.ts — the elementary match-and-project operation.
8
8
 
9
9
  import { cosine, Vec } from "../vec.js";
10
- import type { AncestorReach, MindContext } from "./types.js";
10
+ import type { AncestorReach, MindContext, SaturationStop } from "./types.js";
11
11
  import { gistOf, read } from "./primitives.js";
12
12
 
13
13
  // ── Per-response structural memo ────────────────────────────────────────
@@ -134,7 +134,22 @@ export function edgeAncestors(
134
134
  // is withdrawn. On a small store the floor stays ≤ √N and the atom
135
135
  // climbs exactly as before, so single-letter facts keep working.
136
136
  if (id < 0 && atomIsHub(ctx, contextCount)) {
137
- const reach = { roots: [], contextsReached: 0, saturated: true };
137
+ const bound0 = Math.ceil(Math.sqrt(Math.max(2, contextCount)));
138
+ const reach: AncestorReach = {
139
+ roots: [],
140
+ contextsReached: 0,
141
+ saturated: true,
142
+ ...(ctx.trace
143
+ ? {
144
+ saturation: {
145
+ reason: "byte-atom-commonality" as const,
146
+ node: id,
147
+ observed: atomReach(ctx, contextCount),
148
+ limit: bound0,
149
+ },
150
+ }
151
+ : {}),
152
+ };
138
153
  memo?.set(id, reach);
139
154
  return reach;
140
155
  }
@@ -144,6 +159,10 @@ export function edgeAncestors(
144
159
  const seen = new Set<number>([id]);
145
160
  const ctxSeen = new Set<number>();
146
161
  let saturated = false;
162
+ // Provenance of the FIRST decision that saturated this climb — allocated
163
+ // only when a trace is requested (see AncestorReach.saturation's doc); the
164
+ // climb itself never reads it back.
165
+ let satStop: SaturationStop | undefined;
147
166
 
148
167
  // EXPAND-UNTIL-DECIDED: a reach is consumed either as a VOTE (which needs
149
168
  // contextsReached exactly, and only while ≤ √N — beyond that the region is
@@ -188,12 +207,45 @@ export function edgeAncestors(
188
207
  if (hasNx || pc > 0) {
189
208
  roots.push(x);
190
209
  if (hasNx) ctxSeen.add(x);
191
- if (pc > bound) return false; // decided: ≥ pc > √N distinct contexts
210
+ if (pc > bound) {
211
+ // decided: ≥ pc > √N distinct contexts
212
+ if (ctx.trace) {
213
+ satStop = {
214
+ reason: "predecessor-fan-in",
215
+ node: x,
216
+ observed: pc,
217
+ limit: bound,
218
+ };
219
+ }
220
+ return false;
221
+ }
192
222
  for (const p of ctx.store.prevFirst(x, bound)) ctxSeen.add(p);
193
- if (ctxSeen.size > bound) return false; // decided
223
+ if (ctxSeen.size > bound) {
224
+ // decided
225
+ if (ctx.trace) {
226
+ satStop = {
227
+ reason: "distinct-context-limit",
228
+ node: x,
229
+ observed: ctxSeen.size,
230
+ limit: bound,
231
+ };
232
+ }
233
+ return false;
234
+ }
194
235
  }
195
236
  const parents = ctx.store.parentsFirst(x, bound + 1);
196
- if (parents.length > bound) return false; // decided: hub
237
+ if (parents.length > bound) {
238
+ // decided: hub
239
+ if (ctx.trace) {
240
+ satStop = {
241
+ reason: "parent-fan-out",
242
+ node: x,
243
+ observed: parents.length,
244
+ limit: bound,
245
+ };
246
+ }
247
+ return false;
248
+ }
197
249
  let fresh = 0;
198
250
  for (const p of parents) {
199
251
  if (!seen.has(p)) {
@@ -204,7 +256,18 @@ export function edgeAncestors(
204
256
  }
205
257
  if (fresh > 1) {
206
258
  lateral += fresh - 1;
207
- if (lateral > bound) return false; // decided: cone-wide hub
259
+ if (lateral > bound) {
260
+ // decided: cone-wide hub
261
+ if (ctx.trace) {
262
+ satStop = {
263
+ reason: "lateral-cone-limit",
264
+ node: x,
265
+ observed: lateral,
266
+ limit: bound,
267
+ };
268
+ }
269
+ return false;
270
+ }
208
271
  }
209
272
  return true;
210
273
  };
@@ -266,7 +329,12 @@ export function edgeAncestors(
266
329
  }
267
330
  }
268
331
 
269
- const reach = { roots, contextsReached: ctxSeen.size, saturated };
332
+ const reach: AncestorReach = {
333
+ roots,
334
+ contextsReached: ctxSeen.size,
335
+ saturated,
336
+ ...(saturated && satStop ? { saturation: satStop } : {}),
337
+ };
270
338
  memo?.set(id, reach);
271
339
  return reach;
272
340
  }
package/src/mind/types.ts CHANGED
@@ -175,11 +175,36 @@ export interface RegionVote {
175
175
  absorbed?: number;
176
176
  }
177
177
 
178
+ /** The structural gate that first decided an {@link edgeAncestors} climb was
179
+ * saturated (an abstention, not a discriminative conclusion) — pure
180
+ * instrumentation for {@link ClimbConsensusData}'s reach trace; it never
181
+ * feeds back into the climb itself. */
182
+ export type SaturationReason =
183
+ | "byte-atom-commonality"
184
+ | "predecessor-fan-in"
185
+ | "distinct-context-limit"
186
+ | "parent-fan-out"
187
+ | "lateral-cone-limit";
188
+
189
+ /** One saturation stop's provenance: which reason fired, at which node, the
190
+ * observed count against the bound that decided it. */
191
+ export interface SaturationStop {
192
+ reason: SaturationReason;
193
+ node: number;
194
+ observed: number;
195
+ limit: number;
196
+ }
197
+
178
198
  /** The edge-bearing contexts reached by climbing from a node, plus saturation info. */
179
199
  export interface AncestorReach {
180
200
  roots: number[];
181
201
  contextsReached: number;
182
202
  saturated: boolean;
203
+ /** The saturation gate that stopped this climb, when {@link saturated} is
204
+ * true and a trace was requested — see {@link edgeAncestors}. Absent for
205
+ * a non-saturated reach, and absent (even when saturated) when no trace
206
+ * was requested — instrumentation must not allocate when tracing is off. */
207
+ saturation?: SaturationStop;
183
208
  }
184
209
 
185
210
  /** Saturated-interval information for the noise-drop gate. */