@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.
@@ -3,3 +3,5 @@ export type { Input, Response } from "./mind.js";
3
3
  export type { ComputedSpan, ExtensionHost } from "./mind.js";
4
4
  export type { MechanismResult, PipelineMechanism, Precomputed, } from "./pipeline-mechanism.js";
5
5
  export type { InspectRationale, RationaleItem, RationaleStep, } from "./rationale.js";
6
+ export type { AnchorRejectionReason, ClimbConsensusData, ConsensusAnchorTrace, ConsensusReachTrace, ConsensusRegionTrace, CrossRegionTier, JunctionVoteTrace, RegionOutcome, } from "./attention.js";
7
+ export type { AncestorReach, AttentionRead, SaturationReason, SaturationStop, } from "./types.js";
@@ -1,3 +1,4 @@
1
+ import type { Hit } from "../store.js";
1
2
  import type { MindContext } from "./types.js";
2
3
  export interface Junction {
3
4
  /** The node whose learnt bytes evidence this junction (a container form,
@@ -6,6 +7,34 @@ export interface Junction {
6
7
  /** The bytes that belong between left and right. */
7
8
  interior: Uint8Array;
8
9
  }
10
+ /** Which relaxation produced a {@link SynonymJunction}: one side replaced by
11
+ * a distributional halo sibling (`single-synonym`), or both (`double-
12
+ * synonym`) — the two remaining rungs of the graded ladder below exact DAG
13
+ * containment (see the module doc atop {@link junctionSynonyms}). */
14
+ export type SynonymJunctionTier = "single-synonym" | "double-synonym";
15
+ export interface SynonymJunction extends Junction {
16
+ tier: SynonymJunctionTier;
17
+ /** Sibling score for a single-synonym junction; min(left, right) sibling
18
+ * score for a double-synonym junction. */
19
+ confidence: number;
20
+ }
21
+ /** The exact node ids and halo siblings resolved for one junction call's two
22
+ * sides — computed ONCE and reused by every ladder rung that needs them
23
+ * (junctionSynonyms' two tiers, and the structural-resonance tier beyond
24
+ * it). A failed synonym junction means only "no common DAG container was
25
+ * proven" — it does NOT mean the loaded siblings stop being useful. */
26
+ export interface JunctionSynonymSides {
27
+ leftId: number | null;
28
+ rightId: number | null;
29
+ leftSiblings: Hit[];
30
+ rightSiblings: Hit[];
31
+ }
32
+ /** Resolve `left`/`right` to their exact node ids (when known) and load each
33
+ * resolved side's halo siblings once — deterministic (haloSiblings already
34
+ * ranks nearest-first) and shared by every ladder rung that consults
35
+ * siblings, so no ladder rung repeats a halo ANN query the previous one
36
+ * already paid for. */
37
+ export declare function loadJunctionSynonymSides(ctx: MindContext, left: Uint8Array, right: Uint8Array): Promise<JunctionSynonymSides>;
9
38
  /** Seed node ids to ascend from for one side of a junction: the side's own
10
39
  * node when it is a stored form, plus — when the node has no structural
11
40
  * parents — its canonical window ids. A non-W-aligned node may have no
@@ -92,4 +121,4 @@ export declare function junctionContainers(ctx: MindContext, left: Uint8Array, r
92
121
  * cost is bounded at √N·W pops total regardless of how many siblings are
93
122
  * tried. A sibling whose bytes exceed `maxInterior` is skipped (it
94
123
  * cannot be junction-sized). */
95
- export declare function junctionSynonyms(ctx: MindContext, left: Uint8Array, right: Uint8Array, maxInterior: number, unordered?: boolean): Promise<Junction[]>;
124
+ export declare function junctionSynonyms(ctx: MindContext, left: Uint8Array, right: Uint8Array, maxInterior: number, unordered?: boolean, sides?: JunctionSynonymSides): Promise<SynonymJunction[]>;
@@ -16,6 +16,20 @@ import { windowIds } from "./canonical.js";
16
16
  import { hubBound } from "./traverse.js";
17
17
  import { haloSiblings } from "./match.js";
18
18
  import { indexOf } from "../bytes.js";
19
+ /** Resolve `left`/`right` to their exact node ids (when known) and load each
20
+ * resolved side's halo siblings once — deterministic (haloSiblings already
21
+ * ranks nearest-first) and shared by every ladder rung that consults
22
+ * siblings, so no ladder rung repeats a halo ANN query the previous one
23
+ * already paid for. */
24
+ export async function loadJunctionSynonymSides(ctx, left, right) {
25
+ const leftId = resolve(ctx, left);
26
+ const rightId = resolve(ctx, right);
27
+ const leftSiblings = leftId !== null ? await haloSiblings(ctx, leftId) : [];
28
+ const rightSiblings = rightId !== null
29
+ ? await haloSiblings(ctx, rightId)
30
+ : [];
31
+ return { leftId, rightId, leftSiblings, rightSiblings };
32
+ }
19
33
  /** Seed node ids to ascend from for one side of a junction: the side's own
20
34
  * node when it is a stored form, plus — when the node has no structural
21
35
  * parents — its canonical window ids. A non-W-aligned node may have no
@@ -225,38 +239,79 @@ export function junctionContainers(ctx, left, right, maxContainer, unordered = f
225
239
  * cost is bounded at √N·W pops total regardless of how many siblings are
226
240
  * tried. A sibling whose bytes exceed `maxInterior` is skipped (it
227
241
  * cannot be junction-sized). */
228
- export async function junctionSynonyms(ctx, left, right, maxInterior, unordered = false) {
229
- const out = [];
230
- const lId = resolve(ctx, left);
231
- const rId = resolve(ctx, right);
232
- if (lId === null && rId === null)
233
- return out;
234
- const budget = { n: hubBound(ctx) * ctx.space.maxGroup };
242
+ export async function junctionSynonyms(ctx, left, right, maxInterior, unordered = false, sides) {
243
+ const s = sides ?? await loadJunctionSynonymSides(ctx, left, right);
244
+ if (s.leftId === null && s.rightId === null)
245
+ return [];
246
+ // ── Tier 2.5a: single-synonym one side replaced by a halo sibling ──────
247
+ // ONE shared expansion budget across BOTH directions of this tier.
248
+ const singleBudget = { n: hubBound(ctx) * ctx.space.maxGroup };
249
+ const singleOut = new Map();
250
+ const keepBest = (map, j, tier, confidence) => {
251
+ const prev = map.get(j.id);
252
+ if (prev === undefined || confidence > prev.confidence) {
253
+ map.set(j.id, { ...j, tier, confidence });
254
+ }
255
+ };
235
256
  // Left-side synonyms: containers of sibling+right. `right`'s seeds are
236
257
  // FIXED across every sibling this loop tries.
237
- if (lId !== null) {
258
+ if (s.leftId !== null) {
238
259
  const rightSeeds = junctionSeeds(ctx, right);
239
- for (const sib of await haloSiblings(ctx, lId)) {
260
+ for (const sib of s.leftSiblings) {
240
261
  const sibBytes = read(ctx, sib.id, maxInterior + 1);
241
262
  if (sibBytes.length === 0 || sibBytes.length > maxInterior)
242
263
  continue;
243
- const containers = junctionContainersFrom(ctx, sibBytes, right, sibBytes.length + right.length + maxInterior, junctionSeeds(ctx, sibBytes), rightSeeds, budget, unordered);
244
- for (const c of containers)
245
- out.push(c);
264
+ const containers = junctionContainersFrom(ctx, sibBytes, right, sibBytes.length + right.length + maxInterior, junctionSeeds(ctx, sibBytes), rightSeeds, singleBudget, unordered);
265
+ for (const c of containers) {
266
+ keepBest(singleOut, c, "single-synonym", sib.score);
267
+ }
246
268
  }
247
269
  }
248
270
  // Right-side synonyms: containers of left+sibling. `left`'s seeds are
249
271
  // likewise fixed across this loop.
250
- if (rId !== null) {
272
+ if (s.rightId !== null) {
251
273
  const leftSeeds = junctionSeeds(ctx, left);
252
- for (const sib of await haloSiblings(ctx, rId)) {
274
+ for (const sib of s.rightSiblings) {
253
275
  const sibBytes = read(ctx, sib.id, maxInterior + 1);
254
276
  if (sibBytes.length === 0 || sibBytes.length > maxInterior)
255
277
  continue;
256
- const containers = junctionContainersFrom(ctx, left, sibBytes, left.length + sibBytes.length + maxInterior, leftSeeds, junctionSeeds(ctx, sibBytes), budget, unordered);
257
- for (const c of containers)
258
- out.push(c);
278
+ const containers = junctionContainersFrom(ctx, left, sibBytes, left.length + sibBytes.length + maxInterior, leftSeeds, junctionSeeds(ctx, sibBytes), singleBudget, unordered);
279
+ for (const c of containers) {
280
+ keepBest(singleOut, c, "single-synonym", sib.score);
281
+ }
259
282
  }
260
283
  }
261
- return out;
284
+ if (singleOut.size > 0)
285
+ return [...singleOut.values()];
286
+ // ── Tier 2.5b: double-synonym — BOTH sides replaced, tried only when
287
+ // single-synonym found NOTHING. Every (leftSibling, rightSibling) pair,
288
+ // sorted deterministically, bounded to haloQueryK pairs total, ONE fresh
289
+ // shared budget for the whole tier. ─────────────────────────────────────
290
+ if (s.leftSiblings.length === 0 || s.rightSiblings.length === 0)
291
+ return [];
292
+ const pairs = [];
293
+ for (const l of s.leftSiblings) {
294
+ for (const r of s.rightSiblings) {
295
+ pairs.push({ l, r, confidence: Math.min(l.score, r.score) });
296
+ }
297
+ }
298
+ pairs.sort((a, b) => b.confidence - a.confidence ||
299
+ a.l.id - b.l.id ||
300
+ a.r.id - b.r.id);
301
+ const doubleOut = new Map();
302
+ const budget = { n: hubBound(ctx) * ctx.space.maxGroup };
303
+ const tries = Math.min(pairs.length, ctx.cfg.haloQueryK);
304
+ for (let i = 0; i < tries; i++) {
305
+ const { l, r, confidence } = pairs[i];
306
+ const lBytes = read(ctx, l.id, maxInterior + 1);
307
+ const rBytes = read(ctx, r.id, maxInterior + 1);
308
+ if (lBytes.length === 0 || lBytes.length > maxInterior ||
309
+ rBytes.length === 0 || rBytes.length > maxInterior)
310
+ continue;
311
+ const containers = junctionContainersFrom(ctx, lBytes, rBytes, lBytes.length + rBytes.length + maxInterior, junctionSeeds(ctx, lBytes), junctionSeeds(ctx, rBytes), budget, unordered);
312
+ for (const c of containers) {
313
+ keepBest(doubleOut, c, "double-synonym", confidence);
314
+ }
315
+ }
316
+ return [...doubleOut.values()];
262
317
  }
@@ -42,6 +42,8 @@ export interface Conversation {
42
42
  readonly id: number;
43
43
  }
44
44
  import type { AttentionRead, MindContext, Recognition } from "./types.js";
45
+ export type { AnchorRejectionReason, ClimbConsensusData, ConsensusAnchorTrace, ConsensusReachTrace, ConsensusRegionTrace, CrossRegionTier, JunctionVoteTrace, RegionOutcome, } from "./attention.js";
46
+ export type { AncestorReach, SaturationReason, SaturationStop, } from "./types.js";
45
47
  export interface MindOptions {
46
48
  seed?: number;
47
49
  recallQueryK?: number;
@@ -59,6 +59,11 @@ export interface RationaleStep {
59
59
  /** A one-line, human account of what the mechanism did and why — the sentence
60
60
  * that turns the data into an explanation. */
61
61
  note?: string;
62
+ /** Optional structured payload — a mechanism-specific, plain-serialisable
63
+ * shape (no Map/Set/vectors/mutable internals) that carries more than the
64
+ * human-readable `note` can, for a debugger or downstream tool to consume
65
+ * programmatically. Never read by inference; purely additive. */
66
+ data?: unknown;
62
67
  }
63
68
  /** The callback {@link Mind.respond} / {@link Mind.respondText} accept. It is
64
69
  * invoked once per completed step, AS the inference unfolds — never batched at
@@ -86,7 +91,7 @@ export interface Scope {
86
91
  /** Close the mechanism: emit its step with these outputs and pop it off the
87
92
  * nesting stack. Idempotent — a second call is ignored, so a `finally` that
88
93
  * closes after an early return is safe. */
89
- done(outputs: RationaleItem[], note?: string): void;
94
+ done(outputs: RationaleItem[], note?: string, data?: unknown): void;
90
95
  }
91
96
  /** The live tracer: a stack of open mechanisms over one {@link Mind.respond}.
92
97
  *
@@ -130,5 +135,5 @@ export declare class Rationale {
130
135
  enter(name: string, inputs: RationaleItem[], deps?: number[]): Scope;
131
136
  /** Record a mechanism that has no sub-steps — its inputs and outputs are both
132
137
  * known at the call site. Returns its index, for a later step to depend on. */
133
- step(name: string, inputs: RationaleItem[], outputs: RationaleItem[], note?: string, deps?: number[]): number;
138
+ step(name: string, inputs: RationaleItem[], outputs: RationaleItem[], note?: string, deps?: number[], data?: unknown): number;
134
139
  }
@@ -109,7 +109,7 @@ export class Rationale {
109
109
  this.lastByName.set(name, index);
110
110
  return index;
111
111
  }
112
- emit(index, mechanism, inputs, outputs, deps, note) {
112
+ emit(index, mechanism, inputs, outputs, deps, note, data) {
113
113
  this.sink({
114
114
  index,
115
115
  mechanism,
@@ -120,6 +120,7 @@ export class Rationale {
120
120
  inputs,
121
121
  outputs,
122
122
  note,
123
+ data,
123
124
  });
124
125
  }
125
126
  /** Enter a mechanism that has sub-steps. Captures its inputs and the nesting
@@ -141,22 +142,22 @@ export class Rationale {
141
142
  };
142
143
  return {
143
144
  index,
144
- done: (outputs, note) => {
145
+ done: (outputs, note, data) => {
145
146
  if (closed)
146
147
  return;
147
148
  closed = true;
148
149
  pop();
149
- emit(index, mechanism, inputs, outputs, resolvedDeps, note);
150
+ emit(index, mechanism, inputs, outputs, resolvedDeps, note, data);
150
151
  },
151
152
  };
152
153
  }
153
154
  /** Record a mechanism that has no sub-steps — its inputs and outputs are both
154
155
  * known at the call site. Returns its index, for a later step to depend on. */
155
- step(name, inputs, outputs, note, deps) {
156
+ step(name, inputs, outputs, note, deps, data) {
156
157
  const mechanism = this.path(name);
157
158
  const resolvedDeps = deps ?? this.defaultDeps();
158
159
  const index = this.reserve(name);
159
- this.emit(index, mechanism, inputs, outputs, resolvedDeps, note);
160
+ this.emit(index, mechanism, inputs, outputs, resolvedDeps, note, data);
160
161
  return index;
161
162
  }
162
163
  }
@@ -89,7 +89,22 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
89
89
  // is withdrawn. On a small store the floor stays ≤ √N and the atom
90
90
  // climbs exactly as before, so single-letter facts keep working.
91
91
  if (id < 0 && atomIsHub(ctx, contextCount)) {
92
- const reach = { roots: [], contextsReached: 0, saturated: true };
92
+ const bound0 = Math.ceil(Math.sqrt(Math.max(2, contextCount)));
93
+ const reach = {
94
+ roots: [],
95
+ contextsReached: 0,
96
+ saturated: true,
97
+ ...(ctx.trace
98
+ ? {
99
+ saturation: {
100
+ reason: "byte-atom-commonality",
101
+ node: id,
102
+ observed: atomReach(ctx, contextCount),
103
+ limit: bound0,
104
+ },
105
+ }
106
+ : {}),
107
+ };
93
108
  memo?.set(id, reach);
94
109
  return reach;
95
110
  }
@@ -98,6 +113,10 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
98
113
  const seen = new Set([id]);
99
114
  const ctxSeen = new Set();
100
115
  let saturated = false;
116
+ // Provenance of the FIRST decision that saturated this climb — allocated
117
+ // only when a trace is requested (see AncestorReach.saturation's doc); the
118
+ // climb itself never reads it back.
119
+ let satStop;
101
120
  // EXPAND-UNTIL-DECIDED: a reach is consumed either as a VOTE (which needs
102
121
  // contextsReached exactly, and only while ≤ √N — beyond that the region is
103
122
  // non-discriminative) or as an ABSTENTION (saturated — whose roots and
@@ -139,16 +158,46 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
139
158
  roots.push(x);
140
159
  if (hasNx)
141
160
  ctxSeen.add(x);
142
- if (pc > bound)
143
- return false; // decided: ≥ pc > √N distinct contexts
161
+ if (pc > bound) {
162
+ // decided: ≥ pc > √N distinct contexts
163
+ if (ctx.trace) {
164
+ satStop = {
165
+ reason: "predecessor-fan-in",
166
+ node: x,
167
+ observed: pc,
168
+ limit: bound,
169
+ };
170
+ }
171
+ return false;
172
+ }
144
173
  for (const p of ctx.store.prevFirst(x, bound))
145
174
  ctxSeen.add(p);
146
- if (ctxSeen.size > bound)
147
- return false; // decided
175
+ if (ctxSeen.size > bound) {
176
+ // decided
177
+ if (ctx.trace) {
178
+ satStop = {
179
+ reason: "distinct-context-limit",
180
+ node: x,
181
+ observed: ctxSeen.size,
182
+ limit: bound,
183
+ };
184
+ }
185
+ return false;
186
+ }
148
187
  }
149
188
  const parents = ctx.store.parentsFirst(x, bound + 1);
150
- if (parents.length > bound)
151
- return false; // decided: hub
189
+ if (parents.length > bound) {
190
+ // decided: hub
191
+ if (ctx.trace) {
192
+ satStop = {
193
+ reason: "parent-fan-out",
194
+ node: x,
195
+ observed: parents.length,
196
+ limit: bound,
197
+ };
198
+ }
199
+ return false;
200
+ }
152
201
  let fresh = 0;
153
202
  for (const p of parents) {
154
203
  if (!seen.has(p)) {
@@ -159,8 +208,18 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
159
208
  }
160
209
  if (fresh > 1) {
161
210
  lateral += fresh - 1;
162
- if (lateral > bound)
163
- return false; // decided: cone-wide hub
211
+ if (lateral > bound) {
212
+ // decided: cone-wide hub
213
+ if (ctx.trace) {
214
+ satStop = {
215
+ reason: "lateral-cone-limit",
216
+ node: x,
217
+ observed: lateral,
218
+ limit: bound,
219
+ };
220
+ }
221
+ return false;
222
+ }
164
223
  }
165
224
  return true;
166
225
  };
@@ -225,7 +284,12 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
225
284
  }
226
285
  }
227
286
  }
228
- const reach = { roots, contextsReached: ctxSeen.size, saturated };
287
+ const reach = {
288
+ roots,
289
+ contextsReached: ctxSeen.size,
290
+ saturated,
291
+ ...(saturated && satStop ? { saturation: satStop } : {}),
292
+ };
229
293
  memo?.set(id, reach);
230
294
  return reach;
231
295
  }
@@ -133,11 +133,29 @@ export interface RegionVote {
133
133
  * Defaults to 1 when absent. */
134
134
  absorbed?: number;
135
135
  }
136
+ /** The structural gate that first decided an {@link edgeAncestors} climb was
137
+ * saturated (an abstention, not a discriminative conclusion) — pure
138
+ * instrumentation for {@link ClimbConsensusData}'s reach trace; it never
139
+ * feeds back into the climb itself. */
140
+ export type SaturationReason = "byte-atom-commonality" | "predecessor-fan-in" | "distinct-context-limit" | "parent-fan-out" | "lateral-cone-limit";
141
+ /** One saturation stop's provenance: which reason fired, at which node, the
142
+ * observed count against the bound that decided it. */
143
+ export interface SaturationStop {
144
+ reason: SaturationReason;
145
+ node: number;
146
+ observed: number;
147
+ limit: number;
148
+ }
136
149
  /** The edge-bearing contexts reached by climbing from a node, plus saturation info. */
137
150
  export interface AncestorReach {
138
151
  roots: number[];
139
152
  contextsReached: number;
140
153
  saturated: boolean;
154
+ /** The saturation gate that stopped this climb, when {@link saturated} is
155
+ * true and a trace was requested — see {@link edgeAncestors}. Absent for
156
+ * a non-saturated reach, and absent (even when saturated) when no trace
157
+ * was requested — instrumentation must not allocate when tracing is off. */
158
+ saturation?: SaturationStop;
141
159
  }
142
160
  /** Saturated-interval information for the noise-drop gate. */
143
161
  export interface SaturationInfo {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
package/src/geometry.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // identically regardless of what follows — pure structural stability.
8
8
  // 3. The same rule recurses level after level until one root remains.
9
9
 
10
- import { normalize, Vec } from "./vec.js";
10
+ import { addInto, copy, normalize, Vec, zeros } from "./vec.js";
11
11
  import { Sema, sema, Space } from "./sema.js";
12
12
  import { Alphabet } from "./alphabet.js";
13
13
 
@@ -461,8 +461,10 @@ function fold2(space: Space, a: Folded, b: Folded): Folded {
461
461
  /** Plain river fold WITHOUT the final root normalize — the segment-level
462
462
  * building block of {@link stablePrefixFold} (interiors must keep their
463
463
  * byte-proportional magnitude; only the whole perception's root is ever
464
- * normalized). */
465
- function riverFoldRaw(space: Space, row: Folded[]): Folded {
464
+ * normalized). Exported so callers that COMPOSE already-existing structural
465
+ * parts into a hypothetical synthetic root (see {@link composeStructuralGist})
466
+ * can feed the same raw primitive instead of duplicating its mathematics. */
467
+ export function riverFoldRaw(space: Space, row: Folded[]): Folded {
466
468
  if (row.length === 0) {
467
469
  const z = new Float32Array(space.D);
468
470
  return { tree: sema(z, new Uint8Array(0), null), len: 0 };
@@ -477,6 +479,53 @@ function riverFoldRaw(space: Space, row: Folded[]): Folded {
477
479
  return level[0];
478
480
  }
479
481
 
482
+ // ---- structural composition (synthesize from EXISTING structural parts) ----
483
+
484
+ /** One already-existing structural vector to compose, paired with the byte
485
+ * span (query-slot) length it stands in for. `len`, not the vector's own
486
+ * magnitude, is what {@link composeStructuralGist} restores — the composed
487
+ * slot's NATURAL span, exactly as the linear river fold would carry it. */
488
+ export interface StructuralPart {
489
+ v: Vec;
490
+ len: number;
491
+ }
492
+
493
+ /** Synthesize a hypothetical internal structure from already-existing
494
+ * structural vectors — NOT from bytes. This is the raw positional
495
+ * composition the linear river fold already uses (see the folding header
496
+ * above): each part is positionally bound into its own seat, its natural
497
+ * span magnitude is preserved, the parts are linearly superposed, and only
498
+ * the final synthetic root is normalized. It never calls {@link gistOf}
499
+ * (there is no `gistOf` here — geometry.ts has no store), never perceives a
500
+ * concatenated byte string, and never interns or stores a new node: the
501
+ * result is an opaque, ungrounded Vec for an ANN probe only. */
502
+ export function composeStructuralGist(
503
+ space: Space,
504
+ parts: readonly StructuralPart[],
505
+ ): Vec {
506
+ const foldedParts: Folded[] = [];
507
+
508
+ for (const part of parts) {
509
+ if (part.len <= 0) continue;
510
+
511
+ const direction = copy(part.v);
512
+ normalize(direction);
513
+
514
+ const scaled = zeros(space.D);
515
+ addInto(scaled, direction, Math.sqrt(part.len));
516
+
517
+ foldedParts.push({ tree: sema(scaled), len: part.len });
518
+ }
519
+
520
+ if (foldedParts.length === 0) return zeros(space.D);
521
+
522
+ const rawRoot = riverFoldRaw(space, foldedParts);
523
+
524
+ const result = copy(rawRoot.tree.v);
525
+ normalize(result);
526
+ return result;
527
+ }
528
+
480
529
  // ---- pyramid fold (incremental plain perception) ----
481
530
 
482
531
  /** The PLAIN fold's full level pyramid — every level's item list, bottom