eyeprolog 1.4.7 → 1.4.10

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/index.d.ts CHANGED
@@ -27,6 +27,8 @@ export interface EyePrologRunOptions {
27
27
  isoStrict?: boolean;
28
28
  /** Autoload uniquely mapped predicates from the conservative interop profile. Defaults to true outside strict ISO mode. */
29
29
  autoload?: boolean;
30
+ /** Automatically table recursive user predicates in normal mode. Defaults to true; set false for traditional depth-first control. */
31
+ autoTabling?: boolean;
30
32
  /** Initial ISO interpretation of double-quoted list notation. Defaults to chars. */
31
33
  doubleQuotes?: 'chars' | 'codes' | 'atom';
32
34
  ioOptions?: {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.4.7",
6
+ "version": "1.4.10",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/cleanup.js CHANGED
@@ -46,7 +46,7 @@ export function installCleanupLifecycle(Solver) {
46
46
  if (!completed) {
47
47
  let closeError = null;
48
48
  try {
49
- closeOwnedSearchStacks(this, state, owner, thrown != null);
49
+ if (state.active) closeOwnedSearchStacks(this, state, owner, thrown != null);
50
50
  } catch (error) {
51
51
  closeError = error;
52
52
  }
@@ -71,6 +71,7 @@ function ensureLifecycleState(solver) {
71
71
  const state = {
72
72
  owner: null,
73
73
  stackOwners: new WeakMap(),
74
+ active: false,
74
75
  };
75
76
  Object.defineProperty(solver, cleanupLifecycleState, { value: state });
76
77
 
@@ -81,7 +82,7 @@ function ensureLifecycleState(solver) {
81
82
  solveStacks.push = function cleanupAwarePush(...stacks) {
82
83
  for (const stack of stacks) {
83
84
  state.stackOwners.set(stack, state.owner);
84
- patchSearchStack(stack);
85
+ if (state.active) patchSearchStack(stack);
85
86
  }
86
87
  return originalPush.apply(this, stacks);
87
88
  };
@@ -92,8 +93,10 @@ function ensureLifecycleState(solver) {
92
93
  // unwinding an exception, a cleanup exception must not replace it. Normal
93
94
  // consumer pruning closes frames earlier in cleanupAwareSolve(), where
94
95
  // cleanup errors are allowed to propagate.
95
- for (let index = removed.length - 1; index >= 0; index--) {
96
- closeSearchStack(removed[index], true);
96
+ if (state.active) {
97
+ for (let index = removed.length - 1; index >= 0; index--) {
98
+ closeSearchStack(removed[index], true);
99
+ }
97
100
  }
98
101
  return removed;
99
102
  };
@@ -111,6 +114,17 @@ function withOwner(state, owner, operation) {
111
114
  }
112
115
  }
113
116
 
117
+ function activateCleanupLifecycle(solver) {
118
+ const state = ensureLifecycleState(solver);
119
+ if (state.active) return state;
120
+ state.active = true;
121
+ for (const stack of solver.solveStacks) {
122
+ if (!state.stackOwners.has(stack)) state.stackOwners.set(stack, state.owner);
123
+ patchSearchStack(stack);
124
+ }
125
+ return state;
126
+ }
127
+
114
128
  function patchSearchStack(stack) {
115
129
  if (stack[searchStackPatched]) return;
116
130
  Object.defineProperty(stack, searchStackPatched, { value: true });
@@ -189,11 +203,13 @@ function firstSetupSolution(solver, setup, env) {
189
203
  }
190
204
 
191
205
  function callCleanupBuiltin({ solver, goal, env }) {
206
+ activateCleanupLifecycle(solver);
192
207
  const cleanup = callableTerm(goal.args[1], env);
193
208
  return cleanupProtectedIterator(solver, goal.args[0], cleanup, env);
194
209
  }
195
210
 
196
211
  function setupCallCleanupBuiltin({ solver, goal, env }) {
212
+ activateCleanupLifecycle(solver);
197
213
  let protectedIterator = null;
198
214
  let pending = true;
199
215
  const iterator = (function* setupThenProtected() {
package/src/cli.js CHANGED
@@ -30,6 +30,7 @@ export async function main(argv) {
30
30
  isoStrict: false,
31
31
  portable: false,
32
32
  autoload: true,
33
+ autoTabling: true,
33
34
  version: false,
34
35
  warnings: false,
35
36
  goals: [],
@@ -57,6 +58,8 @@ export async function main(argv) {
57
58
  options.portable = true;
58
59
  } else if (!endOptions && arg === '--no-autoload') {
59
60
  options.autoload = false;
61
+ } else if (!endOptions && arg === '--no-auto-table') {
62
+ options.autoTabling = false;
60
63
  } else if (!endOptions && (arg === '--version' || arg === '-v')) {
61
64
  options.version = true;
62
65
  } else if (!endOptions && (arg === '--warnings' || arg === '-w')) {
@@ -153,7 +156,9 @@ export async function main(argv) {
153
156
  sourceMetadata: options.proof || options.isoStrict,
154
157
  isoStrict: options.isoStrict,
155
158
  autoload: options.autoload,
159
+ autoTabling: options.autoTabling,
156
160
  autoloadGoals: options.goals,
161
+ onWarning: printSourceWarning,
157
162
  });
158
163
 
159
164
  const portabilityFailures = program.interopPortabilityWarnings ?? [];
@@ -204,7 +209,10 @@ async function runDefault(engine, program, options) {
204
209
  const solver = new engine.Solver(program, {
205
210
  registry,
206
211
  isoStrict: options.isoStrict,
207
- ioOptions: { write: (text) => process.stdout.write(String(text)) },
212
+ ioOptions: {
213
+ write: (text) => process.stdout.write(String(text)),
214
+ errorWrite: (text) => process.stderr.write(String(text)),
215
+ },
208
216
  });
209
217
  program = solver.program;
210
218
  const goals = engine.normalizeGoals(options.goals, solver);
@@ -251,6 +259,7 @@ Options:
251
259
  reject EyeProlog language extensions and disable automatic tabling.
252
260
  --portable Enforce the EyeProlog/Trealla/Scryer interop profile.
253
261
  --no-autoload Disable conservative interop predicate autoloading.
262
+ --no-auto-table Use traditional depth-first control instead of automatic recursive tabling.
254
263
  -v, --version Show the package version and exit.
255
264
  -w, --warnings Print non-fatal portability warnings to stderr.
256
265
  -g, --goal goal Solve goal and print its ground answers; may be repeated.
@@ -271,6 +280,13 @@ function readStdin() {
271
280
  });
272
281
  }
273
282
 
283
+
284
+ function printSourceWarning(warning) {
285
+ if (warning.kind !== 'singleton') return;
286
+ process.stderr.write(`Warning: singleton: ${warning.name}, near ${warning.filename}:${warning.line}
287
+ `);
288
+ }
289
+
274
290
  function printWarnings(program) {
275
291
  for (const warning of program.interopPortabilityWarnings ?? []) {
276
292
  if (warning.kind === 'library') {
package/src/io.js CHANGED
@@ -64,6 +64,12 @@ export class StreamManager {
64
64
  this.add({ id: 1, alias: 'user_output', mode: 'append', type: 'text',
65
65
  content: '', position: 0, path: '', reposition: false,
66
66
  eofAction: 'reset', standard: true, write: this.output, pastEnd: false });
67
+ // user_error is a conventional de-facto standard stream used by the
68
+ // portable Scryer/Trealla libraries. Keep it on a negative internal handle
69
+ // so the first user-opened ISO stream retains its historical id 2.
70
+ this.add({ id: -1, alias: 'user_error', mode: 'append', type: 'text',
71
+ content: '', position: 0, path: '', reposition: false,
72
+ eofAction: 'reset', standard: true, write: options.errorWrite ?? this.output, pastEnd: false });
67
73
  this.currentInput = 0;
68
74
  this.currentOutput = 1;
69
75
  }
package/src/iso.js CHANGED
@@ -581,19 +581,85 @@ function isPartialList(list, env) {
581
581
  return cursor.type === VAR;
582
582
  }
583
583
 
584
+ const freshCopyGroundCache = new WeakMap();
585
+
586
+ function structurallyVariableFree(term) {
587
+ if (term?.type === VAR) return false;
588
+ if (term?.type !== COMPOUND) return true;
589
+ const known = freshCopyGroundCache.get(term);
590
+ if (known !== undefined) return known;
591
+
592
+ const pending = [{ term, visited: false }];
593
+ while (pending.length > 0) {
594
+ const frame = pending.pop();
595
+ const current = frame.term;
596
+ if (current?.type !== COMPOUND || freshCopyGroundCache.has(current)) continue;
597
+ if (!frame.visited) {
598
+ pending.push({ term: current, visited: true });
599
+ for (let index = current.args.length - 1; index >= 0; index--) {
600
+ const child = current.args[index];
601
+ if (child?.type === COMPOUND && !freshCopyGroundCache.has(child)) {
602
+ pending.push({ term: child, visited: false });
603
+ }
604
+ }
605
+ continue;
606
+ }
607
+ let ground = true;
608
+ for (const child of current.args) {
609
+ if (child?.type === VAR || (child?.type === COMPOUND && freshCopyGroundCache.get(child) !== true)) {
610
+ ground = false;
611
+ break;
612
+ }
613
+ }
614
+ freshCopyGroundCache.set(current, ground);
615
+ }
616
+ return freshCopyGroundCache.get(term) === true;
617
+ }
618
+
584
619
  function freshCopy(term, env, variables = new Map(), id = ++isoFresh) {
585
- term = deref(term, env);
586
- if (term.type === VAR) {
587
- if (!variables.has(term.name)) variables.set(term.name, variable(`__copy${id}_${variables.size}`));
588
- return variables.get(term.name);
620
+ const copyNode = (source) => {
621
+ if (source.type === VAR) {
622
+ if (!variables.has(source.name)) variables.set(source.name, variable(`__copy${id}_${variables.size}`));
623
+ return variables.get(source.name);
624
+ }
625
+ if (source.type !== COMPOUND) return source;
626
+ // Ground terms are immutable in EyeProlog. Sharing them is observably
627
+ // equivalent to copying and avoids rebuilding large lists for assertz/1,
628
+ // copy_term/2, bagof/findall storage, and exception terms.
629
+ if (structurallyVariableFree(source)) return source;
630
+ const copied = compound(source.name, new Array(source.args.length));
631
+ // Module qualification is execution context, not logical variable state.
632
+ // Preserve it across copy_term/2 so meta-predicate closures keep the caller
633
+ // module they received before a library copies and later invokes them.
634
+ if (source.module != null) copied.module = source.module;
635
+ return copied;
636
+ };
637
+
638
+ const source = deref(term, env);
639
+ const root = copyNode(source);
640
+ if (source.type !== COMPOUND || source.args.length === 0) return root;
641
+
642
+ // copy_term/2 is routinely used on long lists and large machine states.
643
+ // Traverse iteratively so term depth is independent of the host call stack.
644
+ // The map also preserves rational-tree cycles when occurs_check is disabled.
645
+ const copies = new Map([[source, root]]);
646
+ const pending = [{ source, target: root }];
647
+ while (pending.length > 0) {
648
+ const current = pending.pop();
649
+ for (let index = 0; index < current.source.args.length; index++) {
650
+ const childSource = deref(current.source.args[index], env);
651
+ let child = childSource.type === COMPOUND ? copies.get(childSource) : null;
652
+ if (child == null) {
653
+ child = copyNode(childSource);
654
+ if (childSource.type === COMPOUND && child !== childSource) {
655
+ copies.set(childSource, child);
656
+ if (childSource.args.length > 0) pending.push({ source: childSource, target: child });
657
+ }
658
+ }
659
+ current.target.args[index] = child;
660
+ }
589
661
  }
590
- if (term.type !== COMPOUND) return term;
591
- const copied = compound(term.name, term.args.map((arg) => freshCopy(arg, env, variables, id)));
592
- // Module qualification is execution context, not logical variable state.
593
- // Preserve it across copy_term/2 so meta-predicate closures keep the caller
594
- // module they received before a library copies and later invokes them.
595
- if (term.module != null) copied.module = term.module;
596
- return copied;
662
+ return root;
597
663
  }
598
664
  function* copyTermBuiltin({ goal, env }) {
599
665
  const next = env.clone();
@@ -609,13 +675,15 @@ function* termVariablesBuiltin({ goal, env }) {
609
675
  }
610
676
  const found = [];
611
677
  const seen = new Set();
612
- const visit = (term) => {
613
- term = deref(term, env);
678
+ const pending = [goal.args[0]];
679
+ while (pending.length > 0) {
680
+ const term = deref(pending.pop(), env);
614
681
  if (term.type === VAR) {
615
682
  if (!seen.has(term.name)) { seen.add(term.name); found.push(term); }
616
- } else for (const arg of term.args) visit(arg);
617
- };
618
- visit(goal.args[0]);
683
+ continue;
684
+ }
685
+ for (let index = term.args.length - 1; index >= 0; index--) pending.push(term.args[index]);
686
+ }
619
687
  const next = env.clone();
620
688
  if (unify(goal.args[1], listFromItems(found), next)) yield next;
621
689
  }
@@ -28,8 +28,7 @@ call_nth(Goal, Nth) :- eyeprolog__call_nth(Goal, Nth).
28
28
  countall(Goal, Count) :- eyeprolog__countall(Goal, Count).
29
29
 
30
30
  forall(Condition, Action) :-
31
- findall(Action, Condition, Actions),
32
- iso_ext__call_all(Actions).
31
+ \+ (Condition, \+ Action).
33
32
 
34
33
  succ(X, S) :-
35
34
  var(X), !,
package/src/lib/random.pl CHANGED
@@ -1,12 +1,13 @@
1
1
  /** Reproducible pseudo-random values.
2
2
 
3
3
  random/3 is EyeProlog's explicit-state interface. maybe/0,
4
- random_integer/3, and set_random/1 are the common Scryer/Trealla library
5
- interface; they use a non-backtrackable seed while retaining the same
6
- Park-Miller generator.
4
+ random/1, random_integer/3, and set_random/1 are the mutable-seed library
5
+ interface used for compatibility. random/1 uses EyeProlog's private native
6
+ state step for hot-loop performance; random/3 remains the portable explicit-
7
+ state Park-Miller generator, and both retain the same sequence.
7
8
  */
8
9
 
9
- :- module(random, [maybe/0, random/3, random_integer/3, set_random/1]).
10
+ :- module(random, [maybe/0, random/1, random/3, random_integer/3, set_random/1]).
10
11
 
11
12
  :- use_module(library(debug), [bb_global_get/2, bb_put/2]).
12
13
  :- use_module(library(error), [instantiation_error/1, type_error/3]).
@@ -14,6 +15,10 @@
14
15
  maybe :-
15
16
  random_integer(0, 2, 0).
16
17
 
18
+
19
+ random(Value) :-
20
+ eyeprolog__random_value(Value).
21
+
17
22
  random_integer(Lower, Upper, R) :-
18
23
  ( var(Lower) -> instantiation_error(random_integer/3)
19
24
  ; var(Upper) -> instantiation_error(random_integer/3)
package/src/parser.js CHANGED
@@ -246,6 +246,8 @@ class Parser {
246
246
  this.anonymous = 0;
247
247
  this.variables = new Map();
248
248
  this.sourceMetadata = options.sourceMetadata !== false;
249
+ this.onWarning = typeof options.onWarning === 'function' ? options.onWarning : null;
250
+ this.currentSourceTermLine = 1;
249
251
  this.strictIso = options.isoStrict === true;
250
252
  this.parserFlagState = options.parserFlagState ?? {
251
253
  doubleQuotes: options.doubleQuotes ?? 'chars',
@@ -1010,9 +1012,14 @@ class Parser {
1010
1012
  parseProgram(emit = null) {
1011
1013
  const clauses = emit ? null : [];
1012
1014
  let clauseNumber = 0;
1013
- const accept = emit ?? ((clause) => clauses.push(clause));
1015
+ const rawAccept = emit ?? ((clause) => clauses.push(clause));
1016
+ const accept = (item) => {
1017
+ reportClauseSingletonWarnings(item, this.onWarning, this.filename, this.currentSourceTermLine);
1018
+ rawAccept(item);
1019
+ };
1014
1020
  while (this.token.type !== TOK.EOF) {
1015
1021
  const line = this.token.line;
1022
+ this.currentSourceTermLine = line;
1016
1023
  // Prefix operator notation needs one program-level distinction so
1017
1024
  // a comma in `?- A, B.` remains inside the query rather than outside the
1018
1025
  // prefix term. Ordinary functional notation is parsed as a term and then
@@ -1226,6 +1233,29 @@ function listAtomNames(term) {
1226
1233
  }
1227
1234
 
1228
1235
 
1236
+ function collectClauseVariableOccurrences(term, counts) {
1237
+ if (term == null) return;
1238
+ if (term.type === 'var') {
1239
+ counts.set(term.name, (counts.get(term.name) ?? 0) + 1);
1240
+ return;
1241
+ }
1242
+ if (term.type === COMPOUND) {
1243
+ for (const arg of term.args) collectClauseVariableOccurrences(arg, counts);
1244
+ }
1245
+ }
1246
+
1247
+ function reportClauseSingletonWarnings(clause, onWarning, filename, line) {
1248
+ if (typeof onWarning !== 'function' || clause?.kind === 'quad') return;
1249
+ const counts = new Map();
1250
+ collectClauseVariableOccurrences(clause.head, counts);
1251
+ for (const goal of clause.body ?? []) collectClauseVariableOccurrences(goal, counts);
1252
+ for (const [name, count] of counts) {
1253
+ if (count !== 1 || String(name).startsWith('_')) continue;
1254
+ onWarning({ kind: 'singleton', name, filename: filename ?? '<input>', line });
1255
+ }
1256
+ }
1257
+
1258
+
1229
1259
  export function parseClauses(source, options = {}) {
1230
1260
  const ownsParserFlagState = options.parserFlagState == null;
1231
1261
  const initialDoubleQuotes = options.doubleQuotes ?? 'chars';
@@ -1282,6 +1312,7 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1282
1312
  const accept = emit ?? ((clause) => clauses.push(clause));
1283
1313
  let anonymous = 0;
1284
1314
  let chunk = '';
1315
+ let chunkStartLine = 1;
1285
1316
 
1286
1317
  const cached = (cache, key, create) => {
1287
1318
  const existing = cache.get(key);
@@ -1466,12 +1497,42 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1466
1497
  return -1;
1467
1498
  };
1468
1499
 
1469
- const emitFastBinaryRange = (text, start, end) => {
1500
+ const reportFastBinarySingletonWarnings = (head, body, line) => {
1501
+ if (typeof options.onWarning !== 'function') return;
1502
+ const variables = [];
1503
+ const add = (type, name) => {
1504
+ if (type !== 'var' || String(name).startsWith('_')) return;
1505
+ variables.push(name);
1506
+ };
1507
+ add(head.arg0Type, head.arg0Name);
1508
+ add(head.arg1Type, head.arg1Name);
1509
+ if (body != null) {
1510
+ add(body.arg0Type, body.arg0Name);
1511
+ add(body.arg1Type, body.arg1Name);
1512
+ }
1513
+ for (let i = 0; i < variables.length; i++) {
1514
+ let count = 0;
1515
+ for (let j = 0; j < variables.length; j++) {
1516
+ if (variables[i] === variables[j]) count++;
1517
+ }
1518
+ if (count === 1) {
1519
+ options.onWarning({
1520
+ kind: 'singleton',
1521
+ name: variables[i],
1522
+ filename: options.filename ?? '<input>',
1523
+ line,
1524
+ });
1525
+ }
1526
+ }
1527
+ };
1528
+
1529
+ const emitFastBinaryRange = (text, start, end, line) => {
1470
1530
  if (!emitBinary || start >= end || text.charCodeAt(end - 1) !== 46) return false;
1471
1531
  const termEnd = end - 1;
1472
1532
  const rule = findRuleInRange(text, start, termEnd);
1473
1533
  if (rule < 0) {
1474
1534
  if (!parseBinaryRawRange(text, start, termEnd, rawHead)) return false;
1535
+ reportFastBinarySingletonWarnings(rawHead, null, line);
1475
1536
  emitBinary(rawHead.name,
1476
1537
  rawHead.arg0Type, rawHead.arg0Name, rawHead.arg1Type, rawHead.arg1Name,
1477
1538
  null, null, null, null, null);
@@ -1480,6 +1541,7 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1480
1541
  if (findRuleInRange(text, rule + 2, termEnd) >= 0 ||
1481
1542
  !parseBinaryRawRange(text, start, rule, rawHead) ||
1482
1543
  !parseBinaryRawRange(text, rule + 2, termEnd, rawBody)) return false;
1544
+ reportFastBinarySingletonWarnings(rawHead, rawBody, line);
1483
1545
  emitBinary(rawHead.name,
1484
1546
  rawHead.arg0Type, rawHead.arg0Name, rawHead.arg1Type, rawHead.arg1Name,
1485
1547
  rawBody.name, rawBody.arg0Type, rawBody.arg0Name, rawBody.arg1Type, rawBody.arg1Name);
@@ -1541,6 +1603,7 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1541
1603
 
1542
1604
  const flush = () => {
1543
1605
  const text = chunk.trim();
1606
+ const sourceLine = chunkStartLine;
1544
1607
  chunk = '';
1545
1608
  if (!text) return true;
1546
1609
  // Once a preparation-time char_conversion/2 mapping is active, every
@@ -1549,11 +1612,15 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1549
1612
  // here would silently bypass Convc for otherwise simple clauses.
1550
1613
  const simple = preparationConversionActive() ? null : parseSimple(text);
1551
1614
  if (simple) {
1615
+ reportClauseSingletonWarnings(simple, options.onWarning, options.filename ?? '<input>', sourceLine);
1552
1616
  accept(simple);
1553
1617
  return true;
1554
1618
  }
1555
1619
  try {
1556
- const parsed = new Parser(text, { ...options, sourceMetadata: false }).parseProgram();
1620
+ const nestedWarning = typeof options.onWarning === 'function'
1621
+ ? (warning) => options.onWarning({ ...warning, line: warning.line + sourceLine - 1 })
1622
+ : null;
1623
+ const parsed = new Parser(text, { ...options, sourceMetadata: false, onWarning: nestedWarning }).parseProgram();
1557
1624
  for (const clause of parsed) accept(clause);
1558
1625
  return true;
1559
1626
  } catch (_) {
@@ -1562,6 +1629,7 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1562
1629
  };
1563
1630
 
1564
1631
  let lineStart = 0;
1632
+ let lineNumber = 1;
1565
1633
  while (lineStart <= source.length) {
1566
1634
  let lineEnd = source.indexOf('\n', lineStart);
1567
1635
  if (lineEnd < 0) lineEnd = source.length;
@@ -1571,17 +1639,21 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1571
1639
  [contentStart, contentEnd] = trimRange(source, contentStart, contentEnd);
1572
1640
  if (contentStart < contentEnd && source.charCodeAt(contentStart) !== 37) {
1573
1641
  if (!chunk && source.charCodeAt(contentEnd - 1) === 46 && !preparationConversionActive()) {
1574
- if (emitFastBinaryRange(source, contentStart, contentEnd)) {
1642
+ if (emitFastBinaryRange(source, contentStart, contentEnd, lineNumber)) {
1575
1643
  // The program builder accepted a compact binary clause directly.
1576
1644
  } else {
1577
1645
  const simple = parseFastRange(source, contentStart, contentEnd);
1578
- if (simple) accept(simple);
1579
- else {
1646
+ if (simple) {
1647
+ reportClauseSingletonWarnings(simple, options.onWarning, options.filename ?? '<input>', lineNumber);
1648
+ accept(simple);
1649
+ } else {
1650
+ chunkStartLine = lineNumber;
1580
1651
  chunk = source.slice(lineStart, lineEnd) + '\n';
1581
1652
  if (!flush()) return null;
1582
1653
  }
1583
1654
  }
1584
1655
  } else {
1656
+ if (!chunk) chunkStartLine = lineNumber;
1585
1657
  chunk += source.slice(lineStart, lineEnd) + '\n';
1586
1658
  if (source.charCodeAt(contentEnd - 1) === 46) {
1587
1659
  if (!flush()) return null;
@@ -1590,6 +1662,7 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1590
1662
  }
1591
1663
  if (lineEnd === source.length) break;
1592
1664
  lineStart = lineEnd + 1;
1665
+ lineNumber++;
1593
1666
  }
1594
1667
  if (chunk.trim() && !flush()) return null;
1595
1668
  return clauses ?? true;
@@ -86,13 +86,24 @@ export function clauseHasCut(clause) {
86
86
  }
87
87
 
88
88
  export function termContainsCut(term) {
89
- if (term.type === ATOM) return term.name === '!';
90
- return term.type === COMPOUND && term.args.some(termContainsCut);
89
+ const pending = [term];
90
+ while (pending.length > 0) {
91
+ const current = pending.pop();
92
+ if (current?.type === ATOM && current.name === '!') return true;
93
+ if (current?.type !== COMPOUND) continue;
94
+ for (let index = current.args.length - 1; index >= 0; index--) pending.push(current.args[index]);
95
+ }
96
+ return false;
91
97
  }
92
98
 
93
99
  export function termHasNoVariables(term) {
94
- if (!term || term.type === 'var') return false;
95
- return !term.args?.some((arg) => !termHasNoVariables(arg));
100
+ const pending = [term];
101
+ while (pending.length > 0) {
102
+ const current = pending.pop();
103
+ if (!current || current.type === 'var') return false;
104
+ for (let index = current.args?.length - 1 ?? -1; index >= 0; index--) pending.push(current.args[index]);
105
+ }
106
+ return true;
96
107
  }
97
108
 
98
109
  // These defaults mirror SWI-Prolog's JITI admission policy: small predicates
@@ -110,7 +121,51 @@ export function makeArgumentIndex() {
110
121
  numberBuckets: new Map(),
111
122
  fallback: [],
112
123
  sawScalar: false,
124
+ // Dynamic predicates often store fully-ground structured keys such as
125
+ // seen(answer(...)). Keep an exact structural hash alongside the scalar
126
+ // indexes so repeated membership checks do not scan every prior clause.
127
+ groundBuckets: new Map(),
128
+ groundFallback: [],
129
+ sawGround: false,
130
+ };
131
+ }
132
+
133
+ function groundTermIndexKey(term, env = null) {
134
+ // Use a compact structural hash instead of materializing the complete term
135
+ // spelling as a Map key. Dynamic bookkeeping predicates frequently store
136
+ // long ground lists; a fixed-size hash avoids retaining another full copy of
137
+ // every list merely for indexing. Hash collisions are harmless because the
138
+ // selected clauses are still matched normally by the solver.
139
+ let hash = 0x811c9dc5;
140
+ const mixCode = (code) => { hash = Math.imul(hash ^ code, 0x01000193) >>> 0; };
141
+ const mixString = (text) => {
142
+ for (let index = 0; index < text.length; index++) mixCode(text.charCodeAt(index));
143
+ mixCode(0xff);
113
144
  };
145
+ const stack = [term];
146
+ while (stack.length > 0) {
147
+ const raw = stack.pop();
148
+ const current = env == null ? raw : deref(raw, env);
149
+ if (current?.type === VAR) return null;
150
+ if (current?.type === COMPOUND) {
151
+ mixCode(1);
152
+ mixString(current.name);
153
+ mixCode(current.arity);
154
+ for (let index = current.args.length - 1; index >= 0; index--) stack.push(current.args[index]);
155
+ continue;
156
+ }
157
+ if (current?.type === 'number') {
158
+ mixCode(2);
159
+ mixString(numberValueKey(current.name));
160
+ } else if (current?.type === 'string') {
161
+ mixCode(3);
162
+ mixString(String(current.name));
163
+ } else {
164
+ mixCode(4);
165
+ mixString(String(current?.name ?? ''));
166
+ }
167
+ }
168
+ return hash;
114
169
  }
115
170
 
116
171
  function scalarBuckets(index, term) {
@@ -168,7 +223,7 @@ export function indexCompactOne(index, type, name, clause, clauses = null, claus
168
223
  }
169
224
  }
170
225
 
171
- export function indexOne(index, arg, clause, clauses = null, clausePosition = -1) {
226
+ export function indexOne(index, arg, clause, clauses = null, clausePosition = -1, indexGround = false) {
172
227
  if (isScalar(arg)) {
173
228
  if (!index.sawScalar) {
174
229
  index.sawScalar = true;
@@ -178,6 +233,18 @@ export function indexOne(index, arg, clause, clauses = null, clausePosition = -1
178
233
  } else if (index.sawScalar) {
179
234
  index.fallback.push(clause);
180
235
  }
236
+
237
+ if (!indexGround) return;
238
+ const groundKey = groundTermIndexKey(arg);
239
+ if (groundKey != null) {
240
+ if (!index.sawGround) {
241
+ index.sawGround = true;
242
+ if (clauses && clausePosition > 0) index.groundFallback = clauses.slice(0, clausePosition);
243
+ }
244
+ addClauseBucket(index.groundBuckets, groundKey, clause);
245
+ } else if (index.sawGround) {
246
+ index.groundFallback.push(clause);
247
+ }
181
248
  }
182
249
 
183
250
  function indexFallback(index, group) {
@@ -204,7 +271,9 @@ export function rebuildGroupIndexes(group) {
204
271
  clause.scalarHead = clause.head.type === COMPOUND && clause.head.args.every(isScalar);
205
272
  if (clauseHasCut(clause)) group.hasCut = true;
206
273
  if (clause.body.length !== 0 || !clause.scalarHead) group.scalarFactsOnly = false;
207
- for (let i = 0; i < group.arity; i++) indexOne(group.argIndexes[i], clause.head.args[i], clause, group.clauses, clausePosition);
274
+ for (let i = 0; i < group.arity; i++) {
275
+ indexOne(group.argIndexes[i], clause.head.args[i], clause, group.clauses, clausePosition, group.dynamic === true);
276
+ }
208
277
  }
209
278
  }
210
279
 
@@ -301,15 +370,36 @@ export function selectClauseCandidates(group, goal, env) {
301
370
  }
302
371
  const positions = [];
303
372
  const values = [];
373
+ let bestGround = null;
374
+ let bestGroundLength = group.clauses.length;
304
375
  for (let i = 0; i < goal.arity; i++) {
305
376
  const arg = deref(goal.args[i], env);
306
- if (!isScalar(arg)) continue;
307
- positions.push(i);
308
- values.push(arg);
377
+ if (isScalar(arg)) {
378
+ positions.push(i);
379
+ values.push(arg);
380
+ continue;
381
+ }
382
+ const index = group.argIndexes[i];
383
+ if (!index?.sawGround) continue;
384
+ const key = groundTermIndexKey(arg, env);
385
+ if (key == null) continue;
386
+ const primary = index.groundBuckets.get(key) ?? null;
387
+ const fallback = index.groundFallback;
388
+ const length = clauseCollectionLength(primary) + fallback.length;
389
+ if (length < bestGroundLength) {
390
+ bestGround = fallback.length === 0 ? (primary ?? [])
391
+ : clauseCollectionLength(primary) === 0 ? fallback
392
+ : mergeClausesInSourceOrder(primary, fallback);
393
+ bestGroundLength = length;
394
+ }
309
395
  }
310
- if (positions.length === 0) return { primary: group.clauses, fallback: [] };
311
-
312
- return selectClauseCandidatesForValues(group, positions, values);
396
+ const scalarParts = positions.length === 0 ? null : selectClauseCandidatesForValues(group, positions, values);
397
+ const scalarLength = scalarParts == null
398
+ ? group.clauses.length
399
+ : clauseCollectionLength(scalarParts.primary) + scalarParts.fallback.length;
400
+ if (bestGround != null && bestGroundLength < scalarLength) return { primary: bestGround, fallback: [] };
401
+ if (scalarParts != null) return scalarParts;
402
+ return { primary: group.clauses, fallback: [] };
313
403
  }
314
404
 
315
405
  // The scalar-fact join already has dereferenced local values. Keeping this