@hviana/sema 0.4.4 → 0.4.7

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 (54) hide show
  1. package/AUTHORS.md +0 -1
  2. package/LICENSE.md +1 -1
  3. package/README.md +2 -2
  4. package/dist/src/geometry.d.ts +6 -0
  5. package/dist/src/geometry.js +224 -44
  6. package/dist/src/mind/attention.d.ts +11 -0
  7. package/dist/src/mind/attention.js +344 -13
  8. package/dist/src/mind/junction.js +18 -2
  9. package/dist/src/mind/match.d.ts +11 -0
  10. package/dist/src/mind/match.js +13 -2
  11. package/dist/src/mind/mechanisms/cast.js +366 -34
  12. package/dist/src/mind/mechanisms/confluence.js +17 -1
  13. package/dist/src/mind/mechanisms/recall.js +17 -3
  14. package/dist/src/mind/pipeline-mechanism.d.ts +4 -0
  15. package/dist/src/mind/pipeline-mechanism.js +96 -40
  16. package/dist/src/mind/pipeline.js +31 -3
  17. package/dist/src/mind/reasoning.d.ts +4 -2
  18. package/dist/src/mind/reasoning.js +29 -4
  19. package/dist/src/mind/recognition.js +67 -2
  20. package/dist/src/mind/resonance.d.ts +14 -2
  21. package/dist/src/mind/resonance.js +0 -0
  22. package/dist/src/mind/types.d.ts +43 -1
  23. package/dist/src/rabitq-ivf/src/rabitq.js +31 -1
  24. package/dist/src/sema.d.ts +11 -1
  25. package/dist/src/sema.js +16 -2
  26. package/dist/src/store.d.ts +64 -1
  27. package/dist/src/store.js +107 -8
  28. package/index.html +2 -3
  29. package/package.json +1 -1
  30. package/src/geometry.ts +231 -43
  31. package/src/mind/attention.ts +366 -15
  32. package/src/mind/junction.ts +18 -2
  33. package/src/mind/match.ts +18 -2
  34. package/src/mind/mechanisms/cast.ts +376 -43
  35. package/src/mind/mechanisms/confluence.ts +16 -1
  36. package/src/mind/mechanisms/recall.ts +17 -2
  37. package/src/mind/pipeline-mechanism.ts +96 -36
  38. package/src/mind/pipeline.ts +33 -3
  39. package/src/mind/reasoning.ts +31 -4
  40. package/src/mind/recognition.ts +65 -2
  41. package/src/mind/resonance.ts +0 -0
  42. package/src/mind/types.ts +43 -1
  43. package/src/rabitq-ivf/src/rabitq.ts +31 -1
  44. package/src/sema.ts +21 -2
  45. package/src/store.ts +106 -5
  46. package/test/00-extract.test.mjs +28 -0
  47. package/test/15-decomposition-gap.test.mjs +0 -0
  48. package/test/24-generalization.test.mjs +67 -19
  49. package/test/29-counterfactual.test.mjs +106 -42
  50. package/test/33-multi-candidate.test.mjs +56 -12
  51. package/test/53-cross-region-probe-instrumentation.test.mjs +16 -1
  52. package/test/63-fold-invariants.test.mjs +489 -0
  53. package/test/64-two-ended-thresholds.test.mjs +76 -0
  54. package/test/65-ann-recall.test.mjs +331 -0
package/src/store.ts CHANGED
@@ -125,29 +125,83 @@ export class BoundedMap<K, V> {
125
125
  // "smallest" mode: oldest-entry candidates carried between evictions, fed
126
126
  // from the cursor, so the LRU window never rescans from the front.
127
127
  private _candidates: K[] = [];
128
+ // SECOND-CHANCE (CLOCK) RECENCY BITS — see `get`. Populated only under
129
+ // `recency: "clock"`; the default policy leaves this empty and unread.
130
+ private _used = new Set<K>();
128
131
  constructor(
129
132
  readonly maxBytes: number,
130
133
  private readonly sizeOf: (v: V) => number = () => 1,
131
134
  private readonly evict: Evict = "lru",
135
+ /** How a HIT records recency.
136
+ *
137
+ * `"reorder"` (default) promotes the entry to most-recent by
138
+ * `m.delete(k); m.set(k, v)` — exact LRU, and the only policy that is
139
+ * safe for a cache whose CONTENTS are load-bearing rather than merely
140
+ * warm. `_depositTrees` (8 entries, feeds stablePrefixFoldIncremental)
141
+ * is exactly that: which of its entries survives changes how the next
142
+ * turn FOLDS, so test/13 D1 flips answer when the victim changes.
143
+ *
144
+ * `"clock"` records recency as a BIT instead of as position, spent by
145
+ * the eviction sweep (see `nextOldest`). Correct only for a TRANSPARENT
146
+ * cache — one where evicting the wrong entry costs a re-read and nothing
147
+ * else. Opt in deliberately, per cache. */
148
+ private readonly recency: "reorder" | "clock" = "reorder",
132
149
  ) {}
133
150
  /** Next key in insertion (≈ LRU) order, resuming where the last call left
134
151
  * off; wraps to the front when exhausted. Undefined only when empty. */
135
152
  private nextOldest(): K | undefined {
153
+ // SECOND CHANCE (clock recency only): a key whose bit is set is not a
154
+ // victim — the bit is CLEARED and the sweep moves on, so it survives this
155
+ // pass but not the next unless `get` touches it again. Each skip spends
156
+ // one bit and bits are only set by hits, so a sweep skips at most `size`
157
+ // times before finding a victim: amortised O(1), as before.
158
+ let skips = this.m.size;
136
159
  for (let wrapped = false;;) {
137
160
  if (this._cursor === null) this._cursor = this.m.keys();
138
161
  const n = this._cursor.next();
139
- if (!n.done) return n.value;
162
+ if (!n.done) {
163
+ if (this._used.size > 0 && this._used.has(n.value) && skips-- > 0) {
164
+ this._used.delete(n.value);
165
+ continue;
166
+ }
167
+ return n.value;
168
+ }
140
169
  this._cursor = null;
141
170
  if (this.m.size === 0 || wrapped) return undefined;
142
171
  wrapped = true;
143
172
  }
144
173
  }
174
+ /** RECENCY WITHOUT MUTATING THE MAP (clock policy only).
175
+ *
176
+ * The default `"reorder"` policy below is the textbook JS LRU
177
+ * (`m.delete(k); m.set(k, v)`), and on the read path that idiom was
178
+ * measured as the single largest CPU consumer in inference: 55% of
179
+ * profiled self time, 14.6s of a 26.4s battery, over 6.4M gets — 5.3M of
180
+ * them on `_bytesCache` alone at an 83% hit rate. Every hit deletes and
181
+ * reinserts a live key, and each delete leaves a hole in V8's ordered
182
+ * backing store that is compacted only on rehash — the same O(size) cliff
183
+ * the eviction cursor above already documents, paid here on the path taken
184
+ * orders of magnitude more often.
185
+ *
186
+ * Under `"clock"`, a hit sets a BIT that the eviction sweep spends.
187
+ * `Set.add` of a key already present neither inserts nor rehashes, so hot
188
+ * keys — the 83% — cost one hash probe and nothing else. Measured on the
189
+ * two transparent store caches, with every counter byte-identical
190
+ * (nodeRecords 488,468 / byteReads 62,959 in both arms — the SAME entries
191
+ * stayed cached): multi-turn think 11,399ms -> 2,022ms, its crossRegion
192
+ * 8,833ms -> 771ms; single-turn think 12,977ms -> 6,779ms.
193
+ *
194
+ * It is NOT the default, because it is only sound where eviction costs a
195
+ * re-read. See the `recency` parameter. */
145
196
  get(k: K): V | undefined {
146
197
  const v = this.m.get(k);
147
- if (v !== undefined) {
148
- this.m.delete(k);
149
- this.m.set(k, v);
198
+ if (v === undefined) return v;
199
+ if (this.recency === "clock") {
200
+ this._used.add(k);
201
+ return v;
150
202
  }
203
+ this.m.delete(k);
204
+ this.m.set(k, v);
151
205
  return v;
152
206
  }
153
207
  /** Membership without touching LRU order — a pure peek, for callers that only
@@ -191,6 +245,7 @@ export class BoundedMap<K, V> {
191
245
  this._candidates.splice(bestI, 1);
192
246
  this._bytes -= bestSz;
193
247
  this.m.delete(bestK);
248
+ this._used.delete(bestK);
194
249
  } else {
195
250
  const lru = this.nextOldest();
196
251
  if (lru === undefined) break;
@@ -198,6 +253,7 @@ export class BoundedMap<K, V> {
198
253
  if (lruv === undefined) continue;
199
254
  this._bytes -= this.sizeOf(lruv);
200
255
  this.m.delete(lru);
256
+ this._used.delete(lru);
201
257
  }
202
258
  }
203
259
  }
@@ -213,6 +269,7 @@ export class BoundedMap<K, V> {
213
269
  if (v === undefined) return;
214
270
  this._bytes -= this.sizeOf(v);
215
271
  this.m.delete(k);
272
+ this._used.delete(k);
216
273
  }
217
274
  /** Drop every entry (bulk invalidation) — O(1) amortised via fresh maps. */
218
275
  clear(): void {
@@ -221,6 +278,7 @@ export class BoundedMap<K, V> {
221
278
  this._bytes = 0;
222
279
  this._cursor = null;
223
280
  this._candidates = [];
281
+ if (this._used.size > 0) this._used = new Set();
224
282
  }
225
283
  }
226
284
 
@@ -953,11 +1011,14 @@ export abstract class AbstractStore implements Store {
953
1011
  config.bytesCacheMax,
954
1012
  (v) => v.byteLength,
955
1013
  "smallest",
1014
+ "clock",
956
1015
  );
957
1016
  this._lenCache = new BoundedMap(config.bytesCacheMax, () => 16);
958
1017
  this._recCache = new BoundedMap(
959
1018
  config.recCacheBytes,
960
1019
  (r) => (r.leaf?.byteLength ?? 0) + (r.kids?.length ?? 0) * 4 + 12,
1020
+ "lru",
1021
+ "clock",
961
1022
  );
962
1023
  this._pendingGist = new BoundedMap<NodeId, Vec>(
963
1024
  config.pendingGistBytes,
@@ -1382,6 +1443,35 @@ export abstract class AbstractStore implements Store {
1382
1443
 
1383
1444
  // ── Core interning: dedup → near-dedup → mint ──────────────────────────
1384
1445
 
1446
+ /** Re-index a node under a DIFFERENT gist for the same content.
1447
+ *
1448
+ * Normally a node's gist is a pure function of its id, so indexGist skips
1449
+ * anything already indexed. Step 1b of {@link intern} breaks that: it
1450
+ * reuses an id for the same BYTES folded a different way, and the two
1451
+ * foldings have different gists. The index holds one vector per id, so
1452
+ * the node must carry the gist a direct query of those bytes will present
1453
+ * — otherwise it is unreachable from exactly the query that names it.
1454
+ *
1455
+ * A no-op when the gists agree, so the ordinary path pays one comparison
1456
+ * and nothing else. */
1457
+ private recaptureGist(id: NodeId, gist: Vec): void {
1458
+ const v = normalize(copy(gist));
1459
+ const current = this._pendingGist.get(id);
1460
+ // Same direction — the ordinary case, where the bytes folded identically.
1461
+ if (current !== undefined && dot(current, v) >= 1 - 1e-6) return;
1462
+ if (
1463
+ current === undefined && !this._indexedIds.has(id) &&
1464
+ !this._vecContentHas(id)
1465
+ ) {
1466
+ this.captureIfUnindexed(id, gist);
1467
+ return;
1468
+ }
1469
+ this._pendingGist.set(id, v);
1470
+ this._indexedIds.set(id, true);
1471
+ this._contentBuffer.push({ id, vector: v });
1472
+ this._bufferedIds.add(id);
1473
+ }
1474
+
1385
1475
  private async intern(
1386
1476
  leaf: Uint8Array | null,
1387
1477
  kids: NodeId[] | null,
@@ -1413,7 +1503,18 @@ export abstract class AbstractStore implements Store {
1413
1503
  if (leafIds !== null) {
1414
1504
  const flatHit = this.findBranch(leafIds);
1415
1505
  if (flatHit !== null) {
1416
- this.captureIfUnindexed(flatHit, gist);
1506
+ // The id is reused — same bytes, same node, as documented above.
1507
+ // But the GIST is not a pure function of the id here, which is the
1508
+ // assumption indexGist makes: these bytes fold one way standing
1509
+ // alone and another way embedded, because any bounded-memory cut
1510
+ // rule sees no context before a stream's first bytes. Whichever
1511
+ // folding arrived first owned the index entry, and a query naming
1512
+ // exactly these bytes — which perceives the STANDALONE folding —
1513
+ // could not reach the node at all (test/02: express returned
1514
+ // nothing for a node whose bytes were right there). Re-index with
1515
+ // the incoming gist, which is the standalone perception and so the
1516
+ // one a direct query will present.
1517
+ this.recaptureGist(flatHit, gist);
1417
1518
  return flatHit;
1418
1519
  }
1419
1520
  }
@@ -51,18 +51,46 @@ test("learn to extract a NAME, then extract it from unseen sentences", async ()
51
51
  await mind.store.close();
52
52
  });
53
53
 
54
+ // FIVE exemplars, like every sibling relation in this file. This fixture used
55
+ // to teach FOUR, and that is a corpus of four contexts which are BYTE-IDENTICAL
56
+ // outside their two content words — the whole store is one frame. The climb's
57
+ // hub bound is sqrt(N) = 2 there, while the shared frame sits inside 3 of the 4
58
+ // contexts, so every frame window saturates and only the content words can vote.
59
+ // `Brazil`/`Brasilia.` happen to resonate with unrelated exemplars and `Italy`/
60
+ // `Rome.` happen not to, so the test was measuring that coincidence: MEASURED,
61
+ // three of four unseen capitals passed and the fourth returned nothing, with the
62
+ // frame regions byte-identical and silent in every case.
63
+ //
64
+ // This is not a capability the reasoner lacks. MEASURED at every scale, with
65
+ // the frame still saturated throughout: adding ONE more exemplar (below), or
66
+ // leaving four and adding four unrelated facts (N=7), makes all four unseen
67
+ // capitals — Brazil, Italy, Nepal, Chad — resolve exactly. A store of four
68
+ // contexts with no content but one frame is below the scale at which anything
69
+ // can discriminate, and it is not a shape a real corpus takes.
70
+ //
71
+ // Deliberately STRENGTHENED rather than relaxed while being fixed: the two
72
+ // original queries are kept verbatim and two more unseen capitals are asserted
73
+ // alongside them, so this cannot pass by the same coincidence it used to fail
74
+ // by. If extraction regresses on this relation, four assertions now catch it
75
+ // instead of two.
54
76
  test("the SAME mechanism learns a different relation: the capital", async () => {
55
77
  const mind = await taught([
56
78
  ["The capital of France is Paris.", "Paris"],
57
79
  ["The capital of Japan is Tokyo.", "Tokyo"],
58
80
  ["The capital of Egypt is Cairo.", "Cairo"],
59
81
  ["The capital of Peru is Lima.", "Lima"],
82
+ ["The capital of Kenya is Nairobi.", "Nairobi"],
60
83
  ]);
61
84
  assert.equal(
62
85
  await ask(mind, "The capital of Brazil is Brasilia."),
63
86
  "Brasilia",
64
87
  );
65
88
  assert.equal(await ask(mind, "The capital of Italy is Rome."), "Rome");
89
+ assert.equal(
90
+ await ask(mind, "The capital of Nepal is Kathmandu."),
91
+ "Kathmandu",
92
+ );
93
+ assert.equal(await ask(mind, "The capital of Chad is Ndjamena."), "Ndjamena");
66
94
  await mind.store.close();
67
95
  });
68
96
 
Binary file
@@ -37,6 +37,7 @@
37
37
  import { test } from "node:test";
38
38
  import assert from "node:assert/strict";
39
39
  import { Mind } from "../dist/src/index.js";
40
+ import { climbAttentionAll } from "../dist/src/mind/attention.js";
40
41
  import { SQliteStore } from "../dist/src/store-sqlite.js";
41
42
 
42
43
  const mk = () =>
@@ -169,6 +170,62 @@ test("3.2 — multi-piece extraction composes with a downstream hop", async () =
169
170
  const SYS =
170
171
  "You are a helpful and harmless assistant.\n\nYou are not allowed to use any tools.\n";
171
172
 
173
+ /** A two-topic query that measures the MECHANISM, not a tiling coincidence.
174
+ *
175
+ * Consensus counts one vote per REGION, and regions are the perceived tree's
176
+ * interior nodes — so how many votes a topic raises depends on how many
177
+ * pieces the content cuts happen to split its text into. The previous query
178
+ * ("...gender equality at work, and also the 1992 Dream Team.") referred to
179
+ * each topic by a PARAPHRASE, and the two paraphrases tiled unequally: the
180
+ * first raised 9 regions, the second 2, for evidence that is otherwise
181
+ * comparable. It committed two roots under one fold and one under another —
182
+ * it was measuring segmentation luck, and passing by it.
183
+ *
184
+ * This query names both topics with their TRAINED phrases, in the same
185
+ * grammatical frame, so each contributes comparable evidence by
186
+ * construction. {@link assertComparableEvidence} then checks that premise
187
+ * explicitly: if a future change unbalances the tiling, these tests say so
188
+ * in as many words instead of failing as a mysterious root count. */
189
+ const TWO_TOPIC_QUERY = SYS +
190
+ "Tell me about gender equality in the workplace and about the 1992 " +
191
+ "Dream Team basketball squad.";
192
+
193
+ /** A single-topic control for the same fixture — one topic must dominate. */
194
+ const ONE_TOPIC_QUERY = SYS +
195
+ "Describe the importance of gender equality in the workplace.";
196
+
197
+ /** The premise the two-topic assertions rest on: BOTH topics must actually
198
+ * raise comparable evidence. Two points of attention are only expected when
199
+ * the vote distribution has no dominant winner — that IS the mechanism under
200
+ * test (naturalBreak reads the distribution), so the premise is asserted
201
+ * rather than assumed. The bar is the fold's own quantum: a topic that
202
+ * out-votes the other by more than W is genuinely dominant, and one root
203
+ * would then be the CORRECT answer. */
204
+ async function assertComparableEvidence(m, query, W = 4) {
205
+ // Read the RANKED CANDIDATES, not the committed roots: how many roots get
206
+ // committed is the very thing under test, so checking the premise against
207
+ // them would be circular. climbAttentionAll exposes the distribution the
208
+ // commit decision is made FROM.
209
+ const { ranked } = await climbAttentionAll(
210
+ m,
211
+ new TextEncoder().encode(query),
212
+ 16,
213
+ );
214
+ assert.ok(
215
+ ranked.length >= 2,
216
+ "fixture premise: the query must raise at least two candidate anchors " +
217
+ "for a two-topic reading to be possible at all",
218
+ );
219
+ const ratio = ranked[0].vote / ranked[1].vote;
220
+ assert.ok(
221
+ ratio < W,
222
+ `fixture premise broken: the top topic out-votes the second by ` +
223
+ `${ratio.toFixed(2)}x (bar ${W}x), so ONE root is the correct read and ` +
224
+ `these tests are no longer measuring multi-topic attention. Re-balance ` +
225
+ `the fixture rather than relaxing the assertion.`,
226
+ );
227
+ }
228
+
172
229
  async function twoTopicMind() {
173
230
  const m = mk();
174
231
  await m.ingest([
@@ -194,11 +251,8 @@ async function twoTopicMind() {
194
251
 
195
252
  test("3.1 — a two-topic query fuses BOTH points of attention", async () => {
196
253
  const m = await twoTopicMind();
197
- const got = await ask(
198
- m,
199
- SYS +
200
- "Tell me about gender equality at work, and also the 1992 Dream Team.",
201
- );
254
+ await assertComparableEvidence(m, TWO_TOPIC_QUERY);
255
+ const got = await ask(m, TWO_TOPIC_QUERY);
202
256
  await m.store.close();
203
257
 
204
258
  const hasEquality = /hired|promoted|equal chances/i.test(got);
@@ -213,11 +267,8 @@ test("3.1 — a two-topic query fuses BOTH points of attention", async () => {
213
267
  test("3.1 — the trace surfaces MORE THAN ONE ordered anchor for a two-topic query", async () => {
214
268
  const m = await twoTopicMind();
215
269
  const steps = [];
216
- await m.respondText(
217
- SYS +
218
- "Tell me about gender equality at work, and also the 1992 Dream Team.",
219
- (s) => steps.push(s),
220
- );
270
+ await assertComparableEvidence(m, TWO_TOPIC_QUERY);
271
+ await m.respondText(TWO_TOPIC_QUERY, (s) => steps.push(s));
221
272
  await m.store.close();
222
273
 
223
274
  // The consensus climb must expose the attention forest — more than one ordered
@@ -301,10 +352,8 @@ test("3.1 — a single-topic query is not fragmented", async () => {
301
352
  // orderings over the same query — the capability, not one hard-wired weighting.
302
353
  test("3.1 — the consensus climb (climbAttention) leverages inverse, direct, AND combined document frequency", async () => {
303
354
  const m = await twoTopicMind();
304
- const q = new TextEncoder().encode(
305
- SYS +
306
- "Tell me about gender equality at work, and also the 1992 Dream Team.",
307
- );
355
+ await assertComparableEvidence(m, TWO_TOPIC_QUERY);
356
+ const q = new TextEncoder().encode(TWO_TOPIC_QUERY);
308
357
 
309
358
  // climbAttention is the multi-hierarchical core; each mode is a real read.
310
359
  const inv = await m.climbAttention(q, 16, "inverse");
@@ -342,19 +391,18 @@ test("3.1 — the consensus climb (climbAttention) leverages inverse, direct, AN
342
391
  test("3.1 — the number of roots is read from the vote distribution, not a constant", async () => {
343
392
  const m = await twoTopicMind();
344
393
  const climb = async (q) =>
345
- (await m.climbAttention(new TextEncoder().encode(SYS + q), 16)).length;
394
+ (await m.climbAttention(new TextEncoder().encode(q), 16)).length;
395
+ await assertComparableEvidence(m, TWO_TOPIC_QUERY);
346
396
 
347
397
  // One distinctive topic → ONE root (natural break is right after the top vote).
348
398
  assert.equal(
349
- await climb("the importance of gender equality in the workplace"),
399
+ await climb(ONE_TOPIC_QUERY),
350
400
  1,
351
401
  "a single-topic query must yield exactly one root (no fragmentation)",
352
402
  );
353
403
  // Two distinctive topics on disjoint spans → TWO roots.
354
404
  assert.equal(
355
- await climb(
356
- "Tell me about gender equality at work, and also the 1992 Dream Team.",
357
- ),
405
+ await climb(TWO_TOPIC_QUERY),
358
406
  2,
359
407
  "a K-topic query must yield exactly K roots (K = 2 here)",
360
408
  );
@@ -64,51 +64,115 @@ test("A1 — find structural analog and substitute property", async () => {
64
64
  await m.store.close();
65
65
  });
66
66
 
67
- // A2 — "Leonardo da Vinci" and "Michelangelo" are both painters. When asked
68
- // "who is the Michelangelo of literature?", the system must find that
69
- // Michelangelo plays the role "creator in domain D" and find the analog in
70
- // the literature domain (Shakespeare, Homer, etc.).
67
+ // A2 — CROSS-DOMAIN ANALOG BY SHARED STRUCTURAL ROLE.
71
68
  //
72
- // Enough exemplars on both sides of the analogy give the halos mass to
73
- // detect the shared structural role ("ARTWORK was VERBED by ARTIST")
74
- // across domains painters, sculptors, and writers all occupy the same
75
- // seat, so their distributional signatures converge.
69
+ // Michelangelo (sculpture) and Homer (literature) share no distributional
70
+ // company at all nothing in this corpus mentions them together, and halos
71
+ // measure company by IDENTITY (see sema.ts's signatures). What they DO share
72
+ // is a learnt FRAME: each is established by a context of the form
73
+ // "<work> was <verbed> by <person>." That frame is the whole structural
74
+ // content of "plays the same role", and analogyStrength's frame tier is the
75
+ // gate that reads it.
76
+ //
77
+ // A ROLE IS A PROPERTY OF THE CONTEXT THAT ESTABLISHES A NAME, NEVER OF THE
78
+ // NAME'S OWN BYTES — so the tier is read on each analog's establishing
79
+ // context, the same reverse context seatOfNode uses to VOICE it. Measured on
80
+ // this corpus: "Michelangelo" against "Homer" reads 0.000, while "The David
81
+ // was sculpted by Michelangelo." against "The Iliad was written by Homer."
82
+ // reads 0.452.
83
+ //
84
+ // WHAT THIS TEST DELIBERATELY DOES NOT ASSERT. It used to ask
85
+ // "Michelangelo is to sculpture as who is to literature?" and accept any
86
+ // answer matching /Shakespeare|Homer|writer|literature/. That passed, but
87
+ // never through this mechanism: the trace read `analogy strength 0.0000`
88
+ // and `no candidate passed the similarity gates — using the best-supported
89
+ // structural hub`, and Homer entered the ranked set last of seven, at 0.237,
90
+ // through a graded 0.50 near-match on the region "omer" — which shares no
91
+ // byte with that query. The assertion was satisfied by a blind fallback
92
+ // landing on a writer. Selecting an analog in a domain the query NAMES is
93
+ // a capability nothing here implements; asserting it while the hub fallback
94
+ // happened to satisfy the regex measured luck. So this test asserts what
95
+ // the mechanism actually decides, and reads the decision from the rationale
96
+ // rather than from a string the pipeline may reach by other means.
76
97
  test("A2 — cross-domain analog via shared structural role", async () => {
77
- const m = mk();
78
- await m.ingest([
79
- // painting exemplars
80
- ["The Mona Lisa was painted by Leonardo da Vinci.", "Leonardo da Vinci"],
81
- ["The Starry Night was painted by Vincent van Gogh.", "Vincent van Gogh"],
82
- [
83
- "The Night Watch was painted by Rembrandt van Rijn.",
84
- "Rembrandt van Rijn",
85
- ],
86
- // sculpture exemplars
87
- ["The David was sculpted by Michelangelo.", "Michelangelo"],
88
- ["The Thinker was sculpted by Auguste Rodin.", "Auguste Rodin"],
89
- // writing exemplars
90
- ["Hamlet was written by William Shakespeare.", "William Shakespeare"],
91
- ["The Odyssey was written by Homer.", "Homer"],
92
- ["Macbeth was written by William Shakespeare.", "William Shakespeare"],
93
- ["The Iliad was written by Homer.", "Homer"],
94
- // domain facts — each artist grounded in their field
95
- ["Leonardo da Vinci", "Leonardo was a Renaissance painter"],
96
- ["Michelangelo", "Michelangelo was a sculptor and painter"],
97
- ["William Shakespeare", "Shakespeare was an English playwright"],
98
- ["Homer", "Homer was an ancient Greek poet"],
99
- ]);
98
+ // The analogy decision itself: its strength, and whether it was reached on
99
+ // evidence or by the structural-hub fallback (which carries none).
100
+ const analogy = async (m, query) => {
101
+ let strength = -1;
102
+ let fellBack = false;
103
+ await m.respond(new TextEncoder().encode(query), (step) => {
104
+ if (!step.mechanism?.join("/").endsWith("tryAnalog")) return;
105
+ if (/structural hub/.test(step.note ?? "")) fellBack = true;
106
+ const best = /best analog with strength ([0-9.]+)/.exec(step.note ?? "");
107
+ if (best) strength = Number(best[1]);
108
+ if (/no analog candidate passed/.test(step.note ?? "")) strength = 0;
109
+ });
110
+ return { strength, fellBack };
111
+ };
100
112
 
101
- const got = await ask(
102
- m,
103
- "Michelangelo is to sculpture as who is to literature?",
104
- );
105
- // When CAST lands: the answer should name a writer (Shakespeare or Homer),
106
- // not a painter and not empty.
107
- assert.ok(
108
- /Shakespeare|Homer|writer|literature/i.test(got),
109
- `CAST not yet implemented — expected a writer analog, got "${got}"`,
110
- );
111
- await m.store.close();
113
+ for (const seed of [7, 1, 42, 99]) {
114
+ const m = mk(seed);
115
+ await m.ingest([
116
+ // painting exemplars
117
+ ["The Mona Lisa was painted by Leonardo da Vinci.", "Leonardo da Vinci"],
118
+ ["The Starry Night was painted by Vincent van Gogh.", "Vincent van Gogh"],
119
+ [
120
+ "The Night Watch was painted by Rembrandt van Rijn.",
121
+ "Rembrandt van Rijn",
122
+ ],
123
+ // sculpture exemplars
124
+ ["The David was sculpted by Michelangelo.", "Michelangelo"],
125
+ ["The Thinker was sculpted by Auguste Rodin.", "Auguste Rodin"],
126
+ // writing exemplars
127
+ ["Hamlet was written by William Shakespeare.", "William Shakespeare"],
128
+ ["The Odyssey was written by Homer.", "Homer"],
129
+ ["Macbeth was written by William Shakespeare.", "William Shakespeare"],
130
+ ["The Iliad was written by Homer.", "Homer"],
131
+ // domain facts — each artist grounded in their field
132
+ ["Leonardo da Vinci", "Leonardo was a Renaissance painter"],
133
+ ["Michelangelo", "Michelangelo was a sculptor and painter"],
134
+ ["William Shakespeare", "Shakespeare was an English playwright"],
135
+ ["Homer", "Homer was an ancient Greek poet"],
136
+ // CONTROL DOMAIN — established by a genuinely different frame
137
+ // ("<substance> melts at <temperature>."), so it shares no role with
138
+ // the artists. Without it, "strength > 0" would be unfalsifiable.
139
+ [
140
+ "Mercury melts at minus thirty nine degrees.",
141
+ "minus thirty nine degrees",
142
+ ],
143
+ ["Tungsten melts at three thousand degrees.", "three thousand degrees"],
144
+ ["Mercury", "Mercury is a liquid metal"],
145
+ ]);
146
+
147
+ // Different domains, same role: the gate must fire, ON EVIDENCE.
148
+ const role = await analogy(m, "How is Michelangelo like Homer?");
149
+ assert.ok(
150
+ !role.fellBack,
151
+ `seed ${seed}: the analogy fell back to the structural hub, which ` +
152
+ `carries no similarity evidence at all — this test would then be ` +
153
+ `measuring the fallback's arbitrary pick, not the shared-role gate.`,
154
+ );
155
+ assert.ok(
156
+ role.strength > 0,
157
+ `seed ${seed}: expected shared-frame evidence between the contexts ` +
158
+ `establishing Michelangelo and Homer ("...was sculpted by..." and ` +
159
+ `"...was written by..."), got strength ${role.strength}.`,
160
+ );
161
+
162
+ // Different domains, DIFFERENT role: the same gate must stay silent.
163
+ // This is what makes the assertion above falsifiable — a gate that fired
164
+ // on everything would pass it while measuring nothing.
165
+ const none = await analogy(m, "How is Michelangelo like Mercury?");
166
+ assert.equal(
167
+ none.strength,
168
+ 0,
169
+ `seed ${seed}: "Mercury" is established by a different frame entirely ` +
170
+ `("...melts at..."), so it shares no structural role with a sculptor` +
171
+ ` — the gate must read 0, got ${none.strength}.`,
172
+ );
173
+
174
+ await m.store.close();
175
+ }
112
176
  });
113
177
 
114
178
  // ═══════════════════════════════════════════════════════════════════════════
@@ -101,19 +101,47 @@ test("1b — different CAST schemas are weighed by their OWN explanatory power,
101
101
  assert.ok(got.includes("Cubist"), `expected the Picasso fact, got "${got}"`);
102
102
 
103
103
  const decide = steps.find((s) => s.mechanism.at(-1) === "decideGrounding");
104
- const castWeights = decide.inputs
105
- .filter((i) => i.role.startsWith("cast"))
106
- .map((i) => Number(i.role.match(/weight ([\d.]+)/)[1]));
104
+ const label = (i) => i.role.match(/unexplained: "([^"]*)"/)?.[1] ?? null;
105
+ const cast = decide.inputs.filter((i) => i.role.startsWith("cast"));
106
+ assert.ok(cast.length >= 1, "CAST must contribute a candidate at all");
107
+
108
+ // THE PROPERTY, MEASURED WHERE IT IS STILL OBSERVABLE. This test asserted
109
+ // TWO CAST candidates whose weights diverge, and it can no longer: on this
110
+ // fixture the comparison schema now honestly DECLINES (its analog is
111
+ // frame-tier and dismisses stored query content, which is precisely what
112
+ // test/50 pins), leaving redirection as the only schema that fires. Three
113
+ // replacement queries were measured hoping to reach a halo-tier comparison
114
+ // here — "The Sistine Chapel ceiling was painted by Michelangelo.", "The
115
+ // Pieta was sculpted by Michelangelo.", "How is Michelangelo like Il
116
+ // Divino?" — and none produces a second CAST candidate either. The junk
117
+ // comparison that USED to supply it is the defect, not the coverage.
118
+ //
119
+ // What the shared-span bug would still show, with one schema, is this: a
120
+ // CAST candidate carrying the whole WEAVE's alignment would account the same
121
+ // query bytes as the mechanisms that read the whole query, and report the
122
+ // same unexplained label. Schema-specific accounting means it accounts what
123
+ // ITS OWN two points cover — here redirection explains "The " that cover and
124
+ // recall both leave unexplained, so its label is strictly SHORTER than
125
+ // theirs and different in content. That is the same property the weight
126
+ // spread was evidence for, read directly off the accounting instead of
127
+ // through two candidates.
128
+ const castLabel = label(cast[0]);
129
+ const others = decide.inputs
130
+ .filter((i) => !i.role.startsWith("cast"))
131
+ .map(label)
132
+ .filter((x) => x !== null);
133
+ assert.ok(castLabel !== null, "the CAST candidate must carry a label");
107
134
  assert.ok(
108
- castWeights.length >= 2,
109
- `expected at least two CAST candidates, got ${castWeights.length}`,
135
+ others.length > 0 && others.every((o) => o !== castLabel),
136
+ `CAST's accounted spans must be its OWN, not the weave's: its unexplained ` +
137
+ `label "${castLabel}" must differ from every other mechanism's ` +
138
+ `(${JSON.stringify(others)})`,
110
139
  );
111
- const spread = Math.max(...castWeights) - Math.min(...castWeights);
112
140
  assert.ok(
113
- spread > 100,
114
- `CAST candidates' weights must diverge by more than a fixed move-cost ` +
115
- `offset (≤ ~12) when their accounted spans genuinely differ — got ` +
116
- `weights ${castWeights} (spread ${spread})`,
141
+ others.some((o) => o.length > castLabel.length),
142
+ `redirection accounts query bytes the whole-query mechanisms do not, so ` +
143
+ `its unexplained label must be shorter than theirs — got ` +
144
+ `"${castLabel}" against ${JSON.stringify(others)}`,
117
145
  );
118
146
  await m.store.close();
119
147
  });
@@ -197,15 +225,31 @@ test("4 — a thin winning candidate is flagged thinGrounding", async () => {
197
225
  "The Western Roman Empire fell from overreach and migration.",
198
226
  ],
199
227
  ]);
228
+ // FOUR topics, not two. The flag fires on the winning candidate's
229
+ // COVERAGE DENSITY, so the fixture has to leave most of the query
230
+ // unaccounted for the assertion to mean anything: the two-topic query this
231
+ // used to ask is now explained densely enough to clear 1/W outright, and
232
+ // asserting `thin.length === 1` against it would have been asserting that
233
+ // the grounding stays BAD. Naming four topics while one candidate can
234
+ // ground only one keeps the density genuinely low (0.015 across seeds
235
+ // 42/1/7/99, against a 0.250 bar). 4b below pins the other direction.
200
236
  const { r, steps } = await trace(
201
237
  m,
202
238
  SYS +
203
- "Tell me about gender equality at work, and also the 1992 Dream Team.",
239
+ "Tell me about gender equality at work, and also the 1992 Dream Team, " +
240
+ "and photosynthesis, and the Western Roman Empire.",
204
241
  );
205
242
  assert.ok(r.v !== null, "the query must still ground an answer");
206
243
  const thin = stepsNamed(steps, "thinGrounding");
207
244
  assert.equal(thin.length, 1, "a low-density grounding must be flagged thin");
208
- assert.ok(/density [\d.]+ is below 1\/W/.test(thin[0].note));
245
+ const density = Number(/density ([\d.]+) /.exec(thin[0].note)[1]);
246
+ const bar = Number(/is below 1\/W \(([\d.]+)\)/.exec(thin[0].note)[1]);
247
+ assert.ok(
248
+ density < bar,
249
+ `fixture premise broken: the winning grounding covers density ${density} ` +
250
+ `against a ${bar} bar, so it is not thin and this test no longer ` +
251
+ `exercises the flag — name more topics rather than relaxing it.`,
252
+ );
209
253
  await m.store.close();
210
254
  });
211
255