@hviana/sema 0.6.0 → 0.7.2
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/.github/workflows/release.yml +80 -0
- package/AGENTS.md +53 -9
- package/HOW_IT_WORKS.md +17 -16
- package/dist/src/meter.d.ts +14 -4
- package/dist/src/meter.js +27 -3
- package/dist/src/mind/attention.js +22 -20
- package/dist/src/mind/graph-search.d.ts +43 -9
- package/dist/src/mind/graph-search.js +82 -15
- package/dist/src/mind/junction.d.ts +13 -0
- package/dist/src/mind/junction.js +26 -1
- package/dist/src/mind/mechanisms/cover.js +23 -2
- package/dist/src/mind/mechanisms/prefix-completion.d.ts +2 -1
- package/dist/src/mind/mechanisms/prefix-completion.js +40 -20
- package/dist/src/mind/mechanisms/recall.js +8 -4
- package/dist/src/mind/pipeline-mechanism.d.ts +0 -24
- package/dist/src/mind/pipeline-mechanism.js +13 -36
- package/dist/src/mind/pipeline.d.ts +24 -0
- package/dist/src/mind/pipeline.js +71 -5
- package/dist/src/mind/recognition.d.ts +15 -1
- package/dist/src/mind/recognition.js +15 -1
- package/dist/src/mind/resonance.js +54 -12
- package/dist/src/store.js +22 -1
- package/jsr.json +1 -1
- package/package.json +7 -2
- package/src/meter.ts +27 -4
- package/src/mind/attention.ts +22 -19
- package/src/mind/graph-search.ts +93 -16
- package/src/mind/junction.ts +25 -1
- package/src/mind/mechanisms/cover.ts +23 -4
- package/src/mind/mechanisms/prefix-completion.ts +40 -20
- package/src/mind/mechanisms/recall.ts +8 -4
- package/src/mind/pipeline-mechanism.ts +13 -42
- package/src/mind/pipeline.ts +106 -5
- package/src/mind/recognition.ts +19 -2
- package/src/mind/resonance.ts +84 -49
- package/src/store.ts +21 -1
- package/test/89-completion-recursion.test.mjs +230 -0
- package/test/90-connector-read-cap.test.mjs +130 -0
- package/test/91-branch-bytes-cache.test.mjs +152 -0
- package/test/93-regime-prediction.test.mjs +148 -0
- package/test/94-cross-region-budget.test.mjs +67 -0
- package/test/95-wide-resonance-removed.test.mjs +109 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// 89-completion-recursion.test.mjs — the completion recursion must be
|
|
2
|
+
// OUTPUT-SENSITIVE.
|
|
3
|
+
//
|
|
4
|
+
// AGENTS §2.8: "No per-query read may grow with the corpus." §2.8 enforces
|
|
5
|
+
// that per READ (nextFirst, bytesPrefix, …), and every one of those caps holds.
|
|
6
|
+
// What no guard covered is the NUMBER of reads: `recompleteNode`
|
|
7
|
+
// (src/mind/graph-search.ts) re-covers a produced node by calling `solve`
|
|
8
|
+
// recursively, and each nested solve builds its own agenda and chart. Its own
|
|
9
|
+
// doc states the intent —
|
|
10
|
+
//
|
|
11
|
+
// "its cost tracks the ANSWER's own structure, not how densely the corpus
|
|
12
|
+
// interconnects the nodes passed through"
|
|
13
|
+
//
|
|
14
|
+
// — but argues termination from "Distinct node ids are finite and each finished
|
|
15
|
+
// completion is memoised". Finite-in-the-corpus is exactly the bound §2.8
|
|
16
|
+
// forbids, and the recursion is emitted at `cost: 0` while the nested cover's
|
|
17
|
+
// own `cost` is computed and discarded, so A* has no gradient against depth.
|
|
18
|
+
//
|
|
19
|
+
// MEASURED on a trained store (18,938,834 nodes, edgeSourceCount 796,528):
|
|
20
|
+
// `respond("Hi")` reached recursion depth 331 and 9.1 GB RSS in 56 s without
|
|
21
|
+
// terminating. Every level was a "Hi…" opener — a 2-byte hub re-entering
|
|
22
|
+
// itself — and the descent visited whole utterances unrelated to the answer
|
|
23
|
+
// ("Who's their goalkeeper?", "Glad I could assist, have a great day."). That
|
|
24
|
+
// is what killed a 5 h training run at a checkpoint recall: the 15 s
|
|
25
|
+
// withTimeout around it is a setTimeout, and a synchronous search never yields
|
|
26
|
+
// to the timer phase, so it cannot fire.
|
|
27
|
+
//
|
|
28
|
+
// WHY THE EXISTING GUARD MISSES IT. 14-scaling.test.mjs asserts this same law
|
|
29
|
+
// ("inference: cost is sublinear in corpus size"), but builds each size point
|
|
30
|
+
// from a DISJOINT salted corpus and queries it with `unknownInput()`, whose
|
|
31
|
+
// "tokens are substrings of no learned form". A query that recognises nothing
|
|
32
|
+
// never enters the graph, so it never reaches the fixpoint that gates the
|
|
33
|
+
// recursion. Both assumptions are load-bearing; this file drops them.
|
|
34
|
+
//
|
|
35
|
+
// THE CORPUS. Real English fragments taken from the repo's own *.md prose and
|
|
36
|
+
// recombined, plus the query deposited as a standalone context with several
|
|
37
|
+
// continuations (what makes a greeting a hub in real dialogue). Eight
|
|
38
|
+
// hand-written generators failed to reproduce this — chains stop after two
|
|
39
|
+
// rungs, dense graphs never reach a fixpoint, and a query the pipeline declines
|
|
40
|
+
// never reaches `cover` at all. Real prose plus a deposited hub does it.
|
|
41
|
+
// Nothing is added to the tree: the corpus is the documentation already here.
|
|
42
|
+
//
|
|
43
|
+
// MEASURED HERE, on the deterministic public counters (mind.lastCost), answer
|
|
44
|
+
// byte-identical at every size:
|
|
45
|
+
//
|
|
46
|
+
// pairs searches pops maxDepth
|
|
47
|
+
// 1508 126 55,208 71
|
|
48
|
+
// 2008 196 88,241 127
|
|
49
|
+
// 3008 309 146,282 207
|
|
50
|
+
// 4008 416 201,174 270 (22 s for a 3-byte query)
|
|
51
|
+
//
|
|
52
|
+
// growth exponent k ≈ 1.25 (searches), 1.32 (pops) — SUPER-linear.
|
|
53
|
+
//
|
|
54
|
+
// With the recursion bounded, the same corpus gives searches 10→13 and pops
|
|
55
|
+
// 3,946→5,826 (k ≈ 0.27 / 0.40) in 0.31 s→0.76 s, and the answer does not
|
|
56
|
+
// change. So this file's thresholds are achievable, and the fix costs nothing
|
|
57
|
+
// in output on this corpus.
|
|
58
|
+
|
|
59
|
+
import { test } from "node:test";
|
|
60
|
+
import assert from "node:assert/strict";
|
|
61
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
62
|
+
import { dirname, join } from "node:path";
|
|
63
|
+
import { fileURLToPath } from "node:url";
|
|
64
|
+
import { Mind } from "../dist/src/index.js";
|
|
65
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
66
|
+
|
|
67
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
68
|
+
|
|
69
|
+
// ── corpus ────────────────────────────────────────────────────────────────
|
|
70
|
+
// Deterministic scrambler: the suite forbids Math.random in fixtures, and the
|
|
71
|
+
// whole point of the counters is that two runs are diffable.
|
|
72
|
+
const mix = (x) => {
|
|
73
|
+
x = (x ^ 61) ^ (x >>> 16);
|
|
74
|
+
x = x + (x << 3);
|
|
75
|
+
x = x ^ (x >>> 4);
|
|
76
|
+
x = Math.imul(x, 0x27d4eb2d);
|
|
77
|
+
return (x ^ (x >>> 15)) >>> 0;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Four-word windows of the repo's own English prose. Code fences, inline
|
|
81
|
+
* code and link targets are stripped so what is left is language, which is
|
|
82
|
+
* where the fragment overlap lives. */
|
|
83
|
+
function fragments() {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const f of readdirSync(REPO).filter((f) => f.endsWith(".md")).sort()) {
|
|
86
|
+
let t = readFileSync(join(REPO, f), "utf8");
|
|
87
|
+
t = t.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
|
|
88
|
+
t = t.replace(/\[[^\]]*\]\([^)]*\)/g, " ");
|
|
89
|
+
t = t.toLowerCase().replace(/[^a-z ]+/g, " ").replace(/\s+/g, " ");
|
|
90
|
+
const w = t.split(" ").filter(Boolean);
|
|
91
|
+
for (let i = 0; i + 4 < w.length; i += 2) {
|
|
92
|
+
out.push(w.slice(i, i + 4).join(" "));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const FRAG = fragments();
|
|
99
|
+
const QUERY = "hi.";
|
|
100
|
+
|
|
101
|
+
const utter = (i) =>
|
|
102
|
+
[
|
|
103
|
+
FRAG[mix(i * 3 + 1) % FRAG.length],
|
|
104
|
+
FRAG[mix(i * 5 + 2) % FRAG.length],
|
|
105
|
+
FRAG[mix(i * 7 + 3) % FRAG.length],
|
|
106
|
+
].join(" ");
|
|
107
|
+
|
|
108
|
+
/** n recombined utterance pairs, plus the query as a standalone context with
|
|
109
|
+
* eight distinct continuations — the hub. Every pair's answer is itself a
|
|
110
|
+
* context (multi-turn), so a completed form has somewhere to continue. */
|
|
111
|
+
function corpus(n) {
|
|
112
|
+
const pairs = [];
|
|
113
|
+
for (let k = 0; k < 8; k++) pairs.push([QUERY, utter(k * 101 + 7)]);
|
|
114
|
+
for (let i = 0; i < n; i++) {
|
|
115
|
+
pairs.push([utter(i), utter(i * 3 + 1)]);
|
|
116
|
+
pairs.push([utter(i * 3 + 1), utter(i * 7 + 2)]);
|
|
117
|
+
}
|
|
118
|
+
return pairs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Power-law exponent k in work ≈ c·n^k, by log–log least squares — the same
|
|
122
|
+
* statistic and the same k < 0.6 bar 14-scaling.test.mjs uses. */
|
|
123
|
+
function logLogSlope(sizes, ys) {
|
|
124
|
+
const n = sizes.length;
|
|
125
|
+
const xs = sizes.map(Math.log), ly = ys.map((v) => Math.log(Math.max(v, 1)));
|
|
126
|
+
const mx = xs.reduce((a, b) => a + b, 0) / n;
|
|
127
|
+
const my = ly.reduce((a, b) => a + b, 0) / n;
|
|
128
|
+
let num = 0, den = 0;
|
|
129
|
+
for (let i = 0; i < n; i++) {
|
|
130
|
+
num += (xs[i] - mx) * (ly[i] - my);
|
|
131
|
+
den += (xs[i] - mx) ** 2;
|
|
132
|
+
}
|
|
133
|
+
return num / den;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `corpus(n)` emits two pairs per turn plus the eight hub pairs, so these are
|
|
137
|
+
// the 1508 / 2008 / 3008-pair points of the table above.
|
|
138
|
+
const SIZES = [750, 1000, 1500];
|
|
139
|
+
|
|
140
|
+
test("completion recursion: per-query work does not grow with the corpus", async () => {
|
|
141
|
+
assert.ok(
|
|
142
|
+
FRAG.length > 4000,
|
|
143
|
+
`only ${FRAG.length} prose fragments found in ${REPO}/*.md — this test ` +
|
|
144
|
+
`draws its corpus from the repo's own documentation; with the prose gone ` +
|
|
145
|
+
`it can no longer exercise the completion recursion at all`,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const searches = [], pops = [], answers = [], secs = [];
|
|
149
|
+
|
|
150
|
+
for (const n of SIZES) {
|
|
151
|
+
const store = new SQliteStore({ path: ":memory:", D: 1024 });
|
|
152
|
+
const mind = new Mind({ seed: 7, store, profile: true });
|
|
153
|
+
await mind.ingest(corpus(n));
|
|
154
|
+
|
|
155
|
+
const t0 = performance.now();
|
|
156
|
+
const answer = String(await mind.respondText(QUERY));
|
|
157
|
+
secs.push((performance.now() - t0) / 1000);
|
|
158
|
+
|
|
159
|
+
const c = mind.lastCost.counters;
|
|
160
|
+
searches.push(c.searches ?? 0);
|
|
161
|
+
pops.push(c.searchPops ?? 0);
|
|
162
|
+
answers.push(answer);
|
|
163
|
+
await store.close();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log(" completion recursion vs corpus size (fixed 3-byte query):");
|
|
167
|
+
SIZES.forEach((n, i) =>
|
|
168
|
+
console.log(
|
|
169
|
+
` pairs=${String(n * 2 + 8).padStart(5)} searches=${
|
|
170
|
+
String(searches[i]).padStart(5)
|
|
171
|
+
} pops=${String(pops[i]).padStart(8)} ${secs[i].toFixed(2)}s`,
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// NON-VACUITY. If the corpus stopped reaching the recursion, every counter
|
|
176
|
+
// would be flat at zero and the growth assertions below would pass while
|
|
177
|
+
// proving nothing. A test that can go green by not exercising the code is
|
|
178
|
+
// worse than no test, so this fails loudly instead.
|
|
179
|
+
assert.ok(
|
|
180
|
+
searches.every((s) => s > 0),
|
|
181
|
+
`the graph search never ran (searches=${JSON.stringify(searches)}) — the ` +
|
|
182
|
+
`corpus no longer engages cover(), so this file is not testing anything`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// NO OUTPUT CONFOUND. Work is allowed to grow with the ANSWER. Pinning the
|
|
186
|
+
// answer byte-for-byte across every size removes that defence entirely: any
|
|
187
|
+
// growth measured below bought exactly nothing.
|
|
188
|
+
assert.ok(
|
|
189
|
+
answers.every((a) => a === answers[0]),
|
|
190
|
+
`the answer changed across corpus sizes (${
|
|
191
|
+
JSON.stringify(answers.map((a) => a.slice(0, 40)))
|
|
192
|
+
}) — with the output moving, work growth is no longer attributable to the ` +
|
|
193
|
+
`corpus alone`,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const kSearches = logLogSlope(SIZES, searches);
|
|
197
|
+
const kPops = logLogSlope(SIZES, pops);
|
|
198
|
+
console.log(
|
|
199
|
+
` growth exponent k ≈ ${kSearches.toFixed(2)} (nested searches), ${
|
|
200
|
+
kPops.toFixed(2)
|
|
201
|
+
} (agenda pops) — target ≪ 1 (sublinear in the corpus)`,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// THE LAW. Same answer, more corpus, so cost must not move. k ≈ 0 is flat,
|
|
205
|
+
// k ≈ 1 is linear in the corpus — the bound §2.8 forbids outright.
|
|
206
|
+
//
|
|
207
|
+
// `searches` counts nested solve() calls, which is the recursion itself and
|
|
208
|
+
// nothing else, so it gets 14-scaling.test.mjs's stricter 0.6 bar. Measured
|
|
209
|
+
// 1.28 unfixed, 0.38 fixed.
|
|
210
|
+
assert.ok(
|
|
211
|
+
kSearches < 0.6,
|
|
212
|
+
`nested searches grew with exponent k=${kSearches.toFixed(2)} in corpus ` +
|
|
213
|
+
`size (${searches.join(" → ")}) for a byte-identical answer — each ` +
|
|
214
|
+
`nested solve() builds its own agenda and chart, so this is the ` +
|
|
215
|
+
`completion recursion doing work the answer never asked for`,
|
|
216
|
+
);
|
|
217
|
+
// A LOOSER BAR, FOR A REASON. `searchPops` aggregates the TOP-LEVEL cover's
|
|
218
|
+
// agenda too, and that one legitimately carries some corpus sensitivity: a
|
|
219
|
+
// bigger store recognises more sites inside the same query, so more items are
|
|
220
|
+
// admissible. Only outright linear growth is the forbidden case (§2.8), so
|
|
221
|
+
// this asserts the law itself, k < 1, rather than the stricter 0.6 that suits
|
|
222
|
+
// a counter the fix governs end to end. Measured 1.40 unfixed, 0.54 fixed.
|
|
223
|
+
assert.ok(
|
|
224
|
+
kPops < 1,
|
|
225
|
+
`agenda pops grew with exponent k=${kPops.toFixed(2)} in corpus size (${
|
|
226
|
+
pops.join(" → ")
|
|
227
|
+
}) for a byte-identical answer — k≈1 is work LINEAR in the corpus, which ` +
|
|
228
|
+
`is the bound §2.8 forbids outright`,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// 90-connector-read-cap.test.mjs — the "already answered" probe must read by
|
|
2
|
+
// the QUERY, not by the corpus.
|
|
3
|
+
//
|
|
4
|
+
// `resolveConnectors` (src/mind/mechanisms/cover.ts) drops a site whose
|
|
5
|
+
// continuation already appears elsewhere in the query — stale transcript
|
|
6
|
+
// evidence, whose bridges would only be discarded later. The test is a
|
|
7
|
+
// substring search, so it needs the candidate's bytes; it used to reconstruct
|
|
8
|
+
// them in FULL via `read(ctx, answer)`, whose maxLen defaults to ALL.
|
|
9
|
+
//
|
|
10
|
+
// AGENTS §2.8, prefix-capped reads: "a candidate that exceeds the cap is
|
|
11
|
+
// rejected without reconstructing it — the weave, the junction walks and the
|
|
12
|
+
// bridge all read this way, and uncapped reads there cost seconds per query on
|
|
13
|
+
// a large store." This probe was the exception, and it runs hubBound(ctx) = √N
|
|
14
|
+
// times PER SITE.
|
|
15
|
+
//
|
|
16
|
+
// The probe's corpus-scale cost was once claimed from a trained-store
|
|
17
|
+
// measurement — "88,581 byte reconstructions / 20.5 MB for one 1,314-byte
|
|
18
|
+
// prompt" — but that number was measured on a `respond()` query, where the
|
|
19
|
+
// probe does NOT execute (`answeredSpans` is empty there, so the enclosing
|
|
20
|
+
// guard returns first). It is therefore not attributable to the probe and is
|
|
21
|
+
// not repeated here (§2.16: a comment asserting a measurement inherits Gate 1).
|
|
22
|
+
// The probe runs only on a multi-turn `respondTurn` response; its benefit there
|
|
23
|
+
// is still unmeasured.
|
|
24
|
+
//
|
|
25
|
+
// WHAT THIS PINS. The cap cannot reduce the read COUNT — only a semantic change
|
|
26
|
+
// could (see below). It bounds each read by the QUERY, which is what §2.8 asks
|
|
27
|
+
// and what rescues a SHORT query: candidates averaged 231 B reconstructed
|
|
28
|
+
// against a 3-byte prompt. So the invariant here is per-read SIZE.
|
|
29
|
+
//
|
|
30
|
+
// It is measured by calling `resolveConnectors` DIRECTLY and diffing the meter
|
|
31
|
+
// across it. A whole-response counter cannot express this: `bytesRead` sums
|
|
32
|
+
// every reader in the pipeline, and the answer itself is a long continuation
|
|
33
|
+
// that is legitimately read in full — an earlier draft of this file asserted on
|
|
34
|
+
// the response total and failed even with the fix applied, for that reason.
|
|
35
|
+
//
|
|
36
|
+
// NOT fixed here, deliberately: the READ COUNT is still O(sites × √N). Removing
|
|
37
|
+
// it means replacing the byte-substring test with membership in the query's
|
|
38
|
+
// already-computed recognised sites — an O(1) id-set test. That is NOT
|
|
39
|
+
// equivalent: recognition is a "longest-known-leaf re-segmentation"
|
|
40
|
+
// (src/mind/recognition.ts), so it does not enumerate every learnt form; the set
|
|
41
|
+
// test would filter fewer sites, change which connectors exist, and can change
|
|
42
|
+
// answers. A semantic decision, not a performance one.
|
|
43
|
+
|
|
44
|
+
import { test } from "node:test";
|
|
45
|
+
import assert from "node:assert/strict";
|
|
46
|
+
import { Mind } from "../dist/src/index.js";
|
|
47
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
48
|
+
import { resolveConnectors } from "../dist/src/mind/mechanisms/cover.js";
|
|
49
|
+
|
|
50
|
+
/** Learnt CONTINUATIONS far longer than the query that will be asked — the
|
|
51
|
+
* shape the cap governs: an uncapped probe reconstructs each one in full
|
|
52
|
+
* merely to discover it cannot fit inside a short query. */
|
|
53
|
+
const LONG = (i) =>
|
|
54
|
+
`answer ${i} this is a deliberately long learnt continuation whose bytes go ` +
|
|
55
|
+
`on well past anything the short query could contain, clause after clause, ` +
|
|
56
|
+
`so that reconstructing it in full is plainly more work than the query ` +
|
|
57
|
+
`justifies, and it keeps going for a while yet in variant ${i}`;
|
|
58
|
+
|
|
59
|
+
// ONE context with MANY learnt continuations, so `nextFirst(site, hubBound)`
|
|
60
|
+
// returns a wide fan and the probe does real work on a single site.
|
|
61
|
+
function corpus(n) {
|
|
62
|
+
const pairs = [];
|
|
63
|
+
for (let i = 0; i < n; i++) {
|
|
64
|
+
pairs.push([`ask topic`, LONG(i)]);
|
|
65
|
+
pairs.push([LONG(i), `ask topic`]);
|
|
66
|
+
}
|
|
67
|
+
return pairs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("connector probe reads by the query, not by the learnt continuation", async () => {
|
|
71
|
+
const store = new SQliteStore({ path: ":memory:", D: 256 });
|
|
72
|
+
const mind = new Mind({ seed: 7, store, profile: true });
|
|
73
|
+
await mind.ingest(corpus(300));
|
|
74
|
+
|
|
75
|
+
const QUERY = "ask topic";
|
|
76
|
+
const bytes = new TextEncoder().encode(QUERY);
|
|
77
|
+
|
|
78
|
+
// beginResponse builds the per-response memos AND the meter this reads.
|
|
79
|
+
mind.beginResponse();
|
|
80
|
+
try {
|
|
81
|
+
const { sites } = mind.recogniseSpan(bytes);
|
|
82
|
+
// EXACT ISOLATION. resolveConnectors also reads bytes the probe has
|
|
83
|
+
// nothing to do with (the n-ary bridge reads each ordered node in full, and
|
|
84
|
+
// legitimately so). The probe itself early-returns when answeredSpans is
|
|
85
|
+
// empty, so running the SAME call both ways and differencing leaves exactly
|
|
86
|
+
// the probe's own reads and nothing else.
|
|
87
|
+
const m = mind.meter;
|
|
88
|
+
mind.answeredSpans = [];
|
|
89
|
+
const a0 = m.byteReads, b0 = m.bytesRead;
|
|
90
|
+
await resolveConnectors(mind, sites, bytes);
|
|
91
|
+
const baseReads = m.byteReads - a0, baseBytes = m.bytesRead - b0;
|
|
92
|
+
|
|
93
|
+
mind.answeredSpans = [[0, 1]];
|
|
94
|
+
const a1 = m.byteReads, b1 = m.bytesRead;
|
|
95
|
+
await resolveConnectors(mind, sites, bytes);
|
|
96
|
+
const reads = (m.byteReads - a1) - baseReads;
|
|
97
|
+
const read = (m.bytesRead - b1) - baseBytes;
|
|
98
|
+
|
|
99
|
+
console.log(
|
|
100
|
+
` ${sites.length} sites, query ${QUERY.length} B → ` +
|
|
101
|
+
`byteReads=${reads} bytesRead=${read} ` +
|
|
102
|
+
`(${(read / Math.max(reads, 1)).toFixed(0)} B per read)`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// NON-VACUITY: the probe must actually have read something, or the bound
|
|
106
|
+
// below is trivially true and proves nothing.
|
|
107
|
+
assert.ok(
|
|
108
|
+
reads > 0,
|
|
109
|
+
`resolveConnectors made no byte reads — the probe never ran (sites=${sites.length}), ` +
|
|
110
|
+
`so this file is not testing anything`,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// THE BOUND. Every read here tests a candidate for containment in the
|
|
114
|
+
// query, so none can need more than the query's own length plus the single
|
|
115
|
+
// overflow byte that makes the test exact. Continuations here are ~230 B
|
|
116
|
+
// against an 11 B query, so an uncapped read cannot satisfy this and a
|
|
117
|
+
// capped one cannot violate it.
|
|
118
|
+
const perRead = read / Math.max(reads, 1);
|
|
119
|
+
assert.ok(
|
|
120
|
+
perRead <= QUERY.length + 1,
|
|
121
|
+
`the connector probe averaged ${perRead.toFixed(0)} B per read for a ` +
|
|
122
|
+
`${QUERY.length} B query — a candidate longer than the query cannot ` +
|
|
123
|
+
`occur inside it, so it must be rejected on an overflow probe of ` +
|
|
124
|
+
`${QUERY.length + 1} B, not reconstructed in full (AGENTS §2.8)`,
|
|
125
|
+
);
|
|
126
|
+
} finally {
|
|
127
|
+
mind.endResponse();
|
|
128
|
+
}
|
|
129
|
+
await mind.store.close();
|
|
130
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// 91-branch-bytes-cache.test.mjs — reconstructing a node twice must not
|
|
2
|
+
// re-walk it.
|
|
3
|
+
//
|
|
4
|
+
// `bytesPrefix` rebuilds a node's bytes by descending the DAG, one `store.get`
|
|
5
|
+
// per node visited. `_prefix` consults `_bytesCache` for EVERY id but used to
|
|
6
|
+
// populate it only for leaves, so a BRANCH re-walked its entire subtree on every
|
|
7
|
+
// request — and because the DAG is hash-consed, the same children recur under
|
|
8
|
+
// many different parents.
|
|
9
|
+
//
|
|
10
|
+
// MEASURED on the trained store (18,938,834 nodes), ONE 1,314-byte query:
|
|
11
|
+
// _prefix calls (all levels) : 20,021,474
|
|
12
|
+
// distinct ids : 469,083 → 42.7x reuse
|
|
13
|
+
// avoidable by a cache : 19,552,391 → 97.7%
|
|
14
|
+
// top-level calls : 87,789 over 76,849 distinct (1.1x)
|
|
15
|
+
// hottest id : a single-byte leaf, 2,599,984 reconstructions
|
|
16
|
+
//
|
|
17
|
+
// The 1.1x at the top level is why this hid for so long: measured there, reuse
|
|
18
|
+
// looks absent and a cache looks worthless. All of the reuse is one level down.
|
|
19
|
+
//
|
|
20
|
+
// `_bytesCache` was already the right home — a byte-accounted BoundedMap with
|
|
21
|
+
// "smallest"/"clock" eviction, the configuration this codebase reserves for a
|
|
22
|
+
// TRANSPARENT cache (evicting costs a re-read and nothing else). Reconstruction
|
|
23
|
+
// is a pure function of the store, so it qualifies; only the population was
|
|
24
|
+
// missing.
|
|
25
|
+
//
|
|
26
|
+
// WHAT THIS FILE ACTUALLY GUARDS — read this before trusting it.
|
|
27
|
+
//
|
|
28
|
+
// The re-walk assertion routes through `_prefix` (a CAPPED read), because that
|
|
29
|
+
// is where the fix lives. `bytesPrefix(id, ALL)` short-circuits to `bytes()`
|
|
30
|
+
// (store.ts `bytesPrefix`: `maxLen >= 0x7fffffff → this.bytes(id)`), and
|
|
31
|
+
// `bytes()` has its OWN, pre-existing branch cache — so a re-walk assertion
|
|
32
|
+
// built on the ALL sentinel stays green when the `_prefix` fix is reverted and
|
|
33
|
+
// guards nothing (this file once did exactly that, and its header claimed the
|
|
34
|
+
// cause was "the node carries `flat` bytes", which is false: `flat` is
|
|
35
|
+
// STRUCTURAL — a branch stores its bytes flat iff every kid is an implicit
|
|
36
|
+
// single-byte leaf (store.ts `flatKidsBytes`) — and the fixture's node is
|
|
37
|
+
// non-flat, kids of real chunk nodes. There is no size threshold that drops
|
|
38
|
+
// `flat`).
|
|
39
|
+
//
|
|
40
|
+
// With the fix, a capped read that completes the node caches the BRANCH
|
|
41
|
+
// (`got < maxLen` guard), so a second capped read is a full-cache hit and costs
|
|
42
|
+
// 0 node reads; with the fix reverted it re-reads the root (1 node read) because
|
|
43
|
+
// only the LEAVES are cached. The signal is small at fixture scale precisely
|
|
44
|
+
// because the fixture's tree is shallow (root → flat chunks → leaves); the real
|
|
45
|
+
// 5.4–7.7× nodeRecords reduction is verified on the trained store instead.
|
|
46
|
+
//
|
|
47
|
+
// The TRUNCATION assertion at the end IS a real guard, verified red: with the
|
|
48
|
+
// `got < maxLen` condition removed, it fails with "a capped read poisoned the
|
|
49
|
+
// cache: the full read came back 29 bytes instead of 59". That is the
|
|
50
|
+
// dangerous half of this fix — a truncated prefix served as a node's whole
|
|
51
|
+
// content would silently corrupt every later reader — so that is the half worth
|
|
52
|
+
// having a test for.
|
|
53
|
+
//
|
|
54
|
+
// The fix's real effect is verified on the trained store instead: identical
|
|
55
|
+
// answers and identical `bytesRead`/`byteReads`/`junctionPops`, with
|
|
56
|
+
// `nodeRecords` falling 1,182,651 → 218,449 · 2,462,577 → 321,794 ·
|
|
57
|
+
// 2,492,035 → 381,020 (5.4–7.7×), and the 1,314-byte query 14.0 s → 9.4 s.
|
|
58
|
+
|
|
59
|
+
import { test } from "node:test";
|
|
60
|
+
import assert from "node:assert/strict";
|
|
61
|
+
import { Mind } from "../dist/src/index.js";
|
|
62
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
63
|
+
import { Meter } from "../dist/src/meter.js";
|
|
64
|
+
|
|
65
|
+
const ALL = 0x7fffffff;
|
|
66
|
+
|
|
67
|
+
test("a branch's bytes are reconstructed once, not re-walked", async () => {
|
|
68
|
+
const store = new SQliteStore({ path: ":memory:", D: 256 });
|
|
69
|
+
const mind = new Mind({ seed: 7, store });
|
|
70
|
+
// Long deposits, so the nodes have real interior structure to re-walk.
|
|
71
|
+
await mind.ingest([
|
|
72
|
+
["alpha", "the quick brown fox jumps over the lazy dog again and again"],
|
|
73
|
+
["beta", "the quick brown fox jumps over the lazy cat again and again"],
|
|
74
|
+
["gamma", "a quick brown fox once jumped over a lazy dog and then rested"],
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// A branch node with interior structure — the deposit's own root.
|
|
78
|
+
const tree = mind.perceive(
|
|
79
|
+
"the quick brown fox jumps over the lazy dog again and again",
|
|
80
|
+
);
|
|
81
|
+
const id = mind.resolve(new TextEncoder().encode(
|
|
82
|
+
"the quick brown fox jumps over the lazy dog again and again",
|
|
83
|
+
));
|
|
84
|
+
assert.ok(id !== null, "the deposited form resolves to a stored node");
|
|
85
|
+
assert.ok(tree.kids && tree.kids.length > 1, "and it has interior structure");
|
|
86
|
+
|
|
87
|
+
// Route through `_prefix`, not `bytes()`: the ALL sentinel short-circuits to
|
|
88
|
+
// `bytes()`, whose branch cache is pre-existing and would keep the assertion
|
|
89
|
+
// green even with the `_prefix` fix reverted. A capped read past the node's
|
|
90
|
+
// full length completes the walk (so the branch gets cached, `got < maxLen`)
|
|
91
|
+
// while staying off the ALL fast path. `contentLen` warms `_recCache`/`_lenCache`
|
|
92
|
+
// only, never `_bytesCache`, so the first read below starts cold.
|
|
93
|
+
const fullLen = store.contentLen(id);
|
|
94
|
+
store.meter = new Meter();
|
|
95
|
+
const capped = fullLen + 1;
|
|
96
|
+
|
|
97
|
+
// FIRST reconstruction — this one legitimately walks the subtree.
|
|
98
|
+
const first = store.bytesPrefix(id, capped);
|
|
99
|
+
const walked = store.meter.nodeRecords;
|
|
100
|
+
assert.ok(
|
|
101
|
+
first.length > 0,
|
|
102
|
+
"the node reconstructs to bytes",
|
|
103
|
+
);
|
|
104
|
+
// NON-VACUITY: if the first read did no node reads either, the store answered
|
|
105
|
+
// from some other cache and this test proves nothing about re-walking.
|
|
106
|
+
assert.ok(
|
|
107
|
+
walked > 0,
|
|
108
|
+
`the first reconstruction did no node reads at all (nodeRecords=${walked}), ` +
|
|
109
|
+
`so there is no re-walk for this test to detect`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// SECOND reconstruction of the SAME node — must be free.
|
|
113
|
+
const before = store.meter.nodeRecords;
|
|
114
|
+
const second = store.bytesPrefix(id, capped);
|
|
115
|
+
const again = store.meter.nodeRecords - before;
|
|
116
|
+
|
|
117
|
+
console.log(
|
|
118
|
+
` first reconstruction: ${walked} node reads; second: ${again}`,
|
|
119
|
+
);
|
|
120
|
+
assert.deepEqual(second, first, "the cached bytes are the same bytes");
|
|
121
|
+
assert.equal(
|
|
122
|
+
again,
|
|
123
|
+
0,
|
|
124
|
+
`re-reading the same node cost ${again} node reads (the first cost ` +
|
|
125
|
+
`${walked}) — reconstruction is a pure function of the store, so the ` +
|
|
126
|
+
`second request must be served from _bytesCache, not re-walked`,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// AND a truncated read must never be cached AS the whole node: serving a
|
|
130
|
+
// prefix as the full content would silently corrupt every later reader.
|
|
131
|
+
const cut = Math.max(1, first.length >> 1);
|
|
132
|
+
const store2 = new SQliteStore({ path: ":memory:", D: 256 });
|
|
133
|
+
const mind2 = new Mind({ seed: 7, store: store2 });
|
|
134
|
+
await mind2.ingest([[
|
|
135
|
+
"alpha",
|
|
136
|
+
"the quick brown fox jumps over the lazy dog again and again",
|
|
137
|
+
]]);
|
|
138
|
+
const id2 = mind2.resolve(new TextEncoder().encode(
|
|
139
|
+
"the quick brown fox jumps over the lazy dog again and again",
|
|
140
|
+
));
|
|
141
|
+
store2.bytesPrefix(id2, cut); // truncated FIRST, so a bad cache would poison
|
|
142
|
+
const whole = store2.bytesPrefix(id2, ALL);
|
|
143
|
+
assert.equal(
|
|
144
|
+
whole.length,
|
|
145
|
+
first.length,
|
|
146
|
+
`a capped read poisoned the cache: the full read came back ${whole.length} ` +
|
|
147
|
+
`bytes instead of ${first.length}`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await store.close();
|
|
151
|
+
await store2.close();
|
|
152
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// 93-regime-prediction.test.mjs — the retrieval/composition regime (R8) is
|
|
2
|
+
// exposed as a structured trace step, without changing inference.
|
|
3
|
+
//
|
|
4
|
+
// After the FIRST mechanism runs (cover, which §2.6 places first and floors at
|
|
5
|
+
// 0), the market's whole outcome is already determined by the one cost ladder:
|
|
6
|
+
// the consensus climb runs exactly when `worthRunning(2 * STEP)` is true —
|
|
7
|
+
// CAST (floor 2·STEP) is the cheapest mechanism that first-touches it. An
|
|
8
|
+
// incumbent at or below that floor prunes CAST and, with it, the climb
|
|
9
|
+
// (retrieval); anything above — or no incumbent — runs the full market and the
|
|
10
|
+
// climb (composition). The step is purely observational: it is built only
|
|
11
|
+
// under a trace (optional-chaining short-circuits it otherwise), and it never
|
|
12
|
+
// alters which candidate wins. The assertions here check the payload's
|
|
13
|
+
// STRUCTURE and its consistency with the actual market outcome, never that
|
|
14
|
+
// inference itself changed.
|
|
15
|
+
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { Mind } from "../dist/src/index.js";
|
|
19
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
20
|
+
|
|
21
|
+
const mk = (seed = 7) =>
|
|
22
|
+
new Mind({ seed, store: new SQliteStore({ path: ":memory:", D: 256 }) });
|
|
23
|
+
|
|
24
|
+
/** Collect the full step stream for one traced query. */
|
|
25
|
+
async function trace(mind, q) {
|
|
26
|
+
const steps = [];
|
|
27
|
+
const ans = await mind.respondText(q, (s) => steps.push(s));
|
|
28
|
+
return { steps, ans };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function regimeStep(steps) {
|
|
32
|
+
return steps.filter((s) => s.mechanism.at(-1) === "regimePrediction");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("1. a fully-grounding query predicts retrieval, and the market prunes", async () => {
|
|
36
|
+
const m = mk();
|
|
37
|
+
await m.ingest([
|
|
38
|
+
[
|
|
39
|
+
"who wrote romeo and juliet",
|
|
40
|
+
"William Shakespeare wrote Romeo and Juliet.",
|
|
41
|
+
],
|
|
42
|
+
]);
|
|
43
|
+
const { steps } = await trace(m, "who wrote romeo and juliet");
|
|
44
|
+
await m.store.close();
|
|
45
|
+
|
|
46
|
+
const r = regimeStep(steps);
|
|
47
|
+
assert.equal(r.length, 1, "exactly one regimePrediction step per response");
|
|
48
|
+
const d = r[0].data;
|
|
49
|
+
assert.equal(d.version, 1);
|
|
50
|
+
assert.equal(d.regime, "retrieval");
|
|
51
|
+
assert.ok(
|
|
52
|
+
d.incumbentGrade <= d.climbFloorGrade,
|
|
53
|
+
"incumbent grade at or under the climb floor",
|
|
54
|
+
);
|
|
55
|
+
assert.equal(
|
|
56
|
+
d.climbFloorGrade,
|
|
57
|
+
2,
|
|
58
|
+
"2·STEP = 2 (CAST's floor), in grade units",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("2. an ungroundable query predicts composition with no incumbent", async () => {
|
|
63
|
+
const m = mk();
|
|
64
|
+
await m.ingest([["alpha", "beta"]]);
|
|
65
|
+
const { steps } = await trace(m, "qzx zzjf vbnm plkj");
|
|
66
|
+
await m.store.close();
|
|
67
|
+
|
|
68
|
+
const r = regimeStep(steps);
|
|
69
|
+
assert.equal(r.length, 1);
|
|
70
|
+
assert.equal(r[0].data.regime, "composition");
|
|
71
|
+
assert.equal(
|
|
72
|
+
r[0].data.incumbentGrade,
|
|
73
|
+
null,
|
|
74
|
+
"nothing grounded — no incumbent",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("3. a partially-grounding query predicts composition (one unexplained byte outbids every floor)", async () => {
|
|
79
|
+
const m = mk();
|
|
80
|
+
await m.ingest([
|
|
81
|
+
["the capital of france", "The capital of France is Paris."],
|
|
82
|
+
]);
|
|
83
|
+
const { steps } = await trace(m, "what is the capital of france");
|
|
84
|
+
await m.store.close();
|
|
85
|
+
|
|
86
|
+
const r = regimeStep(steps);
|
|
87
|
+
assert.equal(r[0].data.regime, "composition");
|
|
88
|
+
assert.ok(
|
|
89
|
+
r[0].data.incumbentGrade > r[0].data.climbFloorGrade,
|
|
90
|
+
`incumbent grade ${r[0].data.incumbentGrade} must exceed the climb floor ` +
|
|
91
|
+
`${
|
|
92
|
+
r[0].data.climbFloorGrade
|
|
93
|
+
} — PASS prices each unexplained byte at 1000`,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("5. a fully-covered multi-move query (grade above the climb floor) still predicts composition", async () => {
|
|
98
|
+
// Three contiguous trained forms cover the whole query with three STEP moves
|
|
99
|
+
// and NO unexplained bytes — incumbent grade 3, which the old
|
|
100
|
+
// `worthRunning(CONCEPT + STEP)` boundary (≤ 11) mislabelled "retrieval"
|
|
101
|
+
// even though CAST's 2·STEP floor still runs the consensus climb. The
|
|
102
|
+
// regime must follow the climb, not the market's maximum floor.
|
|
103
|
+
const m = mk();
|
|
104
|
+
await m.ingest([
|
|
105
|
+
["abcdefgh", "ABCDEFGH"],
|
|
106
|
+
["ijklmnop", "IJKLMNOP"],
|
|
107
|
+
["qrstuvwx", "QRSTUVWX"],
|
|
108
|
+
]);
|
|
109
|
+
const { steps } = await trace(m, "abcdefghijklmnopqrstuvwx");
|
|
110
|
+
await m.store.close();
|
|
111
|
+
|
|
112
|
+
const r = regimeStep(steps);
|
|
113
|
+
assert.equal(r.length, 1);
|
|
114
|
+
const d = r[0].data;
|
|
115
|
+
assert.equal(d.regime, "composition", "the climb runs, so it is composition");
|
|
116
|
+
assert.ok(
|
|
117
|
+
d.incumbentGrade > d.climbFloorGrade,
|
|
118
|
+
`incumbent grade ${d.incumbentGrade} must exceed the climb floor ${d.climbFloorGrade}`,
|
|
119
|
+
);
|
|
120
|
+
// The prediction's own premise: the climb DID run (CAST first-touched it).
|
|
121
|
+
const climbed = steps.some((s) => s.mechanism.at(-1) === "climbConsensus");
|
|
122
|
+
assert.ok(climbed, "the consensus climb must actually run in this regime");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("4. the prediction is observational — an untraced response is byte-identical", async () => {
|
|
126
|
+
const mk2 = () =>
|
|
127
|
+
new Mind({
|
|
128
|
+
seed: 7,
|
|
129
|
+
store: new SQliteStore({ path: ":memory:", D: 256 }),
|
|
130
|
+
});
|
|
131
|
+
const q = "who wrote romeo and juliet";
|
|
132
|
+
const corpus = [[
|
|
133
|
+
"who wrote romeo and juliet",
|
|
134
|
+
"William Shakespeare wrote Romeo and Juliet.",
|
|
135
|
+
]];
|
|
136
|
+
|
|
137
|
+
const a = mk2();
|
|
138
|
+
await a.ingest(corpus);
|
|
139
|
+
const plain = await a.respondText(q);
|
|
140
|
+
await a.store.close();
|
|
141
|
+
|
|
142
|
+
const b = mk2();
|
|
143
|
+
await b.ingest(corpus);
|
|
144
|
+
const traced = await b.respondText(q, () => {});
|
|
145
|
+
await b.store.close();
|
|
146
|
+
|
|
147
|
+
assert.equal(traced, plain, "attaching a trace must not change the answer");
|
|
148
|
+
});
|