@hviana/sema 0.5.7 → 0.5.9
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 +23 -0
- package/DATASETS.md +159 -0
- package/HOW_IT_WORKS.md +74 -0
- package/README.md +12 -0
- package/dist/example/train_base.d.ts +73 -3
- package/dist/example/train_base.js +1000 -49
- package/dist/src/geometry.d.ts +20 -0
- package/dist/src/geometry.js +22 -0
- package/dist/src/mind/articulation.js +15 -2
- package/dist/src/mind/attention.d.ts +6 -0
- package/dist/src/mind/attention.js +44 -4
- package/dist/src/mind/learning.js +250 -3
- package/dist/src/mind/mechanisms/cast.js +45 -1
- package/dist/src/mind/mind.d.ts +6 -1
- package/dist/src/mind/mind.js +14 -2
- package/dist/src/mind/reasoning.js +59 -5
- package/dist/src/mind/recognition.js +29 -3
- package/dist/src/mind/traverse.d.ts +34 -0
- package/dist/src/mind/traverse.js +42 -0
- package/dist/src/store-sqlite.d.ts +4 -0
- package/dist/src/store-sqlite.js +47 -0
- package/dist/src/store.d.ts +7 -0
- package/example/train_base.ts +1193 -46
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/geometry.ts +23 -0
- package/src/mind/articulation.ts +16 -2
- package/src/mind/attention.ts +54 -1
- package/src/mind/learning.ts +253 -4
- package/src/mind/mechanisms/cast.ts +48 -1
- package/src/mind/mind.ts +12 -1
- package/src/mind/reasoning.ts +64 -5
- package/src/mind/recognition.ts +29 -3
- package/src/mind/traverse.ts +48 -0
- package/src/store-sqlite.ts +53 -0
- package/src/store.ts +28 -0
- package/test/29-counterfactual.test.mjs +43 -6
- package/test/76-type-level-company.test.mjs +342 -0
- package/test/77-company-saturation.test.mjs +302 -0
- package/test/78-atom-hub-recognition-cliff.test.mjs +135 -0
- package/test/84-composed-answer-honesty.test.mjs +136 -0
- package/test/85-answered-directly.test.mjs +126 -0
- package/test/86-cast-voices-committed.test.mjs +164 -0
- package/test/87-codominant-commitment.test.mjs +250 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// 77-company-saturation.test.mjs — companyProfile stops because the
|
|
2
|
+
// REPRESENTATION IS FULL, not because a budget ran out.
|
|
3
|
+
//
|
|
4
|
+
// The old rule was `PROFILE_VISITS = 64`: a constant, and one that decided
|
|
5
|
+
// which constituents entered a halo by where they sat in a BFS. It was also
|
|
6
|
+
// wrong in both directions on the trained store — it fired at 64 visits while
|
|
7
|
+
// capacity needed a median of 69 (dropping readable evidence), and an uncapped
|
|
8
|
+
// walk accepted ~50 terms where the representation holds √D = 32.
|
|
9
|
+
//
|
|
10
|
+
// The replacement is derived: a superposition of m unit signatures contributes
|
|
11
|
+
// 1/m per term to any cosine taken against it, so once m > √D one term moves
|
|
12
|
+
// nothing above estimatorNoise(D) = 1/√D. `profileCapacity(D) = √D` is that
|
|
13
|
+
// point (geometry.ts). The constituent set is stored as a bottom-k sketch keyed
|
|
14
|
+
// on each unit's own identity, so membership is a property of the UNIT and not
|
|
15
|
+
// of traversal order.
|
|
16
|
+
//
|
|
17
|
+
// Every check below reads the diagnostics companyProfile reports through
|
|
18
|
+
// ingest's inspectRationale — if the instrumentation were decorative, T8 fails.
|
|
19
|
+
|
|
20
|
+
import { test } from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { Mind } from "../dist/src/index.js";
|
|
23
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
24
|
+
import { estimatorNoise, profileCapacity } from "../dist/src/geometry.js";
|
|
25
|
+
|
|
26
|
+
/** Ingest, collecting every companyProfile diagnostic payload. */
|
|
27
|
+
async function ingestTraced(mind, items) {
|
|
28
|
+
const seen = [];
|
|
29
|
+
await mind.ingest(items, undefined, undefined, (s) => {
|
|
30
|
+
if (s.mechanism[s.mechanism.length - 1] === "companyProfile") {
|
|
31
|
+
seen.push(s.data);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return seen;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A store that counts the reads companyProfile's stopping rule performs. */
|
|
38
|
+
function countingStore(opts) {
|
|
39
|
+
const store = new SQliteStore(opts);
|
|
40
|
+
const counts = { get: 0, sketchGet: 0, sketchPut: 0, parentsFirst: 0 };
|
|
41
|
+
for (const m of ["get", "sketchGet", "sketchPut", "parentsFirst"]) {
|
|
42
|
+
const orig = store[m].bind(store);
|
|
43
|
+
store[m] = (...a) => {
|
|
44
|
+
counts[m]++;
|
|
45
|
+
return orig(...a);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { store, counts };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const mk = (D = 1024) => {
|
|
52
|
+
const store = new SQliteStore({ path: ":memory:", D });
|
|
53
|
+
return { store, mind: new Mind({ seed: 7, store }) };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// A long partner whose constituents are many and mostly unique.
|
|
57
|
+
const longText = (n, tail = "") =>
|
|
58
|
+
Array.from({ length: n }, (_, i) => `alpha${i} beta${i} gamma${i}`).join(
|
|
59
|
+
" ",
|
|
60
|
+
) + tail;
|
|
61
|
+
|
|
62
|
+
// ── 1. deep type-level company still works ───────────────────────────────
|
|
63
|
+
// The motivating case: the shared unit sits BELOW the top of the fold, so a
|
|
64
|
+
// depth-1 profile would miss it entirely (test/76 T1 is the full fixture).
|
|
65
|
+
test("T1: a unit shared below depth 1 still enters both profiles", async () => {
|
|
66
|
+
const { store, mind } = mk();
|
|
67
|
+
await mind.ingest([
|
|
68
|
+
["The Eiffel Tower is in Paris", "Tour Eiffel dia any Paris"],
|
|
69
|
+
[
|
|
70
|
+
"A completely unrelated control sentence",
|
|
71
|
+
"Another unrelated control string",
|
|
72
|
+
],
|
|
73
|
+
]);
|
|
74
|
+
// Both partners must have found constituents at all — an empty sketch on a
|
|
75
|
+
// full sentence is the depth-1 failure this design exists to prevent.
|
|
76
|
+
const ids = [];
|
|
77
|
+
for (let i = 0; i < store.nodeCount(); i++) {
|
|
78
|
+
const s = store.sketchGet?.(i);
|
|
79
|
+
if (s && s.length > 0) ids.push(i);
|
|
80
|
+
}
|
|
81
|
+
assert.ok(ids.length > 0, "no node acquired a non-empty constituent sketch");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── 2. training order ────────────────────────────────────────────────────
|
|
85
|
+
test("T2: forward and reverse training order give identical sketches", async () => {
|
|
86
|
+
const pairs = [
|
|
87
|
+
["The Eiffel Tower is in Paris", "Tour Eiffel dia any Paris"],
|
|
88
|
+
["Water freezes at zero degrees", "El agua se congela a cero grados"],
|
|
89
|
+
["The capital of France is Paris", "La capitale de la France est Paris"],
|
|
90
|
+
];
|
|
91
|
+
const read = async (items) => {
|
|
92
|
+
const { store, mind } = mk();
|
|
93
|
+
await mind.ingest(items);
|
|
94
|
+
// Key sketches by CONTENT, not id — ids depend on mint order by design.
|
|
95
|
+
const dec = new TextDecoder();
|
|
96
|
+
const out = new Map();
|
|
97
|
+
for (let i = 0; i < store.nodeCount(); i++) {
|
|
98
|
+
const s = store.sketchGet?.(i);
|
|
99
|
+
if (!s || s.length === 0) continue;
|
|
100
|
+
const key = dec.decode(store.bytes(i).filter((x) => x !== 0));
|
|
101
|
+
out.set(
|
|
102
|
+
key,
|
|
103
|
+
s.map((n) => dec.decode(store.bytes(n).filter((x) => x !== 0))).sort(),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
};
|
|
108
|
+
const fwd = await read(pairs);
|
|
109
|
+
const rev = await read([...pairs].reverse());
|
|
110
|
+
// Every partner present in both must have the SAME constituent set.
|
|
111
|
+
let compared = 0;
|
|
112
|
+
for (const [k, v] of fwd) {
|
|
113
|
+
if (!rev.has(k)) continue;
|
|
114
|
+
compared++;
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
v,
|
|
117
|
+
rev.get(k),
|
|
118
|
+
`sketch differs by training order for ${JSON.stringify(k)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
assert.ok(compared > 0, "no partner was comparable across orders");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── 3. long partners stop by SATURATION, not by a budget ─────────────────
|
|
125
|
+
test("T3: a long partner reports capacity as the stop reason", async () => {
|
|
126
|
+
const { mind } = mk();
|
|
127
|
+
const diag = await ingestTraced(mind, [[
|
|
128
|
+
longText(40),
|
|
129
|
+
"a short continuation",
|
|
130
|
+
]]);
|
|
131
|
+
const long = diag.filter((d) => d.wholeLen > 400);
|
|
132
|
+
assert.ok(long.length > 0, "expected at least one long partner");
|
|
133
|
+
for (const d of long) {
|
|
134
|
+
assert.equal(d.capacity, profileCapacity(1024), "capacity must be √D");
|
|
135
|
+
assert.equal(
|
|
136
|
+
d.stopReason,
|
|
137
|
+
"capacity",
|
|
138
|
+
"long partner must stop at capacity",
|
|
139
|
+
);
|
|
140
|
+
assert.ok(d.saturated, "long partner must report saturation");
|
|
141
|
+
assert.equal(
|
|
142
|
+
d.sketched,
|
|
143
|
+
d.capacity,
|
|
144
|
+
"sketch must be exactly capacity-sized",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── 4. a unit shared at DIFFERENT depths must not be systematically lost ──
|
|
150
|
+
test("T4: a unit shared at different fold depths enters both sketches", async () => {
|
|
151
|
+
// The motivating fixture. Content-defined cuts put " Paris" at a different
|
|
152
|
+
// depth in each sentence — "The Eiffel Tower is in Paris" folds to
|
|
153
|
+
// "The Eiffel " + "Tower is in Paris", "Tour Eiffel dia any Paris" to
|
|
154
|
+
// "Tour Eiffel " + "dia any Paris" — so the shared unit is a child of
|
|
155
|
+
// NEITHER. A selection keyed on traversal position reaches it in one partner
|
|
156
|
+
// and not the other; one keyed on the unit's own identity keeps it in both.
|
|
157
|
+
//
|
|
158
|
+
// NOTE the earlier version of this test compared "zzmarker " at the head
|
|
159
|
+
// against " zzmarker" at the tail. Those are DIFFERENT BYTES, hence different
|
|
160
|
+
// node identities, so the comparison could never have been about position.
|
|
161
|
+
const { store, mind } = mk();
|
|
162
|
+
const A = "The Eiffel Tower is in Paris";
|
|
163
|
+
const B = "Tour Eiffel dia any Paris";
|
|
164
|
+
await mind.ingest([[A, B]]);
|
|
165
|
+
|
|
166
|
+
const dec = new TextDecoder();
|
|
167
|
+
const enc = new TextEncoder();
|
|
168
|
+
const nodeOf = (text) => {
|
|
169
|
+
const want = enc.encode(text);
|
|
170
|
+
for (let i = 0; i < store.nodeCount(); i++) {
|
|
171
|
+
if (store.contentLen(i, want.length + 1) !== want.length) continue;
|
|
172
|
+
const b = store.bytes(i);
|
|
173
|
+
if (b.length === want.length && b.every((x, j) => x === want[j])) {
|
|
174
|
+
return i;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
};
|
|
179
|
+
const a = nodeOf(A), b = nodeOf(B);
|
|
180
|
+
assert.ok(a !== null && b !== null, "both partners must be interned");
|
|
181
|
+
const sa = store.sketchGet(a) ?? [];
|
|
182
|
+
const sb = store.sketchGet(b) ?? [];
|
|
183
|
+
assert.ok(
|
|
184
|
+
sa.length > 0 && sb.length > 0,
|
|
185
|
+
"both partners must sketch something",
|
|
186
|
+
);
|
|
187
|
+
const shared = sa.filter((n) => sb.includes(n));
|
|
188
|
+
assert.ok(
|
|
189
|
+
shared.length > 0,
|
|
190
|
+
`no shared constituent: A=${
|
|
191
|
+
JSON.stringify(sa.map((n) => dec.decode(store.bytes(n))))
|
|
192
|
+
} ` +
|
|
193
|
+
`B=${JSON.stringify(sb.map((n) => dec.decode(store.bytes(n))))}`,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── 5. an irrelevant tail must not buy unbounded work ────────────────────
|
|
198
|
+
test("T5: work per profile does not grow with an irrelevant structural tail", async () => {
|
|
199
|
+
const work = [];
|
|
200
|
+
for (const n of [10, 40, 160]) {
|
|
201
|
+
const { store, counts } = countingStore({ path: ":memory:", D: 1024 });
|
|
202
|
+
const mind = new Mind({ seed: 7, store });
|
|
203
|
+
await mind.ingest([[longText(n), "continuation"]]);
|
|
204
|
+
work.push({ n, parentsFirst: counts.parentsFirst });
|
|
205
|
+
}
|
|
206
|
+
// parentsFirst is the hub probe — exactly one per SKETCHED constituent, so it
|
|
207
|
+
// measures the stopping rule's own cost. Capacity bounds it at √D per pour.
|
|
208
|
+
const cap = profileCapacity(1024);
|
|
209
|
+
for (const w of work) {
|
|
210
|
+
assert.ok(
|
|
211
|
+
w.parentsFirst <= cap * 8,
|
|
212
|
+
`hub probes ${w.parentsFirst} at n=${w.n} exceed a capacity-bounded budget`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
// 16x the tail must not cost 16x the stopping work.
|
|
216
|
+
const ratio = work[2].parentsFirst / Math.max(1, work[0].parentsFirst);
|
|
217
|
+
assert.ok(
|
|
218
|
+
ratio < 4,
|
|
219
|
+
`stopping work grew ${ratio.toFixed(1)}x for a 16x tail`,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── 6. corpus growth must not explode the stopping cost ──────────────────
|
|
224
|
+
test("T6: the same partner costs the same to profile as the corpus grows", async () => {
|
|
225
|
+
const probe = async (extra) => {
|
|
226
|
+
const { store, counts } = countingStore({ path: ":memory:", D: 1024 });
|
|
227
|
+
const mind = new Mind({ seed: 7, store });
|
|
228
|
+
const filler = Array.from(
|
|
229
|
+
{ length: extra },
|
|
230
|
+
(_, i) => [`ctx ${i} alpha`, `ans ${i} beta`],
|
|
231
|
+
);
|
|
232
|
+
await mind.ingest(filler);
|
|
233
|
+
const before = counts.parentsFirst;
|
|
234
|
+
await mind.ingest([[longText(30), "continuation"]]);
|
|
235
|
+
return counts.parentsFirst - before;
|
|
236
|
+
};
|
|
237
|
+
const small = await probe(20);
|
|
238
|
+
const large = await probe(600);
|
|
239
|
+
assert.ok(
|
|
240
|
+
large <= small * 2 + 16,
|
|
241
|
+
`profiling cost grew with corpus size: ${small} -> ${large} hub probes`,
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── 7. stopping happened only once evidence was insignificant ────────────
|
|
246
|
+
test("T7: saturated profiles report a marginal at or below the noise floor", async () => {
|
|
247
|
+
const { mind } = mk();
|
|
248
|
+
const diag = await ingestTraced(mind, [[
|
|
249
|
+
longText(40),
|
|
250
|
+
"a short continuation",
|
|
251
|
+
]]);
|
|
252
|
+
const sat = diag.filter((d) => d.saturated);
|
|
253
|
+
assert.ok(sat.length > 0, "expected a saturated profile");
|
|
254
|
+
for (const d of sat) {
|
|
255
|
+
assert.equal(d.noiseFloor, estimatorNoise(1024));
|
|
256
|
+
// mass = accepted + 1, and capacity is the point where 1/mass reaches the
|
|
257
|
+
// floor. Accepting everything sketched puts marginal AT the floor; hub
|
|
258
|
+
// exclusion can leave it above, and the diagnostics must say which.
|
|
259
|
+
assert.ok(d.mass > 1, "a saturated profile superposed nothing");
|
|
260
|
+
assert.equal(d.residual, d.sketched - d.accepted);
|
|
261
|
+
assert.ok(
|
|
262
|
+
d.marginal <= d.noiseFloor || d.hubDropped + d.dominating === d.residual,
|
|
263
|
+
`marginal ${d.marginal} above floor ${d.noiseFloor} with unexplained residual`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── 8. the instrumentation is not decorative ─────────────────────────────
|
|
269
|
+
test("T8: perturbing capacity measurably moves the diagnostics", async () => {
|
|
270
|
+
// profileCapacity is √D, so changing D perturbs the saturation point. If the
|
|
271
|
+
// reported numbers did not follow, the diagnostics would be describing
|
|
272
|
+
// something other than the rule that actually stops the walk.
|
|
273
|
+
const at = async (D) => {
|
|
274
|
+
const store = new SQliteStore({ path: ":memory:", D });
|
|
275
|
+
const mind = new Mind({ seed: 7, store });
|
|
276
|
+
const diag = await ingestTraced(mind, [[
|
|
277
|
+
longText(40),
|
|
278
|
+
"a short continuation",
|
|
279
|
+
]]);
|
|
280
|
+
return diag.filter((d) => d.saturated);
|
|
281
|
+
};
|
|
282
|
+
const small = await at(256); // capacity 16
|
|
283
|
+
const large = await at(4096); // capacity 64
|
|
284
|
+
assert.ok(
|
|
285
|
+
small.length > 0 && large.length > 0,
|
|
286
|
+
"both D values must saturate",
|
|
287
|
+
);
|
|
288
|
+
assert.equal(small[0].capacity, profileCapacity(256));
|
|
289
|
+
assert.equal(large[0].capacity, profileCapacity(4096));
|
|
290
|
+
assert.ok(
|
|
291
|
+
large[0].sketched > small[0].sketched,
|
|
292
|
+
`sketch size did not follow capacity: ${small[0].sketched} vs ${
|
|
293
|
+
large[0].sketched
|
|
294
|
+
}`,
|
|
295
|
+
);
|
|
296
|
+
assert.ok(
|
|
297
|
+
large[0].marginal < small[0].marginal,
|
|
298
|
+
`marginal did not follow capacity: ${small[0].marginal} vs ${
|
|
299
|
+
large[0].marginal
|
|
300
|
+
}`,
|
|
301
|
+
);
|
|
302
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// 78-atom-hub-recognition-cliff.test.mjs — recognition must not LOSE interior
|
|
2
|
+
// sites when the corpus crosses the atomIsHub threshold.
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS PINS. `atomIsHub` flips at exactly N = 4096 edge sources:
|
|
5
|
+
// atomReach = ⌈N·W/256⌉ = ⌈N/64⌉ exceeds boundFor = √N there (W = 4).
|
|
6
|
+
// recogniseImpl gated FOUR things on that flip, and one of them —
|
|
7
|
+
// tryChain's `!boundary && atomsAreHubs` — blanket-suppressed every
|
|
8
|
+
// off-boundary chain. A store crossing 4096 therefore silently stopped
|
|
9
|
+
// recognising interior forms it had recognised at 4095, with no error and no
|
|
10
|
+
// failing test. Measured on the two-hop fixture below: 4 sites including
|
|
11
|
+
// "France" at N = 3920, 2 sites without it at N = 4227.
|
|
12
|
+
//
|
|
13
|
+
// The fix asks whether the byte-exact branch tryChain ALREADY found is a
|
|
14
|
+
// deposited whole (`bearsEdge`) instead of whether its offset happened to land
|
|
15
|
+
// on a fold cut. So the discriminating assertion is: at N > 4096, an interior
|
|
16
|
+
// form that BEARS A CONTINUATION EDGE is still a recognised site.
|
|
17
|
+
//
|
|
18
|
+
// WHY THIS IS SLOW AND MUST STAY SO. The threshold is a property of corpus
|
|
19
|
+
// scale, so the only honest fixture is one that actually crosses it — 4300
|
|
20
|
+
// deposits, ~6 s. A cheaper store would sit below the flip and pass under the
|
|
21
|
+
// old code too, which is exactly the hole this file exists to close.
|
|
22
|
+
|
|
23
|
+
import { test } from "node:test";
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { Mind } from "../dist/src/index.js";
|
|
26
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
27
|
+
import { recognise } from "../dist/src/mind/recognition.js";
|
|
28
|
+
import { atomIsHub, corpusN } from "../dist/src/mind/traverse.js";
|
|
29
|
+
|
|
30
|
+
const CHAIN = [
|
|
31
|
+
["Eiffel Tower country", "The country of Eiffel Tower is France."],
|
|
32
|
+
["France capital", "The capital of France is Paris."],
|
|
33
|
+
// The PIVOT: a bare entity that bears a continuation edge, and which occurs
|
|
34
|
+
// INSIDE hop 1's answer at an offset the fold did not choose as a cut.
|
|
35
|
+
["France", "The capital of France is Paris."],
|
|
36
|
+
];
|
|
37
|
+
const ANSWER = "The country of Eiffel Tower is France.";
|
|
38
|
+
|
|
39
|
+
// Lexically VARIED filler. A single repeated template ("filler 12 alpha") folds
|
|
40
|
+
// to a handful of shared chunks and leaves the query almost uncontested, which
|
|
41
|
+
// was enough to let the two-hop chain compose even with the gate in place —
|
|
42
|
+
// i.e. a templated corpus makes the behavioural test non-discriminating. Real
|
|
43
|
+
// corpora are lexically diverse, so the filler must be too.
|
|
44
|
+
const WORDS =
|
|
45
|
+
("alpha bravo charlie delta echo foxtrot golf hotel india juliet " +
|
|
46
|
+
"kilo lima mike november oscar papa quebec romeo sierra tango uniform " +
|
|
47
|
+
"victor whiskey xray yankee zulu amber bronze copper dahlia ember fjord " +
|
|
48
|
+
"gossamer harbour indigo jasmine kestrel lantern marigold nectar opal " +
|
|
49
|
+
"pewter quartz ripple saffron thistle umber violet willow xenon yarrow")
|
|
50
|
+
.split(" ");
|
|
51
|
+
const filler = (i) => {
|
|
52
|
+
const w = (n) => WORDS[(i * 7 + n * 13) % WORDS.length];
|
|
53
|
+
return [
|
|
54
|
+
`${w(1)} ${w(2)} ${w(3)} ${i}`,
|
|
55
|
+
`${w(4)} ${w(5)} ${w(6)} ${w(7)} ${i}`,
|
|
56
|
+
];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** One store, ingested past the atomIsHub flip. */
|
|
60
|
+
async function pastTheFlip() {
|
|
61
|
+
const store = new SQliteStore({ path: ":memory:", D: 1024 });
|
|
62
|
+
const mind = new Mind({ seed: 7, store });
|
|
63
|
+
await mind.ingest(CHAIN);
|
|
64
|
+
await mind.ingest(Array.from({ length: 4300 }, (_, i) => filler(i)));
|
|
65
|
+
return { store, mind };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dec = new TextDecoder();
|
|
69
|
+
const textOf = (store, id) =>
|
|
70
|
+
dec.decode(store.bytes(id).filter((x) => x !== 0));
|
|
71
|
+
|
|
72
|
+
test("the fixture really is past the atomIsHub threshold", async () => {
|
|
73
|
+
const { store, mind } = await pastTheFlip();
|
|
74
|
+
const n = corpusN(mind);
|
|
75
|
+
assert.ok(n > 4096, `fixture must cross N=4096, got ${n}`);
|
|
76
|
+
assert.equal(
|
|
77
|
+
atomIsHub(mind, n),
|
|
78
|
+
true,
|
|
79
|
+
"atoms must read as hubs, or this file tests nothing",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("an edge-bearing interior form is still recognised past the flip", async () => {
|
|
84
|
+
const { store, mind } = await pastTheFlip();
|
|
85
|
+
mind.beginResponse?.();
|
|
86
|
+
const rec = recognise(mind, new TextEncoder().encode(ANSWER));
|
|
87
|
+
mind.endResponse?.();
|
|
88
|
+
const texts = rec.sites.map((s) => textOf(store, s.payload));
|
|
89
|
+
assert.ok(
|
|
90
|
+
texts.includes("France"),
|
|
91
|
+
`interior form "France" was not recognised past the flip; sites = ${
|
|
92
|
+
JSON.stringify(texts)
|
|
93
|
+
}`,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("the interior form is reachable as a pivot, so the chain composes", async () => {
|
|
98
|
+
// The behavioural consequence: reason() pivots on the longest unconsumed
|
|
99
|
+
// learnt context the grounded answer CONTAINS. Lose the site and the hop is
|
|
100
|
+
// structurally unreachable, whatever the rest of the pipeline does.
|
|
101
|
+
const { mind } = await pastTheFlip();
|
|
102
|
+
const steps = [];
|
|
103
|
+
const out = await mind.respondText(
|
|
104
|
+
"What is the capital of the country of Eiffel Tower?",
|
|
105
|
+
(s) => steps.push(s.mechanism[s.mechanism.length - 1]),
|
|
106
|
+
);
|
|
107
|
+
assert.ok(
|
|
108
|
+
steps.includes("pivotStep"),
|
|
109
|
+
`no pivotStep past the flip; answer was ${JSON.stringify(out)}`,
|
|
110
|
+
);
|
|
111
|
+
assert.ok(
|
|
112
|
+
out.includes("Paris"),
|
|
113
|
+
`two-hop chain did not compose past the flip: ${JSON.stringify(out)}`,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a fragment that leads nowhere is still NOT recognised", async () => {
|
|
118
|
+
// The other half of the contract: the gate was protecting against
|
|
119
|
+
// opportunistic byte-atom chains, and the fix must not have opened that door.
|
|
120
|
+
// "owe" occurs inside "Eiffel Tower country" but was never deposited as a
|
|
121
|
+
// form of its own, so it bears no edge and no halo.
|
|
122
|
+
const { store, mind } = await pastTheFlip();
|
|
123
|
+
mind.beginResponse?.();
|
|
124
|
+
const rec = recognise(mind, new TextEncoder().encode(ANSWER));
|
|
125
|
+
mind.endResponse?.();
|
|
126
|
+
for (const s of rec.sites) {
|
|
127
|
+
const t = textOf(store, s.payload);
|
|
128
|
+
assert.ok(
|
|
129
|
+
t.length >= 4,
|
|
130
|
+
`sub-window fragment ${JSON.stringify(t)} was admitted as a site`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
// And pure noise must still ground to nothing.
|
|
134
|
+
assert.equal(await mind.respondText("qq8f3kz9 zzxq wvbn"), "");
|
|
135
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// 84-composed-answer-honesty.test.mjs — a two-hop query must COMPOSE or stay
|
|
2
|
+
// SILENT. It must never fabricate: never return an answer built from content
|
|
3
|
+
// belonging to an unrelated deposit.
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS PINS, AND WHAT IT DOES NOT.
|
|
6
|
+
//
|
|
7
|
+
// Measured against a store fed REAL corpus text (Taskmaster dialogue + SmolSent)
|
|
8
|
+
// as distractors, with the chain below held constant:
|
|
9
|
+
//
|
|
10
|
+
// deposits N pivot result
|
|
11
|
+
// ---------------------------------------------------------------------
|
|
12
|
+
// 1,000 1,091 yes "The capital of France is Paris." composed
|
|
13
|
+
// 3,000 3,065 yes "The capital of France is Paris." composed
|
|
14
|
+
// 6,000 6,013 no "Does your order look correct?The country of Eiffel Tower …"
|
|
15
|
+
// 9,000 8,922 no (the same fabrication)
|
|
16
|
+
//
|
|
17
|
+
// Controls held at EVERY scale — "Eiffel Tower country" and "France capital"
|
|
18
|
+
// each answered correctly throughout. So the substrate stays intact and only
|
|
19
|
+
// composition degrades, and when it degrades the store does not fall silent: it
|
|
20
|
+
// GLUES an unrelated dialogue turn onto hop 1 and returns that.
|
|
21
|
+
//
|
|
22
|
+
// This file CANNOT reproduce that. The failure needs real corpus text: a
|
|
23
|
+
// generated dialogue-shaped corpus of the same size composes cleanly at
|
|
24
|
+
// N = 6,003, and lexically-varied word-salad filler composes at N = 9,394. That
|
|
25
|
+
// matches the older observation that synthetic filler is far more forgiving than
|
|
26
|
+
// real text. Shipping the real corpus as a fixture is not an option — size, and
|
|
27
|
+
// the licence rules in DATASETS.md.
|
|
28
|
+
//
|
|
29
|
+
// So what runs here is the CONTRACT, at a scale where the engine currently
|
|
30
|
+
// honours it. It is a regression guard: if a future change makes the store
|
|
31
|
+
// fabricate at low N, this goes red. It does NOT cover the real-text ceiling.
|
|
32
|
+
//
|
|
33
|
+
// TO REPRODUCE THE REAL FAILURE: build the same chain, then ingest ~6,000
|
|
34
|
+
// deposits produced by the Taskmaster adapter (example/train_base.ts §6e′) from
|
|
35
|
+
// TM-2/TM-3/TM-4, and ask the two-hop question. See FINDINGS.md §A1/§A4.
|
|
36
|
+
|
|
37
|
+
import { test } from "node:test";
|
|
38
|
+
import assert from "node:assert/strict";
|
|
39
|
+
import { Mind } from "../dist/src/index.js";
|
|
40
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
41
|
+
|
|
42
|
+
const CHAIN = [
|
|
43
|
+
["Eiffel Tower country", "The country of Eiffel Tower is France."],
|
|
44
|
+
["France capital", "The capital of France is Paris."],
|
|
45
|
+
// The pivot fact: a bare entity that also opens hop 2.
|
|
46
|
+
["France", "The capital of France is Paris."],
|
|
47
|
+
];
|
|
48
|
+
const TWO_HOP = "What is the capital of the country of Eiffel Tower?";
|
|
49
|
+
|
|
50
|
+
// Dialogue-shaped distractors — natural sentences rather than word salad, so
|
|
51
|
+
// the corpus looks like the one the trainer actually deposits.
|
|
52
|
+
const NOUN =
|
|
53
|
+
"coffee latte pizza cinema hotel taxi museum concert train dentist library market garden harbour"
|
|
54
|
+
.split(" ");
|
|
55
|
+
const ADJ =
|
|
56
|
+
"small large iced hot early late extra plain double single quick quiet"
|
|
57
|
+
.split(" ");
|
|
58
|
+
const SUBJ = "order booking ticket table room flight delivery payment account"
|
|
59
|
+
.split(" ");
|
|
60
|
+
const pick = (a, i, n) => a[(i * n) % a.length];
|
|
61
|
+
const filler = (i) => [
|
|
62
|
+
`Can I book ${pick(ADJ, i, 7)} ${pick(NOUN, i, 13)} for ${i}?`,
|
|
63
|
+
`Does your ${pick(SUBJ, i, 11)} look correct? I have ${pick(ADJ, i, 3)} ${
|
|
64
|
+
pick(NOUN, i, 17)
|
|
65
|
+
} number ${i}.`,
|
|
66
|
+
];
|
|
67
|
+
const DEPOSITS = 1500;
|
|
68
|
+
|
|
69
|
+
async function storeWithDistractors() {
|
|
70
|
+
const store = new SQliteStore({ path: ":memory:", D: 1024 });
|
|
71
|
+
const mind = new Mind({ seed: 7, store });
|
|
72
|
+
await mind.ingest(CHAIN);
|
|
73
|
+
await mind.ingest(Array.from({ length: DEPOSITS }, (_, i) => filler(i)));
|
|
74
|
+
return mind;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("each hop still answers on its own — the substrate is intact", async () => {
|
|
78
|
+
// The control that made the real-text measurement conclusive: when
|
|
79
|
+
// composition fails it is NOT because a hop was lost.
|
|
80
|
+
const mind = await storeWithDistractors();
|
|
81
|
+
const hop1 = await mind.respondText("Eiffel Tower country");
|
|
82
|
+
const hop2 = await mind.respondText("France capital");
|
|
83
|
+
assert.ok(
|
|
84
|
+
hop1.includes("France"),
|
|
85
|
+
`hop 1 lost: ${JSON.stringify(hop1)}`,
|
|
86
|
+
);
|
|
87
|
+
assert.ok(
|
|
88
|
+
hop2.includes("Paris"),
|
|
89
|
+
`hop 2 lost: ${JSON.stringify(hop2)}`,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a two-hop query composes or stays silent — it never fabricates", async () => {
|
|
94
|
+
// THE CONTRACT. Three outcomes are conceivable and only two are acceptable:
|
|
95
|
+
// compose -> the answer contains Paris
|
|
96
|
+
// silence -> the empty answer, which is honest (AGENTS §2.13)
|
|
97
|
+
// fabricate-> an assembly carrying content from an unrelated deposit
|
|
98
|
+
// The third is what a store past the real-text ceiling actually does.
|
|
99
|
+
const mind = await storeWithDistractors();
|
|
100
|
+
const answer = await mind.respondText(TWO_HOP);
|
|
101
|
+
|
|
102
|
+
if (answer === "") return; // honest silence is acceptable
|
|
103
|
+
|
|
104
|
+
assert.ok(
|
|
105
|
+
answer.includes("Paris"),
|
|
106
|
+
`neither composed nor silent — fabricated: ${JSON.stringify(answer)}`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Composing is not enough: the answer must not have dragged an unrelated
|
|
110
|
+
// deposit along with it. Every distractor continuation contains "look
|
|
111
|
+
// correct" or "Can I book", and no legitimate answer to this question does.
|
|
112
|
+
for (const foreign of ["look correct", "Can I book", "number "]) {
|
|
113
|
+
assert.ok(
|
|
114
|
+
!answer.includes(foreign),
|
|
115
|
+
`answer glued unrelated deposit content (${JSON.stringify(foreign)}): ${
|
|
116
|
+
JSON.stringify(answer)
|
|
117
|
+
}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("an unanswerable two-hop query stays silent, not inventive", async () => {
|
|
123
|
+
// The same contract where NO chain exists: there is no second hop to find,
|
|
124
|
+
// so the only honest outcomes are silence or an answer about the first hop —
|
|
125
|
+
// never a distractor's sentence.
|
|
126
|
+
const mind = await storeWithDistractors();
|
|
127
|
+
const answer = await mind.respondText(
|
|
128
|
+
"What is the capital of the country of the Statue of Zamunda?",
|
|
129
|
+
);
|
|
130
|
+
for (const foreign of ["look correct", "Can I book"]) {
|
|
131
|
+
assert.ok(
|
|
132
|
+
!answer.includes(foreign),
|
|
133
|
+
`invented an answer from an unrelated deposit: ${JSON.stringify(answer)}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
});
|