eyeprolog 1.4.9 → 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.9",
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,6 +156,7 @@ 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,
157
161
  onWarning: printSourceWarning,
158
162
  });
@@ -205,7 +209,10 @@ async function runDefault(engine, program, options) {
205
209
  const solver = new engine.Solver(program, {
206
210
  registry,
207
211
  isoStrict: options.isoStrict,
208
- 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
+ },
209
216
  });
210
217
  program = solver.program;
211
218
  const goals = engine.normalizeGoals(options.goals, solver);
@@ -252,6 +259,7 @@ Options:
252
259
  reject EyeProlog language extensions and disable automatic tabling.
253
260
  --portable Enforce the EyeProlog/Trealla/Scryer interop profile.
254
261
  --no-autoload Disable conservative interop predicate autoloading.
262
+ --no-auto-table Use traditional depth-first control instead of automatic recursive tabling.
255
263
  -v, --version Show the package version and exit.
256
264
  -w, --warnings Print non-fatal portability warnings to stderr.
257
265
  -g, --goal goal Solve goal and print its ground answers; may be repeated.
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)
@@ -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
package/src/program.js CHANGED
@@ -85,6 +85,9 @@ export class Program {
85
85
  this.multifilePredicates = new Set();
86
86
  this.discontiguousPredicates = new Set();
87
87
  this.strictIso = options.isoStrict === true;
88
+ // Normal mode automatically tables recursive user predicates unless an
89
+ // embedder explicitly requests traditional depth-first Prolog control.
90
+ this.autoTabling = options.autoTabling !== false;
88
91
  this.operators = new Map();
89
92
  const predefinedOperatorSets = this.strictIso
90
93
  ? [ISO_OPERATOR_DEFINITIONS]
@@ -177,7 +180,9 @@ export class Program {
177
180
  group.clauses.push(clause);
178
181
  if (clauseHasCut(clause)) group.hasCut = true;
179
182
  const clausePosition = group.clauses.length - 1;
180
- for (let i = 0; i < head.arity; i++) indexOne(group.argIndexes[i], head.args[i], clause, group.clauses, clausePosition);
183
+ for (let i = 0; i < head.arity; i++) {
184
+ indexOne(group.argIndexes[i], head.args[i], clause, group.clauses, clausePosition, group.dynamic === true);
185
+ }
181
186
  }
182
187
  findGroup(name, arity, module = 'user') {
183
188
  const indicator = `${name}/${arity}`;
@@ -255,9 +260,24 @@ export class Program {
255
260
  clause.groundHead = termHasNoVariables(clause.head);
256
261
  clause.scalarHead = clause.head.type === COMPOUND && clause.head.args.every(isScalar);
257
262
  this.clauses.push(clause);
258
- if (atStart) group.clauses.unshift(clause);
259
- else group.clauses.push(clause);
260
- rebuildGroupIndexes(group);
263
+ if (atStart) {
264
+ group.clauses.unshift(clause);
265
+ rebuildGroupIndexes(group);
266
+ } else {
267
+ const clausePosition = group.clauses.length;
268
+ group.clauses.push(clause);
269
+ // assertz/1 is by far the common dynamic-update path. Appending a clause
270
+ // does not invalidate the existing argument buckets, so extend them in
271
+ // place instead of rebuilding the whole predicate after every insert.
272
+ // This changes repeated ground bookkeeping from quadratic to linear.
273
+ group.demandIndexes.clear();
274
+ group.rejectedDemandIndexes.clear();
275
+ if (clause.body.length !== 0 || !clause.scalarHead) group.scalarFactsOnly = false;
276
+ if (clauseHasCut(clause)) group.hasCut = true;
277
+ for (let i = 0; i < clause.head.arity; i++) {
278
+ indexOne(group.argIndexes[i], clause.head.args[i], clause, group.clauses, clausePosition, true);
279
+ }
280
+ }
261
281
  this.noteMutation(clause.body.length > 0);
262
282
  }
263
283
  removeDynamicClause(group, clause) {
@@ -378,7 +398,7 @@ export class Program {
378
398
  );
379
399
  group.listTailRecursive = directRecursiveComponent && !group.cutRecursive &&
380
400
  hasStrictListTailRecursion(group);
381
- group.tabled = plannedRecursive &&
401
+ group.tabled = this.autoTabling && plannedRecursive &&
382
402
  !componentHasNegativeEdge(start, deps, negativeEdges) &&
383
403
  !group.cutRecursive &&
384
404
  !linearNumeric;
@@ -698,7 +718,7 @@ class ProgramBuilder {
698
718
  if (clauseHasCut(clause)) group.hasCut = true;
699
719
  if (clause.body.length !== 0 || !clause.scalarHead) group.scalarFactsOnly = false;
700
720
  for (let i = 0; i < head.arity; i++) {
701
- indexOne(group.argIndexes[i], head.args[i], clause, group.clauses, clausePosition);
721
+ indexOne(group.argIndexes[i], head.args[i], clause, group.clauses, clausePosition, group.dynamic === true);
702
722
  }
703
723
  continue;
704
724
  }
@@ -780,11 +800,13 @@ class ProgramBuilder {
780
800
  }
781
801
 
782
802
  function buildProgramFromSources(sources, options) {
783
- // The source-metadata-free path is common for CLI and conformance runs. It
784
- // attempts the compact line parser directly into a fresh builder. Should a
785
- // source require the full parser (for example because it defines custom
786
- // operators), the partial builder is simply discarded and the source set is
787
- // rebuilt once with the general streaming parser.
803
+ // The source-metadata-free path is common for CLI and conformance runs. For
804
+ // a homogeneous source set, first try the compact parser directly into a
805
+ // fresh builder. Source sets that contain module-loading directives use a
806
+ // hybrid path instead: directive-heavy sources take the general parser while
807
+ // independent fast-compatible sources can still keep their compact clauses.
808
+ // This matters for launchers that load a small support program followed by a
809
+ // very large ordinary fact/rule file.
788
810
  const hasModuleDirectives = sources.some((source) => {
789
811
  const text = typeof source === 'string' ? source : source?.text ?? source?.source ?? '';
790
812
  return /:-\s*(?:module|use_module)\s*\(/.test(text);
@@ -793,12 +815,47 @@ function buildProgramFromSources(sources, options) {
793
815
  const builder = new ProgramBuilder(options);
794
816
  if (loadSourcesIntoBuilder(builder, sources, options, true)) return builder.finish();
795
817
  }
818
+ if (options.sourceMetadata === false && hasModuleDirectives) {
819
+ const builder = new ProgramBuilder(options);
820
+ if (loadSourcesIntoBuilder(builder, sources, options, 'hybrid')) return builder.finish();
821
+ }
796
822
 
797
823
  const builder = new ProgramBuilder(options);
798
824
  loadSourcesIntoBuilder(builder, sources, options, false);
799
825
  return builder.finish();
800
826
  }
801
827
 
828
+ function cloneOperatorState(state) {
829
+ return {
830
+ infixOperators: new Map(state.infixOperators),
831
+ prefixOperators: new Map(state.prefixOperators),
832
+ postfixOperators: new Map(state.postfixOperators),
833
+ };
834
+ }
835
+
836
+ function cloneParserFlagState(state) {
837
+ return {
838
+ doubleQuotes: state.doubleQuotes,
839
+ charConversion: state.charConversion,
840
+ charConversions: new Map(state.charConversions),
841
+ };
842
+ }
843
+
844
+ function hybridFastSource(source, options) {
845
+ // Module/include directives recurse into additional source texts and can
846
+ // change lexical context. Keep those sources on the general loader. Ordinary
847
+ // user files may still contain op/3, dynamic/1, discontiguous/1, etc.; the
848
+ // compact parser handles those term-by-term and updates the shared state.
849
+ if (/:-(?:\s|\/\*[\s\S]*?\*\/)*(?:module|use_module|include|ensure_loaded)\s*\(/.test(source)) return false;
850
+ const probeOptions = {
851
+ ...options,
852
+ operatorState: cloneOperatorState(options.operatorState),
853
+ parserFlagState: cloneParserFlagState(options.parserFlagState),
854
+ onWarning: null,
855
+ };
856
+ return tryParseClausesFastInto(source, () => {}, () => {}, probeOptions);
857
+ }
858
+
802
859
  // Program.parse() can see host-supplied goals through autoloadGoals while it
803
860
  // builds the program. A Program passed directly to run() has already completed
804
861
  // that phase, so extend the existing instance with the same canonical interop
@@ -863,7 +920,8 @@ function loadSourcesIntoBuilder(builder, sources, options, fast) {
863
920
  module: 'user',
864
921
  textUnit: sourcePath(item.options) ?? `<input:${sourceIndex}>`,
865
922
  };
866
- if (!loadSourceIntoBuilder(builder, text, item.options, ensured, loadedModules, fast, context)) return false;
923
+ const sourceFast = fast === 'hybrid' ? hybridFastSource(text, item.options) : fast;
924
+ if (!loadSourceIntoBuilder(builder, text, item.options, ensured, loadedModules, sourceFast, context)) return false;
867
925
  }
868
926
  const autoloadGoals = parseInteropGoalInputs(options.autoloadGoals, {
869
927
  ...options,
@@ -4,7 +4,7 @@
4
4
  // backtrackable blackboard lives in Env so ordinary Prolog choice points undo
5
5
  // writes automatically.
6
6
 
7
- import { ATOM, COMPOUND, NUMBER, STRING, VAR, atom, compound, deref, listFromItems, unify } from './term.js';
7
+ import { ATOM, COMPOUND, NUMBER, STRING, VAR, atom, compound, deref, listFromItems, numberTerm, numberTextFromDouble, unify } from './term.js';
8
8
  import { PrologError } from './errors.js';
9
9
  import { formatTermForWrite } from './write.js';
10
10
 
@@ -46,6 +46,27 @@ function* bbGlobalPutBuiltin({ solver, goal, env }) {
46
46
  yield env;
47
47
  }
48
48
 
49
+
50
+
51
+ function* randomValueBuiltin({ solver, goal, env }) {
52
+ const key = 'a:$random_seed';
53
+ const stored = solver.nonBacktrackableBlackboard.get(key);
54
+ let seed = stored == null ? 1 : Number(deref(stored, env).name);
55
+ if (!Number.isSafeInteger(seed)) seed = 1;
56
+ seed %= 2147483647;
57
+ if (seed < 0) seed += 2147483647;
58
+ if (seed === 0) seed = 1;
59
+ const high = Math.floor(seed / 44488);
60
+ const low = seed % 44488;
61
+ let nextSeed = 48271 * low - 3399 * high;
62
+ if (nextSeed <= 0) nextSeed += 2147483647;
63
+ const value = (nextSeed - 1) / 2147483646;
64
+ const next = env.clone();
65
+ if (!unify(goal.args[0], numberTerm(numberTextFromDouble(value)), next)) return;
66
+ solver.nonBacktrackableBlackboard.set(key, numberTerm(nextSeed));
67
+ yield next;
68
+ }
69
+
49
70
  const charTypeNames = new Set([
50
71
  'alnum', 'alpha', 'alphabetic', 'alphanumeric', 'ascii', 'ascii_graphic',
51
72
  'ascii_punctuation', 'binary_digit', 'control', 'decimal_digit', 'exponent',
@@ -215,15 +236,34 @@ function* termCharsBuiltin({ solver, goal, env }) {
215
236
  if (unify(goal.args[2], chars(text), next)) yield next;
216
237
  }
217
238
 
239
+
240
+ function* portrayClauseBuiltin({ solver, goal, env }) {
241
+ const text = formatTermForWrite(goal.args[0], env, {
242
+ quoted: true,
243
+ numbervars: true,
244
+ compact: true,
245
+ minimalOperatorSpacing: true,
246
+ operatorAtomsAsArgs: true,
247
+ generateVariableNames: true,
248
+ variableNameState: solver.writeVariableState,
249
+ operators: solver.program.operators.values(),
250
+ });
251
+ const stream = solver.io.resolve(solver.io.currentOutput);
252
+ solver.io.writeUnit(stream, `${text}.\n`);
253
+ yield env;
254
+ }
255
+
218
256
  export const scryerCompatibilityBuiltins = {
219
257
  register(registry) {
220
258
  registry.add('eyeprolog__bb_get', 2, bbGetBuiltin, { deterministic: true, eyePrologLibrary: true });
221
259
  registry.add('eyeprolog__bb_b_put', 2, bbPutBuiltin, { deterministic: true, eyePrologLibrary: true });
222
260
  registry.add('eyeprolog__bb_global_get', 2, bbGlobalGetBuiltin, { deterministic: true, eyePrologLibrary: true });
223
261
  registry.add('eyeprolog__bb_global_put', 2, bbGlobalPutBuiltin, { deterministic: true, eyePrologLibrary: true });
262
+ registry.add('eyeprolog__random_value', 1, randomValueBuiltin, { deterministic: true, eyePrologLibrary: true });
224
263
  registry.add('eyeprolog__char_type', 2, charTypeBuiltin, { eyePrologLibrary: true });
225
264
  registry.add('eyeprolog__current_time', 1, currentTimeBuiltin, { deterministic: true, eyePrologLibrary: true });
226
265
  registry.add('eyeprolog__abolish_all_tables', 0, abolishAllTablesBuiltin, { deterministic: true, eyePrologLibrary: true });
227
266
  registry.add('eyeprolog__term_chars', 3, termCharsBuiltin, { deterministic: true, eyePrologLibrary: true });
267
+ registry.add('portray_clause', 1, portrayClauseBuiltin, { deterministic: true, eyePrologLibrary: true });
228
268
  },
229
269
  };
package/src/solver.js CHANGED
@@ -17,6 +17,7 @@ import { evaluatePositiveDatalog, relationForDatalogGroup, datalogCandidateIndex
17
17
 
18
18
  let freshCounter = 0;
19
19
  const DEFAULT_INNER_TABLE_SCOPE_LIMIT = 1024;
20
+ const GOAL_CONTINUATION_THRESHOLD = 64;
20
21
  // Conservative live-storage estimate for one generated length/2 list cell
21
22
  // (cons object, argument vector, fresh variable, and its generated name).
22
23
  const GENERATED_LENGTH_CELL_RESERVE_BYTES = 256;
@@ -438,8 +439,22 @@ export class Solver {
438
439
  if (frame.kind === 'userClause') {
439
440
  if (this.solutionsSeen >= this.solutionLimit) continue;
440
441
  const { clause, goal: clauseGoal, rest: clauseRest, env: clauseEnv, depth: clauseDepth,
441
- active: clauseActive, release } = frame;
442
+ active: clauseActive, release, tailReleaseBeforeLast, directCutReleaseIndex } = frame;
442
443
  let next;
444
+ if (clause.body.length === 0 && clause.groundHead) {
445
+ const matched = sameResolvedGroundTerm(clauseGoal, clause.head, clauseEnv);
446
+ if (matched === true) {
447
+ stack.push({
448
+ kind: 'goals',
449
+ goals: [...release, ...clauseRest],
450
+ env: clauseEnv,
451
+ depth: clauseDepth + 1,
452
+ active: clauseActive,
453
+ });
454
+ continue;
455
+ }
456
+ if (matched === false) continue;
457
+ }
443
458
  if (clause.body.length === 0 && clause.scalarHead) {
444
459
  next = matchScalarFact(clauseGoal, clause.head, clauseEnv);
445
460
  if (!next) continue;
@@ -463,11 +478,33 @@ export class Solver {
463
478
  next = clauseEnv.clone();
464
479
  this.stats.unify_calls++;
465
480
  if (!unify(clauseGoal, freshHead, next, { knownNonoccurringVariables: headLocalFresh })) continue;
481
+ let nextGoals;
482
+ if (freshBody.length === 0) {
483
+ nextGoals = [...release, ...clauseRest];
484
+ } else if (directCutReleaseIndex >= 0) {
485
+ const afterCut = directCutReleaseIndex + 1;
486
+ nextGoals = [...freshBody.slice(0, afterCut), ...release, ...freshBody.slice(afterCut), ...clauseRest];
487
+ } else if (tailReleaseBeforeLast) {
488
+ const tail = freshBody[freshBody.length - 1];
489
+ next.compactForDeepContinuation?.();
490
+ nextGoals = [...freshBody.slice(0, -1), ...release, tail, ...clauseRest];
491
+ } else if (clauseRest.length >= GOAL_CONTINUATION_THRESHOLD ||
492
+ clauseRest.some((goal) => goal?.kind === 'continueGoals')) {
493
+ // Keep a large caller continuation as one opaque frame instead of
494
+ // copying its complete pending goal list into every nested clause.
495
+ // Small goal lists remain flat because that representation is faster
496
+ // for ordinary shallow search.
497
+ next.compactForDeepContinuation?.();
498
+ nextGoals = [
499
+ ...freshBody,
500
+ { kind: 'continueGoals', goals: clauseRest, depth: clauseDepth, releaseActive: release.length !== 0 },
501
+ ];
502
+ } else {
503
+ nextGoals = [...freshBody, ...release, ...clauseRest];
504
+ }
466
505
  stack.push({
467
506
  kind: 'goals',
468
- goals: freshBody.length === 0
469
- ? [...release, ...clauseRest]
470
- : [...freshBody, ...release, ...clauseRest],
507
+ goals: nextGoals,
471
508
  env: next,
472
509
  depth: clauseDepth + 1,
473
510
  active: clauseActive,
@@ -552,6 +589,12 @@ export class Solver {
552
589
  }
553
590
 
554
591
  const first = goals[0];
592
+ if (first?.kind === 'continueGoals') {
593
+ if (first.releaseActive) active = active.slice(0, -1);
594
+ depth = first.depth;
595
+ goals = first.goals;
596
+ continue;
597
+ }
555
598
  if (first?.kind === 'releaseActive') {
556
599
  active = active.slice(0, -1);
557
600
  goals = goals.slice(1);
@@ -987,7 +1030,7 @@ export class Solver {
987
1030
  }
988
1031
 
989
1032
  *solveUserGoalUncached(group, goal, rest, env, depth) {
990
- if (group.recursive && !group.cutRecursive && !group.linearNumeric && this.activeVariant(goal, env)) {
1033
+ if (this.program.autoTabling !== false && group.recursive && !group.cutRecursive && !group.linearNumeric && this.activeVariant(goal, env)) {
991
1034
  this.recursionCycleDetected = true;
992
1035
  return;
993
1036
  }
@@ -1194,7 +1237,7 @@ function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solv
1194
1237
  }
1195
1238
 
1196
1239
  function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth, active) {
1197
- if (group.recursive && !group.cutRecursive && !group.linearNumeric && activeVariantIn(goal, env, active)) {
1240
+ if (solver.program.autoTabling !== false && group.recursive && !group.cutRecursive && !group.linearNumeric && activeVariantIn(goal, env, active)) {
1198
1241
  solver.recursionCycleDetected = true;
1199
1242
  return;
1200
1243
  }
@@ -1208,9 +1251,6 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1208
1251
  // recursive user predicates. Cut-free, non-recursive library helpers need
1209
1252
  // neither. Copying their full active stack at every recursive step made
1210
1253
  // otherwise linear relations such as length/2 retain O(depth^2) references.
1211
- const guarded = groupNeedsActiveFrame(group);
1212
- const release = guarded ? [{ kind: 'releaseActive' }] : [];
1213
- const nextActive = guarded ? [...active, invocation] : active;
1214
1254
  for (const pass of [candidates.primary, candidates.fallback]) {
1215
1255
  for (let candidateIndex = 0; candidateIndex < clauseCandidateLength(pass); candidateIndex++) {
1216
1256
  const clause = clauseCandidateAt(pass, candidateIndex);
@@ -1226,11 +1266,26 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1226
1266
  rest,
1227
1267
  env,
1228
1268
  depth,
1229
- active: nextActive,
1230
- release,
1231
1269
  });
1232
1270
  }
1233
1271
  }
1272
+ // Traditional depth-first mode disables recursive-cycle guards. A cut
1273
+ // boundary is then needed only if an actually reachable clause contains a
1274
+ // cut. Scalar indexes often narrow a mixed predicate to one cut-free
1275
+ // recursive clause; do not copy the whole active path at every such layer.
1276
+ const guarded = solver.program.autoTabling === false
1277
+ ? frames.some((frame) => clauseHasCutForTailRelease(frame.clause))
1278
+ : groupNeedsActiveFrame(group);
1279
+ const release = guarded ? [{ kind: 'releaseActive' }] : [];
1280
+ const nextActive = guarded ? [...active, invocation] : active;
1281
+ for (const frame of frames) {
1282
+ frame.active = nextActive;
1283
+ frame.release = release;
1284
+ frame.tailReleaseBeforeLast = solver.program.autoTabling === false && guarded &&
1285
+ clauseCanReleaseActiveBeforeTailCall(group, frame.clause);
1286
+ frame.directCutReleaseIndex = solver.program.autoTabling === false && guarded
1287
+ ? clauseDirectCutReleaseIndex(frame.clause) : -1;
1288
+ }
1234
1289
  for (let i = frames.length - 1; i >= 0; i--) stack.push(frames[i]);
1235
1290
  }
1236
1291
 
@@ -1363,6 +1418,51 @@ function prepareLocalVariablesForGoal(goal, env) {
1363
1418
  env.markLocalVariables(goal?._localFirstUseVariables ?? null);
1364
1419
  }
1365
1420
 
1421
+ function clauseDirectCutReleaseIndex(clause) {
1422
+ if (clause?.compactBinary === true) return -1;
1423
+ let lastDirectCut = -1;
1424
+ for (let index = 0; index < clause.body.length; index++) {
1425
+ const goal = clause.body[index];
1426
+ if (goal?.type === ATOM && goal.name === '!') {
1427
+ lastDirectCut = index;
1428
+ continue;
1429
+ }
1430
+ if (termContainsCutForRelease(goal)) return -1;
1431
+ }
1432
+ return lastDirectCut;
1433
+ }
1434
+
1435
+ function termContainsCutForRelease(term) {
1436
+ if (term?.type === ATOM) return term.name === '!';
1437
+ if (term?.type !== COMPOUND) return false;
1438
+ const pending = [...term.args];
1439
+ while (pending.length > 0) {
1440
+ const current = pending.pop();
1441
+ if (current?.type === ATOM && current.name === '!') return true;
1442
+ if (current?.type === COMPOUND) for (const arg of current.args) pending.push(arg);
1443
+ }
1444
+ return false;
1445
+ }
1446
+
1447
+ function clauseCanReleaseActiveBeforeTailCall(group, clause) {
1448
+ if (clauseHasCutForTailRelease(clause)) return false;
1449
+ if (group.clauses[group.clauses.length - 1] !== clause || clause.body.length === 0) return false;
1450
+ const tail = clause.body[clause.body.length - 1];
1451
+ return tail?.type === COMPOUND && tail.name === group.name && tail.arity === group.arity &&
1452
+ (tail.module ?? group.module) === group.module;
1453
+ }
1454
+
1455
+ function clauseHasCutForTailRelease(clause) {
1456
+ if (clause?.compactBinary === true) return false;
1457
+ const pending = [...clause.body];
1458
+ while (pending.length > 0) {
1459
+ const current = pending.pop();
1460
+ if (current?.type === ATOM && current.name === '!') return true;
1461
+ if (current?.type === COMPOUND) for (const arg of current.args) pending.push(arg);
1462
+ }
1463
+ return false;
1464
+ }
1465
+
1366
1466
  function groupNeedsActiveFrame(group) {
1367
1467
  // A direct recursive call that consumes the tail of a matched list cannot
1368
1468
  // revisit an earlier finite-tree call. It needs neither a cycle guard nor an
@@ -2174,7 +2274,7 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
2174
2274
  const groups = [];
2175
2275
  while (runLength < goals.length) {
2176
2276
  const goal = goals[runLength];
2177
- if (!goal || goal.kind === 'releaseActive' || goal.kind === 'memoStore') break;
2277
+ if (!goal || goal.kind === 'releaseActive' || goal.kind === 'memoStore' || goal.kind === 'continueGoals') break;
2178
2278
  if (goal.type !== COMPOUND) break;
2179
2279
  const def = solver.registry.get(goal.name, goal.arity);
2180
2280
  if (def) break;
@@ -2214,7 +2314,7 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
2214
2314
  }
2215
2315
 
2216
2316
  const goal = runGoals[state.index];
2217
- if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) {
2317
+ if (solver.program.autoTabling !== false && activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) {
2218
2318
  solver.recursionCycleDetected = true;
2219
2319
  continue;
2220
2320
  }
@@ -2274,7 +2374,7 @@ function* scalarFactRunGenerator(solver, goals, groups, env, depth, active, pend
2274
2374
  }
2275
2375
 
2276
2376
  const goal = goals[state.index];
2277
- if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) {
2377
+ if (solver.program.autoTabling !== false && activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) {
2278
2378
  solver.recursionCycleDetected = true;
2279
2379
  continue;
2280
2380
  }
@@ -2433,19 +2533,72 @@ function compactIndexBucket(index, type, name) {
2433
2533
  }
2434
2534
 
2435
2535
  function tryPushCompactBinaryChainFrames(stack, solver, group, goal, rest, env, depth, active) {
2436
- if (active.length !== 0 || goal.type !== COMPOUND || goal.arity !== 2) return false;
2437
- // This fast path only accepts scalar arguments. Dereference those arguments
2438
- // directly instead of deep-copying the whole goal before discovering that a
2439
- // list/compound argument is ineligible. Besides avoiding wasted work, this
2440
- // keeps deep DCG state lists away from recursive copyResolved().
2536
+ if (goal.type !== COMPOUND || goal.arity !== 2) return false;
2537
+ // This fast path follows deterministic compact binary chains selected by a
2538
+ // scalar second argument. In addition to a scalar first argument, accept an
2539
+ // unbound first variable when every recursive rule carries that variable
2540
+ // through unchanged. The latter is the common taxonomy form p(X,nN) :-
2541
+ // p(X,nN-1): it can be walked iteratively and X is bound only by the base
2542
+ // fact, avoiding thousands of recursive Env/active-frame allocations.
2441
2543
  const first = derefForLocal(goal.args[0], env);
2442
2544
  const second = derefForLocal(goal.args[1], env);
2443
- let secondType = second?.type;
2444
- let secondName = second?.name;
2445
- if (!isScalarTerm(first) || !['atom', 'string', 'number'].includes(secondType)) return false;
2545
+ const firstScalar = isScalarTerm(first);
2546
+ const firstVariable = first?.type === 'var';
2547
+ if ((!firstScalar && !firstVariable) || !['atom', 'string', 'number'].includes(second?.type)) return false;
2548
+ // Preserve the established cached scalar-chain semantics inside an active
2549
+ // invocation. The variable-carry chain below does not use cross-call caches
2550
+ // and is safe in an active meta-call because it still checks its own cycles.
2551
+ if (firstScalar && active.length !== 0) return false;
2446
2552
 
2447
2553
  const index = group.argIndexes[1];
2448
2554
  if (!index?.sawScalar || index.fallback.length !== 0) return false;
2555
+
2556
+ if (firstVariable) {
2557
+ const seen = scalarSetContainer();
2558
+ let secondType = second.type;
2559
+ let secondName = second.name;
2560
+ let currentDepth = depth;
2561
+ while (true) {
2562
+ if (solver.solutionsSeen >= solver.solutionLimit) return true;
2563
+ solver.stats.max_depth = Math.max(solver.stats.max_depth, currentDepth);
2564
+ const seenSet = seen[secondType];
2565
+ if (!seenSet) return true;
2566
+ if (seenSet.has(secondName)) {
2567
+ solver.recursionCycleDetected = true;
2568
+ return true;
2569
+ }
2570
+ seenSet.add(secondName);
2571
+
2572
+ const candidates = compactIndexBucket(index, secondType, secondName);
2573
+ if (clauseCandidateLength(candidates) !== 1) return false;
2574
+ const clause = clauseCandidateAt(candidates, 0);
2575
+ if (clause?.compactBinary !== true || clause.headName !== group.name) return false;
2576
+ if (clause.head1Type !== secondType || clause.head1Name !== secondName) return true;
2577
+
2578
+ if (clause.bodyName == null) {
2579
+ const next = env.clone();
2580
+ // A variable base fact leaves the carried query variable unconstrained;
2581
+ // a scalar base fact binds it exactly as ordinary clause unification.
2582
+ if (clause.head0Type !== 'var') {
2583
+ if (!unify(goal.args[0], clause.head.args[0], next)) return true;
2584
+ if (next._variableConstraints != null && !next.validateVariableConstraints()) return true;
2585
+ }
2586
+ stack.push({ kind: 'goals', goals: rest, env: next, depth: depth + 1, active });
2587
+ return true;
2588
+ }
2589
+
2590
+ if (clause.bodyName !== group.name || clause.head0Type !== 'var' ||
2591
+ clause.body0Type !== 'var' || clause.body0Name !== clause.head0Name ||
2592
+ !['atom', 'string', 'number'].includes(clause.body1Type)) return false;
2593
+
2594
+ secondType = clause.body1Type;
2595
+ secondName = clause.body1Name;
2596
+ currentDepth++;
2597
+ }
2598
+ }
2599
+
2600
+ let secondType = second.type;
2601
+ let secondName = second.name;
2449
2602
  const cache = compactChainCacheFor(solver, group, first);
2450
2603
  const seen = scalarSetContainer();
2451
2604
  let currentDepth = depth;
@@ -2590,7 +2743,7 @@ function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth,
2590
2743
  solver.recursionCycleDetected = true;
2591
2744
  return true;
2592
2745
  }
2593
- if (activeVariantIn(currentGoal, currentEnv, active)) {
2746
+ if (solver.program.autoTabling !== false && activeVariantIn(currentGoal, currentEnv, active)) {
2594
2747
  solver.recursionCycleDetected = true;
2595
2748
  return true;
2596
2749
  }
@@ -2741,11 +2894,30 @@ function sameScalarTerm(left, right) {
2741
2894
  }
2742
2895
 
2743
2896
  function sameGroundTerm(left, right) {
2744
- if (left?.type !== right?.type) return false;
2745
- if (left?.type === 'number' ? !sameNumberValue(left.name, right.name) : left?.name !== right?.name) return false;
2746
- const arity = left.args?.length ?? 0;
2747
- if (arity !== (right.args?.length ?? 0)) return false;
2748
- for (let i = 0; i < arity; i++) if (!sameGroundTerm(left.args[i], right.args[i])) return false;
2897
+ const pending = [[left, right]];
2898
+ while (pending.length > 0) {
2899
+ const [a, b] = pending.pop();
2900
+ if (a?.type !== b?.type) return false;
2901
+ if (a?.type === 'number' ? !sameNumberValue(a.name, b.name) : a?.name !== b?.name) return false;
2902
+ const arity = a?.args?.length ?? 0;
2903
+ if (arity !== (b?.args?.length ?? 0)) return false;
2904
+ for (let index = 0; index < arity; index++) pending.push([a.args[index], b.args[index]]);
2905
+ }
2906
+ return true;
2907
+ }
2908
+
2909
+ function sameResolvedGroundTerm(left, right, env) {
2910
+ const pending = [[left, right]];
2911
+ while (pending.length > 0) {
2912
+ let [a, b] = pending.pop();
2913
+ a = derefForLocal(a, env);
2914
+ if (a?.type === 'var') return null;
2915
+ if (a?.type !== b?.type) return false;
2916
+ if (a?.type === 'number' ? !sameNumberValue(a.name, b.name) : a?.name !== b?.name) return false;
2917
+ const arity = a?.args?.length ?? 0;
2918
+ if (arity !== (b?.args?.length ?? 0)) return false;
2919
+ for (let index = 0; index < arity; index++) pending.push([a.args[index], b.args[index]]);
2920
+ }
2749
2921
  return true;
2750
2922
  }
2751
2923
 
@@ -2908,7 +3080,7 @@ function pushResumeBuiltinFrame(stack, iterator, goals, depth, active) {
2908
3080
  function selectReadyDeterministicBuiltin(goals, env, registry) {
2909
3081
  for (let i = 0; i < goals.length; i++) {
2910
3082
  const goal = goals[i];
2911
- if (goal?.kind === 'releaseActive' || goal?.kind === 'memoStore') return 0;
3083
+ if (goal?.kind === 'releaseActive' || goal?.kind === 'memoStore' || goal?.kind === 'continueGoals') return 0;
2912
3084
  // A first-use proof is derived from source goal order. Do not move a later
2913
3085
  // deterministic builtin across that equality: doing so could touch one of
2914
3086
  // its proven-fresh variables before the checked binding executes.
@@ -84,6 +84,7 @@ export const eyePrologNativeLibraryIndicators = Object.freeze([
84
84
  'disjoint2/1', 'automaton/3', 'automaton/8', 'chain/2', 'element/3', 'zcompare/3',
85
85
  'fd_var/1', 'fd_inf/2', 'fd_sup/2', 'fd_size/2', 'fd_dom/2',
86
86
  'clpz_t/2', '#=/3', '#</3',
87
+ 'random/1',
87
88
  ]);
88
89
  export const eyePrologPortableLibraryIndicators = Object.freeze([
89
90
  'sumall/3', 'aggregate_min/5', 'aggregate_max/5',
@@ -232,7 +233,7 @@ const eyePrologSharedLibraryIndicators = [
232
233
  'ord_subtract/3', 'ord_symdiff/3', 'ord_union/2', 'ord_union/3', 'ord_union/4',
233
234
  'phrase_from_file/2', 'phrase_from_file/3', 'phrase_to_file/2',
234
235
  'phrase_to_file/3', 'phrase_to_stream/2',
235
- 'maybe/0', 'random_integer/3', 'set_random/1',
236
+ 'maybe/0', 'random/1', 'random_integer/3', 'set_random/1',
236
237
  ',/3', ';/3', '=/3', 'cond_t/3', 'dif/3', 'if_/3', 'memberd_t/3',
237
238
  'tfilter/3', 'tmember/2', 'tmember_t/3', 'tpartition/4',
238
239
  'start_tabling/2', 'abolish_all_tables/0', 'current_time/1', 'format_time/4',
package/src/term.js CHANGED
@@ -13,7 +13,7 @@ const EMPTY_ARGS = Object.freeze([]);
13
13
  // the chain into its indexed Map before that lookup cost dominates. Ordinary
14
14
  // execution keeps the memory-friendly 512-layer threshold; attributed-variable
15
15
  // propagation uses 256 because its repeated negative lookups dominate sooner.
16
- const ENV_FLATTEN_DEPTH = 512;
16
+ const ENV_FLATTEN_DEPTH = 256;
17
17
  const ATTRIBUTED_ENV_FLATTEN_DEPTH = 256;
18
18
  // Runtime terms are structurally immutable: environments hold bindings beside
19
19
  // them rather than rewriting their argument arrays. Cache only the syntactic
@@ -105,6 +105,7 @@ export class Env {
105
105
  bindingValue: undefined,
106
106
  parent: null,
107
107
  depth: 0,
108
+ segmentCount: 0,
108
109
  cacheName: null,
109
110
  cacheValue: undefined,
110
111
  cache: null,
@@ -318,29 +319,55 @@ export class Env {
318
319
  }
319
320
  return undefined;
320
321
  }
322
+ compactForDeepContinuation(segmentLimit = 16) {
323
+ if ((this._state.segmentCount ?? 0) < segmentLimit) return false;
324
+ const flattened = new Map();
325
+ for (let state = this._state; state != null; state = state.parent) {
326
+ if (state.bindingName != null && !flattened.has(state.bindingName)) {
327
+ flattened.set(state.bindingName, state.bindingValue);
328
+ }
329
+ if (state.bindings) {
330
+ for (const [key, value] of state.bindings) {
331
+ if (!flattened.has(key)) flattened.set(key, value);
332
+ }
333
+ }
334
+ }
335
+ this._state = {
336
+ bindings: flattened,
337
+ bindingName: null,
338
+ bindingValue: undefined,
339
+ parent: null,
340
+ depth: 0,
341
+ segmentCount: 0,
342
+ cacheName: null,
343
+ cacheValue: undefined,
344
+ cache: null,
345
+ };
346
+ return true;
347
+ }
321
348
  bind(name, term) {
322
349
  const flattenDepth = this._prologAttributes == null
323
350
  ? ENV_FLATTEN_DEPTH
324
351
  : ATTRIBUTED_ENV_FLATTEN_DEPTH;
325
352
  if (this._state.depth >= flattenDepth) {
326
- const flattened = new Map();
327
- for (let state = this._state; state != null; state = state.parent) {
328
- if (state.bindingName != null && !flattened.has(state.bindingName)) {
329
- flattened.set(state.bindingName, state.bindingValue);
330
- }
331
- if (state.bindings) {
332
- for (const [key, value] of state.bindings) {
333
- if (!flattened.has(key)) flattened.set(key, value);
334
- }
353
+ // Compact only the newest single-binding segment. Older compacted
354
+ // segments remain linked as parents, so deep deterministic recursion
355
+ // never recopies its complete binding history at every threshold.
356
+ const segment = new Map([[name, term]]);
357
+ let state = this._state;
358
+ while (state != null && state.bindings == null) {
359
+ if (state.bindingName != null && !segment.has(state.bindingName)) {
360
+ segment.set(state.bindingName, state.bindingValue);
335
361
  }
362
+ state = state.parent;
336
363
  }
337
- flattened.set(name, term);
338
364
  this._state = {
339
- bindings: flattened,
365
+ bindings: segment,
340
366
  bindingName: null,
341
367
  bindingValue: undefined,
342
- parent: null,
368
+ parent: state,
343
369
  depth: 0,
370
+ segmentCount: (state?.segmentCount ?? 0) + 1,
344
371
  cacheName: null,
345
372
  cacheValue: undefined,
346
373
  cache: null,
@@ -353,6 +380,7 @@ export class Env {
353
380
  bindingValue: term,
354
381
  parent: this._state,
355
382
  depth: this._state.depth + 1,
383
+ segmentCount: this._state.segmentCount ?? 0,
356
384
  cacheName: null,
357
385
  cacheValue: undefined,
358
386
  cache: null,
@@ -923,12 +951,40 @@ export function freshTerm(term, suffix, variables = new Map()) {
923
951
  }
924
952
 
925
953
  export function copyResolved(term, env) {
954
+ const makeCopy = (resolved) => {
955
+ if (resolved.type === VAR) return variable(resolved.name);
956
+ const copied = resolved.type === COMPOUND && resolved.arity === 0
957
+ ? atom(resolved.name)
958
+ : new Term(resolved.type, resolved.name, new Array(resolved.args.length));
959
+ if (resolved.module != null) copied.module = resolved.module;
960
+ return copied;
961
+ };
962
+
926
963
  const resolved = deref(term, env);
927
- if (resolved.type === VAR) return variable(resolved.name);
928
- const copied = resolved.type === COMPOUND && resolved.arity === 0
929
- ? atom(resolved.name)
930
- : new Term(resolved.type, resolved.name, resolved.args.map((arg) => copyResolved(arg, env)));
931
- if (resolved.module != null) copied.module = resolved.module;
964
+ const copied = makeCopy(resolved);
965
+ if (resolved.type === VAR || resolved.args.length === 0) return copied;
966
+
967
+ // Deep lists and machine-state terms can contain thousands of nested cells.
968
+ // Copy them iteratively so readback never consumes the JavaScript call stack.
969
+ // Keep a source-to-copy map as well, both to preserve shared subterms and to
970
+ // terminate on rational trees when occurs_check is disabled.
971
+ const copies = new Map([[resolved, copied]]);
972
+ const pending = [{ source: resolved, target: copied }];
973
+ while (pending.length > 0) {
974
+ const { source, target } = pending.pop();
975
+ for (let index = 0; index < source.args.length; index++) {
976
+ const childSource = deref(source.args[index], env);
977
+ let childCopy = copies.get(childSource);
978
+ if (childCopy == null) {
979
+ childCopy = makeCopy(childSource);
980
+ if (childSource.type !== VAR && childSource.args.length > 0) {
981
+ copies.set(childSource, childCopy);
982
+ pending.push({ source: childSource, target: childCopy });
983
+ }
984
+ }
985
+ target.args[index] = childCopy;
986
+ }
987
+ }
932
988
  return copied;
933
989
  }
934
990
 
@@ -4042,6 +4042,23 @@ child.stdin.write(\`consult(${consultedAtom}).\\n\`);
4042
4042
  assertEqual(result.stderr, '', 'stderr');
4043
4043
  },
4044
4044
  },
4045
+ {
4046
+ name: 'traditional depth-first mode keeps deep non-tail continuations bounded',
4047
+ run: () => {
4048
+ const source = `
4049
+ countdown(0, 0) :- !.
4050
+ countdown(N, Result) :-
4051
+ Next is N-1,
4052
+ countdown(Next, Partial),
4053
+ Result is Partial+1.
4054
+ answer(Result) :- countdown(2048, Result), Result = 2048.
4055
+ `;
4056
+ const result = run(source, { goal: 'answer(Result)', autoTabling: false });
4057
+ assertEqual(result.stdout, 'answer(2048).\n', 'deep non-tail answer');
4058
+ assertEqual(result.stats.max_goal_count <= 70, true,
4059
+ 'caller continuations stay opaque instead of growing one goal per recursive layer');
4060
+ },
4061
+ },
4045
4062
  {
4046
4063
  name: '--portable rejects implementation-specific library dependencies',
4047
4064
  run: () => {
@@ -6126,7 +6143,9 @@ answer(ok) :-
6126
6143
  assertEqual(Boolean(registry.get('is', 2)), true, 'ISO is/2 exists');
6127
6144
  assertEqual(Boolean(registry.get('append', 3)), false, 'append/3 is not ISO core');
6128
6145
  assertEqual(library.eyePrologLibrary, true, 'complete registry marker');
6129
- assertEqual(library.defs.size, 161, 'EyeProlog registry contains ISO definitions, cleanup controls, observability extensions, WFS tnot/1, and generic library adapters');
6146
+ assertEqual(library.defs.size, 163, 'EyeProlog registry contains ISO definitions, cleanup controls, observability extensions, WFS tnot/1, and generic library adapters');
6147
+ assertEqual(registry.get('eyeprolog__random_value', 1), null, 'native random helper is absent from the ISO registry');
6148
+ assertEqual(Boolean(library.get('eyeprolog__random_value', 1)), true, 'native random helper is an internal EyeProlog library adapter');
6130
6149
  assertEqual(Boolean(registry.get('phrase', 2)), true, 'Part 3 phrase/2 exists');
6131
6150
  assertEqual(Boolean(registry.get('phrase', 3)), true, 'Part 3 phrase/3 exists');
6132
6151
  assertEqual(registry.get('statistics', 0), null, 'statistics/0 is absent from the ISO registry');
@@ -6143,9 +6162,9 @@ answer(ok) :-
6143
6162
  assertEqual(Boolean(library.get('call_cleanup', 2)), true, 'call_cleanup/2 is an EyeProlog cleanup control');
6144
6163
  assertEqual(registry.get('setup_call_cleanup', 3), null, 'setup_call_cleanup/3 is absent from the ISO registry');
6145
6164
  assertEqual(Boolean(library.get('setup_call_cleanup', 3)), true, 'setup_call_cleanup/3 is an EyeProlog cleanup control');
6146
- assertEqual(registeredNativeEyePrologLibraryNames().length, 57, 'public host-supported EyeProlog library count');
6165
+ assertEqual(registeredNativeEyePrologLibraryNames().length, 58, 'public host-supported EyeProlog library count');
6147
6166
  assertEqual(eyePrologPortableLibraryIndicators.length, 241, 'portable Prolog library count');
6148
- assertEqual(eyePrologInteropLibraryIndicators.length, 188, 'cross-implementation interop profile count');
6167
+ assertEqual(eyePrologInteropLibraryIndicators.length, 189, 'cross-implementation interop profile count');
6149
6168
  assertEqual(eyePrologInteropLibraryModules.length, 23, 'common explicit library module profile count');
6150
6169
  assertEqual(eyePrologInteropAutoload['member/2'], 'lists', 'member/2 canonical autoload');
6151
6170
  assertEqual(eyePrologInteropAutoload['between/3'], 'between', 'between/3 canonical internal autoload');
@@ -6154,9 +6173,10 @@ answer(ok) :-
6154
6173
  assertEqual(eyePrologInteropAutoload['time/1'], 'iso_ext', 'time/1 canonical interop autoload');
6155
6174
  assertEqual(eyePrologInteropAutoload['.../2'], 'iso_ext', '.../2 canonical interop autoload');
6156
6175
  assertEqual(eyePrologInteropAutoload['set_nth0/4'] ?? null, null, 'EyeProlog-only set_nth0/4 is not autoloadable');
6157
- assertEqual(eyePrologNativeLibraryIndicators.length, 57, 'host-supported library count');
6176
+ assertEqual(eyePrologNativeLibraryIndicators.length, 58, 'host-supported library count');
6158
6177
  assertEqual(eyePrologNativeLibraryIndicators.slice(0, 3).join(','), 'call_nth/2,freeze/2,dif/2', 'control and constraint predicates requiring host support');
6159
- assertEqual(eyePrologLibraryIndicators.length, 298, 'complete non-ISO EyeProlog library surface');
6178
+ assertEqual(eyePrologNativeLibraryIndicators.includes('random/1'), true, 'stateful random/1 is classified as host-supported');
6179
+ assertEqual(eyePrologLibraryIndicators.length, 299, 'complete non-ISO EyeProlog library surface');
6160
6180
  assertEqual(registry.get('eyeprolog__call_nth', 2), null, 'private call_nth adapter is absent from ISO registry');
6161
6181
  assertEqual(Boolean(library.get('eyeprolog__call_nth', 2)), true, 'private call_nth adapter is registered for EyeProlog');
6162
6182
  assertEqual(Boolean(library.get('eyeprolog__call_residue_vars', 2)), true, 'private call_residue_vars adapter is registered for EyeProlog');
@@ -6237,12 +6237,12 @@ so side effects occur in Prolog execution order.
6237
6237
 
6238
6238
  ### The EyeProlog library
6239
6239
 
6240
- EyeProlog exposes **298 distinct non-ISO library and normal-extension predicate
6240
+ EyeProlog exposes **299 distinct non-ISO library and normal-extension predicate
6241
6241
  indicators** in addition to the 129 indicators in its isolated ISO profile.
6242
6242
  **241 are defined entirely as ordinary Prolog clauses** in focused modules under
6243
- `src/lib/`; **57 use host support** for control, attributed variables,
6243
+ `src/lib/`; **58 use host support** for control, attributed variables,
6244
6244
  constraints, or observability. The ISO and library catalogs therefore cover
6245
- **427 distinct predicate indicators**. Normal-mode controls and observability
6245
+ **428 distinct predicate indicators**. Normal-mode controls and observability
6246
6246
  relations that do not belong to a library, such as `call_cleanup/2`,
6247
6247
  `setup_call_cleanup/3`, `statistics/0,2`, and `tnot/1`, are documented in their
6248
6248
  own sections rather than counted as library predicates. `time/1` is additionally
@@ -6337,7 +6337,7 @@ bindings make one aligned subterm pair sufficient. For example:
6337
6337
  | `library(pio)` | `phrase_from_file/2`, `phrase_from_file/3`, `phrase_to_file/2`, `phrase_to_file/3`, `phrase_to_stream/2` | Eager portable DCG file and stream I/O |
6338
6338
  | `library(primes)` | `smallest_divisor_from/3` | Prime factor support |
6339
6339
  | `library(prologue)` | `member/2`, `append/3`, `length/2`, `between/3`, `select/3`, `succ/2`, `maplist/2`, `maplist/3`, `maplist/4`, `maplist/5`, `maplist/6`, `maplist/7`, `maplist/8`, `nth0/3`, `nth0/4`, `nth1/3`, `nth1/4`, `call_nth/2`, `freeze/2`, `foldl/4`, `foldl/5`, `foldl/6`, `countall/2` | Legacy facade over canonical focused modules |
6340
- | `library(random)` | `maybe/0`, `random/3`, `random_integer/3`, `set_random/1` | Common mutable-seed interface plus EyeProlog's pure state-threaded generator |
6340
+ | `library(random)` | `maybe/0`, `random/1`, `random/3`, `random_integer/3`, `set_random/1` | Common mutable-seed interface plus EyeProlog's pure state-threaded generator |
6341
6341
  | `library(reif)` | `,/3`, `;/3`, `=/3`, `cond_t/3`, `dif/3`, `if_/3`, `memberd_t/3`, `tfilter/3`, `tmember/2`, `tmember_t/3`, `tpartition/4` | Reified conditions and list filtering; reused upstream source |
6342
6342
  | `library(si)` | `atom_si/1`, `integer_si/1`, `atomic_si/1`, `list_si/1`, `character_si/1`, `term_si/1`, `chars_si/1`, `dif_si/2`, `not_si/1`, `when_si/2` | Sufficient-instantiation checks used by CLP(Z) |
6343
6343
  | `library(strings)` | `matches/3`, `split/3`, `replace/4`, `lowercase/2`, `uppercase/2`, `trim/2`, `number_string/2`, `atom_string/2`, `term_string/2`, `string_concat/3`, `contains/2`, `matches/2`, `join/3`, `substring/4` | Text relations |
@@ -6558,6 +6558,12 @@ is a compatible declaration rather than a second tabling engine.
6558
6558
  `format_time//2` supports the documented year, month, day, time, month-name,
6559
6559
  weekday-name, and day-of-year specifiers.
6560
6560
 
6561
+ `random/1` keeps the common mutable-seed interface but uses a private native
6562
+ state step for hot-loop performance; its public sequence remains the same
6563
+ Park-Miller sequence used by the portable explicit-state `random/3`. The helper
6564
+ is internal to the normal EyeProlog registry and is not part of the ISO or
6565
+ public library predicate catalogs.
6566
+
6561
6567
  `uuidv4/1`, `uuidv4_string/1`, and `uuid_string/2` provide the common UUID byte
6562
6568
  list and character-list interface. `uuid(+Seed0,-UUID,-Seed)` remains an
6563
6569
  EyeProlog extension that creates a version 4 UUID atom using pure `random/3`.
@@ -6605,6 +6611,7 @@ before calling the corresponding predicate.
6605
6611
  | Predicates and principal modes | Behavior |
6606
6612
  | --- | --- |
6607
6613
  | `lt(+A,+B)`, `le(+A,+B)`, `gt(+A,+B)`, `ge(+A,+B)` | Compare integers exactly, finite numeric text numerically, `PnYnMnD` duration text component-wise, and other lexical values by string order. These differ from ISO arithmetic comparison and standard term order. |
6614
+ | `random(-Value)` | Stateful Park-Miller step using the library seed set by `set_random/1`; implemented through a private native fast path while retaining the same sequence and numeric conversion as `random/3`. |
6608
6615
  | `random(+Seed0,-Value,-Seed)` | Portable Park-Miller generator with explicit state. `Value` is in `[0,1)`; pass the returned integer `Seed` to the next call. The same initial integer seed always reproduces the same sequence. |
6609
6616
  | `difference(+End,+Start,-Duration)` | Portable Prolog. Computes a nonnegative calendar difference between ISO date atoms/character lists and returns atom `'PnYnMnD'`. Invalid dates or an end before the start fail. |
6610
6617