eyeprolog 1.3.3 → 1.3.7

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 (38) hide show
  1. package/README.md +78 -4
  2. package/examples/book/README.md +2 -1
  3. package/examples/book/chapter-07/02-move.pl +4 -0
  4. package/examples/book/chapter-08/01-findall.pl +1 -0
  5. package/examples/book/chapter-39/04-cost.pl +1 -0
  6. package/examples/chart-parser.pl +1 -0
  7. package/examples/dog.pl +1 -0
  8. package/examples/donald-gerald-robert.pl +1 -0
  9. package/examples/equality-saturation.pl +1 -0
  10. package/examples/job-shop-scheduling.pl +1 -0
  11. package/examples/knapsack-optimization.pl +1 -0
  12. package/examples/knuth-bendix-completion.pl +1 -0
  13. package/examples/matrix-chain-order.pl +1 -0
  14. package/examples/missionaries-cannibals.pl +1 -0
  15. package/examples/prime-range.pl +1 -0
  16. package/examples/register-allocation.pl +1 -0
  17. package/examples/send-more-money.pl +1 -0
  18. package/examples/stable-marriage.pl +1 -0
  19. package/examples/totient-summatory.pl +1 -0
  20. package/examples/weighted-interval-scheduling.pl +1 -0
  21. package/index.d.ts +7 -0
  22. package/package.json +1 -1
  23. package/src/datalog.js +354 -0
  24. package/src/iso.js +148 -14
  25. package/src/lib/iso_ext.pl +4 -0
  26. package/src/lib/lists.pl +0 -3
  27. package/src/program.js +258 -20
  28. package/src/solver.js +890 -46
  29. package/src/standard-library.js +12 -7
  30. package/src/term.js +1 -1
  31. package/src/wfs.js +338 -0
  32. package/test/conformance/ISO-COMPLIANCE.md +2 -1
  33. package/test/conformance/README.md +4 -1
  34. package/test/run-conformance.mjs +1 -0
  35. package/test/run-regression.mjs +230 -3
  36. package/the-art-of-eyeprolog.md +95 -21
  37. package/why-eyeprolog.md +17 -0
  38. /package/examples/book/chapter-07/{02-all_tests_pass.pl → 03-all_tests_pass.pl} +0 -0
package/src/iso.js CHANGED
@@ -1734,17 +1734,85 @@ function numberListBuiltin(kind) {
1734
1734
  const numberCharsBuiltin = numberListBuiltin('chars');
1735
1735
  const numberCodesBuiltin = numberListBuiltin('codes');
1736
1736
 
1737
+ class FindallListTerm {
1738
+ constructor(items, offset = 0) {
1739
+ this.type = COMPOUND;
1740
+ this.name = '.';
1741
+ this._items = items;
1742
+ this._offset = offset;
1743
+ this._compactLength = BigInt(items.length - offset);
1744
+ this._args = null;
1745
+ }
1746
+ get arity() { return 2; }
1747
+ get args() {
1748
+ if (this._args == null) {
1749
+ const next = this._offset + 1;
1750
+ this._args = [
1751
+ typeof this._items.get === 'function' ? this._items.get(this._offset) : this._items[this._offset],
1752
+ next >= this._items.length ? emptyList() : new FindallListTerm(this._items, next),
1753
+ ];
1754
+ }
1755
+ return this._args;
1756
+ }
1757
+ }
1758
+
1759
+ class FlatCompoundFindallItems {
1760
+ constructor(template) {
1761
+ this.name = template.name;
1762
+ this.arity = template.arity;
1763
+ this.columns = Array.from({ length: this.arity }, () => []);
1764
+ }
1765
+ get length() {
1766
+ return this.arity === 0 ? 0 : this.columns[0].length;
1767
+ }
1768
+ push(template, env) {
1769
+ const values = new Array(this.arity);
1770
+ for (let i = 0; i < this.arity; i++) {
1771
+ const value = deref(template.args[i], env);
1772
+ if (value.type === VAR || value.type === COMPOUND) return false;
1773
+ values[i] = value;
1774
+ }
1775
+ for (let i = 0; i < this.arity; i++) this.columns[i].push(values[i]);
1776
+ return true;
1777
+ }
1778
+ get(index) {
1779
+ return compound(this.name, this.columns.map((column) => column[index]));
1780
+ }
1781
+ materialize() {
1782
+ const out = new Array(this.length);
1783
+ for (let i = 0; i < out.length; i++) out[i] = this.get(i);
1784
+ return out;
1785
+ }
1786
+ }
1787
+
1788
+ function findallListFromItems(items) {
1789
+ return items.length === 0 ? emptyList() : new FindallListTerm(items);
1790
+ }
1791
+
1792
+ function flatFindallTemplate(template) {
1793
+ return template.type === COMPOUND && template.arity > 0 &&
1794
+ template.args.every((arg) => arg.type !== COMPOUND)
1795
+ ? new FlatCompoundFindallItems(template)
1796
+ : null;
1797
+ }
1798
+
1737
1799
  function* findallBuiltin({ solver, goal, env }) {
1738
1800
  const [template, innerGoal, bag] = goal.args;
1739
1801
  assertListOrPartial(bag, env);
1740
1802
  const collector = solver.cloneForInnerGoal(10000000);
1741
- const collected = [];
1803
+ let compact = flatFindallTemplate(template);
1804
+ let collected = compact == null ? [] : null;
1742
1805
  for (const answerEnv of collector.solve([callable(innerGoal, env)], env.clone(), 0)) {
1806
+ if (compact != null && compact.push(template, answerEnv)) continue;
1807
+ if (compact != null) {
1808
+ collected = compact.materialize();
1809
+ compact = null;
1810
+ }
1743
1811
  collected.push(freshCopy(template, answerEnv));
1744
1812
  }
1745
1813
  solver.absorbStatsFrom(collector);
1746
1814
  const next = env.clone();
1747
- if (unify(bag, listFromItems(collected), next)) yield next;
1815
+ if (unify(bag, findallListFromItems(compact ?? collected), next)) yield next;
1748
1816
  }
1749
1817
 
1750
1818
  function collectVariableNames(term, env, names = new Set()) {
@@ -1888,12 +1956,15 @@ function* countAllBuiltin({ solver, goal, env }) {
1888
1956
  }
1889
1957
 
1890
1958
  const invoked = callable(goal.args[0], env);
1891
- const child = solver.cloneForInnerGoal();
1892
- let count = 0n;
1893
- try {
1894
- for (const _ of child.solve([invoked], env.clone(), 0)) count++;
1895
- } finally {
1896
- solver.absorbStatsFrom(child);
1959
+ let count = solver.fastCountGoal?.(invoked, env) ?? null;
1960
+ if (count == null) {
1961
+ const child = solver.cloneForInnerGoal();
1962
+ count = 0n;
1963
+ try {
1964
+ for (const _ of child.solve([invoked], env.clone(), 0)) count++;
1965
+ } finally {
1966
+ solver.absorbStatsFrom(child);
1967
+ }
1897
1968
  }
1898
1969
 
1899
1970
  const next = env.clone();
@@ -1970,11 +2041,33 @@ function* phraseBuiltin({ solver, goal, env }) {
1970
2041
  module: goal.module ?? grammarBody.module ?? 'user',
1971
2042
  });
1972
2043
  const finish = compound('=', [finalOutput, requestedOutput]);
1973
- const child = solver.cloneForInnerGoal();
2044
+ // Recursive DCGs are automatically tabled in normal mode. Keep tables in a
2045
+ // phrase-local scope keyed by the whole invocation. Repeatedly testing the
2046
+ // same grammar/input (issue #48) reuses its completed table, while switching
2047
+ // to a distinct input (issue #28) drops the previous invocation as one unit
2048
+ // instead of retaining or individually evicting every recursive tail.
2049
+ const phraseModule = goal.module ?? grammarBody.module ?? 'user';
2050
+ const tableScopeSignature = solver.innerTableSignature(
2051
+ [grammarBody, input, requestedOutput],
2052
+ env,
2053
+ `${phraseModule}:`,
2054
+ );
2055
+ const previousPhraseScope = solver.innerTableScopes.get('phrase');
2056
+ const repeatedInvocation = previousPhraseScope?.signature === tableScopeSignature;
2057
+ const child = solver.cloneForInnerGoal(solver.solutionLimit, {
2058
+ tableScope: 'phrase',
2059
+ tableScopeSignature,
2060
+ // A new phrase input cannot benefit from tables retained for a different
2061
+ // input. For structurally tail-consuming DCGs, execute it directly. If the
2062
+ // same invocation repeats (the issue #48 pattern), normal tabling resumes
2063
+ // and the compact phrase-local table is reused by subsequent repetitions.
2064
+ skipListTailTabling: !repeatedInvocation,
2065
+ });
1974
2066
  try {
1975
2067
  yield* child.solve([expanded, finish], env, 0);
1976
2068
  } finally {
1977
2069
  solver.absorbStatsFrom(child);
2070
+ solver.trimInnerTableScope('phrase');
1978
2071
  }
1979
2072
  }
1980
2073
  const defaultErrorContext = atom('eyeprolog');
@@ -2032,11 +2125,28 @@ function prologErrorBall(error) {
2032
2125
  return freshCopy(term, new Env());
2033
2126
  }
2034
2127
  function* catchBuiltin({ solver, goal, env }) {
2035
- const child = solver.cloneForInnerGoal();
2128
+ let child = null;
2036
2129
  try {
2037
2130
  // Corrigendum 2 removed catch/3's own callability errors so that errors
2038
2131
  // raised while converting/executing the protected goal are catchable.
2039
- yield* child.solve([callable(goal.args[0], env)], env.clone(), 0);
2132
+ const invoked = callable(goal.args[0], env);
2133
+ const direct = solver.registry.get(invoked.name, invoked.arity);
2134
+ if (direct?.deterministic && (direct.shouldUse == null || direct.shouldUse({ solver, goal: invoked, env }))) {
2135
+ // A deterministic builtin has no choice points whose lifetime must be
2136
+ // isolated in a child solver. Running it directly avoids constructing a
2137
+ // complete Solver for hot caught failures such as number_chars/2 syntax
2138
+ // probes, while the cloned environment keeps catch/3's rollback boundary.
2139
+ const iterator = direct.handler({ solver, goal: invoked, env: env.clone() });
2140
+ const result = iterator.next();
2141
+ if (result.done) solver.stats.deterministic_builtin_failures++;
2142
+ else {
2143
+ solver.stats.deterministic_builtin_successes++;
2144
+ yield result.value;
2145
+ }
2146
+ return;
2147
+ }
2148
+ child = solver.cloneForInnerGoal();
2149
+ yield* child.solve([invoked], env.clone(), 0);
2040
2150
  } catch (error) {
2041
2151
  const ball = error instanceof ThrownTerm
2042
2152
  ? error.term
@@ -2046,9 +2156,15 @@ function* catchBuiltin({ solver, goal, env }) {
2046
2156
  if (ball == null) throw error;
2047
2157
  const recovered = env.clone();
2048
2158
  if (!unify(goal.args[1], ball, recovered)) throw error;
2049
- yield* solver.solve([callable(goal.args[2], recovered)], recovered, 0);
2159
+ const recovery = callable(goal.args[2], recovered);
2160
+ if (recovery.type === ATOM && (recovery.name === 'false' || recovery.name === 'fail')) return;
2161
+ if (recovery.type === ATOM && recovery.name === 'true') {
2162
+ yield recovered;
2163
+ return;
2164
+ }
2165
+ yield* solver.solve([recovery], recovered, 0);
2050
2166
  } finally {
2051
- solver.absorbStatsFrom(child);
2167
+ if (child != null) solver.absorbStatsFrom(child);
2052
2168
  }
2053
2169
  }
2054
2170
  function* throwBuiltin({ goal, env }) {
@@ -2071,7 +2187,25 @@ function* repeatBuiltin({ env }) {
2071
2187
  while (true) yield env;
2072
2188
  }
2073
2189
  function* negationBuiltin({ solver, goal, env }) {
2074
- for (const _ of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) return;
2190
+ const invoked = callable(goal.args[0], env);
2191
+ const direct = solver.registry.get(invoked.name, invoked.arity);
2192
+ if (direct?.deterministic && (direct.shouldUse == null || direct.shouldUse({ solver, goal: invoked, env }))) {
2193
+ const iterator = direct.handler({ solver, goal: invoked, env: env.clone() });
2194
+ const result = iterator.next();
2195
+ if (result.done) {
2196
+ solver.stats.deterministic_builtin_failures++;
2197
+ yield env;
2198
+ } else {
2199
+ solver.stats.deterministic_builtin_successes++;
2200
+ }
2201
+ return;
2202
+ }
2203
+ const fastTruth = solver.fastGroundGoalTruth?.(invoked, env) ?? null;
2204
+ if (fastTruth != null) {
2205
+ if (!fastTruth) yield env;
2206
+ return;
2207
+ }
2208
+ for (const _ of solver.cloneForInnerGoal(1).solve([invoked], env.clone(), 0)) return;
2075
2209
  yield env;
2076
2210
  }
2077
2211
  function* solveControlBranch(solver, goal, env) {
@@ -1,6 +1,7 @@
1
1
  /** Widely implemented extensions to the ISO Prolog core. */
2
2
 
3
3
  :- module(iso_ext, [
4
+ call_nth/2,
4
5
  countall/2,
5
6
  forall/2,
6
7
  succ/2,
@@ -9,6 +10,7 @@
9
10
  variant/2
10
11
  ]).
11
12
 
13
+ :- meta_predicate(call_nth(0, '?')).
12
14
  :- meta_predicate(countall(0, '?')).
13
15
  :- meta_predicate(forall(0, 0)).
14
16
  :- meta_predicate(findall('?', 0, '?', '?')).
@@ -17,6 +19,8 @@
17
19
  % Trealla. These definitions use only EyeProlog's ISO profile; extensions that
18
20
  % require runtime cleanup, choice-point, alarm, or timeout hooks are omitted.
19
21
 
22
+ call_nth(Goal, Nth) :- eyeprolog__call_nth(Goal, Nth).
23
+
20
24
  countall(Goal, Count) :- eyeprolog__countall(Goal, Count).
21
25
 
22
26
  forall(Condition, Action) :-
package/src/lib/lists.pl CHANGED
@@ -28,7 +28,6 @@
28
28
  min_list/2,
29
29
  max_list/2,
30
30
  list_to_set/2,
31
- countall/2,
32
31
  set_nth0/4,
33
32
  take/3,
34
33
  drop/3,
@@ -45,7 +44,6 @@
45
44
  :- meta_predicate(foldl(3, '?', '?', '?')).
46
45
  :- meta_predicate(foldl(4, '?', '?', '?', '?')).
47
46
  :- meta_predicate(foldl(5, '?', '?', '?', '?', '?')).
48
- :- meta_predicate(countall(0, '?')).
49
47
 
50
48
  % Common pure-Prolog library predicates for EyeProlog.
51
49
  %
@@ -168,7 +166,6 @@ max_list([X|Xs], Max) :- lists__max_list(Xs, X, Max).
168
166
 
169
167
  list_to_set(List, Set) :- lists__list_to_set(List, [], Set).
170
168
 
171
- countall(Goal, Count) :- eyeprolog__countall(Goal, Count).
172
169
 
173
170
  set_nth0(0, [_|Xs], X, [X|Xs]).
174
171
  set_nth0(N, [Y|Ys], X, [Y|Zs]) :-
package/src/program.js CHANGED
@@ -166,6 +166,9 @@ export class Program {
166
166
  tabled: false,
167
167
  recursive: false,
168
168
  tableInputPositions: [],
169
+ tableAllVariants: false,
170
+ datalogLeastModel: false,
171
+ wfsDatalog: false,
169
172
  scalarFactsOnly: true,
170
173
  dynamic: this.dynamicPredicates.has(modulePredicateKey(module, name, arity)),
171
174
  negationStratum: null,
@@ -308,6 +311,7 @@ export class Program {
308
311
  const deps = groups.map(() => new Set());
309
312
  const cutDeps = groups.map(() => new Set());
310
313
  const negativeEdges = [];
314
+ const wfsNegativeEdges = [];
311
315
  for (const group of groups) {
312
316
  const groupIndex = indexByGroup.get(group);
313
317
  for (const clause of group.clauses) {
@@ -337,12 +341,17 @@ export class Program {
337
341
  if (dep) {
338
342
  const dependencyIndex = indexByGroup.get(dep);
339
343
  deps[groupIndex].add(dependencyIndex);
340
- if (dependency.negative) negativeEdges.push([groupIndex, dependencyIndex]);
344
+ if (dependency.negative) {
345
+ negativeEdges.push([groupIndex, dependencyIndex]);
346
+ if (dependency.wfs === true) wfsNegativeEdges.push([groupIndex, dependencyIndex]);
347
+ }
341
348
  }
342
349
  }
343
350
  }
344
351
  }
345
352
  }
353
+ const finiteDatalogCache = new Map();
354
+ const rangeRestrictedDatalogCache = new Map();
346
355
  for (const group of groups) {
347
356
  const start = indexByGroup.get(group);
348
357
  const standardLibraryModule = group.module !== 'user' &&
@@ -386,6 +395,36 @@ export class Program {
386
395
  !componentHasNegativeEdge(start, deps, negativeEdges) &&
387
396
  !group.cutRecursive &&
388
397
  !linearNumeric;
398
+ // Function-free positive recursive rules have a finite Herbrand base.
399
+ // They can safely use a shared most-general table, which avoids computing
400
+ // a separate recursive closure for every bound input (TC/SG/Wine style).
401
+ group.tableAllVariants = group.tabled && isFiniteDatalogGroup(this, group, finiteDatalogCache) &&
402
+ datalogDependencyClauseCount(this, group) >= 128;
403
+ // Large finite, range-restricted positive Datalog can be evaluated as a
404
+ // single indexed least model. Keep tableAllVariants as the semantic
405
+ // fallback for finite programs whose answers may contain variables.
406
+ group.datalogLeastModel = group.tableAllVariants &&
407
+ isRangeRestrictedFiniteDatalogGroup(this, group, rangeRestrictedDatalogCache);
408
+ }
409
+
410
+ // Explicit tnot/1 opts finite, function-free Datalog into well-founded
411
+ // evaluation. Keep ISO \+/1 and not/1 on their existing negation-as-failure
412
+ // path: WFS is an extension with different three-valued semantics and must
413
+ // not silently change ordinary Prolog programs. A group is WFS-backed when
414
+ // its dependency cone reaches an SCC containing an edge introduced by
415
+ // tnot/1 and the whole cone is range-restricted finite Datalog.
416
+ const wfsCycleNodes = new Set();
417
+ for (const [from, to] of wfsNegativeEdges) {
418
+ if (!reachableIndexes(to, deps).has(from)) continue;
419
+ wfsCycleNodes.add(from);
420
+ wfsCycleNodes.add(to);
421
+ }
422
+ const finiteWfsCache = new Map();
423
+ for (const group of groups) {
424
+ const start = indexByGroup.get(group);
425
+ const reachable = reachableIndexes(start, deps);
426
+ const reachesWfsCycle = [...wfsCycleNodes].some((index) => reachable.has(index));
427
+ group.wfsDatalog = reachesWfsCycle && isFiniteWfsDatalogGroup(this, group, finiteWfsCache);
389
428
  }
390
429
  }
391
430
 
@@ -1253,6 +1292,185 @@ function reachableIndexes(start, deps) {
1253
1292
  return seen;
1254
1293
  }
1255
1294
 
1295
+
1296
+ function isFiniteDatalogArgument(term) {
1297
+ return term?.type === VAR || term?.type === ATOM || term?.type === 'string' || term?.type === 'number';
1298
+ }
1299
+
1300
+ function datalogDependencyClauseCount(program, group, seen = new Set()) {
1301
+ if (seen.has(group)) return 0;
1302
+ seen.add(group);
1303
+ let count = group.clauses.length;
1304
+ for (const clause of group.clauses) {
1305
+ if (isCompactBinaryClause(clause)) {
1306
+ if (clause.bodyName != null) {
1307
+ const target = program.findGroup(clause.bodyName, 2, group.module);
1308
+ if (target) count += datalogDependencyClauseCount(program, target, seen);
1309
+ }
1310
+ continue;
1311
+ }
1312
+ for (const goal of clause.body) {
1313
+ if (goal.type !== COMPOUND && goal.type !== ATOM) continue;
1314
+ const target = program.findGroup(goal.name, goal.arity, goal.module ?? group.module);
1315
+ if (target) count += datalogDependencyClauseCount(program, target, seen);
1316
+ }
1317
+ }
1318
+ return count;
1319
+ }
1320
+
1321
+ function isFiniteDatalogGroup(program, group, cache = new Map(), visiting = new Set()) {
1322
+ const cached = cache.get(group);
1323
+ if (cached != null) return cached;
1324
+ if (visiting.has(group)) return true;
1325
+ visiting.add(group);
1326
+ let finite = true;
1327
+ for (const clause of group.clauses) {
1328
+ if (isCompactBinaryClause(clause)) {
1329
+ if (clause.bodyName != null) {
1330
+ const target = program.findGroup(clause.bodyName, 2, group.module);
1331
+ if (!target || !isFiniteDatalogGroup(program, target, cache, visiting)) { finite = false; break; }
1332
+ }
1333
+ continue;
1334
+ }
1335
+ if (clause.head.type === COMPOUND && !clause.head.args.every(isFiniteDatalogArgument)) { finite = false; break; }
1336
+ if (clause.head.type !== COMPOUND && clause.head.type !== ATOM) { finite = false; break; }
1337
+ for (const goal of clause.body) {
1338
+ if (goal.type !== COMPOUND && goal.type !== ATOM) { finite = false; break; }
1339
+ if (goal.type === COMPOUND && !goal.args.every(isFiniteDatalogArgument)) { finite = false; break; }
1340
+ // Walk the whole positive dependency cone. Requiring every reachable
1341
+ // predicate to be source-defined and flat excludes builtins/generators
1342
+ // that could manufacture an unbounded stream of fresh terms.
1343
+ const target = program.findGroup(goal.name, goal.arity, goal.module ?? group.module);
1344
+ if (!target || !isFiniteDatalogGroup(program, target, cache, visiting)) { finite = false; break; }
1345
+ }
1346
+ if (!finite) break;
1347
+ }
1348
+ visiting.delete(group);
1349
+ cache.set(group, finite);
1350
+ return finite;
1351
+ }
1352
+
1353
+
1354
+ function isRangeRestrictedFiniteDatalogGroup(program, group, cache = new Map(), visiting = new Set()) {
1355
+ const cached = cache.get(group);
1356
+ if (cached != null) return cached;
1357
+ if (visiting.has(group)) return true;
1358
+ visiting.add(group);
1359
+ let finite = true;
1360
+
1361
+ for (const clause of group.clauses) {
1362
+ const head = clause.head;
1363
+ if ((head.type !== COMPOUND && head.type !== ATOM) ||
1364
+ (head.type === COMPOUND && !head.args.every(isFiniteDatalogArgument))) {
1365
+ finite = false;
1366
+ break;
1367
+ }
1368
+
1369
+ const headVariables = new Set();
1370
+ const positiveVariables = new Set();
1371
+ collectVariables(head, headVariables);
1372
+ for (const goal of clause.body) {
1373
+ if ((goal.type !== COMPOUND && goal.type !== ATOM) ||
1374
+ (goal.type === COMPOUND && !goal.args.every(isFiniteDatalogArgument))) {
1375
+ finite = false;
1376
+ break;
1377
+ }
1378
+ collectVariables(goal, positiveVariables);
1379
+ const target = program.findGroup(goal.name, goal.arity, goal.module ?? group.module);
1380
+ if (!target || !isRangeRestrictedFiniteDatalogGroup(program, target, cache, visiting)) {
1381
+ finite = false;
1382
+ break;
1383
+ }
1384
+ }
1385
+ if (!finite) break;
1386
+ for (const name of headVariables) {
1387
+ if (!positiveVariables.has(name)) {
1388
+ // Ground facts have no head variables and pass naturally. A variable
1389
+ // fact or a rule with an unbound head variable needs ordinary Prolog
1390
+ // table semantics rather than finite relational materialization.
1391
+ finite = false;
1392
+ break;
1393
+ }
1394
+ }
1395
+ if (!finite) break;
1396
+ }
1397
+
1398
+ visiting.delete(group);
1399
+ cache.set(group, finite);
1400
+ return finite;
1401
+ }
1402
+
1403
+
1404
+ function isFiniteWfsDatalogGroup(program, group, cache = new Map(), visiting = new Set()) {
1405
+ const cached = cache.get(group);
1406
+ if (cached != null) return cached;
1407
+ if (visiting.has(group)) return true;
1408
+ visiting.add(group);
1409
+ let finite = true;
1410
+
1411
+ for (const clause of group.clauses) {
1412
+ if (isCompactBinaryClause(clause)) {
1413
+ if (clause.bodyName != null) {
1414
+ const target = program.findGroup(clause.bodyName, 2, group.module);
1415
+ if (!target || !isFiniteWfsDatalogGroup(program, target, cache, visiting)) { finite = false; break; }
1416
+ }
1417
+ continue;
1418
+ }
1419
+ if ((clause.head.type !== COMPOUND && clause.head.type !== ATOM) ||
1420
+ (clause.head.type === COMPOUND && !clause.head.args.every(isFiniteDatalogArgument))) {
1421
+ finite = false;
1422
+ break;
1423
+ }
1424
+
1425
+ const positiveVariables = new Set();
1426
+ const requiredVariables = new Set();
1427
+ collectVariables(clause.head, requiredVariables);
1428
+ for (const goal of clause.body) {
1429
+ if (goal.type !== COMPOUND && goal.type !== ATOM) { finite = false; break; }
1430
+ if (goal.type === COMPOUND && goal.name === 'tnot' && goal.arity === 1) {
1431
+ const inner = goal.args[0];
1432
+ if ((inner.type !== COMPOUND && inner.type !== ATOM) ||
1433
+ (inner.type === COMPOUND && !inner.args.every(isFiniteDatalogArgument))) {
1434
+ finite = false;
1435
+ break;
1436
+ }
1437
+ collectVariables(inner, requiredVariables);
1438
+ const target = program.findGroup(inner.name, inner.arity, inner.module ?? group.module);
1439
+ if (!target || !isFiniteWfsDatalogGroup(program, target, cache, visiting)) { finite = false; break; }
1440
+ continue;
1441
+ }
1442
+ // WFS is explicit. Ordinary \+/1 and not/1 retain NAF and therefore
1443
+ // disqualify this component from the WFS evaluator.
1444
+ if (goal.type === COMPOUND && (goal.name === '\\+' || goal.name === 'not')) {
1445
+ finite = false;
1446
+ break;
1447
+ }
1448
+ if (goal.type === COMPOUND && !goal.args.every(isFiniteDatalogArgument)) { finite = false; break; }
1449
+ collectVariables(goal, positiveVariables);
1450
+ const target = program.findGroup(goal.name, goal.arity, goal.module ?? group.module);
1451
+ if (!target || !isFiniteWfsDatalogGroup(program, target, cache, visiting)) { finite = false; break; }
1452
+ }
1453
+ if (!finite) break;
1454
+ for (const name of requiredVariables) {
1455
+ if (!positiveVariables.has(name)) { finite = false; break; }
1456
+ }
1457
+ if (!finite) break;
1458
+ }
1459
+
1460
+ visiting.delete(group);
1461
+ cache.set(group, finite);
1462
+ return finite;
1463
+ }
1464
+
1465
+ function collectVariables(term, output) {
1466
+ if (!term) return;
1467
+ if (term.type === VAR) {
1468
+ output.add(term.name);
1469
+ return;
1470
+ }
1471
+ for (const arg of term.args ?? []) collectVariables(arg, output);
1472
+ }
1473
+
1256
1474
  function inferStructuralInputPositions(group) {
1257
1475
  let firstPatternedPosition = -1;
1258
1476
  let firstLinkedInputPosition = -1;
@@ -1304,14 +1522,31 @@ function hasStrictListTailRecursion(group) {
1304
1522
  let foundRecursiveCall = false;
1305
1523
  for (const clause of group.clauses) {
1306
1524
  if (isCompactBinaryClause(clause)) return false;
1307
- for (const goal of clause.body) {
1525
+ for (let goalIndex = 0; goalIndex < clause.body.length; goalIndex++) {
1526
+ const goal = clause.body[goalIndex];
1308
1527
  if (goal.type !== COMPOUND || goal.name !== group.name || goal.arity !== group.arity) continue;
1309
1528
  foundRecursiveCall = true;
1310
1529
  const decreases = goal.args.some((argument, index) => {
1311
1530
  const head = clause.head.args[index];
1312
- return head?.type === COMPOUND && head.name === '.' && head.arity === 2 &&
1313
- head.args[1]?.type === VAR && argument?.type === VAR &&
1314
- head.args[1].name === argument.name;
1531
+ if (head?.type === COMPOUND && head.name === '.' && head.arity === 2 &&
1532
+ head.args[1]?.type === VAR && argument?.type === VAR &&
1533
+ head.args[1].name === argument.name) return true;
1534
+ // DCG expansion represents [_], nt as Input = [_|Tail], nt(Tail,...).
1535
+ // Treat that normalized prefix unification as the same strict list-tail
1536
+ // decrease so phrase/2-3 can choose a non-tabled fast path for new
1537
+ // inputs while retaining ordinary tabling for repeated invocations.
1538
+ if (head?.type !== VAR || argument?.type !== VAR) return false;
1539
+ for (let prefixIndex = 0; prefixIndex < goalIndex; prefixIndex++) {
1540
+ const prefix = clause.body[prefixIndex];
1541
+ if (prefix?.type !== COMPOUND || prefix.name !== '=' || prefix.arity !== 2) continue;
1542
+ for (const [left, right] of [[prefix.args[0], prefix.args[1]], [prefix.args[1], prefix.args[0]]]) {
1543
+ if (left?.type !== VAR || left.name !== head.name ||
1544
+ right?.type !== COMPOUND || right.name !== '.' || right.arity !== 2 ||
1545
+ right.args[1]?.type !== VAR) continue;
1546
+ if (right.args[1].name === argument.name) return true;
1547
+ }
1548
+ }
1549
+ return false;
1315
1550
  });
1316
1551
  if (!decreases) return false;
1317
1552
  }
@@ -1377,7 +1612,7 @@ function directGoalDependencyKey(goal) {
1377
1612
  if (goal.type === ATOM) return `${goal.name}/0`;
1378
1613
  if (goal.type !== COMPOUND) return null;
1379
1614
  if (goal.name === ',' && goal.arity === 2) return null;
1380
- if ((goal.name === '\\+' || goal.name === 'not') && goal.arity === 1) return null;
1615
+ if ((goal.name === '\\+' || goal.name === 'not' || goal.name === 'tnot') && goal.arity === 1) return null;
1381
1616
  if (goal.name === 'once' && goal.arity === 1) return null;
1382
1617
  if (goal.name === 'forall' && goal.arity === 2) return null;
1383
1618
  if ((goal.name === 'findall' || goal.name === 'sumall') && goal.arity === 3) return null;
@@ -1386,43 +1621,46 @@ function directGoalDependencyKey(goal) {
1386
1621
  return `${goal.name}/${goal.arity}`;
1387
1622
  }
1388
1623
 
1389
- function collectGoalDependencies(goal, negated, traverseConditionals = false) {
1390
- if (goal.type === ATOM) return [{ key: `${goal.name}/0`, name: goal.name, arity: 0, module: goal.module, negative: negated }];
1624
+ function collectGoalDependencies(goal, negated, traverseConditionals = false, wfs = false) {
1625
+ if (goal.type === ATOM) return [{ key: `${goal.name}/0`, name: goal.name, arity: 0, module: goal.module, negative: negated, wfs }];
1391
1626
  if (goal.type !== COMPOUND) return [];
1392
1627
  if (goal.name === ',' && goal.arity === 2) {
1393
1628
  return [
1394
- ...collectGoalDependencies(goal.args[0], negated, traverseConditionals),
1395
- ...collectGoalDependencies(goal.args[1], negated, traverseConditionals),
1629
+ ...collectGoalDependencies(goal.args[0], negated, traverseConditionals, wfs),
1630
+ ...collectGoalDependencies(goal.args[1], negated, traverseConditionals, wfs),
1396
1631
  ];
1397
1632
  }
1398
1633
  if (traverseConditionals && (goal.name === ';' || goal.name === '->') && goal.arity === 2) {
1399
1634
  return [
1400
- ...collectGoalDependencies(goal.args[0], negated, true),
1401
- ...collectGoalDependencies(goal.args[1], negated, true),
1635
+ ...collectGoalDependencies(goal.args[0], negated, true, wfs),
1636
+ ...collectGoalDependencies(goal.args[1], negated, true, wfs),
1402
1637
  ];
1403
1638
  }
1404
1639
  if ((goal.name === '\\+' || goal.name === 'not') && goal.arity === 1) {
1405
- return collectGoalDependencies(goal.args[0], !negated, traverseConditionals);
1640
+ return collectGoalDependencies(goal.args[0], !negated, traverseConditionals, wfs);
1641
+ }
1642
+ if (goal.name === 'tnot' && goal.arity === 1) {
1643
+ return collectGoalDependencies(goal.args[0], !negated, traverseConditionals, true);
1406
1644
  }
1407
1645
  if (goal.name === 'once' && goal.arity === 1) {
1408
- return collectGoalDependencies(goal.args[0], negated, traverseConditionals);
1646
+ return collectGoalDependencies(goal.args[0], negated, traverseConditionals, wfs);
1409
1647
  }
1410
1648
  if (goal.name === 'forall' && goal.arity === 2) {
1411
1649
  return [
1412
- ...collectGoalDependencies(goal.args[0], negated, traverseConditionals),
1413
- ...collectGoalDependencies(goal.args[1], negated, traverseConditionals),
1650
+ ...collectGoalDependencies(goal.args[0], negated, traverseConditionals, wfs),
1651
+ ...collectGoalDependencies(goal.args[1], negated, traverseConditionals, wfs),
1414
1652
  ];
1415
1653
  }
1416
1654
  if ((goal.name === 'findall' || goal.name === 'sumall') && goal.arity === 3) {
1417
- return collectGoalDependencies(goal.args[1], negated, traverseConditionals);
1655
+ return collectGoalDependencies(goal.args[1], negated, traverseConditionals, wfs);
1418
1656
  }
1419
1657
  if (goal.name === 'countall' && goal.arity === 2) {
1420
- return collectGoalDependencies(goal.args[0], negated, traverseConditionals);
1658
+ return collectGoalDependencies(goal.args[0], negated, traverseConditionals, wfs);
1421
1659
  }
1422
1660
  if ((goal.name === 'aggregate_min' || goal.name === 'aggregate_max') && goal.arity === 5) {
1423
- return collectGoalDependencies(goal.args[2], negated, traverseConditionals);
1661
+ return collectGoalDependencies(goal.args[2], negated, traverseConditionals, wfs);
1424
1662
  }
1425
- return [{ key: `${goal.name}/${goal.arity}`, name: goal.name, arity: goal.arity, module: goal.module, negative: negated }];
1663
+ return [{ key: `${goal.name}/${goal.arity}`, name: goal.name, arity: goal.arity, module: goal.module, negative: negated, wfs }];
1426
1664
  }
1427
1665
 
1428
1666
  function stronglyConnectedComponents(adjacency) {