@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
@@ -6,7 +6,6 @@
6
6
  // The PROJECTIONS built on these walks (follow, conceptHop, reverseContext,
7
7
  // project) live in match.ts — the elementary match-and-project operation.
8
8
  import { cosine } from "../vec.js";
9
- import { consensusFloor } from "../geometry.js";
10
9
  import { gistOf, read } from "./primitives.js";
11
10
  const structCaches = new WeakMap();
12
11
  function getStructCache(ctx) {
@@ -90,7 +89,22 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
90
89
  // is withdrawn. On a small store the floor stays ≤ √N and the atom
91
90
  // climbs exactly as before, so single-letter facts keep working.
92
91
  if (id < 0 && atomIsHub(ctx, contextCount)) {
93
- 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
+ };
94
108
  memo?.set(id, reach);
95
109
  return reach;
96
110
  }
@@ -99,6 +113,10 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
99
113
  const seen = new Set([id]);
100
114
  const ctxSeen = new Set();
101
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;
102
120
  // EXPAND-UNTIL-DECIDED: a reach is consumed either as a VOTE (which needs
103
121
  // contextsReached exactly, and only while ≤ √N — beyond that the region is
104
122
  // non-discriminative) or as an ABSTENTION (saturated — whose roots and
@@ -140,16 +158,46 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
140
158
  roots.push(x);
141
159
  if (hasNx)
142
160
  ctxSeen.add(x);
143
- if (pc > bound)
144
- 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
+ }
145
173
  for (const p of ctx.store.prevFirst(x, bound))
146
174
  ctxSeen.add(p);
147
- if (ctxSeen.size > bound)
148
- 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
+ }
149
187
  }
150
188
  const parents = ctx.store.parentsFirst(x, bound + 1);
151
- if (parents.length > bound)
152
- 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
+ }
153
201
  let fresh = 0;
154
202
  for (const p of parents) {
155
203
  if (!seen.has(p)) {
@@ -160,8 +208,18 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
160
208
  }
161
209
  if (fresh > 1) {
162
210
  lateral += fresh - 1;
163
- if (lateral > bound)
164
- 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
+ }
165
223
  }
166
224
  return true;
167
225
  };
@@ -226,7 +284,12 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
226
284
  }
227
285
  }
228
286
  }
229
- const reach = { roots, contextsReached: ctxSeen.size, saturated };
287
+ const reach = {
288
+ roots,
289
+ contextsReached: ctxSeen.size,
290
+ saturated,
291
+ ...(saturated && satStop ? { saturation: satStop } : {}),
292
+ };
230
293
  memo?.set(id, reach);
231
294
  return reach;
232
295
  }
@@ -440,20 +503,22 @@ export function chooseNext(ctx, id, guide) {
440
503
  bestMass = mass;
441
504
  }
442
505
  }
443
- // A pick among GENUINELY competing continuations still needs to clear the
444
- // same genuine-corroboration floor {@link consensusFloor} draws for every
445
- // other consumer that turns distinct-context support into a vote (the
446
- // climb's recallByResonance/commitVotes) below it, "most corroborated"
447
- // is only the least-thin echo among several, not real evidence. Gated to
448
- // corpus scale large enough that this echo is even possible (the same
449
- // scale {@link atomIsHub} already switches on for the identical reason:
450
- // at small N every edge IS the evidence there is). A hub id has no
451
- // meaningful "distinct context" reading at all (id < 0 atoms are excluded
452
- // from this path already, since chooseNext only ever sees real edges).
453
- const N = corpusN(ctx);
454
- if (capped.length > 1 && atomIsHub(ctx, N) && bestSupport < consensusFloor(N)) {
455
- return undefined;
456
- }
506
+ // NO consensusFloor gate here (tried and reverted see
507
+ // test/40-choosenext-scale-guard.test.mjs): that floor is calibrated for
508
+ // POOLED, IDF-weighted CLIMB VOTES (recallByResonance, commitVotes), where
509
+ // each corroborating region contributes at most ln N and the floor grows
510
+ // with N exactly as that per-region ceiling does (HOW_IT_WORKS.md §8.6).
511
+ // `bestSupport` here is a different kind of quantity a raw prevCount of
512
+ // how many training contexts predicted ONE destination, bounded by how
513
+ // often that specific fact was retold, never by corpus size N. Gating an
514
+ // N-invariant count against an N-growing threshold guarantees failure
515
+ // once N is large enough, discarding genuinely, structurally dominant
516
+ // edges (observed: a fact corroborated 2-to-1-1-1 refused at N≈325K,
517
+ // falling back to a noisy concept-hop). The loop above already IS the
518
+ // "genuinely competing" test: a tie leaves first-inserted as the pick
519
+ // (test/30's own pinned behaviour); a strict winner is real evidence
520
+ // regardless of corpus scale. Matches HOW_IT_WORKS.md §25's own
521
+ // chooseNext pseudocode, which has no such floor.
457
522
  // Trace is built lazily — the filter + map below only execute when a
458
523
  // trace listener is attached, so the common (no-trace) path pays only
459
524
  // for the prevCount calls in the loop above, never for extra rItemShort
@@ -74,6 +74,23 @@ export interface Attention {
74
74
  * consensus; one that does not is a coincidental single-region echo —
75
75
  * see test/35-attention-confidence.test.mjs. */
76
76
  breadth: number;
77
+ /** DISPERSION: the number of distinct clusters this point's contributing
78
+ * regions form, merging any two whose gap is under one river-fold
79
+ * quantum W. Neither breadth NOR raw region count discriminates a
80
+ * genuine further topic from a coincidental echo (both were tried and
81
+ * falsified — breadth starves a genuine, evenly-split multi-topic query,
82
+ * since no root in a real N-way split can exceed half the vote; raw
83
+ * count doesn't separate them either, since a short, structurally simple
84
+ * echo racks up as many corroborating regions as a real topic does).
85
+ * Dispersion asks a different question: not how MUCH evidence, but how
86
+ * many separate PLACES in the query corroborate it. A coincidental
87
+ * match — one local phrase resonating with an unrelated stored form —
88
+ * is structurally confined to ONE cluster no matter how strong its vote;
89
+ * a genuine further topic is named in its own distinctive wording
90
+ * somewhere the query's scaffolding does not reach, always a SEPARATE
91
+ * cluster from whatever else corroborates it. See
92
+ * test/37-cluster-dispersion-fusion.test.mjs. */
93
+ clusters: number;
77
94
  }
78
95
  /** Both read-outs of one consensus climb. */
79
96
  export interface AttentionRead {
@@ -116,11 +133,29 @@ export interface RegionVote {
116
133
  * Defaults to 1 when absent. */
117
134
  absorbed?: number;
118
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
+ }
119
149
  /** The edge-bearing contexts reached by climbing from a node, plus saturation info. */
120
150
  export interface AncestorReach {
121
151
  roots: number[];
122
152
  contextsReached: number;
123
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;
124
159
  }
125
160
  /** Saturated-interval information for the noise-drop gate. */
126
161
  export interface SaturationInfo {
@@ -226,9 +261,29 @@ export interface MindContext extends GraphSearchHost {
226
261
  export declare const ALL = 2147483647;
227
262
  /** Splice every chosen span in order — the whole cover as one byte string. */
228
263
  export declare function spliceAll(segs: Seg[]): Uint8Array | null;
264
+ /** Whether a chosen span RESTATES the query rather than answering it: its
265
+ * SUBSTITUTED bytes (an edge followed from a recognised site, not the
266
+ * site's own literal text read back) already occur elsewhere in the query
267
+ * — the same principle recall.ts's tiers apply to a whole-query projection
268
+ * ("a projection that is a proper byte-subspan of the query restates part
269
+ * of the question"). A LITERAL span (the site's own bytes, unchanged) is
270
+ * exempt: naming what's already there at its OWN position is not a
271
+ * substitution. A recognised site that is itself an entire PRIOR TURN of
272
+ * a multi-turn query is exactly this shape: it carries a genuine learnt
273
+ * continuation, but that continuation is something the asker already said
274
+ * moments later in the SAME query, not a new answer. Below one river
275
+ * window, byte overlap is chance, not evidence — the same floor
276
+ * identityBar and reachThreshold hold every other structural-overlap claim
277
+ * to. */
278
+ export declare function segRestatesQuery(s: Seg, query: Uint8Array, queryLen: number, W: number): boolean;
229
279
  /** Lift the answer out of the cover for think: the recognised region, free of
230
- * the asker's surrounding (unrecognised) framing. */
231
- export declare function liftAnswer(segs: Seg[], queryLen: number): Uint8Array | null;
280
+ * the asker's surrounding (unrecognised) framing — and free of any chosen
281
+ * span that only RESTATES content the query already contains (see {@link
282
+ * segRestatesQuery}). A restating span is excluded from both the framing
283
+ * (lo/hi) decision and the final concatenation: it is stale, not a second
284
+ * answer, but the OTHER spans a derivation chose are independent evidence
285
+ * and must not be discarded along with it. */
286
+ export declare function liftAnswer(segs: Seg[], queryLen: number, query: Uint8Array, W: number): Uint8Array | null;
232
287
  /** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
233
288
  * tracked deposit interned (`prevSeen`). */
234
289
  export declare function changedNodes(tree: Sema, ids: Map<Sema, number>, prevSeen: Set<number>): Sema[];
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // GraphSearchHost is defined first (minimal imports) so GraphSearch can import
4
4
  // it without pulling in the full MindContext.
5
- import { concatBytes } from "../bytes.js";
5
+ import { bytesEqual, concatBytes, indexOf } from "../bytes.js";
6
6
  import { dominates } from "../geometry.js";
7
7
  // ═══════════════════════════════════════════════════════════════════════════
8
8
  // FREE FUNCTIONS (pure, no state)
@@ -15,13 +15,44 @@ export function spliceAll(segs) {
15
15
  return null;
16
16
  return concatBytes(segs.map((s) => s.bytes));
17
17
  }
18
+ /** Whether a chosen span RESTATES the query rather than answering it: its
19
+ * SUBSTITUTED bytes (an edge followed from a recognised site, not the
20
+ * site's own literal text read back) already occur elsewhere in the query
21
+ * — the same principle recall.ts's tiers apply to a whole-query projection
22
+ * ("a projection that is a proper byte-subspan of the query restates part
23
+ * of the question"). A LITERAL span (the site's own bytes, unchanged) is
24
+ * exempt: naming what's already there at its OWN position is not a
25
+ * substitution. A recognised site that is itself an entire PRIOR TURN of
26
+ * a multi-turn query is exactly this shape: it carries a genuine learnt
27
+ * continuation, but that continuation is something the asker already said
28
+ * moments later in the SAME query, not a new answer. Below one river
29
+ * window, byte overlap is chance, not evidence — the same floor
30
+ * identityBar and reachThreshold hold every other structural-overlap claim
31
+ * to. */
32
+ export function segRestatesQuery(s, query, queryLen, W) {
33
+ if (!s.rec)
34
+ return false;
35
+ const literal = s.j - s.i === s.bytes.length &&
36
+ bytesEqual(s.bytes, query.subarray(s.i, s.j));
37
+ if (literal)
38
+ return false;
39
+ return s.bytes.length >= W && s.bytes.length < queryLen &&
40
+ indexOf(query, s.bytes, 0) >= 0;
41
+ }
18
42
  /** Lift the answer out of the cover for think: the recognised region, free of
19
- * the asker's surrounding (unrecognised) framing. */
20
- export function liftAnswer(segs, queryLen) {
43
+ * the asker's surrounding (unrecognised) framing — and free of any chosen
44
+ * span that only RESTATES content the query already contains (see {@link
45
+ * segRestatesQuery}). A restating span is excluded from both the framing
46
+ * (lo/hi) decision and the final concatenation: it is stale, not a second
47
+ * answer, but the OTHER spans a derivation chose are independent evidence
48
+ * and must not be discarded along with it. */
49
+ export function liftAnswer(segs, queryLen, query, W) {
50
+ const restated = segs.map((s) => segRestatesQuery(s, query, queryLen, W));
21
51
  const recognised = [];
22
- for (let k = 0; k < segs.length; k++)
23
- if (segs[k].rec)
52
+ for (let k = 0; k < segs.length; k++) {
53
+ if (segs[k].rec && !restated[k])
24
54
  recognised.push(k);
55
+ }
25
56
  if (recognised.length === 0)
26
57
  return null;
27
58
  if (recognised.length === 1) {
@@ -42,13 +73,13 @@ export function liftAnswer(segs, queryLen) {
42
73
  if (s.computed && s.i > 0)
43
74
  return s.bytes;
44
75
  if (dominates(s.j - s.i, queryLen)) {
45
- return concatBytes(segs.map((x) => x.bytes));
76
+ return concatBytes(segs.filter((_, k) => !restated[k]).map((x) => x.bytes));
46
77
  }
47
78
  return s.bytes;
48
79
  }
49
80
  const lo = recognised[0];
50
81
  const hi = recognised[recognised.length - 1];
51
- return concatBytes(segs.slice(lo, hi + 1).map((x) => x.bytes));
82
+ return concatBytes(segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]).map((x) => x.bytes));
52
83
  }
53
84
  /** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
54
85
  * tracked deposit interned (`prevSeen`). */
@@ -139,8 +139,17 @@ export interface Store {
139
139
  * question is decided, with per-page work bounded by `limit`. */
140
140
  containersSlice(child: NodeId, offset: number, limit: number): NodeId[];
141
141
  nodeCount(): number;
142
- /** The k nodes whose gist resonates most with v. */
143
- resonate(v: Vec, k: number): Promise<Hit[]>;
142
+ /** The k nodes whose gist resonates most with v. `exhaustive` widens the
143
+ * IVF probe to every cluster (see {@link AbstractStore.efFor}'s doc)
144
+ * for refusal-path-only callers where an approximate top-√C-clusters
145
+ * search is not the same discriminator as the caller's own byte-exact
146
+ * verification (the substitution bridge's proposal channel): a rarer
147
+ * paraphrase can score lower than hundreds of unrelated hits by pure
148
+ * fold-geometry structural distance (a middle-of-string mismatch
149
+ * perturbs the tree hash far more than a tail mismatch of the same
150
+ * byte length) and so never even reach a probed cluster, no matter how
151
+ * large k is — k only reorders WITHIN the clusters already probed. */
152
+ resonate(v: Vec, k: number, exhaustive?: boolean): Promise<Hit[]>;
144
153
  /** Mark a node as a RESONANCE TARGET — promote its gist into the content
145
154
  * index so {@link resonate} can find it. A node's gist is captured at intern
146
155
  * but indexed LAZILY (only targets are indexed; the ~99.5% intermediate DAG
@@ -593,7 +602,7 @@ export declare abstract class AbstractStore implements Store {
593
602
  *
594
603
  * Iterative explicit-queue walk: the call stack never sees tree depth. */
595
604
  protected indexSubtree(root: NodeId): void;
596
- resonate(v: Vec, k: number): Promise<Hit[]>;
605
+ resonate(v: Vec, k: number, exhaustive?: boolean): Promise<Hit[]>;
597
606
  indexedVectorCount(): number;
598
607
  lastResonateReads(): number;
599
608
  /** How many physical compaction attempts have failed this session. Zero in
package/dist/src/store.js CHANGED
@@ -1135,7 +1135,7 @@ export class AbstractStore {
1135
1135
  }
1136
1136
  }
1137
1137
  // ── Soft resonance ─────────────────────────────────────────────────────
1138
- async resonate(v, k) {
1138
+ async resonate(v, k, exhaustive = false) {
1139
1139
  await this._ensureReady();
1140
1140
  // Synchronous flush of any buffered index writes: the FIRST resonance
1141
1141
  // after a large ingest pays that flush here, so it shows up in respond
@@ -1148,14 +1148,20 @@ export class AbstractStore {
1148
1148
  // same values still hits. Lazy-init: null after any index write; the
1149
1149
  // first miss after a flush recreates it. When voteRegions resonates
1150
1150
  // identical perceived sub-regions, only the first call descends the ANN.
1151
- const rk = vecKey(v) + ":" + k;
1151
+ const rk = vecKey(v) + ":" + k + (exhaustive ? ":x" : "");
1152
1152
  const cache = this._resonateCache;
1153
1153
  if (cache) {
1154
1154
  const hit = cache.get(rk);
1155
1155
  if (hit !== undefined)
1156
1156
  return hit;
1157
1157
  }
1158
- const results = this._vecContentQuery(normalize(copy(v)), k * this.overfetch, this.efFor(this._vecContentClusterCount()));
1158
+ const clusters = this._vecContentClusterCount();
1159
+ const results = this._vecContentQuery(normalize(copy(v)), k * this.overfetch,
1160
+ // Exhaustive: probe every cluster (ef ≥ 4·clusters guarantees the
1161
+ // IVF's own ef→nprobe=ceil(ef/4) mapping reaches all of them) — the
1162
+ // natural ceiling for a search that is ALREADY refusal-path-only and
1163
+ // must not miss a candidate hiding in an unprobed cluster.
1164
+ exhaustive ? 4 * clusters : this.efFor(clusters));
1159
1165
  const out = [];
1160
1166
  for (const r of results) {
1161
1167
  const id = r.id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.2.3",
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