eyeprolog 1.2.9 → 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 +22 -5
- package/src/term.js +22 -9
- package/test/run-regression.mjs +47 -0
- package/the-art-of-eyeprolog.md +14 -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
|
@@ -398,6 +398,8 @@ export class Solver {
|
|
|
398
398
|
break;
|
|
399
399
|
}
|
|
400
400
|
}
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw normalizeHostResourceError(error);
|
|
401
403
|
} finally {
|
|
402
404
|
const stackIndex = this.solveStacks.indexOf(registeredStack);
|
|
403
405
|
if (stackIndex >= 0) this.solveStacks.splice(stackIndex, 1);
|
|
@@ -450,8 +452,9 @@ export class Solver {
|
|
|
450
452
|
}
|
|
451
453
|
if (headCannotMatch(goal, clause.head, env)) continue;
|
|
452
454
|
const id = nextFreshId();
|
|
453
|
-
const
|
|
454
|
-
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));
|
|
455
458
|
const next = env.clone();
|
|
456
459
|
this.stats.unify_calls++;
|
|
457
460
|
if (!unify(goal, freshHead, next)) continue;
|
|
@@ -482,6 +485,19 @@ export class Solver {
|
|
|
482
485
|
|
|
483
486
|
}
|
|
484
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
|
+
|
|
485
501
|
function qualifyMetaArguments(goal, group) {
|
|
486
502
|
const callerModule = goal.module ?? 'user';
|
|
487
503
|
for (const index of group.metaArgumentPositions ?? []) {
|
|
@@ -601,8 +617,9 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
|
|
|
601
617
|
}
|
|
602
618
|
if (headCannotMatch(goal, clause.head, env)) continue;
|
|
603
619
|
const id = nextFreshId();
|
|
604
|
-
const
|
|
605
|
-
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));
|
|
606
623
|
const next = env.clone();
|
|
607
624
|
solver.stats.unify_calls++;
|
|
608
625
|
if (!unify(goal, freshHead, next)) continue;
|
|
@@ -1233,7 +1250,7 @@ function copyResolvedWithKey(term, env, variables) {
|
|
|
1233
1250
|
id = variables.size;
|
|
1234
1251
|
variables.set(value.name, id);
|
|
1235
1252
|
}
|
|
1236
|
-
return { term: termModuleCache.variable(value.name), key: `var:${id}` };
|
|
1253
|
+
return { term: termModuleCache.variable(value.name, value.order), key: `var:${id}` };
|
|
1237
1254
|
}
|
|
1238
1255
|
if (!value.args?.length) {
|
|
1239
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
|
@@ -2078,6 +2078,53 @@ open(X) :- candidate(X), \\+ closed(X).
|
|
|
2078
2078
|
assertEqual(answers.join('\n'), 'p(a)', 'answer beyond former default ceiling');
|
|
2079
2079
|
},
|
|
2080
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
|
+
},
|
|
2081
2128
|
{
|
|
2082
2129
|
name: 'solver honors solution limits',
|
|
2083
2130
|
run: () => {
|
package/the-art-of-eyeprolog.md
CHANGED
|
@@ -1811,7 +1811,10 @@ child searches that inherit the solver limit do not stop after a fixed number of
|
|
|
1811
1811
|
solutions. This matters for re-executable goals such as `repeat/0` and for
|
|
1812
1812
|
library relations such as `call_nth/2`; an implementation safety threshold must
|
|
1813
1813
|
not turn a still re-executable search into logical failure. Embedders that need
|
|
1814
|
-
a finite answer budget should pass `solutionLimit` explicitly.
|
|
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`.
|
|
1815
1818
|
|
|
1816
1819
|
### Implementation boundary
|
|
1817
1820
|
|
|
@@ -5611,8 +5614,16 @@ nonterminals.
|
|
|
5611
5614
|
For ISO terms, the standard term order is variables, numbers, atoms, then compounds;
|
|
5612
5615
|
compound terms compare by arity, functor, and arguments. Within the numeric
|
|
5613
5616
|
category, floats precede integers; floats compare by finite numeric value and
|
|
5614
|
-
integers compare exactly.
|
|
5615
|
-
|
|
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.
|
|
5616
5627
|
|
|
5617
5628
|
### Term construction and inspection
|
|
5618
5629
|
|