@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.
- package/dist/example/train_base.js +18 -5
- package/dist/src/geometry.d.ts +13 -2
- package/dist/src/geometry.js +89 -2
- package/dist/src/mind/attention.js +11 -7
- package/dist/src/mind/mechanisms/recall.js +26 -11
- package/dist/src/mind/mind.d.ts +75 -11
- package/dist/src/mind/mind.js +151 -18
- package/dist/src/mind/primitives.d.ts +13 -2
- package/dist/src/mind/primitives.js +27 -5
- package/dist/src/mind/recognition.js +21 -8
- package/dist/src/mind/traverse.d.ts +13 -0
- package/dist/src/mind/traverse.js +41 -0
- package/dist/src/mind/types.d.ts +23 -21
- package/dist/src/store-sqlite.d.ts +2 -0
- package/dist/src/store-sqlite.js +6 -0
- package/dist/src/store.d.ts +11 -0
- package/dist/src/store.js +13 -2
- package/example/train_base.ts +20 -5
- package/index.html +65 -0
- package/package.json +1 -1
- package/src/geometry.ts +93 -1
- package/src/mind/attention.ts +11 -6
- package/src/mind/mechanisms/recall.ts +29 -13
- package/src/mind/mind.ts +250 -19
- package/src/mind/primitives.ts +43 -6
- package/src/mind/recognition.ts +26 -8
- package/src/mind/traverse.ts +47 -0
- package/src/mind/types.ts +20 -21
- package/src/store-sqlite.ts +8 -0
- package/src/store.ts +19 -2
- package/test/13-conversation.test.mjs +190 -20
package/src/mind/recognition.ts
CHANGED
|
@@ -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 {
|
|
11
|
-
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
|
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(
|
|
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
|
}
|
package/src/mind/traverse.ts
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
* (
|
|
150
|
-
*
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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;
|
package/src/store-sqlite.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
38
|
-
// full conversation history:
|
|
39
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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
|
-
|
|
60
|
-
|
|
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
|
+
});
|