@hviana/sema 0.1.7 → 0.1.8
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 +24 -4
- package/dist/src/alu/src/parser.d.ts +9 -0
- package/dist/src/alu/src/parser.js +110 -2
- package/dist/src/canon.d.ts +26 -0
- package/dist/src/canon.js +57 -0
- package/dist/src/geometry.js +12 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/mind/learning.js +33 -1
- package/dist/src/mind/match.d.ts +4 -2
- package/dist/src/mind/match.js +30 -6
- package/dist/src/mind/mechanisms/cast.js +18 -4
- package/dist/src/mind/mechanisms/confluence.js +13 -1
- package/dist/src/mind/mechanisms/recall.js +97 -28
- package/dist/src/mind/mind.d.ts +64 -2
- package/dist/src/mind/mind.js +152 -23
- package/dist/src/mind/primitives.d.ts +9 -0
- package/dist/src/mind/primitives.js +68 -1
- package/dist/src/mind/recognition.js +11 -1
- package/dist/src/mind/types.d.ts +10 -0
- package/dist/src/store-sqlite.d.ts +8 -0
- package/dist/src/store-sqlite.js +52 -0
- package/dist/src/store.d.ts +12 -0
- package/example/train_base.ts +29 -4
- package/package.json +1 -1
- package/src/alu/src/parser.ts +105 -4
- package/src/canon.ts +65 -0
- package/src/geometry.ts +12 -0
- package/src/index.ts +1 -0
- package/src/mind/learning.ts +39 -1
- package/src/mind/match.ts +29 -6
- package/src/mind/mechanisms/cast.ts +21 -6
- package/src/mind/mechanisms/confluence.ts +15 -1
- package/src/mind/mechanisms/recall.ts +116 -41
- package/src/mind/mind.ts +172 -29
- package/src/mind/primitives.ts +66 -1
- package/src/mind/recognition.ts +10 -0
- package/src/mind/types.ts +10 -0
- package/src/store-sqlite.ts +68 -0
- package/src/store.ts +27 -0
- package/test/13-conversation.test.mjs +77 -27
- package/test/35-prefix-edge.test.mjs +86 -0
|
@@ -345,9 +345,24 @@ export async function counterfactualTransfer(
|
|
|
345
345
|
// corrupted-store read now falls back here instead of voicing a hollow
|
|
346
346
|
// seat into the comparison — the same "empty bytes are no grounding"
|
|
347
347
|
// invariant project() has always enforced.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
348
|
+
// A node that only ever CONTINUES — an edge SOURCE with no predecessor —
|
|
349
|
+
// is a learnt CONTEXT (a stored question-shaped frame), not an entity.
|
|
350
|
+
// Voicing it verbatim answers a question with a question (the observed
|
|
351
|
+
// cast echo: two stored questions concatenated as an "answer"). The
|
|
352
|
+
// context's role is established by what it LEADS TO, so its seat is its
|
|
353
|
+
// own continuation — the fact — with the reverse projection and the raw
|
|
354
|
+
// bytes as the ordinary fallbacks for genuine entities.
|
|
355
|
+
const seatOfNode = async (
|
|
356
|
+
id: number,
|
|
357
|
+
fallback: Uint8Array,
|
|
358
|
+
): Promise<Uint8Array> => {
|
|
359
|
+
if (ctx.store.prevCount(id) === 0 && ctx.store.hasNext(id)) {
|
|
360
|
+
const fwd = await follow(ctx, id, qv);
|
|
361
|
+
if (fwd !== null) return fwd;
|
|
362
|
+
}
|
|
363
|
+
return reverseContext(ctx, id, qv) ?? fallback;
|
|
364
|
+
};
|
|
365
|
+
const seatOf = (p: Point): Promise<Uint8Array> => seatOfNode(p.anchor, p.ctx);
|
|
351
366
|
interface AnalogCandidate {
|
|
352
367
|
anchor: number;
|
|
353
368
|
/** The point this candidate came from, or null when it is a nextOf
|
|
@@ -494,10 +509,10 @@ export async function counterfactualTransfer(
|
|
|
494
509
|
[],
|
|
495
510
|
"the two structures keep distributional company beyond chance — genuine analogs",
|
|
496
511
|
);
|
|
497
|
-
const a = seatOf(dominant);
|
|
512
|
+
const a = await seatOf(dominant);
|
|
498
513
|
const b = bestAnalog.point !== null
|
|
499
|
-
? seatOf(bestAnalog.point)
|
|
500
|
-
: seatOfNode(bestAnalog.anchor, read(ctx, bestAnalog.anchor));
|
|
514
|
+
? await seatOf(bestAnalog.point)
|
|
515
|
+
: await seatOfNode(bestAnalog.anchor, read(ctx, bestAnalog.anchor));
|
|
501
516
|
const answer = await joinWithBridge(ctx, a, b);
|
|
502
517
|
record(
|
|
503
518
|
answer,
|
|
@@ -116,6 +116,20 @@ export async function confluenceJoin(
|
|
|
116
116
|
* recognised common form). */
|
|
117
117
|
held: Array<[number, number]>;
|
|
118
118
|
}
|
|
119
|
+
// A constraint must bind a CONSTITUENT, not a shard. A genuinely shared
|
|
120
|
+
// form weaves a contiguous RUN of shared discriminative windows — its
|
|
121
|
+
// merged cover span is the form's own length, beyond one perception
|
|
122
|
+
// quantum (2W: the same "two quanta of structure" bar the conjunctive
|
|
123
|
+
// precondition `query.length < 2W` and CAST's weave live under). A long
|
|
124
|
+
// stored document holds thousands of W-windows and will hold a FEW of any
|
|
125
|
+
// query's by accident, but accidental sharing is one window (a merged
|
|
126
|
+
// span of W, at most ~W+overlap bytes: "ow ma", "ys a", "ías " —
|
|
127
|
+
// observed), never a run (" translucent", " featherlight" — the genuine
|
|
128
|
+
// constraints). Shard-bound streams are no constraints, and their meets
|
|
129
|
+
// are connective debris (". Sure,", "ngul" — observed).
|
|
130
|
+
const bindsAConstituent = (cover: Array<[number, number]>): boolean =>
|
|
131
|
+
cover.some(([cs, ce]) => ce - cs >= 2 * W);
|
|
132
|
+
|
|
119
133
|
const streams: Stream[] = [];
|
|
120
134
|
const rankedCapped = ranked.length > pre.k ? ranked.slice(0, pre.k) : ranked;
|
|
121
135
|
for (const cand of rankedCapped) {
|
|
@@ -134,7 +148,7 @@ export async function confluenceJoin(
|
|
|
134
148
|
if (curC !== null && off <= curC[1]) curC[1] = off + W;
|
|
135
149
|
else cover.push(curC = [off, off + W]);
|
|
136
150
|
}
|
|
137
|
-
if (cover.length > 0) {
|
|
151
|
+
if (cover.length > 0 && bindsAConstituent(cover)) {
|
|
138
152
|
streams.push({ anchor: cand.anchor, vote: cand.vote, ids, cover, held });
|
|
139
153
|
}
|
|
140
154
|
}
|
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
} from "../../geometry.js";
|
|
14
14
|
import type { MindContext } from "../types.js";
|
|
15
15
|
import { gistOf, read, resolve } from "../primitives.js";
|
|
16
|
+
import { bytesEqual, indexOf } from "../../bytes.js";
|
|
16
17
|
import { corpusN, hubBound } from "../traverse.js";
|
|
17
|
-
import { project, reverseContext } from "../match.js";
|
|
18
|
+
import { follow, project, reverseContext } from "../match.js";
|
|
18
19
|
import { CONCEPT, STEP } from "../graph-search.js";
|
|
19
20
|
import { unexplainedLabel } from "../rationale.js";
|
|
20
21
|
import type { PipelineMechanism, Precomputed } from "../pipeline-mechanism.js";
|
|
@@ -81,6 +82,51 @@ export async function recallByResonance(
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// 0b. ARGUMENT BINDING (RC8): the query is not itself a stored form, but
|
|
86
|
+
// it CONTAINS a recognised constituent that is an edge SOURCE — a learnt
|
|
87
|
+
// pair's left side carried inside a wrapper ("How do you say 'thank you'
|
|
88
|
+
// in French?"). The wrapper is scaffolding; the argument is the span
|
|
89
|
+
// that LEADS somewhere, so its continuation — guided by the whole query's
|
|
90
|
+
// gist — is the answer. Matching the wrapper while ignoring the argument
|
|
91
|
+
// (the observed "good morning" template failure) is worse than silence,
|
|
92
|
+
// so anything short of ONE unambiguous binding falls through: the
|
|
93
|
+
// constituent bar is the same two-quanta (2W) reading confluence binds
|
|
94
|
+
// under, nested recognitions collapse to their MAXIMAL span, and two
|
|
95
|
+
// distinct maximal arguments mean the query asks about neither alone.
|
|
96
|
+
if (qId === null) {
|
|
97
|
+
const W2 = 2 * ctx.space.maxGroup;
|
|
98
|
+
const args = pre.rec.sites.filter((s) =>
|
|
99
|
+
s.end - s.start >= W2 &&
|
|
100
|
+
s.end - s.start < query.length &&
|
|
101
|
+
ctx.store.hasNext(s.payload)
|
|
102
|
+
);
|
|
103
|
+
// Maximal spans by one sorted sweep (starts ascending, ties longest
|
|
104
|
+
// first): every earlier span starts at or before s, so s is contained
|
|
105
|
+
// exactly when the running max end already covers it. O(m log m) — a
|
|
106
|
+
// long input recognises O(|input|) sites, and a pairwise scan here was
|
|
107
|
+
// quadratic in the input.
|
|
108
|
+
args.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
109
|
+
const maximal: typeof args = [];
|
|
110
|
+
let maxEnd = -1;
|
|
111
|
+
for (const s of args) {
|
|
112
|
+
if (s.end <= maxEnd) continue;
|
|
113
|
+
maximal.push(s);
|
|
114
|
+
maxEnd = s.end;
|
|
115
|
+
}
|
|
116
|
+
if (maximal.length === 1) {
|
|
117
|
+
const arg = maximal[0];
|
|
118
|
+
const g = await follow(ctx, arg.payload, queryGist);
|
|
119
|
+
if (g !== null && g.length > 0) {
|
|
120
|
+
return ground(
|
|
121
|
+
g,
|
|
122
|
+
"argument binding — the query's sole edge-source constituent, continuation followed",
|
|
123
|
+
[[arg.start, arg.end]],
|
|
124
|
+
STEP,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
84
130
|
const whole = await ctx.store.resonate(queryGist, k);
|
|
85
131
|
if (whole.length === 0) {
|
|
86
132
|
return ground(null, "empty store — nothing to resonate with", [], 0);
|
|
@@ -99,11 +145,27 @@ export async function recallByResonance(
|
|
|
99
145
|
// into bytes — at most one river window (see {@link identityBar}). A
|
|
100
146
|
// fixed cosine bar let long queries claim "near-identical" while whole
|
|
101
147
|
// windows — an answer word — differed.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
148
|
+
// A hit RESTATES the query when its bytes are the query's own — exactly,
|
|
149
|
+
// or under the response's equivalence (a case/width twin). Restating
|
|
150
|
+
// hits may only conclude through disciplined reverse recall: voicing
|
|
151
|
+
// their bytes echoes the query back at itself (never an answer — the
|
|
152
|
+
// same principle that keeps cast from voicing stored questions), and
|
|
153
|
+
// projecting them forward is reverse recall's containment failure in the
|
|
154
|
+
// other direction — "whatever followed these bytes in some document".
|
|
155
|
+
const qKey = ctx.canon ? ctx.canon(query) : query;
|
|
156
|
+
const restates = (b: Uint8Array): boolean =>
|
|
157
|
+
bytesEqual(b, query) ||
|
|
158
|
+
(ctx.canon !== null && bytesEqual(ctx.canon(b), qKey));
|
|
159
|
+
const idBar = identityBar(ctx.store.D, ctx.space.maxGroup, query.length);
|
|
160
|
+
if (top.score >= idBar) {
|
|
105
161
|
for (const h of whole) {
|
|
106
|
-
|
|
162
|
+
// The identity claim is PER HIT, not per tier: hits are ranked
|
|
163
|
+
// nearest-first, and grounding one below the bar under this tier's
|
|
164
|
+
// "near-identical" label would launder byte-overlap noise (observed:
|
|
165
|
+
// "merci" projecting through the unrelated near hit "meraih").
|
|
166
|
+
if (h.score < idBar) break;
|
|
167
|
+
const own = read(ctx, h.id);
|
|
168
|
+
if (h.id === qId || restates(own)) {
|
|
107
169
|
const rev = ctx.store.prevFirst(h.id, hubBound(ctx));
|
|
108
170
|
const g = reverseContext(ctx, h.id, queryGist, rev);
|
|
109
171
|
if (g !== null) {
|
|
@@ -116,15 +178,7 @@ export async function recallByResonance(
|
|
|
116
178
|
STEP,
|
|
117
179
|
);
|
|
118
180
|
}
|
|
119
|
-
|
|
120
|
-
if (own.length > 0) {
|
|
121
|
-
return ground(
|
|
122
|
-
own,
|
|
123
|
-
"perfect self-match — the query IS this node",
|
|
124
|
-
nothing,
|
|
125
|
-
STEP,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
181
|
+
continue;
|
|
128
182
|
}
|
|
129
183
|
const g = await project(ctx, h.id, queryGist);
|
|
130
184
|
if (g) {
|
|
@@ -138,31 +192,11 @@ export async function recallByResonance(
|
|
|
138
192
|
}
|
|
139
193
|
}
|
|
140
194
|
|
|
141
|
-
// 2
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// query, same k, same DF mode) — read them from Precomputed instead of
|
|
147
|
-
// re-climbing, so even a traced response pays for the climb once.
|
|
148
|
-
const forest = (await pre.attention()).roots;
|
|
149
|
-
if (forest.length > 0 && forest[0].vote >= minVote) {
|
|
150
|
-
const g = await project(ctx, forest[0].anchor, queryGist);
|
|
151
|
-
if (g) {
|
|
152
|
-
return ground(
|
|
153
|
-
g,
|
|
154
|
-
"scaffolding-dominated query — ground the consensus-climb anchor",
|
|
155
|
-
[[forest[0].start, forest[0].end]],
|
|
156
|
-
CONCEPT,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// 3. Last resort — gated on the FRACTION OF THE QUERY the grounding
|
|
163
|
-
// explains, not the raw cosine. Root gists are unit vectors, but their
|
|
164
|
-
// magnitudes are recoverable from the byte lengths (‖·‖ = √len under the
|
|
165
|
-
// linear fold): cos = shared/√(lenQ·lenG), so shared/lenQ = cos·√(lenG/lenQ).
|
|
195
|
+
// The query-relative grounding fraction, shared by tiers 2–4 — gated on
|
|
196
|
+
// the FRACTION OF THE QUERY the grounding explains, not the raw cosine.
|
|
197
|
+
// Root gists are unit vectors, but their magnitudes are recoverable from
|
|
198
|
+
// the byte lengths (‖·‖ = √len under the linear fold):
|
|
199
|
+
// cos = shared/√(lenQ·lenG), so shared/lenQ = cos·√(lenG/lenQ).
|
|
166
200
|
// The raw cosine punished honest containment — a query fully inside a
|
|
167
201
|
// longer grounded answer scored √(lenQ/lenG) and was refused — and let a
|
|
168
202
|
// long answer sharing only scaffolding pass; the query-relative fraction
|
|
@@ -177,18 +211,50 @@ export async function recallByResonance(
|
|
|
177
211
|
// subtract the significance bar (3/√D, §8.3) before converting. Derived
|
|
178
212
|
// from the existing bars; never tuned.
|
|
179
213
|
const sig = significanceBar(ctx.store.D);
|
|
214
|
+
const reach = reachThreshold(ctx.space.maxGroup);
|
|
180
215
|
const fracOfQuery = (cos: number, otherLen: number): number =>
|
|
181
216
|
Math.min(
|
|
182
217
|
1,
|
|
183
218
|
Math.max(0, cos - sig) *
|
|
184
219
|
Math.sqrt(otherLen / Math.max(1, query.length)),
|
|
185
220
|
);
|
|
221
|
+
|
|
222
|
+
// 2. Scaffolding-dominated.
|
|
223
|
+
if (top.score >= sig) {
|
|
224
|
+
const N = corpusN(ctx);
|
|
225
|
+
const minVote = consensusFloor(N);
|
|
226
|
+
// The committed points of attention ARE the shared climb's roots (same
|
|
227
|
+
// query, same k, same DF mode) — read them from Precomputed instead of
|
|
228
|
+
// re-climbing, so even a traced response pays for the climb once.
|
|
229
|
+
const forest = (await pre.attention()).roots;
|
|
230
|
+
if (forest.length > 0 && forest[0].vote >= minVote) {
|
|
231
|
+
const g = await project(ctx, forest[0].anchor, queryGist);
|
|
232
|
+
// The anchor cleared the consensus floor, but the floor prices the
|
|
233
|
+
// ANCHOR's evidence, not the projection's: a junk attractor can clear
|
|
234
|
+
// it and project a PIECE OF THE QUERY back at it (the observed
|
|
235
|
+
// "buenos días in English" → "English" fragment). A projection that
|
|
236
|
+
// is a proper byte-subspan of the query restates part of the question
|
|
237
|
+
// — never an answer (the same principle as `restates` above, extended
|
|
238
|
+
// to fragments). Genuine anchor groundings — longer than the query,
|
|
239
|
+
// or disjoint from it — pass untouched.
|
|
240
|
+
if (g && !(g.length < query.length && indexOf(query, g, 0) >= 0)) {
|
|
241
|
+
return ground(
|
|
242
|
+
g,
|
|
243
|
+
"scaffolding-dominated query — ground the consensus-climb anchor",
|
|
244
|
+
[[forest[0].start, forest[0].end]],
|
|
245
|
+
CONCEPT,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 3. Last resort — the nearest grounded whole-query hit, same gate.
|
|
186
252
|
for (const h of whole) {
|
|
187
253
|
const g = await project(ctx, h.id, queryGist);
|
|
188
254
|
if (g) {
|
|
189
255
|
if (
|
|
190
256
|
fracOfQuery(cosine(queryGist, gistOf(ctx, g)), g.length) >=
|
|
191
|
-
|
|
257
|
+
reach
|
|
192
258
|
) {
|
|
193
259
|
return ground(
|
|
194
260
|
g,
|
|
@@ -208,7 +274,6 @@ export async function recallByResonance(
|
|
|
208
274
|
// anyway to be echoed, so the decision uses their EXACT fold: one river
|
|
209
275
|
// fold of the top hit, measured in the same query-relative,
|
|
210
276
|
// chance-corrected units as the tier above.
|
|
211
|
-
const reach = reachThreshold(ctx.space.maxGroup);
|
|
212
277
|
const topBytes = read(ctx, top.id);
|
|
213
278
|
const exact = topBytes.length > 0
|
|
214
279
|
? cosine(queryGist, gistOf(ctx, topBytes))
|
|
@@ -221,6 +286,16 @@ export async function recallByResonance(
|
|
|
221
286
|
0,
|
|
222
287
|
);
|
|
223
288
|
}
|
|
289
|
+
// Echoing the query's own bytes back at it is not an echo of a RELATED
|
|
290
|
+
// form — it is the query restated, which answers nothing.
|
|
291
|
+
if (restates(topBytes)) {
|
|
292
|
+
return ground(
|
|
293
|
+
null,
|
|
294
|
+
"the nearest form IS the query itself — restating it answers nothing",
|
|
295
|
+
[],
|
|
296
|
+
0,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
224
299
|
// Honest echo.
|
|
225
300
|
return ground(
|
|
226
301
|
topBytes,
|
package/src/mind/mind.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { BoundedMap, type Store } from "../store.js";
|
|
26
26
|
import { SQliteStore } from "../store-sqlite.js";
|
|
27
27
|
import { type MindConfig, resolveConfig } from "../config.js";
|
|
28
|
+
import { type Canon, canonHash, textCanon } from "../canon.js";
|
|
28
29
|
import {
|
|
29
30
|
type CandidateSpan,
|
|
30
31
|
coverSequence,
|
|
@@ -160,6 +161,12 @@ export interface MindOptions {
|
|
|
160
161
|
mechanismFactories?: ((
|
|
161
162
|
host: import("../extension.js").ExtensionHost,
|
|
162
163
|
) => import("./pipeline-mechanism.js").PipelineMechanism)[];
|
|
164
|
+
/** Content canonicalizer applied to EVERY response (any modality) for
|
|
165
|
+
* equivalence-class resolution — see src/canon.ts. Text entry points
|
|
166
|
+
* ({@link Mind.respondText}, {@link Mind.respondTurnText}) inject the
|
|
167
|
+
* Unicode text canonicalizer automatically when this is unset; pass
|
|
168
|
+
* `false` to disable canonical resolution everywhere. */
|
|
169
|
+
canon?: Canon | false;
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -182,6 +189,19 @@ export class Mind implements MindContext {
|
|
|
182
189
|
/** The live rationale tracer for the inference currently in flight, or null. */
|
|
183
190
|
trace: Rationale | null = null;
|
|
184
191
|
|
|
192
|
+
/** The content canonicalizer for the response in flight — see
|
|
193
|
+
* {@link MindContext.canon}. Injected per response by the modality entry
|
|
194
|
+
* point; null when the response carries no equivalence. */
|
|
195
|
+
canon: Canon | null = null;
|
|
196
|
+
|
|
197
|
+
/** Per-response canonical-resolution memo — see {@link MindContext.canonMemo}. */
|
|
198
|
+
canonMemo: Map<string, number | null> | null = null;
|
|
199
|
+
|
|
200
|
+
/** The Mind-level canon option: a canonicalizer to use for EVERY response,
|
|
201
|
+
* `false` to disable canonical resolution, or null to let each entry
|
|
202
|
+
* point decide (text entry points inject {@link textCanon}). */
|
|
203
|
+
private _canonOpt: Canon | false | null = null;
|
|
204
|
+
|
|
185
205
|
/** Memo of the consensus climb — content-keyed. See {@link MindContext.climbMemo}. */
|
|
186
206
|
climbMemo: Map<string, Map<string, AttentionRead>> | null = null;
|
|
187
207
|
|
|
@@ -285,8 +305,10 @@ export class Mind implements MindContext {
|
|
|
285
305
|
store: optsStore,
|
|
286
306
|
mechanisms: userMechs,
|
|
287
307
|
mechanismFactories: userFacts,
|
|
308
|
+
canon: optsCanon,
|
|
288
309
|
...rest
|
|
289
310
|
} = (optsOrCfg ?? {}) as MindOptions;
|
|
311
|
+
this._canonOpt = optsCanon ?? null;
|
|
290
312
|
this.cfg = resolveConfig(rest as Partial<MindConfig>);
|
|
291
313
|
this.store = optsStore ?? new SQliteStore({
|
|
292
314
|
maxGroup: this.cfg.geometry.maxGroup,
|
|
@@ -378,11 +400,24 @@ export class Mind implements MindContext {
|
|
|
378
400
|
/** Open one response's transient state — the tracer and the per-response
|
|
379
401
|
* memos. Paired with {@link endResponse}; the ONE place this state is
|
|
380
402
|
* created, so adding a memo cannot forget its reset. */
|
|
381
|
-
private beginResponse(
|
|
403
|
+
private beginResponse(
|
|
404
|
+
inspectRationale?: InspectRationale,
|
|
405
|
+
canon?: Canon | null,
|
|
406
|
+
): void {
|
|
382
407
|
this.trace = inspectRationale ? new Rationale(inspectRationale) : null;
|
|
383
408
|
this.climbMemo = new Map();
|
|
384
409
|
this.recogniseMemo = new Map();
|
|
385
410
|
this.perceiveMemo = new Map();
|
|
411
|
+
this.canon = canon ?? null;
|
|
412
|
+
this.canonMemo = canon ? new Map() : null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The canonicalizer a response should carry: the Mind-level option when
|
|
416
|
+
* set (or none when explicitly disabled), else the entry point's own
|
|
417
|
+
* default — text entry points pass {@link textCanon}, binary ones null. */
|
|
418
|
+
private _canonFor(entryDefault: Canon | null): Canon | null {
|
|
419
|
+
if (this._canonOpt === false) return null;
|
|
420
|
+
return this._canonOpt ?? entryDefault;
|
|
386
421
|
}
|
|
387
422
|
|
|
388
423
|
/** Close one response's transient state — every per-response field, incl.
|
|
@@ -392,6 +427,8 @@ export class Mind implements MindContext {
|
|
|
392
427
|
this.climbMemo = null;
|
|
393
428
|
this.recogniseMemo = null;
|
|
394
429
|
this.perceiveMemo = null;
|
|
430
|
+
this.canon = null;
|
|
431
|
+
this.canonMemo = null;
|
|
395
432
|
this._edgeGuide = null;
|
|
396
433
|
this._edgeChoice.clear();
|
|
397
434
|
}
|
|
@@ -403,8 +440,9 @@ export class Mind implements MindContext {
|
|
|
403
440
|
queryBytes: Uint8Array,
|
|
404
441
|
inspectRationale?: InspectRationale,
|
|
405
442
|
traceLabel = "respond",
|
|
443
|
+
canon: Canon | null = null,
|
|
406
444
|
): Promise<Response> {
|
|
407
|
-
this.beginResponse(inspectRationale);
|
|
445
|
+
this.beginResponse(inspectRationale, canon);
|
|
408
446
|
try {
|
|
409
447
|
const top = this.trace?.enter(traceLabel, [
|
|
410
448
|
rItem(queryBytes, "query"),
|
|
@@ -434,13 +472,27 @@ export class Mind implements MindContext {
|
|
|
434
472
|
input: Input,
|
|
435
473
|
inspectRationale?: InspectRationale,
|
|
436
474
|
): Promise<Response> {
|
|
437
|
-
|
|
475
|
+
// A STRING input is text by nature: it carries the text equivalence even
|
|
476
|
+
// through the generic entry point. Raw bytes / grids carry only the
|
|
477
|
+
// Mind-level canon option, if any.
|
|
478
|
+
const canon = this._canonFor(typeof input === "string" ? textCanon : null);
|
|
479
|
+
return this._respondImpl(
|
|
480
|
+
inputBytes(this, input),
|
|
481
|
+
inspectRationale,
|
|
482
|
+
"respond",
|
|
483
|
+
canon,
|
|
484
|
+
);
|
|
438
485
|
}
|
|
439
486
|
|
|
440
487
|
/** Text view of {@link respond}. NUL bytes (0x00) are stripped before
|
|
441
488
|
* decoding — they are structural padding in text answers. LOSSY for a
|
|
442
489
|
* binary answer that legitimately contains NULs: use {@link respond} and
|
|
443
|
-
* read `bytes` directly for binary/grid modalities.
|
|
490
|
+
* read `bytes` directly for binary/grid modalities.
|
|
491
|
+
*
|
|
492
|
+
* Injects the TEXT canonicalizer (src/canon.ts) so resolution treats
|
|
493
|
+
* every character variation of the same text — case, width, whitespace —
|
|
494
|
+
* as one form, provided the store's canon index is built
|
|
495
|
+
* ({@link buildCanonIndex}). */
|
|
444
496
|
async respondText(
|
|
445
497
|
input: string,
|
|
446
498
|
inspectRationale?: InspectRationale,
|
|
@@ -498,6 +550,52 @@ export class Mind implements MindContext {
|
|
|
498
550
|
};
|
|
499
551
|
}
|
|
500
552
|
|
|
553
|
+
/** Append a turn to a conversation's accumulated context WITHOUT
|
|
554
|
+
* responding — raw byte append plus a boundary offset, never a
|
|
555
|
+
* separator; the fold pyramid advances by O(turn).
|
|
556
|
+
*
|
|
557
|
+
* This is the primitive for turns the Mind should hear but not answer:
|
|
558
|
+
* replaying a transcript, feeding the OTHER speaker's line in a
|
|
559
|
+
* prediction harness, or restoring context piecewise. {@link
|
|
560
|
+
* respondTurn} = addTurn + think + its own reply appended the same way. */
|
|
561
|
+
addTurn(conv: Conversation, turn: Input): ConversationState {
|
|
562
|
+
const data = this._conversations.get(conv.id);
|
|
563
|
+
if (!data) throw new Error(`Conversation ${conv.id} not found`);
|
|
564
|
+
const turnBytes = inputBytes(this, turn);
|
|
565
|
+
this._growContext(data, turnBytes);
|
|
566
|
+
return this.conversationState(conv)!;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Grow a conversation's accumulated context by one turn's bytes — raw
|
|
570
|
+
* append plus a boundary offset, pyramid advanced by O(turn), the grown
|
|
571
|
+
* context's tree seeded into the conversation's perceive memo. The ONE
|
|
572
|
+
* place a context grows ({@link addTurn} and {@link respondTurn} both
|
|
573
|
+
* come through here), so the append semantics cannot drift. */
|
|
574
|
+
private _growContext(data: ConversationData, turnBytes: Uint8Array): Sema {
|
|
575
|
+
const prevLen = data.pyramid.bytes;
|
|
576
|
+
// An empty turn neither grows the context nor marks a boundary —
|
|
577
|
+
// boundaries are documented strictly increasing, and a zero-length
|
|
578
|
+
// "turn" is no turn. The pyramid's zero-growth shortcut makes the
|
|
579
|
+
// refold below O(1), so the existing tree is returned unchanged.
|
|
580
|
+
const grow = turnBytes.length > 0;
|
|
581
|
+
const grown = !grow
|
|
582
|
+
? data.bytes
|
|
583
|
+
: prevLen > 0
|
|
584
|
+
? concat2(data.bytes, turnBytes)
|
|
585
|
+
: turnBytes;
|
|
586
|
+
if (grow && prevLen > 0) data.boundaries.push(prevLen);
|
|
587
|
+
const { tree, pyramid } = bytesToTreePyramid(
|
|
588
|
+
this.space,
|
|
589
|
+
this.alphabet,
|
|
590
|
+
grown,
|
|
591
|
+
data.pyramid,
|
|
592
|
+
);
|
|
593
|
+
data.pyramid = pyramid;
|
|
594
|
+
data.bytes = grown;
|
|
595
|
+
data.perceiveMemo.set(latin1Key(grown), tree);
|
|
596
|
+
return tree;
|
|
597
|
+
}
|
|
598
|
+
|
|
501
599
|
/** Process one turn of a conversation.
|
|
502
600
|
*
|
|
503
601
|
* `turn` is the raw input for the latest turn — its bytes are appended
|
|
@@ -507,7 +605,13 @@ export class Mind implements MindContext {
|
|
|
507
605
|
*
|
|
508
606
|
* Returns the response AND the updated {@link ConversationState} so the
|
|
509
607
|
* caller can persist it. The conversation handle's internal state is
|
|
510
|
-
* updated in place — the returned state is a snapshot for storage.
|
|
608
|
+
* updated in place — the returned state is a snapshot for storage.
|
|
609
|
+
*
|
|
610
|
+
* SINGLE FLIGHT: at most one respondTurn may be in flight per Mind. The
|
|
611
|
+
* conversation's memo caches are swapped into the Mind-level per-response
|
|
612
|
+
* pointers for the duration of the turn, so a concurrently-running
|
|
613
|
+
* respond()/respondTurn() on the SAME Mind would interleave state.
|
|
614
|
+
* Different Minds (or sequential awaits, as in every test) are safe. */
|
|
511
615
|
async respondTurn(
|
|
512
616
|
conv: Conversation,
|
|
513
617
|
turn: Input,
|
|
@@ -517,22 +621,9 @@ export class Mind implements MindContext {
|
|
|
517
621
|
if (!data) throw new Error(`Conversation ${conv.id} not found`);
|
|
518
622
|
|
|
519
623
|
const turnBytes = inputBytes(this, turn);
|
|
520
|
-
const prevLen = data.pyramid.bytes;
|
|
521
|
-
|
|
522
|
-
const prevBytes = data.bytes;
|
|
523
|
-
const newContext = prevLen > 0 ? concat2(prevBytes, turnBytes) : turnBytes;
|
|
524
|
-
data.bytes = newContext;
|
|
525
|
-
|
|
526
|
-
if (prevLen > 0) data.boundaries.push(prevLen);
|
|
527
|
-
|
|
528
624
|
// Incremental perception — O(turn) instead of O(context).
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
this.alphabet,
|
|
532
|
-
newContext,
|
|
533
|
-
data.pyramid,
|
|
534
|
-
);
|
|
535
|
-
data.pyramid = pyramid;
|
|
625
|
+
const tree = this._growContext(data, turnBytes);
|
|
626
|
+
const newContext = data.bytes;
|
|
536
627
|
|
|
537
628
|
// Swap in the conversation's persistent state so the inference
|
|
538
629
|
// pipeline does not re-process the prefix from scratch.
|
|
@@ -547,10 +638,12 @@ export class Mind implements MindContext {
|
|
|
547
638
|
this.climbMemo = data.climbMemo;
|
|
548
639
|
this._resolvedSubtrees = data.resolvedSubtrees;
|
|
549
640
|
this.trace = inspectRationale ? new Rationale(inspectRationale) : null;
|
|
641
|
+
// A string turn is text by nature — carry the text equivalence, same as
|
|
642
|
+
// respond() (see _canonFor).
|
|
643
|
+
this.canon = this._canonFor(typeof turn === "string" ? textCanon : null);
|
|
644
|
+
this.canonMemo = this.canon ? new Map() : null;
|
|
550
645
|
|
|
551
646
|
try {
|
|
552
|
-
this.perceiveMemo.set(latin1Key(newContext), tree);
|
|
553
|
-
|
|
554
647
|
const top = this.trace?.enter("respondTurn", [
|
|
555
648
|
rItem(newContext, "query"),
|
|
556
649
|
]);
|
|
@@ -570,6 +663,14 @@ export class Mind implements MindContext {
|
|
|
570
663
|
"the answer, re-voiced in the asker's words",
|
|
571
664
|
);
|
|
572
665
|
|
|
666
|
+
// The REPLY joins the accumulated context the same way a turn does
|
|
667
|
+
// ({@link addTurn}): raw byte append plus a boundary offset — never a
|
|
668
|
+
// separator. A conversation's context is the full exchange, exactly
|
|
669
|
+
// the cumulative continuous shape multi-turn training deposits, so a
|
|
670
|
+
// later turn can refer to what was ANSWERED ("which of those two…"),
|
|
671
|
+
// not only to what was asked.
|
|
672
|
+
if (voiced.length > 0) this.addTurn(conv, voiced);
|
|
673
|
+
|
|
573
674
|
return {
|
|
574
675
|
response: {
|
|
575
676
|
v: gistOf(this, voiced),
|
|
@@ -579,17 +680,19 @@ export class Mind implements MindContext {
|
|
|
579
680
|
state: this.conversationState(conv)!,
|
|
580
681
|
};
|
|
581
682
|
} finally {
|
|
582
|
-
//
|
|
583
|
-
data.
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
// Clear Mind references — non-conversation respond()
|
|
588
|
-
// fresh per-response memos.
|
|
683
|
+
// No save-back: the conversation's memo MAPS were mutated in place —
|
|
684
|
+
// `data.*` still points at them. Re-assigning from the Mind-level
|
|
685
|
+
// pointers here was a no-op in the single-flight case and, under a
|
|
686
|
+
// concurrently-started respond() (which swaps its own fresh maps into
|
|
687
|
+
// those pointers), would inject a FOREIGN response's memos into this
|
|
688
|
+
// conversation. Clear Mind references — non-conversation respond()
|
|
689
|
+
// calls get fresh per-response memos.
|
|
589
690
|
this.trace = null;
|
|
590
691
|
this.perceiveMemo = null;
|
|
591
692
|
this.recogniseMemo = null;
|
|
592
693
|
this.climbMemo = null;
|
|
694
|
+
this.canon = null;
|
|
695
|
+
this.canonMemo = null;
|
|
593
696
|
this._resolvedSubtrees = null;
|
|
594
697
|
this._edgeGuide = null;
|
|
595
698
|
this._edgeChoice.clear();
|
|
@@ -694,6 +797,46 @@ export class Mind implements MindContext {
|
|
|
694
797
|
);
|
|
695
798
|
}
|
|
696
799
|
|
|
800
|
+
// ── Canonical-form index ───────────────────────────────────────────────
|
|
801
|
+
|
|
802
|
+
/** Build (or incrementally refresh) the store's canonical-form index: for
|
|
803
|
+
* every content-bearing node, record the hash of its CANONICAL key so
|
|
804
|
+
* resolution can find stored forms across surface variation (case, width,
|
|
805
|
+
* whitespace — whatever `canon` equates; see src/canon.ts).
|
|
806
|
+
*
|
|
807
|
+
* Incremental and idempotent: the last indexed node id is remembered in
|
|
808
|
+
* store meta (`canon.upto`), so a refresh after further training scans
|
|
809
|
+
* only the new rows. Run once after training, and again after ingests —
|
|
810
|
+
* the same operational shape as {@link repairContentIndex}.
|
|
811
|
+
*
|
|
812
|
+
* @param canon the canonicalizer to index under — MUST be the same one
|
|
813
|
+
* queries will carry (text queries carry {@link textCanon}
|
|
814
|
+
* unless the Mind was constructed with its own)
|
|
815
|
+
* @returns number of index rows added */
|
|
816
|
+
async buildCanonIndex(canon?: Canon): Promise<number> {
|
|
817
|
+
const c = canon ?? this._canonFor(textCanon);
|
|
818
|
+
const store = this.store;
|
|
819
|
+
if (c === null || !store.canonAdd || !store.eachContent) return 0;
|
|
820
|
+
const from = Number(await store.getMeta("canon.upto") ?? 0);
|
|
821
|
+
let added = 0;
|
|
822
|
+
let maxId = from - 1;
|
|
823
|
+
store.eachContent((id, bytes) => {
|
|
824
|
+
if (id > maxId) maxId = id;
|
|
825
|
+
const key = c(bytes);
|
|
826
|
+
if (key.length === 0) return;
|
|
827
|
+
// Only index content whose canonical key DIFFERS from its raw bytes —
|
|
828
|
+
// an already-canonical span is found by the exact lookup (and by the
|
|
829
|
+
// fallback's own exact probe of the canonical bytes), so indexing it
|
|
830
|
+
// would only add rows.
|
|
831
|
+
if (bytesEqual(key, bytes)) return;
|
|
832
|
+
store.canonAdd!(canonHash(key), id);
|
|
833
|
+
added++;
|
|
834
|
+
}, from);
|
|
835
|
+
await store.setMeta("canon.upto", String(maxId + 1));
|
|
836
|
+
store.commit();
|
|
837
|
+
return added;
|
|
838
|
+
}
|
|
839
|
+
|
|
697
840
|
// ── Persistence ──────────────────────────────────────────────────────────
|
|
698
841
|
|
|
699
842
|
async save(): Promise<Uint8Array> {
|