@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.
- package/dist/src/geometry.d.ts +26 -0
- package/dist/src/geometry.js +32 -3
- package/dist/src/mind/attention.d.ts +246 -7
- package/dist/src/mind/attention.js +771 -173
- package/dist/src/mind/bridge.d.ts +30 -0
- package/dist/src/mind/bridge.js +569 -0
- package/dist/src/mind/index.d.ts +2 -0
- package/dist/src/mind/junction.d.ts +30 -1
- package/dist/src/mind/junction.js +73 -18
- package/dist/src/mind/match.d.ts +15 -2
- package/dist/src/mind/match.js +3 -8
- package/dist/src/mind/mechanisms/cast.d.ts +54 -0
- package/dist/src/mind/mechanisms/cast.js +268 -37
- package/dist/src/mind/mechanisms/cover.js +16 -31
- package/dist/src/mind/mechanisms/recall.js +66 -0
- package/dist/src/mind/mind.d.ts +2 -0
- package/dist/src/mind/rationale.d.ts +7 -2
- package/dist/src/mind/rationale.js +6 -5
- package/dist/src/mind/reasoning.d.ts +11 -0
- package/dist/src/mind/reasoning.js +58 -2
- package/dist/src/mind/recognition.js +127 -7
- package/dist/src/mind/traverse.js +90 -25
- package/dist/src/mind/types.d.ts +57 -2
- package/dist/src/mind/types.js +38 -7
- package/dist/src/store.d.ts +12 -3
- package/dist/src/store.js +9 -3
- package/package.json +1 -1
- package/src/geometry.ts +52 -3
- package/src/mind/attention.ts +1199 -128
- package/src/mind/bridge.ts +596 -0
- package/src/mind/index.ts +16 -0
- package/src/mind/junction.ts +125 -16
- package/src/mind/match.ts +19 -5
- package/src/mind/mechanisms/cast.ts +290 -38
- package/src/mind/mechanisms/cover.ts +23 -36
- package/src/mind/mechanisms/recall.ts +79 -0
- package/src/mind/mind.ts +15 -0
- package/src/mind/rationale.ts +12 -4
- package/src/mind/reasoning.ts +71 -2
- package/src/mind/recognition.ts +132 -7
- package/src/mind/traverse.ts +91 -24
- package/src/mind/types.ts +95 -6
- package/src/store.ts +19 -5
- package/test/36-already-answered-fusion.test.mjs +128 -0
- package/test/37-cluster-dispersion-fusion.test.mjs +190 -0
- package/test/38-reason-restate-guard.test.mjs +94 -0
- package/test/39-cast-restate-guard.test.mjs +102 -0
- package/test/40-choosenext-scale-guard.test.mjs +75 -0
- package/test/41-seatofnode-direction.test.mjs +85 -0
- package/test/42-recognise-trace-idempotence.test.mjs +106 -0
- package/test/43-cast-analog-seat.test.mjs +244 -0
- package/test/44-recognise-edge-whitespace.test.mjs +63 -0
- package/test/45-liftanswer-restated-trim.test.mjs +60 -0
- package/test/46-recognise-multibyte-edge.test.mjs +85 -0
- package/test/47-cast-comparison-coverage.test.mjs +134 -0
- package/test/48-recognise-turn-connective.test.mjs +125 -0
- package/test/49-natural-units-synonym-bridge.test.mjs +175 -0
- package/test/50-cast-analog-consensus-floor.test.mjs +179 -0
- package/test/51-structural-resonance-ladder.test.mjs +552 -0
- package/test/52-climb-consensus-instrumentation.test.mjs +324 -0
package/src/mind/traverse.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// project) live in match.ts — the elementary match-and-project operation.
|
|
8
8
|
|
|
9
9
|
import { cosine, Vec } from "../vec.js";
|
|
10
|
-
import {
|
|
11
|
-
import type { AncestorReach, MindContext } from "./types.js";
|
|
10
|
+
import type { AncestorReach, MindContext, SaturationStop } from "./types.js";
|
|
12
11
|
import { gistOf, read } from "./primitives.js";
|
|
13
12
|
|
|
14
13
|
// ── Per-response structural memo ────────────────────────────────────────
|
|
@@ -135,7 +134,22 @@ export function edgeAncestors(
|
|
|
135
134
|
// is withdrawn. On a small store the floor stays ≤ √N and the atom
|
|
136
135
|
// climbs exactly as before, so single-letter facts keep working.
|
|
137
136
|
if (id < 0 && atomIsHub(ctx, contextCount)) {
|
|
138
|
-
const
|
|
137
|
+
const bound0 = Math.ceil(Math.sqrt(Math.max(2, contextCount)));
|
|
138
|
+
const reach: AncestorReach = {
|
|
139
|
+
roots: [],
|
|
140
|
+
contextsReached: 0,
|
|
141
|
+
saturated: true,
|
|
142
|
+
...(ctx.trace
|
|
143
|
+
? {
|
|
144
|
+
saturation: {
|
|
145
|
+
reason: "byte-atom-commonality" as const,
|
|
146
|
+
node: id,
|
|
147
|
+
observed: atomReach(ctx, contextCount),
|
|
148
|
+
limit: bound0,
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
: {}),
|
|
152
|
+
};
|
|
139
153
|
memo?.set(id, reach);
|
|
140
154
|
return reach;
|
|
141
155
|
}
|
|
@@ -145,6 +159,10 @@ export function edgeAncestors(
|
|
|
145
159
|
const seen = new Set<number>([id]);
|
|
146
160
|
const ctxSeen = new Set<number>();
|
|
147
161
|
let saturated = false;
|
|
162
|
+
// Provenance of the FIRST decision that saturated this climb — allocated
|
|
163
|
+
// only when a trace is requested (see AncestorReach.saturation's doc); the
|
|
164
|
+
// climb itself never reads it back.
|
|
165
|
+
let satStop: SaturationStop | undefined;
|
|
148
166
|
|
|
149
167
|
// EXPAND-UNTIL-DECIDED: a reach is consumed either as a VOTE (which needs
|
|
150
168
|
// contextsReached exactly, and only while ≤ √N — beyond that the region is
|
|
@@ -189,12 +207,45 @@ export function edgeAncestors(
|
|
|
189
207
|
if (hasNx || pc > 0) {
|
|
190
208
|
roots.push(x);
|
|
191
209
|
if (hasNx) ctxSeen.add(x);
|
|
192
|
-
if (pc > bound)
|
|
210
|
+
if (pc > bound) {
|
|
211
|
+
// decided: ≥ pc > √N distinct contexts
|
|
212
|
+
if (ctx.trace) {
|
|
213
|
+
satStop = {
|
|
214
|
+
reason: "predecessor-fan-in",
|
|
215
|
+
node: x,
|
|
216
|
+
observed: pc,
|
|
217
|
+
limit: bound,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
193
222
|
for (const p of ctx.store.prevFirst(x, bound)) ctxSeen.add(p);
|
|
194
|
-
if (ctxSeen.size > bound)
|
|
223
|
+
if (ctxSeen.size > bound) {
|
|
224
|
+
// decided
|
|
225
|
+
if (ctx.trace) {
|
|
226
|
+
satStop = {
|
|
227
|
+
reason: "distinct-context-limit",
|
|
228
|
+
node: x,
|
|
229
|
+
observed: ctxSeen.size,
|
|
230
|
+
limit: bound,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
195
235
|
}
|
|
196
236
|
const parents = ctx.store.parentsFirst(x, bound + 1);
|
|
197
|
-
if (parents.length > bound)
|
|
237
|
+
if (parents.length > bound) {
|
|
238
|
+
// decided: hub
|
|
239
|
+
if (ctx.trace) {
|
|
240
|
+
satStop = {
|
|
241
|
+
reason: "parent-fan-out",
|
|
242
|
+
node: x,
|
|
243
|
+
observed: parents.length,
|
|
244
|
+
limit: bound,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
198
249
|
let fresh = 0;
|
|
199
250
|
for (const p of parents) {
|
|
200
251
|
if (!seen.has(p)) {
|
|
@@ -205,7 +256,18 @@ export function edgeAncestors(
|
|
|
205
256
|
}
|
|
206
257
|
if (fresh > 1) {
|
|
207
258
|
lateral += fresh - 1;
|
|
208
|
-
if (lateral > bound)
|
|
259
|
+
if (lateral > bound) {
|
|
260
|
+
// decided: cone-wide hub
|
|
261
|
+
if (ctx.trace) {
|
|
262
|
+
satStop = {
|
|
263
|
+
reason: "lateral-cone-limit",
|
|
264
|
+
node: x,
|
|
265
|
+
observed: lateral,
|
|
266
|
+
limit: bound,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
209
271
|
}
|
|
210
272
|
return true;
|
|
211
273
|
};
|
|
@@ -267,7 +329,12 @@ export function edgeAncestors(
|
|
|
267
329
|
}
|
|
268
330
|
}
|
|
269
331
|
|
|
270
|
-
const reach = {
|
|
332
|
+
const reach: AncestorReach = {
|
|
333
|
+
roots,
|
|
334
|
+
contextsReached: ctxSeen.size,
|
|
335
|
+
saturated,
|
|
336
|
+
...(saturated && satStop ? { saturation: satStop } : {}),
|
|
337
|
+
};
|
|
271
338
|
memo?.set(id, reach);
|
|
272
339
|
return reach;
|
|
273
340
|
}
|
|
@@ -528,22 +595,22 @@ export function chooseNext(
|
|
|
528
595
|
}
|
|
529
596
|
}
|
|
530
597
|
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
)
|
|
545
|
-
|
|
546
|
-
|
|
598
|
+
// NO consensusFloor gate here (tried and reverted — see
|
|
599
|
+
// test/40-choosenext-scale-guard.test.mjs): that floor is calibrated for
|
|
600
|
+
// POOLED, IDF-weighted CLIMB VOTES (recallByResonance, commitVotes), where
|
|
601
|
+
// each corroborating region contributes at most ln N and the floor grows
|
|
602
|
+
// with N exactly as that per-region ceiling does (HOW_IT_WORKS.md §8.6).
|
|
603
|
+
// `bestSupport` here is a different kind of quantity — a raw prevCount of
|
|
604
|
+
// how many training contexts predicted ONE destination, bounded by how
|
|
605
|
+
// often that specific fact was retold, never by corpus size N. Gating an
|
|
606
|
+
// N-invariant count against an N-growing threshold guarantees failure
|
|
607
|
+
// once N is large enough, discarding genuinely, structurally dominant
|
|
608
|
+
// edges (observed: a fact corroborated 2-to-1-1-1 refused at N≈325K,
|
|
609
|
+
// falling back to a noisy concept-hop). The loop above already IS the
|
|
610
|
+
// "genuinely competing" test: a tie leaves first-inserted as the pick
|
|
611
|
+
// (test/30's own pinned behaviour); a strict winner is real evidence
|
|
612
|
+
// regardless of corpus scale. Matches HOW_IT_WORKS.md §25's own
|
|
613
|
+
// chooseNext pseudocode, which has no such floor.
|
|
547
614
|
|
|
548
615
|
// Trace is built lazily — the filter + map below only execute when a
|
|
549
616
|
// trace listener is attached, so the common (no-trace) path pays only
|
package/src/mind/types.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface DepositCacheEntry {
|
|
|
39
39
|
* its own suffix bytes-equal this exactly. */
|
|
40
40
|
nextBytes?: Uint8Array;
|
|
41
41
|
}
|
|
42
|
-
import { concatBytes } from "../bytes.js";
|
|
42
|
+
import { bytesEqual, concatBytes, indexOf } from "../bytes.js";
|
|
43
43
|
import { dominates } from "../geometry.js";
|
|
44
44
|
|
|
45
45
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -111,6 +111,23 @@ export interface Attention {
|
|
|
111
111
|
* consensus; one that does not is a coincidental single-region echo —
|
|
112
112
|
* see test/35-attention-confidence.test.mjs. */
|
|
113
113
|
breadth: number;
|
|
114
|
+
/** DISPERSION: the number of distinct clusters this point's contributing
|
|
115
|
+
* regions form, merging any two whose gap is under one river-fold
|
|
116
|
+
* quantum W. Neither breadth NOR raw region count discriminates a
|
|
117
|
+
* genuine further topic from a coincidental echo (both were tried and
|
|
118
|
+
* falsified — breadth starves a genuine, evenly-split multi-topic query,
|
|
119
|
+
* since no root in a real N-way split can exceed half the vote; raw
|
|
120
|
+
* count doesn't separate them either, since a short, structurally simple
|
|
121
|
+
* echo racks up as many corroborating regions as a real topic does).
|
|
122
|
+
* Dispersion asks a different question: not how MUCH evidence, but how
|
|
123
|
+
* many separate PLACES in the query corroborate it. A coincidental
|
|
124
|
+
* match — one local phrase resonating with an unrelated stored form —
|
|
125
|
+
* is structurally confined to ONE cluster no matter how strong its vote;
|
|
126
|
+
* a genuine further topic is named in its own distinctive wording
|
|
127
|
+
* somewhere the query's scaffolding does not reach, always a SEPARATE
|
|
128
|
+
* cluster from whatever else corroborates it. See
|
|
129
|
+
* test/37-cluster-dispersion-fusion.test.mjs. */
|
|
130
|
+
clusters: number;
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
/** Both read-outs of one consensus climb. */
|
|
@@ -158,11 +175,36 @@ export interface RegionVote {
|
|
|
158
175
|
absorbed?: number;
|
|
159
176
|
}
|
|
160
177
|
|
|
178
|
+
/** The structural gate that first decided an {@link edgeAncestors} climb was
|
|
179
|
+
* saturated (an abstention, not a discriminative conclusion) — pure
|
|
180
|
+
* instrumentation for {@link ClimbConsensusData}'s reach trace; it never
|
|
181
|
+
* feeds back into the climb itself. */
|
|
182
|
+
export type SaturationReason =
|
|
183
|
+
| "byte-atom-commonality"
|
|
184
|
+
| "predecessor-fan-in"
|
|
185
|
+
| "distinct-context-limit"
|
|
186
|
+
| "parent-fan-out"
|
|
187
|
+
| "lateral-cone-limit";
|
|
188
|
+
|
|
189
|
+
/** One saturation stop's provenance: which reason fired, at which node, the
|
|
190
|
+
* observed count against the bound that decided it. */
|
|
191
|
+
export interface SaturationStop {
|
|
192
|
+
reason: SaturationReason;
|
|
193
|
+
node: number;
|
|
194
|
+
observed: number;
|
|
195
|
+
limit: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
161
198
|
/** The edge-bearing contexts reached by climbing from a node, plus saturation info. */
|
|
162
199
|
export interface AncestorReach {
|
|
163
200
|
roots: number[];
|
|
164
201
|
contextsReached: number;
|
|
165
202
|
saturated: boolean;
|
|
203
|
+
/** The saturation gate that stopped this climb, when {@link saturated} is
|
|
204
|
+
* true and a trace was requested — see {@link edgeAncestors}. Absent for
|
|
205
|
+
* a non-saturated reach, and absent (even when saturated) when no trace
|
|
206
|
+
* was requested — instrumentation must not allocate when tracing is off. */
|
|
207
|
+
saturation?: SaturationStop;
|
|
166
208
|
}
|
|
167
209
|
|
|
168
210
|
/** Saturated-interval information for the noise-drop gate. */
|
|
@@ -273,11 +315,52 @@ export function spliceAll(segs: Seg[]): Uint8Array | null {
|
|
|
273
315
|
return concatBytes(segs.map((s) => s.bytes));
|
|
274
316
|
}
|
|
275
317
|
|
|
318
|
+
/** Whether a chosen span RESTATES the query rather than answering it: its
|
|
319
|
+
* SUBSTITUTED bytes (an edge followed from a recognised site, not the
|
|
320
|
+
* site's own literal text read back) already occur elsewhere in the query
|
|
321
|
+
* — the same principle recall.ts's tiers apply to a whole-query projection
|
|
322
|
+
* ("a projection that is a proper byte-subspan of the query restates part
|
|
323
|
+
* of the question"). A LITERAL span (the site's own bytes, unchanged) is
|
|
324
|
+
* exempt: naming what's already there at its OWN position is not a
|
|
325
|
+
* substitution. A recognised site that is itself an entire PRIOR TURN of
|
|
326
|
+
* a multi-turn query is exactly this shape: it carries a genuine learnt
|
|
327
|
+
* continuation, but that continuation is something the asker already said
|
|
328
|
+
* moments later in the SAME query, not a new answer. Below one river
|
|
329
|
+
* window, byte overlap is chance, not evidence — the same floor
|
|
330
|
+
* identityBar and reachThreshold hold every other structural-overlap claim
|
|
331
|
+
* to. */
|
|
332
|
+
export function segRestatesQuery(
|
|
333
|
+
s: Seg,
|
|
334
|
+
query: Uint8Array,
|
|
335
|
+
queryLen: number,
|
|
336
|
+
W: number,
|
|
337
|
+
): boolean {
|
|
338
|
+
if (!s.rec) return false;
|
|
339
|
+
const literal = s.j - s.i === s.bytes.length &&
|
|
340
|
+
bytesEqual(s.bytes, query.subarray(s.i, s.j));
|
|
341
|
+
if (literal) return false;
|
|
342
|
+
return s.bytes.length >= W && s.bytes.length < queryLen &&
|
|
343
|
+
indexOf(query, s.bytes, 0) >= 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
276
346
|
/** Lift the answer out of the cover for think: the recognised region, free of
|
|
277
|
-
* the asker's surrounding (unrecognised) framing
|
|
278
|
-
|
|
347
|
+
* the asker's surrounding (unrecognised) framing — and free of any chosen
|
|
348
|
+
* span that only RESTATES content the query already contains (see {@link
|
|
349
|
+
* segRestatesQuery}). A restating span is excluded from both the framing
|
|
350
|
+
* (lo/hi) decision and the final concatenation: it is stale, not a second
|
|
351
|
+
* answer, but the OTHER spans a derivation chose are independent evidence
|
|
352
|
+
* and must not be discarded along with it. */
|
|
353
|
+
export function liftAnswer(
|
|
354
|
+
segs: Seg[],
|
|
355
|
+
queryLen: number,
|
|
356
|
+
query: Uint8Array,
|
|
357
|
+
W: number,
|
|
358
|
+
): Uint8Array | null {
|
|
359
|
+
const restated = segs.map((s) => segRestatesQuery(s, query, queryLen, W));
|
|
279
360
|
const recognised: number[] = [];
|
|
280
|
-
for (let k = 0; k < segs.length; k++)
|
|
361
|
+
for (let k = 0; k < segs.length; k++) {
|
|
362
|
+
if (segs[k].rec && !restated[k]) recognised.push(k);
|
|
363
|
+
}
|
|
281
364
|
if (recognised.length === 0) return null;
|
|
282
365
|
|
|
283
366
|
if (recognised.length === 1) {
|
|
@@ -297,13 +380,19 @@ export function liftAnswer(segs: Seg[], queryLen: number): Uint8Array | null {
|
|
|
297
380
|
// trailing glue byte ("2+2." → "4.", the span dominates a 4-byte query).
|
|
298
381
|
if (s.computed && s.i > 0) return s.bytes;
|
|
299
382
|
if (dominates(s.j - s.i, queryLen)) {
|
|
300
|
-
return concatBytes(
|
|
383
|
+
return concatBytes(
|
|
384
|
+
segs.filter((_, k) => !restated[k]).map((x) => x.bytes),
|
|
385
|
+
);
|
|
301
386
|
}
|
|
302
387
|
return s.bytes;
|
|
303
388
|
}
|
|
304
389
|
const lo = recognised[0];
|
|
305
390
|
const hi = recognised[recognised.length - 1];
|
|
306
|
-
return concatBytes(
|
|
391
|
+
return concatBytes(
|
|
392
|
+
segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]).map((x) =>
|
|
393
|
+
x.bytes
|
|
394
|
+
),
|
|
395
|
+
);
|
|
307
396
|
}
|
|
308
397
|
|
|
309
398
|
/** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
|
package/src/store.ts
CHANGED
|
@@ -306,8 +306,17 @@ export interface Store {
|
|
|
306
306
|
nodeCount(): number;
|
|
307
307
|
|
|
308
308
|
// ── soft resonance over node gists ─────────────────────────────────────
|
|
309
|
-
/** The k nodes whose gist resonates most with v.
|
|
310
|
-
|
|
309
|
+
/** The k nodes whose gist resonates most with v. `exhaustive` widens the
|
|
310
|
+
* IVF probe to every cluster (see {@link AbstractStore.efFor}'s doc) —
|
|
311
|
+
* for refusal-path-only callers where an approximate top-√C-clusters
|
|
312
|
+
* search is not the same discriminator as the caller's own byte-exact
|
|
313
|
+
* verification (the substitution bridge's proposal channel): a rarer
|
|
314
|
+
* paraphrase can score lower than hundreds of unrelated hits by pure
|
|
315
|
+
* fold-geometry structural distance (a middle-of-string mismatch
|
|
316
|
+
* perturbs the tree hash far more than a tail mismatch of the same
|
|
317
|
+
* byte length) and so never even reach a probed cluster, no matter how
|
|
318
|
+
* large k is — k only reorders WITHIN the clusters already probed. */
|
|
319
|
+
resonate(v: Vec, k: number, exhaustive?: boolean): Promise<Hit[]>;
|
|
311
320
|
/** Mark a node as a RESONANCE TARGET — promote its gist into the content
|
|
312
321
|
* index so {@link resonate} can find it. A node's gist is captured at intern
|
|
313
322
|
* but indexed LAZILY (only targets are indexed; the ~99.5% intermediate DAG
|
|
@@ -1636,7 +1645,7 @@ export abstract class AbstractStore implements Store {
|
|
|
1636
1645
|
|
|
1637
1646
|
// ── Soft resonance ─────────────────────────────────────────────────────
|
|
1638
1647
|
|
|
1639
|
-
async resonate(v: Vec, k: number): Promise<Hit[]> {
|
|
1648
|
+
async resonate(v: Vec, k: number, exhaustive = false): Promise<Hit[]> {
|
|
1640
1649
|
await this._ensureReady();
|
|
1641
1650
|
// Synchronous flush of any buffered index writes: the FIRST resonance
|
|
1642
1651
|
// after a large ingest pays that flush here, so it shows up in respond
|
|
@@ -1649,17 +1658,22 @@ export abstract class AbstractStore implements Store {
|
|
|
1649
1658
|
// same values still hits. Lazy-init: null after any index write; the
|
|
1650
1659
|
// first miss after a flush recreates it. When voteRegions resonates
|
|
1651
1660
|
// identical perceived sub-regions, only the first call descends the ANN.
|
|
1652
|
-
const rk = vecKey(v) + ":" + k;
|
|
1661
|
+
const rk = vecKey(v) + ":" + k + (exhaustive ? ":x" : "");
|
|
1653
1662
|
const cache = this._resonateCache;
|
|
1654
1663
|
if (cache) {
|
|
1655
1664
|
const hit = cache.get(rk);
|
|
1656
1665
|
if (hit !== undefined) return hit;
|
|
1657
1666
|
}
|
|
1658
1667
|
|
|
1668
|
+
const clusters = this._vecContentClusterCount();
|
|
1659
1669
|
const results = this._vecContentQuery(
|
|
1660
1670
|
normalize(copy(v)),
|
|
1661
1671
|
k * this.overfetch,
|
|
1662
|
-
|
|
1672
|
+
// Exhaustive: probe every cluster (ef ≥ 4·clusters guarantees the
|
|
1673
|
+
// IVF's own ef→nprobe=ceil(ef/4) mapping reaches all of them) — the
|
|
1674
|
+
// natural ceiling for a search that is ALREADY refusal-path-only and
|
|
1675
|
+
// must not miss a candidate hiding in an unprobed cluster.
|
|
1676
|
+
exhaustive ? 4 * clusters : this.efFor(clusters),
|
|
1663
1677
|
);
|
|
1664
1678
|
const out: Hit[] = [];
|
|
1665
1679
|
for (const r of results) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// 36-already-answered-fusion.test.mjs — a point of attention whose own
|
|
2
|
+
// learnt continuation is ALREADY present later in the query must not be
|
|
3
|
+
// fused in again.
|
|
4
|
+
//
|
|
5
|
+
// This is Category A of a two-part defect found while investigating a
|
|
6
|
+
// multi-turn dialogue's garbled fusion ("Thank you very much!" fused with
|
|
7
|
+
// unrelated points of attention). One of those points (root "Hello",
|
|
8
|
+
// anchor of the greeting) traced back to substantial, genuine regions —
|
|
9
|
+
// not noise — and its learnt continuation ("Hello! How can I assist you
|
|
10
|
+
// today?") was found to be VERBATIM present earlier in the query, because
|
|
11
|
+
// it was Sema's own prior reply, appended by addTurn the same way any turn
|
|
12
|
+
// is appended. Re-surfacing it is redundant: the query has already spoken
|
|
13
|
+
// its own answer.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately turn-agnostic and Mind-bookkeeping-free: Mind's multi-turn
|
|
16
|
+
// API is strictly a computational optimization (incremental fold reuse) —
|
|
17
|
+
// it must never be the thing inference depends on for correctness. This
|
|
18
|
+
// check uses only what already exists for ANY accumulated byte stream,
|
|
19
|
+
// single-shot or multi-turn: `follow()` (the same content-addressed
|
|
20
|
+
// continuation walk `reason()`'s own echo guard already uses,
|
|
21
|
+
// `ctx.store.prevCount(qId) > 0`, just applied per-candidate-root instead
|
|
22
|
+
// of to the whole query) and plain byte containment. A query that embeds
|
|
23
|
+
// its own prior exchange — via real respondTurn(), or a caller manually
|
|
24
|
+
// pasting a transcript into one respond() call — is caught identically.
|
|
25
|
+
//
|
|
26
|
+
// (Category B — coincidental echo of a short, generic CURRENT-turn phrase
|
|
27
|
+
// against unrelated corpus content, unrelated to staleness — is a SEPARATE
|
|
28
|
+
// defect, not addressed here; see the investigation notes for why breadth
|
|
29
|
+
// and regionSupport both fail to discriminate it from genuine fusion.)
|
|
30
|
+
|
|
31
|
+
import { test } from "node:test";
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import { Mind } from "../dist/src/index.js";
|
|
34
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
35
|
+
import { fuseAttention } from "../dist/src/mind/reasoning.js";
|
|
36
|
+
import { gistOf } from "../dist/src/mind/primitives.js";
|
|
37
|
+
|
|
38
|
+
const enc = (s) => new TextEncoder().encode(s);
|
|
39
|
+
const dec = (b) =>
|
|
40
|
+
new TextDecoder().decode(b.filter((x) => x !== 0)).replace(/\s+/g, " ")
|
|
41
|
+
.trim();
|
|
42
|
+
|
|
43
|
+
// "greet" leads to "reply-greet" — a learnt exchange the query embeds BOTH
|
|
44
|
+
// halves of, exactly the shape addTurn produces (ask, then the system's own
|
|
45
|
+
// reply, concatenated raw). "red circle" is a genuine, unrelated second
|
|
46
|
+
// topic (test/34's own binding corpus) whose own continuation is nowhere
|
|
47
|
+
// in the query — the ordinary multi-topic case, which must still fuse.
|
|
48
|
+
const CORPUS = [
|
|
49
|
+
["greet", "reply-greet"],
|
|
50
|
+
["red", "is a color"],
|
|
51
|
+
["blue", "is a color"],
|
|
52
|
+
["circle", "is a shape"],
|
|
53
|
+
["square", "is a shape"],
|
|
54
|
+
["red circle", "answer alpha"],
|
|
55
|
+
["red square", "answer beta"],
|
|
56
|
+
["blue circle", "answer gamma"],
|
|
57
|
+
["blue square", "answer delta"],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const mk = (seed) =>
|
|
61
|
+
new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
|
|
62
|
+
|
|
63
|
+
test("fuseAttention: a root whose continuation is already answered in the query is excluded", async () => {
|
|
64
|
+
const m = mk(1);
|
|
65
|
+
await m.ingest(CORPUS);
|
|
66
|
+
// "greet"'s own continuation, "reply-greet", is embedded right in the
|
|
67
|
+
// query — the ask and its answer both present, exactly like a
|
|
68
|
+
// conversation's own turn + reply. "red circle" is a genuine further
|
|
69
|
+
// topic with no such embedded answer.
|
|
70
|
+
const q = enc("greet reply-greet then red then circle");
|
|
71
|
+
const roots = await m.climbAttention(q, 24);
|
|
72
|
+
const greet = roots.find((r) => r.start === 0);
|
|
73
|
+
const redCircle = roots.find((r) => r.start !== 0);
|
|
74
|
+
assert.ok(greet, "expected the 'greet' root at query offset 0");
|
|
75
|
+
assert.ok(redCircle, "expected the 'red circle' binding root");
|
|
76
|
+
|
|
77
|
+
const guide = gistOf(m, q);
|
|
78
|
+
const primary = enc("PRIMARYANCHORTEXT");
|
|
79
|
+
// forest[0] is never independently projected (see reasoning.ts: it is
|
|
80
|
+
// treated as already primary's own source) — a placeholder keeps BOTH
|
|
81
|
+
// real roots in `rest`, where this filter actually applies.
|
|
82
|
+
const placeholder = { ...greet, start: q.length, end: q.length };
|
|
83
|
+
const pre = {
|
|
84
|
+
attention: async () => ({
|
|
85
|
+
roots: [placeholder, greet, redCircle],
|
|
86
|
+
ranked: [placeholder, greet, redCircle],
|
|
87
|
+
}),
|
|
88
|
+
guide,
|
|
89
|
+
};
|
|
90
|
+
const out = dec(await fuseAttention(m, q, primary, pre));
|
|
91
|
+
assert.ok(
|
|
92
|
+
!out.includes("reply-greet"),
|
|
93
|
+
`a root whose continuation is already answered in the query must not fuse in, got "${out}"`,
|
|
94
|
+
);
|
|
95
|
+
assert.ok(
|
|
96
|
+
out.includes("answer alpha"),
|
|
97
|
+
`a genuine further topic with no embedded answer must still fuse in, got "${out}"`,
|
|
98
|
+
);
|
|
99
|
+
await m.store.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("fuseAttention: ordinary multi-topic fusion (no embedded answers) is completely unaffected", async () => {
|
|
103
|
+
const m = mk(1);
|
|
104
|
+
await m.ingest(CORPUS);
|
|
105
|
+
const q = enc("red then circle");
|
|
106
|
+
const roots = await m.climbAttention(q, 24);
|
|
107
|
+
assert.equal(roots.length, 1, "expected the single joint binding root");
|
|
108
|
+
// Force a second, independent (unclimbed-style) inclusion path by
|
|
109
|
+
// reusing the SAME root twice at different synthetic positions, neither
|
|
110
|
+
// of which has an embedded answer — a pure regression check that the
|
|
111
|
+
// new filter doesn't fire when there is nothing to catch.
|
|
112
|
+
const guide = gistOf(m, q);
|
|
113
|
+
const primary = enc("PRIMARYANCHORTEXT");
|
|
114
|
+
const placeholder = { ...roots[0], start: q.length, end: q.length };
|
|
115
|
+
const pre = {
|
|
116
|
+
attention: async () => ({
|
|
117
|
+
roots: [placeholder, roots[0]],
|
|
118
|
+
ranked: [placeholder, roots[0]],
|
|
119
|
+
}),
|
|
120
|
+
guide,
|
|
121
|
+
};
|
|
122
|
+
const out = dec(await fuseAttention(m, q, primary, pre));
|
|
123
|
+
assert.ok(
|
|
124
|
+
out.includes("answer alpha"),
|
|
125
|
+
`an ordinary further topic must still fuse in, got "${out}"`,
|
|
126
|
+
);
|
|
127
|
+
await m.store.close();
|
|
128
|
+
});
|