@hviana/sema 0.7.6 → 0.7.8

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,14 @@ 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 DERIVE-THROUGH produced: a produced fact's own contained
77
+ * entity (the subject the query never named) combined with the query's tail
78
+ * named a learned key, and that key's continuation is this out. Part of
79
+ * {@link key} so the derived reading is a distinct chart item from the plain
80
+ * concatenation of the same bytes. Named for what it does — derive THROUGH
81
+ * a produced fact — because `join` is already the confluence mechanism's
82
+ * `Provenance`, a different act at a different layer. */
83
+ throughFact?: boolean;
76
84
  };
77
85
  export declare const STEP = 1;
78
86
  export declare const CONCEPT = 10;
@@ -136,7 +144,7 @@ export interface DerivationItem {
136
144
  * {@link GraphSearch}'s rules fired, recovered from the rule's premise/
137
145
  * conclusion shape (the rules carry no label, so this classifies by structure,
138
146
  * 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";
147
+ export type DerivationMove = "axiom" | "follow-edge" | "concept-hop" | "voice" | "ground" | "splice-connector" | "split" | "fuse" | "recompose" | "derive-through" | "bridge" | "pool-vote" | "step";
140
148
  /** The lightest-derivation search over the Sema graph. One instance binds the
141
149
  * store, `maxGroup` (the fusible span ceiling), and the canonical
142
150
  * {@link resolve} callback; {@link cover} then solves one query. */
@@ -180,11 +188,12 @@ export declare class GraphSearch {
180
188
  * connector rule (see {@link outRules}), so the returned spans already carry
181
189
  * it — there is no post-pass. */
182
190
  cover(queryLen: number, sites: ReadonlyArray<Site>, conceptTarget: ReadonlyMap<number, number>, leaves: ReadonlyArray<Leaf>, splits: ReadonlySet<number>, starts: ReadonlySet<number>, substitutions?: ReadonlyMap<number, Uint8Array>, connectors?: ReadonlyMap<string, Uint8Array>, computedResults?: ReadonlyArray<ComputedResult>,
183
- /** When given, receives the lightest derivation's rule applications — the
184
- * full adapted A*LD proof tree as classified {@link DerivationStep}s — for the TOP
185
- * cover only (a recursive recompletion solves its own sub-cover and is not
186
- * reported here, to keep the trace one layer per think). Off by default,
187
- * so the search pays nothing when no one inspects. */
191
+ /** When given, receives each solved span's lightest derivation — the full
192
+ * adapted A*LD proof tree as classified {@link DerivationStep}s — for the
193
+ * TOP cover AND every nested completion the sink is threaded into (see
194
+ * {@link recompleteNode}), so a produced form's own recompositions reach
195
+ * the rationale instead of stopping at the first layer. Off by default, so
196
+ * the search pays nothing when no one inspects. */
188
197
  onDerivation?: (steps: DerivationStep[]) => void): {
189
198
  segs: Seg[];
190
199
  cost: number;
@@ -312,10 +321,30 @@ export declare class GraphSearch {
312
321
  * most once per cover. A Set, not a flag, because it states WHICH node is
313
322
  * open — the invariant a reader needs to check the guard. */
314
323
  private recompleteOpen;
324
+ /** DERIVE-THROUGH — a RULE this module's DeductionSystem was missing. The
325
+ * A*LD library is untouched: this is one more `premises → conclusion + cost`
326
+ * rule in the system {@link buildSearch} hands to {@link lightestDerivation},
327
+ * the same kind of rule as `fuse`/`recompose` — not an extension of
328
+ * `src/derive`, and not a grounding mechanism. It derives the answer THROUGH
329
+ * a produced fact, without the intermediate key being named in the query.
330
+ *
331
+ * A produced fact (`fact.node`) carries the subject the query reached but
332
+ * never wrote; the query's remaining tail names the relation to follow from
333
+ * it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
334
+ * store for that key's continuation and, when it exists, concludes with the
335
+ * fact reached through it. On the ladder it is one STEP: a direct edge,
336
+ * exactly as following a literal continuation is. Deterministic and
337
+ * point-probed (`resolve` + `nextFirst`, no scan), so it adds no read that
338
+ * grows with the corpus. The move is visible in the rationale as its own act
339
+ * (`classifyMove` reports `derive-through`), distinct from the
340
+ * byte-concatenating `fuse`/`splice` — and named `derive-through` rather than
341
+ * `join` so it cannot be read as the confluence mechanism's `Provenance`. */
342
+ private deriveThrough;
315
343
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
316
344
  * 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. */
345
+ * boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
346
+ * finalised out, and — for a produced fact — JOINING the entity it contains
347
+ * with the query's tail ({@link deriveThrough}). */
319
348
  private outRules;
320
349
  /** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
321
350
  * 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.throughFact ? "derive-through" : "split";
108
+ }
106
109
  return "step";
107
110
  }
108
111
  if (premises.length === 2) {
@@ -219,11 +222,12 @@ export class GraphSearch {
219
222
  * connector rule (see {@link outRules}), so the returned spans already carry
220
223
  * it — there is no post-pass. */
221
224
  cover(queryLen, sites, conceptTarget, leaves, splits, starts, substitutions, connectors, computedResults,
222
- /** When given, receives the lightest derivation's rule applications — the
223
- * full adapted A*LD proof tree as classified {@link DerivationStep}s — for the TOP
224
- * cover only (a recursive recompletion solves its own sub-cover and is not
225
- * reported here, to keep the trace one layer per think). Off by default,
226
- * so the search pays nothing when no one inspects. */
225
+ /** When given, receives each solved span's lightest derivation — the full
226
+ * adapted A*LD proof tree as classified {@link DerivationStep}s — for the
227
+ * TOP cover AND every nested completion the sink is threaded into (see
228
+ * {@link recompleteNode}), so a produced form's own recompositions reach
229
+ * the rationale instead of stopping at the first layer. Off by default, so
230
+ * the search pays nothing when no one inspects. */
227
231
  onDerivation) {
228
232
  // Top-level entry: reset the per-call recursion state, then run the one
229
233
  // {@link solve} routine that both the query and any produced composite go
@@ -325,6 +329,13 @@ export class GraphSearch {
325
329
  // states the formula itself instead of importing it.
326
330
  const atomsAreHubs = Math.max(1, Math.ceil((this.store.edgeSourceCount() * W) / 256)) > this.hubBound();
327
331
  const nodeBytes = (n) => this.store.bytesPrefix(n, ALL);
332
+ // The query's own bytes, tiled from its perceived leaves. A JOIN reads the
333
+ // tail a produced fact's contained entity has to combine with, and `buildSearch`
334
+ // otherwise only ever sees positions, never the bytes behind them.
335
+ const queryBytes = new Uint8Array(queryLen);
336
+ for (const lf of leaves) {
337
+ queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
338
+ }
328
339
  // Content-addressed probes over the store's hash-cons maps — the same keys
329
340
  // training filled. No byte-by-byte trie walk.
330
341
  const findLeafU = (b) => this.store.findLeaf(b) ?? undefined;
@@ -357,7 +368,7 @@ export class GraphSearch {
357
368
  if (it.kind === "form") {
358
369
  return `f${it.i}.${it.j}.${it.node}.${it.via ? 1 : 0}.${it.rcmp ? 1 : 0}`;
359
370
  }
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)}`;
371
+ return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${it.fix ? 1 : 0}.${it.throughFact ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
361
372
  },
362
373
  *axioms() {
363
374
  yield { item: { kind: "cover", p: 0 }, cost: 0 };
@@ -465,6 +476,8 @@ export class GraphSearch {
465
476
  findBranchU,
466
477
  linksByLeft,
467
478
  linksByRight,
479
+ queryBytes,
480
+ queryLen,
468
481
  });
469
482
  },
470
483
  };
@@ -854,10 +867,67 @@ export class GraphSearch {
854
867
  * most once per cover. A Set, not a flag, because it states WHICH node is
855
868
  * open — the invariant a reader needs to check the guard. */
856
869
  recompleteOpen = new Set();
870
+ /** DERIVE-THROUGH — a RULE this module's DeductionSystem was missing. The
871
+ * A*LD library is untouched: this is one more `premises → conclusion + cost`
872
+ * rule in the system {@link buildSearch} hands to {@link lightestDerivation},
873
+ * the same kind of rule as `fuse`/`recompose` — not an extension of
874
+ * `src/derive`, and not a grounding mechanism. It derives the answer THROUGH
875
+ * a produced fact, without the intermediate key being named in the query.
876
+ *
877
+ * A produced fact (`fact.node`) carries the subject the query reached but
878
+ * never wrote; the query's remaining tail names the relation to follow from
879
+ * it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
880
+ * store for that key's continuation and, when it exists, concludes with the
881
+ * fact reached through it. On the ladder it is one STEP: a direct edge,
882
+ * exactly as following a literal continuation is. Deterministic and
883
+ * point-probed (`resolve` + `nextFirst`, no scan), so it adds no read that
884
+ * grows with the corpus. The move is visible in the rationale as its own act
885
+ * (`classifyMove` reports `derive-through`), distinct from the
886
+ * byte-concatenating `fuse`/`splice` — and named `derive-through` rather than
887
+ * `join` so it cannot be read as the confluence mechanism's `Provenance`. */
888
+ *deriveThrough(fact, queryBytes, queryLen) {
889
+ if (!this.host.recogniseSpan)
890
+ return;
891
+ const tail = queryBytes.subarray(fact.j, queryLen);
892
+ if (tail.length === 0)
893
+ return;
894
+ // The entity candidates are the forms the fact's own bytes CONTAIN — the
895
+ // same recogniser the query went through, so the evidence standard is the
896
+ // query's. A byte atom is never a subject; the fact's own node is the span
897
+ // itself, not an entity inside it.
898
+ for (const site of this.host.recogniseSpan(fact.bytes).sites) {
899
+ if (site.payload < 0 || site.payload === fact.node)
900
+ continue;
901
+ if (!this.store.hasNext(site.payload) &&
902
+ !this.store.hasHalo(site.payload))
903
+ continue;
904
+ const key = this.host.resolve(concat2(this.store.bytesPrefix(site.payload, ALL), tail));
905
+ if (key === null)
906
+ continue;
907
+ const nx = this.store.nextFirst(key, 1);
908
+ if (nx.length === 0)
909
+ continue;
910
+ yield {
911
+ premises: [fact],
912
+ conclusion: {
913
+ kind: "out",
914
+ i: fact.i,
915
+ j: queryLen,
916
+ bytes: this.store.bytesPrefix(nx[0], ALL),
917
+ cover: true,
918
+ rec: true,
919
+ node: nx[0],
920
+ throughFact: true,
921
+ },
922
+ cost: STEP,
923
+ };
924
+ }
925
+ }
857
926
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
858
927
  * 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. */
928
+ * boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
929
+ * finalised out, and — for a produced fact — JOINING the entity it contains
930
+ * with the query's tail ({@link deriveThrough}). */
861
931
  *outRules(it, ctx) {
862
932
  const { splits, coversDone, outsByStart, outsByEnd, coverableByStart } = ctx;
863
933
  const outsByNode = ctx.outsByNode;
@@ -950,6 +1020,18 @@ export class GraphSearch {
950
1020
  yield* this.fuse(it, r, ctx);
951
1021
  for (const l of outsByEnd.get(it.i) ?? [])
952
1022
  yield* this.fuse(l, it, ctx);
1023
+ // ── DERIVE-THROUGH (one more rule of the DeductionSystem this module builds)
1024
+ // A produced fact may CONTAIN the subject the query never named; the query's
1025
+ // remaining tail then names the relation to follow FROM that subject. The
1026
+ // pair (contained entity, tail) is itself a learned key, and its
1027
+ // continuation is the derived answer — a genuine relational join, distinct
1028
+ // from the confluence mechanism's `join` PROVENANCE — and not the
1029
+ // juxtaposition the cover produces when the intermediate key IS named.
1030
+ // Fired per finalized out with a node, so it is the search's own rule, on
1031
+ // the ladder, memoised by {@link key}, and bounded by the fact's own length.
1032
+ if (it.node !== undefined) {
1033
+ yield* this.deriveThrough(it, ctx.queryBytes, ctx.queryLen);
1034
+ }
953
1035
  }
954
1036
  /** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
955
1037
  * the test that lets a connector jump across INTERIOR answers (an N-ary whole)
@@ -12,14 +12,16 @@ import { unexplainedLabel } from "../rationale.js";
12
12
  export function aluToMechanism(alu) {
13
13
  return {
14
14
  name: "alu",
15
- // Not a cover derivation: cover.ts composes an answer by walking
16
- // recognised query STRUCTURE; the ALU evaluates a recognised expression
17
- // to its authoritative result and hands the bytes back untouched. It
18
- // shares cover's near-zero floor (computation always wins, masked into
19
- // cover's own search — see mechanisms/cover.ts), but the candidate this
20
- // produces is not one of cover's derivations, so it carries its own
21
- // honest label, the same way extract/cast/recall each carry theirs.
22
- provenance: "alu",
15
+ // The computation is GROUNDED BY COVER: this adapter's `parse` puts the
16
+ // authoritative span into `pre.computed`, cover masks it, and cover's
17
+ // derivation is what carries the answer out — measured, every computed
18
+ // probe reports provenance `cover`. So the adapter declares the core
19
+ // provenance the answer actually has. The ALU's own act is named where it
20
+ // belongs, in the TRACE (`evalComputation`, emitted by its `parse`), not in
21
+ // the provenance: a mechanism may not invent a label outside the pipeline's
22
+ // `Provenance` vocabulary, because post-grounding gates on that vocabulary
23
+ // (see the `provenance` contract in pipeline-mechanism.ts).
24
+ provenance: "cover",
23
25
  parse: (query) => alu.parse(query),
24
26
  async floor(_ctx, _query, pre, _worthRunning) {
25
27
  return pre.computed.length > 0 ? 0 : null;
@@ -155,8 +155,9 @@ export class Mind {
155
155
  // `makeKeyring`, `Space.rand` and the `Alphabet` below, so folding a
156
156
  // query under config.ts's default (42) against a store trained with
157
157
  // another seed (e.g. 7) lands in a DIFFERENT vector space than the one
158
- // the artifact's nodes were folded into: recognition and resonance then
159
- // read the wrong space and every answer degrades silently. An explicit
158
+ // the artifact's nodes were folded into: recognition and resonance read
159
+ // the wrong space, and answers silently diverge — pinned by test/97,
160
+ // where only adoption reproduces the artifact's own answer. An explicit
160
161
  // caller seed still wins — this only replaces the unconfigured default.
161
162
  if (explicitSeed === undefined && this.store.trainSeed !== null) {
162
163
  this.cfg.seed = this.store.trainSeed;
@@ -185,7 +185,13 @@ export interface MechanismResult {
185
185
  export interface PipelineMechanism {
186
186
  /** Stable identifier for trace/debug. */
187
187
  readonly name: string;
188
- /** Which provenance tag the pipeline attaches to this mechanism's answers. */
188
+ /** Which provenance tag the pipeline attaches to this mechanism's answers.
189
+ * DECLARED, not free-form: the pipeline narrows it to its own `Provenance`
190
+ * vocabulary (pipeline.ts), because post-grounding gates on that vocabulary
191
+ * — so an adapter declares one of those values, never a label of its own.
192
+ * The type is `string` only because a mechanism imports nothing from
193
+ * `pipeline.ts` (constraint 1, decoupling); the contract is semantic, and
194
+ * every shipped adapter honours it (see mechanisms/alu.ts). */
189
195
  readonly provenance: string;
190
196
  /** Parse authoritative spans BEFORE the grounding loop.
191
197
  * Only needed by computational mechanisms (e.g. ALU). Results from ALL
@@ -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 does not hold for every embedded form: the
507
+ // chain grows single-byte leaf ids and SKIPS a prefix whose
508
+ // `findBranch(ids)` is null, so a form the write side stored as a nested
509
+ // tree rather than as a flat leaf-id branch is never `resolveSpan`ned
510
+ // (measured: "Gustaf Molander" embedded in "The director of Eva is
511
+ // Gustaf Molander." gains no branch at any prefix), and its INTERIOR
512
+ // reach is one chunk plus W, which can be shorter than the form. The
513
+ // result was a dead zone: a form shorter than `chainReach` that neither
514
+ // starts on a fold cut nor ends on a node edge was unreachable by either
515
+ // tier — the exact site whose loss `tryChain`'s own note records as "the
516
+ // pivot dies with the site and multi-hop goes silent". The interior
517
+ // pass below spends the same budget on those pairs.
518
+ if (end - start < W)
505
519
  return;
506
520
  if (flatProbe(start, end) === null) {
507
521
  if (!canonBudget)
@@ -565,6 +579,28 @@ function recogniseImpl(ctx, bytes) {
565
579
  if (i < suffixes.length && !spend(suffixes[i], bytes.length))
566
580
  break;
567
581
  }
582
+ // INTERIOR pairs within the same `chainReach(W)` span bound the chain
583
+ // trusts — the dead zone the gate above used to leave: a form that neither
584
+ // starts on a fold cut nor ends on a node edge is exactly the one neither
585
+ // the chain (nested, `findBranch` misses) nor the two edge scans reach.
586
+ // Only pairs whose span lies in [W, chainReach(W)] are PROBED, which is
587
+ // the O(n · W²) work that matters; the enumeration itself is the all-pairs
588
+ // scan, each pair a constant-time span check — and the span bound is what
589
+ // keeps this off the quadratic path the budget note above describes (that
590
+ // one had no span bound at all).
591
+ {
592
+ const reach = chainReach(W);
593
+ for (const end of ordered) {
594
+ for (const start of ordered) {
595
+ if (start >= end)
596
+ continue;
597
+ const span = end - start;
598
+ if (span < W || span > reach)
599
+ continue;
600
+ spend(start, end);
601
+ }
602
+ }
603
+ }
568
604
  }
569
605
  }
570
606
  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
+ "derive-through": "derive the answer through a produced fact's own subject and the query's tail, 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",
@@ -66,8 +66,14 @@ forms need no host; meaning-based paths do.
66
66
 
67
67
  ## Provenance
68
68
 
69
- Grounded ALU answers carry `alu`; `computeExtensions`/`evalComputation` trace
70
- the expression and result.
69
+ `cover`. A computation is grounded by cover: this adapter's `parse` puts the
70
+ authoritative span into `pre.computed`, cover masks it, and cover's derivation
71
+ carries the answer out (measured: 9 of 9 computed probes report `cover` —
72
+ `137*24`, `1000 - 421`, `15 * 7`, `3+3`, `5*5`). The adapter therefore declares
73
+ `cover` — a mechanism may not invent a label outside the pipeline's `Provenance`
74
+ vocabulary, because post-grounding gates on it (see `pipeline-mechanism.ts`).
75
+ The ALU's own act is named in the TRACE (`evalComputation`/`computeExtensions`),
76
+ not in the provenance.
71
77
 
72
78
  ## Pins
73
79
 
@@ -58,6 +58,11 @@ Floor is `2·STEP`. Before touching the shared expensive analyses
58
58
  return the uninvested bound when it already loses. Never compute a shared
59
59
  analysis just to discard it.
60
60
 
61
+ ## Provenance
62
+
63
+ `cast` — the answer came from counterfactual transfer (substitution,
64
+ redirection, or analogical comparison), not from a literal continuation.
65
+
61
66
  ## Pins
62
67
 
63
68
  - **test/17 intelligence** — reordered single-fact must not trigger
@@ -30,6 +30,13 @@ One currency (`mind/graph-search.ts`): `STEP=1`, `CONCEPT=10`, `PASS=1000`/byte.
30
30
  `moves = STEP·slots + CONCEPT` (floor `3·STEP`: two constraints + meet). Weight
31
31
  `moves + PASS·unaccounted` compared at `STEP` grade (`pipeline.ts:think`).
32
32
 
33
+ ## Provenance
34
+
35
+ `join` — confluence is the mechanism that OWNS this provenance
36
+ (`src/mind/mechanisms/confluence.ts` sets it when the independent evidence
37
+ streams meet at one anchor). It is not cover's: cover reports `cover` for every
38
+ derivation it wins (see `docs/mechanisms/cover.md`).
39
+
33
40
  ## Pins
34
41
 
35
42
  `test/32-confluence.test.mjs` — two-constraint intersection, order invariance,
@@ -45,8 +45,11 @@ by node pair. Bridges (`bridge`) splice connectors between rewrites.
45
45
 
46
46
  ## Provenance
47
47
 
48
- `cover` for the query's own cover; `join` when fusing fragments into a deeper
49
- learned form.
48
+ `cover` for every cover derivation — including the fusion/recomposition steps
49
+ (`fuse`/`recompose`) that name a deeper learned form. (`join` is NOT cover's:
50
+ that provenance belongs to the CONFLUENCE mechanism, which reports it when
51
+ independent evidence streams meet at one anchor — see
52
+ `docs/mechanisms/confluence.md`.)
50
53
 
51
54
  ## Pins
52
55
 
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.8",
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.8",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -115,6 +115,14 @@ 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 DERIVE-THROUGH produced: a produced fact's own contained
119
+ * entity (the subject the query never named) combined with the query's tail
120
+ * named a learned key, and that key's continuation is this out. Part of
121
+ * {@link key} so the derived reading is a distinct chart item from the plain
122
+ * concatenation of the same bytes. Named for what it does — derive THROUGH
123
+ * a produced fact — because `join` is already the confluence mechanism's
124
+ * `Provenance`, a different act at a different layer. */
125
+ throughFact?: boolean;
118
126
  };
119
127
  type OutItem = Extract<GItem, { kind: "out" }>;
120
128
 
@@ -240,6 +248,7 @@ export type DerivationMove =
240
248
  | "split" // out→out cut at a sub-leaf form boundary
241
249
  | "fuse" // out+out→out: adjacent fragments recomposed toward a learned form
242
250
  | "recompose" // out+out→form: a fused pair that names an edge-bearing node
251
+ | "derive-through" // out→out: a produced fact's own contained subject + the query's tail names a learned key (derive the answer through the fact)
243
252
  | "bridge" // cover+out→cover: the cover frontier advanced across a span
244
253
  | "pool-vote" // N premises→conclusion, evidence pooled (combine:"sum" — see derive)
245
254
  | "step"; // any other single-premise move (fallback)
@@ -276,7 +285,11 @@ function classifyMove(
276
285
  if (!conclusion.rec) return "step";
277
286
  return articulating ? "voice" : "ground";
278
287
  }
279
- if (p.kind === "out" && conclusion.kind === "out") return "split";
288
+ if (p.kind === "out" && conclusion.kind === "out") {
289
+ // A JOIN derives through a produced fact's own contained subject; a plain
290
+ // single-premise out→out is the byte-level split.
291
+ return conclusion.throughFact ? "derive-through" : "split";
292
+ }
280
293
  return "step";
281
294
  }
282
295
  if (premises.length === 2) {
@@ -400,11 +413,12 @@ export class GraphSearch {
400
413
  substitutions?: ReadonlyMap<number, Uint8Array>,
401
414
  connectors?: ReadonlyMap<string, Uint8Array>,
402
415
  computedResults?: ReadonlyArray<ComputedResult>,
403
- /** When given, receives the lightest derivation's rule applications — the
404
- * full adapted A*LD proof tree as classified {@link DerivationStep}s — for the TOP
405
- * cover only (a recursive recompletion solves its own sub-cover and is not
406
- * reported here, to keep the trace one layer per think). Off by default,
407
- * so the search pays nothing when no one inspects. */
416
+ /** When given, receives each solved span's lightest derivation — the full
417
+ * adapted A*LD proof tree as classified {@link DerivationStep}s — for the
418
+ * TOP cover AND every nested completion the sink is threaded into (see
419
+ * {@link recompleteNode}), so a produced form's own recompositions reach
420
+ * the rationale instead of stopping at the first layer. Off by default, so
421
+ * the search pays nothing when no one inspects. */
408
422
  onDerivation?: (steps: DerivationStep[]) => void,
409
423
  ): { segs: Seg[]; cost: number } | null {
410
424
  // Top-level entry: reset the per-call recursion state, then run the one
@@ -551,6 +565,13 @@ export class GraphSearch {
551
565
  Math.ceil((this.store.edgeSourceCount() * W) / 256),
552
566
  ) > this.hubBound();
553
567
  const nodeBytes = (n: number) => this.store.bytesPrefix(n, ALL);
568
+ // The query's own bytes, tiled from its perceived leaves. A JOIN reads the
569
+ // tail a produced fact's contained entity has to combine with, and `buildSearch`
570
+ // otherwise only ever sees positions, never the bytes behind them.
571
+ const queryBytes = new Uint8Array(queryLen);
572
+ for (const lf of leaves) {
573
+ queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
574
+ }
554
575
  // Content-addressed probes over the store's hash-cons maps — the same keys
555
576
  // training filled. No byte-by-byte trie walk.
556
577
  const findLeafU = (b: Uint8Array) => this.store.findLeaf(b) ?? undefined;
@@ -589,7 +610,7 @@ export class GraphSearch {
589
610
  }
590
611
  return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${
591
612
  it.fix ? 1 : 0
592
- }.${it.node ?? -1}.${latin1(it.bytes)}`;
613
+ }.${it.throughFact ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
593
614
  },
594
615
  *axioms() {
595
616
  yield { item: { kind: "cover", p: 0 }, cost: 0 };
@@ -697,6 +718,8 @@ export class GraphSearch {
697
718
  findBranchU,
698
719
  linksByLeft,
699
720
  linksByRight,
721
+ queryBytes,
722
+ queryLen,
700
723
  });
701
724
  },
702
725
  };
@@ -1113,10 +1136,70 @@ export class GraphSearch {
1113
1136
  * open — the invariant a reader needs to check the guard. */
1114
1137
  private recompleteOpen = new Set<number>();
1115
1138
 
1139
+ /** DERIVE-THROUGH — a RULE this module's DeductionSystem was missing. The
1140
+ * A*LD library is untouched: this is one more `premises → conclusion + cost`
1141
+ * rule in the system {@link buildSearch} hands to {@link lightestDerivation},
1142
+ * the same kind of rule as `fuse`/`recompose` — not an extension of
1143
+ * `src/derive`, and not a grounding mechanism. It derives the answer THROUGH
1144
+ * a produced fact, without the intermediate key being named in the query.
1145
+ *
1146
+ * A produced fact (`fact.node`) carries the subject the query reached but
1147
+ * never wrote; the query's remaining tail names the relation to follow from
1148
+ * it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
1149
+ * store for that key's continuation and, when it exists, concludes with the
1150
+ * fact reached through it. On the ladder it is one STEP: a direct edge,
1151
+ * exactly as following a literal continuation is. Deterministic and
1152
+ * point-probed (`resolve` + `nextFirst`, no scan), so it adds no read that
1153
+ * grows with the corpus. The move is visible in the rationale as its own act
1154
+ * (`classifyMove` reports `derive-through`), distinct from the
1155
+ * byte-concatenating `fuse`/`splice` — and named `derive-through` rather than
1156
+ * `join` so it cannot be read as the confluence mechanism's `Provenance`. */
1157
+ private *deriveThrough(
1158
+ fact: OutItem,
1159
+ queryBytes: Uint8Array,
1160
+ queryLen: number,
1161
+ ): Iterable<Rule<GItem>> {
1162
+ if (!this.host.recogniseSpan) return;
1163
+ const tail = queryBytes.subarray(fact.j, queryLen);
1164
+ if (tail.length === 0) return;
1165
+ // The entity candidates are the forms the fact's own bytes CONTAIN — the
1166
+ // same recogniser the query went through, so the evidence standard is the
1167
+ // query's. A byte atom is never a subject; the fact's own node is the span
1168
+ // itself, not an entity inside it.
1169
+ for (const site of this.host.recogniseSpan(fact.bytes).sites) {
1170
+ if (site.payload < 0 || site.payload === fact.node) continue;
1171
+ if (
1172
+ !this.store.hasNext(site.payload) &&
1173
+ !this.store.hasHalo(site.payload)
1174
+ ) continue;
1175
+ const key = this.host.resolve(
1176
+ concat2(this.store.bytesPrefix(site.payload, ALL), tail),
1177
+ );
1178
+ if (key === null) continue;
1179
+ const nx = this.store.nextFirst(key, 1);
1180
+ if (nx.length === 0) continue;
1181
+ yield {
1182
+ premises: [fact],
1183
+ conclusion: {
1184
+ kind: "out",
1185
+ i: fact.i,
1186
+ j: queryLen,
1187
+ bytes: this.store.bytesPrefix(nx[0], ALL),
1188
+ cover: true,
1189
+ rec: true,
1190
+ node: nx[0],
1191
+ throughFact: true,
1192
+ },
1193
+ cost: STEP,
1194
+ };
1195
+ }
1196
+ }
1197
+
1116
1198
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
1117
1199
  * 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. */
1200
+ * boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
1201
+ * finalised out, and — for a produced fact — JOINING the entity it contains
1202
+ * with the query's tail ({@link deriveThrough}). */
1120
1203
  private *outRules(
1121
1204
  it: OutItem,
1122
1205
  ctx: {
@@ -1133,6 +1216,8 @@ export class GraphSearch {
1133
1216
  findBranchU: (k: number[]) => number | undefined;
1134
1217
  linksByLeft?: ReadonlyMap<number, Array<[number, Uint8Array]>>;
1135
1218
  linksByRight?: ReadonlyMap<number, Array<[number, Uint8Array]>>;
1219
+ queryBytes: Uint8Array;
1220
+ queryLen: number;
1136
1221
  },
1137
1222
  ): Iterable<Rule<GItem>> {
1138
1223
  const { splits, coversDone, outsByStart, outsByEnd, coverableByStart } =
@@ -1225,6 +1310,19 @@ export class GraphSearch {
1225
1310
 
1226
1311
  for (const r of outsByStart.get(it.j) ?? []) yield* this.fuse(it, r, ctx);
1227
1312
  for (const l of outsByEnd.get(it.i) ?? []) yield* this.fuse(l, it, ctx);
1313
+
1314
+ // ── DERIVE-THROUGH (one more rule of the DeductionSystem this module builds)
1315
+ // A produced fact may CONTAIN the subject the query never named; the query's
1316
+ // remaining tail then names the relation to follow FROM that subject. The
1317
+ // pair (contained entity, tail) is itself a learned key, and its
1318
+ // continuation is the derived answer — a genuine relational join, distinct
1319
+ // from the confluence mechanism's `join` PROVENANCE — and not the
1320
+ // juxtaposition the cover produces when the intermediate key IS named.
1321
+ // Fired per finalized out with a node, so it is the search's own rule, on
1322
+ // the ladder, memoised by {@link key}, and bounded by the fact's own length.
1323
+ if (it.node !== undefined) {
1324
+ yield* this.deriveThrough(it, ctx.queryBytes, ctx.queryLen);
1325
+ }
1228
1326
  }
1229
1327
 
1230
1328
  /** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
@@ -16,14 +16,16 @@ import type { PipelineMechanism } from "../pipeline-mechanism.js";
16
16
  export function aluToMechanism(alu: Alu): PipelineMechanism {
17
17
  return {
18
18
  name: "alu",
19
- // Not a cover derivation: cover.ts composes an answer by walking
20
- // recognised query STRUCTURE; the ALU evaluates a recognised expression
21
- // to its authoritative result and hands the bytes back untouched. It
22
- // shares cover's near-zero floor (computation always wins, masked into
23
- // cover's own search — see mechanisms/cover.ts), but the candidate this
24
- // produces is not one of cover's derivations, so it carries its own
25
- // honest label, the same way extract/cast/recall each carry theirs.
26
- provenance: "alu",
19
+ // The computation is GROUNDED BY COVER: this adapter's `parse` puts the
20
+ // authoritative span into `pre.computed`, cover masks it, and cover's
21
+ // derivation is what carries the answer out — measured, every computed
22
+ // probe reports provenance `cover`. So the adapter declares the core
23
+ // provenance the answer actually has. The ALU's own act is named where it
24
+ // belongs, in the TRACE (`evalComputation`, emitted by its `parse`), not in
25
+ // the provenance: a mechanism may not invent a label outside the pipeline's
26
+ // `Provenance` vocabulary, because post-grounding gates on that vocabulary
27
+ // (see the `provenance` contract in pipeline-mechanism.ts).
28
+ provenance: "cover",
27
29
  parse: (query) => alu.parse(query),
28
30
  async floor(_ctx, _query, pre, _worthRunning) {
29
31
  return pre.computed.length > 0 ? 0 : null;
package/src/mind/mind.ts CHANGED
@@ -410,8 +410,9 @@ export class Mind implements MindContext {
410
410
  // `makeKeyring`, `Space.rand` and the `Alphabet` below, so folding a
411
411
  // query under config.ts's default (42) against a store trained with
412
412
  // another seed (e.g. 7) lands in a DIFFERENT vector space than the one
413
- // the artifact's nodes were folded into: recognition and resonance then
414
- // read the wrong space and every answer degrades silently. An explicit
413
+ // the artifact's nodes were folded into: recognition and resonance read
414
+ // the wrong space, and answers silently diverge — pinned by test/97,
415
+ // where only adoption reproduces the artifact's own answer. An explicit
415
416
  // caller seed still wins — this only replaces the unconfigured default.
416
417
  if (explicitSeed === undefined && this.store.trainSeed !== null) {
417
418
  this.cfg.seed = this.store.trainSeed;
@@ -712,7 +712,13 @@ export interface PipelineMechanism {
712
712
  /** Stable identifier for trace/debug. */
713
713
  readonly name: string;
714
714
 
715
- /** Which provenance tag the pipeline attaches to this mechanism's answers. */
715
+ /** Which provenance tag the pipeline attaches to this mechanism's answers.
716
+ * DECLARED, not free-form: the pipeline narrows it to its own `Provenance`
717
+ * vocabulary (pipeline.ts), because post-grounding gates on that vocabulary
718
+ * — so an adapter declares one of those values, never a label of its own.
719
+ * The type is `string` only because a mechanism imports nothing from
720
+ * `pipeline.ts` (constraint 1, decoupling); the contract is semantic, and
721
+ * every shipped adapter honours it (see mechanisms/alu.ts). */
716
722
  readonly provenance: string;
717
723
 
718
724
  /** Parse authoritative spans BEFORE the grounding loop.
@@ -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 does not hold for every embedded form: the
524
+ // chain grows single-byte leaf ids and SKIPS a prefix whose
525
+ // `findBranch(ids)` is null, so a form the write side stored as a nested
526
+ // tree rather than as a flat leaf-id branch is never `resolveSpan`ned
527
+ // (measured: "Gustaf Molander" embedded in "The director of Eva is
528
+ // Gustaf Molander." gains no branch at any prefix), and its INTERIOR
529
+ // reach is one chunk plus W, which can be shorter than the form. The
530
+ // result was a dead zone: a form shorter than `chainReach` that neither
531
+ // starts on a fold cut nor ends on a node edge was unreachable by either
532
+ // tier — the exact site whose loss `tryChain`'s own note records as "the
533
+ // pivot dies with the site and multi-hop goes silent". The interior
534
+ // pass below spends the same budget on those 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,26 @@ 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 within the same `chainReach(W)` span bound the chain
593
+ // trusts — the dead zone the gate above used to leave: a form that neither
594
+ // starts on a fold cut nor ends on a node edge is exactly the one neither
595
+ // the chain (nested, `findBranch` misses) nor the two edge scans reach.
596
+ // Only pairs whose span lies in [W, chainReach(W)] are PROBED, which is
597
+ // the O(n · W²) work that matters; the enumeration itself is the all-pairs
598
+ // scan, each pair a constant-time span check — and the span bound is what
599
+ // keeps this off the quadratic path the budget note above describes (that
600
+ // one had no span bound at all).
601
+ {
602
+ const reach = chainReach(W);
603
+ for (const end of ordered) {
604
+ for (const start of ordered) {
605
+ if (start >= end) continue;
606
+ const span = end - start;
607
+ if (span < W || span > reach) continue;
608
+ spend(start, end);
609
+ }
610
+ }
611
+ }
578
612
  }
579
613
  }
580
614
 
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
+ "derive-through":
79
+ "derive the answer through a produced fact's own subject and the query's tail, 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
+ });
@@ -6,8 +6,9 @@
6
6
  // authoritative for the artifact. The seed feeds `makeKeyring`, `Space.rand`
7
7
  // and the `Alphabet` in the Mind constructor: folding a query under any other
8
8
  // seed lands in a DIFFERENT vector space than the one the artifact's nodes were
9
- // folded into, so recognition and resonance read the wrong space and every
10
- // answer degrades silently.
9
+ // folded into, so recognition and resonance read the wrong space and answers
10
+ // silently diverge — this file pins that only adoption reproduces the
11
+ // artifact's own answer.
11
12
  //
12
13
  // The store recovers `train.D` and `geometry.maxGroup` from its own metadata at
13
14
  // open; `train.seed` must be recovered the same way, and a Mind that did not
@@ -0,0 +1,99 @@
1
+ // 99-fact-join.test.mjs — DERIVE-THROUGH: derive the answer THROUGH a produced
2
+ // fact 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 `deriveThrough` rule in graph-search.ts closes the gap: it
9
+ // takes the entity the produced fact CONTAINS, combines it with the query's
10
+ // remaining tail, and asks the store for that key's continuation — a direct STEP
11
+ // on the ladder, reported as `derive-through` in the rationale. (The operation
12
+ // is a relational join; the MOVE is named `derive-through` so it cannot be read
13
+ // as the confluence mechanism's `join` PROVENANCE.)
14
+ //
15
+ // WHY THE FIXTURE CROSSES N=4096. Below the atomIsHub flip the interior subject
16
+ // surfaces anyway (small-store recognition) and the PIVOT alone reaches the
17
+ // chain, so a small fixture passes under the pre-change tree too — measured on a
18
+ // 3-deposit store, which answered "The father of Gustaf Molander is Harald
19
+ // Molander." with no derive-through move. That rule is what carries the chain at corpus scale,
20
+ // so the fixture must sit past the flip, exactly as test/78 does. The filler is
21
+ // lexically varied for the same reason test/78's is: a templated corpus folds to
22
+ // shared chunks and leaves the query uncontested.
23
+
24
+ import { test } from "node:test";
25
+ import assert from "node:assert/strict";
26
+ import { Mind } from "../dist/src/index.js";
27
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
28
+
29
+ const CHAIN = [
30
+ ["Eiffel Tower country", "The country of Eiffel Tower is France."],
31
+ ["France capital", "The capital of France is Paris."],
32
+ // The pivot fact: the intermediate subject as a context of its own.
33
+ ["France", "The capital of France is Paris."],
34
+ ];
35
+
36
+ const WORDS =
37
+ ("alpha bravo charlie delta echo foxtrot golf hotel india juliet " +
38
+ "kilo lima mike november oscar papa quebec romeo sierra tango uniform " +
39
+ "victor whiskey xray yankee zulu amber bronze copper dahlia ember fjord " +
40
+ "gossamer harbour indigo jasmine kestrel lantern marigold nectar opal " +
41
+ "pewter quartz ripple saffron thistle umber violet willow xenon yarrow")
42
+ .split(" ");
43
+ const filler = (i) => {
44
+ const w = (n) => WORDS[(i * 7 + n * 13) % WORDS.length];
45
+ return [
46
+ `${w(1)} ${w(2)} ${w(3)} ${i}`,
47
+ `${w(4)} ${w(5)} ${w(6)} ${w(7)} ${i}`,
48
+ ];
49
+ };
50
+
51
+ /** One store, ingested past the atomIsHub flip. */
52
+ async function pastTheFlip() {
53
+ const store = new SQliteStore({ path: ":memory:", D: 1024 });
54
+ const mind = new Mind({ seed: 7, store });
55
+ await mind.ingest(CHAIN);
56
+ await mind.ingest(Array.from({ length: 4300 }, (_, i) => filler(i)));
57
+ return { store, mind };
58
+ }
59
+
60
+ test("derive-through: a produced fact whose subject the query never names", async () => {
61
+ const { store, mind } = await pastTheFlip();
62
+ const query = "Eiffel Tower country capital";
63
+ assert.ok(
64
+ !query.includes("France") && !query.includes("capital of France"),
65
+ "sanity: the intermediate subject must NOT be named in the query",
66
+ );
67
+
68
+ const moves = [];
69
+ const out = await mind.respondText(
70
+ query,
71
+ (s) => moves.push(s.mechanism[s.mechanism.length - 1]),
72
+ );
73
+
74
+ assert.equal(out.trim(), "The capital of France is Paris.");
75
+ assert.ok(
76
+ moves.includes("derive-through"),
77
+ `expected derive-through to be the move that reached the answer, got: ${
78
+ [...new Set(moves)].join(", ")
79
+ }`,
80
+ );
81
+ await store.close();
82
+ });
83
+
84
+ test("naming the intermediate does not need derive-through — the cover reads it directly", async () => {
85
+ // The contrast that keeps the move honest: when the subject IS written, the
86
+ // cover already reaches the chain, so no derive-through is claimed.
87
+ const { store, mind } = await pastTheFlip();
88
+ const moves = [];
89
+ const out = await mind.respondText(
90
+ "Eiffel Tower country France capital",
91
+ (s) => moves.push(s.mechanism[s.mechanism.length - 1]),
92
+ );
93
+ assert.ok(out.includes("The capital of France is Paris."));
94
+ assert.ok(
95
+ !moves.includes("derive-through"),
96
+ "a named intermediate must not be reported as derive-through",
97
+ );
98
+ await store.close();
99
+ });