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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.8",
6
+ "version": "1.2.10",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
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 freshHead = freshTerm(clause.head, id);
105
- const freshBody = clause.body.map((term) => freshTerm(term, id));
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
- return variable(name);
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
- this.solutionLimit = options.solutionLimit ?? 10000000;
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 freshHead = freshTerm(clause.head, id);
451
- const freshBody = clause.body.map((term) => freshTerm(term, id));
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 freshHead = freshTerm(clause.head, id);
602
- const freshBody = clause.body.map((term) => freshTerm(term, id));
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
- if (!variableOrders.has(term.name)) variableOrders.set(term.name, ++variableOrder);
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) return variable(`${term.name}#${suffix}`);
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
- return leftOrder < rightOrder ? -1 : leftOrder > rightOrder ? 1 : 0;
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;
@@ -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: () => {
@@ -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. Double-quoted Prolog source follows the
5609
- `double_quotes` flag and never creates an extra host-only scalar category.
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