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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.9",
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
@@ -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 freshHead = freshTerm(clause.head, id);
454
- 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));
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 freshHead = freshTerm(clause.head, id);
605
- 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));
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
- 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;
@@ -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: () => {
@@ -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. Double-quoted Prolog source follows the
5615
- `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.
5616
5627
 
5617
5628
  ### Term construction and inspection
5618
5629