@hviana/sema 0.9.0 → 0.9.2

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 (118) hide show
  1. package/AGENTS.md +7 -7
  2. package/dist/src/alu/src/index.d.ts +1 -1
  3. package/dist/src/alu/src/index.js +1 -1
  4. package/dist/src/alu/src/parser.js +2 -6
  5. package/dist/src/alu/src/resonance.d.ts +13 -0
  6. package/dist/src/alu/src/resonance.js +41 -0
  7. package/dist/src/alu/test/alu.test.js +39 -0
  8. package/dist/src/bytes.d.ts +6 -2
  9. package/dist/src/bytes.js +10 -4
  10. package/dist/src/canon.js +44 -0
  11. package/dist/src/geometry.d.ts +19 -1
  12. package/dist/src/geometry.js +125 -141
  13. package/dist/src/meter.d.ts +33 -0
  14. package/dist/src/meter.js +34 -1
  15. package/dist/src/mind/articulation.js +14 -1
  16. package/dist/src/mind/attention.d.ts +12 -0
  17. package/dist/src/mind/attention.js +44 -16
  18. package/dist/src/mind/bridge.js +3 -3
  19. package/dist/src/mind/derivation.d.ts +40 -0
  20. package/dist/src/mind/derivation.js +34 -0
  21. package/dist/src/mind/evidence.d.ts +24 -0
  22. package/dist/src/mind/evidence.js +90 -0
  23. package/dist/src/mind/graph-search.d.ts +89 -15
  24. package/dist/src/mind/graph-search.js +345 -174
  25. package/dist/src/mind/learning.js +1 -1
  26. package/dist/src/mind/mechanisms/cover.d.ts +19 -3
  27. package/dist/src/mind/mechanisms/cover.js +142 -61
  28. package/dist/src/mind/mechanisms/recall.js +10 -3
  29. package/dist/src/mind/mind.d.ts +6 -0
  30. package/dist/src/mind/mind.js +5 -2
  31. package/dist/src/mind/pipeline.d.ts +5 -1
  32. package/dist/src/mind/pipeline.js +220 -90
  33. package/dist/src/mind/primitives.d.ts +25 -5
  34. package/dist/src/mind/primitives.js +107 -44
  35. package/dist/src/mind/reasoning.d.ts +18 -4
  36. package/dist/src/mind/reasoning.js +487 -328
  37. package/dist/src/mind/recognition.js +29 -13
  38. package/dist/src/mind/resonance.js +1 -11
  39. package/dist/src/mind/traverse.d.ts +45 -5
  40. package/dist/src/mind/traverse.js +285 -8
  41. package/dist/src/mind/types.d.ts +16 -1
  42. package/dist/src/store-sqlite.d.ts +25 -0
  43. package/dist/src/store-sqlite.js +89 -1
  44. package/dist/src/store.d.ts +48 -4
  45. package/dist/src/store.js +86 -6
  46. package/docs/INDEX.md +20 -19
  47. package/docs/INVARIANTS.md +17 -16
  48. package/docs/architecture/bounded-reads.md +1 -1
  49. package/docs/architecture/caches.md +5 -4
  50. package/docs/architecture/closure.md +45 -5
  51. package/docs/architecture/cost-model.md +16 -0
  52. package/docs/architecture/evidence.md +113 -0
  53. package/docs/architecture/exact-vs-approximate.md +10 -9
  54. package/docs/architecture/factored-machinery.md +14 -13
  55. package/docs/architecture/fold-contract.md +51 -1
  56. package/docs/architecture/mechanism-market.md +21 -0
  57. package/docs/architecture/memoization.md +3 -3
  58. package/docs/architecture/meter.md +2 -1
  59. package/docs/architecture/saturation.md +12 -0
  60. package/docs/architecture/store.md +25 -2
  61. package/docs/failures/tempting-but-wrong.md +13 -2
  62. package/docs/harness/gates.md +12 -10
  63. package/docs/mechanisms/cover.md +23 -6
  64. package/jsr.json +1 -1
  65. package/package.json +1 -1
  66. package/src/alu/README.md +10 -2
  67. package/src/alu/src/index.ts +1 -0
  68. package/src/alu/src/parser.ts +6 -6
  69. package/src/alu/src/resonance.ts +42 -0
  70. package/src/alu/test/alu.test.ts +40 -0
  71. package/src/bytes.ts +13 -3
  72. package/src/canon.ts +40 -0
  73. package/src/geometry.ts +183 -154
  74. package/src/meter.ts +34 -1
  75. package/src/mind/articulation.ts +14 -2
  76. package/src/mind/attention.ts +47 -25
  77. package/src/mind/bridge.ts +3 -3
  78. package/src/mind/derivation.ts +77 -0
  79. package/src/mind/evidence.ts +107 -0
  80. package/src/mind/graph-search.ts +449 -221
  81. package/src/mind/learning.ts +1 -7
  82. package/src/mind/match.ts +1 -2
  83. package/src/mind/mechanisms/cast.ts +1 -2
  84. package/src/mind/mechanisms/cover.ts +207 -87
  85. package/src/mind/mechanisms/extraction.ts +1 -2
  86. package/src/mind/mechanisms/prefix-completion.ts +1 -1
  87. package/src/mind/mechanisms/recall.ts +17 -5
  88. package/src/mind/mechanisms/reference.ts +1 -1
  89. package/src/mind/mind.ts +9 -30
  90. package/src/mind/pipeline.ts +263 -104
  91. package/src/mind/primitives.ts +119 -43
  92. package/src/mind/reasoning.ts +611 -419
  93. package/src/mind/recognition.ts +24 -9
  94. package/src/mind/resonance.ts +2 -16
  95. package/src/mind/trace.ts +1 -1
  96. package/src/mind/traverse.ts +321 -8
  97. package/src/mind/types.ts +15 -11
  98. package/src/store-sqlite.ts +92 -1
  99. package/src/store.ts +113 -7
  100. package/test/105-derive-through-reports-its-refusal.test.mjs +8 -5
  101. package/test/106-the-join-fires.test.mjs +21 -0
  102. package/test/111-the-cover-assembly-is-counted.test.mjs +8 -5
  103. package/test/128-the-leads-somewhere-pair-agrees.test.mjs +18 -12
  104. package/test/136-the-two-named-limits.test.mjs +3 -2
  105. package/test/137-the-law-lives-once-and-below.test.mjs +21 -0
  106. package/test/148-exact-shortcuts-agree.test.mjs +188 -0
  107. package/test/149-the-closure-engine.test.mjs +138 -0
  108. package/test/150-the-join-is-output-sensitive.test.mjs +66 -0
  109. package/test/151-the-cover-pays-for-what-it-reaches.test.mjs +142 -0
  110. package/test/152-the-read-side-names-as-the-write-side.test.mjs +146 -0
  111. package/test/153-a-cheaper-bound-is-looked-at-first.test.mjs +155 -0
  112. package/test/154-the-question-names-the-step.test.mjs +281 -0
  113. package/test/24-generalization.test.mjs +32 -0
  114. package/test/36-bloom.test.mjs +53 -0
  115. package/test/37-cluster-dispersion-fusion.test.mjs +75 -0
  116. package/test/48-recognise-turn-connective.test.mjs +3 -2
  117. package/test/55-cost-meter.test.mjs +4 -4
  118. package/test/90-connector-read-cap.test.mjs +7 -7
@@ -48,9 +48,6 @@ function pushInto(mp, k, v) {
48
48
  else
49
49
  mp.set(k, [v]);
50
50
  }
51
- /** Read the chosen spans back off a derivation: the goal is a chain of bridge
52
- * steps, each whose second premise is the `out` it crossed. Walk the chain to
53
- * the axiom and reverse into left-to-right order. */
54
51
  /** The BYTE TERM of a derivation's cost: how many bytes its bridge rule charged
55
52
  * at PASS each — read back off the rule that charged them (`bridgeRule`:
56
53
  * `o.rec ? MICRO : PASS * (o.j - o.i)`), so the split exists in ONE place and
@@ -74,6 +71,9 @@ function readBridgedBytes(derivation) {
74
71
  }
75
72
  return bytes;
76
73
  }
74
+ /** Read the chosen spans back off a derivation: the goal is a chain of bridge
75
+ * steps, each whose second premise is the `out` it crossed. Walk the chain to
76
+ * the axiom and reverse into left-to-right order. */
77
77
  function readCover(derivation) {
78
78
  const segs = [];
79
79
  let node = derivation;
@@ -95,6 +95,43 @@ function readCover(derivation) {
95
95
  segs.reverse();
96
96
  return segs;
97
97
  }
98
+ /** The chart's canonical key — an item's boundary signature (see
99
+ * {@link DeductionSystem.key}). Module-level because the join license
100
+ * ({@link GraphSearch.solve}) names facts by the same key the chart does. */
101
+ function chartKey(it) {
102
+ if (it.kind === "cover")
103
+ return "c" + it.p;
104
+ if (it.kind === "form") {
105
+ return `f${it.i}.${it.j}.${it.node}.${it.via ? 1 : 0}.${it.rcmp ? 1 : 0}${it.ask ? "?" : ""}`;
106
+ }
107
+ 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)}`;
108
+ }
109
+ /** The facts a derivation STANDS ON — every node-bearing `out` in its proof
110
+ * tree, premises before conclusions, each once. */
111
+ function factsOf(root) {
112
+ const out = [];
113
+ const seen = new Set();
114
+ const walk = (d) => {
115
+ if (seen.has(d))
116
+ return;
117
+ seen.add(d);
118
+ for (const p of d.premises)
119
+ walk(p);
120
+ if (d.item.kind === "out" && d.item.node !== undefined)
121
+ out.push(d.item);
122
+ };
123
+ walk(root);
124
+ return out;
125
+ }
126
+ /** Whether a run asked for anything — its cover is then provisional. */
127
+ function asked(concepts, connectors) {
128
+ return concepts.asked.size > 0 || (connectors?.asked.size ?? 0) > 0;
129
+ }
130
+ /** Nothing offered: a cover with no async premises (a nested completion,
131
+ * articulation's revoicing). */
132
+ export function noLicence() {
133
+ return { offered: new Set(), granted: new Map(), asked: new Set() };
134
+ }
98
135
  /** Flatten a {@link GItem} into the {@link DerivationItem} a rationale shows. */
99
136
  function derivationItem(it) {
100
137
  if (it.kind === "cover")
@@ -217,17 +254,9 @@ export class GraphSearch {
217
254
  this.maxGroup = maxGroup;
218
255
  this.host = host;
219
256
  }
220
- /** The nodes the QUERY canonically names — the same identity the store's keys
221
- * were written through. A byte-exact test is not enough: the query writes
222
- * `Eiffel Tower country` and the deposited node is `eiffel tower country`, so
223
- * a join that filters the query's own subject by RAW bytes re-admits it —
224
- * measured: that is the trap's wrong answer (`The capital of Eiffel Tower
225
- * country is Berlin.`). Cached by query identity, because the search is
226
- * reused across responses. */
227
- /* * The hub bound √N (bounded-reads.md) — the ONE
228
- * fan-out cap, stated here rather than imported from `traverse.ts` because
229
- * this module is deliberately host-based (it holds a bare Store, never a
230
- * MindContext).
257
+ /** The hub bound √N (bounded-reads.md) — the ONE fan-out cap, stated here
258
+ * rather than imported from `traverse.ts` because this module is
259
+ * deliberately host-based (it holds a bare Store, never a MindContext).
231
260
  * That is the same write/read-side duplication convention canonical.ts's
232
261
  * header documents: if the formula changes it must change in BOTH places.
233
262
  * It is stated ONCE per side, though — the expression used to be spelled
@@ -251,8 +280,10 @@ export class GraphSearch {
251
280
  *
252
281
  * Any learnt connector between two rewrites is spliced IN by the in-search
253
282
  * connector rule (see {@link outRules}), so the returned spans already carry
254
- * it — there is no post-pass. */
255
- cover(queryLen, sites, conceptTarget, leaves, splits, substitutions, connectors, computedResults,
283
+ * it — there is no post-pass. With a {@link ConnectorLicence}, a cover
284
+ * returned while `connectors.asked` is non-empty is PROVISIONAL: grant the
285
+ * asked pairs and cover again. */
286
+ cover(queryLen, sites, concepts, leaves, splits, substitutions, connectors, computedResults,
256
287
  /** When given, receives each solved span's lightest derivation — the full
257
288
  * adapted A*LD proof tree as classified {@link DerivationStep}s — for the
258
289
  * TOP cover AND every nested completion the sink is threaded into (see
@@ -265,11 +296,15 @@ export class GraphSearch {
265
296
  // through (completion is cover, recursively — see {@link recompleteNode}).
266
297
  this.recompleteOpen.clear();
267
298
  this.recompleteMemo = new Map();
299
+ this.entityMemo = new Map();
268
300
  // The top cover's derivation sink is threaded into every nested completion
269
301
  // so the recompositions a produced form needs are reported in the same
270
302
  // trace instead of vanishing after the first layer.
271
303
  this.derivationSink = onDerivation;
272
- const solved = this.solve(queryLen, { sites, leaves, splits }, conceptTarget, substitutions, connectors, computedResults, onDerivation);
304
+ concepts.asked.clear();
305
+ if (connectors)
306
+ connectors.asked.clear();
307
+ const solved = this.solve(queryLen, { sites, leaves, splits }, concepts, substitutions, connectors, computedResults, onDerivation);
273
308
  // Deepening runs HERE, once, on the derivation the top cover CHOSE — never
274
309
  // inside the nested solve a completion runs. Nesting the deepening is what
275
310
  // made per-query cost track how densely the corpus interconnects the forms
@@ -277,7 +312,10 @@ export class GraphSearch {
277
312
  // continuations instead of following the chain the answer itself licensed.
278
313
  // With deepening only at the top, `recompleteNode` walks the accepted chain
279
314
  // one link at a time (its own memo and stack), so the work is the answer's.
280
- return solved === null ? null : {
315
+ // A provisional cover is not deepened: it is about to be re-covered.
316
+ if (solved === null || asked(concepts, connectors))
317
+ return solved;
318
+ return {
281
319
  segs: this.deepen(solved.segs),
282
320
  cost: solved.cost,
283
321
  moves: solved.moves,
@@ -296,18 +334,78 @@ export class GraphSearch {
296
334
  * decomposition may therefore run as deep as the graph licenses — three
297
335
  * decomposes, two recomposes, any mix — and stops only when it reaches a node
298
336
  * that leads nowhere new, never at an arbitrary count. */
299
- solve(spanLen, recognition, conceptTarget, substitutions, connectors, computedResults, onDerivation) {
300
- const system = this.buildSearch(spanLen, recognition.sites, conceptTarget, recognition.leaves, recognition.splits, substitutions, connectors, computedResults);
337
+ solve(spanLen, recognition, concepts, substitutions, connectors, computedResults, onDerivation) {
338
+ // The query's own bytes, tiled from its perceived leaves. A JOIN reads the
339
+ // tail a produced fact's contained entity has to combine with, and the
340
+ // search otherwise only ever sees positions, never the bytes behind them.
341
+ const queryBytes = new Uint8Array(spanLen);
342
+ for (const lf of recognition.leaves) {
343
+ queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
344
+ }
345
+ // The nodes this span's recognition names — read ONCE per solve, on the first
346
+ // join that asks, instead of re-reading the memo per fact.
347
+ let queryNodeSet;
348
+ const queryNodes = () => queryNodeSet ??= new Set((this.host.recogniseSpan?.(queryBytes)?.sites ?? []).map((s) => s.payload));
349
+ // THE JOIN DERIVES THROUGH THE FACTS THE DERIVATION STANDS ON — never
350
+ // through every fact the exploration REACHES. How many facts the search
351
+ // reaches is set by how densely the corpus interconnects the forms on the
352
+ // way (a dialogue hub's continuations, walked to their fixpoints, are
353
+ // thousands), and pricing a join from each of them made the work track the
354
+ // corpus instead of the answer: measured on the 31.7M-node store, a 46-byte
355
+ // query reached 2,000+ fixpoints in a minute, each paying an entity scan,
356
+ // past 10 GB. It is the trap {@link recompleteNode} already records, and
357
+ // the same cure as {@link deepen}: the expensive step runs on what the
358
+ // lightest derivation CHOSE.
359
+ //
360
+ // So the search runs to its lightest derivation, the facts that derivation
361
+ // stands on are LICENSED — their joins computed once — and it runs again
362
+ // with those joins as ordinary rules of the same system, priced exactly as
363
+ // before (the fact's own cost + STEP). A join's conclusion is itself a fact
364
+ // the next derivation may stand on, so chains extend round by round until
365
+ // no newly licensed fact joins anything: the depth is the answer's, never a
366
+ // count. A round is re-run only when a newly licensed fact DOES join, so a
367
+ // query whose facts join nothing pays one search, as before.
368
+ //
369
+ // The joins a covering round licensed are KEPT across a licence's re-covers:
370
+ // a round that asked for nothing is the very round the next re-cover repeats
371
+ // (see {@link Licence}), so its joins are the ones that re-cover would grant
372
+ // again.
373
+ const joins = this.joinsKept.get(concepts) ?? new Map();
374
+ this.joinsKept.set(concepts, joins);
301
375
  // Search-effort accounting (src/meter.ts): the chart's pops/pushes are
302
376
  // the cover's real cost, and a heuristic that stops being admissible
303
377
  // shows up as a pop count that explodes while the answer stays the same.
304
378
  const meter = this.host.meter;
305
- const stats = meter ? { pops: 0, pushes: 0 } : undefined;
306
- const derivation = lightestDerivation(system, stats);
307
- if (meter && stats) {
308
- meter.searches++;
309
- meter.searchPops += stats.pops;
310
- meter.searchPushes += stats.pushes;
379
+ let derivation;
380
+ for (;;) {
381
+ const system = this.buildSearch(spanLen, recognition.sites, concepts, recognition.leaves, recognition.splits, queryBytes, joins, substitutions, connectors, computedResults);
382
+ const stats = meter ? { pops: 0, pushes: 0 } : undefined;
383
+ derivation = lightestDerivation(system, stats);
384
+ if (meter && stats) {
385
+ meter.searches++;
386
+ meter.searchPops += stats.pops;
387
+ meter.searchPushes += stats.pushes;
388
+ }
389
+ // A round that ASKED for a connector is provisional: return it before any
390
+ // join is licensed from a derivation the granted connectors may change.
391
+ if (derivation === null || asked(concepts, connectors))
392
+ break;
393
+ let joined = false;
394
+ for (const fact of factsOf(derivation)) {
395
+ const k = chartKey(fact);
396
+ if (joins.has(k))
397
+ continue;
398
+ if (meter)
399
+ meter.joinFacts++;
400
+ const granted = [
401
+ ...this.deriveThrough(fact, queryBytes, spanLen, queryNodes),
402
+ ].map((r) => ({ conclusion: r.conclusion, cost: r.cost }));
403
+ joins.set(k, granted);
404
+ if (granted.length > 0)
405
+ joined = true;
406
+ }
407
+ if (!joined)
408
+ break;
311
409
  }
312
410
  // When covering under a substitution map (articulation), a form→out rule is
313
411
  // the form EMITTING the asker's voice, not grounding to its own answer — so
@@ -354,20 +452,13 @@ export class GraphSearch {
354
452
  * adjacent fragments toward a known leaf (findLeaf) or branch (findBranch);
355
453
  * a completion fused with its neighbour may spell a deeper learned form the
356
454
  * flat probes can't name, recovered canonically by {@link resolve}. */
357
- buildSearch(queryLen, sites, conceptTarget, leaves, splits, substitutions, connectors, computedResults) {
455
+ buildSearch(queryLen, sites, concepts, leaves, splits, queryBytes, joins, substitutions, connectors, computedResults) {
358
456
  const W = this.maxGroup; // fusible span ceiling (shortest composite bound)
359
457
  // Same corpus-scale hub floor {@link atomIsHub}/{@link atomReach} (traverse.ts)
360
458
  // derive for byte atoms — see {@link hubBound} below for why this module
361
459
  // states the formula itself instead of importing it.
362
460
  const atomsAreHubs = Math.max(1, Math.ceil((this.store.edgeSourceCount() * W) / 256)) > this.hubBound();
363
461
  const nodeBytes = (n) => this.store.bytesPrefix(n, ALL);
364
- // The query's own bytes, tiled from its perceived leaves. A JOIN reads the
365
- // tail a produced fact's contained entity has to combine with, and `buildSearch`
366
- // otherwise only ever sees positions, never the bytes behind them.
367
- const queryBytes = new Uint8Array(queryLen);
368
- for (const lf of leaves) {
369
- queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
370
- }
371
462
  // Content-addressed probes over the store's hash-cons maps — the same keys
372
463
  // training filled. No byte-by-byte trie walk.
373
464
  const findLeafU = (b) => this.store.findLeaf(b) ?? undefined;
@@ -378,30 +469,80 @@ export class GraphSearch {
378
469
  const outsByEnd = new Map();
379
470
  const outsByNode = new Map();
380
471
  const coverableByStart = new Map();
381
- // Index the connectors by their left and right answer-node, so the connector
382
- // rule iterates only this out's FEW resolved partners (selective, and for the
383
- // N-ary case O(parts) keys) instead of scanning every position pair — what
384
- // keeps the in-search bridge bounded when many parts are recognised at once.
472
+ // Index the offered connectors by their left and right answer-node, so the
473
+ // connector rule iterates only this out's FEW offered partners (selective,
474
+ // and for the N-ary case O(parts) keys) instead of scanning every position
475
+ // pair — what keeps the in-search bridge bounded when many parts are
476
+ // recognised at once.
385
477
  const linksByLeft = new Map();
386
478
  const linksByRight = new Map();
387
479
  if (connectors) {
388
- for (const [key, bytes] of connectors) {
480
+ for (const key of connectors.offered) {
389
481
  const comma = key.indexOf(",");
390
482
  const l = Number(key.slice(0, comma));
391
483
  const r = Number(key.slice(comma + 1));
392
- pushInto(linksByLeft, l, [r, bytes]);
393
- pushInto(linksByRight, r, [l, bytes]);
484
+ pushInto(linksByLeft, l, [r, key]);
485
+ pushInto(linksByRight, r, [l, key]);
394
486
  }
395
487
  }
396
- return {
397
- key(it) {
398
- if (it.kind === "cover")
399
- return "c" + it.p;
400
- if (it.kind === "form") {
401
- return `f${it.i}.${it.j}.${it.node}.${it.via ? 1 : 0}.${it.rcmp ? 1 : 0}`;
488
+ // A SPAN'S CHEAPEST COMPLETION DOMINATES THE REST. Coverage is positional:
489
+ // every recognised completion of [i, j) advances the cover from i to j at
490
+ // the same MICRO, so for the goal only the cheapest matters. What else a
491
+ // completion can do turns on its BYTES — fuse it with a neighbour, splice a
492
+ // connector, join through it — and those rules fire from the completion the
493
+ // search would stand on for that span, never from every alternative it
494
+ // reached: the cure {@link deepen} and the join license already apply, for
495
+ // the same trap. Measured on the 31.7M-node store, a 46-byte dialogue query
496
+ // reached 60,530 continuation forms and 57,072 fixpoints for its few spans —
497
+ // a greeting's thousands of replies, each a fixpoint at the same depth — and
498
+ // canonically resolved every one of them fused with its neighbours (5.4 s
499
+ // of an 8.6 s cover) to stand, in the end, on two hops.
500
+ //
501
+ // So a form or completion of [i, j) whose cost has reached that of a
502
+ // completion of [i, j) already yielded is DOMINATED and fires nothing: every
503
+ // completion it could lead to costs at least as much (local costs are
504
+ // non-negative and a form's rules keep its span), and a tie goes to the one
505
+ // yielded first — the evidence-preferred continuation, yielded first by
506
+ // {@link formRules}. This is also what makes the stop-here offer a real
507
+ // horizon: a chain deeper than its first hop's stop (STEP + CONCEPT) is
508
+ // dominated by it, where before the PASS-priced goal let the search buy a
509
+ // thousand hops to look for one more byte.
510
+ const spanBest = new Map();
511
+ const spanOf = (i, j) => i * (queryLen + 1) + j;
512
+ const completes = (it) => it.kind === "out" && it.rec && it.cover;
513
+ const dominated = (it, cost) => {
514
+ if (it.kind === "cover")
515
+ return false;
516
+ if (it.kind === "out" && !completes(it))
517
+ return false;
518
+ const best = spanBest.get(spanOf(it.i, it.j));
519
+ if (best === undefined || best.cost > cost) {
520
+ if (completes(it))
521
+ spanBest.set(spanOf(it.i, it.j), { cost, item: it });
522
+ return false;
523
+ }
524
+ return best.item !== it &&
525
+ (it.kind !== "out" || chartKey(best.item) !== chartKey(it));
526
+ };
527
+ const completing = function* (rules, costOf) {
528
+ for (const r of rules) {
529
+ const c = r.conclusion;
530
+ if (completes(c)) {
531
+ let cost = r.cost;
532
+ for (const p of r.premises)
533
+ cost += costOf(p);
534
+ const s = spanOf(c.i, c.j);
535
+ const best = spanBest.get(s);
536
+ if (best === undefined || cost < best.cost) {
537
+ spanBest.set(s, { cost, item: c });
538
+ }
402
539
  }
403
- 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)}`;
404
- },
540
+ yield r;
541
+ }
542
+ };
543
+ const meter = this.host.meter;
544
+ return {
545
+ key: chartKey,
405
546
  *axioms() {
406
547
  yield { item: { kind: "cover", p: 0 }, cost: 0 };
407
548
  // One out per tree leaf — content-defined chunks, far fewer than bytes.
@@ -487,14 +628,19 @@ export class GraphSearch {
487
628
  const right = it.kind === "cover" ? it.p : it.j;
488
629
  return (queryLen - right) * MICRO;
489
630
  },
490
- rules: (it) => {
631
+ rules: (it, costOf) => {
491
632
  if (it.kind === "cover") {
492
633
  return this.coverRules(it, coversDone, coverableByStart);
493
634
  }
635
+ if (dominated(it, costOf(it))) {
636
+ if (meter)
637
+ meter.searchDominated++;
638
+ return [];
639
+ }
494
640
  if (it.kind === "form") {
495
- return this.formRules(it, conceptTarget, substitutions, nodeBytes, queryLen);
641
+ return completing(this.formRules(it, concepts, substitutions, nodeBytes), costOf);
496
642
  }
497
- return this.outRules(it, {
643
+ return completing(this.outRules(it, {
498
644
  W,
499
645
  splits,
500
646
  atomsAreHubs,
@@ -507,9 +653,9 @@ export class GraphSearch {
507
653
  findBranchU,
508
654
  linksByLeft,
509
655
  linksByRight,
510
- queryBytes,
511
- queryLen,
512
- });
656
+ connectors,
657
+ joins,
658
+ }), costOf);
513
659
  },
514
660
  };
515
661
  }
@@ -551,16 +697,22 @@ export class GraphSearch {
551
697
  }
552
698
  /** The connector-SPLICE rule for an oriented (l, r) pair, or null when the
553
699
  * pair does not qualify — the ONE body behind {@link outRules}' two
554
- * mirror loops (this-as-left over resolved right partners, this-as-right
555
- * over resolved left partners). Fires only when both sides are
700
+ * mirror loops (this-as-left over offered right partners, this-as-right
701
+ * over offered left partners). Fires only when both sides are
556
702
  * recognised, r starts at or after l ends, and the gap between them is
557
703
  * empty or wholly recognised — never across the asker's own literal
558
- * separator. */
559
- trySplice(l, r, link, outsByEnd) {
704
+ * separator — and the pair's connector is granted and learnt. A qualifying
705
+ * pair not yet granted is ASKED for (see {@link ConnectorLicence}). */
706
+ trySplice(l, r, key, licence, outsByEnd) {
560
707
  if (!l.rec || !r.rec || r.i < l.j)
561
708
  return null;
562
709
  if (!this.gapRecognised(l.j, r.i, outsByEnd))
563
710
  return null;
711
+ const link = licence.granted.get(key);
712
+ if (link === undefined)
713
+ licence.asked.add(key);
714
+ if (!link)
715
+ return null;
564
716
  return {
565
717
  premises: [l, r],
566
718
  conclusion: {
@@ -577,7 +729,12 @@ export class GraphSearch {
577
729
  }
578
730
  /** form(i,j,node,via): follow the graph out of `node`, or (in articulation)
579
731
  * emit its substitute voice directly. */
580
- *formRules(it, conceptTarget, substitutions, nodeBytes, queryLen) {
732
+ *formRules(it, concepts, substitutions, nodeBytes) {
733
+ // An asking form reached the agenda's front: the hop it stands for is due.
734
+ if (it.ask) {
735
+ concepts.asked.add(it.node);
736
+ return;
737
+ }
581
738
  // Articulation: emit voice bytes at the recognised span; the hop/concept/
582
739
  // emit chain is suppressed — the form contributes only its substitute.
583
740
  if (substitutions) {
@@ -611,41 +768,17 @@ export class GraphSearch {
611
768
  // guard then dead-ends it) with no way to reach the forward edge.
612
769
  // Forking offers every continuation as its own rule so the one that
613
770
  // genuinely advances (not a duplicate) is still reachable.
614
- // A CHAIN HOP OFFERS ONLY WHAT THE QUESTION CAN PAY FOR.
615
771
  //
616
- // `hubBound` = √N is the READ cap — every read here stays inside it — but
617
- // it is not an exploration bound: measured, a hub of degree 1083 sits
618
- // BELOW √N = 1559, so a hop offered all 1083 continuations, the chart grew
619
- // to 3113 outs for a two-word question, and since every out with an
620
- // uncovered tail probes its tail's prefixes (measured: 16 885 canonical
621
- // probes = 87% of that query's work, and its 270 MB peak / 256 MB OOM),
622
- // the cost came from OFFERING rather than from reading.
623
- //
624
- // The bound is derived, not tuned: a derivation of L hops consumes ~L
625
- // units of the question, so a hop cannot be paid for by offering more
626
- // continuations than the question has units —
627
- // `ceil(queryLen / W)`, floored at 2 for plurality. It is QUERY-sized
628
- // (invariant 5: no per-query read grows with N) and it leaves `hubBound`
629
- // and every read untouched.
630
- // THE OFFER IS THE CORPUS'S OWN STRUCTURE, and the search pays for
631
- // exploring it. There is no offer cap here any more: the traversal cap I
632
- // had put on this hop was a short-circuit — it bounded what a hop could
633
- // OFFER instead of charging for it — and it was not needed.
634
- //
635
- // MEASURED in the regime where it used to bite (`hubBound = ceil(√N)`
636
- // GREATER than the hub's degree — reached in a fixture by choosing the
637
- // degree below √N, so the trained store is not needed): with the cap the
638
- // offer was 8/9/9 continuations at degrees 35/70/120; without it, 52/84/120
639
- // — and the WORK is LINEAR in the degree, not quadratic: pushes 262/296/332,
640
- // perceptions 530/592/757, while the PEAK is identical with and without the
641
- // cap (218/415/689 MB against 215/410/662) because it is set by the store,
642
- // not by the fan-out. What made this hop expensive was never the fan-out
643
- // breadth: it was the per-offer work, two duplicate/oversized computations
644
- // since removed (the per-offset canonical scan, and the tail scan now
645
- // restricted to the fold's boundaries).
646
- //
647
- // The residual, stated: the trained store's hub (degree 1 083) is an
648
- // EXTRAPOLATION from this linear shape, not a measurement.
772
+ // THE OFFER IS THE CORPUS'S OWN STRUCTURE, and the search pays for it.
773
+ // In the closure law's terms (derivation.ts) a hop is a MOVE — it reaches
774
+ // structure the derivation has not stood on — so it is admitted on that
775
+ // ground and priced one STEP; the A*LD search, not a count, decides how
776
+ // many are worth taking. `hubBound` is the READ cap (invariant 5) and
777
+ // nothing else bounds the offer: a cap on what a hop may OFFER was tried
778
+ // and removed as a short-circuit (trap 13). Measured with the hub's
779
+ // degree below √N: the work is LINEAR in the degree (pushes 262/296/332 at
780
+ // degrees 35/70/120) and the peak is set by the store, not the fan-out
781
+ // (test/112 pins the non-quadratic shape).
649
782
  const nx = this.store.nextFirst(it.node, this.hubBound());
650
783
  // Count what is OFFERED, not what was read: the evidence-preferred
651
784
  // continuation is yielded too, even when it lies outside the cap.
@@ -799,8 +932,26 @@ export class GraphSearch {
799
932
  // Recognised but edge-less: borrow a concept (halo) sibling's edge. No
800
933
  // edge and no concept means the form leads nowhere — it yields no rule, so
801
934
  // a query of only such forms produces no derivation, and think is silent.
802
- const target = conceptTarget.get(it.node);
803
- if (target !== undefined) {
935
+ // An offered form not yet granted yields the hop's ASKING form at the
936
+ // hop's own cost and span (see {@link Licence}).
937
+ if (!concepts.offered.has(it.node))
938
+ return;
939
+ const target = concepts.granted.get(it.node);
940
+ if (target === undefined) {
941
+ yield {
942
+ premises: [it],
943
+ conclusion: {
944
+ kind: "form",
945
+ i: it.i,
946
+ j: it.j,
947
+ node: it.node,
948
+ via: true,
949
+ ask: true,
950
+ },
951
+ cost: CONCEPT,
952
+ };
953
+ }
954
+ else if (target !== null) {
804
955
  yield {
805
956
  premises: [it],
806
957
  conclusion: {
@@ -937,7 +1088,7 @@ export class GraphSearch {
937
1088
  sites: [...recognised, ...structural],
938
1089
  leaves: rec.leaves,
939
1090
  splits: rec.splits,
940
- }, new Map(), undefined, undefined, undefined, this.derivationSink);
1091
+ }, noLicence(), undefined, undefined, undefined, this.derivationSink);
941
1092
  const answer = solved && concatBytes(solved.segs.map((s) => s.bytes));
942
1093
  // ACCEPT, then CONTINUE THE CHAIN — but only along an accepted
943
1094
  // completion. A re-cover whose result is not itself a learnt node (the
@@ -966,6 +1117,14 @@ export class GraphSearch {
966
1117
  * outs of a long query re-cover each distinct node at most once); reset at the
967
1118
  * top of {@link cover}. */
968
1119
  recompleteMemo = new Map();
1120
+ /** {@link entityProposals}, per fact BYTES — a pure function of them while
1121
+ * the store is read-only (one response), so a fact the chart reaches as
1122
+ * several items (cover/fix variants, nested completions) is scanned once.
1123
+ * Reset at the top of {@link cover}, like {@link recompleteMemo}. */
1124
+ entityMemo = new Map();
1125
+ /** The joins licensed under one cover's {@link Licence}, kept across its
1126
+ * re-covers (see {@link solve}). */
1127
+ joinsKept = new WeakMap();
969
1128
  /** The derivation sink of the TOP cover, threaded into every nested
970
1129
  * completion so a produced form's own recompositions are reported in the
971
1130
  * same trace instead of vanishing after the first layer. Undefined when
@@ -980,6 +1139,57 @@ export class GraphSearch {
980
1139
  * most once per cover. A Set, not a flag, because it states WHICH node is
981
1140
  * open — the invariant a reader needs to check the guard. */
982
1141
  recompleteOpen = new Set();
1142
+ /** The ENTITIES a produced fact's bytes contain that lead somewhere — the
1143
+ * candidates a join may travel through. The forms the fact CONTAINS, by the
1144
+ * same recogniser the query went through (so the evidence standard is the
1145
+ * query's), plus — because the recognition of a STORED WHOLE returns the
1146
+ * whole and stops (measured: one site, the fact's own node, for `The
1147
+ * director of Eva is Gustaf Molander.`) — the longest canonically-resolving
1148
+ * form at each offset. A byte atom is never a subject. The scan runs only
1149
+ * for a FORM (≥ W: per letter it measured 20-26 s in test/99), and each probe
1150
+ * is a `canonResolve` whose exact tier decides a miss by its segment probe,
1151
+ * without a fold. The admission predicate is the store's `leadsSomewhere`
1152
+ * (edge or halo); the host lends its memoised form when it can.
1153
+ *
1154
+ * The SOURCE of each proposal travels with it, so a refusal names which path
1155
+ * proposed the candidate. Memoised per fact bytes ({@link entityMemo}). */
1156
+ entityProposals(factBytes) {
1157
+ const memoKey = latin1(factBytes);
1158
+ const hit = this.entityMemo.get(memoKey);
1159
+ if (hit !== undefined)
1160
+ return hit;
1161
+ const leads = (id) => this.host.leadsSomewhere !== undefined
1162
+ ? this.host.leadsSomewhere(id)
1163
+ : this.store.leadsSomewhere(id);
1164
+ const sites = this.host.recogniseSpan?.(factBytes)?.sites ?? [];
1165
+ const proposed = new Map();
1166
+ const source = new Map();
1167
+ for (const s of sites) {
1168
+ if (s.payload >= 0 && leads(s.payload)) {
1169
+ proposed.set(s.payload, this.store.bytesPrefix(s.payload, ALL));
1170
+ source.set(s.payload, "recognised site");
1171
+ }
1172
+ }
1173
+ const W = this.maxGroup;
1174
+ if (this.host.canonResolve !== undefined && factBytes.length >= W) {
1175
+ const canon = this.host.canonResolve.bind(this.host);
1176
+ for (let start = 0; start < factBytes.length; start++) {
1177
+ for (let end = factBytes.length; end - start >= W; end--) {
1178
+ const id = canon(factBytes.subarray(start, end));
1179
+ if (id === null)
1180
+ continue;
1181
+ if (leads(id)) {
1182
+ proposed.set(id, this.store.bytesPrefix(id, ALL));
1183
+ source.set(id, "canonical fold");
1184
+ }
1185
+ break; // the longest form at this offset wins
1186
+ }
1187
+ }
1188
+ }
1189
+ const out = { proposed, source, sites };
1190
+ this.entityMemo.set(memoKey, out);
1191
+ return out;
1192
+ }
983
1193
  /** DERIVE-THROUGH — a RULE this module's DeductionSystem was missing. The
984
1194
  * A*LD library is untouched: this is one more `premises → conclusion + cost`
985
1195
  * rule in the system {@link buildSearch} hands to {@link lightestDerivation},
@@ -994,19 +1204,22 @@ export class GraphSearch {
994
1204
  * fact reached through it. On the ladder it is one STEP: a direct edge,
995
1205
  * exactly as following a literal continuation is. Deterministic and
996
1206
  * point-probed (`resolve` + `nextFirst`, no scan), so it adds no read that
997
- * grows with the corpus. The move is visible in the rationale as its own act
1207
+ * grows with the corpus. Asked ONCE per fact a lightest derivation stood on
1208
+ * (the join license, {@link solve}) — never per fact the exploration merely
1209
+ * reached — so the number of facts it prices is the answer's, not the
1210
+ * corpus's. The move is visible in the rationale as its own act
998
1211
  * (`classifyMove` reports `derive-through`), distinct from the
999
1212
  * byte-concatenating `fuse`/`splice` — and named `derive-through` rather than
1000
1213
  * `join` so it cannot be read as the confluence mechanism's `Provenance`. */
1001
- *deriveThrough(fact, queryBytes, queryLen) {
1214
+ *deriveThrough(fact, queryBytes, queryLen, queryNodes) {
1002
1215
  if (!this.host.recogniseSpan)
1003
1216
  return;
1004
1217
  const tail = queryBytes.subarray(fact.j, queryLen);
1005
1218
  if (tail.length === 0)
1006
1219
  return;
1007
- // Report ONLY the invocations that could have joined: the search asks this
1008
- // rule for every finalized out with a node, which includes the one-byte
1009
- // outs the cover bridges with — measured, 68 refusals for a single
1220
+ // Report ONLY the invocations that could have joined: a derivation stands on
1221
+ // node-bearing outs of every width, including the one-byte outs the cover
1222
+ // bridges with — measured, 68 refusals for a single
1010
1223
  // 3-relation query, all of them letters. A form shorter than one window is
1011
1224
  // not a fact a join could travel through, so it is not a refusal worth
1012
1225
  // reporting; W is the same line the rest of the mind draws between a chance
@@ -1016,65 +1229,20 @@ export class GraphSearch {
1016
1229
  // same recogniser the query went through, so the evidence standard is the
1017
1230
  // query's. A byte atom is never a subject; the fact's own node is the span
1018
1231
  // itself, not an entity inside it. The admission predicate has ONE
1019
- // definition — `traverse.ts`'s `leadsSomewhere` (edge or halo); the host
1020
- // LENDS it when it can (Mind does, with the response-scoped struct cache),
1021
- // and a bare host falls back to the raw-store probe, so the search stays
1232
+ // definition — the store's `leadsSomewhere` (edge or halo); the host LENDS
1233
+ // its memoised form when it can (Mind does, with the response-scoped struct
1234
+ // cache), and a bare host asks the store directly, so the search stays
1022
1235
  // host-based.
1023
- const factRec = this.host.recogniseSpan(fact.bytes);
1024
- const leads = (id) => this.host.leadsSomewhere !== undefined
1025
- ? this.host.leadsSomewhere(id)
1026
- : this.store.hasNext(id) || this.store.hasHalo(id);
1236
+ const { proposed, source, sites: factSites } = this.entityProposals(fact.bytes);
1027
1237
  // THE QUERY'S OWN SUBJECT, CANONICALLY. The filter used raw bytes and the
1028
1238
  // store's nodes are canonical, so `Eiffel Tower country` in the query did
1029
1239
  // not match the deposited `eiffel tower country` — measured, that is the
1030
- // trap's wrong answer.
1031
- //
1032
- // TAKEN FROM THE RECOGNITION THE RESPONSE ALREADY COMPUTED — the host's
1033
- // `recogniseSpan`, the same surface the rest of this search uses — not from
1034
- // a second offset scan. `canonicalQueryNodes` re-derived, per byte offset,
1035
- // what `recognise` had already resolved once per query (its memo is keyed by
1036
- // content), and that scan was the largest single cost the DIANOT join added:
1037
- // measured against the pre-change tree, the same fixture and the same test
1038
- // were 21 s slower with the scan than without it. A recognised site IS a
1039
- // canonical node of the query that can lead somewhere, which is exactly the
1040
- // set this filter wants, and it costs nothing to read.
1041
- const queryNodes = new Set((this.host.recogniseSpan?.(queryBytes)?.sites ?? []).map((s) => s.payload));
1042
- // TWO SOURCES, ONE ADMISSION. The recognition of a STORED WHOLE returns the
1043
- // whole and stops — measured: for `The director of Eva is Gustaf Molander.`
1044
- // it yields exactly ONE site, the fact's own node — so the entity a join
1045
- // exists for is never proposed. The canonical fold is the second source,
1046
- // and the scan runs only for a FORM (≥ W: a one-byte out is not something to
1047
- // join through, and running it per letter measured 20-26 s in test/99).
1048
- const W = this.maxGroup;
1049
- const proposed = new Map();
1050
- // The SOURCE of each proposal travels with it: a refusal that names only the
1051
- // bytes leaves the next reader guessing which path proposed them — three
1052
- // attempts at the chained join were spent fixing paths that never produced
1053
- // the offending candidate.
1054
- const source = new Map();
1055
- for (const s of factRec.sites) {
1056
- if (s.payload >= 0 && leads(s.payload)) {
1057
- proposed.set(s.payload, this.store.bytesPrefix(s.payload, ALL));
1058
- source.set(s.payload, "recognised site");
1059
- }
1060
- }
1061
- if (this.host.canonResolve !== undefined && fact.bytes.length >= W) {
1062
- const canon = this.host.canonResolve.bind(this.host);
1063
- for (let start = 0; start < fact.bytes.length; start++) {
1064
- for (let end = fact.bytes.length; end - start >= W; end--) {
1065
- const id = canon(fact.bytes.subarray(start, end));
1066
- if (id === null)
1067
- continue;
1068
- if (leads(id)) {
1069
- proposed.set(id, this.store.bytesPrefix(id, ALL));
1070
- source.set(id, "canonical fold");
1071
- }
1072
- break; // the longest form at this offset wins
1073
- }
1074
- }
1075
- }
1240
+ // trap's wrong answer. Read from the recognition the response already
1241
+ // computed (`queryNodes`, once per solve), never from a second offset scan:
1242
+ // a per-offset `canonicalQueryNodes` re-derived what `recognise` had
1243
+ // resolved once, and was the largest single cost the join first added.
1076
1244
  const leading = [...proposed]
1077
- .filter(([payload]) => payload !== fact.node && !queryNodes.has(payload))
1245
+ .filter(([payload]) => payload !== fact.node && !queryNodes().has(payload))
1078
1246
  .map(([payload, bytes]) => ({ payload, bytes }));
1079
1247
  // …then prefer the entity the query did NOT name, and the MAXIMAL one. The
1080
1248
  // join exists to reach the subject the query never wrote, so:
@@ -1093,8 +1261,8 @@ export class GraphSearch {
1093
1261
  // Report WHAT the recognition returned, not just that nothing led: the
1094
1262
  // count and the first few site texts are the difference between "the
1095
1263
  // fact was not recognised" and "it was recognised but nothing led".
1096
- const seen = factRec.sites.slice(0, 3).map((s) => this.store.bytesPrefix(s.payload, ALL));
1097
- this.host.reportSearch?.("deriveThroughMiss", [fact.bytes, tail, ...seen], `no entity inside the fact leads anywhere — ${factRec.sites.length} site(s) recognised inside it`);
1264
+ const seen = factSites.slice(0, 3).map((s) => this.store.bytesPrefix(s.payload, ALL));
1265
+ this.host.reportSearch?.("deriveThroughMiss", [fact.bytes, tail, ...seen], `no entity inside the fact leads anywhere — ${factSites.length} site(s) recognised inside it`);
1098
1266
  }
1099
1267
  }
1100
1268
  const candidates = leading
@@ -1199,12 +1367,12 @@ export class GraphSearch {
1199
1367
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
1200
1368
  * learnt connector (the in-search bridge), splitting (at a sub-leaf form
1201
1369
  * boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
1202
- * finalised out, and — for a produced fact — JOINING the entity it contains
1203
- * with the query's tail ({@link deriveThrough}). */
1370
+ * finalised out, and — for a LICENSED fact — JOINING the entity it contains
1371
+ * with the query's tail ({@link deriveThrough}, granted in {@link solve}). */
1204
1372
  *outRules(it, ctx) {
1205
1373
  const { splits, coversDone, outsByStart, outsByEnd, coverableByStart } = ctx;
1206
1374
  const outsByNode = ctx.outsByNode;
1207
- const byRight = ctx.linksByRight ?? new Map();
1375
+ const byRight = ctx.linksByRight;
1208
1376
  pushInto(outsByStart, it.i, it);
1209
1377
  pushInto(outsByEnd, it.j, it);
1210
1378
  if (it.rec && it.node !== undefined)
@@ -1221,24 +1389,26 @@ export class GraphSearch {
1221
1389
  // Points 2 & 5) — but NEVER across the asker's own unrecognised separator (a
1222
1390
  // space, comma), so "ice fire" stays "cold hot", never "cold or hot".
1223
1391
  //
1224
- // Cost stays bounded by iterating only the FEW resolved connector targets of
1392
+ // Cost stays bounded by iterating only the FEW offered connector targets of
1225
1393
  // this out's node (links are selective and, for the N-ary case, keyed first→
1226
1394
  // later — O(parts), not O(parts²)), and matching them against finalised outs
1227
- // by node id, rather than scanning every position pair.
1395
+ // by node id, rather than scanning every position pair. A pair is bridged
1396
+ // only once its two premises have met here ({@link ConnectorLicence}).
1228
1397
  const byLeft = ctx.linksByLeft;
1229
- if (byLeft && it.rec && it.node !== undefined) {
1230
- // L = this out, R = a later out whose node is a resolved target.
1231
- for (const [rNode, link] of byLeft.get(it.node) ?? []) {
1398
+ const licence = ctx.connectors;
1399
+ if (licence && it.rec && it.node !== undefined) {
1400
+ // L = this out, R = a later out whose node is an offered target.
1401
+ for (const [rNode, key] of byLeft.get(it.node) ?? []) {
1232
1402
  for (const r of outsByNode.get(rNode) ?? []) {
1233
- const rule = this.trySplice(it, r, link, outsByEnd);
1403
+ const rule = this.trySplice(it, r, key, licence, outsByEnd);
1234
1404
  if (rule)
1235
1405
  yield rule;
1236
1406
  }
1237
1407
  }
1238
- // R = this out, L = an earlier out whose node has a resolved target here.
1239
- for (const [lNode, link] of byRight.get(it.node) ?? []) {
1408
+ // R = this out, L = an earlier out whose node has an offered target here.
1409
+ for (const [lNode, key] of byRight.get(it.node) ?? []) {
1240
1410
  for (const l of outsByNode.get(lNode) ?? []) {
1241
- const rule = this.trySplice(l, it, link, outsByEnd);
1411
+ const rule = this.trySplice(l, it, key, licence, outsByEnd);
1242
1412
  if (rule)
1243
1413
  yield rule;
1244
1414
  }
@@ -1300,10 +1470,11 @@ export class GraphSearch {
1300
1470
  // continuation is the derived answer — a genuine relational join, distinct
1301
1471
  // from the confluence mechanism's `join` PROVENANCE — and not the
1302
1472
  // juxtaposition the cover produces when the intermediate key IS named.
1303
- // Fired per finalized out with a node, so it is the search's own rule, on
1304
- // the ladder, memoised by {@link key}, and bounded by the fact's own length.
1305
- if (it.node !== undefined) {
1306
- yield* this.deriveThrough(it, ctx.queryBytes, ctx.queryLen);
1473
+ // On the ladder and memoised by {@link key} like every rule, but fired only
1474
+ // for a fact the solve has LICENSED (a fact a lightest derivation stood on —
1475
+ // see {@link solve}), from the joins computed for it once.
1476
+ for (const g of ctx.joins.get(chartKey(it)) ?? []) {
1477
+ yield { premises: [it], conclusion: g.conclusion, cost: g.cost };
1307
1478
  }
1308
1479
  }
1309
1480
  /** Whether the query span [from, to) is wholly covered by RECOGNISED outs —