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 +16 -0
- package/package.json +1 -1
- package/src/iso.js +15 -11
- package/src/platform.js +9 -4
- package/src/program.js +24 -0
- package/src/solver.js +156 -55
- package/src/term.js +104 -16
- package/test/run-regression.mjs +94 -4
- package/the-art-of-eyeprolog.md +13 -4
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
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
|
|
2046
|
+
yield* solveControlBranch(solver, left.args[1], conditionEnv);
|
|
2037
2047
|
return;
|
|
2038
2048
|
}
|
|
2039
|
-
yield* solver
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
return
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (
|
|
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
|
}
|
package/test/run-regression.mjs
CHANGED
|
@@ -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: '
|
|
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,
|
|
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,
|
|
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', '
|
|
2915
|
+
assertEqual(result.stdout, 'recovered', 'discarded recursive terms are collectible after unwinding');
|
|
2826
2916
|
},
|
|
2827
2917
|
},
|
|
2828
2918
|
{
|
package/the-art-of-eyeprolog.md
CHANGED
|
@@ -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.
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
|