eyeprolog 1.2.32 → 1.2.34

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
@@ -93,6 +93,7 @@ export interface EyePrologPredicateGroup {
93
93
  rejectedDemandIndexes: Set<string>;
94
94
  tabled: boolean;
95
95
  recursive: boolean;
96
+ listTailRecursive: boolean;
96
97
  tableInputPositions: number[];
97
98
  negationStratum: number | null;
98
99
  }
@@ -107,6 +108,14 @@ export class Term {
107
108
  get arity(): number;
108
109
  }
109
110
 
111
+ export class CompactListTerm {
112
+ readonly type: 'compound';
113
+ readonly name: '.';
114
+ readonly args: EyePrologTerm[];
115
+ readonly arity: 2;
116
+ mayContainVariable(name: string): boolean;
117
+ }
118
+
110
119
  export class Env {
111
120
  constructor(bindings?: Iterable<readonly [string, EyePrologTerm]> | null);
112
121
  bindings: Map<string, EyePrologTerm>;
@@ -198,6 +207,9 @@ export function numberTerm(value: string | number): Term;
198
207
  export function compound(name: string, args?: EyePrologTerm[]): Term;
199
208
  export function emptyList(): Term;
200
209
  export function cons(head: EyePrologTerm, tail: EyePrologTerm): Term;
210
+ export function compactVariableList(length: bigint | number | string, variablePrefix: string): CompactListTerm | Term;
211
+ export function isCompactList(term: EyePrologTerm | null | undefined): term is CompactListTerm;
212
+ export function compactListLength(term: EyePrologTerm | null | undefined): bigint | null;
201
213
  export function deref(term: EyePrologTerm, env: Env): EyePrologTerm;
202
214
  export function isScalar(term: EyePrologTerm | null | undefined): boolean;
203
215
  export function isEmptyList(term: EyePrologTerm | null | undefined): boolean;
@@ -259,6 +271,7 @@ declare const eyeprolog: {
259
271
  NUMBER: typeof NUMBER;
260
272
  COMPOUND: typeof COMPOUND;
261
273
  Term: typeof Term;
274
+ CompactListTerm: typeof CompactListTerm;
262
275
  Env: typeof Env;
263
276
  Program: typeof Program;
264
277
  Solver: typeof Solver;
@@ -273,6 +286,9 @@ declare const eyeprolog: {
273
286
  compound: typeof compound;
274
287
  emptyList: typeof emptyList;
275
288
  cons: typeof cons;
289
+ compactVariableList: typeof compactVariableList;
290
+ isCompactList: typeof isCompactList;
291
+ compactListLength: typeof compactListLength;
276
292
  deref: typeof deref;
277
293
  isScalar: typeof isScalar;
278
294
  isEmptyList: typeof isEmptyList;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.32",
6
+ "version": "1.2.34",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/lib/clpz.pl CHANGED
@@ -7,22 +7,24 @@
7
7
  constraints will be added as their propagation semantics become available.
8
8
  */
9
9
 
10
+ % Current operator names in predicate indicators are parenthesized as bare
11
+ % atoms (for example, (#>)/2); quoting is unnecessary.
10
12
  :- module(clpz, [
11
- ('#>')/2,
12
- ('#<')/2,
13
- ('#>=')/2,
14
- ('#=<')/2,
15
- ('#=')/2,
16
- ('#\\=')/2,
17
- (#\\)/1,
18
- ('#<==>')/2,
19
- ('#==>')/2,
20
- ('#<==')/2,
21
- ('#\\/')/2,
22
- (#\\)/2,
23
- ('#/\\')/2,
24
- ('in')/2,
25
- ('ins')/2,
13
+ (#>)/2,
14
+ (#<)/2,
15
+ (#>=)/2,
16
+ (#=<)/2,
17
+ (#=)/2,
18
+ (#\=)/2,
19
+ (#\)/1,
20
+ (#<==>)/2,
21
+ (#==>)/2,
22
+ (#<==)/2,
23
+ (#\/)/2,
24
+ (#\)/2,
25
+ (#/\)/2,
26
+ (in)/2,
27
+ (ins)/2,
26
28
  all_different/1,
27
29
  all_distinct/1,
28
30
  nvalue/2,
@@ -50,33 +52,33 @@
50
52
  :- op(760, yfx, #<==>).
51
53
  :- op(750, xfy, #==>).
52
54
  :- op(750, yfx, #<==).
53
- :- op(740, yfx, #\\/).
54
- :- op(730, yfx, #\\).
55
- :- op(720, yfx, #/\\).
56
- :- op(710, fy, #\\).
55
+ :- op(740, yfx, #\/).
56
+ :- op(730, yfx, #\).
57
+ :- op(720, yfx, #/\).
58
+ :- op(710, fy, #\).
57
59
  :- op(700, xfx, #>).
58
60
  :- op(700, xfx, #<).
59
61
  :- op(700, xfx, #>=).
60
62
  :- op(700, xfx, #=<).
61
63
  :- op(700, xfx, #=).
62
- :- op(700, xfx, #\\=).
64
+ :- op(700, xfx, #\=).
63
65
  :- op(700, xfx, in).
64
66
  :- op(700, xfx, ins).
65
67
  :- op(450, xfx, ..).
66
68
 
67
- '#>'(Left, Right) :- eyeprolog__clpz_post('#>'(Left, Right)).
68
- '#<'(Left, Right) :- eyeprolog__clpz_post('#<'(Left, Right)).
69
- '#>='(Left, Right) :- eyeprolog__clpz_post('#>='(Left, Right)).
70
- '#=<'(Left, Right) :- eyeprolog__clpz_post('#=<'(Left, Right)).
71
- '#='(Left, Right) :- eyeprolog__clpz_post('#='(Left, Right)).
72
- '#\\='(Left, Right) :- eyeprolog__clpz_post('#\\='(Left, Right)).
73
- '#\\'(Constraint) :- eyeprolog__clpz_post('#\\'(Constraint)).
74
- '#<==>'(Left, Right) :- eyeprolog__clpz_post('#<==>'(Left, Right)).
75
- '#==>'(Left, Right) :- eyeprolog__clpz_post('#==>'(Left, Right)).
76
- '#<=='(Left, Right) :- eyeprolog__clpz_post('#<=='(Left, Right)).
77
- '#\\/'(Left, Right) :- eyeprolog__clpz_post('#\\/'(Left, Right)).
78
- '#\\'(Left, Right) :- eyeprolog__clpz_post('#\\'(Left, Right)).
79
- '#/\\'(Left, Right) :- eyeprolog__clpz_post('#/\\'(Left, Right)).
69
+ #>(Left, Right) :- eyeprolog__clpz_post(#>(Left, Right)).
70
+ #<(Left, Right) :- eyeprolog__clpz_post(#<(Left, Right)).
71
+ #>=(Left, Right) :- eyeprolog__clpz_post(#>=(Left, Right)).
72
+ #=<(Left, Right) :- eyeprolog__clpz_post(#=<(Left, Right)).
73
+ #=(Left, Right) :- eyeprolog__clpz_post(#=(Left, Right)).
74
+ #\=(Left, Right) :- eyeprolog__clpz_post(#\=(Left, Right)).
75
+ #\(Constraint) :- eyeprolog__clpz_post(#\(Constraint)).
76
+ #<==>(Left, Right) :- eyeprolog__clpz_post(#<==>(Left, Right)).
77
+ #==>(Left, Right) :- eyeprolog__clpz_post(#==>(Left, Right)).
78
+ #<==(Left, Right) :- eyeprolog__clpz_post(#<==(Left, Right)).
79
+ #\/(Left, Right) :- eyeprolog__clpz_post(#\/(Left, Right)).
80
+ #\(Left, Right) :- eyeprolog__clpz_post(#\(Left, Right)).
81
+ #/\(Left, Right) :- eyeprolog__clpz_post(#/\(Left, Right)).
80
82
 
81
83
  in(Integer, Domain) :- eyeprolog__clpz_in(Integer, Domain).
82
84
  ins(Integers, Domain) :- eyeprolog__clpz_ins(Integers, Domain).
package/src/platform.js CHANGED
@@ -43,6 +43,14 @@ export function usedHeapSize() {
43
43
  }
44
44
 
45
45
  export function softHeapLimit() {
46
+ const limit = hardHeapLimit();
47
+ // Leave ample room for the generator stack to unwind and for the top level
48
+ // to construct and print resource_error(memory). Fatal V8 OOMs cannot be
49
+ // caught after the heap limit itself has been reached.
50
+ return Number.isFinite(limit) && limit > 0 ? Math.floor(limit * 0.75) : Infinity;
51
+ }
52
+
53
+ export function hardHeapLimit() {
46
54
  let limit = null;
47
55
  if (isNode) {
48
56
  limit = v8?.getHeapStatistics?.().heap_size_limit ?? null;
@@ -51,10 +59,7 @@ export function softHeapLimit() {
51
59
  const memory = globalThis.performance?.memory;
52
60
  if (Number.isFinite(memory?.jsHeapSizeLimit)) limit = memory.jsHeapSizeLimit;
53
61
  }
54
- // Leave ample room for the generator stack to unwind and for the top level
55
- // to construct and print resource_error(memory). Fatal V8 OOMs cannot be
56
- // caught after the heap limit itself has been reached.
57
- return Number.isFinite(limit) && limit > 0 ? Math.floor(limit * 0.75) : Infinity;
62
+ return Number.isFinite(limit) && limit > 0 ? limit : Infinity;
58
63
  }
59
64
 
60
65
  function configuredOldSpaceBytes() {
package/src/program.js CHANGED
@@ -368,6 +368,11 @@ export class Program {
368
368
  (isPiAccumulator(group) || isPortableBetweenGenerator(group));
369
369
  group.linearNumeric = linearNumeric;
370
370
  group.fastPi = linearNumeric && isPiAccumulator(group);
371
+ const directRecursiveComponent = plannedRecursive && [...deps[start]].every((dependency) =>
372
+ dependency === start || !reachableIndexes(dependency, deps).has(start)
373
+ );
374
+ group.listTailRecursive = directRecursiveComponent && !group.cutRecursive &&
375
+ hasStrictListTailRecursion(group);
371
376
  group.tabled = plannedRecursive &&
372
377
  !componentHasNegativeEdge(start, deps, negativeEdges) &&
373
378
  !group.cutRecursive &&
@@ -1063,6 +1068,25 @@ function inferStructuralInputPositions(group) {
1063
1068
  return Array.from({ length: group.arity }, (_, index) => index);
1064
1069
  }
1065
1070
 
1071
+ function hasStrictListTailRecursion(group) {
1072
+ let foundRecursiveCall = false;
1073
+ for (const clause of group.clauses) {
1074
+ if (isCompactBinaryClause(clause)) return false;
1075
+ for (const goal of clause.body) {
1076
+ if (goal.type !== COMPOUND || goal.name !== group.name || goal.arity !== group.arity) continue;
1077
+ foundRecursiveCall = true;
1078
+ const decreases = goal.args.some((argument, index) => {
1079
+ const head = clause.head.args[index];
1080
+ return head?.type === COMPOUND && head.name === '.' && head.arity === 2 &&
1081
+ head.args[1]?.type === VAR && argument?.type === VAR &&
1082
+ head.args[1].name === argument.name;
1083
+ });
1084
+ if (!decreases) return false;
1085
+ }
1086
+ }
1087
+ return foundRecursiveCall;
1088
+ }
1089
+
1066
1090
  function hasLinearNumericRecursion(group) {
1067
1091
  let recursiveClause = null;
1068
1092
  for (const clause of group.clauses) {
package/src/solver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Depth-first EyeProlog solver with builtin dispatch, memoization, and guarded recursion handling.
2
2
  // Most semantic decisions still flow through unification; optimizations only select candidates earlier.
3
3
  import {
4
- COMPOUND, NUMBER, VAR, Env, compound, cons, copyResolved, deref, emptyList,
4
+ COMPOUND, NUMBER, VAR, Env, compactListLength, compactVariableList, compound, cons, copyResolved, deref, emptyList,
5
5
  flattenConjunction, freshTerm, isCons, isDecimalInteger, isEmptyList,
6
6
  numberTerm, numberTextFromDouble, termIsGround, termToString, unify, variable, variantTerms,
7
7
  } from './term.js';
@@ -11,7 +11,7 @@ import { getEyePrologRegistry } from './standard-library.js';
11
11
  import { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program.js';
12
12
  import { StreamManager } from './io.js';
13
13
  import { clpzStateConsistent } from './clpz.js';
14
- import { softHeapLimit, usedHeapSize } from './platform.js';
14
+ import { hardHeapLimit, softHeapLimit, usedHeapSize } from './platform.js';
15
15
 
16
16
  let freshCounter = 0;
17
17
 
@@ -51,6 +51,11 @@ export class Solver {
51
51
  this.inferences = 0;
52
52
  this.inferenceLimitExceeded = false;
53
53
  this.maxMemoryBytes = options.maxMemoryBytes ?? softHeapLimit();
54
+ this.memoryRecovery = options.memoryRecovery ?? {
55
+ active: false,
56
+ reservationBytes: 0,
57
+ checks: 0,
58
+ };
54
59
  this.nextMemoryCheck = 0;
55
60
  // Do not impose an implicit answer cap. Infinite and very large searches are
56
61
  // part of normal Prolog semantics; callers that need a resource bound can
@@ -121,6 +126,7 @@ export class Solver {
121
126
  maxDepth: this.maxDepth,
122
127
  maxInferences: this.maxInferences,
123
128
  maxMemoryBytes: this.maxMemoryBytes,
129
+ memoryRecovery: this.memoryRecovery,
124
130
  solutionLimit,
125
131
  isoStrict: this.isoStrict,
126
132
  prologFlags: this.prologFlags,
@@ -424,7 +430,18 @@ export class Solver {
424
430
  }
425
431
  }
426
432
  } catch (error) {
427
- throw normalizeHostResourceError(error);
433
+ const normalized = normalizeHostResourceError(error);
434
+ if (normalized instanceof PrologError && normalized.formal === 'resource_error(memory)') {
435
+ // Unwinding makes query-local terms unreachable, but hosts are free to
436
+ // postpone collection. Give the shared solver family bounded breathing
437
+ // room on its next query so a GC can observe those released references.
438
+ if (!this.memoryRecovery.active) {
439
+ this.memoryRecovery.active = true;
440
+ this.memoryRecovery.reservationBytes = 1024 * 1024;
441
+ this.memoryRecovery.checks = 16;
442
+ }
443
+ }
444
+ throw normalized;
428
445
  } finally {
429
446
  const stackIndex = this.solveStacks.indexOf(registeredStack);
430
447
  if (stackIndex >= 0) this.solveStacks.splice(stackIndex, 1);
@@ -441,7 +458,12 @@ export class Solver {
441
458
  this.nextMemoryCheck = this.inferences + 256;
442
459
  if (!Number.isFinite(this.maxMemoryBytes)) return;
443
460
  const used = usedHeapSize();
444
- if (used != null && used >= this.maxMemoryBytes) {
461
+ if (used != null && used < this.maxMemoryBytes) this.finishMemoryRecovery();
462
+ if (used != null && used >= this.currentMemoryLimit()) {
463
+ if (this.memoryRecovery.active && this.memoryRecovery.checks > 0) {
464
+ this.memoryRecovery.checks--;
465
+ return;
466
+ }
445
467
  throw new PrologError('resource_error(memory)');
446
468
  }
447
469
  }
@@ -449,11 +471,32 @@ export class Solver {
449
471
  checkMemoryReservation(bytes) {
450
472
  if (!Number.isFinite(this.maxMemoryBytes) || !Number.isFinite(bytes) || bytes <= 0) return;
451
473
  const used = usedHeapSize();
452
- if (used != null && bytes > Math.max(0, this.maxMemoryBytes - used)) {
474
+ if (used != null && used < this.maxMemoryBytes) this.finishMemoryRecovery();
475
+ if (used != null && bytes > Math.max(0, this.currentMemoryLimit() - used)) {
476
+ if (this.memoryRecovery.active && bytes <= this.memoryRecovery.reservationBytes) {
477
+ this.memoryRecovery.reservationBytes -= bytes;
478
+ return;
479
+ }
453
480
  throw new PrologError('resource_error(memory)');
454
481
  }
455
482
  }
456
483
 
484
+ currentMemoryLimit() {
485
+ if (!this.memoryRecovery.active) return this.maxMemoryBytes;
486
+ // Retain at least five percent of the actual host ceiling for error
487
+ // construction and unwinding. For an embedder-supplied lower soft limit,
488
+ // cap the temporary recovery window as well.
489
+ const hostSafetyLimit = hardHeapLimit() * 0.95;
490
+ const recoveryAllowance = Math.max(8 * 1024 * 1024, this.maxMemoryBytes * 0.125);
491
+ return Math.min(hostSafetyLimit, this.maxMemoryBytes + recoveryAllowance);
492
+ }
493
+
494
+ finishMemoryRecovery() {
495
+ this.memoryRecovery.active = false;
496
+ this.memoryRecovery.reservationBytes = 0;
497
+ this.memoryRecovery.checks = 0;
498
+ }
499
+
457
500
  *solveUserGoal(goal, rest, env, depth) {
458
501
  this.stats.solve_one_goal_calls++;
459
502
  if (depth > this.maxDepth) {
@@ -700,6 +743,10 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
700
743
  }
701
744
 
702
745
  function groupNeedsActiveFrame(group) {
746
+ // A direct recursive call that consumes the tail of a matched list cannot
747
+ // revisit an earlier finite-tree call. It needs neither a cycle guard nor an
748
+ // O(depth) copy of the active-call stack at every element.
749
+ if (group.listTailRecursive === true && group.hasCut !== true) return false;
703
750
  // User code may observe the surrounding control context through later cuts,
704
751
  // so only apply this planning shortcut to the fixed bundled-library graph.
705
752
  if (group.bundledLibrary !== true) return true;
@@ -772,23 +819,21 @@ function* fixedLengthSolutions(solver, list, length, env) {
772
819
  return;
773
820
  }
774
821
 
775
- // A materialized list cell with its fresh variable occupies roughly 216
776
- // bytes on current V8 builds. Reserve conservatively before constructing a
777
- // huge list so a resource_error does not leave the heap at the soft limit
778
- // and poison the next query.
779
- const estimatedListCellBytes = 256n;
780
822
  if (remaining > BigInt(Number.MAX_SAFE_INTEGER)) throw new PrologError('resource_error(memory)');
781
- solver.checkMemoryReservation(Number(remaining * estimatedListCellBytes));
782
-
783
823
  const id = nextFreshId();
784
- let suffix = emptyList();
785
- for (let index = 0n; index < remaining; index++) {
786
- suffix = cons(variable(`__length${id}_${index}`), suffix);
787
- lengthAllocationCheckpoint(solver, ++steps);
788
- }
824
+ // Keep an unobserved fixed-length list as one compact skeleton. Unification,
825
+ // list predicates, and readback expand it one cell at a time if its elements
826
+ // are actually inspected.
827
+ solver.checkMemoryReservation(256);
828
+ const suffix = compactVariableList(remaining, `__length${id}_`);
789
829
  const next = env.clone();
790
830
  solver.stats.unify_calls++;
791
- if (unify(cursor, suffix, next)) yield next;
831
+ // cursor is dereferenced and the compact skeleton contains only freshly
832
+ // generated variables, so this binding cannot create a cycle. Binding it
833
+ // directly avoids traversing and expanding the new skeleton for an occurs
834
+ // check whose result is known by construction.
835
+ next.bind(cursor.name, suffix);
836
+ yield next;
792
837
  }
793
838
 
794
839
  function* generatedLengthSolutions(solver, list, length, env) {
@@ -1338,18 +1383,61 @@ function variantShape(term, env) {
1338
1383
  return term.args.map((arg) => variantArgumentSize(arg, env)).join(',');
1339
1384
  }
1340
1385
 
1386
+ const rawProperListLengths = new WeakMap();
1387
+
1388
+ function rawProperListLength(term) {
1389
+ const compactLength = compactListLength(term);
1390
+ if (compactLength != null) return compactLength;
1391
+ if (!isCons(term)) return null;
1392
+ const cells = [];
1393
+ const seen = new WeakSet();
1394
+ let cursor = term;
1395
+ let suffixLength = 0;
1396
+ while (isCons(cursor)) {
1397
+ const cached = rawProperListLengths.get(cursor);
1398
+ if (cached != null) {
1399
+ suffixLength = cached;
1400
+ break;
1401
+ }
1402
+ if (seen.has(cursor)) return null;
1403
+ seen.add(cursor);
1404
+ cells.push(cursor);
1405
+ // Only cache the raw spine. A variable tail may resolve differently in
1406
+ // separate environments and therefore needs the general shape walk.
1407
+ cursor = cursor.args[1];
1408
+ }
1409
+ if (!isEmptyList(cursor) && rawProperListLengths.get(cursor) == null) return null;
1410
+ for (let index = cells.length - 1; index >= 0; index--) {
1411
+ rawProperListLengths.set(cells[index], ++suffixLength);
1412
+ }
1413
+ return rawProperListLengths.get(term) ?? suffixLength;
1414
+ }
1415
+
1341
1416
  function variantArgumentSize(term, env) {
1342
- const pending = [term];
1417
+ const resolved = derefForLocal(term, env);
1418
+ const listLength = rawProperListLength(resolved);
1419
+ if (listLength != null) return `list:${listLength}`;
1420
+ const pending = [{ term, exit: false }];
1421
+ const ancestors = new WeakSet();
1343
1422
  let size = 0;
1344
1423
  while (pending.length > 0) {
1345
- const current = derefForLocal(pending.pop(), env);
1424
+ const item = pending.pop();
1425
+ if (item.exit) {
1426
+ ancestors.delete(item.term);
1427
+ continue;
1428
+ }
1429
+ const current = derefForLocal(item.term, env);
1346
1430
  size++;
1347
- // This is only a rejection key. Capping keeps pathological cyclic or very
1348
- // large terms bounded; equal capped sizes still fall through to the exact
1349
- // variant check.
1350
- if (size > 4096) return 4097;
1351
1431
  if (current?.type === COMPOUND) {
1352
- for (let index = 0; index < current.arity; index++) pending.push(current.args[index]);
1432
+ // Finite terms get an exact size, which cheaply distinguishes successive
1433
+ // tails of a long list. Keep cyclic terms conservative so the exact
1434
+ // variant check remains authoritative.
1435
+ if (ancestors.has(current)) return '*';
1436
+ ancestors.add(current);
1437
+ pending.push({ term: current, exit: true });
1438
+ for (let index = current.arity - 1; index >= 0; index--) {
1439
+ pending.push({ term: current.args[index], exit: false });
1440
+ }
1353
1441
  }
1354
1442
  }
1355
1443
  return size;
@@ -1400,45 +1488,58 @@ function derefForLocal(term, env) {
1400
1488
  }
1401
1489
 
1402
1490
  function memoKey(goal, env, group = null) {
1403
- let hasBound = false;
1404
- const variables = new Map();
1405
1491
  const required = group?.tableInputPositions ?? [];
1406
- const ground = [];
1492
+ const ground = goal.args.map((arg) => termIsGround(arg, env));
1493
+ const hasBound = required.length > 0
1494
+ ? required.some((index) => ground[index])
1495
+ : ground.some(Boolean);
1496
+ // Automatic tabling only admits calls with a ground input. Avoid building a
1497
+ // potentially huge canonical key for the non-ground structural calls that
1498
+ // will use the ordinary active-call guard instead.
1499
+ if (!hasBound) return { hasBound: false, text: '' };
1500
+
1501
+ const variables = new Map();
1407
1502
  const parts = goal.args.map((arg) => {
1408
1503
  const value = derefForLocal(arg, env);
1409
- if (value.type === 'var') {
1410
- ground.push(false);
1411
- return '_';
1412
- }
1413
- const canonical = canonicalTermInfo(value, env, variables);
1414
- ground.push(canonical.ground);
1415
- if (canonical.ground) hasBound = true;
1416
- return canonical.key;
1504
+ return value.type === 'var' ? '_' : canonicalTermKey(value, env, variables);
1417
1505
  });
1418
- if (required.length > 0) {
1419
- hasBound = required.some((index) => ground[index]);
1420
- }
1421
1506
  return { hasBound, text: parts.join('|') };
1422
1507
  }
1423
1508
 
1424
- function canonicalTermInfo(term, env, variables) {
1425
- const value = derefForLocal(term, env);
1426
- if (value.type === 'var') {
1427
- let id = variables.get(value.name);
1428
- if (id == null) {
1429
- id = variables.size;
1430
- variables.set(value.name, id);
1509
+ function canonicalTermKey(term, env, variables) {
1510
+ // Memo keys are needed before deciding whether a recursive call is tabled.
1511
+ // Build them iteratively: a perfectly ordinary bound list can be deeper than
1512
+ // JavaScript's native call stack even though the solver itself is iterative.
1513
+ const key = [];
1514
+ const pending = [{ kind: 'term', term }];
1515
+ while (pending.length > 0) {
1516
+ const item = pending.pop();
1517
+ if (item.kind === 'text') {
1518
+ key.push(item.text);
1519
+ continue;
1520
+ }
1521
+ const value = derefForLocal(item.term, env);
1522
+ if (value.type === 'var') {
1523
+ let id = variables.get(value.name);
1524
+ if (id == null) {
1525
+ id = variables.size;
1526
+ variables.set(value.name, id);
1527
+ }
1528
+ key.push(`var:${id}`);
1529
+ continue;
1530
+ }
1531
+ if (!value.args?.length) {
1532
+ key.push(`${value.type}:${value.name}`);
1533
+ continue;
1534
+ }
1535
+ key.push(`${value.type}:${value.name}(`);
1536
+ pending.push({ kind: 'text', text: ')' });
1537
+ for (let index = value.args.length - 1; index >= 0; index--) {
1538
+ if (index < value.args.length - 1) pending.push({ kind: 'text', text: ',' });
1539
+ pending.push({ kind: 'term', term: value.args[index] });
1431
1540
  }
1432
- return { key: `var:${id}`, ground: false };
1433
1541
  }
1434
- if (!value.args?.length) return { key: `${value.type}:${value.name}`, ground: true };
1435
- let ground = true;
1436
- const keys = value.args.map((arg) => {
1437
- const child = canonicalTermInfo(arg, env, variables);
1438
- if (!child.ground) ground = false;
1439
- return child.key;
1440
- });
1441
- return { key: `${value.type}:${value.name}(${keys.join(',')})`, ground };
1542
+ return key.join('');
1442
1543
  }
1443
1544
 
1444
1545
  function copyResolvedWithKey(term, env, variables) {
package/src/term.js CHANGED
@@ -8,6 +8,7 @@ export const STRING = 'string';
8
8
  export const NUMBER = 'number';
9
9
  export const COMPOUND = 'compound';
10
10
  const EMPTY_ARGS = Object.freeze([]);
11
+ const ENV_FLATTEN_DEPTH = 1024;
11
12
 
12
13
  export class Term {
13
14
  constructor(type, name, args = []) {
@@ -20,6 +21,55 @@ export class Term {
20
21
  }
21
22
  }
22
23
 
24
+ // A fixed-length list of fresh variables is represented as one compact
25
+ // skeleton and expanded cell-by-cell only when a goal actually inspects it.
26
+ // This keeps ordinary logical construction proportional to what the program
27
+ // observes instead of eagerly allocating two host objects per list element.
28
+ export class CompactListTerm {
29
+ constructor(length, variablePrefix, offset = 0n, state = null) {
30
+ this.type = COMPOUND;
31
+ this.name = '.';
32
+ this._compactLength = BigInt(length);
33
+ this._variablePrefix = variablePrefix;
34
+ this._offset = BigInt(offset);
35
+ this._compactState = state ?? { maxPossiblyBoundIndex: -1n };
36
+ this._args = null;
37
+ }
38
+ get arity() {
39
+ return 2;
40
+ }
41
+ get args() {
42
+ if (this._args == null) {
43
+ const head = variable(`${this._variablePrefix}${this._offset}`);
44
+ head._compactState = this._compactState;
45
+ head._compactIndex = this._offset;
46
+ const tail = this._compactLength === 1n
47
+ ? emptyList()
48
+ : new CompactListTerm(
49
+ this._compactLength - 1n,
50
+ this._variablePrefix,
51
+ this._offset + 1n,
52
+ this._compactState,
53
+ );
54
+ this._args = [head, tail];
55
+ }
56
+ return this._args;
57
+ }
58
+ mayContainVariable(name) {
59
+ if (String(name).startsWith(this._variablePrefix)) {
60
+ const indexText = String(name).slice(this._variablePrefix.length);
61
+ if (/^\d+$/.test(indexText)) {
62
+ const index = BigInt(indexText);
63
+ if (index >= this._offset && index < this._offset + this._compactLength) return true;
64
+ }
65
+ }
66
+ // If no generated head in this suffix has ever participated in a binding,
67
+ // an unrelated variable cannot occur below it. This remains conservative
68
+ // across backtracking because the high-water mark is never rolled back.
69
+ return this._compactState.maxPossiblyBoundIndex >= this._offset;
70
+ }
71
+ }
72
+
23
73
  export const variable = (name) => new Term(VAR, name, EMPTY_ARGS);
24
74
  export const atom = (name) => new Term(ATOM, name, EMPTY_ARGS);
25
75
  export const stringTerm = (value) => new Term(STRING, value, EMPTY_ARGS);
@@ -27,6 +77,12 @@ export const numberTerm = (value) => new Term(NUMBER, value, EMPTY_ARGS);
27
77
  export const compound = (name, args = []) => args.length === 0 ? atom(name) : new Term(COMPOUND, name, args);
28
78
  export const emptyList = () => atom('[]');
29
79
  export const cons = (head, tail) => compound('.', [head, tail]);
80
+ export const compactVariableList = (length, variablePrefix) => {
81
+ const size = BigInt(length);
82
+ return size === 0n ? emptyList() : new CompactListTerm(size, variablePrefix);
83
+ };
84
+ export const isCompactList = (term) => term instanceof CompactListTerm;
85
+ export const compactListLength = (term) => isCompactList(term) ? term._compactLength : null;
30
86
 
31
87
  export class Env {
32
88
  constructor(bindings) {
@@ -94,7 +150,7 @@ export class Env {
94
150
  return undefined;
95
151
  }
96
152
  bind(name, term) {
97
- if (this._state.depth >= 32) {
153
+ if (this._state.depth >= ENV_FLATTEN_DEPTH) {
98
154
  const flattened = new Map();
99
155
  for (let state = this._state; state != null; state = state.parent) {
100
156
  if (state.bindingName != null && !flattened.has(state.bindingName)) {
@@ -200,6 +256,7 @@ function occurs(variableName, term, env) {
200
256
  if (binding !== undefined) stack.push(binding);
201
257
  continue;
202
258
  }
259
+ if (isCompactList(current) && !current.mayContainVariable(variableName)) continue;
203
260
  if (current?.type !== COMPOUND || seenTerms.has(current)) continue;
204
261
  seenTerms.add(current);
205
262
  for (let i = 0; i < current.arity; i++) stack.push(current.args[i]);
@@ -224,6 +281,7 @@ export function unify(left, right, env, options = {}) {
224
281
  if (a.type === VAR && b.type === VAR) {
225
282
  // Both variables are already dereferenced and unbound, so linking them
226
283
  // cannot create a cycle and needs no occurs-check traversal.
284
+ markCompactVariableBound(a);
227
285
  env.bind(a.name, b);
228
286
  continue;
229
287
  }
@@ -232,6 +290,7 @@ export function unify(left, right, env, options = {}) {
232
290
  occursCheckHandler?.(a, b, env);
233
291
  return false;
234
292
  }
293
+ markCompactVariableBound(a);
235
294
  env.bind(a.name, b);
236
295
  continue;
237
296
  }
@@ -240,6 +299,7 @@ export function unify(left, right, env, options = {}) {
240
299
  occursCheckHandler?.(b, a, env);
241
300
  return false;
242
301
  }
302
+ markCompactVariableBound(b);
243
303
  env.bind(b.name, a);
244
304
  continue;
245
305
  }
@@ -264,6 +324,13 @@ export function unify(left, right, env, options = {}) {
264
324
  return true;
265
325
  }
266
326
 
327
+ function markCompactVariableBound(term) {
328
+ if (term?._compactState == null || term._compactIndex == null) return;
329
+ if (term._compactIndex > term._compactState.maxPossiblyBoundIndex) {
330
+ term._compactState.maxPossiblyBoundIndex = term._compactIndex;
331
+ }
332
+ }
333
+
267
334
  export function cloneTerm(term) {
268
335
  if (term.type === VAR) return variable(term.name);
269
336
  const cloned = term.type === COMPOUND && term.arity === 0
@@ -307,7 +374,12 @@ export function termIsGround(term, env = new Env()) {
307
374
  if (resolved.type === VAR) return false;
308
375
  if (seen.has(resolved)) continue;
309
376
  seen.add(resolved);
310
- for (const arg of resolved.args) pending.push(arg);
377
+ // Visit leftmost arguments first. Lists and other recursive structures
378
+ // commonly carry their first unbound variable there, allowing a
379
+ // non-ground check to finish without walking the complete tail.
380
+ for (let index = resolved.args.length - 1; index >= 0; index--) {
381
+ pending.push(resolved.args[index]);
382
+ }
311
383
  }
312
384
  return true;
313
385
  }
@@ -505,21 +577,37 @@ export function termSignature(term) {
505
577
  }
506
578
 
507
579
  export function variantTerms(left, leftEnv, right, rightEnv, pairs = new Map(), reverse = new Map()) {
508
- left = deref(left, leftEnv);
509
- right = deref(right, rightEnv);
510
- if (left.type === VAR || right.type === VAR) {
511
- if (left.type !== VAR || right.type !== VAR) return false;
512
- if (pairs.has(left.name) || reverse.has(right.name)) {
513
- return pairs.get(left.name) === right.name && reverse.get(right.name) === left.name;
580
+ // Variant checks sit on the recursive-call hot path. Use an explicit work
581
+ // stack so long lists do not consume the JavaScript call stack.
582
+ const pending = [[left, right]];
583
+ const seen = new WeakMap();
584
+ while (pending.length > 0) {
585
+ [left, right] = pending.pop();
586
+ left = deref(left, leftEnv);
587
+ right = deref(right, rightEnv);
588
+ if (left.type === VAR || right.type === VAR) {
589
+ if (left.type !== VAR || right.type !== VAR) return false;
590
+ if (pairs.has(left.name) || reverse.has(right.name)) {
591
+ if (pairs.get(left.name) !== right.name || reverse.get(right.name) !== left.name) return false;
592
+ continue;
593
+ }
594
+ pairs.set(left.name, right.name);
595
+ reverse.set(right.name, left.name);
596
+ continue;
514
597
  }
515
- pairs.set(left.name, right.name);
516
- reverse.set(right.name, left.name);
517
- return true;
518
- }
519
- if (left.type !== right.type || left.arity !== right.arity) return false;
520
- if (left.type === NUMBER ? !sameNumberValue(left.name, right.name) : left.name !== right.name) return false;
521
- for (let i = 0; i < left.arity; i++) {
522
- if (!variantTerms(left.args[i], leftEnv, right.args[i], rightEnv, pairs, reverse)) return false;
598
+
599
+ if (left.type !== right.type || left.arity !== right.arity) return false;
600
+ if (left.type === NUMBER ? !sameNumberValue(left.name, right.name) : left.name !== right.name) return false;
601
+ if (left.type !== COMPOUND) continue;
602
+
603
+ let rights = seen.get(left);
604
+ if (rights?.has(right)) continue;
605
+ if (rights == null) {
606
+ rights = new WeakSet();
607
+ seen.set(left, rights);
608
+ }
609
+ rights.add(right);
610
+ for (let i = left.arity - 1; i >= 0; i--) pending.push([left.args[i], right.args[i]]);
523
611
  }
524
612
  return true;
525
613
  }
@@ -40,6 +40,7 @@ import {
40
40
  } from '../src/index.js';
41
41
  import { parseGoalText, parseNumberTokenText } from '../src/parser.js';
42
42
  import { compareTerms } from '../src/term.js';
43
+ import { formatTermForWrite } from '../src/write.js';
43
44
  import { selectClauseCandidates } from '../src/program.js';
44
45
  import { TestReporter, isMainModule } from './test-style.mjs';
45
46
  import { buildConformanceReport, formatConformanceReport } from './run-conformance-report.mjs';
@@ -730,6 +731,36 @@ c4 ?- call((!;1)).
730
731
  assertIncludes(caught.message, 'operator atom', 'syntax rejection');
731
732
  },
732
733
  },
734
+ {
735
+ name: 'CLP(Z) operator declarations avoid unnecessary quoted atoms',
736
+ run: () => {
737
+ const filename = path.join(packageRoot, 'src', 'lib', 'clpz.pl');
738
+ const source = fs.readFileSync(filename, 'utf8');
739
+ for (const name of ['#>', '#<', '#>=', '#=<', '#=', '#\\=', '#<==>', '#==>', '#<==', '#\\/', '#/\\']) {
740
+ assertIncludes(source, `(${name})/2`, `${name}/2 export is parenthesized`);
741
+ assertNotIncludes(source, `('${name}')/2`, `${name}/2 export is not quoted`);
742
+ assertNotIncludes(source, `'${name}'(`, `${name} functional notation is not quoted`);
743
+ }
744
+ assertIncludes(source, '(#\\)/1', '#\\/1 export is parenthesized');
745
+ assertIncludes(source, '(#\\)/2', '#\\/2 export is parenthesized');
746
+ assertIncludes(source, '(in)/2', 'in/2 export is parenthesized');
747
+ assertIncludes(source, '(ins)/2', 'ins/2 export is parenthesized');
748
+ assertNotIncludes(source, "('in')/2", 'in/2 export is not quoted');
749
+ assertNotIncludes(source, "('ins')/2", 'ins/2 export is not quoted');
750
+
751
+ const canonicalOptions = { quoted: true, ignoreOps: true, compact: true, operators: [] };
752
+ assertEqual(
753
+ formatTermForWrite(compound('#>', [atom('a'), atom('b')]), new Env(), canonicalOptions),
754
+ '#>(a,b)',
755
+ 'canonical #> functor has no quotes',
756
+ );
757
+ assertEqual(
758
+ formatTermForWrite(compound('#\\=', [atom('a'), atom('b')]), new Env(), canonicalOptions),
759
+ '#\\=(a,b)',
760
+ 'canonical #\\= functor has no quotes',
761
+ );
762
+ },
763
+ },
733
764
  {
734
765
  name: 'layout distinguishes prefix notation from functional notation',
735
766
  run: () => {
@@ -2765,7 +2796,7 @@ open(X) :- candidate(X), \\+ closed(X).
2765
2796
  },
2766
2797
  },
2767
2798
  {
2768
- name: 'resource_error(memory) leaves enough heap for subsequent list work',
2799
+ name: 'discarded fixed-length lists stay compact under a bounded heap',
2769
2800
  run: () => {
2770
2801
  const engineUrl = new URL('../src/index.js', import.meta.url).href;
2771
2802
  const script = `
@@ -2776,11 +2807,75 @@ open(X) :- candidate(X), \\+ closed(X).
2776
2807
  const goal = parseGoalText(text, { operatorDefinitions: [...program.operators.values()] });
2777
2808
  return [...solver.solve([goal], new Env(), 0)];
2778
2809
  };
2810
+ execute('length(L,10000000),fail');
2811
+ if (execute('length(L,1),L=[X],X=L').length !== 0) {
2812
+ throw new Error('compact list admitted a cyclic binding');
2813
+ }
2779
2814
  execute('length(L,1000),fail');
2815
+ process.stdout.write('compact');
2816
+ `;
2817
+ const result = spawnSync(process.execPath, [
2818
+ '--max-old-space-size=64',
2819
+ '--input-type=module',
2820
+ '--eval',
2821
+ script,
2822
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2823
+ if (result.error) throw result.error;
2824
+ assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2825
+ assertEqual(result.stdout, 'compact', 'discarded list skeleton does not exhaust the heap');
2826
+ },
2827
+ },
2828
+ {
2829
+ name: 'deep recursive list guards do not consume the host call stack',
2830
+ run: () => {
2831
+ const program = Program.parse('l([]).\nl([_|L]) :- l(L).\n', { sourceMetadata: false });
2832
+ const left = listFromItems(Array.from({ length: 20000 }, (_, index) => variable(`L${index}`)));
2833
+ const right = listFromItems(Array.from({ length: 20000 }, (_, index) => variable(`R${index}`)));
2834
+ const solver = new Solver(program, {
2835
+ registry: getEyePrologRegistry(),
2836
+ maxDepth: 0,
2837
+ maxMemoryBytes: Infinity,
2838
+ });
2839
+ const answers = [...solver.solve([compound('l', [left])], new Env(), 0)];
2840
+ assertEqual(answers.length, 0, 'depth bound stops after stack-safe memo classification');
2841
+ assertEqual(variantTerms(left, new Env(), right, new Env()), true, 'deep lists are variants');
2842
+ },
2843
+ },
2844
+ {
2845
+ name: 'discarded recursive allocations recover after resource_error(memory)',
2846
+ run: () => {
2847
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
2848
+ const programText = `
2849
+ :- use_module(library(prologue)).
2850
+ l([]).
2851
+ l([E|L]) :- length(E,1000), l(L).
2852
+ `;
2853
+ const reportedGoal = 'length(_,I),N is 2^I,\\+ \\+ (length(L,N),l(L)),L=[_|_]';
2854
+ const script = `
2855
+ import { Program, Solver, Env, deref, variable, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
2856
+ const program = Program.parse(${JSON.stringify(programText)}, { sourceMetadata: false });
2857
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
2858
+ const execute = (text) => {
2859
+ const goal = parseGoalText(text, { operatorDefinitions: [...program.operators.values()] });
2860
+ return [...solver.solve([goal], new Env(), 0)];
2861
+ };
2862
+ const reported = parseGoalText(${JSON.stringify(reportedGoal)}, {
2863
+ operatorDefinitions: [...program.operators.values()],
2864
+ });
2865
+ let reportedAnswers = 0;
2866
+ for (const answer of solver.solve([reported], new Env(), 0)) {
2867
+ reportedAnswers++;
2868
+ if (reportedAnswers !== 14) continue;
2869
+ if (deref(variable('I'), answer).name !== '13' || deref(variable('N'), answer).name !== '8192') {
2870
+ throw new Error('reported query did not reach I=13, N=8192');
2871
+ }
2872
+ break;
2873
+ }
2874
+ if (reportedAnswers !== 14) throw new Error('reported query produced too few answers');
2780
2875
  let caught = null;
2781
- try { execute('length(L,1000000),fail'); } catch (error) { caught = error; }
2876
+ try { execute('length(L,32768),l(L)'); } catch (error) { caught = error; }
2782
2877
  if (caught?.formal !== 'resource_error(memory)') throw caught ?? new Error('no resource error');
2783
- execute('length(L,1000),fail');
2878
+ if (execute('length(L,10),l(L)').length !== 1) throw new Error('recovery query failed');
2784
2879
  process.stdout.write('recovered');
2785
2880
  `;
2786
2881
  const result = spawnSync(process.execPath, [
@@ -2791,7 +2886,7 @@ open(X) :- candidate(X), \\+ closed(X).
2791
2886
  ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2792
2887
  if (result.error) throw result.error;
2793
2888
  assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2794
- assertEqual(result.stdout, 'recovered', 'solver remains usable after resource error');
2889
+ assertEqual(result.stdout, 'recovered', 'discarded recursive terms are collectible after unwinding');
2795
2890
  },
2796
2891
  },
2797
2892
  {