@hviana/sema 0.1.6 → 0.1.7

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.
@@ -7,8 +7,14 @@
7
7
  import { rItem } from "./trace.js";
8
8
 
9
9
  import type { MindContext, Recognition, Segment } from "./types.js";
10
- import { foldTree, gistOf, perceive, resolve } from "./primitives.js";
11
- import { leadsSomewhere } from "./traverse.js";
10
+ import {
11
+ foldTree,
12
+ gistOf,
13
+ latin1Key,
14
+ perceive,
15
+ resolve,
16
+ } from "./primitives.js";
17
+ import { atomIsHub, corpusN, leadsSomewhere } from "./traverse.js";
12
18
  import { chainReach, leafIdAt } from "./canonical.js";
13
19
  import { isChunk, type Sema } from "../sema.js";
14
20
  import type { Leaf, Site } from "./graph-search.js";
@@ -26,15 +32,15 @@ import type { Leaf, Site } from "./graph-search.js";
26
32
  *
27
33
  * Both O(n · maxGroup) bounded O(1) probes — never a scan of the corpus. */
28
34
  export function recognise(ctx: MindContext, bytes: Uint8Array): Recognition {
29
- // Per-response memo (see MindContext.recogniseMemo): think, articulate and
30
- // the pre-consume pass all recognise the same byte objects; each repeat is
31
- // O(n·maxGroup²) store probes. Skipped while tracing so every call still
32
- // emits its rationale step.
35
+ // Content-keyed memo works for both single-turn respond() and multi-turn
36
+ // respondTurn() (where the map persists across calls). Skipped while
37
+ // tracing so every call still emits its rationale step.
33
38
  if (ctx.recogniseMemo && !ctx.trace) {
34
- const hit = ctx.recogniseMemo.get(bytes);
39
+ const key = latin1Key(bytes);
40
+ const hit = ctx.recogniseMemo.get(key);
35
41
  if (hit !== undefined) return hit;
36
42
  const fresh = recogniseImpl(ctx, bytes);
37
- ctx.recogniseMemo.set(bytes, fresh);
43
+ ctx.recogniseMemo.set(key, fresh);
38
44
  return fresh;
39
45
  }
40
46
  return recogniseImpl(ctx, bytes);
@@ -64,7 +70,19 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
64
70
  return id;
65
71
  };
66
72
 
73
+ // Byte atoms (implicit negative-id single-byte leaves) are admitted as
74
+ // recognised sites only while atoms can still DISCRIMINATE at this corpus
75
+ // scale (see {@link atomIsHub}). On a small store a single-letter fact
76
+ // ("a" → "A") is genuine learnt content and its site is essential; on a
77
+ // large one every letter of every query would otherwise become a
78
+ // "recognised form" — the bridge then finds junction connectors between
79
+ // bare letters, cover follows edges hanging off them, and pure noise
80
+ // ("qq8f3kz9…") grounds to an arbitrary learnt sentence instead of
81
+ // silence. Atoms stay available as leaves (PASS-carried literals) and
82
+ // through exact tier-0 resolution regardless.
83
+ const atomsAreHubs = atomIsHub(ctx, corpusN(ctx));
67
84
  const emit = (start: number, end: number, id: number) => {
85
+ if (id < 0 && atomsAreHubs) return;
68
86
  if (leadsSomewhere(ctx, id)) {
69
87
  sites.push({ start, end, payload: id });
70
88
  }
@@ -115,6 +115,30 @@ export function edgeAncestors(
115
115
  const hit = memo?.get(id);
116
116
  if (hit !== undefined) return hit;
117
117
 
118
+ // BYTE-ATOM COMMONALITY. A single-byte leaf (implicit negative id) has no
119
+ // structural parents BY CONSTRUCTION — atoms are never linked into the kid
120
+ // or contain tables — so this climb cannot observe its containment at all.
121
+ // The walk below would see only the atom's own direct edges and report
122
+ // contextsReached ≈ 1, turning the MOST common content in the store into
123
+ // the MOST discriminative voter (observed on a 325K-context store: every
124
+ // recognised single-letter site voted full ln N for the one fact whose
125
+ // continuation is that letter, and their pooled sum out-voted every
126
+ // genuine anchor). An unmeasurable containment must not default to
127
+ // "maximally rare": it is bounded below by the uniform expectation over
128
+ // the byte alphabet — N contexts, each at least one chunk of up to W of
129
+ // the 256 possible atoms, reach ≥ N·W/256 contexts per atom on average
130
+ // (see {@link atomReach}). When that floor itself exceeds the hub bound
131
+ // √N the atom is a hub at this corpus scale and the climb abstains
132
+ // (saturated) — the atom's own edges remain fully traversable (tier-0
133
+ // exact recall, chooseNext, project); only its say as a consensus voter
134
+ // is withdrawn. On a small store the floor stays ≤ √N and the atom
135
+ // climbs exactly as before, so single-letter facts keep working.
136
+ if (id < 0 && atomIsHub(ctx, contextCount)) {
137
+ const reach = { roots: [], contextsReached: 0, saturated: true };
138
+ memo?.set(id, reach);
139
+ return reach;
140
+ }
141
+
118
142
  const bound = Math.ceil(Math.sqrt(contextCount));
119
143
  const roots: number[] = [];
120
144
  const seen = new Set<number>([id]);
@@ -257,6 +281,29 @@ export function prevOf(ctx: MindContext, id: number): number[] {
257
281
  return ctx.store.prev(id);
258
282
  }
259
283
 
284
+ /** The uniform-expectation floor on a byte atom's corpus commonality: N
285
+ * learnt contexts, each at least one perception chunk of up to W of the 256
286
+ * possible byte values, contain a given atom in ≥ N·W/256 contexts on
287
+ * average. An atom's TRUE containment is unmeasurable (atoms carry no
288
+ * kid/contain links by construction), so this floor is the honest stand-in:
289
+ * derived entirely from the corpus scale N, the perception window W, and
290
+ * the alphabet size — never tuned. */
291
+ export function atomReach(ctx: MindContext, contextCount: number): number {
292
+ return Math.max(
293
+ 1,
294
+ Math.ceil((contextCount * ctx.space.maxGroup) / 256),
295
+ );
296
+ }
297
+
298
+ /** Whether a byte atom is a hub at this corpus scale — its commonality floor
299
+ * {@link atomReach} exceeds the hub bound √N. Below it (small stores) an
300
+ * atom votes and is recognised exactly as any stored form; above it the
301
+ * alphabet is scaffolding everywhere and abstains. */
302
+ export function atomIsHub(ctx: MindContext, contextCount: number): boolean {
303
+ return atomReach(ctx, contextCount) >
304
+ Math.ceil(Math.sqrt(Math.max(2, contextCount)));
305
+ }
306
+
260
307
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
261
308
  * The admission predicate recognition filters sites with (HOW_IT_WORKS
262
309
  * §15.3): a form that leads nowhere contributes nothing to any derivation.
package/src/mind/types.ts CHANGED
@@ -144,28 +144,27 @@ export interface MindContext extends GraphSearchHost {
144
144
  cfg: MindConfig;
145
145
  search: GraphSearch;
146
146
  trace: Rationale | null;
147
- climbMemo: WeakMap<Uint8Array, Map<string, AttentionRead>> | null;
148
- /** Per-response memo of {@link recognise} keyed by the byte-array OBJECT
149
- * (think, articulate, and the post-grounding pre-consume all recognise the
150
- * same query/answer objects). Valid because the store is read-only while
151
- * a response is in flight; bypassed when a trace is attached so every
152
- * recognise still emits its rationale step. Null outside respond(). */
153
- recogniseMemo: WeakMap<Uint8Array, Recognition> | null;
154
- /** Per-response memo of {@link perceive} keyed by the byte-array OBJECT —
155
- * the GENERAL memo the result-level ones (recogniseMemo, climbMemo,
156
- * _gistCache) each partially compensate for: resolve(), gistOf(), and
157
- * every mechanism's re-perception of the same query/answer object hit it
158
- * (a reason hop used to fold the same answer three times). Valid because
159
- * the store is read-only while a response is in flight and perception is
160
- * a pure function of bytes; only inference-shaped calls (plain Uint8Array,
161
- * no leafAt/lookup capabilities) are memoised, so the deposit path never
162
- * sees it. Keyed by CONTENT (latin1 of the bytes), not object identity —
163
- * mechanisms materialise the same span in fresh subarrays constantly
164
- * (measured on a trained store: 46% of one response's perceptions were
165
- * byte-identical repeats an identity key missed). NOT bypassed under
166
- * trace — perception emits no rationale steps, so there is nothing a memo
167
- * hit could swallow. Null outside respond(). */
147
+ /** Memo of the consensus climb — content-keyed (latin1) so results
148
+ * persist across conversation turns where the same byte spans recur.
149
+ * Null outside respond(); during respondTurn() the conversation's
150
+ * persistent map is swapped in. */
151
+ climbMemo: Map<string, Map<string, AttentionRead>> | null;
152
+ /** Memo of {@link recognise} content-keyed (latin1) so recognised
153
+ * forms carry forward across conversation turns. Bypassed while a
154
+ * trace is attached. Null outside respond(). */
155
+ recogniseMemo: Map<string, Recognition> | null;
156
+ /** Memo of {@link perceive} content-keyed (latin1). The general
157
+ * cache the result-level memos each partially compensate for. NOT
158
+ * bypassed under trace perception emits no rationale steps.
159
+ * Null outside respond(). */
168
160
  perceiveMemo: Map<string, Sema> | null;
161
+ /** Subtree-resolution cache: Sema node → its store id and byte length.
162
+ * Populated by {@link foldTree} during inference; checked before
163
+ * walking children. When a conversation's pyramid reuses prefix
164
+ * subtrees, this cache lets {@link recognise} skip them entirely —
165
+ * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
166
+ * the Sema objects the pyramid keeps alive). */
167
+ _resolvedSubtrees: WeakMap<Sema, { id: number; len: number }> | null;
169
168
  _edgeGuide: Vec | null;
170
169
  _edgeChoice: Map<number, number>;
171
170
  _prevSeen: Set<number> | null;
@@ -1048,6 +1048,10 @@ export class SQliteStore extends AbstractStore implements Store {
1048
1048
  return this.content ? this.content.physicalSize : 0;
1049
1049
  }
1050
1050
 
1051
+ protected _vecContentClusterCount(): number {
1052
+ return this.content ? this.content.clusterCount : 0;
1053
+ }
1054
+
1051
1055
  protected _vecContentCompact(): void {
1052
1056
  this.content!.compact();
1053
1057
  }
@@ -1099,6 +1103,10 @@ export class SQliteStore extends AbstractStore implements Store {
1099
1103
  return this.halos ? this.halos.physicalSize : 0;
1100
1104
  }
1101
1105
 
1106
+ protected _vecHaloClusterCount(): number {
1107
+ return this.halos ? this.halos.clusterCount : 0;
1108
+ }
1109
+
1102
1110
  protected _vecHaloCompact(): void {
1103
1111
  this.halos!.compact();
1104
1112
  }
package/src/store.ts CHANGED
@@ -724,6 +724,7 @@ export abstract class AbstractStore implements Store {
724
724
  protected abstract _vecContentSize(): number;
725
725
  protected abstract _vecContentLastReads(): number;
726
726
  protected abstract _vecContentPhysicalSize(): number;
727
+ protected abstract _vecContentClusterCount(): number;
727
728
  protected abstract _vecContentCompact(): void;
728
729
  /** Live content-index entries whose INTERNAL id is > `after`, as
729
730
  * {ext, internal} pairs in internal-id order. Internal ids are monotone
@@ -759,8 +760,24 @@ export abstract class AbstractStore implements Store {
759
760
  * the tombstone-ratio compaction trigger compares physical size against. */
760
761
  protected abstract _vecHaloSize(): number;
761
762
  protected abstract _vecHaloPhysicalSize(): number;
763
+ protected abstract _vecHaloClusterCount(): number;
762
764
  protected abstract _vecHaloCompact(): void;
763
765
 
766
+ /** Derived query breadth for a partitioned index of C clusters: probe √C
767
+ * of them (the same √-of-the-population convention as the hub bound √N).
768
+ * The IVF maps ef → nprobe as ceil(ef/4), so ef = 4·⌈√C⌉ probes exactly
769
+ * ⌈√C⌉ clusters. A FIXED efSearch stops scaling the moment the
770
+ * collection outgrows it: at 4,270 clusters the default 64 probed 16
771
+ * clusters (0.4%), and an exact stored match of a query routinely sat in
772
+ * an unprobed cluster — recall silently degraded as the store grew. The
773
+ * configured efSearch remains the floor for small collections. */
774
+ protected efFor(clusterCount: number): number {
775
+ return Math.max(
776
+ this.efSearch,
777
+ 4 * Math.ceil(Math.sqrt(Math.max(1, clusterCount))),
778
+ );
779
+ }
780
+
764
781
  // ── Config ─────────────────────────────────────────────────────────────
765
782
 
766
783
  protected _D: number;
@@ -1615,7 +1632,7 @@ export abstract class AbstractStore implements Store {
1615
1632
  const results = this._vecContentQuery(
1616
1633
  normalize(copy(v)),
1617
1634
  k * this.overfetch,
1618
- this.efSearch,
1635
+ this.efFor(this._vecContentClusterCount()),
1619
1636
  );
1620
1637
  const out: Hit[] = [];
1621
1638
  for (const r of results) {
@@ -1966,7 +1983,7 @@ export abstract class AbstractStore implements Store {
1966
1983
  const results = this._vecHaloQuery(
1967
1984
  normalize(copy(v)),
1968
1985
  k * this.overfetch,
1969
- this.efSearch,
1986
+ this.efFor(this._vecHaloClusterCount()),
1970
1987
  );
1971
1988
  const out: Hit[] = [];
1972
1989
  for (const r of results) {
@@ -14,14 +14,10 @@
14
14
  // The assertions describe BEHAVIOUR only — they never mention how context is
15
15
  // represented — so they stay valid for any implementation.
16
16
  //
17
- // Note: Sema trains on the accumulated context string ("turn0\nturn1\nturn2")
18
- // and queries the same accumulated string at inference. This is not a
19
- // weakness LLMs work the same way: a chat model receives the full
20
- // conversation history as its prompt, and that same history must be
21
- // provided at inference to produce the next turn. The difference is in
22
- // how the answer is produced: Sema composes it from learned graph forms;
23
- // an LLM samples it from a parametric distribution. Neither is "just a
24
- // lookup."
17
+ // Sema trains on the raw concatenation of prior turns and queries the same
18
+ // accumulated bytes at inference. The Conversation API tracks turn-boundary
19
+ // offsets explicitly so no separator character is needed the geometry never
20
+ // inspects content to find turn boundaries.
25
21
  // ─────────────────────────────────────────────────────────────────────────
26
22
 
27
23
  import { test } from "node:test";
@@ -34,16 +30,14 @@ const newMind = () => new Mind({ seed: 7 });
34
30
  // ═══════════════════════════════════════════════════════════════════════
35
31
  // teachConversation — store one conversation (an ordered list of turns).
36
32
  //
37
- // Accumulate every prior turn into the context so each episode carries the
38
- // full conversation history: (t₀ → t₁), (t₀+t₁ → t₂), (t₀+t₁+t₂ → t₃) …
39
- // A pivot turn ("what is its name?") that appears in two conversations now
40
- // produces different episode vectors because the context side encodes which
41
- // conversation it belongs to. All SEMA operations remain sublinear — the
42
- // river cuts any context into O(log N) chunks regardless of length.
33
+ // Accumulate prior turns by raw concatenation no separator character.
34
+ // Each episode carries the full conversation history: (t₀ → t₁),
35
+ // (t₀+t₁ t₂), (t₀+t₁+t₂ t₃) where + is byte concatenation.
43
36
  // ═══════════════════════════════════════════════════════════════════════
44
37
  async function teachConversation(mind, turns) {
38
+ let context = "";
45
39
  for (let i = 0; i + 1 < turns.length; i++) {
46
- const context = turns.slice(0, i + 1).join("\n");
40
+ context += turns[i];
47
41
  await mind.ingest(context, turns[i + 1]);
48
42
  }
49
43
  }
@@ -51,13 +45,22 @@ async function teachConversation(mind, turns) {
51
45
  // ═══════════════════════════════════════════════════════════════════════
52
46
  // predictNext — given the turns spoken so far, predict the next turn.
53
47
  //
54
- // Join every prior turn into one accumulated context, mirroring the storage
55
- // pattern in teachConversation. The resulting query string is the exact
56
- // context side of the matching episode, so recall resolves unambiguously.
48
+ // Uses the Conversation API: beginConversation respondTurn for each
49
+ // prior turn respondTurnText for the query endConversation.
50
+ // Turns are raw strings, concatenated by the Mind no separator.
57
51
  // ═══════════════════════════════════════════════════════════════════════
58
52
  async function predictNext(mind, priorTurns) {
59
- const context = priorTurns.join("\n");
60
- return await mind.respondText(context);
53
+ if (priorTurns.length === 0) return "";
54
+ const conv = mind.beginConversation();
55
+ for (let i = 0; i < priorTurns.length - 1; i++) {
56
+ await mind.respondTurn(conv, priorTurns[i]);
57
+ }
58
+ const { response } = await mind.respondTurnText(
59
+ conv,
60
+ priorTurns[priorTurns.length - 1],
61
+ );
62
+ mind.endConversation(conv);
63
+ return response;
61
64
  }
62
65
 
63
66
  // A small convenience: teach a conversation, then ask for the continuation
@@ -226,3 +229,170 @@ test("D2: prediction is deterministic across runs", async () => {
226
229
  };
227
230
  assert.equal(await run(), await run());
228
231
  });
232
+
233
+ // ═══════════════════════════════════════════════════════════════════════
234
+ // Section E — Conversation API lifecycle & cache management
235
+ //
236
+ // Tests for the beginConversation / respondTurn / endConversation API
237
+ // itself, independent of the conversation-training pattern above.
238
+ // ═══════════════════════════════════════════════════════════════════════
239
+
240
+ test("E1: begin → respondTurn → end completes a single-turn conversation", async () => {
241
+ const mind = newMind();
242
+ await mind.ingest("hello", "world");
243
+
244
+ const conv = mind.beginConversation();
245
+ const { response, state } = await mind.respondTurnText(conv, "hello");
246
+ assert.equal(response, "world");
247
+ // Single turn — no boundaries yet.
248
+ assert.equal(state.boundaries.length, 0);
249
+ mind.endConversation(conv);
250
+ await mind.store.close();
251
+ });
252
+
253
+ test("E2: multi-turn conversation accumulates context and boundaries", async () => {
254
+ const mind = newMind();
255
+ await teachConversation(mind, CAT);
256
+
257
+ const conv = mind.beginConversation();
258
+ // First turn establishes the subject.
259
+ await mind.respondTurn(conv, "I adopted a cat");
260
+
261
+ // Second turn is the pivot query.
262
+ const t2 = await mind.respondTurnText(conv, "what is its name?");
263
+ // Now we have one boundary: the byte position after the first turn.
264
+ assert.equal(t2.state.boundaries.length, 1);
265
+ assert.equal(t2.response, "her name is Whiskers");
266
+
267
+ mind.endConversation(conv);
268
+ await mind.store.close();
269
+ });
270
+
271
+ test("E3: save → end → restore → continue works", async () => {
272
+ const mind = newMind();
273
+ await teachConversation(mind, CAT);
274
+
275
+ // First two turns.
276
+ const conv = mind.beginConversation();
277
+ await mind.respondTurn(conv, "I adopted a cat");
278
+ const { state: saved } = await mind.respondTurnText(
279
+ conv,
280
+ "what is its name?",
281
+ );
282
+ assert.equal(saved.boundaries.length, 1);
283
+
284
+ // Save and end.
285
+ mind.endConversation(conv);
286
+
287
+ // Restore from the saved state into a new handle.
288
+ const conv2 = mind.beginConversation(saved);
289
+ const { response, state: state2 } = await mind.respondTurnText(
290
+ conv2,
291
+ "her name is Whiskers",
292
+ );
293
+ // The restored conversation continues from where it left off.
294
+ assert.ok(response.length > 0 || response === "");
295
+ // Boundaries are intact from the restore.
296
+ assert.equal(state2.boundaries.length, 2);
297
+
298
+ mind.endConversation(conv2);
299
+ await mind.store.close();
300
+ });
301
+
302
+ test("E4: endConversation is idempotent", async () => {
303
+ const mind = newMind();
304
+ const conv = mind.beginConversation();
305
+ mind.endConversation(conv);
306
+ // Second end on the same handle — must not throw.
307
+ mind.endConversation(conv);
308
+ await mind.store.close();
309
+ });
310
+
311
+ test("E5: respondTurn on an ended conversation throws", async () => {
312
+ const mind = newMind();
313
+ const conv = mind.beginConversation();
314
+ mind.endConversation(conv);
315
+ await assert.rejects(
316
+ () => mind.respondTurn(conv, "hello"),
317
+ /not found/,
318
+ );
319
+ await mind.store.close();
320
+ });
321
+
322
+ test("E6: conversationState returns null after endConversation", async () => {
323
+ const mind = newMind();
324
+ const conv = mind.beginConversation();
325
+ mind.endConversation(conv);
326
+ assert.equal(mind.conversationState(conv), null);
327
+ await mind.store.close();
328
+ });
329
+
330
+ test("E7: multiple concurrent conversations are independent", async () => {
331
+ const mind = newMind();
332
+ await teachConversation(mind, CAT);
333
+ await teachConversation(mind, DOG);
334
+
335
+ const catConv = mind.beginConversation();
336
+ const dogConv = mind.beginConversation();
337
+
338
+ // Feed the distinguishing context to each.
339
+ await mind.respondTurn(catConv, "I adopted a cat");
340
+ await mind.respondTurn(dogConv, "I adopted a dog");
341
+
342
+ // The same pivot turn — different answers.
343
+ const catR = await mind.respondTurnText(catConv, "what is its name?");
344
+ const dogR = await mind.respondTurnText(dogConv, "what is its name?");
345
+
346
+ assert.equal(catR.response, "her name is Whiskers");
347
+ assert.equal(dogR.response, "his name is Rex");
348
+ assert.notEqual(catR.response, dogR.response);
349
+
350
+ // Each conversation has its own boundaries.
351
+ assert.equal(catR.state.boundaries.length, 1);
352
+ assert.equal(dogR.state.boundaries.length, 1);
353
+
354
+ // Ending one does not affect the other.
355
+ mind.endConversation(catConv);
356
+ assert.equal(mind.conversationState(catConv), null);
357
+ assert.notEqual(mind.conversationState(dogConv), null);
358
+
359
+ // End the second — both are now gone.
360
+ mind.endConversation(dogConv);
361
+ assert.equal(mind.conversationState(dogConv), null);
362
+
363
+ // Ending again is idempotent.
364
+ mind.endConversation(dogConv);
365
+ await mind.store.close();
366
+ });
367
+
368
+ test("E8: respondTurn with a forged handle throws", async () => {
369
+ const mind = newMind();
370
+ // { id: 999 } was never returned by beginConversation.
371
+ await assert.rejects(
372
+ () => mind.respondTurn({ id: 999 }, "hello"),
373
+ /not found/,
374
+ );
375
+ await mind.store.close();
376
+ });
377
+
378
+ test("E9: respond and respondTurn give the same answer for the same cumulative bytes", async () => {
379
+ const mind = newMind();
380
+ await teachConversation(mind, CAT);
381
+
382
+ // Via respond() — raw concatenation, same as respondTurn.
383
+ const viaRespond = await mind.respondText(
384
+ "I adopted a catwhat is its name?",
385
+ );
386
+
387
+ // Via respondTurn — the new way.
388
+ const conv = mind.beginConversation();
389
+ await mind.respondTurnText(conv, "I adopted a cat");
390
+ const { response: viaConv } = await mind.respondTurnText(
391
+ conv,
392
+ "what is its name?",
393
+ );
394
+ mind.endConversation(conv);
395
+
396
+ assert.equal(viaRespond, viaConv);
397
+ await mind.store.close();
398
+ });