@hviana/sema 0.6.0 → 0.7.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.
Files changed (40) hide show
  1. package/.github/workflows/release.yml +80 -0
  2. package/AGENTS.md +53 -9
  3. package/dist/src/meter.d.ts +1 -4
  4. package/dist/src/meter.js +0 -3
  5. package/dist/src/mind/attention.js +22 -20
  6. package/dist/src/mind/graph-search.d.ts +43 -9
  7. package/dist/src/mind/graph-search.js +82 -15
  8. package/dist/src/mind/junction.d.ts +13 -0
  9. package/dist/src/mind/junction.js +13 -0
  10. package/dist/src/mind/mechanisms/cover.js +23 -2
  11. package/dist/src/mind/mechanisms/prefix-completion.js +13 -11
  12. package/dist/src/mind/mechanisms/recall.js +8 -4
  13. package/dist/src/mind/pipeline-mechanism.d.ts +0 -24
  14. package/dist/src/mind/pipeline-mechanism.js +13 -36
  15. package/dist/src/mind/pipeline.d.ts +23 -0
  16. package/dist/src/mind/pipeline.js +51 -3
  17. package/dist/src/mind/recognition.d.ts +6 -1
  18. package/dist/src/mind/recognition.js +11 -6
  19. package/dist/src/mind/resonance.js +48 -13
  20. package/dist/src/store.js +22 -1
  21. package/jsr.json +1 -1
  22. package/package.json +7 -2
  23. package/src/meter.ts +1 -4
  24. package/src/mind/attention.ts +22 -19
  25. package/src/mind/graph-search.ts +93 -16
  26. package/src/mind/junction.ts +13 -0
  27. package/src/mind/mechanisms/cover.ts +23 -4
  28. package/src/mind/mechanisms/prefix-completion.ts +13 -11
  29. package/src/mind/mechanisms/recall.ts +8 -4
  30. package/src/mind/pipeline-mechanism.ts +13 -42
  31. package/src/mind/pipeline.ts +87 -3
  32. package/src/mind/recognition.ts +19 -6
  33. package/src/mind/resonance.ts +79 -50
  34. package/src/store.ts +21 -1
  35. package/test/89-completion-recursion.test.mjs +230 -0
  36. package/test/90-connector-read-cap.test.mjs +130 -0
  37. package/test/91-branch-bytes-cache.test.mjs +152 -0
  38. package/test/93-regime-prediction.test.mjs +148 -0
  39. package/test/94-cross-region-budget.test.mjs +67 -0
  40. package/test/95-wide-resonance-removed.test.mjs +109 -0
@@ -12,7 +12,7 @@ import { concat2, concatBytes, indexOf } from "../bytes.js";
12
12
  import type { MindContext } from "./types.js";
13
13
  import { gistOf, read, resolve, walkTree } from "./primitives.js";
14
14
  import { perceive } from "./primitives.js";
15
- import { argmaxBy, argmaxCosine, candidateGist, hubBound } from "./traverse.js";
15
+ import { argmaxCosine, candidateGist, hubBound } from "./traverse.js";
16
16
  import {
17
17
  cachedRead,
18
18
  type Junction,
@@ -365,7 +365,13 @@ export async function pivotInto(
365
365
  }
366
366
  for (const c of n.kids) queue.push(c); // breadth-first: larger regions first
367
367
  }
368
- const rec = recognise(ctx, answer);
368
+ // TRIMMED recognition: the pivot's own filter below rejects fragments
369
+ // (`hasParents || hasContainers → -Infinity`), and recognition's edge-trim
370
+ // fallbacks exist to find exactly those misaligned FRAGMENTS. Skipping them
371
+ // (the structural pass + canonResolve still run) is byte-identical for every
372
+ // pivot — the fallbacks' output is discarded by the filter — and halves the
373
+ // O(n·W²) recognition of a long answer (measured: 36KB recognise 4.0s → 2.0s).
374
+ const rec = recognise(ctx, answer, true);
369
375
  for (const s of rec.sites) {
370
376
  if (!consumed.has(s.payload) && ctx.store.hasNext(s.payload)) {
371
377
  scored.set(s.payload, Math.max(scored.get(s.payload) ?? 0, 1));
@@ -373,54 +379,77 @@ export async function pivotInto(
373
379
  }
374
380
  // Byte containment, longest wins — the answer literally contains the
375
381
  // pivot's bytes, and the biggest well-evidenced span is the real pivot.
376
- const found = argmaxBy(
377
- scored.keys(),
378
- (id) => {
379
- // A PIVOT MUST BE A THING THE CORPUS DEPOSITED, NOT A PIECE OF ONE.
380
- // "Longest wins" ranks candidates but never asks whether the winner is
381
- // an entity at all, and by the time a chain reaches here `consumeAll`
382
- // has taken the answer's real contexts so on a corpus of
383
- // near-identical records the field is left to whatever interned
384
- // fragments remain. Measured on a 200-line templated log corpus, query
385
- // "what happened to request_id=1042 and request_id=1077?": CAST
386
- // produced the correct comparison and one `pivotStep` replaced it
387
- // wholesale, pivoting through `s=70` — a four-byte tail of
388
- // `latency_ms=70` — onto an unrelated record (`handled 1130`).
389
- //
390
- // The separator is NOT length. Measured against the multi-hop tests'
391
- // own pivots: `Paris` (5 bytes), `Jupiter` (7), `lithium` (7), `Mona
392
- // Lisa` (9) against junk `s=70` (4) a two-quantum floor, which
393
- // confluence.ts applies to a meet for the same "one window is not an
394
- // entity" reason, discards three of the four legitimate pivots.
395
- // Entities are simply short.
396
- //
397
- // What separates them is STRUCTURAL, and the store already holds it:
398
- //
399
- // s=70 parents 2 containers 1 prevCount 0 halo no
400
- // Paris parents 0 containers 0 prevCount 1 halo yes
401
- // Jupiter parents 0 containers 0 prevCount 1 halo yes
402
- // lithium parents 0 containers 0 prevCount 1 halo yes
403
- // Mona Lisa parents 0 containers 0 prevCount 1 halo yes
404
- //
405
- // A deposited whole a context or an answer is interned in its own
406
- // right and has neither structural parents nor containment links. A
407
- // fragment is addressable ONLY because window interning made its span
408
- // addressable inside something bigger, and that containment is exactly
409
- // what `parents`/`containers` record. Reasoning steps THROUGH a fact;
410
- // a span that was never a fact on its own is not one to step through.
411
- // No constant enters it is a structural predicate, not a threshold.
412
- if (ctx.store.hasParents(id) || ctx.store.hasContainers(id)) {
413
- return -Infinity;
414
- }
415
- const bytes = read(ctx, id);
416
- if (indexOf(answer, bytes, 0) < 0) return -Infinity;
417
- for (const v of voiced) if (indexOf(v, bytes, 0) >= 0) return -Infinity;
418
- return bytes.length;
419
- },
420
- 0,
421
- true,
422
- );
423
- return found?.item ?? null;
382
+ //
383
+ // REAL SATURATION, not a hard cap: the score IS the candidate's byte
384
+ // length, so the scan is DECIDED the moment the first candidate that passes
385
+ // every filter is found in DESCENDING length order a shorter candidate can
386
+ // never outscore it. `contentLen` (the prefix-capped length read, §2.8) is
387
+ // the cheap ordering key, and the first-inserted tie-break is made explicit
388
+ // (`a.index - b.index`) so equal lengths keep `scored`'s insertion order
389
+ // exactly the tie argmaxBy(strict) used to keep. The bytes of at most ONE
390
+ // winning candidate are read; every shorter candidate the probes proposed is
391
+ // skipped without reconstruction, where the old argmax read them all.
392
+ const ranked = [...scored.keys()]
393
+ .map((id, index) => ({
394
+ id,
395
+ index,
396
+ len: ctx.store.contentLen(id, answer.length + 1),
397
+ }))
398
+ .sort((a, b) => b.len - a.len || a.index - b.index);
399
+ let pivotId: number | null = null;
400
+ for (const c of ranked) {
401
+ const id = c.id;
402
+ // A PIVOT MUST BE A THING THE CORPUS DEPOSITED, NOT A PIECE OF ONE.
403
+ // "Longest wins" ranks candidates but never asks whether the winner is
404
+ // an entity at all, and by the time a chain reaches here `consumeAll`
405
+ // has taken the answer's real contexts so on a corpus of
406
+ // near-identical records the field is left to whatever interned
407
+ // fragments remain. Measured on a 200-line templated log corpus, query
408
+ // "what happened to request_id=1042 and request_id=1077?": CAST
409
+ // produced the correct comparison and one `pivotStep` replaced it
410
+ // wholesale, pivoting through `s=70` — a four-byte tail of
411
+ // `latency_ms=70`onto an unrelated record (`handled 1130`).
412
+ //
413
+ // The separator is NOT length. Measured against the multi-hop tests'
414
+ // own pivots: `Paris` (5 bytes), `Jupiter` (7), `lithium` (7), `Mona
415
+ // Lisa` (9) against junk `s=70` (4) a two-quantum floor, which
416
+ // confluence.ts applies to a meet for the same "one window is not an
417
+ // entity" reason, discards three of the four legitimate pivots.
418
+ // Entities are simply short.
419
+ //
420
+ // What separates them is STRUCTURAL, and the store already holds it:
421
+ //
422
+ // s=70 parents 2 containers 1 prevCount 0 halo no
423
+ // Paris parents 0 containers 0 prevCount 1 halo yes
424
+ // Jupiter parents 0 containers 0 prevCount 1 halo yes
425
+ // lithium parents 0 containers 0 prevCount 1 halo yes
426
+ // Mona Lisa parents 0 containers 0 prevCount 1 halo yes
427
+ //
428
+ // A deposited whole — a context or an answer — is interned in its own
429
+ // right and has neither structural parents nor containment links. A
430
+ // fragment is addressable ONLY because window interning made its span
431
+ // addressable inside something bigger, and that containment is exactly
432
+ // what `parents`/`containers` record. Reasoning steps THROUGH a fact;
433
+ // a span that was never a fact on its own is not one to step through.
434
+ // No constant enters — it is a structural predicate, not a threshold.
435
+ if (ctx.store.hasParents(id) || ctx.store.hasContainers(id)) continue;
436
+ // A candidate whose bytes are LONGER than the answer cannot be a
437
+ // substring of it — `indexOf` would return −1 regardless. Prune by
438
+ // length BEFORE reconstructing the bytes: `read` is an UNCAPPED read
439
+ // (AGENTS §2.8), and a resonated context far longer than the answer is
440
+ // exactly the candidate that makes it cost a whole deposit's worth of
441
+ // reconstruction for a containment test that must fail. `contentLen`
442
+ // with the `answer.length + 1` cap is the prefix-capped length read the
443
+ // same contract prescribes; the prune is byte-identical to the old
444
+ // `indexOf` miss (it returns −1 for a needle longer than the haystack).
445
+ if (c.len > answer.length) continue;
446
+ const bytes = read(ctx, id);
447
+ if (indexOf(answer, bytes, 0) < 0) continue;
448
+ if (voiced.some((v) => indexOf(v, bytes, 0) >= 0)) continue;
449
+ pivotId = id;
450
+ break;
451
+ }
452
+ return pivotId;
424
453
  }
425
454
 
426
455
  /** Which of the given labelled forms a span MEANS — generic resonance over
package/src/store.ts CHANGED
@@ -1269,7 +1269,27 @@ export abstract class AbstractStore implements Store {
1269
1269
  parts.push(child);
1270
1270
  got += child.length;
1271
1271
  }
1272
- return concat(parts);
1272
+ const out = concat(parts);
1273
+ // Cache the BRANCH too, not just the leaf above. Reconstruction is a pure
1274
+ // function of the store, so this is a transparent cache in the strict sense
1275
+ // — an eviction costs a re-walk and nothing else — which is exactly what
1276
+ // `_bytesCache`'s "smallest"/"clock" configuration is for.
1277
+ //
1278
+ // Caching only leaves made every branch re-walk its whole subtree on every
1279
+ // request, and the DAG is hash-consed, so the same children recur under many
1280
+ // parents. Measured on the 18.9M-node store, ONE 1,314-byte query:
1281
+ // 20,021,474 `_prefix` calls over 469,083 distinct ids (42.7x reuse) to
1282
+ // produce 87,789 results — 97.7% of the work re-derived bytes it had already
1283
+ // built. One single-byte leaf was reconstructed 2,599,984 times. Measuring
1284
+ // reuse at the TOP level only shows 1.1x and hides all of it.
1285
+ //
1286
+ // Only a COMPLETE reconstruction may be cached: `_prefix` is also called
1287
+ // with a cap, and a truncated prefix stored under `id` would be served as
1288
+ // if it were the node's whole content by the `_bytesCache` hit above.
1289
+ // `got < maxLen` is that proof — the walk ran out of children before it ran
1290
+ // out of budget, so nothing below was truncated either.
1291
+ if (got < maxLen) this._bytesCache.set(id, out);
1292
+ return out;
1273
1293
  }
1274
1294
 
1275
1295
  contentLen(id: NodeId, cap = Infinity): number {
@@ -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
+ });