eyeling 1.16.2 → 1.16.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.
package/lib/engine.js CHANGED
@@ -30,7 +30,7 @@ const {
30
30
  // In N3/Turtle, rdf:nil is the canonical IRI for the empty RDF list.
31
31
  // Eyeling represents list literals with ListTerm; ensure rdf:nil unifies with ().
32
32
  const RDF_NIL_IRI = RDF_NS + 'nil';
33
- const __EMPTY_LIST = new ListTerm([]);
33
+ const EMPTY_LIST_TERM = new ListTerm([]);
34
34
 
35
35
  const { lex, N3SyntaxError } = require('./lexer');
36
36
  const { Parser } = require('./parser');
@@ -72,13 +72,13 @@ let version = 'dev';
72
72
  try {
73
73
  // Node: keep package.json version if available
74
74
  if (typeof require === 'function') version = require('./package.json').version || version;
75
- } catch (_) {}
75
+ } catch {}
76
76
 
77
77
  let nodeCrypto = null;
78
78
  try {
79
79
  // Node: crypto available
80
80
  if (typeof require === 'function') nodeCrypto = require('crypto');
81
- } catch (_) {}
81
+ } catch {}
82
82
  // For a single reasoning run, this maps a canonical representation
83
83
  // of the subject term in log:skolem to a Skolem IRI.
84
84
  const skolemCache = new Map();
@@ -90,10 +90,10 @@ const skolemCache = new Map();
90
90
  // - Across reasoning runs (default): same subject -> different Skolem IRI.
91
91
  // - Optional legacy mode: stable across runs (CLI: --deterministic-skolem).
92
92
  let deterministicSkolemAcrossRuns = false;
93
- let __skolemRunDepth = 0;
94
- let __skolemRunSalt = null;
93
+ let skolemRunDepth = 0;
94
+ let skolemRunSalt = null;
95
95
 
96
- function __makeSkolemRunSalt() {
96
+ function makeSkolemRunSalt() {
97
97
  // Prefer WebCrypto if present (browser/worker)
98
98
  try {
99
99
  const g = typeof globalThis !== 'undefined' ? globalThis : null;
@@ -107,7 +107,7 @@ function __makeSkolemRunSalt() {
107
107
  .join('');
108
108
  }
109
109
  }
110
- } catch (_) {}
110
+ } catch {}
111
111
 
112
112
  // Node.js crypto
113
113
  try {
@@ -115,7 +115,7 @@ function __makeSkolemRunSalt() {
115
115
  if (typeof nodeCrypto.randomUUID === 'function') return nodeCrypto.randomUUID();
116
116
  if (typeof nodeCrypto.randomBytes === 'function') return nodeCrypto.randomBytes(16).toString('hex');
117
117
  }
118
- } catch (_) {}
118
+ } catch {}
119
119
 
120
120
  // Last-resort fallback (not cryptographically strong)
121
121
  return (
@@ -123,30 +123,30 @@ function __makeSkolemRunSalt() {
123
123
  );
124
124
  }
125
125
 
126
- function __enterReasoningRun() {
127
- __skolemRunDepth += 1;
128
- if (__skolemRunDepth === 1) {
126
+ function enterReasoningRun() {
127
+ skolemRunDepth += 1;
128
+ if (skolemRunDepth === 1) {
129
129
  skolemCache.clear();
130
- __skolemRunSalt = deterministicSkolemAcrossRuns ? '' : __makeSkolemRunSalt();
130
+ skolemRunSalt = deterministicSkolemAcrossRuns ? '' : makeSkolemRunSalt();
131
131
  }
132
132
  }
133
133
 
134
- function __exitReasoningRun() {
135
- if (__skolemRunDepth > 0) __skolemRunDepth -= 1;
136
- if (__skolemRunDepth === 0) {
134
+ function exitReasoningRun() {
135
+ if (skolemRunDepth > 0) skolemRunDepth -= 1;
136
+ if (skolemRunDepth === 0) {
137
137
  // Clear the salt so a future top-level run gets a fresh one (default mode).
138
- __skolemRunSalt = null;
138
+ skolemRunSalt = null;
139
139
  }
140
140
  }
141
141
 
142
- function __skolemIdForKey(key) {
142
+ function skolemIdForKey(key) {
143
143
  if (deterministicSkolemAcrossRuns) return deterministicSkolemIdFromKey(key);
144
144
  // Ensure we have a run salt even if log:skolem is invoked outside forwardChain().
145
- if (__skolemRunSalt === null) {
145
+ if (skolemRunSalt === null) {
146
146
  skolemCache.clear();
147
- __skolemRunSalt = __makeSkolemRunSalt();
147
+ skolemRunSalt = makeSkolemRunSalt();
148
148
  }
149
- return deterministicSkolemIdFromKey(__skolemRunSalt + '|' + key);
149
+ return deterministicSkolemIdFromKey(skolemRunSalt + '|' + key);
150
150
  }
151
151
 
152
152
  function getDeterministicSkolemEnabled() {
@@ -156,8 +156,8 @@ function getDeterministicSkolemEnabled() {
156
156
  function setDeterministicSkolemEnabled(v) {
157
157
  deterministicSkolemAcrossRuns = !!v;
158
158
  // Reset per-run state so the new mode takes effect immediately for the next run.
159
- if (__skolemRunDepth === 0) {
160
- __skolemRunSalt = null;
159
+ if (skolemRunDepth === 0) {
160
+ skolemRunSalt = null;
161
161
  skolemCache.clear();
162
162
  }
163
163
  }
@@ -243,6 +243,7 @@ function __computeHeadIsStrictGround(r) {
243
243
  if (r.isFuse) return false;
244
244
  // Dynamic heads depend on runtime bindings; treat as non-ground.
245
245
  if (r.__dynamicConclusionTerm) return false;
246
+ if (r.__fromRulePromotion) return false;
246
247
  if (r.headBlankLabels && r.headBlankLabels.size) return false;
247
248
  for (const tr of r.conclusion) if (!__isStrictGroundTriple(tr)) return false;
248
249
  return true;
@@ -1118,10 +1119,13 @@ function candidateFacts(facts, goal) {
1118
1119
  else if (wildPO) wild = wildPO;
1119
1120
  else wild = facts.__wildPred.length ? facts.__wildPred : null;
1120
1121
 
1121
- if (exact && wild) return exact.concat(wild);
1122
- if (exact) return exact;
1123
- if (wild) return wild;
1124
- return [];
1122
+ return {
1123
+ exact: exact || null,
1124
+ wild: wild || null,
1125
+ exactLen: exact ? exact.length : 0,
1126
+ wildLen: wild ? wild.length : 0,
1127
+ totalLen: (exact ? exact.length : 0) + (wild ? wild.length : 0),
1128
+ };
1125
1129
  }
1126
1130
 
1127
1131
  return null;
@@ -1164,6 +1168,11 @@ function pushFactIndexed(facts, tr) {
1164
1168
  indexFact(facts, tr, idx);
1165
1169
  }
1166
1170
 
1171
+ function makeDerivedRecord(fact, rule, premises, subst, captureExplanations) {
1172
+ if (captureExplanations === false) return { fact };
1173
+ return new DerivedFact(fact, rule, premises.slice(), { ...subst });
1174
+ }
1175
+
1167
1176
  function ensureBackRuleIndexes(backRules) {
1168
1177
  if (backRules.__byHeadPred && backRules.__wildHeadPred) return;
1169
1178
 
@@ -1197,6 +1206,164 @@ function indexBackRule(backRules, r) {
1197
1206
  }
1198
1207
  }
1199
1208
 
1209
+ function isSinglePremiseAgendaRuleSafe(r, backRules) {
1210
+ if (!r || r.isFuse || !Array.isArray(r.premise) || r.premise.length !== 1) return false;
1211
+
1212
+ // Keep agenda firing restricted to rules whose observable output order is
1213
+ // already stable in the legacy engine. Dynamic heads and head-blank
1214
+ // skolemization are deliberately left on the old path so example outputs keep
1215
+ // the same derived blank labels and rule-promotion behavior.
1216
+ if (r.__dynamicConclusionTerm) return false;
1217
+ if (r.__fromRulePromotion) return false;
1218
+ if (r.headBlankLabels && r.headBlankLabels.size) return false;
1219
+
1220
+ const goal = r.premise[0];
1221
+
1222
+ // Builtin-only bodies need the normal proveGoals path because they can
1223
+ // succeed without matching an extensional fact and may depend on scoped state.
1224
+ if (isBuiltinPred(goal.p)) return false;
1225
+
1226
+ // Safe only when the sole premise cannot be satisfied via backward rules.
1227
+ // Otherwise matching just against newly-seen facts would be incomplete.
1228
+ ensureBackRuleIndexes(backRules);
1229
+ if (goal.p instanceof Iri) {
1230
+ if ((backRules.__byHeadPred.get(goal.p.__tid) || []).length) return false;
1231
+ if (backRules.__wildHeadPred.length) return false;
1232
+ return true;
1233
+ }
1234
+
1235
+ return backRules.__wildHeadPred.length === 0;
1236
+ }
1237
+
1238
+ function mergeSinglePremiseAgendaBuckets() {
1239
+ let out = null;
1240
+ let seen = null;
1241
+
1242
+ for (let i = 0; i < arguments.length; i++) {
1243
+ const bucket = arguments[i];
1244
+ if (!bucket || bucket.length === 0) continue;
1245
+
1246
+ if (out === null) {
1247
+ out = bucket.length === 1 ? [bucket[0]] : bucket.slice();
1248
+ if (bucket.length > 1) seen = new Set(out);
1249
+ continue;
1250
+ }
1251
+
1252
+ if (!seen) seen = new Set(out);
1253
+ for (let j = 0; j < bucket.length; j++) {
1254
+ const entry = bucket[j];
1255
+ if (seen.has(entry)) continue;
1256
+ seen.add(entry);
1257
+ out.push(entry);
1258
+ }
1259
+ }
1260
+
1261
+ return out;
1262
+ }
1263
+
1264
+ function makeSinglePremiseAgendaIndex(forwardRules, backRules) {
1265
+ const index = {
1266
+ byPred: new Map(),
1267
+ byPS: new Map(),
1268
+ byPO: new Map(),
1269
+ wildPred: [],
1270
+ wildPS: new Map(),
1271
+ wildPO: new Map(),
1272
+ indexed: new Set(),
1273
+ size: 0,
1274
+ };
1275
+
1276
+ function addToMapArray(m, k, v) {
1277
+ let bucket = m.get(k);
1278
+ if (!bucket) {
1279
+ bucket = [];
1280
+ m.set(k, bucket);
1281
+ }
1282
+ bucket.push(v);
1283
+ }
1284
+
1285
+ for (let i = 0; i < forwardRules.length; i++) {
1286
+ const r = forwardRules[i];
1287
+ if (!isSinglePremiseAgendaRuleSafe(r, backRules)) continue;
1288
+
1289
+ const goal = r.premise[0];
1290
+ const entry = {
1291
+ rule: r,
1292
+ ruleIndex: i,
1293
+ goal,
1294
+ goalPredTid: goal.p instanceof Iri ? goal.p.__tid : null,
1295
+ goalSKey: termFastKey(goal.s),
1296
+ goalOKey: termFastKey(goal.o),
1297
+ };
1298
+
1299
+ index.indexed.add(r);
1300
+ index.size += 1;
1301
+
1302
+ if (entry.goalPredTid !== null) {
1303
+ if (entry.goalSKey === null && entry.goalOKey === null) addToMapArray(index.byPred, entry.goalPredTid, entry);
1304
+ if (entry.goalSKey !== null) {
1305
+ let ps = index.byPS.get(entry.goalPredTid);
1306
+ if (!ps) {
1307
+ ps = new Map();
1308
+ index.byPS.set(entry.goalPredTid, ps);
1309
+ }
1310
+ addToMapArray(ps, entry.goalSKey, entry);
1311
+ }
1312
+ if (entry.goalOKey !== null) {
1313
+ let po = index.byPO.get(entry.goalPredTid);
1314
+ if (!po) {
1315
+ po = new Map();
1316
+ index.byPO.set(entry.goalPredTid, po);
1317
+ }
1318
+ addToMapArray(po, entry.goalOKey, entry);
1319
+ }
1320
+ } else {
1321
+ if (entry.goalSKey === null && entry.goalOKey === null) index.wildPred.push(entry);
1322
+ if (entry.goalSKey !== null) addToMapArray(index.wildPS, entry.goalSKey, entry);
1323
+ if (entry.goalOKey !== null) addToMapArray(index.wildPO, entry.goalOKey, entry);
1324
+ }
1325
+ }
1326
+
1327
+ return index;
1328
+ }
1329
+
1330
+ function getSinglePremiseAgendaCandidates(index, fact) {
1331
+ if (!index || index.size === 0) return null;
1332
+
1333
+ const sk = termFastKey(fact.s);
1334
+ const ok = termFastKey(fact.o);
1335
+
1336
+ let exact = null;
1337
+ if (fact.p instanceof Iri) {
1338
+ const pk = fact.p.__tid;
1339
+ const byPred = index.byPred.get(pk) || null;
1340
+ let byPS = null;
1341
+ if (sk !== null) {
1342
+ const ps = index.byPS.get(pk);
1343
+ if (ps) byPS = ps.get(sk) || null;
1344
+ }
1345
+ let byPO = null;
1346
+ if (ok !== null) {
1347
+ const po = index.byPO.get(pk);
1348
+ if (po) byPO = po.get(ok) || null;
1349
+ }
1350
+
1351
+ exact = mergeSinglePremiseAgendaBuckets(byPred, byPS, byPO);
1352
+ }
1353
+
1354
+ const wildPred = index.wildPred.length ? index.wildPred : null;
1355
+ let wildPS = null;
1356
+ if (sk !== null) wildPS = index.wildPS.get(sk) || null;
1357
+
1358
+ let wildPO = null;
1359
+ if (ok !== null) wildPO = index.wildPO.get(ok) || null;
1360
+
1361
+ const wild = mergeSinglePremiseAgendaBuckets(wildPred, wildPS, wildPO);
1362
+
1363
+ if (!exact && !wild) return null;
1364
+ return { exact, wild, exactLen: exact ? exact.length : 0, wildLen: wild ? wild.length : 0 };
1365
+ }
1366
+
1200
1367
  // ===========================================================================
1201
1368
  // Special predicate helpers
1202
1369
  // ===========================================================================
@@ -1222,7 +1389,7 @@ function isLogImpliedBy(p) {
1222
1389
  // So this improves reuse across repeated backward proofs without changing the
1223
1390
  // semantics of recursive goals.
1224
1391
 
1225
- function __goalTableScopeVersion(facts, backRules) {
1392
+ function goalTableScopeVersion(facts, backRules) {
1226
1393
  const factCount = Array.isArray(facts) ? facts.length : 0;
1227
1394
  const backRuleCount = Array.isArray(backRules) ? backRules.length : 0;
1228
1395
  const scopedLevel = facts && typeof facts.__scopedClosureLevel === 'number' ? facts.__scopedClosureLevel : 0;
@@ -1239,26 +1406,26 @@ function __makeGoalTable() {
1239
1406
 
1240
1407
  function __attachGoalTable(scopeCarrier, goalTable) {
1241
1408
  if (!scopeCarrier) return goalTable;
1242
- if (!hasOwn.call(scopeCarrier, '__goalTable')) {
1243
- Object.defineProperty(scopeCarrier, '__goalTable', {
1409
+ if (!hasOwn.call(scopeCarrier, 'goalTable')) {
1410
+ Object.defineProperty(scopeCarrier, 'goalTable', {
1244
1411
  value: goalTable,
1245
1412
  enumerable: false,
1246
1413
  writable: true,
1247
1414
  configurable: true,
1248
1415
  });
1249
1416
  } else {
1250
- scopeCarrier.__goalTable = goalTable;
1417
+ scopeCarrier.goalTable = goalTable;
1251
1418
  }
1252
1419
  return goalTable;
1253
1420
  }
1254
1421
 
1255
1422
  function __ensureGoalTable(facts, backRules) {
1256
- let table = (facts && facts.__goalTable) || (backRules && backRules.__goalTable) || null;
1423
+ let table = (facts && facts.goalTable) || (backRules && backRules.goalTable) || null;
1257
1424
  if (!table) table = __makeGoalTable();
1258
1425
  __attachGoalTable(facts, table);
1259
1426
  __attachGoalTable(backRules, table);
1260
1427
 
1261
- const version = __goalTableScopeVersion(facts, backRules);
1428
+ const version = goalTableScopeVersion(facts, backRules);
1262
1429
  if (table.scopeVersion !== version) {
1263
1430
  table.scopeVersion = version;
1264
1431
  table.entries.clear();
@@ -1304,6 +1471,7 @@ function __canStoreGoalMemo(visited, maxResults) {
1304
1471
  // ===========================================================================
1305
1472
 
1306
1473
  function containsVarTerm(t, v) {
1474
+ if (t instanceof Iri || t instanceof Literal || t instanceof Blank) return false;
1307
1475
  if (t instanceof Var) return t.name === v;
1308
1476
  if (t instanceof ListTerm) return t.elems.some((e) => containsVarTerm(e, v));
1309
1477
  if (t instanceof OpenListTerm) return t.prefix.some((e) => containsVarTerm(e, v)) || t.tailVar === v;
@@ -1327,6 +1495,7 @@ function isGroundTripleInGraph(tr) {
1327
1495
  }
1328
1496
 
1329
1497
  function isGroundTerm(t) {
1498
+ if (t instanceof Iri || t instanceof Literal || t instanceof Blank) return true;
1330
1499
  if (t instanceof Var) return false;
1331
1500
  if (t instanceof ListTerm) return t.elems.every((e) => isGroundTerm(e));
1332
1501
  if (t instanceof OpenListTerm) return false;
@@ -1360,7 +1529,7 @@ function skolemIriFromGroundTerm(t) {
1360
1529
  const key = skolemKeyFromTerm(t);
1361
1530
  let iri = skolemCache.get(key);
1362
1531
  if (!iri) {
1363
- const id = __skolemIdForKey(key);
1532
+ const id = skolemIdForKey(key);
1364
1533
  iri = internIri(SKOLEM_NS + id);
1365
1534
  skolemCache.set(key, iri);
1366
1535
  }
@@ -1368,6 +1537,9 @@ function skolemIriFromGroundTerm(t) {
1368
1537
  }
1369
1538
 
1370
1539
  function applySubstTerm(t, s) {
1540
+ // Hot fast path: most terms are already-ground atomic terms.
1541
+ if (t instanceof Iri || t instanceof Literal || t instanceof Blank) return t;
1542
+
1371
1543
  // Common case: variable
1372
1544
  if (t instanceof Var) {
1373
1545
  const first = s[t.name];
@@ -1562,8 +1734,8 @@ function unifyTermWithOptions(a, b, subst, opts) {
1562
1734
 
1563
1735
  // Normalize rdf:nil IRI to the empty list term, so it unifies with () and
1564
1736
  // list builtins treat it consistently.
1565
- if (a instanceof Iri && a.value === RDF_NIL_IRI) a = __EMPTY_LIST;
1566
- if (b instanceof Iri && b.value === RDF_NIL_IRI) b = __EMPTY_LIST;
1737
+ if (a instanceof Iri && a.value === RDF_NIL_IRI) a = EMPTY_LIST_TERM;
1738
+ if (b instanceof Iri && b.value === RDF_NIL_IRI) b = EMPTY_LIST_TERM;
1567
1739
 
1568
1740
  // Variable binding
1569
1741
  if (a instanceof Var) {
@@ -1793,10 +1965,10 @@ function __builtinIsSatisfiableWhenFullyUnbound(pIriVal) {
1793
1965
  }
1794
1966
 
1795
1967
  function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxResults, opts) {
1796
- const __goalTable = __canLookupGoalMemo(visited) ? __ensureGoalTable(facts, backRules) : null;
1797
- const __goalMemoKeyNow = __goalTable ? __goalMemoKey(goals, subst, facts, opts) : null;
1798
- if (__goalTable && __goalTable.entries.has(__goalMemoKeyNow)) {
1799
- const cached = __goalTable.entries.get(__goalMemoKeyNow) || [];
1968
+ const goalTable = __canLookupGoalMemo(visited) ? __ensureGoalTable(facts, backRules) : null;
1969
+ const goalMemoKeyNow = goalTable ? __goalMemoKey(goals, subst, facts, opts) : null;
1970
+ if (goalTable && goalTable.entries.has(goalMemoKeyNow)) {
1971
+ const cached = goalTable.entries.get(goalMemoKeyNow) || [];
1800
1972
  const cloned = __cloneGoalSolutions(cached);
1801
1973
  if (typeof maxResults === 'number' && maxResults > 0 && cloned.length > maxResults)
1802
1974
  return cloned.slice(0, maxResults);
@@ -1815,7 +1987,7 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1815
1987
 
1816
1988
  // IMPORTANT: Goal reordering / deferral is only enabled when explicitly
1817
1989
  // requested by the caller (used for forward rules).
1818
- const __allowDeferBuiltins = !!(opts && opts.deferBuiltins);
1990
+ const allowDeferredBuiltins = !!(opts && opts.deferBuiltins);
1819
1991
 
1820
1992
  const initialGoals = Array.isArray(goals) ? goals.slice() : [];
1821
1993
  const substMut = subst ? { ...subst } : {};
@@ -1830,8 +2002,8 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1830
2002
 
1831
2003
  if (!initialGoals.length) {
1832
2004
  results.push(gcCompactForGoals(substMut, [], answerVars));
1833
- if (__goalTable && __canStoreGoalMemo(visited, maxResults)) {
1834
- __goalTable.entries.set(__goalMemoKeyNow, __cloneGoalSolutions(results));
2005
+ if (goalTable && __canStoreGoalMemo(visited, maxResults)) {
2006
+ goalTable.entries.set(goalMemoKeyNow, __cloneGoalSolutions(results));
1835
2007
  }
1836
2008
  return results;
1837
2009
  }
@@ -1871,14 +2043,14 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1871
2043
  const visitedCounts = new Map(); // key -> count
1872
2044
  const visitedTrail = []; // stack of keys in insertion order
1873
2045
 
1874
- const __termKeyCache = typeof WeakMap === 'function' ? new WeakMap() : null;
2046
+ const termKeyCache = typeof WeakMap === 'function' ? new WeakMap() : null;
1875
2047
 
1876
- function __termKeyForVisited(t) {
2048
+ function termKeyForVisited(t) {
1877
2049
  if (t instanceof Iri && t.value === RDF_NIL_IRI) return '()';
1878
2050
  if (t instanceof ListTerm && t.elems.length === 0) return '()';
1879
2051
 
1880
- if (__termKeyCache && t && typeof t === 'object') {
1881
- const cached = __termKeyCache.get(t);
2052
+ if (termKeyCache && t && typeof t === 'object') {
2053
+ const cached = termKeyCache.get(t);
1882
2054
  if (cached) return cached;
1883
2055
  }
1884
2056
 
@@ -1909,14 +2081,14 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1909
2081
  // Iri / Blank and other atomic interned terms
1910
2082
  out = 'T' + t.__tid;
1911
2083
  } else if (t instanceof ListTerm) {
1912
- out = '[' + t.elems.map(__termKeyForVisited).join(',') + ']';
2084
+ out = '[' + t.elems.map(termKeyForVisited).join(',') + ']';
1913
2085
  } else if (t instanceof OpenListTerm) {
1914
- out = '[open:' + t.prefix.map(__termKeyForVisited).join(',') + '|tail:' + t.tailVar + ']';
2086
+ out = '[open:' + t.prefix.map(termKeyForVisited).join(',') + '|tail:' + t.tailVar + ']';
1915
2087
  } else if (t instanceof GraphTerm) {
1916
2088
  out =
1917
2089
  '{' +
1918
2090
  t.triples
1919
- .map((tr) => __termKeyForVisited(tr.s) + ' ' + __termKeyForVisited(tr.p) + ' ' + __termKeyForVisited(tr.o))
2091
+ .map((tr) => termKeyForVisited(tr.s) + ' ' + termKeyForVisited(tr.p) + ' ' + termKeyForVisited(tr.o))
1920
2092
  .join(';') +
1921
2093
  '}';
1922
2094
  } else {
@@ -1924,20 +2096,20 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1924
2096
  out = skolemKeyFromTerm(t);
1925
2097
  }
1926
2098
 
1927
- if (__termKeyCache && t && typeof t === 'object') __termKeyCache.set(t, out);
2099
+ if (termKeyCache && t && typeof t === 'object') termKeyCache.set(t, out);
1928
2100
  return out;
1929
2101
  }
1930
2102
 
1931
- function __tripleKeyForVisited(tr) {
1932
- return __termKeyForVisited(tr.s) + '\t' + __termKeyForVisited(tr.p) + '\t' + __termKeyForVisited(tr.o);
2103
+ function tripleKeyForVisited(tr) {
2104
+ return termKeyForVisited(tr.s) + '\t' + termKeyForVisited(tr.p) + '\t' + termKeyForVisited(tr.o);
1933
2105
  }
1934
2106
 
1935
- function __pushVisited(key) {
2107
+ function pushVisitedKey(key) {
1936
2108
  visitedTrail.push(key);
1937
2109
  visitedCounts.set(key, (visitedCounts.get(key) || 0) + 1);
1938
2110
  }
1939
2111
 
1940
- function __undoVisitedTo(mark) {
2112
+ function undoVisitedKeysTo(mark) {
1941
2113
  for (let i = visitedTrail.length - 1; i >= mark; i--) {
1942
2114
  const k = visitedTrail[i];
1943
2115
  const c = visitedCounts.get(k);
@@ -1947,7 +2119,7 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1947
2119
  visitedTrail.length = mark;
1948
2120
  }
1949
2121
 
1950
- for (const tr of initialVisited) __pushVisited(__tripleKeyForVisited(tr));
2122
+ for (const tr of initialVisited) pushVisitedKey(tripleKeyForVisited(tr));
1951
2123
 
1952
2124
  // ---------------------------------------------------------------------------
1953
2125
  // In-place unification into the mutable substitution + trail.
@@ -1979,8 +2151,10 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1979
2151
 
1980
2152
  // Normalize rdf:nil IRI to the empty list term, so it unifies with () and
1981
2153
  // list builtins treat it consistently.
1982
- if (a instanceof Iri && a.value === RDF_NIL_IRI) a = __EMPTY_LIST;
1983
- if (b instanceof Iri && b.value === RDF_NIL_IRI) b = __EMPTY_LIST;
2154
+ if (a instanceof Iri && a.value === RDF_NIL_IRI) a = EMPTY_LIST_TERM;
2155
+ if (b instanceof Iri && b.value === RDF_NIL_IRI) b = EMPTY_LIST_TERM;
2156
+
2157
+ if (a === b) return true;
1984
2158
 
1985
2159
  // Variable binding
1986
2160
  if (a instanceof Var) return bindVarTrail(a.name, b);
@@ -2099,7 +2273,7 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2099
2273
  kind: 'node',
2100
2274
  goalsNow: initialGoals,
2101
2275
  curDepth: depth || 0,
2102
- canDeferBuiltins: __allowDeferBuiltins,
2276
+ canDeferBuiltins: allowDeferredBuiltins,
2103
2277
  deferCount: 0,
2104
2278
  });
2105
2279
 
@@ -2108,7 +2282,7 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2108
2282
 
2109
2283
  if (frame.kind === 'undo') {
2110
2284
  undoTo(frame.substMark);
2111
- __undoVisitedTo(frame.visitedMark);
2285
+ undoVisitedKeysTo(frame.visitedMark);
2112
2286
  continue;
2113
2287
  }
2114
2288
 
@@ -2167,15 +2341,15 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2167
2341
  // still preventing trivial non-termination in mutually recursive rule
2168
2342
  // cycles.
2169
2343
  if (frame.goalWasVisited && rStd.premise && rStd.premise.length) {
2170
- let __cycle = false;
2344
+ let hasCycle = false;
2171
2345
  for (let i = 0; i < rStd.premise.length; i++) {
2172
- const premKey = __tripleKeyForVisited(applySubstTriple(rStd.premise[i], substMut));
2346
+ const premKey = tripleKeyForVisited(applySubstTriple(rStd.premise[i], substMut));
2173
2347
  if (visitedCounts.has(premKey)) {
2174
- __cycle = true;
2348
+ hasCycle = true;
2175
2349
  break;
2176
2350
  }
2177
2351
  }
2178
- if (__cycle) {
2352
+ if (hasCycle) {
2179
2353
  undoTo(mark);
2180
2354
  continue;
2181
2355
  }
@@ -2184,7 +2358,7 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2184
2358
  const newGoals = rStd.premise.concat(frame.restGoals);
2185
2359
 
2186
2360
  const vMark = visitedTrail.length;
2187
- __pushVisited(frame.goalKey);
2361
+ pushVisitedKey(frame.goalKey);
2188
2362
 
2189
2363
  // Explore the rule body; then undo; then resume trying further rules.
2190
2364
  stack.push(frame);
@@ -2206,8 +2380,15 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2206
2380
  const candidates = frame.candidates;
2207
2381
  const isIndexed = !!candidates;
2208
2382
 
2209
- while (frame.idx < (isIndexed ? candidates.length : factsList.length) && results.length < max) {
2210
- const f = isIndexed ? factsList[candidates[frame.idx++]] : factsList[frame.idx++];
2383
+ while (frame.idx < (isIndexed ? candidates.totalLen : factsList.length) && results.length < max) {
2384
+ let f;
2385
+ if (isIndexed) {
2386
+ const idxNow = frame.idx++;
2387
+ if (idxNow < candidates.exactLen) f = factsList[candidates.exact[idxNow]];
2388
+ else f = factsList[candidates.wild[idxNow - candidates.exactLen]];
2389
+ } else {
2390
+ f = factsList[frame.idx++];
2391
+ }
2211
2392
 
2212
2393
  const mark = trail.length;
2213
2394
  if (!unifyTripleTrail(frame.goal0, f)) {
@@ -2250,13 +2431,13 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2250
2431
  const goal0 = applySubstTriple(rawGoal, substMut);
2251
2432
 
2252
2433
  // 1) Builtins
2253
- const __pv0 = goal0.p instanceof Iri ? goal0.p.value : null;
2254
- const __rdfFirstOrRest = __pv0 === RDF_NS + 'first' || __pv0 === RDF_NS + 'rest';
2255
- const __treatBuiltin =
2434
+ const goalPredicateIri = goal0.p instanceof Iri ? goal0.p.value : null;
2435
+ const isRdfFirstOrRest = goalPredicateIri === RDF_NS + 'first' || goalPredicateIri === RDF_NS + 'rest';
2436
+ const shouldTreatAsBuiltin =
2256
2437
  isBuiltinPred(goal0.p) &&
2257
- !(__rdfFirstOrRest && !(goal0.s instanceof ListTerm || goal0.s instanceof OpenListTerm));
2438
+ !(isRdfFirstOrRest && !(goal0.s instanceof ListTerm || goal0.s instanceof OpenListTerm));
2258
2439
 
2259
- if (__treatBuiltin) {
2440
+ if (shouldTreatAsBuiltin) {
2260
2441
  const remaining = max - results.length;
2261
2442
  if (remaining <= 0) continue;
2262
2443
  const builtinMax = Number.isFinite(remaining) && !restGoals.length ? remaining : undefined;
@@ -2264,11 +2445,11 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2264
2445
  let deltas = evalBuiltin(goal0, {}, facts, backRules, frame.curDepth, varGen, builtinMax);
2265
2446
 
2266
2447
  const dc = typeof frame.deferCount === 'number' ? frame.deferCount : 0;
2267
- const __vacuous = deltas.length > 0 && deltas.every((d) => Object.keys(d).length === 0);
2448
+ const builtinDeltasAreVacuous = deltas.length > 0 && deltas.every((d) => Object.keys(d).length === 0);
2268
2449
 
2269
2450
  if (
2270
2451
  frame.canDeferBuiltins &&
2271
- (!deltas.length || __vacuous) &&
2452
+ (!deltas.length || builtinDeltasAreVacuous) &&
2272
2453
  restGoals.length &&
2273
2454
  __tripleHasVarOrBlank(goal0) &&
2274
2455
  dc < goalsNow.length
@@ -2284,14 +2465,14 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2284
2465
  continue;
2285
2466
  }
2286
2467
 
2287
- const __fullyUnboundSO =
2468
+ const subjectAndObjectAreFullyUnbound =
2288
2469
  (goal0.s instanceof Var || goal0.s instanceof Blank) && (goal0.o instanceof Var || goal0.o instanceof Blank);
2289
2470
 
2290
2471
  if (
2291
2472
  frame.canDeferBuiltins &&
2292
2473
  !deltas.length &&
2293
- __builtinIsSatisfiableWhenFullyUnbound(__pv0) &&
2294
- __fullyUnboundSO &&
2474
+ __builtinIsSatisfiableWhenFullyUnbound(goalPredicateIri) &&
2475
+ subjectAndObjectAreFullyUnbound &&
2295
2476
  (!restGoals.length || dc >= goalsNow.length)
2296
2477
  ) {
2297
2478
  deltas = [{}];
@@ -2326,7 +2507,7 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2326
2507
  // We therefore *allow* re-entering a visited goal, but when a goal is
2327
2508
  // already visited we avoid applying backward rules whose premises would
2328
2509
  // immediately re-enter any visited goal again (a cheap cycle guard).
2329
- const goalKey = __tripleKeyForVisited(goal0);
2510
+ const goalKey = tripleKeyForVisited(goal0);
2330
2511
  const goalWasVisited = visitedCounts.has(goalKey);
2331
2512
 
2332
2513
  // 3) Backward rules (indexed by head predicate) — explored first
@@ -2376,8 +2557,8 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2376
2557
  }
2377
2558
  }
2378
2559
 
2379
- if (__goalTable && __canStoreGoalMemo(visited, maxResults)) {
2380
- __goalTable.entries.set(__goalMemoKeyNow, __cloneGoalSolutions(results));
2560
+ if (goalTable && __canStoreGoalMemo(visited, maxResults)) {
2561
+ goalTable.entries.set(goalMemoKeyNow, __cloneGoalSolutions(results));
2381
2562
  }
2382
2563
 
2383
2564
  return results;
@@ -2387,8 +2568,8 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2387
2568
  // Forward chaining to fixpoint
2388
2569
  // ===========================================================================
2389
2570
 
2390
- function forwardChain(facts, forwardRules, backRules, onDerived /* optional */) {
2391
- __enterReasoningRun();
2571
+ function forwardChain(facts, forwardRules, backRules, onDerived /* optional */, opts = {}) {
2572
+ enterReasoningRun();
2392
2573
  try {
2393
2574
  ensureFactIndexes(facts);
2394
2575
  ensureBackRuleIndexes(backRules);
@@ -2397,7 +2578,7 @@ function forwardChain(facts, forwardRules, backRules, onDerived /* optional */)
2397
2578
  __attachGoalTable(facts, goalTable);
2398
2579
  __attachGoalTable(backRules, goalTable);
2399
2580
 
2400
- const factList = facts.slice();
2581
+ const captureExplanations = !(opts && opts.captureExplanations === false);
2401
2582
  const derivedForward = [];
2402
2583
  const varGen = [0];
2403
2584
  const skCounter = [0];
@@ -2473,46 +2654,216 @@ function forwardChain(facts, forwardRules, backRules, onDerived /* optional */)
2473
2654
  return snap;
2474
2655
  }
2475
2656
 
2657
+ function __skipForwardRuleNow(r) {
2658
+ // Skip forward rules that are guaranteed to "delay" due to scoped
2659
+ // builtins (log:collectAllIn / log:forAllIn / log:includes / log:notIncludes)
2660
+ // until a snapshot exists (and a certain closure level is reached).
2661
+ // This prevents expensive proofs that will definitely fail in Phase A
2662
+ // and in early closure levels.
2663
+ const info = r.__scopedSkipInfo;
2664
+ if (info && info.needsSnap) {
2665
+ const snapHere = facts.__scopedSnapshot || null;
2666
+ const lvlHere = (facts && typeof facts.__scopedClosureLevel === 'number' && facts.__scopedClosureLevel) || 0;
2667
+ if (!snapHere) return true;
2668
+ if (lvlHere < info.requiredLevel) return true;
2669
+ }
2670
+
2671
+ // Optimization: if the rule head is **structurally ground** (no vars anywhere, even inside
2672
+ // quoted formulas) and has no head blanks, then the head does not depend on which body
2673
+ // solution we pick. In that case, we only need *one* proof of the body, and once all head
2674
+ // triples are already known we can skip proving the body entirely.
2675
+ const headIsStrictGround = r.__headIsStrictGround;
2676
+ if (headIsStrictGround) {
2677
+ let allKnown = true;
2678
+ for (const tr of r.conclusion) {
2679
+ if (!hasFactIndexed(facts, tr)) {
2680
+ allKnown = false;
2681
+ break;
2682
+ }
2683
+ }
2684
+ if (allKnown) return true;
2685
+ }
2686
+
2687
+ return false;
2688
+ }
2689
+
2690
+ function __emitForwardRuleSolution(r, ruleIndex, s) {
2691
+ let changedHere = false;
2692
+ let rulesChanged = false;
2693
+
2694
+ // IMPORTANT: one skolem map per *rule firing*
2695
+ const skMap = {};
2696
+ const instantiatedPremises = r.premise.map((b) => applySubstTriple(b, s));
2697
+ const fireKey = __firingKey(ruleIndex, instantiatedPremises);
2698
+
2699
+ // Support "dynamic" rule heads where the consequent is a term that
2700
+ // (after substitution) evaluates to a quoted formula.
2701
+ // Example: { :a :b ?C } => ?C.
2702
+ let dynamicHeadTriples = null;
2703
+ let headBlankLabelsHere = r.headBlankLabels;
2704
+ if (r.__dynamicConclusionTerm) {
2705
+ const dynTerm = applySubstTerm(r.__dynamicConclusionTerm, s);
2706
+
2707
+ // Allow dynamic fuses: ... => ?X. where ?X becomes false
2708
+ if (dynTerm instanceof Literal && dynTerm.value === 'false') {
2709
+ console.log('# Inference fuse triggered: dynamic head resolved to false.');
2710
+ process.exit(2);
2711
+ }
2712
+
2713
+ const dynTriples = __graphTriplesOrTrue(dynTerm);
2714
+ dynamicHeadTriples = dynTriples !== null ? dynTriples : [];
2715
+
2716
+ // If the dynamic head contains explicit blank nodes, treat them as
2717
+ // head blanks for skolemization.
2718
+ const dynHeadBlankLabels =
2719
+ dynamicHeadTriples && dynamicHeadTriples.length ? collectBlankLabelsInTriples(dynamicHeadTriples) : null;
2720
+ if (dynHeadBlankLabels && dynHeadBlankLabels.size) {
2721
+ headBlankLabelsHere = new Set([...headBlankLabelsHere, ...dynHeadBlankLabels]);
2722
+ }
2723
+ }
2724
+
2725
+ const headPatterns =
2726
+ dynamicHeadTriples && dynamicHeadTriples.length ? r.conclusion.concat(dynamicHeadTriples) : r.conclusion;
2727
+
2728
+ for (const cpat of headPatterns) {
2729
+ const instantiated = applySubstTriple(cpat, s);
2730
+
2731
+ const subj = instantiated.s;
2732
+ const obj = instantiated.o;
2733
+
2734
+ const subjIsGraph = subj instanceof GraphTerm;
2735
+ const objIsGraph = obj instanceof GraphTerm;
2736
+ const subjIsTrue = subj instanceof Literal && subj.value === 'true';
2737
+ const objIsTrue = obj instanceof Literal && obj.value === 'true';
2738
+
2739
+ const isFwRuleTriple =
2740
+ isLogImplies(instantiated.p) &&
2741
+ ((subjIsGraph && objIsGraph) || (subjIsTrue && objIsGraph) || (subjIsGraph && objIsTrue));
2742
+
2743
+ const isBwRuleTriple =
2744
+ isLogImpliedBy(instantiated.p) &&
2745
+ ((subjIsGraph && objIsGraph) || (subjIsGraph && objIsTrue) || (subjIsTrue && objIsGraph));
2746
+
2747
+ if (isFwRuleTriple || isBwRuleTriple) {
2748
+ if (!hasFactIndexed(facts, instantiated)) {
2749
+ pushFactIndexed(facts, instantiated);
2750
+ const df = makeDerivedRecord(instantiated, r, instantiatedPremises, s, captureExplanations);
2751
+ derivedForward.push(df);
2752
+ if (typeof onDerived === 'function') onDerived(df);
2753
+ changedHere = true;
2754
+ }
2755
+
2756
+ // Promote rule-producing triples to live rules, treating literal true as {}.
2757
+ const left = __graphTriplesOrTrue(subj);
2758
+ const right = __graphTriplesOrTrue(obj);
2759
+
2760
+ if (left !== null && right !== null) {
2761
+ if (isFwRuleTriple) {
2762
+ const [premise, conclusion] = liftBlankRuleVars(left, right);
2763
+ const headBlankLabels = collectBlankLabelsInTriples(conclusion);
2764
+ const newRule = new Rule(premise, conclusion, true, false, headBlankLabels);
2765
+ __prepareForwardRule(newRule);
2766
+
2767
+ const key = __ruleKey(
2768
+ newRule.isForward,
2769
+ newRule.isFuse,
2770
+ newRule.premise,
2771
+ newRule.conclusion,
2772
+ newRule.__dynamicConclusionTerm || null,
2773
+ );
2774
+ if (!forwardRules.__ruleKeySet.has(key)) {
2775
+ forwardRules.__ruleKeySet.add(key);
2776
+ forwardRules.push(newRule);
2777
+ rulesChanged = true;
2778
+ }
2779
+ } else if (isBwRuleTriple) {
2780
+ const [premise, conclusion] = liftBlankRuleVars(right, left);
2781
+ const headBlankLabels = collectBlankLabelsInTriples(conclusion);
2782
+ const newRule = new Rule(premise, conclusion, false, false, headBlankLabels);
2783
+
2784
+ const key = __ruleKey(
2785
+ newRule.isForward,
2786
+ newRule.isFuse,
2787
+ newRule.premise,
2788
+ newRule.conclusion,
2789
+ newRule.__dynamicConclusionTerm || null,
2790
+ );
2791
+ if (!backRules.__ruleKeySet.has(key)) {
2792
+ backRules.__ruleKeySet.add(key);
2793
+ backRules.push(newRule);
2794
+ indexBackRule(backRules, newRule);
2795
+ rulesChanged = true;
2796
+ }
2797
+ }
2798
+ }
2799
+
2800
+ continue; // skip normal fact handling
2801
+ }
2802
+
2803
+ // Only skolemize blank nodes that occur explicitly in the rule head
2804
+ const inst = skolemizeTripleForHeadBlanks(
2805
+ instantiated,
2806
+ headBlankLabelsHere,
2807
+ skMap,
2808
+ skCounter,
2809
+ fireKey,
2810
+ headSkolemCache,
2811
+ );
2812
+
2813
+ if (!isGroundTriple(inst)) continue;
2814
+ if (hasFactIndexed(facts, inst)) continue;
2815
+
2816
+ pushFactIndexed(facts, inst);
2817
+ const df = makeDerivedRecord(inst, r, instantiatedPremises, s, captureExplanations);
2818
+ derivedForward.push(df);
2819
+ if (typeof onDerived === 'function') onDerived(df);
2820
+
2821
+ changedHere = true;
2822
+ }
2823
+
2824
+ return { changedHere, rulesChanged };
2825
+ }
2826
+
2476
2827
  function runFixpoint() {
2477
2828
  let anyChange = false;
2829
+ let agendaIndex = makeSinglePremiseAgendaIndex(forwardRules, backRules);
2830
+ let agendaCursor = 0;
2478
2831
 
2479
2832
  while (true) {
2480
2833
  let changed = false;
2481
2834
 
2482
- for (let i = 0; i < forwardRules.length; i++) {
2483
- const r = forwardRules[i];
2835
+ while (agendaCursor < facts.length && agendaIndex.size) {
2836
+ const fact = facts[agendaCursor++];
2837
+ const candidates = getSinglePremiseAgendaCandidates(agendaIndex, fact);
2838
+ if (!candidates) continue;
2484
2839
 
2485
- // Skip forward rules that are guaranteed to "delay" due to scoped
2486
- // builtins (log:collectAllIn / log:forAllIn / log:includes / log:notIncludes)
2487
- // until a snapshot exists (and a certain closure level is reached).
2488
- // This prevents expensive proofs that will definitely fail in Phase A
2489
- // and in early closure levels.
2490
- const info = r.__scopedSkipInfo;
2491
- if (info && info.needsSnap) {
2492
- const snapHere = facts.__scopedSnapshot || null;
2493
- const lvlHere =
2494
- (facts && typeof facts.__scopedClosureLevel === 'number' && facts.__scopedClosureLevel) || 0;
2495
- if (!snapHere) continue;
2496
- if (lvlHere < info.requiredLevel) continue;
2497
- }
2840
+ const total = candidates.exactLen + candidates.wildLen;
2841
+ for (let ci = 0; ci < total; ci++) {
2842
+ const entry = ci < candidates.exactLen ? candidates.exact[ci] : candidates.wild[ci - candidates.exactLen];
2843
+ const r = entry.rule;
2844
+ if (__skipForwardRuleNow(r)) continue;
2498
2845
 
2499
- // Optimization: if the rule head is **structurally ground** (no vars anywhere, even inside
2500
- // quoted formulas) and has no head blanks, then the head does not depend on which body
2501
- // solution we pick. In that case, we only need *one* proof of the body, and once all head
2502
- // triples are already known we can skip proving the body entirely.
2503
- const headIsStrictGround = r.__headIsStrictGround;
2846
+ const s = unifyTriple(entry.goal, fact, {});
2847
+ if (s === null) continue;
2504
2848
 
2505
- if (headIsStrictGround) {
2506
- let allKnown = true;
2507
- for (const tr of r.conclusion) {
2508
- if (!hasFactIndexed(facts, tr)) {
2509
- allKnown = false;
2510
- break;
2511
- }
2849
+ const outcome = __emitForwardRuleSolution(r, entry.ruleIndex, s);
2850
+ if (outcome.rulesChanged) {
2851
+ agendaIndex = makeSinglePremiseAgendaIndex(forwardRules, backRules);
2852
+ agendaCursor = 0;
2853
+ }
2854
+ if (outcome.changedHere) {
2855
+ changed = true;
2856
+ anyChange = true;
2512
2857
  }
2513
- if (allKnown) continue;
2514
2858
  }
2859
+ }
2515
2860
 
2861
+ for (let i = 0; i < forwardRules.length; i++) {
2862
+ const r = forwardRules[i];
2863
+ if (agendaIndex.indexed.has(r)) continue;
2864
+ if (__skipForwardRuleNow(r)) continue;
2865
+
2866
+ const headIsStrictGround = r.__headIsStrictGround;
2516
2867
  const maxSols = r.isFuse || headIsStrictGround ? 1 : undefined;
2517
2868
  // Enable builtin deferral / goal reordering for forward rules only.
2518
2869
  // This keeps forward-chaining conjunctions order-insensitive while
@@ -2529,145 +2880,22 @@ function forwardChain(facts, forwardRules, backRules, onDerived /* optional */)
2529
2880
  }
2530
2881
 
2531
2882
  for (const s of sols) {
2532
- // IMPORTANT: one skolem map per *rule firing*
2533
- const skMap = {};
2534
- const instantiatedPremises = r.premise.map((b) => applySubstTriple(b, s));
2535
- const fireKey = __firingKey(i, instantiatedPremises);
2536
-
2537
- // Support "dynamic" rule heads where the consequent is a term that
2538
- // (after substitution) evaluates to a quoted formula.
2539
- // Example: { :a :b ?C } => ?C.
2540
- let dynamicHeadTriples = null;
2541
- let headBlankLabelsHere = r.headBlankLabels;
2542
- if (r.__dynamicConclusionTerm) {
2543
- const dynTerm = applySubstTerm(r.__dynamicConclusionTerm, s);
2544
-
2545
- // Allow dynamic fuses: ... => ?X. where ?X becomes false
2546
- if (dynTerm instanceof Literal && dynTerm.value === 'false') {
2547
- console.log('# Inference fuse triggered: dynamic head resolved to false.');
2548
- process.exit(2);
2549
- }
2550
-
2551
- const dynTriples = __graphTriplesOrTrue(dynTerm);
2552
- dynamicHeadTriples = dynTriples !== null ? dynTriples : [];
2553
-
2554
- // If the dynamic head contains explicit blank nodes, treat them as
2555
- // head blanks for skolemization.
2556
- const dynHeadBlankLabels =
2557
- dynamicHeadTriples && dynamicHeadTriples.length
2558
- ? collectBlankLabelsInTriples(dynamicHeadTriples)
2559
- : null;
2560
- if (dynHeadBlankLabels && dynHeadBlankLabels.size) {
2561
- headBlankLabelsHere = new Set([...headBlankLabelsHere, ...dynHeadBlankLabels]);
2562
- }
2883
+ const outcome = __emitForwardRuleSolution(r, i, s);
2884
+ if (outcome.rulesChanged) {
2885
+ agendaIndex = makeSinglePremiseAgendaIndex(forwardRules, backRules);
2886
+ agendaCursor = 0;
2563
2887
  }
2564
-
2565
- const headPatterns =
2566
- dynamicHeadTriples && dynamicHeadTriples.length ? r.conclusion.concat(dynamicHeadTriples) : r.conclusion;
2567
-
2568
- for (const cpat of headPatterns) {
2569
- const instantiated = applySubstTriple(cpat, s);
2570
-
2571
- const subj = instantiated.s;
2572
- const obj = instantiated.o;
2573
-
2574
- const subjIsGraph = subj instanceof GraphTerm;
2575
- const objIsGraph = obj instanceof GraphTerm;
2576
- const subjIsTrue = subj instanceof Literal && subj.value === 'true';
2577
- const objIsTrue = obj instanceof Literal && obj.value === 'true';
2578
-
2579
- const isFwRuleTriple =
2580
- isLogImplies(instantiated.p) &&
2581
- ((subjIsGraph && objIsGraph) || (subjIsTrue && objIsGraph) || (subjIsGraph && objIsTrue));
2582
-
2583
- const isBwRuleTriple =
2584
- isLogImpliedBy(instantiated.p) &&
2585
- ((subjIsGraph && objIsGraph) || (subjIsGraph && objIsTrue) || (subjIsTrue && objIsGraph));
2586
-
2587
- if (isFwRuleTriple || isBwRuleTriple) {
2588
- if (!hasFactIndexed(facts, instantiated)) {
2589
- factList.push(instantiated);
2590
- pushFactIndexed(facts, instantiated);
2591
- const df = new DerivedFact(instantiated, r, instantiatedPremises.slice(), { ...s });
2592
- derivedForward.push(df);
2593
- if (typeof onDerived === 'function') onDerived(df);
2594
-
2595
- changed = true;
2596
- }
2597
-
2598
- // Promote rule-producing triples to live rules, treating literal true as {}.
2599
- const left = __graphTriplesOrTrue(subj);
2600
- const right = __graphTriplesOrTrue(obj);
2601
-
2602
- if (left !== null && right !== null) {
2603
- if (isFwRuleTriple) {
2604
- const [premise, conclusion] = liftBlankRuleVars(left, right);
2605
- const headBlankLabels = collectBlankLabelsInTriples(conclusion);
2606
- const newRule = new Rule(premise, conclusion, true, false, headBlankLabels);
2607
- __prepareForwardRule(newRule);
2608
-
2609
- const key = __ruleKey(
2610
- newRule.isForward,
2611
- newRule.isFuse,
2612
- newRule.premise,
2613
- newRule.conclusion,
2614
- newRule.__dynamicConclusionTerm || null,
2615
- );
2616
- if (!forwardRules.__ruleKeySet.has(key)) {
2617
- forwardRules.__ruleKeySet.add(key);
2618
- forwardRules.push(newRule);
2619
- }
2620
- } else if (isBwRuleTriple) {
2621
- const [premise, conclusion] = liftBlankRuleVars(right, left);
2622
- const headBlankLabels = collectBlankLabelsInTriples(conclusion);
2623
- const newRule = new Rule(premise, conclusion, false, false, headBlankLabels);
2624
-
2625
- const key = __ruleKey(
2626
- newRule.isForward,
2627
- newRule.isFuse,
2628
- newRule.premise,
2629
- newRule.conclusion,
2630
- newRule.__dynamicConclusionTerm || null,
2631
- );
2632
- if (!backRules.__ruleKeySet.has(key)) {
2633
- backRules.__ruleKeySet.add(key);
2634
- backRules.push(newRule);
2635
- indexBackRule(backRules, newRule);
2636
- }
2637
- }
2638
- }
2639
-
2640
- continue; // skip normal fact handling
2641
- }
2642
-
2643
- // Only skolemize blank nodes that occur explicitly in the rule head
2644
- const inst = skolemizeTripleForHeadBlanks(
2645
- instantiated,
2646
- headBlankLabelsHere,
2647
- skMap,
2648
- skCounter,
2649
- fireKey,
2650
- headSkolemCache,
2651
- );
2652
-
2653
- if (!isGroundTriple(inst)) continue;
2654
- if (hasFactIndexed(facts, inst)) continue;
2655
-
2656
- factList.push(inst);
2657
- pushFactIndexed(facts, inst);
2658
- const df = new DerivedFact(inst, r, instantiatedPremises.slice(), {
2659
- ...s,
2660
- });
2661
- derivedForward.push(df);
2662
- if (typeof onDerived === 'function') onDerived(df);
2663
-
2888
+ if (outcome.changedHere) {
2664
2889
  changed = true;
2890
+ anyChange = true;
2665
2891
  }
2666
2892
  }
2667
2893
  }
2668
2894
 
2669
- if (!changed) break;
2670
- anyChange = true;
2895
+ if (!changed) {
2896
+ if (agendaCursor < facts.length && agendaIndex.size) continue;
2897
+ break;
2898
+ }
2671
2899
  }
2672
2900
 
2673
2901
  return anyChange;
@@ -2711,7 +2939,7 @@ function forwardChain(facts, forwardRules, backRules, onDerived /* optional */)
2711
2939
 
2712
2940
  return derivedForward;
2713
2941
  } finally {
2714
- __exitReasoningRun();
2942
+ exitReasoningRun();
2715
2943
  }
2716
2944
  }
2717
2945
 
@@ -2783,7 +3011,7 @@ function __withScopedSnapshotForQueries(facts, fn) {
2783
3011
  }
2784
3012
  }
2785
3013
 
2786
- function collectLogQueryConclusions(logQueryRules, facts, backRules) {
3014
+ function collectLogQueryConclusions(logQueryRules, facts, backRules, opts = {}) {
2787
3015
  const queryTriples = [];
2788
3016
  const queryDerived = [];
2789
3017
  const seen = new Set();
@@ -2799,6 +3027,8 @@ function collectLogQueryConclusions(logQueryRules, facts, backRules) {
2799
3027
  __attachGoalTable(facts, goalTable);
2800
3028
  __attachGoalTable(backRules, goalTable);
2801
3029
 
3030
+ const captureExplanations = !(opts && opts.captureExplanations === false);
3031
+
2802
3032
  // Shared state across all query firings (mirrors forwardChain()).
2803
3033
  const varGen = [0];
2804
3034
  const skCounter = [0];
@@ -2850,7 +3080,7 @@ function collectLogQueryConclusions(logQueryRules, facts, backRules) {
2850
3080
  if (seen.has(k)) continue;
2851
3081
  seen.add(k);
2852
3082
  queryTriples.push(inst);
2853
- queryDerived.push(new DerivedFact(inst, r, instantiatedPremises.slice(), { ...s }));
3083
+ queryDerived.push(makeDerivedRecord(inst, r, instantiatedPremises, s, captureExplanations));
2854
3084
  }
2855
3085
  }
2856
3086
  }
@@ -2859,16 +3089,23 @@ function collectLogQueryConclusions(logQueryRules, facts, backRules) {
2859
3089
  });
2860
3090
  }
2861
3091
 
2862
- function forwardChainAndCollectLogQueryConclusions(facts, forwardRules, backRules, logQueryRules, onDerived) {
2863
- __enterReasoningRun();
3092
+ function forwardChainAndCollectLogQueryConclusions(
3093
+ facts,
3094
+ forwardRules,
3095
+ backRules,
3096
+ logQueryRules,
3097
+ onDerived,
3098
+ opts = {},
3099
+ ) {
3100
+ enterReasoningRun();
2864
3101
  try {
2865
3102
  // Forward chain first (saturates `facts`).
2866
- const derived = forwardChain(facts, forwardRules, backRules, onDerived);
3103
+ const derived = forwardChain(facts, forwardRules, backRules, onDerived, opts);
2867
3104
  // Then collect query conclusions against the saturated closure.
2868
- const { queryTriples, queryDerived } = collectLogQueryConclusions(logQueryRules, facts, backRules);
3105
+ const { queryTriples, queryDerived } = collectLogQueryConclusions(logQueryRules, facts, backRules, opts);
2869
3106
  return { derived, queryTriples, queryDerived };
2870
3107
  } finally {
2871
- __exitReasoningRun();
3108
+ exitReasoningRun();
2872
3109
  }
2873
3110
  }
2874
3111