@hviana/sema 0.8.9 → 0.9.1

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 (111) 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 +27 -0
  14. package/dist/src/meter.js +28 -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/graph-search.d.ts +89 -15
  22. package/dist/src/mind/graph-search.js +345 -174
  23. package/dist/src/mind/learning.js +1 -1
  24. package/dist/src/mind/mechanisms/cover.d.ts +19 -3
  25. package/dist/src/mind/mechanisms/cover.js +101 -58
  26. package/dist/src/mind/mechanisms/recall.js +0 -1
  27. package/dist/src/mind/mind.js +2 -2
  28. package/dist/src/mind/pipeline.d.ts +5 -1
  29. package/dist/src/mind/pipeline.js +175 -87
  30. package/dist/src/mind/primitives.d.ts +25 -5
  31. package/dist/src/mind/primitives.js +107 -44
  32. package/dist/src/mind/reasoning.d.ts +18 -4
  33. package/dist/src/mind/reasoning.js +445 -321
  34. package/dist/src/mind/recognition.js +55 -73
  35. package/dist/src/mind/resonance.js +1 -11
  36. package/dist/src/mind/traverse.d.ts +3 -3
  37. package/dist/src/mind/traverse.js +3 -3
  38. package/dist/src/mind/types.d.ts +7 -1
  39. package/dist/src/store-sqlite.d.ts +25 -0
  40. package/dist/src/store-sqlite.js +89 -1
  41. package/dist/src/store.d.ts +48 -4
  42. package/dist/src/store.js +86 -6
  43. package/docs/INDEX.md +18 -18
  44. package/docs/INVARIANTS.md +16 -16
  45. package/docs/architecture/bounded-reads.md +1 -1
  46. package/docs/architecture/caches.md +5 -4
  47. package/docs/architecture/closure.md +45 -5
  48. package/docs/architecture/cost-model.md +16 -0
  49. package/docs/architecture/factored-machinery.md +14 -13
  50. package/docs/architecture/fold-contract.md +51 -1
  51. package/docs/architecture/mechanism-market.md +21 -0
  52. package/docs/architecture/memoization.md +3 -3
  53. package/docs/architecture/meter.md +2 -1
  54. package/docs/architecture/saturation.md +12 -0
  55. package/docs/architecture/store.md +25 -2
  56. package/docs/failures/tempting-but-wrong.md +13 -2
  57. package/docs/harness/gates.md +12 -10
  58. package/docs/mechanisms/cover.md +23 -6
  59. package/jsr.json +1 -1
  60. package/package.json +1 -1
  61. package/src/alu/README.md +10 -2
  62. package/src/alu/src/index.ts +1 -0
  63. package/src/alu/src/parser.ts +6 -6
  64. package/src/alu/src/resonance.ts +42 -0
  65. package/src/alu/test/alu.test.ts +40 -0
  66. package/src/bytes.ts +13 -3
  67. package/src/canon.ts +40 -0
  68. package/src/geometry.ts +183 -154
  69. package/src/meter.ts +28 -1
  70. package/src/mind/articulation.ts +14 -2
  71. package/src/mind/attention.ts +47 -25
  72. package/src/mind/bridge.ts +3 -3
  73. package/src/mind/derivation.ts +77 -0
  74. package/src/mind/graph-search.ts +449 -221
  75. package/src/mind/learning.ts +1 -7
  76. package/src/mind/match.ts +1 -2
  77. package/src/mind/mechanisms/cast.ts +1 -2
  78. package/src/mind/mechanisms/cover.ts +149 -84
  79. package/src/mind/mechanisms/extraction.ts +1 -2
  80. package/src/mind/mechanisms/prefix-completion.ts +1 -1
  81. package/src/mind/mechanisms/recall.ts +1 -3
  82. package/src/mind/mechanisms/reference.ts +1 -1
  83. package/src/mind/mind.ts +5 -30
  84. package/src/mind/pipeline.ts +206 -102
  85. package/src/mind/primitives.ts +119 -43
  86. package/src/mind/reasoning.ts +558 -413
  87. package/src/mind/recognition.ts +49 -65
  88. package/src/mind/resonance.ts +2 -16
  89. package/src/mind/trace.ts +1 -1
  90. package/src/mind/traverse.ts +3 -3
  91. package/src/mind/types.ts +9 -11
  92. package/src/store-sqlite.ts +92 -1
  93. package/src/store.ts +113 -7
  94. package/test/105-derive-through-reports-its-refusal.test.mjs +8 -5
  95. package/test/106-the-join-fires.test.mjs +21 -0
  96. package/test/111-the-cover-assembly-is-counted.test.mjs +8 -5
  97. package/test/128-the-leads-somewhere-pair-agrees.test.mjs +18 -12
  98. package/test/136-the-two-named-limits.test.mjs +3 -2
  99. package/test/137-the-law-lives-once-and-below.test.mjs +21 -0
  100. package/test/148-exact-shortcuts-agree.test.mjs +188 -0
  101. package/test/149-the-closure-engine.test.mjs +138 -0
  102. package/test/150-the-join-is-output-sensitive.test.mjs +66 -0
  103. package/test/151-the-cover-pays-for-what-it-reaches.test.mjs +142 -0
  104. package/test/152-the-read-side-names-as-the-write-side.test.mjs +146 -0
  105. package/test/153-a-cheaper-bound-is-looked-at-first.test.mjs +155 -0
  106. package/test/24-generalization.test.mjs +32 -0
  107. package/test/36-bloom.test.mjs +53 -0
  108. package/test/37-cluster-dispersion-fusion.test.mjs +75 -0
  109. package/test/48-recognise-turn-connective.test.mjs +3 -2
  110. package/test/55-cost-meter.test.mjs +4 -4
  111. package/test/90-connector-read-cap.test.mjs +7 -7
@@ -87,6 +87,11 @@ export type GItem =
87
87
  * consolidated, more-explanatory reading wins over leaving the parts split.
88
88
  * See {@link GraphSearch.fuse} and {@link GraphSearch.formRules}. */
89
89
  rcmp?: boolean;
90
+ /** Set on a concept hop's ASKING form: the edge-less form `node` whose halo
91
+ * target the caller has not granted yet, held at the hop's own cost and
92
+ * span. Popping it asks for the target; it leads nowhere itself, so it
93
+ * is never part of a final derivation (see {@link Licence}). */
94
+ ask?: boolean;
90
95
  }
91
96
  | {
92
97
  kind: "out";
@@ -126,6 +131,13 @@ export type GItem =
126
131
  };
127
132
  type OutItem = Extract<GItem, { kind: "out" }>;
128
133
 
134
+ /** What {@link GraphSearch.entityProposals} found inside one fact. */
135
+ interface EntityProposals {
136
+ readonly proposed: ReadonlyMap<number, Uint8Array>;
137
+ readonly source: ReadonlyMap<number, string>;
138
+ readonly sites: ReadonlyArray<Site>;
139
+ }
140
+
129
141
  // The cost ladder is a strict ORDERING, not tuned magic:
130
142
  // • Coverage dominates everything: leaving one query byte unrecognised (PASS)
131
143
  // outweighs any chain of graph steps a covering derivation could take, so
@@ -174,9 +186,6 @@ export interface Seg {
174
186
  fix?: boolean;
175
187
  }
176
188
 
177
- /** Read the chosen spans back off a derivation: the goal is a chain of bridge
178
- * steps, each whose second premise is the `out` it crossed. Walk the chain to
179
- * the axiom and reverse into left-to-right order. */
180
189
  /** The BYTE TERM of a derivation's cost: how many bytes its bridge rule charged
181
190
  * at PASS each — read back off the rule that charged them (`bridgeRule`:
182
191
  * `o.rec ? MICRO : PASS * (o.j - o.i)`), so the split exists in ONE place and
@@ -201,6 +210,9 @@ function readBridgedBytes(derivation: Derivation<GItem>): number {
201
210
  return bytes;
202
211
  }
203
212
 
213
+ /** Read the chosen spans back off a derivation: the goal is a chain of bridge
214
+ * steps, each whose second premise is the `out` it crossed. Walk the chain to
215
+ * the axiom and reverse into left-to-right order. */
204
216
  function readCover(derivation: Derivation<GItem>): Seg[] {
205
217
  const segs: Seg[] = [];
206
218
  let node: Derivation<GItem> | undefined = derivation;
@@ -258,10 +270,6 @@ export interface DerivationItem {
258
270
  node?: number;
259
271
  }
260
272
 
261
- /** The reasoning act a derivation rule performs — the human name for which of
262
- * {@link GraphSearch}'s rules fired, recovered from the rule's premise/
263
- * conclusion shape (the rules carry no label, so this classifies by structure,
264
- * the single place that maps rule geometry to a name). */
265
273
  // CLOSED ON PURPOSE — AND ONLY THIS ONE IS. A derivation move is BRANCHED ON
266
274
  // (`classifyMove`, the rationale's readers, MOVE_NOTE's fallback), so it is a
267
275
  // closed union: adding one without teaching every reader is a compile error,
@@ -270,6 +278,10 @@ export interface DerivationItem {
270
278
  // branched on — and they stay free strings that COMPOSE with the nesting
271
279
  // (`["respond", "think", "recognise"]`). That asymmetry is deliberate; do not
272
280
  // "fix" it by uniting the two (see test/126 for the pipeline half of it).
281
+ /** The reasoning act a derivation rule performs — the human name for which of
282
+ * {@link GraphSearch}'s rules fired, recovered from the rule's premise/
283
+ * conclusion shape (the rules carry no label, so this classifies by structure,
284
+ * the single place that maps rule geometry to a name). */
273
285
  export type DerivationMove =
274
286
  | "axiom" // a seed: a perceived leaf, a recognised form, or a computed result
275
287
  | "follow-edge" // form→form via a continuation edge (STEP) — the core "what follows what"
@@ -285,6 +297,93 @@ export type DerivationMove =
285
297
  | "pool-vote" // N premises→conclusion, evidence pooled (combine:"sum" — see derive)
286
298
  | "step"; // any other single-premise move (fallback)
287
299
 
300
+ /** The chart's canonical key — an item's boundary signature (see
301
+ * {@link DeductionSystem.key}). Module-level because the join license
302
+ * ({@link GraphSearch.solve}) names facts by the same key the chart does. */
303
+ function chartKey(it: GItem): string {
304
+ if (it.kind === "cover") return "c" + it.p;
305
+ if (it.kind === "form") {
306
+ return `f${it.i}.${it.j}.${it.node}.${it.via ? 1 : 0}.${it.rcmp ? 1 : 0}${
307
+ it.ask ? "?" : ""
308
+ }`;
309
+ }
310
+ return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${
311
+ it.fix ? 1 : 0
312
+ }.${it.throughFact ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
313
+ }
314
+
315
+ /** The facts a derivation STANDS ON — every node-bearing `out` in its proof
316
+ * tree, premises before conclusions, each once. */
317
+ function factsOf(root: Derivation<GItem>): OutItem[] {
318
+ const out: OutItem[] = [];
319
+ const seen = new Set<Derivation<GItem>>();
320
+ const walk = (d: Derivation<GItem>): void => {
321
+ if (seen.has(d)) return;
322
+ seen.add(d);
323
+ for (const p of d.premises) walk(p);
324
+ if (d.item.kind === "out" && d.item.node !== undefined) out.push(d.item);
325
+ };
326
+ walk(root);
327
+ return out;
328
+ }
329
+
330
+ /** The joins granted to the facts a derivation stood on: fact chart key → the
331
+ * conclusions {@link GraphSearch.deriveThrough} derives through it, computed
332
+ * once per fact (an empty list is an answer: that fact joins nothing). */
333
+ type JoinLicense = Map<
334
+ string,
335
+ ReadonlyArray<{ conclusion: GItem; cost: number }>
336
+ >;
337
+
338
+ /** THE ASYNC PREMISES A COVER USES, RESOLVED ONLY WHERE THE SEARCH REACHES THEM.
339
+ *
340
+ * Two of the cover's rules need data the synchronous search cannot fetch — a
341
+ * connector splice (a bridge between two answers) and a concept hop (an
342
+ * edge-less form's halo sibling) — and both used to be resolved for EVERY
343
+ * candidate before the search ran. A rule fires only from popped premises,
344
+ * and the agenda pops in cost order up to the goal, so most of that work fed
345
+ * rules the search never reached: measured on the 31.7M-node store, a 262-byte
346
+ * query recognised whole paid 23 bridges (143,520 junction pops, 7.3 s of its
347
+ * 7.9 s) for sub-forms whose rewrites cost a STEP each and so never left the
348
+ * agenda before the one-STEP goal — and then 1.2 s of halo reads for concept
349
+ * hops priced at CONCEPT, ten times that goal.
350
+ *
351
+ * So the caller OFFERS what it can resolve; where the search reaches an offered
352
+ * key it uses a GRANTED value and ASKS for an ungranted one — a connector when
353
+ * its splice's premises meet, a concept target when the hop's conclusion, held
354
+ * as an asking form at the hop's own cost, is popped. A run that asked
355
+ * nothing is the run every key resolved in advance would have made: the pops
356
+ * are decided only by the rules fired from popped items, a granted key fires
357
+ * the very rule it fired before, and an asking form that was never popped fired
358
+ * none. So its cover is final. A run that asked is PROVISIONAL: the caller
359
+ * grants the asked keys (the async reads the search cannot run) and covers
360
+ * again; every round grants at least one offered key, so the rounds end. */
361
+ export interface Licence<K, V> {
362
+ /** Every key the caller can resolve. */
363
+ readonly offered: ReadonlySet<K>;
364
+ /** The keys resolved so far: their value, or null (nothing learnt). */
365
+ readonly granted: ReadonlyMap<K, V | null>;
366
+ /** Filled by the search: the offered keys it reached ungranted. */
367
+ readonly asked: Set<K>;
368
+ }
369
+ /** Connectors, keyed `L,R` by answer-node ids, valued by the learnt glue. */
370
+ export type ConnectorLicence = Licence<string, Uint8Array>;
371
+ /** Concept hops, keyed by an edge-less form's node, valued by the node its
372
+ * halo sibling's edge leads to. */
373
+ export type ConceptLicence = Licence<number, number>;
374
+ /** Whether a run asked for anything — its cover is then provisional. */
375
+ function asked(
376
+ concepts: ConceptLicence,
377
+ connectors: ConnectorLicence | undefined,
378
+ ): boolean {
379
+ return concepts.asked.size > 0 || (connectors?.asked.size ?? 0) > 0;
380
+ }
381
+ /** Nothing offered: a cover with no async premises (a nested completion,
382
+ * articulation's revoicing). */
383
+ export function noLicence<K, V>(): Licence<K, V> {
384
+ return { offered: new Set(), granted: new Map(), asked: new Set() };
385
+ }
386
+
288
387
  /** Flatten a {@link GItem} into the {@link DerivationItem} a rationale shows. */
289
388
  function derivationItem(it: GItem): DerivationItem {
290
389
  if (it.kind === "cover") return { kind: "cover", span: [it.p, it.p] };
@@ -407,18 +506,9 @@ export class GraphSearch {
407
506
  private readonly host: GraphSearchHost,
408
507
  ) {}
409
508
 
410
- /** The nodes the QUERY canonically names — the same identity the store's keys
411
- * were written through. A byte-exact test is not enough: the query writes
412
- * `Eiffel Tower country` and the deposited node is `eiffel tower country`, so
413
- * a join that filters the query's own subject by RAW bytes re-admits it —
414
- * measured: that is the trap's wrong answer (`The capital of Eiffel Tower
415
- * country is Berlin.`). Cached by query identity, because the search is
416
- * reused across responses. */
417
-
418
- /* * The hub bound √N (bounded-reads.md) — the ONE
419
- * fan-out cap, stated here rather than imported from `traverse.ts` because
420
- * this module is deliberately host-based (it holds a bare Store, never a
421
- * MindContext).
509
+ /** The hub bound √N (bounded-reads.md) — the ONE fan-out cap, stated here
510
+ * rather than imported from `traverse.ts` because this module is
511
+ * deliberately host-based (it holds a bare Store, never a MindContext).
422
512
  * That is the same write/read-side duplication convention canonical.ts's
423
513
  * header documents: if the formula changes it must change in BOTH places.
424
514
  * It is stated ONCE per side, though — the expression used to be spelled
@@ -443,15 +533,17 @@ export class GraphSearch {
443
533
  *
444
534
  * Any learnt connector between two rewrites is spliced IN by the in-search
445
535
  * connector rule (see {@link outRules}), so the returned spans already carry
446
- * it — there is no post-pass. */
536
+ * it — there is no post-pass. With a {@link ConnectorLicence}, a cover
537
+ * returned while `connectors.asked` is non-empty is PROVISIONAL: grant the
538
+ * asked pairs and cover again. */
447
539
  cover(
448
540
  queryLen: number,
449
541
  sites: ReadonlyArray<Site>,
450
- conceptTarget: ReadonlyMap<number, number>,
542
+ concepts: ConceptLicence,
451
543
  leaves: ReadonlyArray<Leaf>,
452
544
  splits: ReadonlySet<number>,
453
545
  substitutions?: ReadonlyMap<number, Uint8Array>,
454
- connectors?: ReadonlyMap<string, Uint8Array>,
546
+ connectors?: ConnectorLicence,
455
547
  computedResults?: ReadonlyArray<ComputedResult>,
456
548
  /** When given, receives each solved span's lightest derivation — the full
457
549
  * adapted A*LD proof tree as classified {@link DerivationStep}s — for the
@@ -466,14 +558,17 @@ export class GraphSearch {
466
558
  // through (completion is cover, recursively — see {@link recompleteNode}).
467
559
  this.recompleteOpen.clear();
468
560
  this.recompleteMemo = new Map<number, Uint8Array | null>();
561
+ this.entityMemo = new Map<string, EntityProposals>();
469
562
  // The top cover's derivation sink is threaded into every nested completion
470
563
  // so the recompositions a produced form needs are reported in the same
471
564
  // trace instead of vanishing after the first layer.
472
565
  this.derivationSink = onDerivation;
566
+ concepts.asked.clear();
567
+ if (connectors) connectors.asked.clear();
473
568
  const solved = this.solve(
474
569
  queryLen,
475
570
  { sites, leaves, splits },
476
- conceptTarget,
571
+ concepts,
477
572
  substitutions,
478
573
  connectors,
479
574
  computedResults,
@@ -486,7 +581,9 @@ export class GraphSearch {
486
581
  // continuations instead of following the chain the answer itself licensed.
487
582
  // With deepening only at the top, `recompleteNode` walks the accepted chain
488
583
  // one link at a time (its own memo and stack), so the work is the answer's.
489
- return solved === null ? null : {
584
+ // A provisional cover is not deepened: it is about to be re-covered.
585
+ if (solved === null || asked(concepts, connectors)) return solved;
586
+ return {
490
587
  segs: this.deepen(solved.segs),
491
588
  cost: solved.cost,
492
589
  moves: solved.moves,
@@ -512,32 +609,94 @@ export class GraphSearch {
512
609
  leaves: ReadonlyArray<Leaf>;
513
610
  splits: ReadonlySet<number>;
514
611
  },
515
- conceptTarget: ReadonlyMap<number, number>,
612
+ concepts: ConceptLicence,
516
613
  substitutions?: ReadonlyMap<number, Uint8Array>,
517
- connectors?: ReadonlyMap<string, Uint8Array>,
614
+ connectors?: ConnectorLicence,
518
615
  computedResults?: ReadonlyArray<ComputedResult>,
519
616
  onDerivation?: (steps: DerivationStep[]) => void,
520
617
  ): { segs: Seg[]; cost: number; moves: number } | null {
521
- const system = this.buildSearch(
522
- spanLen,
523
- recognition.sites,
524
- conceptTarget,
525
- recognition.leaves,
526
- recognition.splits,
527
- substitutions,
528
- connectors,
529
- computedResults,
530
- );
618
+ // The query's own bytes, tiled from its perceived leaves. A JOIN reads the
619
+ // tail a produced fact's contained entity has to combine with, and the
620
+ // search otherwise only ever sees positions, never the bytes behind them.
621
+ const queryBytes = new Uint8Array(spanLen);
622
+ for (const lf of recognition.leaves) {
623
+ queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
624
+ }
625
+ // The nodes this span's recognition names — read ONCE per solve, on the first
626
+ // join that asks, instead of re-reading the memo per fact.
627
+ let queryNodeSet: ReadonlySet<number> | undefined;
628
+ const queryNodes = (): ReadonlySet<number> =>
629
+ queryNodeSet ??= new Set<number>(
630
+ (this.host.recogniseSpan?.(queryBytes)?.sites ?? []).map((s) =>
631
+ s.payload
632
+ ),
633
+ );
634
+ // THE JOIN DERIVES THROUGH THE FACTS THE DERIVATION STANDS ON — never
635
+ // through every fact the exploration REACHES. How many facts the search
636
+ // reaches is set by how densely the corpus interconnects the forms on the
637
+ // way (a dialogue hub's continuations, walked to their fixpoints, are
638
+ // thousands), and pricing a join from each of them made the work track the
639
+ // corpus instead of the answer: measured on the 31.7M-node store, a 46-byte
640
+ // query reached 2,000+ fixpoints in a minute, each paying an entity scan,
641
+ // past 10 GB. It is the trap {@link recompleteNode} already records, and
642
+ // the same cure as {@link deepen}: the expensive step runs on what the
643
+ // lightest derivation CHOSE.
644
+ //
645
+ // So the search runs to its lightest derivation, the facts that derivation
646
+ // stands on are LICENSED — their joins computed once — and it runs again
647
+ // with those joins as ordinary rules of the same system, priced exactly as
648
+ // before (the fact's own cost + STEP). A join's conclusion is itself a fact
649
+ // the next derivation may stand on, so chains extend round by round until
650
+ // no newly licensed fact joins anything: the depth is the answer's, never a
651
+ // count. A round is re-run only when a newly licensed fact DOES join, so a
652
+ // query whose facts join nothing pays one search, as before.
653
+ //
654
+ // The joins a covering round licensed are KEPT across a licence's re-covers:
655
+ // a round that asked for nothing is the very round the next re-cover repeats
656
+ // (see {@link Licence}), so its joins are the ones that re-cover would grant
657
+ // again.
658
+ const joins: JoinLicense = this.joinsKept.get(concepts) ?? new Map();
659
+ this.joinsKept.set(concepts, joins);
531
660
  // Search-effort accounting (src/meter.ts): the chart's pops/pushes are
532
661
  // the cover's real cost, and a heuristic that stops being admissible
533
662
  // shows up as a pop count that explodes while the answer stays the same.
534
663
  const meter = this.host.meter;
535
- const stats = meter ? { pops: 0, pushes: 0 } : undefined;
536
- const derivation = lightestDerivation(system, stats);
537
- if (meter && stats) {
538
- meter.searches++;
539
- meter.searchPops += stats.pops;
540
- meter.searchPushes += stats.pushes;
664
+ let derivation: Derivation<GItem> | null;
665
+ for (;;) {
666
+ const system = this.buildSearch(
667
+ spanLen,
668
+ recognition.sites,
669
+ concepts,
670
+ recognition.leaves,
671
+ recognition.splits,
672
+ queryBytes,
673
+ joins,
674
+ substitutions,
675
+ connectors,
676
+ computedResults,
677
+ );
678
+ const stats = meter ? { pops: 0, pushes: 0 } : undefined;
679
+ derivation = lightestDerivation(system, stats);
680
+ if (meter && stats) {
681
+ meter.searches++;
682
+ meter.searchPops += stats.pops;
683
+ meter.searchPushes += stats.pushes;
684
+ }
685
+ // A round that ASKED for a connector is provisional: return it before any
686
+ // join is licensed from a derivation the granted connectors may change.
687
+ if (derivation === null || asked(concepts, connectors)) break;
688
+ let joined = false;
689
+ for (const fact of factsOf(derivation)) {
690
+ const k = chartKey(fact);
691
+ if (joins.has(k)) continue;
692
+ if (meter) meter.joinFacts++;
693
+ const granted = [
694
+ ...this.deriveThrough(fact, queryBytes, spanLen, queryNodes),
695
+ ].map((r) => ({ conclusion: r.conclusion, cost: r.cost }));
696
+ joins.set(k, granted);
697
+ if (granted.length > 0) joined = true;
698
+ }
699
+ if (!joined) break;
541
700
  }
542
701
  // When covering under a substitution map (articulation), a form→out rule is
543
702
  // the form EMITTING the asker's voice, not grounding to its own answer — so
@@ -587,11 +746,13 @@ export class GraphSearch {
587
746
  private buildSearch(
588
747
  queryLen: number,
589
748
  sites: ReadonlyArray<Site>,
590
- conceptTarget: ReadonlyMap<number, number>,
749
+ concepts: ConceptLicence,
591
750
  leaves: ReadonlyArray<Leaf>,
592
751
  splits: ReadonlySet<number>,
752
+ queryBytes: Uint8Array,
753
+ joins: JoinLicense,
593
754
  substitutions?: ReadonlyMap<number, Uint8Array>,
594
- connectors?: ReadonlyMap<string, Uint8Array>,
755
+ connectors?: ConnectorLicence,
595
756
  computedResults?: ReadonlyArray<ComputedResult>,
596
757
  ): DeductionSystem<GItem> {
597
758
  const W = this.maxGroup; // fusible span ceiling (shortest composite bound)
@@ -603,13 +764,6 @@ export class GraphSearch {
603
764
  Math.ceil((this.store.edgeSourceCount() * W) / 256),
604
765
  ) > this.hubBound();
605
766
  const nodeBytes = (n: number) => this.store.bytesPrefix(n, ALL);
606
- // The query's own bytes, tiled from its perceived leaves. A JOIN reads the
607
- // tail a produced fact's contained entity has to combine with, and `buildSearch`
608
- // otherwise only ever sees positions, never the bytes behind them.
609
- const queryBytes = new Uint8Array(queryLen);
610
- for (const lf of leaves) {
611
- queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
612
- }
613
767
  // Content-addressed probes over the store's hash-cons maps — the same keys
614
768
  // training filled. No byte-by-byte trie walk.
615
769
  const findLeafU = (b: Uint8Array) => this.store.findLeaf(b) ?? undefined;
@@ -622,34 +776,82 @@ export class GraphSearch {
622
776
  const outsByNode = new Map<number, OutItem[]>();
623
777
  const coverableByStart = new Map<number, OutItem[]>();
624
778
 
625
- // Index the connectors by their left and right answer-node, so the connector
626
- // rule iterates only this out's FEW resolved partners (selective, and for the
627
- // N-ary case O(parts) keys) instead of scanning every position pair — what
628
- // keeps the in-search bridge bounded when many parts are recognised at once.
629
- const linksByLeft = new Map<number, Array<[number, Uint8Array]>>();
630
- const linksByRight = new Map<number, Array<[number, Uint8Array]>>();
779
+ // Index the offered connectors by their left and right answer-node, so the
780
+ // connector rule iterates only this out's FEW offered partners (selective,
781
+ // and for the N-ary case O(parts) keys) instead of scanning every position
782
+ // pair — what keeps the in-search bridge bounded when many parts are
783
+ // recognised at once.
784
+ const linksByLeft = new Map<number, Array<[number, string]>>();
785
+ const linksByRight = new Map<number, Array<[number, string]>>();
631
786
  if (connectors) {
632
- for (const [key, bytes] of connectors) {
787
+ for (const key of connectors.offered) {
633
788
  const comma = key.indexOf(",");
634
789
  const l = Number(key.slice(0, comma));
635
790
  const r = Number(key.slice(comma + 1));
636
- pushInto(linksByLeft, l, [r, bytes]);
637
- pushInto(linksByRight, r, [l, bytes]);
791
+ pushInto(linksByLeft, l, [r, key]);
792
+ pushInto(linksByRight, r, [l, key]);
638
793
  }
639
794
  }
640
795
 
641
- return {
642
- key(it) {
643
- if (it.kind === "cover") return "c" + it.p;
644
- if (it.kind === "form") {
645
- return `f${it.i}.${it.j}.${it.node}.${it.via ? 1 : 0}.${
646
- it.rcmp ? 1 : 0
647
- }`;
796
+ // A SPAN'S CHEAPEST COMPLETION DOMINATES THE REST. Coverage is positional:
797
+ // every recognised completion of [i, j) advances the cover from i to j at
798
+ // the same MICRO, so for the goal only the cheapest matters. What else a
799
+ // completion can do turns on its BYTES — fuse it with a neighbour, splice a
800
+ // connector, join through it — and those rules fire from the completion the
801
+ // search would stand on for that span, never from every alternative it
802
+ // reached: the cure {@link deepen} and the join license already apply, for
803
+ // the same trap. Measured on the 31.7M-node store, a 46-byte dialogue query
804
+ // reached 60,530 continuation forms and 57,072 fixpoints for its few spans —
805
+ // a greeting's thousands of replies, each a fixpoint at the same depth — and
806
+ // canonically resolved every one of them fused with its neighbours (5.4 s
807
+ // of an 8.6 s cover) to stand, in the end, on two hops.
808
+ //
809
+ // So a form or completion of [i, j) whose cost has reached that of a
810
+ // completion of [i, j) already yielded is DOMINATED and fires nothing: every
811
+ // completion it could lead to costs at least as much (local costs are
812
+ // non-negative and a form's rules keep its span), and a tie goes to the one
813
+ // yielded first — the evidence-preferred continuation, yielded first by
814
+ // {@link formRules}. This is also what makes the stop-here offer a real
815
+ // horizon: a chain deeper than its first hop's stop (STEP + CONCEPT) is
816
+ // dominated by it, where before the PASS-priced goal let the search buy a
817
+ // thousand hops to look for one more byte.
818
+ const spanBest = new Map<number, { cost: number; item: GItem }>();
819
+ const spanOf = (i: number, j: number) => i * (queryLen + 1) + j;
820
+ const completes = (it: GItem): it is OutItem =>
821
+ it.kind === "out" && it.rec && it.cover;
822
+ const dominated = (it: GItem, cost: number): boolean => {
823
+ if (it.kind === "cover") return false;
824
+ if (it.kind === "out" && !completes(it)) return false;
825
+ const best = spanBest.get(spanOf(it.i, it.j));
826
+ if (best === undefined || best.cost > cost) {
827
+ if (completes(it)) spanBest.set(spanOf(it.i, it.j), { cost, item: it });
828
+ return false;
829
+ }
830
+ return best.item !== it &&
831
+ (it.kind !== "out" || chartKey(best.item) !== chartKey(it));
832
+ };
833
+ const completing = function* (
834
+ rules: Iterable<Rule<GItem>>,
835
+ costOf: (it: GItem) => number,
836
+ ): Iterable<Rule<GItem>> {
837
+ for (const r of rules) {
838
+ const c = r.conclusion;
839
+ if (completes(c)) {
840
+ let cost = r.cost;
841
+ for (const p of r.premises) cost += costOf(p);
842
+ const s = spanOf(c.i, c.j);
843
+ const best = spanBest.get(s);
844
+ if (best === undefined || cost < best.cost) {
845
+ spanBest.set(s, { cost, item: c });
846
+ }
648
847
  }
649
- return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${
650
- it.fix ? 1 : 0
651
- }.${it.throughFact ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
652
- },
848
+ yield r;
849
+ }
850
+ };
851
+ const meter = this.host.meter;
852
+
853
+ return {
854
+ key: chartKey,
653
855
  *axioms() {
654
856
  yield { item: { kind: "cover", p: 0 }, cost: 0 };
655
857
  // One out per tree leaf — content-defined chunks, far fewer than bytes.
@@ -735,35 +937,39 @@ export class GraphSearch {
735
937
  const right = it.kind === "cover" ? it.p : it.j;
736
938
  return (queryLen - right) * MICRO;
737
939
  },
738
- rules: (it) => {
940
+ rules: (it, costOf) => {
739
941
  if (it.kind === "cover") {
740
942
  return this.coverRules(it, coversDone, coverableByStart);
741
943
  }
944
+ if (dominated(it, costOf(it))) {
945
+ if (meter) meter.searchDominated++;
946
+ return [];
947
+ }
742
948
  if (it.kind === "form") {
743
- return this.formRules(
744
- it,
745
- conceptTarget,
746
- substitutions,
747
- nodeBytes,
748
- queryLen,
949
+ return completing(
950
+ this.formRules(it, concepts, substitutions, nodeBytes),
951
+ costOf,
749
952
  );
750
953
  }
751
- return this.outRules(it, {
752
- W,
753
- splits,
754
- atomsAreHubs,
755
- coversDone,
756
- outsByStart,
757
- outsByEnd,
758
- outsByNode,
759
- coverableByStart,
760
- findLeafU,
761
- findBranchU,
762
- linksByLeft,
763
- linksByRight,
764
- queryBytes,
765
- queryLen,
766
- });
954
+ return completing(
955
+ this.outRules(it, {
956
+ W,
957
+ splits,
958
+ atomsAreHubs,
959
+ coversDone,
960
+ outsByStart,
961
+ outsByEnd,
962
+ outsByNode,
963
+ coverableByStart,
964
+ findLeafU,
965
+ findBranchU,
966
+ linksByLeft,
967
+ linksByRight,
968
+ connectors,
969
+ joins,
970
+ }),
971
+ costOf,
972
+ );
767
973
  },
768
974
  };
769
975
  }
@@ -815,19 +1021,24 @@ export class GraphSearch {
815
1021
 
816
1022
  /** The connector-SPLICE rule for an oriented (l, r) pair, or null when the
817
1023
  * pair does not qualify — the ONE body behind {@link outRules}' two
818
- * mirror loops (this-as-left over resolved right partners, this-as-right
819
- * over resolved left partners). Fires only when both sides are
1024
+ * mirror loops (this-as-left over offered right partners, this-as-right
1025
+ * over offered left partners). Fires only when both sides are
820
1026
  * recognised, r starts at or after l ends, and the gap between them is
821
1027
  * empty or wholly recognised — never across the asker's own literal
822
- * separator. */
1028
+ * separator — and the pair's connector is granted and learnt. A qualifying
1029
+ * pair not yet granted is ASKED for (see {@link ConnectorLicence}). */
823
1030
  private trySplice(
824
1031
  l: OutItem,
825
1032
  r: OutItem,
826
- link: Uint8Array,
1033
+ key: string,
1034
+ licence: ConnectorLicence,
827
1035
  outsByEnd: Map<number, OutItem[]>,
828
1036
  ): Rule<GItem> | null {
829
1037
  if (!l.rec || !r.rec || r.i < l.j) return null;
830
1038
  if (!this.gapRecognised(l.j, r.i, outsByEnd)) return null;
1039
+ const link = licence.granted.get(key);
1040
+ if (link === undefined) licence.asked.add(key);
1041
+ if (!link) return null;
831
1042
  return {
832
1043
  premises: [l, r],
833
1044
  conclusion: {
@@ -847,11 +1058,15 @@ export class GraphSearch {
847
1058
  * emit its substitute voice directly. */
848
1059
  private *formRules(
849
1060
  it: Extract<GItem, { kind: "form" }>,
850
- conceptTarget: ReadonlyMap<number, number>,
1061
+ concepts: ConceptLicence,
851
1062
  substitutions: ReadonlyMap<number, Uint8Array> | undefined,
852
1063
  nodeBytes: (n: number) => Uint8Array,
853
- queryLen: number,
854
1064
  ): Iterable<Rule<GItem>> {
1065
+ // An asking form reached the agenda's front: the hop it stands for is due.
1066
+ if (it.ask) {
1067
+ concepts.asked.add(it.node);
1068
+ return;
1069
+ }
855
1070
  // Articulation: emit voice bytes at the recognised span; the hop/concept/
856
1071
  // emit chain is suppressed — the form contributes only its substitute.
857
1072
  if (substitutions) {
@@ -886,41 +1101,17 @@ export class GraphSearch {
886
1101
  // guard then dead-ends it) with no way to reach the forward edge.
887
1102
  // Forking offers every continuation as its own rule so the one that
888
1103
  // genuinely advances (not a duplicate) is still reachable.
889
- // A CHAIN HOP OFFERS ONLY WHAT THE QUESTION CAN PAY FOR.
890
- //
891
- // `hubBound` = √N is the READ cap — every read here stays inside it — but
892
- // it is not an exploration bound: measured, a hub of degree 1083 sits
893
- // BELOW √N = 1559, so a hop offered all 1083 continuations, the chart grew
894
- // to 3113 outs for a two-word question, and since every out with an
895
- // uncovered tail probes its tail's prefixes (measured: 16 885 canonical
896
- // probes = 87% of that query's work, and its 270 MB peak / 256 MB OOM),
897
- // the cost came from OFFERING rather than from reading.
898
1104
  //
899
- // The bound is derived, not tuned: a derivation of L hops consumes ~L
900
- // units of the question, so a hop cannot be paid for by offering more
901
- // continuations than the question has units —
902
- // `ceil(queryLen / W)`, floored at 2 for plurality. It is QUERY-sized
903
- // (invariant 5: no per-query read grows with N) and it leaves `hubBound`
904
- // and every read untouched.
905
- // THE OFFER IS THE CORPUS'S OWN STRUCTURE, and the search pays for
906
- // exploring it. There is no offer cap here any more: the traversal cap I
907
- // had put on this hop was a short-circuit — it bounded what a hop could
908
- // OFFER instead of charging for it — and it was not needed.
909
- //
910
- // MEASURED in the regime where it used to bite (`hubBound = ceil(√N)`
911
- // GREATER than the hub's degree — reached in a fixture by choosing the
912
- // degree below √N, so the trained store is not needed): with the cap the
913
- // offer was 8/9/9 continuations at degrees 35/70/120; without it, 52/84/120
914
- // — and the WORK is LINEAR in the degree, not quadratic: pushes 262/296/332,
915
- // perceptions 530/592/757, while the PEAK is identical with and without the
916
- // cap (218/415/689 MB against 215/410/662) because it is set by the store,
917
- // not by the fan-out. What made this hop expensive was never the fan-out
918
- // breadth: it was the per-offer work, two duplicate/oversized computations
919
- // since removed (the per-offset canonical scan, and the tail scan now
920
- // restricted to the fold's boundaries).
921
- //
922
- // The residual, stated: the trained store's hub (degree 1 083) is an
923
- // EXTRAPOLATION from this linear shape, not a measurement.
1105
+ // THE OFFER IS THE CORPUS'S OWN STRUCTURE, and the search pays for it.
1106
+ // In the closure law's terms (derivation.ts) a hop is a MOVE — it reaches
1107
+ // structure the derivation has not stood on — so it is admitted on that
1108
+ // ground and priced one STEP; the A*LD search, not a count, decides how
1109
+ // many are worth taking. `hubBound` is the READ cap (invariant 5) and
1110
+ // nothing else bounds the offer: a cap on what a hop may OFFER was tried
1111
+ // and removed as a short-circuit (trap 13). Measured with the hub's
1112
+ // degree below √N: the work is LINEAR in the degree (pushes 262/296/332 at
1113
+ // degrees 35/70/120) and the peak is set by the store, not the fan-out
1114
+ // (test/112 pins the non-quadratic shape).
924
1115
  const nx = this.store.nextFirst(it.node, this.hubBound());
925
1116
  // Count what is OFFERED, not what was read: the evidence-preferred
926
1117
  // continuation is yielded too, even when it lies outside the cap.
@@ -1071,8 +1262,24 @@ export class GraphSearch {
1071
1262
  // Recognised but edge-less: borrow a concept (halo) sibling's edge. No
1072
1263
  // edge and no concept means the form leads nowhere — it yields no rule, so
1073
1264
  // a query of only such forms produces no derivation, and think is silent.
1074
- const target = conceptTarget.get(it.node);
1075
- if (target !== undefined) {
1265
+ // An offered form not yet granted yields the hop's ASKING form at the
1266
+ // hop's own cost and span (see {@link Licence}).
1267
+ if (!concepts.offered.has(it.node)) return;
1268
+ const target = concepts.granted.get(it.node);
1269
+ if (target === undefined) {
1270
+ yield {
1271
+ premises: [it],
1272
+ conclusion: {
1273
+ kind: "form",
1274
+ i: it.i,
1275
+ j: it.j,
1276
+ node: it.node,
1277
+ via: true,
1278
+ ask: true,
1279
+ },
1280
+ cost: CONCEPT,
1281
+ };
1282
+ } else if (target !== null) {
1076
1283
  yield {
1077
1284
  premises: [it],
1078
1285
  conclusion: {
@@ -1211,7 +1418,7 @@ export class GraphSearch {
1211
1418
  leaves: rec.leaves,
1212
1419
  splits: rec.splits,
1213
1420
  },
1214
- new Map(),
1421
+ noLicence(),
1215
1422
  undefined,
1216
1423
  undefined,
1217
1424
  undefined,
@@ -1245,6 +1452,14 @@ export class GraphSearch {
1245
1452
  * outs of a long query re-cover each distinct node at most once); reset at the
1246
1453
  * top of {@link cover}. */
1247
1454
  private recompleteMemo = new Map<number, Uint8Array | null>();
1455
+ /** {@link entityProposals}, per fact BYTES — a pure function of them while
1456
+ * the store is read-only (one response), so a fact the chart reaches as
1457
+ * several items (cover/fix variants, nested completions) is scanned once.
1458
+ * Reset at the top of {@link cover}, like {@link recompleteMemo}. */
1459
+ private entityMemo = new Map<string, EntityProposals>();
1460
+ /** The joins licensed under one cover's {@link Licence}, kept across its
1461
+ * re-covers (see {@link solve}). */
1462
+ private readonly joinsKept = new WeakMap<ConceptLicence, JoinLicense>();
1248
1463
  /** The derivation sink of the TOP cover, threaded into every nested
1249
1464
  * completion so a produced form's own recompositions are reported in the
1250
1465
  * same trace instead of vanishing after the first layer. Undefined when
@@ -1260,6 +1475,57 @@ export class GraphSearch {
1260
1475
  * open — the invariant a reader needs to check the guard. */
1261
1476
  private recompleteOpen = new Set<number>();
1262
1477
 
1478
+ /** The ENTITIES a produced fact's bytes contain that lead somewhere — the
1479
+ * candidates a join may travel through. The forms the fact CONTAINS, by the
1480
+ * same recogniser the query went through (so the evidence standard is the
1481
+ * query's), plus — because the recognition of a STORED WHOLE returns the
1482
+ * whole and stops (measured: one site, the fact's own node, for `The
1483
+ * director of Eva is Gustaf Molander.`) — the longest canonically-resolving
1484
+ * form at each offset. A byte atom is never a subject. The scan runs only
1485
+ * for a FORM (≥ W: per letter it measured 20-26 s in test/99), and each probe
1486
+ * is a `canonResolve` whose exact tier decides a miss by its segment probe,
1487
+ * without a fold. The admission predicate is the store's `leadsSomewhere`
1488
+ * (edge or halo); the host lends its memoised form when it can.
1489
+ *
1490
+ * The SOURCE of each proposal travels with it, so a refusal names which path
1491
+ * proposed the candidate. Memoised per fact bytes ({@link entityMemo}). */
1492
+ private entityProposals(factBytes: Uint8Array): EntityProposals {
1493
+ const memoKey = latin1(factBytes);
1494
+ const hit = this.entityMemo.get(memoKey);
1495
+ if (hit !== undefined) return hit;
1496
+ const leads = (id: number): boolean =>
1497
+ this.host.leadsSomewhere !== undefined
1498
+ ? this.host.leadsSomewhere(id)
1499
+ : this.store.leadsSomewhere(id);
1500
+ const sites = this.host.recogniseSpan?.(factBytes)?.sites ?? [];
1501
+ const proposed = new Map<number, Uint8Array>();
1502
+ const source = new Map<number, string>();
1503
+ for (const s of sites) {
1504
+ if (s.payload >= 0 && leads(s.payload)) {
1505
+ proposed.set(s.payload, this.store.bytesPrefix(s.payload, ALL));
1506
+ source.set(s.payload, "recognised site");
1507
+ }
1508
+ }
1509
+ const W = this.maxGroup;
1510
+ if (this.host.canonResolve !== undefined && factBytes.length >= W) {
1511
+ const canon = this.host.canonResolve.bind(this.host);
1512
+ for (let start = 0; start < factBytes.length; start++) {
1513
+ for (let end = factBytes.length; end - start >= W; end--) {
1514
+ const id = canon(factBytes.subarray(start, end));
1515
+ if (id === null) continue;
1516
+ if (leads(id)) {
1517
+ proposed.set(id, this.store.bytesPrefix(id, ALL));
1518
+ source.set(id, "canonical fold");
1519
+ }
1520
+ break; // the longest form at this offset wins
1521
+ }
1522
+ }
1523
+ }
1524
+ const out = { proposed, source, sites };
1525
+ this.entityMemo.set(memoKey, out);
1526
+ return out;
1527
+ }
1528
+
1263
1529
  /** DERIVE-THROUGH — a RULE this module's DeductionSystem was missing. The
1264
1530
  * A*LD library is untouched: this is one more `premises → conclusion + cost`
1265
1531
  * rule in the system {@link buildSearch} hands to {@link lightestDerivation},
@@ -1274,7 +1540,10 @@ export class GraphSearch {
1274
1540
  * fact reached through it. On the ladder it is one STEP: a direct edge,
1275
1541
  * exactly as following a literal continuation is. Deterministic and
1276
1542
  * point-probed (`resolve` + `nextFirst`, no scan), so it adds no read that
1277
- * grows with the corpus. The move is visible in the rationale as its own act
1543
+ * grows with the corpus. Asked ONCE per fact a lightest derivation stood on
1544
+ * (the join license, {@link solve}) — never per fact the exploration merely
1545
+ * reached — so the number of facts it prices is the answer's, not the
1546
+ * corpus's. The move is visible in the rationale as its own act
1278
1547
  * (`classifyMove` reports `derive-through`), distinct from the
1279
1548
  * byte-concatenating `fuse`/`splice` — and named `derive-through` rather than
1280
1549
  * `join` so it cannot be read as the confluence mechanism's `Provenance`. */
@@ -1282,13 +1551,14 @@ export class GraphSearch {
1282
1551
  fact: OutItem,
1283
1552
  queryBytes: Uint8Array,
1284
1553
  queryLen: number,
1554
+ queryNodes: () => ReadonlySet<number>,
1285
1555
  ): Iterable<Rule<GItem>> {
1286
1556
  if (!this.host.recogniseSpan) return;
1287
1557
  const tail = queryBytes.subarray(fact.j, queryLen);
1288
1558
  if (tail.length === 0) return;
1289
- // Report ONLY the invocations that could have joined: the search asks this
1290
- // rule for every finalized out with a node, which includes the one-byte
1291
- // outs the cover bridges with — measured, 68 refusals for a single
1559
+ // Report ONLY the invocations that could have joined: a derivation stands on
1560
+ // node-bearing outs of every width, including the one-byte outs the cover
1561
+ // bridges with — measured, 68 refusals for a single
1292
1562
  // 3-relation query, all of them letters. A form shorter than one window is
1293
1563
  // not a fact a join could travel through, so it is not a refusal worth
1294
1564
  // reporting; W is the same line the rest of the mind draws between a chance
@@ -1298,69 +1568,24 @@ export class GraphSearch {
1298
1568
  // same recogniser the query went through, so the evidence standard is the
1299
1569
  // query's. A byte atom is never a subject; the fact's own node is the span
1300
1570
  // itself, not an entity inside it. The admission predicate has ONE
1301
- // definition — `traverse.ts`'s `leadsSomewhere` (edge or halo); the host
1302
- // LENDS it when it can (Mind does, with the response-scoped struct cache),
1303
- // and a bare host falls back to the raw-store probe, so the search stays
1571
+ // definition — the store's `leadsSomewhere` (edge or halo); the host LENDS
1572
+ // its memoised form when it can (Mind does, with the response-scoped struct
1573
+ // cache), and a bare host asks the store directly, so the search stays
1304
1574
  // host-based.
1305
- const factRec = this.host.recogniseSpan(fact.bytes);
1306
- const leads = (id: number): boolean =>
1307
- this.host.leadsSomewhere !== undefined
1308
- ? this.host.leadsSomewhere(id)
1309
- : this.store.hasNext(id) || this.store.hasHalo(id);
1575
+ const { proposed, source, sites: factSites } = this.entityProposals(
1576
+ fact.bytes,
1577
+ );
1310
1578
  // THE QUERY'S OWN SUBJECT, CANONICALLY. The filter used raw bytes and the
1311
1579
  // store's nodes are canonical, so `Eiffel Tower country` in the query did
1312
1580
  // not match the deposited `eiffel tower country` — measured, that is the
1313
- // trap's wrong answer.
1314
- //
1315
- // TAKEN FROM THE RECOGNITION THE RESPONSE ALREADY COMPUTED — the host's
1316
- // `recogniseSpan`, the same surface the rest of this search uses — not from
1317
- // a second offset scan. `canonicalQueryNodes` re-derived, per byte offset,
1318
- // what `recognise` had already resolved once per query (its memo is keyed by
1319
- // content), and that scan was the largest single cost the DIANOT join added:
1320
- // measured against the pre-change tree, the same fixture and the same test
1321
- // were 21 s slower with the scan than without it. A recognised site IS a
1322
- // canonical node of the query that can lead somewhere, which is exactly the
1323
- // set this filter wants, and it costs nothing to read.
1324
- const queryNodes = new Set<number>(
1325
- (this.host.recogniseSpan?.(queryBytes)?.sites ?? []).map((s) =>
1326
- s.payload
1327
- ),
1328
- );
1329
- // TWO SOURCES, ONE ADMISSION. The recognition of a STORED WHOLE returns the
1330
- // whole and stops — measured: for `The director of Eva is Gustaf Molander.`
1331
- // it yields exactly ONE site, the fact's own node — so the entity a join
1332
- // exists for is never proposed. The canonical fold is the second source,
1333
- // and the scan runs only for a FORM (≥ W: a one-byte out is not something to
1334
- // join through, and running it per letter measured 20-26 s in test/99).
1335
- const W = this.maxGroup;
1336
- const proposed = new Map<number, Uint8Array>();
1337
- // The SOURCE of each proposal travels with it: a refusal that names only the
1338
- // bytes leaves the next reader guessing which path proposed them — three
1339
- // attempts at the chained join were spent fixing paths that never produced
1340
- // the offending candidate.
1341
- const source = new Map<number, string>();
1342
- for (const s of factRec.sites) {
1343
- if (s.payload >= 0 && leads(s.payload)) {
1344
- proposed.set(s.payload, this.store.bytesPrefix(s.payload, ALL));
1345
- source.set(s.payload, "recognised site");
1346
- }
1347
- }
1348
- if (this.host.canonResolve !== undefined && fact.bytes.length >= W) {
1349
- const canon = this.host.canonResolve.bind(this.host);
1350
- for (let start = 0; start < fact.bytes.length; start++) {
1351
- for (let end = fact.bytes.length; end - start >= W; end--) {
1352
- const id = canon(fact.bytes.subarray(start, end));
1353
- if (id === null) continue;
1354
- if (leads(id)) {
1355
- proposed.set(id, this.store.bytesPrefix(id, ALL));
1356
- source.set(id, "canonical fold");
1357
- }
1358
- break; // the longest form at this offset wins
1359
- }
1360
- }
1361
- }
1581
+ // trap's wrong answer. Read from the recognition the response already
1582
+ // computed (`queryNodes`, once per solve), never from a second offset scan:
1583
+ // a per-offset `canonicalQueryNodes` re-derived what `recognise` had
1584
+ // resolved once, and was the largest single cost the join first added.
1362
1585
  const leading = [...proposed]
1363
- .filter(([payload]) => payload !== fact.node && !queryNodes.has(payload))
1586
+ .filter(([payload]) =>
1587
+ payload !== fact.node && !queryNodes().has(payload)
1588
+ )
1364
1589
  .map(([payload, bytes]) => ({ payload, bytes }));
1365
1590
  // …then prefer the entity the query did NOT name, and the MAXIMAL one. The
1366
1591
  // join exists to reach the subject the query never wrote, so:
@@ -1378,13 +1603,13 @@ export class GraphSearch {
1378
1603
  // Report WHAT the recognition returned, not just that nothing led: the
1379
1604
  // count and the first few site texts are the difference between "the
1380
1605
  // fact was not recognised" and "it was recognised but nothing led".
1381
- const seen = factRec.sites.slice(0, 3).map((s) =>
1606
+ const seen = factSites.slice(0, 3).map((s) =>
1382
1607
  this.store.bytesPrefix(s.payload, ALL)
1383
1608
  );
1384
1609
  this.host.reportSearch?.(
1385
1610
  "deriveThroughMiss",
1386
1611
  [fact.bytes, tail, ...seen],
1387
- `no entity inside the fact leads anywhere — ${factRec.sites.length} site(s) recognised inside it`,
1612
+ `no entity inside the fact leads anywhere — ${factSites.length} site(s) recognised inside it`,
1388
1613
  );
1389
1614
  }
1390
1615
  }
@@ -1496,8 +1721,8 @@ export class GraphSearch {
1496
1721
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
1497
1722
  * learnt connector (the in-search bridge), splitting (at a sub-leaf form
1498
1723
  * boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
1499
- * finalised out, and — for a produced fact — JOINING the entity it contains
1500
- * with the query's tail ({@link deriveThrough}). */
1724
+ * finalised out, and — for a LICENSED fact — JOINING the entity it contains
1725
+ * with the query's tail ({@link deriveThrough}, granted in {@link solve}). */
1501
1726
  private *outRules(
1502
1727
  it: OutItem,
1503
1728
  ctx: {
@@ -1511,16 +1736,16 @@ export class GraphSearch {
1511
1736
  coverableByStart: Map<number, OutItem[]>;
1512
1737
  findLeafU: (b: Uint8Array) => number | undefined;
1513
1738
  findBranchU: (k: number[]) => number | undefined;
1514
- linksByLeft?: ReadonlyMap<number, Array<[number, Uint8Array]>>;
1515
- linksByRight?: ReadonlyMap<number, Array<[number, Uint8Array]>>;
1516
- queryBytes: Uint8Array;
1517
- queryLen: number;
1739
+ linksByLeft: ReadonlyMap<number, Array<[number, string]>>;
1740
+ linksByRight: ReadonlyMap<number, Array<[number, string]>>;
1741
+ connectors?: ConnectorLicence;
1742
+ joins: JoinLicense;
1518
1743
  },
1519
1744
  ): Iterable<Rule<GItem>> {
1520
1745
  const { splits, coversDone, outsByStart, outsByEnd, coverableByStart } =
1521
1746
  ctx;
1522
1747
  const outsByNode = ctx.outsByNode;
1523
- const byRight = ctx.linksByRight ?? new Map();
1748
+ const byRight = ctx.linksByRight;
1524
1749
  pushInto(outsByStart, it.i, it);
1525
1750
  pushInto(outsByEnd, it.j, it);
1526
1751
  if (it.rec && it.node !== undefined) pushInto(outsByNode, it.node, it);
@@ -1536,23 +1761,25 @@ export class GraphSearch {
1536
1761
  // Points 2 & 5) — but NEVER across the asker's own unrecognised separator (a
1537
1762
  // space, comma), so "ice fire" stays "cold hot", never "cold or hot".
1538
1763
  //
1539
- // Cost stays bounded by iterating only the FEW resolved connector targets of
1764
+ // Cost stays bounded by iterating only the FEW offered connector targets of
1540
1765
  // this out's node (links are selective and, for the N-ary case, keyed first→
1541
1766
  // later — O(parts), not O(parts²)), and matching them against finalised outs
1542
- // by node id, rather than scanning every position pair.
1767
+ // by node id, rather than scanning every position pair. A pair is bridged
1768
+ // only once its two premises have met here ({@link ConnectorLicence}).
1543
1769
  const byLeft = ctx.linksByLeft;
1544
- if (byLeft && it.rec && it.node !== undefined) {
1545
- // L = this out, R = a later out whose node is a resolved target.
1546
- for (const [rNode, link] of byLeft.get(it.node) ?? []) {
1770
+ const licence = ctx.connectors;
1771
+ if (licence && it.rec && it.node !== undefined) {
1772
+ // L = this out, R = a later out whose node is an offered target.
1773
+ for (const [rNode, key] of byLeft.get(it.node) ?? []) {
1547
1774
  for (const r of outsByNode.get(rNode) ?? []) {
1548
- const rule = this.trySplice(it, r, link, outsByEnd);
1775
+ const rule = this.trySplice(it, r, key, licence, outsByEnd);
1549
1776
  if (rule) yield rule;
1550
1777
  }
1551
1778
  }
1552
- // R = this out, L = an earlier out whose node has a resolved target here.
1553
- for (const [lNode, link] of byRight.get(it.node) ?? []) {
1779
+ // R = this out, L = an earlier out whose node has an offered target here.
1780
+ for (const [lNode, key] of byRight.get(it.node) ?? []) {
1554
1781
  for (const l of outsByNode.get(lNode) ?? []) {
1555
- const rule = this.trySplice(l, it, link, outsByEnd);
1782
+ const rule = this.trySplice(l, it, key, licence, outsByEnd);
1556
1783
  if (rule) yield rule;
1557
1784
  }
1558
1785
  }
@@ -1615,10 +1842,11 @@ export class GraphSearch {
1615
1842
  // continuation is the derived answer — a genuine relational join, distinct
1616
1843
  // from the confluence mechanism's `join` PROVENANCE — and not the
1617
1844
  // juxtaposition the cover produces when the intermediate key IS named.
1618
- // Fired per finalized out with a node, so it is the search's own rule, on
1619
- // the ladder, memoised by {@link key}, and bounded by the fact's own length.
1620
- if (it.node !== undefined) {
1621
- yield* this.deriveThrough(it, ctx.queryBytes, ctx.queryLen);
1845
+ // On the ladder and memoised by {@link key} like every rule, but fired only
1846
+ // for a fact the solve has LICENSED (a fact a lightest derivation stood on —
1847
+ // see {@link solve}), from the joins computed for it once.
1848
+ for (const g of ctx.joins.get(chartKey(it)) ?? []) {
1849
+ yield { premises: [it], conclusion: g.conclusion, cost: g.cost };
1622
1850
  }
1623
1851
  }
1624
1852