eyeprolog 1.2.8 → 1.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/explain.js +3 -2
- package/src/parser.js +7 -1
- package/src/solver.js +26 -6
- package/src/term.js +22 -9
- package/test/run-regression.mjs +62 -0
- package/the-art-of-eyeprolog.md +20 -3
package/package.json
CHANGED
package/src/explain.js
CHANGED
|
@@ -101,8 +101,9 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active) {
|
|
|
101
101
|
for (let candidateIndex = 0; candidateIndex < clauseCandidateLength(pass); candidateIndex++) {
|
|
102
102
|
const clause = clauseCandidateAt(pass, candidateIndex);
|
|
103
103
|
const id = nextFreshId();
|
|
104
|
-
const
|
|
105
|
-
const
|
|
104
|
+
const freshVariables = new Map();
|
|
105
|
+
const freshHead = freshTerm(clause.head, id, freshVariables);
|
|
106
|
+
const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
|
|
106
107
|
const next = env.clone();
|
|
107
108
|
if (!unify(goal, freshHead, next)) continue;
|
|
108
109
|
|
package/src/parser.js
CHANGED
|
@@ -165,6 +165,7 @@ class Parser {
|
|
|
165
165
|
this.pos = 0;
|
|
166
166
|
this.line = 1;
|
|
167
167
|
this.anonymous = 0;
|
|
168
|
+
this.variables = new Map();
|
|
168
169
|
this.sourceMetadata = options.sourceMetadata !== false;
|
|
169
170
|
this.strictIso = options.isoStrict === true;
|
|
170
171
|
this.parserFlagState = options.parserFlagState ?? {
|
|
@@ -633,7 +634,12 @@ class Parser {
|
|
|
633
634
|
const name = this.token.text;
|
|
634
635
|
this.advance();
|
|
635
636
|
if (name === '_') return variable(`__anon${this.anonymous++}`);
|
|
636
|
-
|
|
637
|
+
let term = this.variables.get(name);
|
|
638
|
+
if (term == null) {
|
|
639
|
+
term = variable(name);
|
|
640
|
+
this.variables.set(name, term);
|
|
641
|
+
}
|
|
642
|
+
return term;
|
|
637
643
|
}
|
|
638
644
|
if (this.token.type === TOK.STRING) {
|
|
639
645
|
const value = this.token.text;
|
package/src/solver.js
CHANGED
|
@@ -47,7 +47,10 @@ export class Solver {
|
|
|
47
47
|
this.maxInferences = options.maxInferences ?? Infinity;
|
|
48
48
|
this.inferences = 0;
|
|
49
49
|
this.inferenceLimitExceeded = false;
|
|
50
|
-
|
|
50
|
+
// Do not impose an implicit answer cap. Infinite and very large searches are
|
|
51
|
+
// part of normal Prolog semantics; callers that need a resource bound can
|
|
52
|
+
// still supply solutionLimit explicitly.
|
|
53
|
+
this.solutionLimit = options.solutionLimit ?? Infinity;
|
|
51
54
|
this.solutionsSeen = 0;
|
|
52
55
|
this.prologFlags = options.prologFlags ?? defaultPrologFlags('error', this.isoStrict);
|
|
53
56
|
if (this.isoStrict) {
|
|
@@ -395,6 +398,8 @@ export class Solver {
|
|
|
395
398
|
break;
|
|
396
399
|
}
|
|
397
400
|
}
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw normalizeHostResourceError(error);
|
|
398
403
|
} finally {
|
|
399
404
|
const stackIndex = this.solveStacks.indexOf(registeredStack);
|
|
400
405
|
if (stackIndex >= 0) this.solveStacks.splice(stackIndex, 1);
|
|
@@ -447,8 +452,9 @@ export class Solver {
|
|
|
447
452
|
}
|
|
448
453
|
if (headCannotMatch(goal, clause.head, env)) continue;
|
|
449
454
|
const id = nextFreshId();
|
|
450
|
-
const
|
|
451
|
-
const
|
|
455
|
+
const freshVariables = new Map();
|
|
456
|
+
const freshHead = freshTerm(clause.head, id, freshVariables);
|
|
457
|
+
const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
|
|
452
458
|
const next = env.clone();
|
|
453
459
|
this.stats.unify_calls++;
|
|
454
460
|
if (!unify(goal, freshHead, next)) continue;
|
|
@@ -479,6 +485,19 @@ export class Solver {
|
|
|
479
485
|
|
|
480
486
|
}
|
|
481
487
|
|
|
488
|
+
function normalizeHostResourceError(error) {
|
|
489
|
+
if (error?.name !== 'RangeError') return error;
|
|
490
|
+
const message = String(error?.message ?? '');
|
|
491
|
+
// V8 reports exhausted Map/Set capacity as a host RangeError. ISO 7.12.2 h
|
|
492
|
+
// requires processor resource exhaustion to surface as resource_error/1,
|
|
493
|
+
// with the resource atom implementation dependent. EyeProlog uses the
|
|
494
|
+
// finite_memory spelling already accepted by its ISO conformance corpus.
|
|
495
|
+
if (/^(?:Map|Set) maximum size exceeded$/.test(message)) {
|
|
496
|
+
return new PrologError('resource_error(finite_memory)');
|
|
497
|
+
}
|
|
498
|
+
return error;
|
|
499
|
+
}
|
|
500
|
+
|
|
482
501
|
function qualifyMetaArguments(goal, group) {
|
|
483
502
|
const callerModule = goal.module ?? 'user';
|
|
484
503
|
for (const index of group.metaArgumentPositions ?? []) {
|
|
@@ -598,8 +617,9 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
|
|
|
598
617
|
}
|
|
599
618
|
if (headCannotMatch(goal, clause.head, env)) continue;
|
|
600
619
|
const id = nextFreshId();
|
|
601
|
-
const
|
|
602
|
-
const
|
|
620
|
+
const freshVariables = new Map();
|
|
621
|
+
const freshHead = freshTerm(clause.head, id, freshVariables);
|
|
622
|
+
const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
|
|
603
623
|
const next = env.clone();
|
|
604
624
|
solver.stats.unify_calls++;
|
|
605
625
|
if (!unify(goal, freshHead, next)) continue;
|
|
@@ -1230,7 +1250,7 @@ function copyResolvedWithKey(term, env, variables) {
|
|
|
1230
1250
|
id = variables.size;
|
|
1231
1251
|
variables.set(value.name, id);
|
|
1232
1252
|
}
|
|
1233
|
-
return { term: termModuleCache.variable(value.name), key: `var:${id}` };
|
|
1253
|
+
return { term: termModuleCache.variable(value.name, value.order), key: `var:${id}` };
|
|
1234
1254
|
}
|
|
1235
1255
|
if (!value.args?.length) {
|
|
1236
1256
|
return {
|
package/src/term.js
CHANGED
|
@@ -6,8 +6,10 @@ export const STRING = 'string';
|
|
|
6
6
|
export const NUMBER = 'number';
|
|
7
7
|
export const COMPOUND = 'compound';
|
|
8
8
|
const EMPTY_ARGS = Object.freeze([]);
|
|
9
|
+
// Variable term order is carried by the variable objects themselves. Do not
|
|
10
|
+
// retain a process-global name registry: runtime clause freshening can create
|
|
11
|
+
// an unbounded sequence of names in an otherwise constant-space computation.
|
|
9
12
|
let variableOrder = 0;
|
|
10
|
-
const variableOrders = new Map();
|
|
11
13
|
|
|
12
14
|
export class Term {
|
|
13
15
|
constructor(type, name, args = []) {
|
|
@@ -20,10 +22,9 @@ export class Term {
|
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
export const variable = (name) => {
|
|
25
|
+
export const variable = (name, order = null) => {
|
|
24
26
|
const term = new Term(VAR, name, EMPTY_ARGS);
|
|
25
|
-
|
|
26
|
-
term.order = variableOrders.get(term.name);
|
|
27
|
+
term.order = order ?? ++variableOrder;
|
|
27
28
|
return term;
|
|
28
29
|
};
|
|
29
30
|
export const atom = (name) => new Term(ATOM, name, EMPTY_ARGS);
|
|
@@ -270,6 +271,7 @@ export function unify(left, right, env, options = {}) {
|
|
|
270
271
|
}
|
|
271
272
|
|
|
272
273
|
export function cloneTerm(term) {
|
|
274
|
+
if (term.type === VAR) return variable(term.name, term.order);
|
|
273
275
|
const cloned = term.type === COMPOUND && term.arity === 0
|
|
274
276
|
? atom(term.name)
|
|
275
277
|
: new Term(term.type, term.name, term.args.map(cloneTerm));
|
|
@@ -277,18 +279,25 @@ export function cloneTerm(term) {
|
|
|
277
279
|
return cloned;
|
|
278
280
|
}
|
|
279
281
|
|
|
280
|
-
export function freshTerm(term, suffix) {
|
|
281
|
-
if (term.type === VAR)
|
|
282
|
+
export function freshTerm(term, suffix, variables = new Map()) {
|
|
283
|
+
if (term.type === VAR) {
|
|
284
|
+
let fresh = variables.get(term.name);
|
|
285
|
+
if (fresh == null) {
|
|
286
|
+
fresh = variable(`${term.name}#${suffix}`);
|
|
287
|
+
variables.set(term.name, fresh);
|
|
288
|
+
}
|
|
289
|
+
return fresh;
|
|
290
|
+
}
|
|
282
291
|
const fresh = term.type === COMPOUND && term.arity === 0
|
|
283
292
|
? atom(term.name)
|
|
284
|
-
: new Term(term.type, term.name, term.args.map((arg) => freshTerm(arg, suffix)));
|
|
293
|
+
: new Term(term.type, term.name, term.args.map((arg) => freshTerm(arg, suffix, variables)));
|
|
285
294
|
if (term.module != null) fresh.module = term.module;
|
|
286
295
|
return fresh;
|
|
287
296
|
}
|
|
288
297
|
|
|
289
298
|
export function copyResolved(term, env) {
|
|
290
299
|
const resolved = deref(term, env);
|
|
291
|
-
if (resolved.type === VAR) return variable(resolved.name);
|
|
300
|
+
if (resolved.type === VAR) return variable(resolved.name, resolved.order);
|
|
292
301
|
const copied = resolved.type === COMPOUND && resolved.arity === 0
|
|
293
302
|
? atom(resolved.name)
|
|
294
303
|
: new Term(resolved.type, resolved.name, resolved.args.map((arg) => copyResolved(arg, env)));
|
|
@@ -517,9 +526,13 @@ export function compareTerms(left, right) {
|
|
|
517
526
|
return compareNumberText(left.name, right.name);
|
|
518
527
|
}
|
|
519
528
|
if (left.type === VAR) {
|
|
529
|
+
if (left.name === right.name) return 0;
|
|
520
530
|
const leftOrder = left.order ?? 0;
|
|
521
531
|
const rightOrder = right.order ?? 0;
|
|
522
|
-
|
|
532
|
+
if (leftOrder !== rightOrder) return leftOrder < rightOrder ? -1 : 1;
|
|
533
|
+
// Explicitly copied order values are permitted internally; keep the
|
|
534
|
+
// implementation-defined variable order total even in that case.
|
|
535
|
+
return left.name < right.name ? -1 : 1;
|
|
523
536
|
}
|
|
524
537
|
if (left.type === ATOM || left.type === STRING) return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
|
|
525
538
|
if (left.arity !== right.arity) return left.arity < right.arity ? -1 : 1;
|
package/test/run-regression.mjs
CHANGED
|
@@ -2063,6 +2063,68 @@ open(X) :- candidate(X), \\+ closed(X).
|
|
|
2063
2063
|
assertEqual(answers.join('\n'), 'p(a)\np(b)', 'answers');
|
|
2064
2064
|
},
|
|
2065
2065
|
},
|
|
2066
|
+
{
|
|
2067
|
+
name: 'solver has no implicit solution limit',
|
|
2068
|
+
run: () => {
|
|
2069
|
+
const program = Program.parse('p(a).\n');
|
|
2070
|
+
const solver = new Solver(program);
|
|
2071
|
+
assertEqual(String(solver.solutionLimit), 'Infinity', 'default solution limit');
|
|
2072
|
+
// Crossing the former 10,000,000-answer ceiling must not make an
|
|
2073
|
+
// otherwise available answer disappear. This exercises the boundary
|
|
2074
|
+
// without making the regression suite enumerate ten million answers.
|
|
2075
|
+
solver.solutionsSeen = 10_000_000;
|
|
2076
|
+
const goal = parseGoalText('p(X)');
|
|
2077
|
+
const answers = [...solver.solve([goal], new Env(), 0)].map((env) => termToString(goal, env, true));
|
|
2078
|
+
assertEqual(answers.join('\n'), 'p(a)', 'answer beyond former default ceiling');
|
|
2079
|
+
},
|
|
2080
|
+
},
|
|
2081
|
+
{
|
|
2082
|
+
name: 'fresh-variable generation stays bounded under a small host heap',
|
|
2083
|
+
run: () => {
|
|
2084
|
+
const engineUrl = new URL('../src/index.js', import.meta.url).href;
|
|
2085
|
+
const programText = 'p(X) :- repeat, q(X).\nq(_).\n';
|
|
2086
|
+
const script = `
|
|
2087
|
+
import { Program, Solver, Env, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
|
|
2088
|
+
const program = Program.parse(${JSON.stringify(programText)});
|
|
2089
|
+
const solver = new Solver(program, { registry: getEyePrologRegistry() });
|
|
2090
|
+
const goal = parseGoalText('p(X)');
|
|
2091
|
+
let count = 0;
|
|
2092
|
+
for (const _ of solver.solve([goal], new Env(), 0)) {
|
|
2093
|
+
if (++count === 300000) break;
|
|
2094
|
+
}
|
|
2095
|
+
if (count !== 300000) throw new Error('unexpected answer count: ' + count);
|
|
2096
|
+
process.stdout.write(String(count));
|
|
2097
|
+
`;
|
|
2098
|
+
const result = spawnSync(process.execPath, [
|
|
2099
|
+
'--max-old-space-size=32',
|
|
2100
|
+
'--input-type=module',
|
|
2101
|
+
'--eval',
|
|
2102
|
+
script,
|
|
2103
|
+
], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
|
|
2104
|
+
if (result.error) throw result.error;
|
|
2105
|
+
assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
|
|
2106
|
+
assertEqual(result.stdout, '300000', 'fresh-variable answer count');
|
|
2107
|
+
},
|
|
2108
|
+
},
|
|
2109
|
+
{
|
|
2110
|
+
name: 'host Map capacity errors become ISO resource errors',
|
|
2111
|
+
run: () => {
|
|
2112
|
+
const registry = new BuiltinRegistry();
|
|
2113
|
+
registry.add('exhaust_map', 0, function* () {
|
|
2114
|
+
throw new RangeError('Map maximum size exceeded');
|
|
2115
|
+
});
|
|
2116
|
+
const solver = new Solver(Program.parse(''), { registry });
|
|
2117
|
+
const goal = parseGoalText('exhaust_map');
|
|
2118
|
+
let caught = null;
|
|
2119
|
+
try {
|
|
2120
|
+
[...solver.solve([goal], new Env(), 0)];
|
|
2121
|
+
} catch (error) {
|
|
2122
|
+
caught = error;
|
|
2123
|
+
}
|
|
2124
|
+
assertEqual(caught?.name, 'PrologError', 'normalized error type');
|
|
2125
|
+
assertEqual(caught?.formal, 'resource_error(finite_memory)', 'normalized resource error');
|
|
2126
|
+
},
|
|
2127
|
+
},
|
|
2066
2128
|
{
|
|
2067
2129
|
name: 'solver honors solution limits',
|
|
2068
2130
|
run: () => {
|
package/the-art-of-eyeprolog.md
CHANGED
|
@@ -1805,7 +1805,16 @@ const solver = new Solver(program, {
|
|
|
1805
1805
|
```
|
|
1806
1806
|
|
|
1807
1807
|
The limits are safety ceilings, not logical declarations. Reaching one may
|
|
1808
|
-
truncate search; it does not prove that no further answer exists.
|
|
1808
|
+
truncate search; it does not prove that no further answer exists. At the `Solver`
|
|
1809
|
+
API boundary, `solutionLimit` is opt-in: if it is omitted, ordinary solving and
|
|
1810
|
+
child searches that inherit the solver limit do not stop after a fixed number of
|
|
1811
|
+
solutions. This matters for re-executable goals such as `repeat/0` and for
|
|
1812
|
+
library relations such as `call_nth/2`; an implementation safety threshold must
|
|
1813
|
+
not turn a still re-executable search into logical failure. Embedders that need
|
|
1814
|
+
a finite answer budget should pass `solutionLimit` explicitly. Host capacity
|
|
1815
|
+
failures that V8 reports as `Map maximum size exceeded` or `Set maximum size
|
|
1816
|
+
exceeded` are normalized at the solver boundary to the ISO error
|
|
1817
|
+
`resource_error(finite_memory)` instead of leaking a JavaScript `RangeError`.
|
|
1809
1818
|
|
|
1810
1819
|
### Implementation boundary
|
|
1811
1820
|
|
|
@@ -5605,8 +5614,16 @@ nonterminals.
|
|
|
5605
5614
|
For ISO terms, the standard term order is variables, numbers, atoms, then compounds;
|
|
5606
5615
|
compound terms compare by arity, functor, and arguments. Within the numeric
|
|
5607
5616
|
category, floats precede integers; floats compare by finite numeric value and
|
|
5608
|
-
integers compare exactly.
|
|
5609
|
-
|
|
5617
|
+
integers compare exactly. ISO leaves the ordering of two distinct variables
|
|
5618
|
+
implementation dependent, subject to stability while a sorted list is being
|
|
5619
|
+
created. EyeProlog assigns a creation ordinal to each logical variable and
|
|
5620
|
+
carries that ordinal on the variable term itself; repeated occurrences share it
|
|
5621
|
+
within parsing or clause-freshening scope. It therefore does not keep a
|
|
5622
|
+
process-global table of every fresh variable name ever created, so long-running
|
|
5623
|
+
generators can create and discard fresh variables without growing an unrelated
|
|
5624
|
+
host `Map`. Double-quoted Prolog source
|
|
5625
|
+
follows the `double_quotes` flag and never creates an extra host-only scalar
|
|
5626
|
+
category.
|
|
5610
5627
|
|
|
5611
5628
|
### Term construction and inspection
|
|
5612
5629
|
|