@polycode-projects/the-mechanical-code-talker 2.5.2 → 2.5.3

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 (90) hide show
  1. package/README.md +11 -7
  2. package/bin/tmct.mjs +7 -4
  3. package/corpus/LICENSES.json +7 -0
  4. package/corpus/reference/LICENSE-NOTICE +36 -0
  5. package/corpus/reference/README.md +19 -0
  6. package/corpus/reference/index.json.gz +0 -0
  7. package/corpus/reference/manifest.json +367 -0
  8. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  9. package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
  10. package/corpus/reference/shards/ref-02.jsonl.gz +0 -0
  11. package/corpus/reference/shards/ref-03.jsonl.gz +0 -0
  12. package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
  13. package/corpus/reference/shards/ref-05.jsonl.gz +0 -0
  14. package/corpus/reference/shards/ref-06.jsonl.gz +0 -0
  15. package/corpus/reference/shards/ref-07.jsonl.gz +0 -0
  16. package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
  17. package/corpus/reference/shards/ref-09.jsonl.gz +0 -0
  18. package/corpus/reference/shards/ref-0a.jsonl.gz +0 -0
  19. package/corpus/reference/shards/ref-0b.jsonl.gz +0 -0
  20. package/corpus/reference/shards/ref-0c.jsonl.gz +0 -0
  21. package/corpus/reference/shards/ref-0d.jsonl.gz +0 -0
  22. package/corpus/reference/shards/ref-0e.jsonl.gz +0 -0
  23. package/corpus/reference/shards/ref-0f.jsonl.gz +0 -0
  24. package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
  25. package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
  26. package/corpus/reference/shards/ref-12.jsonl.gz +0 -0
  27. package/corpus/reference/shards/ref-13.jsonl.gz +0 -0
  28. package/corpus/reference/shards/ref-14.jsonl.gz +0 -0
  29. package/corpus/reference/shards/ref-15.jsonl.gz +0 -0
  30. package/corpus/reference/shards/ref-16.jsonl.gz +0 -0
  31. package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
  32. package/corpus/reference/shards/ref-18.jsonl.gz +0 -0
  33. package/corpus/reference/shards/ref-19.jsonl.gz +0 -0
  34. package/corpus/reference/shards/ref-1a.jsonl.gz +0 -0
  35. package/corpus/reference/shards/ref-1b.jsonl.gz +0 -0
  36. package/corpus/reference/shards/ref-1c.jsonl.gz +0 -0
  37. package/corpus/reference/shards/ref-1d.jsonl.gz +0 -0
  38. package/corpus/reference/shards/ref-1e.jsonl.gz +0 -0
  39. package/corpus/reference/shards/ref-1f.jsonl.gz +0 -0
  40. package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
  41. package/corpus/reference/shards/ref-21.jsonl.gz +0 -0
  42. package/corpus/reference/shards/ref-22.jsonl.gz +0 -0
  43. package/corpus/reference/shards/ref-23.jsonl.gz +0 -0
  44. package/corpus/reference/shards/ref-24.jsonl.gz +0 -0
  45. package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
  46. package/corpus/reference/shards/ref-26.jsonl.gz +0 -0
  47. package/corpus/reference/shards/ref-27.jsonl.gz +0 -0
  48. package/corpus/reference/shards/ref-28.jsonl.gz +0 -0
  49. package/corpus/reference/shards/ref-29.jsonl.gz +0 -0
  50. package/corpus/reference/shards/ref-2a.jsonl.gz +0 -0
  51. package/corpus/reference/shards/ref-2b.jsonl.gz +0 -0
  52. package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
  53. package/corpus/reference/shards/ref-2d.jsonl.gz +0 -0
  54. package/corpus/reference/shards/ref-2e.jsonl.gz +0 -0
  55. package/corpus/reference/shards/ref-2f.jsonl.gz +0 -0
  56. package/corpus/reference/shards/ref-30.jsonl.gz +0 -0
  57. package/corpus/reference/shards/ref-31.jsonl.gz +0 -0
  58. package/corpus/reference/shards/ref-32.jsonl.gz +0 -0
  59. package/corpus/reference/shards/ref-33.jsonl.gz +0 -0
  60. package/corpus/reference/shards/ref-34.jsonl.gz +0 -0
  61. package/corpus/reference/shards/ref-35.jsonl.gz +0 -0
  62. package/corpus/reference/shards/ref-36.jsonl.gz +0 -0
  63. package/corpus/reference/shards/ref-37.jsonl.gz +0 -0
  64. package/corpus/reference/shards/ref-38.jsonl.gz +0 -0
  65. package/corpus/reference/shards/ref-39.jsonl.gz +0 -0
  66. package/corpus/reference/shards/ref-3a.jsonl.gz +0 -0
  67. package/corpus/reference/shards/ref-3b.jsonl.gz +0 -0
  68. package/corpus/reference/shards/ref-3c.jsonl.gz +0 -0
  69. package/corpus/reference/shards/ref-3d.jsonl.gz +0 -0
  70. package/corpus/reference/shards/ref-3e.jsonl.gz +0 -0
  71. package/corpus/reference/shards/ref-3f.jsonl.gz +0 -0
  72. package/package.json +5 -1
  73. package/src/adapters/corpus/reference-pack.mjs +107 -0
  74. package/src/adapters/memory/core.mjs +104 -10
  75. package/src/domain/ask.mjs +66 -9
  76. package/src/domain/cli-verbs.mjs +3 -3
  77. package/src/domain/codegraph.mjs +6 -0
  78. package/src/domain/dialogue-acts.mjs +155 -0
  79. package/src/domain/interpret/normalize.mjs +14 -2
  80. package/src/domain/interpret/strategies/keywords.mjs +10 -1
  81. package/src/domain/memory/trust.mjs +12 -0
  82. package/src/domain/reference-pack.mjs +72 -0
  83. package/src/domain/skos-view.mjs +111 -0
  84. package/src/domain/syllogise.mjs +647 -56
  85. package/src/services/chat.mjs +497 -121
  86. package/src/surfaces/web/chat-browser-entry.mjs +87 -0
  87. package/src/surfaces/web/memory-ask-browser.bundle.js +864 -73
  88. package/src/tools/definitions.mjs +13 -0
  89. package/src/tools/handlers/index.mjs +2 -0
  90. package/src/tools/handlers/tmct-related.mjs +30 -0
@@ -72,6 +72,11 @@ export const ENTAILED_SVF1_PROVENANCE = `entailed:${CLS_SVF1_RULE}`;
72
72
  /** Same sub-1 discount as CAX_DW_RULE_CONFIDENCE, same reason. */
73
73
  export const CLS_SVF1_RULE_CONFIDENCE = 0.95;
74
74
 
75
+ /** The environment-set cap: how many independent premise sets one entailed
76
+ * fact may carry as ' | '-separated mgx:factJustification environments — the
77
+ * bounded-ATMS knob alongside depth/budget/focus. */
78
+ export const DEFAULT_MAX_ENVIRONMENTS = 4;
79
+
75
80
  const SEP = "␟"; // an in-key separator no fact term can contain
76
81
  const isSubClassOf = (p) => String(p || "").trim().toLowerCase() === "rdfs:subclassof";
77
82
  const isType = (p) => String(p || "").trim().toLowerCase() === "rdf:type";
@@ -166,6 +171,79 @@ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus =
166
171
  return derived;
167
172
  }
168
173
 
174
+ /**
175
+ * PURE semi-naive subClassOf closure: the delta twin of
176
+ * `deriveSubClassClosure`, same contract exactly (only-new `{ subject,
177
+ * object, via }` conclusions, tautology/dedup/focus screens, the same
178
+ * per-round candidate sort, identical `budget`/`depth` semantics). Instead of
179
+ * re-joining the whole relation each round, round one joins only
180
+ * `deltaEdges` against the full relation (Δ∘R and R∘Δ — Δ∘Δ falls out of Δ∘R
181
+ * because `allEdges` contains the delta rows too), and each later round joins
182
+ * only what the previous round committed. When the non-delta part of
183
+ * `allEdges` is already closed (a prior pass ran to fixpoint), the output
184
+ * equals the full kernel's novel output, order included.
185
+ */
186
+ export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, budget = 50, focus = null } = {}) {
187
+ const present = new Set(); // "a\0b" for every edge already known
188
+ const succ = new Map(); // a -> Set(b): the live successor relation
189
+ const pred = new Map(); // b -> Set(a): its inverse, for the R∘Δ join
190
+ for (const [a, b] of allEdges || []) {
191
+ if (!a || !b || a === b) continue;
192
+ present.add(`${a}${SEP}${b}`);
193
+ if (!succ.has(a)) succ.set(a, new Set());
194
+ succ.get(a).add(b);
195
+ if (!pred.has(b)) pred.set(b, new Set());
196
+ pred.get(b).add(a);
197
+ }
198
+ let delta = [];
199
+ const seenDelta = new Set();
200
+ for (const [a, b] of deltaEdges || []) {
201
+ if (!a || !b || a === b) continue;
202
+ const key = `${a}${SEP}${b}`;
203
+ if (seenDelta.has(key)) continue;
204
+ seenDelta.add(key);
205
+ delta.push([a, b]);
206
+ }
207
+ const focusSet = focus instanceof Set ? (focus.size ? focus : null) : normalizeFocus(focus);
208
+ const inFocus = (a, b, c) => !focusSet || focusSet.has(a) || focusSet.has(b) || focusSet.has(c);
209
+
210
+ const derived = [];
211
+ const derivedKeys = new Set();
212
+ for (let round = 0; round < depth && delta.length; round += 1) {
213
+ const additions = [];
214
+ const consider = (a, b, c) => {
215
+ if (a === c) return; // tautology screen (reflexive)
216
+ const key = `${a}${SEP}${c}`;
217
+ if (present.has(key) || derivedKeys.has(key)) return; // dedup / novelty screen
218
+ if (!inFocus(a, b, c)) return; // focus-connection screen
219
+ additions.push([a, b, c, key]);
220
+ };
221
+ for (const [a, b] of delta) {
222
+ for (const c of succ.get(b) || []) consider(a, b, c); // Δ∘R
223
+ for (const z of pred.get(a) || []) consider(z, a, b); // R∘Δ
224
+ }
225
+ if (!additions.length) break; // fixpoint reached
226
+ additions.sort((x, y) => x[0].localeCompare(y[0]) || x[2].localeCompare(y[2]) || x[1].localeCompare(y[1]));
227
+ let progressed = false;
228
+ const nextDelta = [];
229
+ for (const [a, b, c, key] of additions) {
230
+ if (derivedKeys.has(key)) continue; // an earlier addition this round covered it
231
+ if (derived.length >= budget) break;
232
+ derivedKeys.add(key);
233
+ derived.push({ subject: a, object: c, via: b });
234
+ if (!succ.has(a)) succ.set(a, new Set());
235
+ succ.get(a).add(c);
236
+ if (!pred.has(c)) pred.set(c, new Set());
237
+ pred.get(c).add(a);
238
+ nextDelta.push([a, c]);
239
+ progressed = true;
240
+ }
241
+ if (derived.length >= budget || !progressed) break; // budget hit or nothing committed
242
+ delta = nextDelta;
243
+ }
244
+ return derived;
245
+ }
246
+
169
247
  /**
170
248
  * Shared closure machinery: given `subClassEdges` ([[a,b], …], already-
171
249
  * normalized), returns a memoized `ancestorsOf(c)` that walks the FULL
@@ -200,6 +278,52 @@ function buildAncestorCloser(subClassEdges) {
200
278
  };
201
279
  }
202
280
 
281
+ /** `buildAncestorCloser` with the edges reversed: a memoized
282
+ * `descendantsOf(c)` walking every SUBclass reachable below `c`. */
283
+ function buildDescendantCloser(subClassEdges) {
284
+ return buildAncestorCloser((subClassEdges || []).map(([a, b]) => [b, a]));
285
+ }
286
+
287
+ /**
288
+ * PURE relevance frontier: the set of terms a change touching `seedTerms`
289
+ * can actually affect, over the fact `rows` given. Seeds (normalized) plus
290
+ * the ⊑-descendant closure of each seed (a new edge above a class affects
291
+ * everything below it), plus every instance carrying a stored type in that
292
+ * affected-class set, plus every declared someValuesFrom restriction node
293
+ * whose target filler class sits in it. Over-approximation is harmless — the
294
+ * kernels' own dedup screens drop anything already known — while a term the
295
+ * frontier misses simply waits for the next full pass. The same structure
296
+ * serves forward relevance (a delta pass's focus) that `retractSubClassOf`'s
297
+ * citedBy index serves backward.
298
+ */
299
+ export function buildRelevanceFrontier(rows, seedTerms) {
300
+ const subClassEdges = [];
301
+ const typeEdges = [];
302
+ const onPropertyOf = new Map(); // restriction -> owl:onProperty's object
303
+ const someValuesFromOf = new Map(); // restriction -> owl:someValuesFrom's object
304
+ for (const r of rows || []) {
305
+ if (!r || !r.subject || !r.predicate || !r.object) continue;
306
+ if (isSubClassOf(r.predicate)) subClassEdges.push([r.subject, r.object]);
307
+ else if (isType(r.predicate)) typeEdges.push([r.subject, r.object]);
308
+ else if (isOnProperty(r.predicate)) onPropertyOf.set(r.subject, r.object);
309
+ else if (isSomeValuesFrom(r.predicate)) someValuesFromOf.set(r.subject, r.object);
310
+ }
311
+ const descendantsOf = buildDescendantCloser(subClassEdges);
312
+ const affectedClasses = new Set();
313
+ for (const t of seedTerms || []) {
314
+ const n = normFactTerm(t);
315
+ if (!n) continue;
316
+ affectedClasses.add(n);
317
+ for (const d of descendantsOf(n)) affectedClasses.add(d);
318
+ }
319
+ const frontier = new Set(affectedClasses);
320
+ for (const [x, c] of typeEdges) if (affectedClasses.has(c)) frontier.add(x);
321
+ for (const [restriction, target] of someValuesFromOf) {
322
+ if (onPropertyOf.has(restriction) && affectedClasses.has(target)) frontier.add(restriction);
323
+ }
324
+ return frontier;
325
+ }
326
+
203
327
  /**
204
328
  * PURE cax-sco: rdf:type propagation across a subClassOf chain — (x rdf:type
205
329
  * C), (C ⊑ … ⊑ D) ⊨ (x rdf:type D). `subClassEdges` is a fixed input (unlike
@@ -207,13 +331,18 @@ function buildAncestorCloser(subClassEdges) {
207
331
  * class (`buildAncestorCloser`) covers the whole chain — no fixpoint rounds
208
332
  * needed. Returns ONLY new `{ subject, object, via }` conclusions, bounded by
209
333
  * `budget`, focus-filtered, tautology- and dedup-screened, deterministic order.
334
+ *
335
+ * `presentTypeEdges` (defaults to `typeEdges`) feeds ONLY the novelty screen:
336
+ * a delta caller that pre-filters `typeEdges` to the relevant slice passes the
337
+ * FULL list here, so an already-stored conclusion outside the slice is still
338
+ * recognized as known rather than re-derived.
210
339
  */
211
- export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, focus = null } = {}) {
340
+ export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, focus = null, presentTypeEdges = typeEdges } = {}) {
212
341
  const ancestorsOf = buildAncestorCloser(subClassEdges);
213
342
 
214
343
  const present = new Set(); // "x\0C" for every rdf:type edge already known
215
344
  const seenTypeEdge = new Set(); // dedup repeated (x,C) input rows
216
- for (const [x, c] of typeEdges || []) if (x && c) present.add(`${x}${SEP}${c}`);
345
+ for (const [x, c] of presentTypeEdges || []) if (x && c) present.add(`${x}${SEP}${c}`);
217
346
  const focusSet = focus instanceof Set ? (focus.size ? focus : null) : normalizeFocus(focus);
218
347
  const inFocus = (x, c, d) => !focusSet || focusSet.has(x) || focusSet.has(c) || focusSet.has(d);
219
348
 
@@ -664,18 +793,47 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
664
793
  * (`min(premiseTrusts) x ruleConfidence`) when premises are resolvable in the
665
794
  * pre-pass snapshot, else falls back to the bare entailed prior.
666
795
  *
796
+ * After the kernels, an ALTERNATE-DISCOVERY step (bounded by the same budget
797
+ * number, spent separately) enumerates additional premise environments for
798
+ * this pass's conclusions and for stored purely-entailed facts still under
799
+ * the `maxEnvironments` cap, so a fact's justification accretes every
800
+ * independent derivation route retraction can later check by set membership.
801
+ *
802
+ * SEMI-NAIVE DELTA MODE: when the store carries a watermark from the last
803
+ * complete pass (the optional `loadSyllogiseState`/`saveSyllogiseState` store
804
+ * members), no caller focus is given, no fact was removed since, and `full`
805
+ * was not forced, the pass runs delta evaluation — scm-sco joins only the
806
+ * since-watermark rows (`deriveSubClassClosureDelta`), and the four later
807
+ * kernels are scoped by a relevance frontier built from the delta
808
+ * (`buildRelevanceFrontier`) plus per-kernel input pre-filters. Conclusions
809
+ * are identical to a full pass (the dedup screens make over-approximation
810
+ * harmless); what shrinks is candidate generation and the joins — the pass
811
+ * still pays the store snapshot read. The watermark advances ONLY after an
812
+ * unfocused pass that ends at a natural fixpoint.
813
+ *
667
814
  * opts: `depth` (max fixpoint rounds, default 32), `budget` (max new
668
815
  * derivations this pass, shared across all five rules, default 50), `focus`
669
816
  * (Set|array of class terms scoping derivations to what touches it — omit
670
- * for a whole-graph pass), `store` (REQUIRED the memory store's
817
+ * for a whole-graph pass), `expandFocus` (default false: when true, a caller
818
+ * focus expands through `buildRelevanceFrontier` before the kernels run, so
819
+ * a derivation among the focus terms' descendants and instances is in scope
820
+ * even when none of its own three terms was named), `maxEnvironments`
821
+ * (per-fact environment cap,
822
+ * default DEFAULT_MAX_ENVIRONMENTS), `full` (force full evaluation even with
823
+ * a valid watermark), `store` (REQUIRED — the memory store's
671
824
  * { loadMemory, readFactRows, appendFacts } read/write functions, injected so
672
- * this inference module never imports the store itself).
825
+ * this inference module never imports the store itself; optional
826
+ * loadSyllogiseState/saveSyllogiseState enable delta mode).
673
827
  *
674
828
  * Returns { derived: [{ id, subject, object, via, rule }], count, budget,
675
- * depth, truncated }.
829
+ * depth, truncated, mode, deltaSize, environmentsAdded, alternatesTruncated }.
676
830
  */
677
- export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null, store } = {}) {
831
+ export async function syllogise(repoDir, {
832
+ depth = 32, budget = 50, focus = null, expandFocus = false,
833
+ maxEnvironments = DEFAULT_MAX_ENVIRONMENTS, full = false, store,
834
+ } = {}) {
678
835
  const { loadMemory, readFactRows, appendFacts } = requireStore(store, ["loadMemory", "readFactRows", "appendFacts"], "syllogise");
836
+ const stateFnsPresent = typeof store?.loadSyllogiseState === "function" && typeof store?.saveSyllogiseState === "function";
679
837
  const memory = await loadMemory(repoDir);
680
838
  const rows = readFactRows(memory);
681
839
  const subClassEdges = rows.filter((r) => isSubClassOf(r.predicate)).map((r) => [r.subject, r.object]);
@@ -699,7 +857,25 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
699
857
  const target = someValuesFromOf.get(restriction);
700
858
  if (target) restrictionEdges.push({ restriction, property, target });
701
859
  }
702
- const normalizedFocus = normalizeFocus(focus);
860
+ const callerFocus = normalizeFocus(focus);
861
+ // An expanded focus is the same relevance walk a delta pass runs, seeded by
862
+ // the caller's terms instead of a change set — still a focus (never reads
863
+ // or advances the watermark), just one that reaches the descendants,
864
+ // instances and restrictions the named terms actually govern.
865
+ const normalizedFocus = expandFocus && callerFocus
866
+ ? buildRelevanceFrontier(rows, [...callerFocus])
867
+ : callerFocus;
868
+
869
+ // Mode: delta only with a valid watermark (state present, nothing removed
870
+ // since — an id-set diff catches retractions, snapshots and hand-edits),
871
+ // no caller focus, and no forced full. Anything else is a full pass.
872
+ const state = normalizedFocus === null && stateFnsPresent ? await store.loadSyllogiseState(repoDir) : null;
873
+ const currentIdSet = new Set(rows.map((r) => r.id));
874
+ const removedSinceLast = Array.isArray(state?.factIds) ? state.factIds.filter((id) => !currentIdSet.has(id)) : [];
875
+ const mode = state && Array.isArray(state.factIds) && !removedSinceLast.length && !full ? "delta" : "full";
876
+ const watermark = mode === "delta" ? new Set(state.factIds) : null;
877
+ const deltaRows = mode === "delta" ? rows.filter((r) => !watermark.has(r.id)) : rows;
878
+ const deltaSize = deltaRows.length;
703
879
 
704
880
  // Pre-pass trust snapshot for the entailed hook's premiseTrusts lookup,
705
881
  // wired for cax-dw/cls-svf1/scm-svf1 only: with ruleConfidence defaulting
@@ -713,33 +889,93 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
713
889
  const hasTriple = (s, p, o) => trustByTriple.has(`${s}${SEP}${p}${SEP}${o}`);
714
890
  const numericOnly = (arr) => arr.filter((t) => typeof t === "number");
715
891
 
716
- const scmDerived = deriveSubClassClosure(subClassEdges, { depth, budget, focus: normalizedFocus });
892
+ // An empty delta derives nothing by construction and an empty frontier
893
+ // must never be handed to the kernels as focus, because an empty focus Set
894
+ // means "whole graph" to normalizeFocus. Skipping the kernels outright is
895
+ // both the honest and the cheap reading.
896
+ const deltaEmpty = mode === "delta" && !deltaRows.length;
897
+ const deltaSubEdges = mode === "delta"
898
+ ? deltaRows.filter((r) => isSubClassOf(r.predicate)).map((r) => [r.subject, r.object])
899
+ : [];
900
+ const scmDerived = mode === "delta"
901
+ ? (deltaSubEdges.length ? deriveSubClassClosureDelta(subClassEdges, deltaSubEdges, { depth, budget, focus: normalizedFocus }) : [])
902
+ : deriveSubClassClosure(subClassEdges, { depth, budget, focus: normalizedFocus });
717
903
  // cax-sco sees the ENLARGED subClassOf edge set (stated ∪ this pass's own
718
904
  // scm-sco conclusions) so both rules complete in one `tmct syllogise` call.
719
905
  const enlargedSubClassEdges = subClassEdges.concat(scmDerived.map((d) => [d.subject, d.object]));
906
+
907
+ // Delta mode scopes the four later kernels by the relevance frontier: built
908
+ // AFTER scm-sco over the enlarged edge set, seeded by every term of every
909
+ // delta row, then applied as their focus plus per-kernel input pre-filters.
910
+ let kernelFocus = normalizedFocus;
911
+ let frontier = null;
912
+ if (mode === "delta" && !deltaEmpty) {
913
+ const frontierRows = rows.concat(scmDerived.map((d) => ({ subject: d.subject, predicate: SUBCLASS_PREDICATE, object: d.object })));
914
+ const seeds = [];
915
+ for (const r of deltaRows) seeds.push(r.subject, r.object);
916
+ frontier = buildRelevanceFrontier(frontierRows, seeds);
917
+ kernelFocus = frontier;
918
+ }
919
+ const inFrontier = (t) => !frontier || frontier.has(t);
920
+ // cax-sco's inputs narrow to the frontier's type edges, but its NOVELTY set
921
+ // must still see every stored type edge (presentTypeEdges) — otherwise the
922
+ // filtered call re-derives stored conclusions and idempotency breaks.
923
+ const caxTypeEdges = frontier ? typeEdges.filter(([x, c]) => inFrontier(x) || inFrontier(c)) : typeEdges;
924
+ // cax-dw additionally keeps any type whose ⊑-ancestry reaches an endpoint
925
+ // of a delta disjointWith row — a new disjointness above an old type is
926
+ // invisible to the frontier's descendant walk.
927
+ const deltaDwEndpoints = new Set();
928
+ if (frontier) {
929
+ for (const r of deltaRows) {
930
+ if (isDisjoint(r.predicate)) { deltaDwEndpoints.add(r.subject); deltaDwEndpoints.add(r.object); }
931
+ }
932
+ }
933
+ const dwAncestorsOf = frontier && deltaDwEndpoints.size ? buildAncestorCloser(enlargedSubClassEdges) : null;
934
+ const dwTypeEdges = frontier
935
+ ? typeEdges.filter(([x, c]) => inFrontier(x) || inFrontier(c)
936
+ || (dwAncestorsOf && [...dwAncestorsOf(c)].some((a) => deltaDwEndpoints.has(a))))
937
+ : typeEdges;
938
+ // cls-svf1's property edges narrow to the frontier — plus every edge over a
939
+ // property whose restriction declaration is itself in the delta, so a new
940
+ // restriction reaches old edges; its type edges stay FULL (they feed the
941
+ // novelty screen and the filler-type join).
942
+ const deltaRestrictionProperties = new Set();
943
+ if (frontier) {
944
+ for (const r of deltaRows) {
945
+ if (isOnProperty(r.predicate) || isSomeValuesFrom(r.predicate)) {
946
+ const property = onPropertyOf.get(r.subject);
947
+ if (property) deltaRestrictionProperties.add(normFactTerm(property));
948
+ }
949
+ }
950
+ }
951
+ const svf1PropertyEdges = frontier
952
+ ? propertyEdges.filter(([x, p, y]) => inFrontier(x) || inFrontier(y) || deltaRestrictionProperties.has(normFactTerm(p)))
953
+ : propertyEdges;
954
+
720
955
  const remainingBudget = Math.max(0, budget - scmDerived.length);
721
- const caxDerived = remainingBudget > 0
722
- ? deriveTypePropagation(typeEdges, enlargedSubClassEdges, { budget: remainingBudget, focus: normalizedFocus })
956
+ const caxDerived = remainingBudget > 0 && !deltaEmpty
957
+ ? deriveTypePropagation(caxTypeEdges, enlargedSubClassEdges, { budget: remainingBudget, focus: kernelFocus, presentTypeEdges: typeEdges })
723
958
  : [];
724
959
  // cax-dw sees the SAME enlarged subClassOf set (so its own ⊑-lift reaches a
725
960
  // chain scm-sco just grew this pass) — it doesn't need the enlarged TYPE
726
961
  // edge set too, since it walks each direct type's own ⊑-ancestor closure.
727
962
  const remainingBudgetDw = Math.max(0, budget - scmDerived.length - caxDerived.length);
728
- const dwDerived = remainingBudgetDw > 0
729
- ? deriveDisjointViolations(typeEdges, enlargedSubClassEdges, disjointEdges, { budget: remainingBudgetDw, focus: normalizedFocus })
963
+ const dwDerived = remainingBudgetDw > 0 && !deltaEmpty
964
+ ? deriveDisjointViolations(dwTypeEdges, enlargedSubClassEdges, disjointEdges, { budget: remainingBudgetDw, focus: kernelFocus })
730
965
  : [];
731
966
  // cls-svf1 sees the SAME enlarged subClassOf set (its own ⊑-lift) but NOT
732
967
  // the enlarged type edge set, so a same-pass cax-sco conclusion on `y`
733
968
  // can't be consumed before a human can audit it.
734
969
  const remainingBudgetSvf1 = Math.max(0, budget - scmDerived.length - caxDerived.length - dwDerived.length);
735
- const svf1Derived = remainingBudgetSvf1 > 0 && restrictionEdges.length
736
- ? deriveSomeValuesFromApplication(propertyEdges, typeEdges, enlargedSubClassEdges, restrictionEdges, { budget: remainingBudgetSvf1, focus: normalizedFocus })
970
+ const svf1Derived = remainingBudgetSvf1 > 0 && restrictionEdges.length && !deltaEmpty
971
+ ? deriveSomeValuesFromApplication(svf1PropertyEdges, typeEdges, enlargedSubClassEdges, restrictionEdges, { budget: remainingBudgetSvf1, focus: kernelFocus })
737
972
  : [];
738
973
  // scm-svf1 reuses the SAME restrictionEdges built for cls-svf1 above —
739
- // needs at least two restrictions over one property to compare.
974
+ // needs at least two restrictions over one property to compare. In delta
975
+ // mode the frontier scopes it as focus only, no input pre-filter.
740
976
  const remainingBudgetScmSvf = Math.max(0, budget - scmDerived.length - caxDerived.length - dwDerived.length - svf1Derived.length);
741
- const scmSvfDerived = remainingBudgetScmSvf > 0 && restrictionEdges.length > 1
742
- ? deriveSomeValuesFromSubsumption(restrictionEdges, enlargedSubClassEdges, { budget: remainingBudgetScmSvf, focus: normalizedFocus })
977
+ const scmSvfDerived = remainingBudgetScmSvf > 0 && restrictionEdges.length > 1 && !deltaEmpty
978
+ ? deriveSomeValuesFromSubsumption(restrictionEdges, enlargedSubClassEdges, { budget: remainingBudgetScmSvf, focus: kernelFocus })
743
979
  : [];
744
980
  const restrictionByRid = new Map(restrictionEdges.map((r) => [r.restriction, r]));
745
981
 
@@ -749,16 +985,18 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
749
985
  ...scmDerived.map((d) => ({
750
986
  subject: d.subject, predicate: SUBCLASS_PREDICATE, object: d.object,
751
987
  provenance: ENTAILED_PROVENANCE,
752
- // Persisted justification: the premise fact ids this conclusion rode
753
- // (a⊑b, b⊑c) content-addressed ids work even when a premise is
754
- // itself an entailment this same pass just derived. Read back by
988
+ // Persisted justification: one environment per independent derivation,
989
+ // each an ordered premise fact-id list this first one is the premise
990
+ // set the conclusion rode (a⊑b, b⊑c); the alternate-discovery step
991
+ // below may append more. Content-addressed ids work even when a premise
992
+ // is itself an entailment this same pass just derived. Read back by
755
993
  // retractSubClassOf (below) to find every entailment a retracted
756
994
  // premise could have supported. All five rules persist one, each
757
995
  // citing its own premise shape.
758
- justification: [
996
+ justification: [[
759
997
  factIdForTriple(d.subject, SUBCLASS_PREDICATE, d.via),
760
998
  factIdForTriple(d.via, SUBCLASS_PREDICATE, d.object),
761
- ],
999
+ ]],
762
1000
  })),
763
1001
  ...caxDerived.map((d) => ({
764
1002
  subject: d.subject, predicate: TYPE_PREDICATE, object: d.object,
@@ -767,10 +1005,10 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
767
1005
  // taught chain is multi-hop: scm-sco materialises that edge (this same
768
1006
  // pass or an earlier one), and retraction re-VERIFIES every candidate
769
1007
  // anyway, so a citation left dangling by budget truncation is inert.
770
- justification: [
1008
+ justification: [[
771
1009
  factIdForTriple(d.subject, TYPE_PREDICATE, d.via),
772
1010
  factIdForTriple(d.via, SUBCLASS_PREDICATE, d.object),
773
- ],
1011
+ ]],
774
1012
  })),
775
1013
  ...dwDerived.map((d) => {
776
1014
  // disjointWith is symmetric, taught as ONE direction — the premise row
@@ -788,11 +1026,11 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
788
1026
  return {
789
1027
  subject: d.subject, predicate: DISJOINT_PREDICATE, object: d.object,
790
1028
  provenance: ENTAILED_DISJOINT_PROVENANCE,
791
- justification: [
1029
+ justification: [[
792
1030
  factIdForTriple(d.subject, TYPE_PREDICATE, d.viaType),
793
1031
  factIdForTriple(dwS, DISJOINT_PREDICATE, dwO),
794
1032
  ...(d.viaClass !== d.viaType ? [factIdForTriple(d.viaType, SUBCLASS_PREDICATE, d.viaClass)] : []),
795
- ],
1033
+ ]],
796
1034
  ...(premiseTrusts.length ? { premiseTrusts, ruleConfidence: CAX_DW_RULE_CONFIDENCE } : {}),
797
1035
  };
798
1036
  }),
@@ -809,13 +1047,13 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
809
1047
  return {
810
1048
  subject: d.subject, predicate: TYPE_PREDICATE, object: d.object,
811
1049
  provenance: ENTAILED_SVF1_PROVENANCE,
812
- justification: [
1050
+ justification: [[
813
1051
  factIdForTriple(d.subject, d.viaProperty, d.viaValue),
814
1052
  factIdForTriple(d.viaValue, TYPE_PREDICATE, d.viaType),
815
1053
  factIdForTriple(d.object, ON_PROPERTY_PREDICATE, d.viaPropertyKey),
816
1054
  factIdForTriple(d.object, SOME_VALUES_FROM_PREDICATE, d.viaTarget),
817
1055
  ...(d.viaType !== d.viaTarget ? [factIdForTriple(d.viaType, SUBCLASS_PREDICATE, d.viaTarget)] : []),
818
- ],
1056
+ ]],
819
1057
  // same sub-1 discount as cax-dw, same reason (see CAX_DW_RULE_CONFIDENCE).
820
1058
  ...(premiseTrusts.length ? { premiseTrusts, ruleConfidence: CLS_SVF1_RULE_CONFIDENCE } : {}),
821
1059
  };
@@ -833,18 +1071,76 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
833
1071
  return {
834
1072
  subject: d.subject, predicate: SUBCLASS_PREDICATE, object: d.object,
835
1073
  provenance: ENTAILED_SCM_SVF_PROVENANCE,
836
- justification: [
1074
+ justification: [[
837
1075
  ...(r1 ? [factIdForTriple(d.subject, ON_PROPERTY_PREDICATE, r1.property)] : []),
838
1076
  factIdForTriple(d.subject, SOME_VALUES_FROM_PREDICATE, d.viaY1),
839
1077
  ...(r2 ? [factIdForTriple(d.object, ON_PROPERTY_PREDICATE, r2.property)] : []),
840
1078
  factIdForTriple(d.object, SOME_VALUES_FROM_PREDICATE, d.viaY2),
841
1079
  factIdForTriple(d.viaY1, SUBCLASS_PREDICATE, d.viaY2),
842
- ],
1080
+ ]],
843
1081
  // same sub-1 discount as cax-dw/cls-svf1, same reason (see CAX_DW_RULE_CONFIDENCE).
844
1082
  ...(premiseTrusts.length ? { premiseTrusts, ruleConfidence: SCM_SVF_RULE_CONFIDENCE } : {}),
845
1083
  };
846
1084
  }),
847
1085
  ];
1086
+
1087
+ // ---- alternate-environment discovery ----
1088
+ // Enumerate additional premise environments for this pass's conclusions and
1089
+ // for stored purely-entailed facts still under the cap, so retraction can
1090
+ // later keep a multiply-derived fact by set membership instead of a
1091
+ // re-derivation. The examination spends its own copy of the budget number —
1092
+ // it never competes with the derivation budget above.
1093
+ const conclusionCandidates = toWrite.map((w) => ({
1094
+ id: factIdForTriple(w.subject, w.predicate, w.object),
1095
+ subject: w.subject, predicate: w.predicate, object: w.object,
1096
+ environments: w.justification,
1097
+ write: w,
1098
+ }));
1099
+ const enumerateSupport = buildSupportEnumerator(rows.concat(conclusionCandidates.map((c) => ({
1100
+ id: c.id, subject: c.subject, predicate: c.predicate, object: c.object,
1101
+ }))));
1102
+ const rowById = new Map(rows.map((r) => [r.id, r]));
1103
+ const ownedPredicate = (p) => isSubClassOf(p) || isType(p) || isDisjoint(p);
1104
+ // Delta mode examines only stored facts the frontier touches; an alternate
1105
+ // enabled solely by a change outside it (e.g. a new filler type for
1106
+ // cls-svf1) waits for the next full pass — retraction stays correct either
1107
+ // way through its enumerate/boolean fallbacks.
1108
+ const storedCandidateInScope = (r) => mode !== "delta"
1109
+ || (frontier !== null && (frontier.has(r.subject) || frontier.has(r.object)));
1110
+ const storedCandidates = rows
1111
+ .filter((r) => ownedPredicate(r.predicate) && isPurelyEntailed(r.provenance)
1112
+ && environmentsOf(r).length < maxEnvironments && storedCandidateInScope(r))
1113
+ .map((r) => ({
1114
+ id: r.id, subject: r.subject, predicate: r.predicate, object: r.object,
1115
+ environments: environmentsOf(r), provenance: r.provenance,
1116
+ }));
1117
+ const alternateCandidates = [...conclusionCandidates, ...storedCandidates]
1118
+ .sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object));
1119
+ let environmentsAdded = 0;
1120
+ let alternatesTruncated = false;
1121
+ let examined = 0;
1122
+ for (const cand of alternateCandidates) {
1123
+ if (examined >= budget) { alternatesTruncated = true; break; }
1124
+ examined += 1;
1125
+ // Enumerate one PAST the cap so a distinct environment falling to the cap
1126
+ // is visible to the merge below and honestly reported as truncation.
1127
+ const discovered = enumerateSupport(cand, { maxEnvironments: maxEnvironments + 1 });
1128
+ const { kept, truncated: mergeTruncated } = capMergeEnvironments(cand.environments, discovered, maxEnvironments);
1129
+ if (mergeTruncated) alternatesTruncated = true;
1130
+ if (kept.length <= cand.environments.length) continue; // nothing new to record
1131
+ environmentsAdded += kept.length - cand.environments.length;
1132
+ if (cand.write) { cand.write.justification = kept; continue; }
1133
+ // A stored fact gains its newly discovered environments via a minimal
1134
+ // upsert row: provenance omitted (first-write-wins keeps the union), and
1135
+ // the three premise-discounted rules re-state their best environment's
1136
+ // trusts so the entailed hook stays engaged through the trust recompute.
1137
+ toWrite.push({
1138
+ subject: cand.subject, predicate: cand.predicate, object: cand.object,
1139
+ justification: kept,
1140
+ ...(bestEnvironmentTrustOpts(cand.provenance, kept, (pid) => rowById.get(pid)?.trust) || {}),
1141
+ });
1142
+ }
1143
+
848
1144
  const { ids } = await appendFacts(repoDir, toWrite);
849
1145
  const written = [];
850
1146
  let i = 0;
@@ -868,7 +1164,24 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
868
1164
  written.push({ id: ids[i], subject: d.subject, object: d.object, via: d.viaY1, rule: SCM_SVF_RULE });
869
1165
  i += 1;
870
1166
  }
871
- return { derived: written, count: written.length, budget, depth, truncated: written.length >= budget };
1167
+ const truncated = written.length >= budget;
1168
+
1169
+ // The watermark advances ONLY after an unfocused pass whose derivations
1170
+ // ended at a natural fixpoint — a truncated or focused pass has not seen
1171
+ // everything, so its id set must not masquerade as a completed frontier.
1172
+ // alternatesTruncated does not block: alternates change justifications,
1173
+ // never which conclusions exist.
1174
+ if (normalizedFocus === null && stateFnsPresent && !truncated) {
1175
+ const factIds = new Set(currentIdSet);
1176
+ for (const id of ids) factIds.add(id);
1177
+ await store.saveSyllogiseState(repoDir, {
1178
+ version: 1, factIds: [...factIds].sort(), completedAt: new Date().toISOString(),
1179
+ });
1180
+ }
1181
+ return {
1182
+ derived: written, count: written.length, budget, depth, truncated,
1183
+ mode, deltaSize, environmentsAdded, alternatesTruncated,
1184
+ };
872
1185
  }
873
1186
 
874
1187
  /** True when EVERY provenance tag on a fact's (possibly " | "-joined) union is
@@ -968,6 +1281,219 @@ function buildSurvivorDerivabilityCheck(rows) {
968
1281
  };
969
1282
  }
970
1283
 
1284
+ /** A row's persisted environments, upgrading a store whose readFactRows
1285
+ * predates the environment field: a bare justification list reads as one
1286
+ * environment. */
1287
+ const environmentsOf = (row) => row.environments
1288
+ || (Array.isArray(row.justification) && row.justification.length ? [row.justification] : []);
1289
+
1290
+ /** Builds the environment ENUMERATOR: given ONLY the fact rows in `rows`,
1291
+ * returns `(row, { maxEnvironments }) => string[][]` — every premise-id set
1292
+ * (up to the cap) that independently derives the row's (s,p,o) conclusion
1293
+ * under the rule families that own its predicate, in a fixed deterministic
1294
+ * order, each environment citing ids in the same order the write path
1295
+ * cites them. An environment counts only when EVERY cited id resolves to a
1296
+ * row in `rows` and none is the row's own id — stricter than
1297
+ * buildSurvivorDerivabilityCheck's closure walk (a multi-hop ⊑ premise with
1298
+ * no materialised direct edge enumerates nothing), which is why that boolean
1299
+ * check stays the final authority in retraction. Pure, no I/O. */
1300
+ function buildSupportEnumerator(rows) {
1301
+ const storedIds = new Set();
1302
+ const subClassEdges = [];
1303
+ const succ = new Map(); // a -> Set(direct stored superclass)
1304
+ const typesOf = new Map(); // x -> Set(direct stored types)
1305
+ const disjointForward = new Set(); // "a␟b" per stored disjointWith row, as-stored orientation
1306
+ const disjointOf = new Map(); // term -> Set(partners), symmetric
1307
+ const onPropertyOf = new Map(); // restriction -> owl:onProperty's object
1308
+ const someValuesFromOf = new Map(); // restriction -> owl:someValuesFrom's object
1309
+ const propertyEdgesOf = new Map(); // x -> [[rawPredicate, y], …]
1310
+ for (const r of rows) {
1311
+ if (!r || !r.subject || !r.predicate || !r.object) continue;
1312
+ if (r.id) storedIds.add(r.id);
1313
+ const pLower = String(r.predicate || "").trim().toLowerCase();
1314
+ if (isSubClassOf(r.predicate)) {
1315
+ subClassEdges.push([r.subject, r.object]);
1316
+ if (!succ.has(r.subject)) succ.set(r.subject, new Set());
1317
+ succ.get(r.subject).add(r.object);
1318
+ } else if (isType(r.predicate)) {
1319
+ if (!typesOf.has(r.subject)) typesOf.set(r.subject, new Set());
1320
+ typesOf.get(r.subject).add(r.object);
1321
+ } else if (isDisjoint(r.predicate)) {
1322
+ disjointForward.add(`${r.subject}${SEP}${r.object}`);
1323
+ if (!disjointOf.has(r.subject)) disjointOf.set(r.subject, new Set());
1324
+ disjointOf.get(r.subject).add(r.object);
1325
+ if (!disjointOf.has(r.object)) disjointOf.set(r.object, new Set());
1326
+ disjointOf.get(r.object).add(r.subject);
1327
+ } else if (isOnProperty(r.predicate)) onPropertyOf.set(r.subject, r.object);
1328
+ else if (isSomeValuesFrom(r.predicate)) someValuesFromOf.set(r.subject, r.object);
1329
+ else if (!RESERVED_PREDICATES.has(pLower)) {
1330
+ if (!propertyEdgesOf.has(r.subject)) propertyEdgesOf.set(r.subject, []);
1331
+ propertyEdgesOf.get(r.subject).push([r.predicate, r.object]);
1332
+ }
1333
+ }
1334
+ const ancestorsOf = buildAncestorCloser(subClassEdges);
1335
+ const succOf = (a) => succ.get(a) || new Set();
1336
+ const restrictionOf = (node) => {
1337
+ const property = onPropertyOf.get(node);
1338
+ const target = someValuesFromOf.get(node);
1339
+ return property && target ? { property, propertyKey: normFactTerm(property), target } : null;
1340
+ };
1341
+
1342
+ return (row, { maxEnvironments = DEFAULT_MAX_ENVIRONMENTS } = {}) => {
1343
+ const out = [];
1344
+ const seen = new Set();
1345
+ const admit = (env) => {
1346
+ if (out.length >= maxEnvironments) return;
1347
+ if (row.id && env.includes(row.id)) return; // self-support is no support
1348
+ if (!env.every((id) => storedIds.has(id))) return; // a dangling citation makes the whole set inert
1349
+ const key = [...env].sort().join(" ");
1350
+ if (seen.has(key)) return;
1351
+ seen.add(key);
1352
+ out.push(env);
1353
+ };
1354
+
1355
+ if (isSubClassOf(row.predicate)) {
1356
+ // scm-sco: each pivot m with stored direct s⊑m and m⊑o edges.
1357
+ for (const m of [...succOf(row.subject)].sort()) {
1358
+ if (out.length >= maxEnvironments) break;
1359
+ if (m === row.subject || m === row.object) continue;
1360
+ if (!succOf(m).has(row.object)) continue;
1361
+ admit([
1362
+ factIdForTriple(row.subject, SUBCLASS_PREDICATE, m),
1363
+ factIdForTriple(m, SUBCLASS_PREDICATE, row.object),
1364
+ ]);
1365
+ }
1366
+ // scm-svf1: both ends declared restrictions over the SAME property with
1367
+ // a stored direct filler ⊑.
1368
+ if (out.length < maxEnvironments) {
1369
+ const r1 = restrictionOf(row.subject);
1370
+ const r2 = restrictionOf(row.object);
1371
+ if (r1 && r2 && r1.propertyKey === r2.propertyKey && r1.target !== r2.target
1372
+ && succOf(r1.target).has(r2.target)) {
1373
+ admit([
1374
+ factIdForTriple(row.subject, ON_PROPERTY_PREDICATE, r1.property),
1375
+ factIdForTriple(row.subject, SOME_VALUES_FROM_PREDICATE, r1.target),
1376
+ factIdForTriple(row.object, ON_PROPERTY_PREDICATE, r2.property),
1377
+ factIdForTriple(row.object, SOME_VALUES_FROM_PREDICATE, r2.target),
1378
+ factIdForTriple(r1.target, SUBCLASS_PREDICATE, r2.target),
1379
+ ]);
1380
+ }
1381
+ }
1382
+ return out;
1383
+ }
1384
+ if (isType(row.predicate)) {
1385
+ // cax-sco: each stored type c with a stored direct c⊑D edge.
1386
+ for (const c of [...(typesOf.get(row.subject) || [])].sort()) {
1387
+ if (out.length >= maxEnvironments) break;
1388
+ if (c === row.object) continue;
1389
+ if (!succOf(c).has(row.object)) continue;
1390
+ admit([
1391
+ factIdForTriple(row.subject, TYPE_PREDICATE, c),
1392
+ factIdForTriple(c, SUBCLASS_PREDICATE, row.object),
1393
+ ]);
1394
+ }
1395
+ // cls-svf1: D a declared restriction, each stored property edge over its
1396
+ // property whose value's type hits the target directly or by one stored
1397
+ // direct ⊑ edge.
1398
+ const rec = restrictionOf(row.object);
1399
+ if (rec) {
1400
+ const edges = [...(propertyEdgesOf.get(row.subject) || [])]
1401
+ .filter(([p]) => normFactTerm(p) === rec.propertyKey)
1402
+ .sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
1403
+ for (const [p, y] of edges) {
1404
+ if (out.length >= maxEnvironments) break;
1405
+ for (const c of [...(typesOf.get(y) || [])].sort()) {
1406
+ if (out.length >= maxEnvironments) break;
1407
+ if (c !== rec.target && !succOf(c).has(rec.target)) continue;
1408
+ admit([
1409
+ factIdForTriple(row.subject, p, y),
1410
+ factIdForTriple(y, TYPE_PREDICATE, c),
1411
+ factIdForTriple(row.object, ON_PROPERTY_PREDICATE, rec.propertyKey),
1412
+ factIdForTriple(row.object, SOME_VALUES_FROM_PREDICATE, rec.target),
1413
+ ...(c !== rec.target ? [factIdForTriple(c, SUBCLASS_PREDICATE, rec.target)] : []),
1414
+ ]);
1415
+ }
1416
+ }
1417
+ }
1418
+ return out;
1419
+ }
1420
+ if (isDisjoint(row.predicate)) {
1421
+ // cax-dw: each stored type c and lift class d whose stored disjoint row
1422
+ // reaches the conclusion's object (orientation as stored).
1423
+ const pairs = [];
1424
+ for (const c of typesOf.get(row.subject) || []) {
1425
+ for (const d of [c, ...ancestorsOf(c)]) {
1426
+ if ((disjointOf.get(d) || new Set()).has(row.object)) pairs.push([c, d]);
1427
+ }
1428
+ }
1429
+ pairs.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
1430
+ for (const [c, d] of pairs) {
1431
+ if (out.length >= maxEnvironments) break;
1432
+ const dwStoredForward = disjointForward.has(`${d}${SEP}${row.object}`);
1433
+ const [dwS, dwO] = dwStoredForward ? [d, row.object] : [row.object, d];
1434
+ admit([
1435
+ factIdForTriple(row.subject, TYPE_PREDICATE, c),
1436
+ factIdForTriple(dwS, DISJOINT_PREDICATE, dwO),
1437
+ ...(d !== c ? [factIdForTriple(c, SUBCLASS_PREDICATE, d)] : []),
1438
+ ]);
1439
+ }
1440
+ return out;
1441
+ }
1442
+ return out; // a predicate no rule family owns enumerates nothing
1443
+ };
1444
+ }
1445
+
1446
+ /** Merge already-stored environments with newly discovered ones under the
1447
+ * cap: stored first (their order preserved), then discoveries in enumeration
1448
+ * order, deduped by canonical key, truncated at `cap`. Returns
1449
+ * { kept, truncated } — truncated true when a distinct environment was
1450
+ * dropped for the cap. */
1451
+ function capMergeEnvironments(storedEnvs, discoveredEnvs, cap) {
1452
+ const kept = [];
1453
+ const seen = new Set();
1454
+ let truncated = false;
1455
+ for (const env of [...(storedEnvs || []), ...(discoveredEnvs || [])]) {
1456
+ if (!Array.isArray(env) || !env.length) continue;
1457
+ const key = [...env].sort().join(" ");
1458
+ if (seen.has(key)) continue;
1459
+ if (kept.length >= cap) { truncated = true; continue; }
1460
+ seen.add(key);
1461
+ kept.push(env);
1462
+ }
1463
+ return { kept, truncated };
1464
+ }
1465
+
1466
+ const ENTAILED_RULE_CONFIDENCE_BY_TAG = new Map([
1467
+ [ENTAILED_DISJOINT_PROVENANCE, CAX_DW_RULE_CONFIDENCE],
1468
+ [ENTAILED_SVF1_PROVENANCE, CLS_SVF1_RULE_CONFIDENCE],
1469
+ [ENTAILED_SCM_SVF_PROVENANCE, SCM_SVF_RULE_CONFIDENCE],
1470
+ ]);
1471
+
1472
+ /** The entailed-hook opts for re-stating a conclusion of one of the three
1473
+ * premise-discounted rules: the BEST environment's premise trusts (max over
1474
+ * environments of min(premise trusts); tie → the earlier environment) plus
1475
+ * the rule's confidence — without this, recomputeFactTrust would silently
1476
+ * reset a discounted conclusion to the bare entailed prior. Null for
1477
+ * scm-sco/cax-sco conclusions (which ride the bare prior by design) and when
1478
+ * no environment's premises all resolve to a numeric trust. */
1479
+ function bestEnvironmentTrustOpts(provenance, environments, trustOfId) {
1480
+ let ruleConfidence;
1481
+ for (const tag of String(provenance || "").split(" | ")) {
1482
+ const rc = ENTAILED_RULE_CONFIDENCE_BY_TAG.get(tag);
1483
+ if (rc !== undefined) { ruleConfidence = rc; break; }
1484
+ }
1485
+ if (ruleConfidence === undefined) return null;
1486
+ let best = null;
1487
+ let bestMin = -1;
1488
+ for (const env of environments || []) {
1489
+ const trusts = env.map((id) => trustOfId(id)).filter((t) => typeof t === "number");
1490
+ if (trusts.length !== env.length) continue; // a premise with no resolvable trust can't price the environment
1491
+ const weakest = Math.min(...trusts);
1492
+ if (weakest > bestMin) { bestMin = weakest; best = trusts; }
1493
+ }
1494
+ return best ? { premiseTrusts: best, ruleConfidence } : null;
1495
+ }
1496
+
971
1497
  /**
972
1498
  * A scoped retraction slice: DRed (delete-and-rederive; Gupta, Mumick &
973
1499
  * Subrahmanian, SIGMOD 1993), NOT JTMS. It recomputes the MATERIALISATION —
@@ -989,16 +1515,26 @@ function buildSurvivorDerivabilityCheck(rows) {
989
1515
  * justifications into every rule's conclusions (transitive ⊑, propagated
990
1516
  * types, disjointness violations, restriction membership and subsumption).
991
1517
  *
992
- * A survivor keeps its stale, still-single justification as-is; a later
993
- * retraction of its OTHER supporting path therefore won't re-examine it.
994
- * Re-grounding survivors or tracking every alternate justification set
995
- * is the ATMS horizon, not this bounded slice.
1518
+ * Each candidate is checked in three steps, cheapest first: (1) FAST PATH —
1519
+ * any stored environment untouched by the cascade and fully backed by
1520
+ * surviving rows keeps the fact by set membership alone; (2) ENUMERATE a
1521
+ * fresh premise environment found among the survivors re-grounds it; (3) the
1522
+ * BOOLEAN BACKSTOP — the closure-walking derivability check, which sees
1523
+ * multi-hop support the enumerator's stored-direct-edge discipline cannot
1524
+ * cite, stays the final authority. A survivor whose environments changed is
1525
+ * RE-GROUNDED after the removal (its pruned or fresh environments written
1526
+ * back) when the store carries `appendFacts` — an OPTIONAL seam member:
1527
+ * without it removal is still correct, the survivor's environments just stay
1528
+ * stale until the next syllogise pass.
996
1529
  *
997
1530
  * Returns { retracted, count, budget, depth, truncated, found } — `found` is
998
1531
  * false when `subject ⊑ object` was never a stored fact.
999
1532
  */
1000
- export async function retractSubClassOf(repoDir, subject, object, { budget = 50, depth = 32, store } = {}) {
1533
+ export async function retractSubClassOf(repoDir, subject, object, {
1534
+ budget = 50, depth = 32, maxEnvironments = DEFAULT_MAX_ENVIRONMENTS, store,
1535
+ } = {}) {
1001
1536
  const { loadMemory, readFactRows, removeFacts } = requireStore(store, ["loadMemory", "readFactRows", "removeFacts"], "retractSubClassOf");
1537
+ const appendFactsFn = typeof store?.appendFacts === "function" ? store.appendFacts : null;
1002
1538
  const s = normFactTerm(subject);
1003
1539
  const o = normFactTerm(object);
1004
1540
  const targetId = factIdForTriple(s, SUBCLASS_PREDICATE, o);
@@ -1009,50 +1545,105 @@ export async function retractSubClassOf(repoDir, subject, object, { budget = 50,
1009
1545
 
1010
1546
  // Only a purely-entailed fact ever carries a walkable justification —
1011
1547
  // a fact later independently taught is never a cascade candidate at all.
1012
- const entailedRows = rows.filter((r) => r.justification.length && isPurelyEntailed(r.provenance));
1548
+ const entailedRows = rows.filter((r) => environmentsOf(r).length && isPurelyEntailed(r.provenance));
1549
+ // premise id -> the entailed fact ids whose environments cite it. Built
1550
+ // ONCE; each round's candidate set reads it for the facts the newest
1551
+ // removals could actually touch — backward relevance from the same
1552
+ // structure a forward pass reads forward.
1553
+ const citedBy = new Map();
1554
+ for (const r of entailedRows) {
1555
+ for (const env of environmentsOf(r)) {
1556
+ for (const premiseId of env) {
1557
+ if (!citedBy.has(premiseId)) citedBy.set(premiseId, new Set());
1558
+ citedBy.get(premiseId).add(r.id);
1559
+ }
1560
+ }
1561
+ }
1013
1562
 
1014
1563
  const removed = new Set([targetId]);
1015
1564
  const order = [targetId]; // deterministic report order: target first, then removal order
1565
+ const reground = new Map(); // survivor fact id -> the environments to persist for it
1016
1566
  let truncated = false;
1017
1567
  let round = 0;
1568
+ let newlyRemoved = [targetId];
1018
1569
  for (; round < depth; round += 1) {
1019
- const candidates = entailedRows
1020
- .filter((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)))
1570
+ const candidateIds = new Set();
1571
+ for (const id of newlyRemoved) {
1572
+ for (const cited of citedBy.get(id) || []) {
1573
+ if (!removed.has(cited)) candidateIds.add(cited);
1574
+ }
1575
+ }
1576
+ const candidates = [...candidateIds].map((id) => byId.get(id))
1021
1577
  .sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object));
1022
- if (!candidates.length) break; // fixpoint — nothing left to (re-)check
1578
+ if (!candidates.length) break; // fixpoint — nothing cites what just fell
1023
1579
 
1024
- // The surviving fact set for THIS round's verify walk excludes every
1025
- // candidate's own row too, not just `removed` — otherwise a candidate
1026
- // could trivially "reach itself" through its own not-yet-deleted edge, or
1027
- // lean on a sibling candidate standing on the same broken premise.
1028
- const candidateIds = new Set(candidates.map((c) => c.id));
1029
- const stillDerivable = buildSurvivorDerivabilityCheck(
1030
- rows.filter((r) => !removed.has(r.id) && !candidateIds.has(r.id)),
1031
- );
1580
+ // The surviving fact set for THIS round excludes every candidate's own
1581
+ // row too, not just `removed` — otherwise a candidate could trivially
1582
+ // "reach itself" through its own not-yet-deleted edge, or lean on a
1583
+ // sibling candidate standing on the same broken premise.
1584
+ const survivors = rows.filter((r) => !removed.has(r.id) && !candidateIds.has(r.id));
1585
+ const survivorIds = new Set(survivors.map((r) => r.id));
1586
+ const enumerateSupport = buildSupportEnumerator(survivors);
1587
+ const stillDerivable = buildSurvivorDerivabilityCheck(survivors);
1032
1588
 
1033
1589
  let progressed = false;
1034
1590
  let hitBudget = false;
1591
+ newlyRemoved = [];
1035
1592
  for (const c of candidates) {
1036
1593
  if (removed.size >= budget) { hitBudget = true; break; }
1037
- // does the conclusion still hold WITHOUT the retracted premise, via ANY
1038
- // surviving derivation (not just the one this fact was originally
1039
- // derived through)? A survivor keeps its (now possibly re-groundable,
1040
- // still TRUE) fact and is never re-examined again this call.
1041
- if (stillDerivable(c)) continue; // a second, independent derivation still supports it — keep
1594
+ // FAST PATH: an environment whose every premise still stands keeps the
1595
+ // fact pure set membership, no re-derivation. When some environments
1596
+ // broke, queue the pruned set so the next retraction still sees the
1597
+ // survivor (the stale-justification fix).
1598
+ const environments = environmentsOf(c);
1599
+ const intact = environments.filter((env) => env.every((id) => survivorIds.has(id)));
1600
+ if (intact.length) {
1601
+ if (intact.length !== environments.length) reground.set(c.id, intact);
1602
+ continue;
1603
+ }
1604
+ // ENUMERATE: a fresh premise environment among the survivors re-grounds
1605
+ // the fact under new citations.
1606
+ const fresh = enumerateSupport(c, { maxEnvironments });
1607
+ if (fresh.length) {
1608
+ reground.set(c.id, fresh);
1609
+ continue;
1610
+ }
1611
+ // BOOLEAN BACKSTOP: the closure walk is the final authority — it sees
1612
+ // multi-hop support with no materialised direct edge to cite, so a
1613
+ // still-derivable fact is never removed on a stale citation alone (its
1614
+ // environments stay as they were).
1615
+ if (stillDerivable(c)) continue;
1042
1616
  removed.add(c.id);
1043
1617
  order.push(c.id);
1618
+ newlyRemoved.push(c.id);
1044
1619
  progressed = true;
1045
1620
  }
1046
1621
  if (hitBudget) { truncated = true; break; }
1047
- if (!progressed) break; // every candidate this round survived verification — fixpoint
1622
+ if (!progressed) break; // every candidate this round survived — fixpoint
1048
1623
  }
1049
1624
  if (!truncated && round >= depth) {
1050
- // depth exhausted, not a natural fixpoint — honestly flag it if a
1051
- // pending candidate would still have been checked next round.
1625
+ // depth exhausted, not a natural fixpoint — honestly flag it if a pending
1626
+ // candidate (any surviving fact whose environment union still cites a
1627
+ // removed id) would have been checked next round.
1052
1628
  truncated = entailedRows.some((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)));
1053
1629
  }
1054
1630
 
1055
1631
  const { removed: actuallyRemoved } = await removeFacts(repoDir, order);
1632
+ if (appendFactsFn) {
1633
+ const regroundWrites = [...reground.entries()]
1634
+ .filter(([id]) => !removed.has(id))
1635
+ .sort((a, b) => a[0].localeCompare(b[0]))
1636
+ .map(([id, environments]) => {
1637
+ const row = byId.get(id);
1638
+ return {
1639
+ subject: row.subject, predicate: row.predicate, object: row.object,
1640
+ // provenance omitted — appendFacts' first-write-wins keeps the union
1641
+ justification: environments,
1642
+ ...(bestEnvironmentTrustOpts(row.provenance, environments, (pid) => byId.get(pid)?.trust) || {}),
1643
+ };
1644
+ });
1645
+ if (regroundWrites.length) await appendFactsFn(repoDir, regroundWrites);
1646
+ }
1056
1647
  return { retracted: actuallyRemoved, count: actuallyRemoved.length, budget, depth, truncated, found: true };
1057
1648
  }
1058
1649