@hviana/sema 0.4.7 → 0.5.1
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/AGENTS.md +290 -77
- package/HOW_IT_WORKS.md +2170 -735
- package/dist/example/train_base.d.ts +9 -3
- package/dist/example/train_base.js +21 -4
- package/dist/src/canon.d.ts +19 -0
- package/dist/src/canon.js +28 -0
- package/dist/src/geometry.d.ts +52 -0
- package/dist/src/geometry.js +87 -1
- package/dist/src/mind/attention.d.ts +15 -10
- package/dist/src/mind/attention.js +15 -10
- package/dist/src/mind/bridge.js +27 -1
- package/dist/src/mind/frame-filler.d.ts +15 -0
- package/dist/src/mind/frame-filler.js +535 -0
- package/dist/src/mind/learning.js +6 -11
- package/dist/src/mind/mechanisms/cast.js +72 -2
- package/dist/src/mind/mechanisms/cover.js +6 -1
- package/dist/src/mind/mechanisms/extraction.js +27 -0
- package/dist/src/mind/mechanisms/recall.js +214 -34
- package/dist/src/mind/mind.d.ts +52 -3
- package/dist/src/mind/mind.js +140 -12
- package/dist/src/mind/pipeline-mechanism.d.ts +7 -0
- package/dist/src/mind/pipeline.js +29 -1
- package/dist/src/mind/prefix-completion.d.ts +59 -0
- package/dist/src/mind/prefix-completion.js +270 -0
- package/dist/src/mind/primitives.d.ts +29 -10
- package/dist/src/mind/primitives.js +98 -71
- package/dist/src/mind/recognition.js +153 -26
- package/dist/src/mind/traverse.d.ts +32 -0
- package/dist/src/mind/traverse.js +52 -0
- package/dist/src/mind/types.d.ts +61 -18
- package/dist/src/mind/types.js +68 -19
- package/dist/src/store.d.ts +21 -0
- package/dist/src/store.js +21 -0
- package/example/train_base.ts +21 -4
- package/package.json +1 -1
- package/src/canon.ts +28 -0
- package/src/geometry.ts +100 -1
- package/src/mind/attention.ts +15 -10
- package/src/mind/bridge.ts +34 -0
- package/src/mind/frame-filler.ts +604 -0
- package/src/mind/learning.ts +5 -9
- package/src/mind/mechanisms/cast.ts +70 -2
- package/src/mind/mechanisms/cover.ts +6 -1
- package/src/mind/mechanisms/extraction.ts +27 -0
- package/src/mind/mechanisms/recall.ts +236 -37
- package/src/mind/mind.ts +166 -18
- package/src/mind/pipeline-mechanism.ts +7 -0
- package/src/mind/pipeline.ts +33 -1
- package/src/mind/prefix-completion.ts +314 -0
- package/src/mind/primitives.ts +105 -80
- package/src/mind/recognition.ts +151 -23
- package/src/mind/traverse.ts +52 -0
- package/src/mind/types.ts +104 -44
- package/src/store.ts +25 -0
- package/test/13-conversation.test.mjs +13 -0
- package/test/57-fusion-order.test.mjs +65 -0
- package/test/66-query-edge-whitespace.test.mjs +99 -0
- package/test/67-climb-anchor-breadth.test.mjs +113 -0
- package/test/68-extraction-unanchored.test.mjs +79 -0
- package/test/69-frame-filler.test.mjs +115 -0
- package/test/70-prefix-completion.test.mjs +170 -0
- package/test/71-embedded-canon-equivalence.test.mjs +121 -0
- package/test/72-prefix-candidate-supply.test.mjs +114 -0
- package/test/73-scaffolding-only-bridge-abstains.test.mjs +178 -0
- package/test/74-prefix-trap-not-sprung-early.test.mjs +114 -0
- package/test/75-multiturn-context-optimisation.test.mjs +1334 -0
package/src/mind/types.ts
CHANGED
|
@@ -20,23 +20,18 @@ import type {
|
|
|
20
20
|
Site,
|
|
21
21
|
} from "./graph-search.js";
|
|
22
22
|
import type { Rationale } from "./rationale.js";
|
|
23
|
-
import type {
|
|
23
|
+
import type { ContentFold, Grid } from "../geometry.js";
|
|
24
24
|
|
|
25
|
-
/** One {@link MindContext._depositTrees} entry — see that field's doc.
|
|
25
|
+
/** One {@link MindContext._depositTrees} entry — see that field's doc.
|
|
26
|
+
*
|
|
27
|
+
* A PURE WORK CACHE. It carries the already-folded content segments of a
|
|
28
|
+
* deposited stream so a longer stream sharing its byte prefix can skip
|
|
29
|
+
* refolding them. It holds no turn boundaries and no continuation proof
|
|
30
|
+
* because the deposit fold imposes nothing: reuse is bit-identical to a cold
|
|
31
|
+
* fold, so a hit can only save time, never change a tree. */
|
|
26
32
|
export interface DepositCacheEntry {
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
* whole-context length. Empty for a first-seen (single-turn) input. */
|
|
30
|
-
boundaries: number[];
|
|
31
|
-
/** Stable-prefix segment folds (grown-context inputs only). */
|
|
32
|
-
stable?: StableFold;
|
|
33
|
-
/** The continuation bytes this ctxInput was paired with in ingestPair, if
|
|
34
|
-
* any — the ONLY thing that makes a later, longer ctxInput a genuine next
|
|
35
|
-
* TURN of the same conversation rather than an unrelated fact that
|
|
36
|
-
* happens to share this one's byte prefix (e.g. "2+2" vs. "2+2=5"). A
|
|
37
|
-
* later deposit only takes this entry as its stable-prefix `prev` when
|
|
38
|
-
* its own suffix bytes-equal this exactly. */
|
|
39
|
-
nextBytes?: Uint8Array;
|
|
33
|
+
/** The plain content fold's reusable segment state. */
|
|
34
|
+
content: ContentFold;
|
|
40
35
|
}
|
|
41
36
|
import { bytesEqual, concatBytes, indexOf } from "../bytes.js";
|
|
42
37
|
import { dominates } from "../geometry.js";
|
|
@@ -336,9 +331,23 @@ export interface MindContext extends GraphSearchHost {
|
|
|
336
331
|
/** Subtree-resolution cache: Sema node → its store id and byte length.
|
|
337
332
|
* Populated by {@link foldTree} during inference; checked before
|
|
338
333
|
* walking children. When a conversation's pyramid reuses prefix
|
|
339
|
-
* subtrees, this cache
|
|
340
|
-
*
|
|
341
|
-
*
|
|
334
|
+
* subtrees, this cache names them without a store probe. It does NOT let
|
|
335
|
+
* {@link recognise} skip them: recognise walks with a `visit` callback and
|
|
336
|
+
* emits its sites from it, so a skipped descent would mean fewer sites on
|
|
337
|
+
* a warm cache than a cold one. foldTree short-circuits only for
|
|
338
|
+
* visitor-less walks (O(suffix) there); a visiting walk stays O(context)
|
|
339
|
+
* and banks the elided probes. Mind-lifetime (WeakMap keys are the Sema
|
|
340
|
+
* objects the pyramid keeps alive).
|
|
341
|
+
*
|
|
342
|
+
* THAT REUSE IS A PRECONDITION, NOT A GIVEN: the keys are node IDENTITIES,
|
|
343
|
+
* so it hits only while the conversation's fold hands back the SAME Sema
|
|
344
|
+
* objects for the unchanged prefix. `_growContext` rebuilt the whole tree
|
|
345
|
+
* with `bytesToTree` on every turn, so every key was fresh and this cache
|
|
346
|
+
* could not hit even once — the O(suffix) claim above described an
|
|
347
|
+
* intention rather than the code. It now grows the context through
|
|
348
|
+
* {@link stablePrefixFoldIncremental}, which reuses each already-folded
|
|
349
|
+
* segment: measured over four turns, turn 4 shared 69 of its 95 nodes with
|
|
350
|
+
* turn 3 (26 new ≈ the new turn's own size). */
|
|
342
351
|
_resolvedSubtrees: WeakMap<Sema, { id: number; len: number }> | null;
|
|
343
352
|
/** Completed assistant-turn byte spans in the current cumulative query.
|
|
344
353
|
* Empty for ordinary respond(); response-scoped structural context for
|
|
@@ -436,49 +445,100 @@ export function segRestatesQuery(
|
|
|
436
445
|
* (lo/hi) decision and the final concatenation: it is stale, not a second
|
|
437
446
|
* answer, but the OTHER spans a derivation chose are independent evidence
|
|
438
447
|
* and must not be discarded along with it. */
|
|
439
|
-
|
|
448
|
+
/** The spans {@link liftAnswer} actually concatenates, in order — the answer
|
|
449
|
+
* before it is joined. Exposed so a caller can ask what the lifted answer is
|
|
450
|
+
* MADE OF without re-deriving the selection: in particular how much of it is
|
|
451
|
+
* SCAFFOLDING (a `rec: false` span — query bytes carried through verbatim
|
|
452
|
+
* because nothing explained them, the same spans the liftAnswer trace labels
|
|
453
|
+
* "scaffolding" rather than "chosen").
|
|
454
|
+
*
|
|
455
|
+
* That quantity is load-bearing for the grounding decision. Two candidates
|
|
456
|
+
* can leave the SAME number of query bytes unaccounted and therefore grade
|
|
457
|
+
* identically, while one of them pads its answer with those bytes and the
|
|
458
|
+
* other does not — measured on test/22's two-fact chain, cover and recall
|
|
459
|
+
* both graded 11001 with 11 bytes unexplained, and cover won the tie only on
|
|
460
|
+
* consideration order, answering "The capital of France is Paris famous for"
|
|
461
|
+
* where recall had crossed the hop. Carrying an unexplained span into the
|
|
462
|
+
* answer is strictly weaker than not explaining it: it manufactures fluency
|
|
463
|
+
* out of the asker's own words. See the tie-break in pipeline.ts. */
|
|
464
|
+
export function liftAnswerParts(
|
|
440
465
|
segs: Seg[],
|
|
441
466
|
queryLen: number,
|
|
442
467
|
query: Uint8Array,
|
|
443
468
|
W: number,
|
|
444
|
-
):
|
|
469
|
+
): Seg[] {
|
|
445
470
|
const restated = segs.map((s) => segRestatesQuery(s, query, queryLen, W));
|
|
446
471
|
const recognised: number[] = [];
|
|
447
472
|
for (let k = 0; k < segs.length; k++) {
|
|
448
473
|
if (segs[k].rec && !restated[k]) recognised.push(k);
|
|
449
474
|
}
|
|
450
|
-
if (recognised.length === 0) return
|
|
475
|
+
if (recognised.length === 0) return [];
|
|
451
476
|
|
|
452
477
|
if (recognised.length === 1) {
|
|
453
478
|
const s = segs[recognised[0]];
|
|
454
|
-
|
|
455
|
-
// evidence of how much of the query's meaning it accounts for — the
|
|
456
|
-
// half-dominance check below (built for a genuinely RECOGNISED learned
|
|
457
|
-
// form) is not a valid framing signal for it (see the `computed` field
|
|
458
|
-
// doc on Seg/GItem): "1000 - 421" outweighs "what is …?" by width only
|
|
459
|
-
// because the operands are big, not because the framing matters less.
|
|
460
|
-
// A LITERAL PREFIX before a computed span is unambiguous framing
|
|
461
|
-
// regardless of width — an arithmetic expression is never itself
|
|
462
|
-
// preceded by more literal computed content, so anything literal before
|
|
463
|
-
// it is question wording ("what is ", "compute ") to lift clear of.
|
|
464
|
-
// With no prefix (s.i === 0) the span is judged by the ordinary
|
|
465
|
-
// half-dominance rule below, which already correctly keeps a short
|
|
466
|
-
// trailing glue byte ("2+2." → "4.", the span dominates a 4-byte query).
|
|
467
|
-
if (s.computed && s.i > 0) return s.bytes;
|
|
479
|
+
if (s.computed && s.i > 0) return [s];
|
|
468
480
|
if (dominates(s.j - s.i, queryLen)) {
|
|
469
|
-
return
|
|
470
|
-
segs.filter((_, k) => !restated[k]).map((x) => x.bytes),
|
|
471
|
-
);
|
|
481
|
+
return segs.filter((_, k) => !restated[k]);
|
|
472
482
|
}
|
|
473
|
-
return s
|
|
483
|
+
return [s];
|
|
474
484
|
}
|
|
475
485
|
const lo = recognised[0];
|
|
476
486
|
const hi = recognised[recognised.length - 1];
|
|
477
|
-
return
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
)
|
|
487
|
+
return segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The SCAFFOLDING byte count of a lifted answer: how many of its bytes come
|
|
491
|
+
* from spans nothing recognised (see {@link liftAnswerParts}).
|
|
492
|
+
*
|
|
493
|
+
* ONLY RUNS OF AT LEAST ONE RIVER WINDOW COUNT. Not all carried-through
|
|
494
|
+
* bytes are a failure to explain: a period, a question mark, the space
|
|
495
|
+
* between two fused topics are GLUE — they belong to the answer's surface,
|
|
496
|
+
* and dropping them to look better-derived would be a worse answer, not a
|
|
497
|
+
* more honest one. A substantive phrase the derivation never explained
|
|
498
|
+
* ("famous for") is a different claim entirely.
|
|
499
|
+
*
|
|
500
|
+
* W is the line between them, and it is the same line the rest of the mind
|
|
501
|
+
* already draws: below one river window byte overlap is chance, not evidence
|
|
502
|
+
* (see identityBar, the bridge's attestedQ, and recognition's site floor).
|
|
503
|
+
* Counting every scaffolding byte instead — which is what this did first —
|
|
504
|
+
* made punctuation preservation lose a tie it should win, and test/00's
|
|
505
|
+
* "period preserved" / "question mark preserved" caught it immediately. */
|
|
506
|
+
export function liftedScaffolding(
|
|
507
|
+
segs: Seg[],
|
|
508
|
+
queryLen: number,
|
|
509
|
+
query: Uint8Array,
|
|
510
|
+
W: number,
|
|
511
|
+
): number {
|
|
512
|
+
// MEASURED PER CONTIGUOUS RUN, not per span. A PASS span is one BYTE — the
|
|
513
|
+
// cover charges unrecognised bytes individually — so asking whether a single
|
|
514
|
+
// span reaches W would find no run ever, whatever the query. " famous for"
|
|
515
|
+
// arrives as eleven one-byte spans in a row and is one eleven-byte run.
|
|
516
|
+
let n = 0;
|
|
517
|
+
let run = 0;
|
|
518
|
+
const close = () => {
|
|
519
|
+
if (run >= W) n += run;
|
|
520
|
+
run = 0;
|
|
521
|
+
};
|
|
522
|
+
for (const s of liftAnswerParts(segs, queryLen, query, W)) {
|
|
523
|
+
if (s.rec) close();
|
|
524
|
+
else run += s.bytes.length;
|
|
525
|
+
}
|
|
526
|
+
close();
|
|
527
|
+
return n;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function liftAnswer(
|
|
531
|
+
segs: Seg[],
|
|
532
|
+
queryLen: number,
|
|
533
|
+
query: Uint8Array,
|
|
534
|
+
W: number,
|
|
535
|
+
): Uint8Array | null {
|
|
536
|
+
// ONE selection rule, in {@link liftAnswerParts} — this is its join. The
|
|
537
|
+
// two used to be separate copies of the same lo/hi/restated reasoning, which
|
|
538
|
+
// is exactly how an answer and the accounting OF that answer drift apart.
|
|
539
|
+
const parts = liftAnswerParts(segs, queryLen, query, W);
|
|
540
|
+
if (parts.length === 0) return null;
|
|
541
|
+
return concatBytes(parts.map((x) => x.bytes));
|
|
482
542
|
}
|
|
483
543
|
|
|
484
544
|
/** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
|
package/src/store.ts
CHANGED
|
@@ -325,6 +325,10 @@ export interface Store {
|
|
|
325
325
|
contentLen(id: NodeId, cap?: number): number;
|
|
326
326
|
findLeaf(bytes: Uint8Array): NodeId | null;
|
|
327
327
|
findBranch(kids: NodeId[]): NodeId | null;
|
|
328
|
+
/** {@link findBranch} for a run of single-byte leaves, addressed by the raw
|
|
329
|
+
* bytes — the allocation-free probe span scanners use. Optional: a store
|
|
330
|
+
* without it is simply probed through `findBranch`. */
|
|
331
|
+
findFlatBranch?(bytes: Uint8Array): NodeId | null;
|
|
328
332
|
/** The branch nodes that list `id` among their children — the reverse of
|
|
329
333
|
* `get(id).kids`. Lets the structural DAG be climbed upward, from a
|
|
330
334
|
* recognised fragment to the larger learned forms that contain it. */
|
|
@@ -1269,6 +1273,27 @@ export abstract class AbstractStore implements Store {
|
|
|
1269
1273
|
return id;
|
|
1270
1274
|
}
|
|
1271
1275
|
|
|
1276
|
+
/** {@link findBranch} for a run of SINGLE-BYTE leaves, addressed by the
|
|
1277
|
+
* bytes themselves — no kid array, no key string, no copy.
|
|
1278
|
+
*
|
|
1279
|
+
* A flat branch stores its children as {@link flatKidsBytes}, and that
|
|
1280
|
+
* encoding is the identity on single-byte leaves: kid id −(b+1) IS byte b.
|
|
1281
|
+
* So for such a run the kid array and the byte span are the same object in
|
|
1282
|
+
* two spellings, and `findBranch(leafIds.slice(i, j))` and this call are
|
|
1283
|
+
* the same lookup — except that the array path allocates the slice, then
|
|
1284
|
+
* `kids.join(",")`, then the flat bytes, all O(span), for a probe whose
|
|
1285
|
+
* answer is usually "no". The bloom filter behind `_dbFindBranchByLeaf`
|
|
1286
|
+
* answers most of those with no I/O at all, so the allocations dominated.
|
|
1287
|
+
*
|
|
1288
|
+
* Pass a subarray: it is a view, so a caller scanning spans of a query
|
|
1289
|
+
* allocates nothing per probe. Deliberately NOT memoized — its callers
|
|
1290
|
+
* probe many spans that miss, and a key string per probe is the cost this
|
|
1291
|
+
* exists to remove. */
|
|
1292
|
+
findFlatBranch(bytes: Uint8Array): NodeId | null {
|
|
1293
|
+
if (this.meter) this.meter.branchLookups++;
|
|
1294
|
+
return this._dbFindBranchByLeaf(hashOf(bytes), bytes);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1272
1297
|
findBranch(kids: NodeId[]): NodeId | null {
|
|
1273
1298
|
if (this.meter) this.meter.branchLookups++;
|
|
1274
1299
|
const key = kids.join(",");
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
// accumulated bytes at inference. The Conversation API tracks turn-boundary
|
|
19
19
|
// offsets explicitly so no separator character is needed — the geometry never
|
|
20
20
|
// inspects content to find turn boundaries.
|
|
21
|
+
//
|
|
22
|
+
// "NO SEPARATOR IS NEEDED" ≠ "A SEPARATOR IS A PROBLEM". This file joins its
|
|
23
|
+
// turns with nothing; example/train_base.ts joins its oasst2 turns with "\n".
|
|
24
|
+
// Both are correct, and neither is a convention the other has to match: a
|
|
25
|
+
// separator is CORPUS CONTENT, folded like any other byte, while a turn
|
|
26
|
+
// boundary is an OFFSET the API carries beside the bytes. A harness replaying
|
|
27
|
+
// a "\n"-joined corpus simply passes `"\n" + turnText` to addTurn and gets the
|
|
28
|
+
// trained byte stream back exactly. See Mind.addTurn's "ON SEPARATORS" note
|
|
29
|
+
// for the full statement — it exists because a review read the mismatch
|
|
30
|
+
// between this file's join and the trainer's as an architectural
|
|
31
|
+
// incompatibility, and it is not one. If you are comparing this harness to a
|
|
32
|
+
// corpus and getting poor recall, check that you are feeding the bytes that
|
|
33
|
+
// were actually trained before concluding anything about the engine.
|
|
21
34
|
// ─────────────────────────────────────────────────────────────────────────
|
|
22
35
|
|
|
23
36
|
import { test } from "node:test";
|
|
@@ -82,6 +82,71 @@ test("2. reversing the question reverses the fused answer", async () => {
|
|
|
82
82
|
await mind.store.close();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
test("2b. a topic is never ECHOED back instead of answered", async () => {
|
|
86
|
+
// The failure this pins: "What is the capital of France? And what is the
|
|
87
|
+
// largest planet?" answered "The capital of France is Paris.What is the
|
|
88
|
+
// largest planet?" — one topic answered, the other repeated verbatim.
|
|
89
|
+
//
|
|
90
|
+
// It hinged on CASE. The comparison schema seats a directly-aligned analog
|
|
91
|
+
// by its own bytes rather than chasing a forward edge, which is correct when
|
|
92
|
+
// those bytes are an answer (test/43 pins that) and an echo when they are
|
|
93
|
+
// the question the asker just asked. The guard against that is a restatement
|
|
94
|
+
// check, and a BYTE-EXACT one missed here: the trained node is "What is the
|
|
95
|
+
// largest planet?" while the query says "And what is the largest planet?" —
|
|
96
|
+
// the same words, one capital apart. The check now reads the response's own
|
|
97
|
+
// injected canon, so it sees what the rest of the mind sees.
|
|
98
|
+
//
|
|
99
|
+
// SCOPE: this asserts only that nothing is echoed. Whether BOTH topics get
|
|
100
|
+
// fused is a separate, corpus- and seed-dependent property of the consensus
|
|
101
|
+
// climb — at this file's seed the second point is sometimes not committed at
|
|
102
|
+
// all, which is why test 1 above guards its ordering assertion on both names
|
|
103
|
+
// being present. Answering one topic and staying silent about the other is a
|
|
104
|
+
// coverage limit; answering one and parroting the other is a defect.
|
|
105
|
+
//
|
|
106
|
+
// Asserted in BOTH orders because the echo appeared in only one: which topic
|
|
107
|
+
// got echoed depended on whether the climb landed on the question node or
|
|
108
|
+
// the answer node, so a single-order test passes while the bug is live.
|
|
109
|
+
// ITS OWN CORPUS, DELIBERATELY. The file's shared `trained()` fixture cannot
|
|
110
|
+
// reproduce this: with five same-frame facts the climb often commits only
|
|
111
|
+
// ONE point, so there is no second topic to echo and the test would pass
|
|
112
|
+
// against the unfixed code (verified — it did). The echo needs exactly two
|
|
113
|
+
// topics, each a bare question node whose answer hangs off a forward edge.
|
|
114
|
+
const mind = new Mind({
|
|
115
|
+
seed: 7,
|
|
116
|
+
store: new SQliteStore({ path: ":memory:" }),
|
|
117
|
+
});
|
|
118
|
+
await mind.ingest([
|
|
119
|
+
["What is the capital of France?", "The capital of France is Paris."],
|
|
120
|
+
["What is the largest planet?", "The largest planet is Jupiter."],
|
|
121
|
+
]);
|
|
122
|
+
for (
|
|
123
|
+
const q of [
|
|
124
|
+
"What is the capital of France? And what is the largest planet?",
|
|
125
|
+
"What is the largest planet? And what is the capital of France?",
|
|
126
|
+
]
|
|
127
|
+
) {
|
|
128
|
+
const a = await mind.respondText(q);
|
|
129
|
+
assert.ok(
|
|
130
|
+
!/And what is/i.test(a),
|
|
131
|
+
`the query was echoed rather than answered: ${JSON.stringify(a)} for ${
|
|
132
|
+
JSON.stringify(q)
|
|
133
|
+
}`,
|
|
134
|
+
);
|
|
135
|
+
assert.ok(
|
|
136
|
+
a.includes("Paris") && a.includes("Jupiter"),
|
|
137
|
+
`both topics must be ANSWERED, got ${JSON.stringify(a)} for ${
|
|
138
|
+
JSON.stringify(q)
|
|
139
|
+
}`,
|
|
140
|
+
);
|
|
141
|
+
// Nor may an answer be a bare restatement of one of the asked questions.
|
|
142
|
+
assert.ok(
|
|
143
|
+
!/^\s*What is the (largest planet|capital of France)\?\s*$/i.test(a),
|
|
144
|
+
`the answer is just the question restated: ${JSON.stringify(a)}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
await mind.store.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
85
150
|
test("3. a single-topic answer is unchanged by the ordering rule", async () => {
|
|
86
151
|
const mind = await trained();
|
|
87
152
|
assert.match(
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 66-query-edge-whitespace.test.mjs — a query's leading/trailing whitespace is
|
|
2
|
+
// presentation, not part of the question, and must not decide whether a trained
|
|
3
|
+
// fact is reachable.
|
|
4
|
+
//
|
|
5
|
+
// canon.ts's contract: "a span's leading or trailing separator belongs BETWEEN
|
|
6
|
+
// forms, not to the form". canon itself PRESERVES edge whitespace, and must,
|
|
7
|
+
// because the hazard it cites is a recognised SUB-span swallowing the boundary
|
|
8
|
+
// byte that separates it from its neighbour ("ice " matching the stored "ice").
|
|
9
|
+
// At the outer edges of a WHOLE input there is no neighbour, so that hazard
|
|
10
|
+
// cannot arise — which is why respond() may trim there and canon may not.
|
|
11
|
+
// test/44 already relies on the same reading for recognise()'s miss path.
|
|
12
|
+
//
|
|
13
|
+
// THE GAP THIS CLOSES (measured on the 15.7M-node trained store): ONE leading
|
|
14
|
+
// space took `Who wrote Romeo and Juliet?` and `What is the chemical symbol for
|
|
15
|
+
// water?` from answered to silent, because a shift re-seats every fold boundary
|
|
16
|
+
// (cos(query, query shifted 1 byte) = 0.68 against a 0.875 reach bar). That was
|
|
17
|
+
// the whole of analyze_training.ts's K2 phase-robustness gap: 15/18 → 18/18.
|
|
18
|
+
//
|
|
19
|
+
// WHY A RETRY AND NOT A PRE-FILTER — the regression this file pins. Trimming
|
|
20
|
+
// the query up front is ASYMMETRIC: it normalises the query but not the stored
|
|
21
|
+
// forms, so it breaks byte-exact identity for a form trained WITH edge
|
|
22
|
+
// whitespace. Verified: pre-filtering broke test/04's [" ice ", "cold"] case.
|
|
23
|
+
// The exact bytes are therefore tried FIRST and the trim is reached only when
|
|
24
|
+
// they grounded nothing — which also means the retry costs nothing on any
|
|
25
|
+
// answering path.
|
|
26
|
+
//
|
|
27
|
+
// NOTE ON WHAT IS AND IS NOT TESTABLE HERE. The padded-query WIN cannot be
|
|
28
|
+
// reproduced in a miniature fixture: a small store answers a padded query on the
|
|
29
|
+
// first pass anyway (an earlier tier catches it), so the retry never fires and
|
|
30
|
+
// an end-to-end assertion passes with or without the fix — verified, an earlier
|
|
31
|
+
// version of this file did exactly that and guarded nothing. What a fixture CAN
|
|
32
|
+
// pin is the trim's own contract and the asymmetry regression, which is what
|
|
33
|
+
// these tests do; the win itself is evidenced on the real store.
|
|
34
|
+
|
|
35
|
+
import { test } from "node:test";
|
|
36
|
+
import assert from "node:assert/strict";
|
|
37
|
+
import { Mind } from "../dist/src/index.js";
|
|
38
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
39
|
+
import { textEdgeTrim } from "../dist/src/canon.js";
|
|
40
|
+
|
|
41
|
+
const enc = (s) => new TextEncoder().encode(s);
|
|
42
|
+
const dec = new TextDecoder();
|
|
43
|
+
const trim = (s) => dec.decode(textEdgeTrim(enc(s)));
|
|
44
|
+
|
|
45
|
+
test("1. textEdgeTrim drops only the outer spacing run", () => {
|
|
46
|
+
assert.equal(trim(" ice "), "ice");
|
|
47
|
+
assert.equal(trim("\tice\n"), "ice");
|
|
48
|
+
assert.equal(trim("ice"), "ice");
|
|
49
|
+
// INTERIOR whitespace is content and is never touched.
|
|
50
|
+
assert.equal(trim(" a b "), "a b");
|
|
51
|
+
// All-separator and empty inputs collapse to empty rather than throwing.
|
|
52
|
+
assert.equal(trim(" "), "");
|
|
53
|
+
assert.equal(trim(""), "");
|
|
54
|
+
// The untouched case must return the SAME object (no copy on the hot path).
|
|
55
|
+
const b = enc("ice");
|
|
56
|
+
assert.equal(textEdgeTrim(b), b);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("2. a form trained WITH edge whitespace still answers when asked exactly", async () => {
|
|
60
|
+
// The asymmetry regression: trimming the query but not the store would make
|
|
61
|
+
// this query miss its own deposited form.
|
|
62
|
+
const m = new Mind({ seed: 7 });
|
|
63
|
+
await m.ingest([[" ice ", "cold"]]);
|
|
64
|
+
assert.equal(await m.respondText(" ice "), "cold");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("3. whitespace-only and empty queries are silent, not errors", async () => {
|
|
68
|
+
const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
|
|
69
|
+
await m.ingest([["what is ice?", "ice is frozen water"]]);
|
|
70
|
+
for (const q of ["", " ", " ", "\t\n"]) {
|
|
71
|
+
assert.equal(
|
|
72
|
+
await m.respondText(q),
|
|
73
|
+
"",
|
|
74
|
+
`expected silence for ${JSON.stringify(q)}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
await m.store.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("4. a padded query never answers something the unpadded one would not", async () => {
|
|
81
|
+
// The retry may add REACH, never licence: whatever padding does, it must not
|
|
82
|
+
// ground a fact for a question the store cannot answer.
|
|
83
|
+
const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
|
|
84
|
+
await m.ingest([
|
|
85
|
+
["what is the capital of France?", "The capital of France is Paris."],
|
|
86
|
+
["what is the capital of Spain?", "Madrid is the capital of Spain."],
|
|
87
|
+
]);
|
|
88
|
+
for (const q of [" Who wrote the Iliad? ", " xyzzy plugh quux "]) {
|
|
89
|
+
const a = await m.respondText(q);
|
|
90
|
+
assert.doesNotMatch(
|
|
91
|
+
a,
|
|
92
|
+
/Paris|Madrid/,
|
|
93
|
+
`padding manufactured an answer for ${JSON.stringify(q)}: ${
|
|
94
|
+
JSON.stringify(a)
|
|
95
|
+
}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
await m.store.close();
|
|
99
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// 67-climb-anchor-breadth.test.mjs — recall's scaffolding-dominated tier
|
|
2
|
+
// trusts a consensus-climb anchor on its SCALE-INVARIANT breadth as well as on
|
|
3
|
+
// its absolute IDF vote, and a breadth-qualified anchor must also be
|
|
4
|
+
// DISCRIMINATIVE.
|
|
5
|
+
//
|
|
6
|
+
// WHY THE ABSOLUTE VOTE IS NOT ENOUGH. Attention.breadth's own contract
|
|
7
|
+
// (types.ts) already says it: the IDF vote is "an absolute, ln(N)-scaled
|
|
8
|
+
// quantity that means 'strong' on a small store and 'weak' on a large one for
|
|
9
|
+
// the SAME degree of genuine consensus", while breadth is "the fraction of the
|
|
10
|
+
// query's OWN regions whose evidence this point accounts for" and "a point
|
|
11
|
+
// whose breadth clears `dominates` … is real consensus". Attention.peak's
|
|
12
|
+
// contract makes the same point from the other side: a floor that prices ONE
|
|
13
|
+
// region's evidence may not be compared against a POOLED SUM.
|
|
14
|
+
//
|
|
15
|
+
// Measured on the 15.7M-node trained store (N=325,615, floor = ln N + ½ =
|
|
16
|
+
// 13.19). The climb picked the RIGHT context and the floor discarded it, while
|
|
17
|
+
// a junk attractor for a query that must stay SILENT outvoted every correct
|
|
18
|
+
// anchor:
|
|
19
|
+
//
|
|
20
|
+
// anchor the climb picked vote breadth correct?
|
|
21
|
+
// "What is the chemical formula …" 10.60 0.556 RIGHT
|
|
22
|
+
// "Qual é a capital de França?" 8.19 0.667 RIGHT
|
|
23
|
+
// "Who wrote the play Romeo …?" 8.25 0.833 RIGHT
|
|
24
|
+
// "How do you say "good morning" …" 10.77 0.800 RIGHT
|
|
25
|
+
// "What is the commercial capital …" 12.69 0.333 Zamunda — MUST be silent
|
|
26
|
+
// "Menene sunan ginin mafi tsayi …" 12.79 0.214 wrong (Hausa)
|
|
27
|
+
//
|
|
28
|
+
// No vote threshold separates those; breadth > ½ separates them exactly. On
|
|
29
|
+
// that store the old floor was never cleared at all, so the tier was dead code
|
|
30
|
+
// and 12 probes fell through to silence.
|
|
31
|
+
//
|
|
32
|
+
// WHAT MUST NOT REGRESS, and why the gate is an OR of two guarded readings:
|
|
33
|
+
//
|
|
34
|
+
// • REPLACING the vote test with the breadth test broke 7 tests. On a small
|
|
35
|
+
// store ln(N) is low, so the vote bar is the reading that legitimately
|
|
36
|
+
// fires there; and Attention.clusters' contract warns that "breadth starves
|
|
37
|
+
// a genuine, evenly-split multi-topic query, since no root in a real N-way
|
|
38
|
+
// split can exceed half the vote" — the two-topic fusion tests are exactly
|
|
39
|
+
// that shape. Each reading is sufficient on its own evidence.
|
|
40
|
+
// • BREADTH ALONE fabricates. On a one-context store every region trivially
|
|
41
|
+
// corroborates the only anchor there is, so breadth is 1 while the anchor's
|
|
42
|
+
// IDF is 0 — test/31 A2 answered a lone cat fact for "explain quantum
|
|
43
|
+
// chromodynamics". Hence the companion condition: a region's IDF for an
|
|
44
|
+
// anchor reached through c of N contexts is ln(N/c), so requiring it past
|
|
45
|
+
// ln 2 requires c·2 < N — the same half-dominance reading in IDF units.
|
|
46
|
+
|
|
47
|
+
import { test } from "node:test";
|
|
48
|
+
import assert from "node:assert/strict";
|
|
49
|
+
import { Mind } from "../dist/src/index.js";
|
|
50
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
51
|
+
|
|
52
|
+
const mk = () =>
|
|
53
|
+
new Mind({ seed: 1, store: new SQliteStore({ path: ":memory:" }) });
|
|
54
|
+
|
|
55
|
+
test("1. a one-context store never grounds an unrelated query (breadth alone must not decide)", async () => {
|
|
56
|
+
// Breadth is trivially 1 when there is only one anchor to corroborate, but
|
|
57
|
+
// that anchor's IDF is 0 — it says nothing. VERIFIED to bite: dropping the
|
|
58
|
+
// `peak > ln 2` companion makes this fail (and test/31 A2 with it). The
|
|
59
|
+
// fixture matches A2's exactly — `new Mind({ seed: 7 })`, the default store —
|
|
60
|
+
// because the same shape over a SQliteStore did NOT reproduce it.
|
|
61
|
+
const m = new Mind({ seed: 7 });
|
|
62
|
+
await m.ingest([["what is a cat?", "a cat is a small feline"]]);
|
|
63
|
+
const r = await m.respond("explain quantum chromodynamics");
|
|
64
|
+
assert.equal(
|
|
65
|
+
r.v,
|
|
66
|
+
null,
|
|
67
|
+
"a lone low-IDF anchor must not ground a foreign query",
|
|
68
|
+
);
|
|
69
|
+
assert.equal(r.provenance, undefined);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("2. an evenly-split multi-topic query still fuses (breadth must not be required)", async () => {
|
|
73
|
+
// The shape Attention.clusters' contract says breadth starves: no root in a
|
|
74
|
+
// real N-way split can hold more than half the query's regions, so a
|
|
75
|
+
// breadth-only gate would refuse both topics.
|
|
76
|
+
const m = mk();
|
|
77
|
+
await m.ingest([
|
|
78
|
+
["ice", "cold"],
|
|
79
|
+
["fire", "hot"],
|
|
80
|
+
["what is ice?", "ice is frozen water"],
|
|
81
|
+
["what is fire?", "fire is rapid oxidation"],
|
|
82
|
+
]);
|
|
83
|
+
const a = await m.respondText("ice fire");
|
|
84
|
+
assert.ok(a.length > 0, "a two-topic query must still ground something");
|
|
85
|
+
await m.store.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("3. honest silence survives on an unrelated corpus", async () => {
|
|
89
|
+
const m = mk();
|
|
90
|
+
await m.ingest([
|
|
91
|
+
["what is the capital of France?", "The capital of France is Paris."],
|
|
92
|
+
["what is the capital of Spain?", "Madrid is the capital of Spain."],
|
|
93
|
+
["what is the capital of Italy?", "Rome is the capital of Italy."],
|
|
94
|
+
]);
|
|
95
|
+
for (const q of ["xyzzy plugh quux baz?", "qq8f3kz9 vv2m1x7w?"]) {
|
|
96
|
+
const a = await m.respondText(q);
|
|
97
|
+
assert.equal(a, "", `gibberish must stay silent, got ${JSON.stringify(a)}`);
|
|
98
|
+
}
|
|
99
|
+
await m.store.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("4. a trained fact still answers (the tier did not displace an earlier one)", async () => {
|
|
103
|
+
const m = mk();
|
|
104
|
+
await m.ingest([
|
|
105
|
+
["what is the capital of France?", "The capital of France is Paris."],
|
|
106
|
+
["what is the capital of Spain?", "Madrid is the capital of Spain."],
|
|
107
|
+
]);
|
|
108
|
+
assert.match(
|
|
109
|
+
await m.respondText("what is the capital of France?"),
|
|
110
|
+
/Paris/,
|
|
111
|
+
);
|
|
112
|
+
await m.store.close();
|
|
113
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 68-extraction-unanchored.test.mjs — an extraction that located NO frame of its
|
|
2
|
+
// exemplar in the query is not an extraction, and must not answer.
|
|
3
|
+
//
|
|
4
|
+
// extractBySkill's own contract says `accounted` carries "the located frames AND
|
|
5
|
+
// any read span BOUNDED by located frames on both sides", while an open-ended
|
|
6
|
+
// read "remains a guess about where the span stops — it stays unaccounted".
|
|
7
|
+
// EMPTY accounted is the degenerate case: no frame was located at all, so
|
|
8
|
+
// nothing ties the bytes just read to this question. isSpanShaped is
|
|
9
|
+
// deliberately permissive (a sparse-subsequence check) and will accept an
|
|
10
|
+
// exemplar whose relation to the query is coincidental gap-matching; requiring
|
|
11
|
+
// at least one located frame is the structural evidence it leaves out.
|
|
12
|
+
//
|
|
13
|
+
// THE CASE (analyze_training.ts F, the battery's ONLY wrong non-silent answer,
|
|
14
|
+
// on the 15.7M-node store): "Which city is France's seat of government?"
|
|
15
|
+
// answered "Which ci" — a fragment of the query itself — from the exemplar
|
|
16
|
+
// "What is dll", with accounted=[] and pieces=1. A/B verified: without the gate
|
|
17
|
+
// the answer is "Which ci"; with it, silence. The battery went from
|
|
18
|
+
// 31✓/1 weak/10 empty to 31✓/0 weak/11 empty, and `extract` left the provenance
|
|
19
|
+
// census entirely — no wrong answers remain anywhere in it.
|
|
20
|
+
//
|
|
21
|
+
// WHY THE GATE LIVES HERE AND NOT IN THE PIPELINE. The same test at the
|
|
22
|
+
// pipeline's post-grounding density check was tried and REVERTED: `accounted` is
|
|
23
|
+
// passed empty BY CONVENTION on recall's own tiers (recall.ts ground(…, [], …)),
|
|
24
|
+
// so a density veto there refused six legitimate reverse-recall groundings
|
|
25
|
+
// (seat symmetry, bidirectional chain, E9 turn parity, C1 reverse-recall …).
|
|
26
|
+
// Inside extraction the field is this mechanism's own output and carries its
|
|
27
|
+
// documented meaning, so the test is sound exactly where the convention cannot
|
|
28
|
+
// reach it.
|
|
29
|
+
//
|
|
30
|
+
// NOT REPRODUCIBLE IN A FIXTURE: a miniature corpus yields ANCHORED extractions
|
|
31
|
+
// (a frame IS located, accounted non-empty), which this gate correctly permits —
|
|
32
|
+
// verified across seeds 1/7/42. So what this file pins is the other side: the
|
|
33
|
+
// gate must not block a located-frame extraction. The wrong-answer fix itself is
|
|
34
|
+
// evidenced by the real-store A/B above.
|
|
35
|
+
|
|
36
|
+
import { test } from "node:test";
|
|
37
|
+
import assert from "node:assert/strict";
|
|
38
|
+
import { Mind } from "../dist/src/index.js";
|
|
39
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
40
|
+
|
|
41
|
+
/** Span-shaped exemplars: the answer is a subsequence of its context, so the
|
|
42
|
+
* learnt skill is "read the thing the frame wraps". */
|
|
43
|
+
const TRAIN = [
|
|
44
|
+
["What is dll?", "dll"],
|
|
45
|
+
["What is api?", "api"],
|
|
46
|
+
["What is ram?", "ram"],
|
|
47
|
+
["What is cpu?", "cpu"],
|
|
48
|
+
["What is gpu?", "gpu"],
|
|
49
|
+
["What is ssd?", "ssd"],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
test("1. an extraction whose frame IS located still answers (gate must not over-block)", async () => {
|
|
53
|
+
for (const seed of [1, 7, 42]) {
|
|
54
|
+
const m = new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
|
|
55
|
+
await m.ingest(TRAIN);
|
|
56
|
+
const a = await m.respondText("Which colour is the deepest ocean?");
|
|
57
|
+
assert.ok(
|
|
58
|
+
a.length > 0,
|
|
59
|
+
`seed ${seed}: a located-frame extraction must survive the unanchored gate`,
|
|
60
|
+
);
|
|
61
|
+
await m.store.close();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("2. the learnt skill still reads its own trained frame", async () => {
|
|
66
|
+
const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
|
|
67
|
+
await m.ingest(TRAIN);
|
|
68
|
+
assert.match(await m.respondText("What is dll?"), /dll/);
|
|
69
|
+
await m.store.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("3. gibberish stays silent — the gate adds refusal, never licence", async () => {
|
|
73
|
+
const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
|
|
74
|
+
await m.ingest(TRAIN);
|
|
75
|
+
for (const q of ["qq8f3kz9 vv2m1x7w?", "xyzzy plugh quux baz?"]) {
|
|
76
|
+
assert.equal(await m.respondText(q), "", `expected silence for ${q}`);
|
|
77
|
+
}
|
|
78
|
+
await m.store.close();
|
|
79
|
+
});
|