@hviana/sema 0.2.9 → 0.3.0

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.
@@ -63,6 +63,11 @@ export interface ConsensusReachTrace {
63
63
  contextsReached: number;
64
64
  saturated: boolean;
65
65
  saturation?: SaturationStop;
66
+ /** Nodes the climb processed — see {@link AncestorReach.visited}. Absent
67
+ * on payloads recorded before this field existed. */
68
+ visited?: number;
69
+ /** Maximum ascent distance — see {@link AncestorReach.maxDepth}. */
70
+ maxDepth?: number;
66
71
  }
67
72
  export type AnchorRejectionReason = "below-natural-break" | "below-consensus-floor" | "leading-saturation";
68
73
  export interface ConsensusAnchorTrace {
@@ -38,6 +38,9 @@ function serialiseReaches(reachMemo) {
38
38
  contextsReached: r.contextsReached,
39
39
  saturated: r.saturated,
40
40
  ...(r.saturation ? { saturation: r.saturation } : {}),
41
+ ...(r.visited !== undefined
42
+ ? { visited: r.visited, maxDepth: r.maxDepth }
43
+ : {}),
41
44
  });
42
45
  }
43
46
  return out;
@@ -5,3 +5,5 @@ export type { MechanismResult, PipelineMechanism, Precomputed, } from "./pipelin
5
5
  export type { InspectRationale, RationaleItem, RationaleStep, } from "./rationale.js";
6
6
  export type { AnchorRejectionReason, ClimbConsensusData, ConsensusAnchorTrace, ConsensusReachTrace, ConsensusRegionTrace, CrossRegionTier, JunctionVoteTrace, RegionOutcome, } from "./attention.js";
7
7
  export type { AncestorReach, AttentionRead, SaturationReason, SaturationStop, } from "./types.js";
8
+ export type { DepositReport } from "./learning.js";
9
+ export type { DecideGroundingData, NarrowDecisionData, Provenance, } from "./pipeline.js";
@@ -28,8 +28,30 @@ export declare function deposit(ctx: MindContext, input: Input, track: boolean,
28
28
  export declare function ingestOne(ctx: MindContext, input: Input): Promise<Sema & {
29
29
  id: number;
30
30
  }>;
31
- /** Ingest a pair (context, continuation)learn an edge and pour halos. */
32
- export declare function ingestPair(ctx: MindContext, ctxInput: Input, cont: Input): Promise<void>;
31
+ /** What one ingested item depositedreported through {@link ingest}'s
32
+ * optional `onDeposit` callback. Pure provenance read-out: node ids are
33
+ * content-addressed, so two byte-identical items report the SAME ids (that
34
+ * is content addressing working, not an error), and the callback observes
35
+ * the deposit without influencing it. */
36
+ export interface DepositReport {
37
+ /** Zero-based position of the item in the ingested input (0 for a scalar
38
+ * or single pair). */
39
+ index: number;
40
+ /** Whether the item was a bare experience or a (context, continuation)
41
+ * pair. */
42
+ kind: "one" | "pair";
43
+ /** Root node id of the item's (context) bytes. */
44
+ contextId: number;
45
+ /** Root node id of the continuation bytes — pairs only. */
46
+ continuationId?: number;
47
+ }
48
+ /** Ingest a pair (context, continuation) — learn an edge and pour halos.
49
+ * Returns the deposited root ids (context, continuation) — a pure
50
+ * read-out; callers that ignore it behave exactly as before. */
51
+ export declare function ingestPair(ctx: MindContext, ctxInput: Input, cont: Input): Promise<{
52
+ ctxId: number;
53
+ contId: number;
54
+ }>;
33
55
  /** Dispatch the public ingest input shapes onto one-input / pair handlers —
34
56
  * THE one reading of ingest's polymorphic surface (scalar, (context,
35
57
  * continuation) pair, or a list mixing bare inputs and pairs). Both ingest
@@ -41,7 +63,13 @@ export declare function dispatchIngest(input: Input | (Input | [Input, Input])[]
41
63
  }>, onPair: (ctxInput: Input, cont: Input) => Promise<void>): Promise<(Sema & {
42
64
  id: number;
43
65
  }) | undefined>;
44
- /** Ingest an input or array of inputs/pairs. The public ingest entry point. */
45
- export declare function ingest(ctx: MindContext, input: Input | (Input | [Input, Input])[], second?: Input): Promise<(Sema & {
66
+ /** Ingest an input or array of inputs/pairs. The public ingest entry point.
67
+ *
68
+ * `onDeposit`, when given, is invoked once per ingested item with the
69
+ * deposited root node ids ({@link DepositReport}) — item-level provenance
70
+ * for tooling that needs to know which stored node an ingested item became.
71
+ * Purely observational: the callback runs after the item's deposit
72
+ * completed and nothing reads its result. */
73
+ export declare function ingest(ctx: MindContext, input: Input | (Input | [Input, Input])[], second?: Input, onDeposit?: (report: DepositReport) => void): Promise<(Sema & {
46
74
  id: number;
47
75
  }) | undefined>;
@@ -188,7 +188,9 @@ async function propagateSuffixes(ctx, src, dst) {
188
188
  await ctx.store.link(id, dst);
189
189
  }
190
190
  }
191
- /** Ingest a pair (context, continuation) — learn an edge and pour halos. */
191
+ /** Ingest a pair (context, continuation) — learn an edge and pour halos.
192
+ * Returns the deposited root ids (context, continuation) — a pure
193
+ * read-out; callers that ignore it behave exactly as before. */
192
194
  export async function ingestPair(ctx, ctxInput, cont) {
193
195
  const c = await deposit(ctx, ctxInput, true, true);
194
196
  const cont_ = await deposit(ctx, cont, false);
@@ -213,6 +215,7 @@ export async function ingestPair(ctx, ctxInput, cont) {
213
215
  await ctx.store.pourHalo(partId, contSeat);
214
216
  await ctx.store.pourHalo(contId, bindSeat(ctx.space, companySignature(ctx.space, partId), 0));
215
217
  }
218
+ return { ctxId, contId };
216
219
  }
217
220
  /** Dispatch the public ingest input shapes onto one-input / pair handlers —
218
221
  * THE one reading of ingest's polymorphic surface (scalar, (context,
@@ -242,7 +245,26 @@ export async function dispatchIngest(input, second, onOne, onPair) {
242
245
  await onPair(input, second);
243
246
  return undefined;
244
247
  }
245
- /** Ingest an input or array of inputs/pairs. The public ingest entry point. */
246
- export async function ingest(ctx, input, second) {
247
- return dispatchIngest(input, second, (i) => ingestOne(ctx, i), (a, b) => ingestPair(ctx, a, b));
248
+ /** Ingest an input or array of inputs/pairs. The public ingest entry point.
249
+ *
250
+ * `onDeposit`, when given, is invoked once per ingested item with the
251
+ * deposited root node ids ({@link DepositReport}) — item-level provenance
252
+ * for tooling that needs to know which stored node an ingested item became.
253
+ * Purely observational: the callback runs after the item's deposit
254
+ * completed and nothing reads its result. */
255
+ export async function ingest(ctx, input, second, onDeposit) {
256
+ let index = 0;
257
+ return dispatchIngest(input, second, async (i) => {
258
+ const r = await ingestOne(ctx, i);
259
+ onDeposit?.({ index: index++, kind: "one", contextId: r.id });
260
+ return r;
261
+ }, async (a, b) => {
262
+ const { ctxId, contId } = await ingestPair(ctx, a, b);
263
+ onDeposit?.({
264
+ index: index++,
265
+ kind: "pair",
266
+ contextId: ctxId,
267
+ continuationId: contId,
268
+ });
269
+ });
248
270
  }
@@ -235,7 +235,10 @@ export declare class Mind implements MindContext {
235
235
  * read-out direction of the same operation, without recall's grounding
236
236
  * ladder. If either side's acceptance rule changes, revisit the other. */
237
237
  express(idOrV: number | Vec): Promise<Uint8Array>;
238
- ingest(input: Input | (Input | [Input, Input])[], second?: Input): Promise<(Sema & {
238
+ /** See {@link import("./learning.js").ingest} `onDeposit`, when given,
239
+ * reports each ingested item's deposited root node ids
240
+ * ({@link DepositReport}); purely observational. */
241
+ ingest(input: Input | (Input | [Input, Input])[], second?: Input, onDeposit?: (report: import("./learning.js").DepositReport) => void): Promise<(Sema & {
239
242
  id: number;
240
243
  }) | undefined>;
241
244
  private extensionHost;
@@ -464,8 +464,11 @@ export class Mind {
464
464
  return new Uint8Array(0);
465
465
  }
466
466
  // ── Learning ─────────────────────────────────────────────────────────────
467
- async ingest(input, second) {
468
- return ingest(this, input, second);
467
+ /** See {@link import("./learning.js").ingest} — `onDeposit`, when given,
468
+ * reports each ingested item's deposited root node ids
469
+ * ({@link DepositReport}); purely observational. */
470
+ async ingest(input, second, onDeposit) {
471
+ return ingest(this, input, second, onDeposit);
469
472
  }
470
473
  // ── Extension Surface ────────────────────────────────────────────────────
471
474
  extensionHost() {
@@ -8,6 +8,35 @@ export interface Thought {
8
8
  bytes: Uint8Array;
9
9
  provenance: Provenance;
10
10
  }
11
+ /** Structured payload of the "decideGrounding" rationale step — the same
12
+ * numbers the human-readable candidate labels already carry, exposed as
13
+ * data so a downstream tool need not parse free text. Purely additive
14
+ * instrumentation: built only under `ctx.trace?.` (optional chaining
15
+ * short-circuits its arguments), never read by inference. */
16
+ export interface DecideGroundingData {
17
+ version: 1;
18
+ /** Every grounding candidate weighed, in consideration order. */
19
+ candidates: Array<{
20
+ provenance: string;
21
+ /** The candidate's exact weight in the one cost ladder. */
22
+ weight: number;
23
+ /** The DISCRETE grade the decision actually compares (floor(weight/STEP)). */
24
+ grade: number;
25
+ /** Query bytes the candidate's accounted spans leave unexplained. */
26
+ unexplainedBytes: number;
27
+ /** Whether this candidate won the decision. */
28
+ decided: boolean;
29
+ }>;
30
+ /** Grade margin between the winner and the runner-up, when both exist —
31
+ * the same quantity the "narrowDecision" step reports as narrow when
32
+ * ≤ 1. Absent for a single-candidate decision. */
33
+ runnerUpMargin?: number;
34
+ }
35
+ /** Structured payload of the "narrowDecision" rationale step. */
36
+ export interface NarrowDecisionData {
37
+ version: 1;
38
+ margin: number;
39
+ }
11
40
  /** Think: a single lightest-derivation exploration of the Sema graph.
12
41
  *
13
42
  * Every answer travels the same path:
@@ -132,9 +132,12 @@ export async function think(ctx, query, mechs) {
132
132
  // initial null, so the read-back needs the assertion.)
133
133
  const decided = best;
134
134
  if (candidates.length > 1) {
135
- ctx.trace?.step("decideGrounding", candidates.map((c) => rItem(c.bytes, `${c.provenance} (weight ${c.weight.toFixed(3)}${c.unexplained ? `, unexplained: "${c.unexplained}"` : ""})`)), decided ? [rItem(decided.bytes, decided.provenance)] : [], "the lightest grounding derivation wins — every mechanism weighed in the one cost ladder");
135
+ // The runner-up is computed BEFORE the decideGrounding step so its grade
136
+ // margin can ride along in the step's structured data payload; the
137
+ // computation itself is pure and was always unconditional — only its
138
+ // position moved.
139
+ let runnerUp = null;
136
140
  if (decided !== null) {
137
- let runnerUp = null;
138
141
  for (const c of candidates) {
139
142
  if (c === decided)
140
143
  continue;
@@ -142,15 +145,28 @@ export async function think(ctx, query, mechs) {
142
145
  runnerUp = c;
143
146
  }
144
147
  }
145
- if (runnerUp !== null) {
146
- const margin = grade(runnerUp.weight) - grade(decided.weight);
147
- if (margin <= 1) {
148
- ctx.trace?.step("narrowDecision", [
149
- rItem(decided.bytes, `${decided.provenance} (weight ${decided.weight.toFixed(3)})`),
150
- ], [
151
- rItem(runnerUp.bytes, `${runnerUp.provenance} (weight ${runnerUp.weight.toFixed(3)})`),
152
- ], `margin ${margin} grade-unit(s) — the decision could change with one more training fact`);
153
- }
148
+ }
149
+ const margin = decided !== null && runnerUp !== null
150
+ ? grade(runnerUp.weight) - grade(decided.weight)
151
+ : null;
152
+ ctx.trace?.step("decideGrounding", candidates.map((c) => rItem(c.bytes, `${c.provenance} (weight ${c.weight.toFixed(3)}${c.unexplained ? `, unexplained: "${c.unexplained}"` : ""})`)), decided ? [rItem(decided.bytes, decided.provenance)] : [], "the lightest grounding derivation wins — every mechanism weighed in the one cost ladder", undefined, {
153
+ version: 1,
154
+ candidates: candidates.map((c) => ({
155
+ provenance: c.provenance,
156
+ weight: c.weight,
157
+ grade: grade(c.weight),
158
+ unexplainedBytes: unaccounted(c.accounted),
159
+ decided: c === decided,
160
+ })),
161
+ ...(margin !== null ? { runnerUpMargin: margin } : {}),
162
+ });
163
+ if (decided !== null && runnerUp !== null && margin !== null) {
164
+ if (margin <= 1) {
165
+ ctx.trace?.step("narrowDecision", [
166
+ rItem(decided.bytes, `${decided.provenance} (weight ${decided.weight.toFixed(3)})`),
167
+ ], [
168
+ rItem(runnerUp.bytes, `${runnerUp.provenance} (weight ${runnerUp.weight.toFixed(3)})`),
169
+ ], `margin ${margin} grade-unit(s) — the decision could change with one more training fact`, undefined, { version: 1, margin });
154
170
  }
155
171
  }
156
172
  }
@@ -102,6 +102,8 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
102
102
  observed: atomReach(ctx, contextCount),
103
103
  limit: bound0,
104
104
  },
105
+ visited: 0,
106
+ maxDepth: 0,
105
107
  }
106
108
  : {}),
107
109
  };
@@ -151,7 +153,20 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
151
153
  // ~20K distinct nodes per climb family, >95% unique — not memoisable)
152
154
  // while the context account never decided.
153
155
  let lateral = 0;
156
+ // CLIMB READ-OUT (pure instrumentation, same contract as satStop): the
157
+ // parallel `depths` stack mirrors every push/pop of `stack`, so a node's
158
+ // ascent distance is known at its pop. Allocated only when a trace is
159
+ // requested; the climb itself never reads any of these back.
160
+ const depths = ctx.trace ? [] : null;
161
+ let curDepth = 0;
162
+ let visitedCount = 0;
163
+ let maxDepth = 0;
154
164
  const visit = (x) => {
165
+ if (depths) {
166
+ visitedCount++;
167
+ if (curDepth > maxDepth)
168
+ maxDepth = curDepth;
169
+ }
155
170
  const hasNx = cachedHasNext(ctx, x, structCache);
156
171
  const pc = cachedPrevCount(ctx, x, structCache);
157
172
  if (hasNx || pc > 0) {
@@ -203,6 +218,7 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
203
218
  if (!seen.has(p)) {
204
219
  seen.add(p);
205
220
  stack.push(p);
221
+ depths?.push(curDepth + 1);
206
222
  fresh++;
207
223
  }
208
224
  }
@@ -225,8 +241,10 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
225
241
  };
226
242
  const stack = [];
227
243
  const containment = !cachedHasParents(ctx, id, structCache);
228
- if (!containment)
244
+ if (!containment) {
229
245
  stack.push(id);
246
+ depths?.push(0);
247
+ }
230
248
  // The containment seed is STREAMED in pages of √N: a distinctive window's
231
249
  // containers (which converge on one or two contexts, however many chunks
232
250
  // of one deposit repeat it) are walked IN FULL — exact — while a common
@@ -247,17 +265,22 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
247
265
  if (!seen.has(c)) {
248
266
  seen.add(c);
249
267
  stack.push(c);
268
+ depths?.push(1);
250
269
  }
251
270
  }
252
271
  if (stack.length === 0) {
253
- if (containerOff === 0)
272
+ if (containerOff === 0) {
254
273
  stack.push(id); // no containers at all
274
+ depths?.push(0);
275
+ }
255
276
  else
256
277
  break;
257
278
  }
258
279
  }
259
280
  while (stack.length > 0) {
260
281
  let x = stack.pop();
282
+ if (depths)
283
+ curDepth = depths.pop();
261
284
  // TRANSPARENT-CHAIN HOP: a node with no edges in or out and exactly one
262
285
  // parent contributes nothing here — no root, no context, no lateral
263
286
  // entry — so the run to its first non-transparent ancestor is skipped
@@ -277,6 +300,10 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
277
300
  if (dup)
278
301
  continue;
279
302
  x = top;
303
+ // The chain's interior hops are part of the terminal's ascent
304
+ // distance — count them exactly as a node-at-a-time ascent would.
305
+ if (depths)
306
+ curDepth += run.length - 1;
280
307
  }
281
308
  if (!visit(x)) {
282
309
  saturated = true;
@@ -289,6 +316,7 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
289
316
  contextsReached: ctxSeen.size,
290
317
  saturated,
291
318
  ...(saturated && satStop ? { saturation: satStop } : {}),
319
+ ...(depths ? { visited: visitedCount, maxDepth } : {}),
292
320
  };
293
321
  memo?.set(id, reach);
294
322
  return reach;
@@ -156,6 +156,17 @@ export interface AncestorReach {
156
156
  * a non-saturated reach, and absent (even when saturated) when no trace
157
157
  * was requested — instrumentation must not allocate when tracing is off. */
158
158
  saturation?: SaturationStop;
159
+ /** The number of nodes the climb actually PROCESSED (popped and examined
160
+ * by its visit step; a transparent chain counts as its one terminal).
161
+ * Present only when a trace was requested — same contract as
162
+ * {@link saturation}: instrumentation must not allocate when tracing is
163
+ * off. Purely a read-out; the climb never consults it. */
164
+ visited?: number;
165
+ /** The maximum structural ascent distance (in parent/containment hops,
166
+ * transparent-chain interiors counted) from the start node among the
167
+ * processed nodes. Present only when a trace was requested — see
168
+ * {@link visited}. */
169
+ maxDepth?: number;
159
170
  }
160
171
  /** Saturated-interval information for the noise-drop gate. */
161
172
  export interface SaturationInfo {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -149,6 +149,11 @@ export interface ConsensusReachTrace {
149
149
  contextsReached: number;
150
150
  saturated: boolean;
151
151
  saturation?: SaturationStop;
152
+ /** Nodes the climb processed — see {@link AncestorReach.visited}. Absent
153
+ * on payloads recorded before this field existed. */
154
+ visited?: number;
155
+ /** Maximum ascent distance — see {@link AncestorReach.maxDepth}. */
156
+ maxDepth?: number;
152
157
  }
153
158
 
154
159
  export type AnchorRejectionReason =
@@ -419,6 +424,9 @@ function serialiseReaches(
419
424
  contextsReached: r.contextsReached,
420
425
  saturated: r.saturated,
421
426
  ...(r.saturation ? { saturation: r.saturation } : {}),
427
+ ...(r.visited !== undefined
428
+ ? { visited: r.visited, maxDepth: r.maxDepth }
429
+ : {}),
422
430
  });
423
431
  }
424
432
  return out;
package/src/mind/index.ts CHANGED
@@ -32,3 +32,9 @@ export type {
32
32
  SaturationReason,
33
33
  SaturationStop,
34
34
  } from "./types.js";
35
+ export type { DepositReport } from "./learning.js";
36
+ export type {
37
+ DecideGroundingData,
38
+ NarrowDecisionData,
39
+ Provenance,
40
+ } from "./pipeline.js";
@@ -215,12 +215,32 @@ async function propagateSuffixes(
215
215
  }
216
216
  }
217
217
 
218
- /** Ingest a pair (context, continuation)learn an edge and pour halos. */
218
+ /** What one ingested item depositedreported through {@link ingest}'s
219
+ * optional `onDeposit` callback. Pure provenance read-out: node ids are
220
+ * content-addressed, so two byte-identical items report the SAME ids (that
221
+ * is content addressing working, not an error), and the callback observes
222
+ * the deposit without influencing it. */
223
+ export interface DepositReport {
224
+ /** Zero-based position of the item in the ingested input (0 for a scalar
225
+ * or single pair). */
226
+ index: number;
227
+ /** Whether the item was a bare experience or a (context, continuation)
228
+ * pair. */
229
+ kind: "one" | "pair";
230
+ /** Root node id of the item's (context) bytes. */
231
+ contextId: number;
232
+ /** Root node id of the continuation bytes — pairs only. */
233
+ continuationId?: number;
234
+ }
235
+
236
+ /** Ingest a pair (context, continuation) — learn an edge and pour halos.
237
+ * Returns the deposited root ids (context, continuation) — a pure
238
+ * read-out; callers that ignore it behave exactly as before. */
219
239
  export async function ingestPair(
220
240
  ctx: MindContext,
221
241
  ctxInput: Input,
222
242
  cont: Input,
223
- ): Promise<void> {
243
+ ): Promise<{ ctxId: number; contId: number }> {
224
244
  const c = await deposit(ctx, ctxInput, true, true);
225
245
  const cont_ = await deposit(ctx, cont, false);
226
246
  const ctxId = c.rootId, contId = cont_.rootId;
@@ -249,6 +269,7 @@ export async function ingestPair(
249
269
  bindSeat(ctx.space, companySignature(ctx.space, partId), 0),
250
270
  );
251
271
  }
272
+ return { ctxId, contId };
252
273
  }
253
274
 
254
275
  /** Dispatch the public ingest input shapes onto one-input / pair handlers —
@@ -286,16 +307,36 @@ export async function dispatchIngest(
286
307
  return undefined;
287
308
  }
288
309
 
289
- /** Ingest an input or array of inputs/pairs. The public ingest entry point. */
310
+ /** Ingest an input or array of inputs/pairs. The public ingest entry point.
311
+ *
312
+ * `onDeposit`, when given, is invoked once per ingested item with the
313
+ * deposited root node ids ({@link DepositReport}) — item-level provenance
314
+ * for tooling that needs to know which stored node an ingested item became.
315
+ * Purely observational: the callback runs after the item's deposit
316
+ * completed and nothing reads its result. */
290
317
  export async function ingest(
291
318
  ctx: MindContext,
292
319
  input: Input | (Input | [Input, Input])[],
293
320
  second?: Input,
321
+ onDeposit?: (report: DepositReport) => void,
294
322
  ): Promise<(Sema & { id: number }) | undefined> {
323
+ let index = 0;
295
324
  return dispatchIngest(
296
325
  input,
297
326
  second,
298
- (i) => ingestOne(ctx, i),
299
- (a, b) => ingestPair(ctx, a, b),
327
+ async (i) => {
328
+ const r = await ingestOne(ctx, i);
329
+ onDeposit?.({ index: index++, kind: "one", contextId: r.id });
330
+ return r;
331
+ },
332
+ async (a, b) => {
333
+ const { ctxId, contId } = await ingestPair(ctx, a, b);
334
+ onDeposit?.({
335
+ index: index++,
336
+ kind: "pair",
337
+ contextId: ctxId,
338
+ continuationId: contId,
339
+ });
340
+ },
300
341
  );
301
342
  }
package/src/mind/mind.ts CHANGED
@@ -767,11 +767,15 @@ export class Mind implements MindContext {
767
767
 
768
768
  // ── Learning ─────────────────────────────────────────────────────────────
769
769
 
770
+ /** See {@link import("./learning.js").ingest} — `onDeposit`, when given,
771
+ * reports each ingested item's deposited root node ids
772
+ * ({@link DepositReport}); purely observational. */
770
773
  async ingest(
771
774
  input: Input | (Input | [Input, Input])[],
772
775
  second?: Input,
776
+ onDeposit?: (report: import("./learning.js").DepositReport) => void,
773
777
  ): Promise<(Sema & { id: number }) | undefined> {
774
- return ingest(this, input, second);
778
+ return ingest(this, input, second, onDeposit);
775
779
  }
776
780
 
777
781
  // ── Extension Surface ────────────────────────────────────────────────────
@@ -77,6 +77,37 @@ export interface Thought {
77
77
  provenance: Provenance;
78
78
  }
79
79
 
80
+ /** Structured payload of the "decideGrounding" rationale step — the same
81
+ * numbers the human-readable candidate labels already carry, exposed as
82
+ * data so a downstream tool need not parse free text. Purely additive
83
+ * instrumentation: built only under `ctx.trace?.` (optional chaining
84
+ * short-circuits its arguments), never read by inference. */
85
+ export interface DecideGroundingData {
86
+ version: 1;
87
+ /** Every grounding candidate weighed, in consideration order. */
88
+ candidates: Array<{
89
+ provenance: string;
90
+ /** The candidate's exact weight in the one cost ladder. */
91
+ weight: number;
92
+ /** The DISCRETE grade the decision actually compares (floor(weight/STEP)). */
93
+ grade: number;
94
+ /** Query bytes the candidate's accounted spans leave unexplained. */
95
+ unexplainedBytes: number;
96
+ /** Whether this candidate won the decision. */
97
+ decided: boolean;
98
+ }>;
99
+ /** Grade margin between the winner and the runner-up, when both exist —
100
+ * the same quantity the "narrowDecision" step reports as narrow when
101
+ * ≤ 1. Absent for a single-candidate decision. */
102
+ runnerUpMargin?: number;
103
+ }
104
+
105
+ /** Structured payload of the "narrowDecision" rationale step. */
106
+ export interface NarrowDecisionData {
107
+ version: 1;
108
+ margin: number;
109
+ }
110
+
80
111
  /** Think: a single lightest-derivation exploration of the Sema graph.
81
112
  *
82
113
  * Every answer travels the same path:
@@ -211,6 +242,22 @@ export async function think(
211
242
  // initial null, so the read-back needs the assertion.)
212
243
  const decided = best as Candidate | null;
213
244
  if (candidates.length > 1) {
245
+ // The runner-up is computed BEFORE the decideGrounding step so its grade
246
+ // margin can ride along in the step's structured data payload; the
247
+ // computation itself is pure and was always unconditional — only its
248
+ // position moved.
249
+ let runnerUp: Candidate | null = null;
250
+ if (decided !== null) {
251
+ for (const c of candidates) {
252
+ if (c === decided) continue;
253
+ if (runnerUp === null || grade(c.weight) < grade(runnerUp.weight)) {
254
+ runnerUp = c;
255
+ }
256
+ }
257
+ }
258
+ const margin = decided !== null && runnerUp !== null
259
+ ? grade(runnerUp.weight) - grade(decided.weight)
260
+ : null;
214
261
  ctx.trace?.step(
215
262
  "decideGrounding",
216
263
  candidates.map((c) =>
@@ -223,35 +270,39 @@ export async function think(
223
270
  ),
224
271
  decided ? [rItem(decided.bytes, decided.provenance)] : [],
225
272
  "the lightest grounding derivation wins — every mechanism weighed in the one cost ladder",
273
+ undefined,
274
+ {
275
+ version: 1,
276
+ candidates: candidates.map((c) => ({
277
+ provenance: c.provenance,
278
+ weight: c.weight,
279
+ grade: grade(c.weight),
280
+ unexplainedBytes: unaccounted(c.accounted),
281
+ decided: c === decided,
282
+ })),
283
+ ...(margin !== null ? { runnerUpMargin: margin } : {}),
284
+ } satisfies DecideGroundingData,
226
285
  );
227
- if (decided !== null) {
228
- let runnerUp: Candidate | null = null;
229
- for (const c of candidates) {
230
- if (c === decided) continue;
231
- if (runnerUp === null || grade(c.weight) < grade(runnerUp.weight)) {
232
- runnerUp = c;
233
- }
234
- }
235
- if (runnerUp !== null) {
236
- const margin = grade(runnerUp.weight) - grade(decided.weight);
237
- if (margin <= 1) {
238
- ctx.trace?.step(
239
- "narrowDecision",
240
- [
241
- rItem(
242
- decided.bytes,
243
- `${decided.provenance} (weight ${decided.weight.toFixed(3)})`,
244
- ),
245
- ],
246
- [
247
- rItem(
248
- runnerUp.bytes,
249
- `${runnerUp.provenance} (weight ${runnerUp.weight.toFixed(3)})`,
250
- ),
251
- ],
252
- `margin ${margin} grade-unit(s) — the decision could change with one more training fact`,
253
- );
254
- }
286
+ if (decided !== null && runnerUp !== null && margin !== null) {
287
+ if (margin <= 1) {
288
+ ctx.trace?.step(
289
+ "narrowDecision",
290
+ [
291
+ rItem(
292
+ decided.bytes,
293
+ `${decided.provenance} (weight ${decided.weight.toFixed(3)})`,
294
+ ),
295
+ ],
296
+ [
297
+ rItem(
298
+ runnerUp.bytes,
299
+ `${runnerUp.provenance} (weight ${runnerUp.weight.toFixed(3)})`,
300
+ ),
301
+ ],
302
+ `margin ${margin} grade-unit(s) — the decision could change with one more training fact`,
303
+ undefined,
304
+ { version: 1, margin } satisfies NarrowDecisionData,
305
+ );
255
306
  }
256
307
  }
257
308
  }
@@ -147,6 +147,8 @@ export function edgeAncestors(
147
147
  observed: atomReach(ctx, contextCount),
148
148
  limit: bound0,
149
149
  },
150
+ visited: 0,
151
+ maxDepth: 0,
150
152
  }
151
153
  : {}),
152
154
  };
@@ -201,7 +203,20 @@ export function edgeAncestors(
201
203
  // while the context account never decided.
202
204
  let lateral = 0;
203
205
 
206
+ // CLIMB READ-OUT (pure instrumentation, same contract as satStop): the
207
+ // parallel `depths` stack mirrors every push/pop of `stack`, so a node's
208
+ // ascent distance is known at its pop. Allocated only when a trace is
209
+ // requested; the climb itself never reads any of these back.
210
+ const depths: number[] | null = ctx.trace ? [] : null;
211
+ let curDepth = 0;
212
+ let visitedCount = 0;
213
+ let maxDepth = 0;
214
+
204
215
  const visit = (x: number): boolean => {
216
+ if (depths) {
217
+ visitedCount++;
218
+ if (curDepth > maxDepth) maxDepth = curDepth;
219
+ }
205
220
  const hasNx = cachedHasNext(ctx, x, structCache);
206
221
  const pc = cachedPrevCount(ctx, x, structCache);
207
222
  if (hasNx || pc > 0) {
@@ -251,6 +266,7 @@ export function edgeAncestors(
251
266
  if (!seen.has(p)) {
252
267
  seen.add(p);
253
268
  stack.push(p);
269
+ depths?.push(curDepth + 1);
254
270
  fresh++;
255
271
  }
256
272
  }
@@ -274,7 +290,10 @@ export function edgeAncestors(
274
290
 
275
291
  const stack: number[] = [];
276
292
  const containment = !cachedHasParents(ctx, id, structCache);
277
- if (!containment) stack.push(id);
293
+ if (!containment) {
294
+ stack.push(id);
295
+ depths?.push(0);
296
+ }
278
297
 
279
298
  // The containment seed is STREAMED in pages of √N: a distinctive window's
280
299
  // containers (which converge on one or two contexts, however many chunks
@@ -295,15 +314,19 @@ export function edgeAncestors(
295
314
  if (!seen.has(c)) {
296
315
  seen.add(c);
297
316
  stack.push(c);
317
+ depths?.push(1);
298
318
  }
299
319
  }
300
320
  if (stack.length === 0) {
301
- if (containerOff === 0) stack.push(id); // no containers at all
302
- else break;
321
+ if (containerOff === 0) {
322
+ stack.push(id); // no containers at all
323
+ depths?.push(0);
324
+ } else break;
303
325
  }
304
326
  }
305
327
  while (stack.length > 0) {
306
328
  let x = stack.pop()!;
329
+ if (depths) curDepth = depths.pop()!;
307
330
  // TRANSPARENT-CHAIN HOP: a node with no edges in or out and exactly one
308
331
  // parent contributes nothing here — no root, no context, no lateral
309
332
  // entry — so the run to its first non-transparent ancestor is skipped
@@ -321,6 +344,9 @@ export function edgeAncestors(
321
344
  for (let i = 1; i < run.length; i++) seen.add(run[i]);
322
345
  if (dup) continue;
323
346
  x = top;
347
+ // The chain's interior hops are part of the terminal's ascent
348
+ // distance — count them exactly as a node-at-a-time ascent would.
349
+ if (depths) curDepth += run.length - 1;
324
350
  }
325
351
  if (!visit(x)) {
326
352
  saturated = true;
@@ -334,6 +360,7 @@ export function edgeAncestors(
334
360
  contextsReached: ctxSeen.size,
335
361
  saturated,
336
362
  ...(saturated && satStop ? { saturation: satStop } : {}),
363
+ ...(depths ? { visited: visitedCount, maxDepth } : {}),
337
364
  };
338
365
  memo?.set(id, reach);
339
366
  return reach;
package/src/mind/types.ts CHANGED
@@ -205,6 +205,17 @@ export interface AncestorReach {
205
205
  * a non-saturated reach, and absent (even when saturated) when no trace
206
206
  * was requested — instrumentation must not allocate when tracing is off. */
207
207
  saturation?: SaturationStop;
208
+ /** The number of nodes the climb actually PROCESSED (popped and examined
209
+ * by its visit step; a transparent chain counts as its one terminal).
210
+ * Present only when a trace was requested — same contract as
211
+ * {@link saturation}: instrumentation must not allocate when tracing is
212
+ * off. Purely a read-out; the climb never consults it. */
213
+ visited?: number;
214
+ /** The maximum structural ascent distance (in parent/containment hops,
215
+ * transparent-chain interiors counted) from the start node among the
216
+ * processed nodes. Present only when a trace was requested — see
217
+ * {@link visited}. */
218
+ maxDepth?: number;
208
219
  }
209
220
 
210
221
  /** Saturated-interval information for the noise-drop gate. */
@@ -0,0 +1,175 @@
1
+ // 54-evidence-k-instrumentation.test.mjs — the three purely-additive
2
+ // read-outs added for evidence-breadth measurement tooling:
3
+ // 1. AncestorReach.visited / maxDepth (trace-gated, serialised into
4
+ // ClimbConsensusData.reaches),
5
+ // 2. structured data payloads on "decideGrounding" / "narrowDecision",
6
+ // 3. ingest()'s optional onDeposit provenance callback.
7
+ //
8
+ // Every assertion here checks a READ-OUT; the final test pins that none of
9
+ // them changes the answer (the same invariant tests 52 §6 / 53 §9 pin for
10
+ // the climb instrumentation).
11
+
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { decodeText, Mind } from "../dist/src/index.js";
15
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
16
+
17
+ const mk = (seed = 1) =>
18
+ new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
19
+
20
+ const CORPUS = [
21
+ ["red", "is a color"],
22
+ ["blue", "is a color"],
23
+ ["circle", "is a shape"],
24
+ ["square", "is a shape"],
25
+ ["red circle", "answer alpha"],
26
+ ["red square", "answer beta"],
27
+ ["blue circle", "answer gamma"],
28
+ ["blue square", "answer delta"],
29
+ ];
30
+
31
+ async function trace(mind, q) {
32
+ const steps = [];
33
+ const ans = await mind.respondText(q, (s) => steps.push(s));
34
+ return { steps, ans };
35
+ }
36
+
37
+ // ═══════════════════════════════════════════════════════════════════════════
38
+ // 1. climb read-out: visited / maxDepth
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+
41
+ test("1. traced climb reaches carry visited/maxDepth; untraced ones do not", async () => {
42
+ const m = mk(1);
43
+ await m.ingest(CORPUS);
44
+
45
+ const { steps } = await trace(m, "red then circle");
46
+ const climb = steps.find((s) => s.mechanism.at(-1) === "climbConsensus");
47
+ assert.ok(climb?.data?.reaches?.length > 0, "expected reach traces");
48
+ for (const r of climb.data.reaches) {
49
+ assert.equal(typeof r.visited, "number");
50
+ assert.equal(typeof r.maxDepth, "number");
51
+ assert.ok(r.visited >= 0);
52
+ assert.ok(r.maxDepth >= 0);
53
+ // A climb that processed no node cannot have ascended. (One processed
54
+ // node can already sit at depth 1: containment seeds start one hop up.)
55
+ if (r.visited === 0) assert.equal(r.maxDepth, 0);
56
+ // Depth is bounded by the number of processed nodes plus any
57
+ // transparent-chain interiors — sanity only: never negative, and zero
58
+ // whenever nothing was climbed.
59
+ assert.ok(r.maxDepth >= 0);
60
+ }
61
+
62
+ // Untraced (no respond in flight): the same contract as `saturation` —
63
+ // instrumentation fields absent when no trace was requested.
64
+ const someNode = climb.data.reaches[0].node;
65
+ const reach = m.edgeAncestors(someNode, 8);
66
+ assert.equal(reach.visited, undefined);
67
+ assert.equal(reach.maxDepth, undefined);
68
+ await m.store.close();
69
+ });
70
+
71
+ // ═══════════════════════════════════════════════════════════════════════════
72
+ // 2. decideGrounding / narrowDecision data payloads
73
+ // ═══════════════════════════════════════════════════════════════════════════
74
+
75
+ test("2. decideGrounding carries every candidate's weight/grade as data, exactly one decided", async () => {
76
+ const m = mk(1);
77
+ await m.ingest(CORPUS);
78
+ const { steps, ans } = await trace(m, "red then circle");
79
+ await m.store.close();
80
+
81
+ const dg = steps.find((s) => s.mechanism.at(-1) === "decideGrounding");
82
+ assert.ok(dg, "expected a decideGrounding step (≥2 candidates)");
83
+ assert.ok(dg.data, "decideGrounding must carry a data payload");
84
+ assert.equal(dg.data.version, 1);
85
+ assert.equal(dg.data.candidates.length, dg.inputs.length);
86
+ for (const c of dg.data.candidates) {
87
+ assert.equal(typeof c.provenance, "string");
88
+ assert.equal(typeof c.weight, "number");
89
+ assert.equal(typeof c.grade, "number");
90
+ assert.equal(typeof c.unexplainedBytes, "number");
91
+ assert.ok(c.unexplainedBytes >= 0);
92
+ }
93
+ const decided = dg.data.candidates.filter((c) => c.decided);
94
+ assert.equal(decided.length, 1, "exactly one candidate wins");
95
+ // The winner's grade is minimal — the data restates the decision rule.
96
+ const minGrade = Math.min(...dg.data.candidates.map((c) => c.grade));
97
+ assert.equal(decided[0].grade, minGrade);
98
+ assert.ok(ans.length > 0);
99
+
100
+ if (dg.data.runnerUpMargin !== undefined) {
101
+ assert.ok(dg.data.runnerUpMargin >= 0);
102
+ const nd = steps.find((s) => s.mechanism.at(-1) === "narrowDecision");
103
+ if (dg.data.runnerUpMargin <= 1) {
104
+ assert.ok(nd, "margin ≤ 1 must emit narrowDecision");
105
+ assert.equal(nd.data.version, 1);
106
+ assert.equal(nd.data.margin, dg.data.runnerUpMargin);
107
+ } else {
108
+ assert.equal(nd, undefined);
109
+ }
110
+ }
111
+ });
112
+
113
+ // ═══════════════════════════════════════════════════════════════════════════
114
+ // 3. onDeposit provenance callback
115
+ // ═══════════════════════════════════════════════════════════════════════════
116
+
117
+ test("3. onDeposit reports one item-indexed record per ingested item, ids content-addressed", async () => {
118
+ const m = mk(1);
119
+ const reports = [];
120
+ await m.ingest(CORPUS, undefined, (r) => reports.push(r));
121
+
122
+ assert.equal(reports.length, CORPUS.length);
123
+ reports.forEach((r, i) => {
124
+ assert.equal(r.index, i);
125
+ assert.equal(r.kind, "pair");
126
+ assert.equal(typeof r.contextId, "number");
127
+ assert.equal(typeof r.continuationId, "number");
128
+ // The reported ids ARE the items' content-addressed nodes.
129
+ assert.equal(decodeText(m.store.bytes(r.contextId)), CORPUS[i][0]);
130
+ assert.equal(decodeText(m.store.bytes(r.continuationId)), CORPUS[i][1]);
131
+ });
132
+ // Content addressing: identical continuations share one node id.
133
+ const colorIds = [reports[0], reports[1]].map((r) => r.continuationId);
134
+ assert.equal(colorIds[0], colorIds[1]);
135
+ await m.store.close();
136
+ });
137
+
138
+ test("3b. onDeposit reports bare items as kind 'one'; omitting it changes nothing", async () => {
139
+ const m = mk(1);
140
+ const reports = [];
141
+ await m.ingest(
142
+ ["standalone note", ["ctx", "cont"]],
143
+ undefined,
144
+ (r) => reports.push(r),
145
+ );
146
+ assert.deepEqual(reports.map((r) => [r.index, r.kind]), [[0, "one"], [
147
+ 1,
148
+ "pair",
149
+ ]]);
150
+ assert.equal(reports[1].continuationId !== undefined, true);
151
+ assert.equal(reports[0].continuationId, undefined);
152
+ await m.store.close();
153
+ });
154
+
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+ // 4. none of the read-outs changes the answer
157
+ // ═══════════════════════════════════════════════════════════════════════════
158
+
159
+ test("4. answers are identical with and without the new read-outs attached", async () => {
160
+ const ask = async (withHooks) => {
161
+ const m = mk(7);
162
+ await m.ingest(CORPUS, undefined, withHooks ? () => {} : undefined);
163
+ const out = [];
164
+ for (
165
+ const q of ["red then circle", "red circle blue square", "blue square"]
166
+ ) {
167
+ out.push(
168
+ withHooks ? await m.respondText(q, () => {}) : await m.respondText(q),
169
+ );
170
+ }
171
+ await m.store.close();
172
+ return out;
173
+ };
174
+ assert.deepEqual(await ask(true), await ask(false));
175
+ });