@hviana/sema 0.7.6 → 0.7.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.
@@ -73,6 +73,12 @@ export type GItem = {
73
73
  * derivation actually CHOSE. Part of {@link key}, because it decides
74
74
  * whether the span's final bytes may still change. */
75
75
  fix?: boolean;
76
+ /** Set on the out a JOIN produced: a produced fact's own contained entity
77
+ * (the subject the query never named) combined with the query's adjacent
78
+ * relation span named a learned key, and that key's continuation is this
79
+ * out. Part of {@link key} so the joined reading is a distinct chart item
80
+ * from the plain concatenation of the same bytes. */
81
+ join?: boolean;
76
82
  };
77
83
  export declare const STEP = 1;
78
84
  export declare const CONCEPT = 10;
@@ -136,7 +142,7 @@ export interface DerivationItem {
136
142
  * {@link GraphSearch}'s rules fired, recovered from the rule's premise/
137
143
  * conclusion shape (the rules carry no label, so this classifies by structure,
138
144
  * the single place that maps rule geometry to a name). */
139
- export type DerivationMove = "axiom" | "follow-edge" | "concept-hop" | "voice" | "ground" | "splice-connector" | "split" | "fuse" | "recompose" | "bridge" | "pool-vote" | "step";
145
+ export type DerivationMove = "axiom" | "follow-edge" | "concept-hop" | "voice" | "ground" | "splice-connector" | "split" | "fuse" | "recompose" | "join-fact" | "bridge" | "pool-vote" | "step";
140
146
  /** The lightest-derivation search over the Sema graph. One instance binds the
141
147
  * store, `maxGroup` (the fusible span ceiling), and the canonical
142
148
  * {@link resolve} callback; {@link cover} then solves one query. */
@@ -312,10 +318,25 @@ export declare class GraphSearch {
312
318
  * most once per cover. A Set, not a flag, because it states WHICH node is
313
319
  * open — the invariant a reader needs to check the guard. */
314
320
  private recompleteOpen;
321
+ /** JOIN — the move the substrate was missing: derive the answer THROUGH a
322
+ * produced fact, without the intermediate key being named in the query.
323
+ *
324
+ * A produced fact (`fact.node`) carries the subject the query reached but
325
+ * never wrote; the query's remaining tail names the relation to follow from
326
+ * it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
327
+ * store for that key's continuation and, when it exists, concludes with the
328
+ * joined fact. On the ladder it is one STEP: a direct edge, exactly as
329
+ * following a literal continuation is. Deterministic and point-probed
330
+ * (`resolve` + `nextFirst`, no scan), so it adds no read that grows with the
331
+ * corpus. The move is visible in the rationale as its own act
332
+ * (`classifyMove` reports `join-fact`), distinct from the byte-concatenating
333
+ * `fuse`/`splice`. */
334
+ private join;
315
335
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
316
336
  * learnt connector (the in-search bridge), splitting (at a sub-leaf form
317
- * boundary), bridging (cover(i) ∧ this → cover(j)), and fusing with an
318
- * adjacent finalised out. */
337
+ * boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
338
+ * finalised out, and — for a produced fact — JOINING the entity it contains
339
+ * with the query's tail ({@link join}). */
319
340
  private outRules;
320
341
  /** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
321
342
  * the test that lets a connector jump across INTERIOR answers (an N-ary whole)
@@ -101,8 +101,11 @@ function classifyMove(premises, conclusion, articulating) {
101
101
  return "step";
102
102
  return articulating ? "voice" : "ground";
103
103
  }
104
- if (p.kind === "out" && conclusion.kind === "out")
105
- return "split";
104
+ if (p.kind === "out" && conclusion.kind === "out") {
105
+ // A JOIN derives through a produced fact's own contained subject; a plain
106
+ // single-premise out→out is the byte-level split.
107
+ return conclusion.join ? "join-fact" : "split";
108
+ }
106
109
  return "step";
107
110
  }
108
111
  if (premises.length === 2) {
@@ -325,6 +328,13 @@ export class GraphSearch {
325
328
  // states the formula itself instead of importing it.
326
329
  const atomsAreHubs = Math.max(1, Math.ceil((this.store.edgeSourceCount() * W) / 256)) > this.hubBound();
327
330
  const nodeBytes = (n) => this.store.bytesPrefix(n, ALL);
331
+ // The query's own bytes, tiled from its perceived leaves. A JOIN reads the
332
+ // tail a produced fact's contained entity has to combine with, and `buildSearch`
333
+ // otherwise only ever sees positions, never the bytes behind them.
334
+ const queryBytes = new Uint8Array(queryLen);
335
+ for (const lf of leaves) {
336
+ queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
337
+ }
328
338
  // Content-addressed probes over the store's hash-cons maps — the same keys
329
339
  // training filled. No byte-by-byte trie walk.
330
340
  const findLeafU = (b) => this.store.findLeaf(b) ?? undefined;
@@ -357,7 +367,7 @@ export class GraphSearch {
357
367
  if (it.kind === "form") {
358
368
  return `f${it.i}.${it.j}.${it.node}.${it.via ? 1 : 0}.${it.rcmp ? 1 : 0}`;
359
369
  }
360
- return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${it.fix ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
370
+ return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${it.fix ? 1 : 0}.${it.join ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
361
371
  },
362
372
  *axioms() {
363
373
  yield { item: { kind: "cover", p: 0 }, cost: 0 };
@@ -465,6 +475,8 @@ export class GraphSearch {
465
475
  findBranchU,
466
476
  linksByLeft,
467
477
  linksByRight,
478
+ queryBytes,
479
+ queryLen,
468
480
  });
469
481
  },
470
482
  };
@@ -854,10 +866,62 @@ export class GraphSearch {
854
866
  * most once per cover. A Set, not a flag, because it states WHICH node is
855
867
  * open — the invariant a reader needs to check the guard. */
856
868
  recompleteOpen = new Set();
869
+ /** JOIN — the move the substrate was missing: derive the answer THROUGH a
870
+ * produced fact, without the intermediate key being named in the query.
871
+ *
872
+ * A produced fact (`fact.node`) carries the subject the query reached but
873
+ * never wrote; the query's remaining tail names the relation to follow from
874
+ * it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
875
+ * store for that key's continuation and, when it exists, concludes with the
876
+ * joined fact. On the ladder it is one STEP: a direct edge, exactly as
877
+ * following a literal continuation is. Deterministic and point-probed
878
+ * (`resolve` + `nextFirst`, no scan), so it adds no read that grows with the
879
+ * corpus. The move is visible in the rationale as its own act
880
+ * (`classifyMove` reports `join-fact`), distinct from the byte-concatenating
881
+ * `fuse`/`splice`. */
882
+ *join(fact, queryBytes, queryLen) {
883
+ if (!this.host.recogniseSpan)
884
+ return;
885
+ const tail = queryBytes.subarray(fact.j, queryLen);
886
+ if (tail.length === 0)
887
+ return;
888
+ // The entity candidates are the forms the fact's own bytes CONTAIN — the
889
+ // same recogniser the query went through, so the evidence standard is the
890
+ // query's. A byte atom is never a subject; the fact's own node is the span
891
+ // itself, not an entity inside it.
892
+ for (const site of this.host.recogniseSpan(fact.bytes).sites) {
893
+ if (site.payload < 0 || site.payload === fact.node)
894
+ continue;
895
+ if (!this.store.hasNext(site.payload) &&
896
+ !this.store.hasHalo(site.payload))
897
+ continue;
898
+ const key = this.host.resolve(concat2(this.store.bytesPrefix(site.payload, ALL), tail));
899
+ if (key === null)
900
+ continue;
901
+ const nx = this.store.nextFirst(key, 1);
902
+ if (nx.length === 0)
903
+ continue;
904
+ yield {
905
+ premises: [fact],
906
+ conclusion: {
907
+ kind: "out",
908
+ i: fact.i,
909
+ j: queryLen,
910
+ bytes: this.store.bytesPrefix(nx[0], ALL),
911
+ cover: true,
912
+ rec: true,
913
+ node: nx[0],
914
+ join: true,
915
+ },
916
+ cost: STEP,
917
+ };
918
+ }
919
+ }
857
920
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
858
921
  * learnt connector (the in-search bridge), splitting (at a sub-leaf form
859
- * boundary), bridging (cover(i) ∧ this → cover(j)), and fusing with an
860
- * adjacent finalised out. */
922
+ * boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
923
+ * finalised out, and — for a produced fact — JOINING the entity it contains
924
+ * with the query's tail ({@link join}). */
861
925
  *outRules(it, ctx) {
862
926
  const { splits, coversDone, outsByStart, outsByEnd, coverableByStart } = ctx;
863
927
  const outsByNode = ctx.outsByNode;
@@ -950,6 +1014,17 @@ export class GraphSearch {
950
1014
  yield* this.fuse(it, r, ctx);
951
1015
  for (const l of outsByEnd.get(it.i) ?? [])
952
1016
  yield* this.fuse(l, it, ctx);
1017
+ // ── JOIN (the A*LD extension) ───────────────────────────────────────
1018
+ // A produced fact may CONTAIN the subject the query never named; the query's
1019
+ // remaining tail then names the relation to follow FROM that subject. The
1020
+ // pair (contained entity, tail) is itself a learned key, and its
1021
+ // continuation is the derived answer — a genuine two-fact join, not the
1022
+ // juxtaposition the cover produces when the intermediate key IS named.
1023
+ // Fired per finalized out with a node, so it is the search's own rule, on
1024
+ // the ladder, memoised by {@link key}, and bounded by the fact's own length.
1025
+ if (it.node !== undefined) {
1026
+ yield* this.join(it, ctx.queryBytes, ctx.queryLen);
1027
+ }
953
1028
  }
954
1029
  /** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
955
1030
  * the test that lets a connector jump across INTERIOR answers (an N-ary whole)
@@ -501,7 +501,21 @@ function recogniseImpl(ctx, bytes) {
501
501
  // embedded differently-cased form needed 64x the budget to be found,
502
502
  // while the exact route it was competing with needed none of it.
503
503
  const probe = (start, end, canonBudget) => {
504
- if (end - start < W || end - start <= chainReach(W))
504
+ // Any span at least one river window wide is worth a probe. This used
505
+ // to stop at `chainReach(W)` — "the chain already covers anything that
506
+ // short" — and that premise is false for a NESTED form: the chain grows
507
+ // single-byte leaf ids and gates each step on `findBranch(ids)`, which
508
+ // is null for a form the write side chunked (measured: "Gustaf
509
+ // Molander" embedded in "The director of Eva is Gustaf Molander." gains
510
+ // no branch at any prefix, so `resolveSpan` is never reached), and its
511
+ // INTERIOR reach is one chunk plus W, which can be shorter than the
512
+ // form. The result was a dead zone: a form shorter than `chainReach`
513
+ // that neither starts on a fold cut nor ends on a node edge was
514
+ // unreachable by either tier — the exact site whose loss `tryChain`'s
515
+ // own note records as "the pivot dies with the site and multi-hop goes
516
+ // silent". The interior pass below spends the same budget on those
517
+ // pairs.
518
+ if (end - start < W)
505
519
  return;
506
520
  if (flatProbe(start, end) === null) {
507
521
  if (!canonBudget)
@@ -565,6 +579,26 @@ function recogniseImpl(ctx, bytes) {
565
579
  if (i < suffixes.length && !spend(suffixes[i], bytes.length))
566
580
  break;
567
581
  }
582
+ // INTERIOR pairs, bounded by the same `chainReach(W)` the chain trusts —
583
+ // the dead zone the gate above used to leave: a form that neither starts
584
+ // on a fold cut nor ends on a node edge is exactly the one neither the
585
+ // chain (nested, `findBranch` misses) nor the two edge scans reach. The
586
+ // pair count is `|endpoints| · chainReach(W)`, i.e. LINEAR in the query —
587
+ // the W² span bound is what keeps this from being the quadratic scan the
588
+ // budget note above describes (that one had no span bound at all).
589
+ {
590
+ const reach = chainReach(W);
591
+ for (const end of ordered) {
592
+ for (const start of ordered) {
593
+ if (start >= end)
594
+ continue;
595
+ const span = end - start;
596
+ if (span < W || span > reach)
597
+ continue;
598
+ spend(start, end);
599
+ }
600
+ }
601
+ }
568
602
  }
569
603
  }
570
604
  const chunkEnd = new Uint32Array(bytes.length);
@@ -50,6 +50,7 @@ export const MOVE_NOTE = {
50
50
  "split": "cut a span at a sub-leaf form boundary so a form can be reached",
51
51
  "fuse": "fuse adjacent fragments toward a deeper learned form",
52
52
  "recompose": "recompose fused parts into a learned whole that leads on",
53
+ "join-fact": "join a produced fact's own subject with the query's tail — derive through the fact, not alongside it",
53
54
  "bridge": "advance the cover frontier across this span",
54
55
  "pool-vote": "pool independent regions' evidence for a shared anchor (sum, not shortest path)",
55
56
  "axiom": "a seed: a perceived leaf, recognised form, or computed result",
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://jsr.io/schema/config-file.v1.json",
3
3
  "name": "@hviana/sema",
4
- "version": "0.7.6",
4
+ "version": "0.7.7",
5
5
  "exports": "./src/index.ts"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -115,6 +115,12 @@ export type GItem =
115
115
  * derivation actually CHOSE. Part of {@link key}, because it decides
116
116
  * whether the span's final bytes may still change. */
117
117
  fix?: boolean;
118
+ /** Set on the out a JOIN produced: a produced fact's own contained entity
119
+ * (the subject the query never named) combined with the query's adjacent
120
+ * relation span named a learned key, and that key's continuation is this
121
+ * out. Part of {@link key} so the joined reading is a distinct chart item
122
+ * from the plain concatenation of the same bytes. */
123
+ join?: boolean;
118
124
  };
119
125
  type OutItem = Extract<GItem, { kind: "out" }>;
120
126
 
@@ -240,6 +246,7 @@ export type DerivationMove =
240
246
  | "split" // out→out cut at a sub-leaf form boundary
241
247
  | "fuse" // out+out→out: adjacent fragments recomposed toward a learned form
242
248
  | "recompose" // out+out→form: a fused pair that names an edge-bearing node
249
+ | "join-fact" // out→out: a produced fact's own contained subject + the query's tail names a learned key (the join)
243
250
  | "bridge" // cover+out→cover: the cover frontier advanced across a span
244
251
  | "pool-vote" // N premises→conclusion, evidence pooled (combine:"sum" — see derive)
245
252
  | "step"; // any other single-premise move (fallback)
@@ -276,7 +283,11 @@ function classifyMove(
276
283
  if (!conclusion.rec) return "step";
277
284
  return articulating ? "voice" : "ground";
278
285
  }
279
- if (p.kind === "out" && conclusion.kind === "out") return "split";
286
+ if (p.kind === "out" && conclusion.kind === "out") {
287
+ // A JOIN derives through a produced fact's own contained subject; a plain
288
+ // single-premise out→out is the byte-level split.
289
+ return conclusion.join ? "join-fact" : "split";
290
+ }
280
291
  return "step";
281
292
  }
282
293
  if (premises.length === 2) {
@@ -551,6 +562,13 @@ export class GraphSearch {
551
562
  Math.ceil((this.store.edgeSourceCount() * W) / 256),
552
563
  ) > this.hubBound();
553
564
  const nodeBytes = (n: number) => this.store.bytesPrefix(n, ALL);
565
+ // The query's own bytes, tiled from its perceived leaves. A JOIN reads the
566
+ // tail a produced fact's contained entity has to combine with, and `buildSearch`
567
+ // otherwise only ever sees positions, never the bytes behind them.
568
+ const queryBytes = new Uint8Array(queryLen);
569
+ for (const lf of leaves) {
570
+ queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
571
+ }
554
572
  // Content-addressed probes over the store's hash-cons maps — the same keys
555
573
  // training filled. No byte-by-byte trie walk.
556
574
  const findLeafU = (b: Uint8Array) => this.store.findLeaf(b) ?? undefined;
@@ -589,7 +607,7 @@ export class GraphSearch {
589
607
  }
590
608
  return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${
591
609
  it.fix ? 1 : 0
592
- }.${it.node ?? -1}.${latin1(it.bytes)}`;
610
+ }.${it.join ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
593
611
  },
594
612
  *axioms() {
595
613
  yield { item: { kind: "cover", p: 0 }, cost: 0 };
@@ -697,6 +715,8 @@ export class GraphSearch {
697
715
  findBranchU,
698
716
  linksByLeft,
699
717
  linksByRight,
718
+ queryBytes,
719
+ queryLen,
700
720
  });
701
721
  },
702
722
  };
@@ -1113,10 +1133,65 @@ export class GraphSearch {
1113
1133
  * open — the invariant a reader needs to check the guard. */
1114
1134
  private recompleteOpen = new Set<number>();
1115
1135
 
1136
+ /** JOIN — the move the substrate was missing: derive the answer THROUGH a
1137
+ * produced fact, without the intermediate key being named in the query.
1138
+ *
1139
+ * A produced fact (`fact.node`) carries the subject the query reached but
1140
+ * never wrote; the query's remaining tail names the relation to follow from
1141
+ * it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
1142
+ * store for that key's continuation and, when it exists, concludes with the
1143
+ * joined fact. On the ladder it is one STEP: a direct edge, exactly as
1144
+ * following a literal continuation is. Deterministic and point-probed
1145
+ * (`resolve` + `nextFirst`, no scan), so it adds no read that grows with the
1146
+ * corpus. The move is visible in the rationale as its own act
1147
+ * (`classifyMove` reports `join-fact`), distinct from the byte-concatenating
1148
+ * `fuse`/`splice`. */
1149
+ private *join(
1150
+ fact: OutItem,
1151
+ queryBytes: Uint8Array,
1152
+ queryLen: number,
1153
+ ): Iterable<Rule<GItem>> {
1154
+ if (!this.host.recogniseSpan) return;
1155
+ const tail = queryBytes.subarray(fact.j, queryLen);
1156
+ if (tail.length === 0) return;
1157
+ // The entity candidates are the forms the fact's own bytes CONTAIN — the
1158
+ // same recogniser the query went through, so the evidence standard is the
1159
+ // query's. A byte atom is never a subject; the fact's own node is the span
1160
+ // itself, not an entity inside it.
1161
+ for (const site of this.host.recogniseSpan(fact.bytes).sites) {
1162
+ if (site.payload < 0 || site.payload === fact.node) continue;
1163
+ if (
1164
+ !this.store.hasNext(site.payload) &&
1165
+ !this.store.hasHalo(site.payload)
1166
+ ) continue;
1167
+ const key = this.host.resolve(
1168
+ concat2(this.store.bytesPrefix(site.payload, ALL), tail),
1169
+ );
1170
+ if (key === null) continue;
1171
+ const nx = this.store.nextFirst(key, 1);
1172
+ if (nx.length === 0) continue;
1173
+ yield {
1174
+ premises: [fact],
1175
+ conclusion: {
1176
+ kind: "out",
1177
+ i: fact.i,
1178
+ j: queryLen,
1179
+ bytes: this.store.bytesPrefix(nx[0], ALL),
1180
+ cover: true,
1181
+ rec: true,
1182
+ node: nx[0],
1183
+ join: true,
1184
+ },
1185
+ cost: STEP,
1186
+ };
1187
+ }
1188
+ }
1189
+
1116
1190
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
1117
1191
  * learnt connector (the in-search bridge), splitting (at a sub-leaf form
1118
- * boundary), bridging (cover(i) ∧ this → cover(j)), and fusing with an
1119
- * adjacent finalised out. */
1192
+ * boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
1193
+ * finalised out, and — for a produced fact — JOINING the entity it contains
1194
+ * with the query's tail ({@link join}). */
1120
1195
  private *outRules(
1121
1196
  it: OutItem,
1122
1197
  ctx: {
@@ -1133,6 +1208,8 @@ export class GraphSearch {
1133
1208
  findBranchU: (k: number[]) => number | undefined;
1134
1209
  linksByLeft?: ReadonlyMap<number, Array<[number, Uint8Array]>>;
1135
1210
  linksByRight?: ReadonlyMap<number, Array<[number, Uint8Array]>>;
1211
+ queryBytes: Uint8Array;
1212
+ queryLen: number;
1136
1213
  },
1137
1214
  ): Iterable<Rule<GItem>> {
1138
1215
  const { splits, coversDone, outsByStart, outsByEnd, coverableByStart } =
@@ -1225,6 +1302,18 @@ export class GraphSearch {
1225
1302
 
1226
1303
  for (const r of outsByStart.get(it.j) ?? []) yield* this.fuse(it, r, ctx);
1227
1304
  for (const l of outsByEnd.get(it.i) ?? []) yield* this.fuse(l, it, ctx);
1305
+
1306
+ // ── JOIN (the A*LD extension) ───────────────────────────────────────
1307
+ // A produced fact may CONTAIN the subject the query never named; the query's
1308
+ // remaining tail then names the relation to follow FROM that subject. The
1309
+ // pair (contained entity, tail) is itself a learned key, and its
1310
+ // continuation is the derived answer — a genuine two-fact join, not the
1311
+ // juxtaposition the cover produces when the intermediate key IS named.
1312
+ // Fired per finalized out with a node, so it is the search's own rule, on
1313
+ // the ladder, memoised by {@link key}, and bounded by the fact's own length.
1314
+ if (it.node !== undefined) {
1315
+ yield* this.join(it, ctx.queryBytes, ctx.queryLen);
1316
+ }
1228
1317
  }
1229
1318
 
1230
1319
  /** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
@@ -518,7 +518,21 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
518
518
  end: number,
519
519
  canonBudget: boolean,
520
520
  ): void => {
521
- if (end - start < W || end - start <= chainReach(W)) return;
521
+ // Any span at least one river window wide is worth a probe. This used
522
+ // to stop at `chainReach(W)` — "the chain already covers anything that
523
+ // short" — and that premise is false for a NESTED form: the chain grows
524
+ // single-byte leaf ids and gates each step on `findBranch(ids)`, which
525
+ // is null for a form the write side chunked (measured: "Gustaf
526
+ // Molander" embedded in "The director of Eva is Gustaf Molander." gains
527
+ // no branch at any prefix, so `resolveSpan` is never reached), and its
528
+ // INTERIOR reach is one chunk plus W, which can be shorter than the
529
+ // form. The result was a dead zone: a form shorter than `chainReach`
530
+ // that neither starts on a fold cut nor ends on a node edge was
531
+ // unreachable by either tier — the exact site whose loss `tryChain`'s
532
+ // own note records as "the pivot dies with the site and multi-hop goes
533
+ // silent". The interior pass below spends the same budget on those
534
+ // pairs.
535
+ if (end - start < W) return;
522
536
  if (flatProbe(start, end) === null) {
523
537
  if (!canonBudget) return;
524
538
  if (!canonAdmits(start, end)) return;
@@ -575,6 +589,24 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
575
589
  if (i < prefixes.length && !spend(0, prefixes[i])) break;
576
590
  if (i < suffixes.length && !spend(suffixes[i], bytes.length)) break;
577
591
  }
592
+ // INTERIOR pairs, bounded by the same `chainReach(W)` the chain trusts —
593
+ // the dead zone the gate above used to leave: a form that neither starts
594
+ // on a fold cut nor ends on a node edge is exactly the one neither the
595
+ // chain (nested, `findBranch` misses) nor the two edge scans reach. The
596
+ // pair count is `|endpoints| · chainReach(W)`, i.e. LINEAR in the query —
597
+ // the W² span bound is what keeps this from being the quadratic scan the
598
+ // budget note above describes (that one had no span bound at all).
599
+ {
600
+ const reach = chainReach(W);
601
+ for (const end of ordered) {
602
+ for (const start of ordered) {
603
+ if (start >= end) continue;
604
+ const span = end - start;
605
+ if (span < W || span > reach) continue;
606
+ spend(start, end);
607
+ }
608
+ }
609
+ }
578
610
  }
579
611
  }
580
612
 
package/src/mind/trace.ts CHANGED
@@ -75,6 +75,8 @@ export const MOVE_NOTE: Record<string, string> = {
75
75
  "split": "cut a span at a sub-leaf form boundary so a form can be reached",
76
76
  "fuse": "fuse adjacent fragments toward a deeper learned form",
77
77
  "recompose": "recompose fused parts into a learned whole that leads on",
78
+ "join-fact":
79
+ "join a produced fact's own subject with the query's tail — derive through the fact, not alongside it",
78
80
  "bridge": "advance the cover frontier across this span",
79
81
  "pool-vote":
80
82
  "pool independent regions' evidence for a shared anchor (sum, not shortest path)",
@@ -83,3 +83,33 @@ test("recognise(): a wide edge trim does not corrupt an unrelated short-form ans
83
83
  const r = await m.respond("2+2 は何ですか");
84
84
  assert.equal(dec(r.bytes), "4");
85
85
  });
86
+
87
+ test("recognise(): an INTERIOR form at a non-cut offset is recovered, not only an edge one", async () => {
88
+ // The edge tier used to probe only prefixes of 0 and suffixes to bytes.length,
89
+ // and the flat-leaf chain cannot rebuild a write-side-chunked form (its
90
+ // `findBranch(ids)` pre-check misses at every prefix, so `resolveSpan` is
91
+ // never reached) while its interior reach is one chunk plus W. A form that
92
+ // neither starts on a fold cut nor ends on a node edge therefore fell in a
93
+ // dead zone — exactly the object inside a produced fact ("…is Gustaf
94
+ // Molander."), which is the site the pivot would need to chain on.
95
+ // MEASURED on the pre-change tree: no site for the entity. With the bounded
96
+ // interior pass (spans W..chainReach(W), linear in the query), it is found at
97
+ // its true span.
98
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
99
+ await m.ingest([
100
+ ["x", "The director of Eva is Gustaf Molander."],
101
+ ["Gustaf Molander", "The father of Gustaf Molander is Harald Molander."],
102
+ ]);
103
+ const expected = resolve(m, enc("Gustaf Molander"));
104
+ assert.ok(expected !== null, "sanity: the entity must resolve standalone");
105
+
106
+ const rec = recognise(m, enc("The director of Eva is Gustaf Molander."));
107
+ const hit = rec.sites.find((s) => s.payload === expected);
108
+ assert.ok(
109
+ hit,
110
+ `expected an interior site for the entity, got: ` +
111
+ JSON.stringify(rec.sites.map((s) => [s.start, s.end, s.payload])),
112
+ );
113
+ assert.deepEqual([hit.start, hit.end], [23, 38]);
114
+ await m.store.close();
115
+ });
@@ -0,0 +1,97 @@
1
+ // 99-fact-join.test.mjs — the JOIN: derive the answer THROUGH a produced fact
2
+ // whose subject the query never names.
3
+ //
4
+ // THE MOVE THIS PINS. A query like "Eiffel Tower country capital" names hop 1's
5
+ // key ("Eiffel Tower country") and hop 2's relation (" capital"), but never the
6
+ // intermediate subject ("France"). The cover alone can only juxtapose the two
7
+ // facts; the pivot needs the subject to surface as an unconsumed site inside the
8
+ // produced fact. The `join` rule in graph-search.ts closes the gap: it takes the
9
+ // entity the produced fact CONTAINS, combines it with the query's remaining tail,
10
+ // and asks the store for that key's continuation — a direct STEP on the ladder,
11
+ // reported as `join-fact` in the rationale.
12
+ //
13
+ // WHY THE FIXTURE CROSSES N=4096. Below the atomIsHub flip the interior subject
14
+ // surfaces anyway (small-store recognition) and the PIVOT alone reaches the
15
+ // chain, so a small fixture passes under the pre-change tree too — measured on a
16
+ // 3-deposit store, which answered "The father of Gustaf Molander is Harald
17
+ // Molander." with no join. The join is what carries the chain at corpus scale,
18
+ // so the fixture must sit past the flip, exactly as test/78 does. The filler is
19
+ // lexically varied for the same reason test/78's is: a templated corpus folds to
20
+ // shared chunks and leaves the query uncontested.
21
+
22
+ import { test } from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import { Mind } from "../dist/src/index.js";
25
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
26
+
27
+ const CHAIN = [
28
+ ["Eiffel Tower country", "The country of Eiffel Tower is France."],
29
+ ["France capital", "The capital of France is Paris."],
30
+ // The pivot fact: the intermediate subject as a context of its own.
31
+ ["France", "The capital of France is Paris."],
32
+ ];
33
+
34
+ const WORDS =
35
+ ("alpha bravo charlie delta echo foxtrot golf hotel india juliet " +
36
+ "kilo lima mike november oscar papa quebec romeo sierra tango uniform " +
37
+ "victor whiskey xray yankee zulu amber bronze copper dahlia ember fjord " +
38
+ "gossamer harbour indigo jasmine kestrel lantern marigold nectar opal " +
39
+ "pewter quartz ripple saffron thistle umber violet willow xenon yarrow")
40
+ .split(" ");
41
+ const filler = (i) => {
42
+ const w = (n) => WORDS[(i * 7 + n * 13) % WORDS.length];
43
+ return [
44
+ `${w(1)} ${w(2)} ${w(3)} ${i}`,
45
+ `${w(4)} ${w(5)} ${w(6)} ${w(7)} ${i}`,
46
+ ];
47
+ };
48
+
49
+ /** One store, ingested past the atomIsHub flip. */
50
+ async function pastTheFlip() {
51
+ const store = new SQliteStore({ path: ":memory:", D: 1024 });
52
+ const mind = new Mind({ seed: 7, store });
53
+ await mind.ingest(CHAIN);
54
+ await mind.ingest(Array.from({ length: 4300 }, (_, i) => filler(i)));
55
+ return { store, mind };
56
+ }
57
+
58
+ test("a join derives through a produced fact whose subject the query never names", async () => {
59
+ const { store, mind } = await pastTheFlip();
60
+ const query = "Eiffel Tower country capital";
61
+ assert.ok(
62
+ !query.includes("France") && !query.includes("capital of France"),
63
+ "sanity: the intermediate subject must NOT be named in the query",
64
+ );
65
+
66
+ const moves = [];
67
+ const out = await mind.respondText(
68
+ query,
69
+ (s) => moves.push(s.mechanism[s.mechanism.length - 1]),
70
+ );
71
+
72
+ assert.equal(out.trim(), "The capital of France is Paris.");
73
+ assert.ok(
74
+ moves.includes("join-fact"),
75
+ `expected the join to be the move that reached the answer, got: ${
76
+ [...new Set(moves)].join(", ")
77
+ }`,
78
+ );
79
+ await store.close();
80
+ });
81
+
82
+ test("naming the intermediate does not need the join — the cover reads it directly", async () => {
83
+ // The contrast that keeps the move honest: when the subject IS written, the
84
+ // cover already reaches the chain, so no join is claimed.
85
+ const { store, mind } = await pastTheFlip();
86
+ const moves = [];
87
+ const out = await mind.respondText(
88
+ "Eiffel Tower country France capital",
89
+ (s) => moves.push(s.mechanism[s.mechanism.length - 1]),
90
+ );
91
+ assert.ok(out.includes("The capital of France is Paris."));
92
+ assert.ok(
93
+ !moves.includes("join-fact"),
94
+ "a named intermediate must not be reported as a join",
95
+ );
96
+ await store.close();
97
+ });