eyeprolog 1.2.33 → 1.2.35

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.33",
6
+ "version": "1.2.35",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/iso.js CHANGED
@@ -2029,35 +2029,39 @@ function* negationBuiltin({ solver, goal, env }) {
2029
2029
  for (const _ of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) return;
2030
2030
  yield env;
2031
2031
  }
2032
+ function* solveControlBranch(solver, goal, env) {
2033
+ for (const answer of solver.solve([callable(goal, env)], env, 0)) {
2034
+ // A branch answer is internal to its enclosing control construct. The
2035
+ // surrounding solve will count the completed control goal after the
2036
+ // builtin yields it. Leaving both counts in place makes a bounded search
2037
+ // such as once/1 or negation stop before it can observe the branch answer.
2038
+ if (solver.solutionsSeen > 0) solver.solutionsSeen--;
2039
+ yield answer;
2040
+ }
2041
+ }
2032
2042
  function* disjunctionBuiltin({ solver, goal, env }) {
2033
2043
  const left = deref(goal.args[0], env);
2034
2044
  if (left.type === COMPOUND && left.name === '->' && left.arity === 2) {
2035
2045
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(left.args[0], env)], env.clone(), 0)) {
2036
- yield* solver.solve([callable(left.args[1], conditionEnv)], conditionEnv, 0);
2046
+ yield* solveControlBranch(solver, left.args[1], conditionEnv);
2037
2047
  return;
2038
2048
  }
2039
- yield* solver.solve([callable(goal.args[1], env)], env.clone(), 0);
2049
+ yield* solveControlBranch(solver, goal.args[1], env.clone());
2040
2050
  return;
2041
2051
  }
2042
2052
  const marker = solver.active[solver.active.length - 1] ?? null;
2043
2053
  const markerCutEpoch = marker?.cutEpoch ?? 0;
2044
2054
  const solverCutEpoch = solver.cutEpoch;
2045
- yield* solver.solve([callable(goal.args[0], env)], env.clone(), 0);
2055
+ yield* solveControlBranch(solver, goal.args[0], env.clone());
2046
2056
  const cutThisScope = marker == null
2047
2057
  ? solver.cutEpoch !== solverCutEpoch
2048
2058
  : (marker.cutEpoch ?? 0) !== markerCutEpoch;
2049
2059
  if (cutThisScope) return;
2050
- yield* solver.solve([callable(goal.args[1], env)], env.clone(), 0);
2060
+ yield* solveControlBranch(solver, goal.args[1], env.clone());
2051
2061
  }
2052
2062
  function* ifThenBuiltin({ solver, goal, env }) {
2053
2063
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) {
2054
- for (const consequentEnv of solver.solve([callable(goal.args[1], conditionEnv)], conditionEnv, 0)) {
2055
- // The consequent is an internal part of the current solution, not a
2056
- // completed top-level solution. Keep a surrounding bounded search (for
2057
- // example nested ISO once-as-if-then) from consuming its limit early.
2058
- if (solver.solutionsSeen > 0) solver.solutionsSeen--;
2059
- yield consequentEnv;
2060
- }
2064
+ yield* solveControlBranch(solver, goal.args[1], conditionEnv);
2061
2065
  return;
2062
2066
  }
2063
2067
  }
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
  }
@@ -477,6 +477,32 @@ c4 ?- call((!;1)).
477
477
  assertEqual(result.stdout, 'quads: 22 run, 22 passed, 0 failed.\n', 'quad report');
478
478
  },
479
479
  },
480
+ {
481
+ name: 'negation observes disjunction through direct and call/1 execution',
482
+ run: () => {
483
+ const reported = publicApi.runQuads(String.raw`?- \+ (true ; true).
484
+ false.
485
+
486
+ ?- call(\+ (true ; true)).
487
+ false.
488
+ `);
489
+ assertEqual(reported.total, 2, 'reported query count');
490
+ assertEqual(reported.passed, 2, 'reported queries pass');
491
+
492
+ const program = Program.parse('');
493
+ const answerCount = (text) => {
494
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
495
+ return [...solver.solve([parseGoalText(text)], new Env(), 0)].length;
496
+ };
497
+
498
+ assertEqual(answerCount(String.raw`\+ (true ; true)`), 0, 'direct successful disjunction is negated');
499
+ assertEqual(answerCount(String.raw`call(\+ (true ; true))`), 0, 'called negation fails');
500
+ assertEqual(answerCount(String.raw`\+ (true ; fail)`), 0, 'successful left branch is observed');
501
+ assertEqual(answerCount(String.raw`\+ (fail ; true)`), 0, 'successful right branch is observed');
502
+ assertEqual(answerCount(String.raw`\+ (fail ; fail)`), 1, 'failed disjunction is negated');
503
+ assertEqual(answerCount('once((true ; true))'), 1, 'once keeps the first disjunction answer');
504
+ },
505
+ },
480
506
  {
481
507
  name: 'runQuads passes the complete vendored ISO phrase quad corpus',
482
508
  run: () => {
@@ -2796,7 +2822,7 @@ open(X) :- candidate(X), \\+ closed(X).
2796
2822
  },
2797
2823
  },
2798
2824
  {
2799
- name: 'resource_error(memory) leaves enough heap for subsequent list work',
2825
+ name: 'discarded fixed-length lists stay compact under a bounded heap',
2800
2826
  run: () => {
2801
2827
  const engineUrl = new URL('../src/index.js', import.meta.url).href;
2802
2828
  const script = `
@@ -2807,11 +2833,75 @@ open(X) :- candidate(X), \\+ closed(X).
2807
2833
  const goal = parseGoalText(text, { operatorDefinitions: [...program.operators.values()] });
2808
2834
  return [...solver.solve([goal], new Env(), 0)];
2809
2835
  };
2836
+ execute('length(L,10000000),fail');
2837
+ if (execute('length(L,1),L=[X],X=L').length !== 0) {
2838
+ throw new Error('compact list admitted a cyclic binding');
2839
+ }
2810
2840
  execute('length(L,1000),fail');
2841
+ process.stdout.write('compact');
2842
+ `;
2843
+ const result = spawnSync(process.execPath, [
2844
+ '--max-old-space-size=64',
2845
+ '--input-type=module',
2846
+ '--eval',
2847
+ script,
2848
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2849
+ if (result.error) throw result.error;
2850
+ assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2851
+ assertEqual(result.stdout, 'compact', 'discarded list skeleton does not exhaust the heap');
2852
+ },
2853
+ },
2854
+ {
2855
+ name: 'deep recursive list guards do not consume the host call stack',
2856
+ run: () => {
2857
+ const program = Program.parse('l([]).\nl([_|L]) :- l(L).\n', { sourceMetadata: false });
2858
+ const left = listFromItems(Array.from({ length: 20000 }, (_, index) => variable(`L${index}`)));
2859
+ const right = listFromItems(Array.from({ length: 20000 }, (_, index) => variable(`R${index}`)));
2860
+ const solver = new Solver(program, {
2861
+ registry: getEyePrologRegistry(),
2862
+ maxDepth: 0,
2863
+ maxMemoryBytes: Infinity,
2864
+ });
2865
+ const answers = [...solver.solve([compound('l', [left])], new Env(), 0)];
2866
+ assertEqual(answers.length, 0, 'depth bound stops after stack-safe memo classification');
2867
+ assertEqual(variantTerms(left, new Env(), right, new Env()), true, 'deep lists are variants');
2868
+ },
2869
+ },
2870
+ {
2871
+ name: 'discarded recursive allocations recover after resource_error(memory)',
2872
+ run: () => {
2873
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
2874
+ const programText = `
2875
+ :- use_module(library(prologue)).
2876
+ l([]).
2877
+ l([E|L]) :- length(E,1000), l(L).
2878
+ `;
2879
+ const reportedGoal = 'length(_,I),N is 2^I,\\+ \\+ (length(L,N),l(L)),L=[_|_]';
2880
+ const script = `
2881
+ import { Program, Solver, Env, deref, variable, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
2882
+ const program = Program.parse(${JSON.stringify(programText)}, { sourceMetadata: false });
2883
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
2884
+ const execute = (text) => {
2885
+ const goal = parseGoalText(text, { operatorDefinitions: [...program.operators.values()] });
2886
+ return [...solver.solve([goal], new Env(), 0)];
2887
+ };
2888
+ const reported = parseGoalText(${JSON.stringify(reportedGoal)}, {
2889
+ operatorDefinitions: [...program.operators.values()],
2890
+ });
2891
+ let reportedAnswers = 0;
2892
+ for (const answer of solver.solve([reported], new Env(), 0)) {
2893
+ reportedAnswers++;
2894
+ if (reportedAnswers !== 14) continue;
2895
+ if (deref(variable('I'), answer).name !== '13' || deref(variable('N'), answer).name !== '8192') {
2896
+ throw new Error('reported query did not reach I=13, N=8192');
2897
+ }
2898
+ break;
2899
+ }
2900
+ if (reportedAnswers !== 14) throw new Error('reported query produced too few answers');
2811
2901
  let caught = null;
2812
- try { execute('length(L,1000000),fail'); } catch (error) { caught = error; }
2902
+ try { execute('length(L,32768),l(L)'); } catch (error) { caught = error; }
2813
2903
  if (caught?.formal !== 'resource_error(memory)') throw caught ?? new Error('no resource error');
2814
- execute('length(L,1000),fail');
2904
+ if (execute('length(L,10),l(L)').length !== 1) throw new Error('recovery query failed');
2815
2905
  process.stdout.write('recovered');
2816
2906
  `;
2817
2907
  const result = spawnSync(process.execPath, [
@@ -2822,7 +2912,7 @@ open(X) :- candidate(X), \\+ closed(X).
2822
2912
  ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2823
2913
  if (result.error) throw result.error;
2824
2914
  assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2825
- assertEqual(result.stdout, 'recovered', 'solver remains usable after resource error');
2915
+ assertEqual(result.stdout, 'recovered', 'discarded recursive terms are collectible after unwinding');
2826
2916
  },
2827
2917
  },
2828
2918
  {
@@ -1844,7 +1844,10 @@ normalized at the solver boundary instead of leaking a JavaScript `RangeError`.
1844
1844
  ISO 13211-1 leaves the resource atom implementation dependent. EyeProlog uses
1845
1845
  `memory` for a finite host allocation/capacity ceiling and reserves the
1846
1846
  `finite_memory` spelling for the distinct convention where no finite amount of
1847
- memory could complete the computation.
1847
+ memory could complete the computation. After a recoverable memory error, the
1848
+ solver keeps a bounded recovery window while the failed search unwinds so the
1849
+ host can collect released query terms. The same solver can then run later
1850
+ queries; this recovery does not resume the query that exhausted its limit.
1848
1851
 
1849
1852
  The iterative solver keeps active-call frames only where they are semantically
1850
1853
  needed for cut scope or recursive variant guards. Bundled-library helpers whose
@@ -1853,9 +1856,15 @@ guard therefore do not copy a growing active-call sequence at every step.
1853
1856
  Under the normal EyeProlog registry, the bundled Prologue `length/2` also has a
1854
1857
  scoped iterative execution path: named lists are counted or constructed without
1855
1858
  recursive interpreter frames, and an anonymous list is not materialized because
1856
- its binding cannot be observed. The ordinary clauses remain the authoritative
1857
- module definition and are used unchanged by the ISO-only registry and whenever
1858
- delays or finite-domain constraints require their normal wake-up points.
1859
+ its binding cannot be observed. A newly constructed fixed-length suffix starts
1860
+ as a lazy compact skeleton and expands one ordinary `./2` cell at a time when
1861
+ unification, another list predicate, or answer readback inspects it. This is a
1862
+ storage optimization, not a distinct Prolog term or list semantics. Embedders
1863
+ that inspect the JavaScript term model can recognize this representation with
1864
+ `CompactListTerm`, `isCompactList`, and `compactListLength`, or construct one
1865
+ with `compactVariableList`. The ordinary clauses remain the authoritative module
1866
+ definition and are used unchanged by the ISO-only registry and whenever delays
1867
+ or finite-domain constraints require their normal wake-up points.
1859
1868
 
1860
1869
  ### Implementation boundary
1861
1870