eyeprolog 1.2.9 → 1.2.11

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.
@@ -11,7 +11,7 @@ This report summarizes the file-based conformance corpus under `test/conformance
11
11
  | builtins | 11 | 0 | 0 | 0 | 11 |
12
12
  | context | 11 | 0 | 0 | 0 | 11 |
13
13
  | control | 15 | 0 | 0 | 0 | 15 |
14
- | iso | 167 | 216 | 0 | 0 | 383 |
14
+ | iso | 167 | 218 | 0 | 0 | 385 |
15
15
  | lists | 52 | 3 | 0 | 0 | 55 |
16
16
  | modules | 2 | 0 | 0 | 0 | 2 |
17
17
  | negation | 8 | 0 | 19 | 0 | 27 |
@@ -23,4 +23,4 @@ This report summarizes the file-based conformance corpus under `test/conformance
23
23
  | terms | 26 | 3 | 0 | 0 | 29 |
24
24
  | unification | 18 | 0 | 0 | 0 | 18 |
25
25
  | variables | 16 | 9 | 0 | 0 | 25 |
26
- | **Total** | **482** | **267** | **19** | **21** | **789** |
26
+ | **Total** | **482** | **269** | **19** | **21** | **791** |
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.11",
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/iso.js CHANGED
@@ -1564,6 +1564,13 @@ function parseIsoNumber(text) {
1564
1564
  }
1565
1565
 
1566
1566
  const numericText = `${sign}${text.slice(position)}`;
1567
+ // 8.16.7/8.16.8 parse the character sequence according to the syntax rules
1568
+ // for numbers and negative numbers (6.3.1.1/6.3.1.2), not as an arbitrary
1569
+ // term whose value happens to be numeric. Every such number starts with a
1570
+ // decimal digit after an optional negative sign, so parenthesized terms such
1571
+ // as `(0)` or `-(0)` must be rejected before the general term parser sees
1572
+ // them. This keeps the parser reuse below from admitting grouping syntax.
1573
+ if (!/^-?\d/.test(numericText)) return null;
1567
1574
  // ISO floating-point syntax requires a decimal fraction before an exponent.
1568
1575
  if (/^-?\d+[eE][+-]?\d+$/.test(numericText)) return null;
1569
1576
  try {
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;
@@ -16,7 +16,7 @@ compliance audit and the remaining work before a full conformance claim.
16
16
  | 8.8-8.10 database and solutions | logical update view, dynamic mutation, all-solutions grouping | `dynamic_database`, `trealla_logical_update_view`, `corrigenda_retractall`, `grouped_solutions_and_clauses` |
17
17
  | 8.11-8.14 streams and term I/O | text/binary streams, properties, units, read/write options and operators | `streams_and_term_io`, `operators`, Corrigendum 3 option cases, stream error cases |
18
18
  | 8.15 logic and control | negation, once, repeat, `call/2` through `call/8`, `false/0` | `logtalk_once`, `corrigenda_call_closure`, `false_builtin` |
19
- | 8.16 atomic processing | atoms, characters, codes and number conversion with prescribed errors | `atomic_term_processing`, focused forward/reverse cases, Logtalk-derived error cases |
19
+ | 8.16 atomic processing | atoms, characters, codes and number conversion with prescribed errors | `atomic_term_processing`, focused forward/reverse cases, parenthesized-number rejection, Logtalk-derived error cases |
20
20
  | 8.17 flags and hooks | required flags, mutation permissions, halt and character conversion | `exceptions_and_flags`, `remaining_builtins_and_directives`, flag error cases |
21
21
  | Clause 9 evaluable functors | integer, float, rounding, transcendental and bitwise operations | `arithmetic`, `corrigenda_arithmetic`, `corrigenda_atan2_zero`, `corrigenda_integer_negative_power` |
22
22
  | ISO/IEC 13211-2 modules | module declarations, exports, imports, qualification, meta-predicate context | `modules/qualified_call`, `modules/selective_library_import`, `dcg_module_nonterminal_indicator` |
@@ -102,7 +102,7 @@ Selected cases are adapted from the ISO and standard-core suites of Logtalk,
102
102
  Scryer Prolog, Trealla Prolog, and SWI-Prolog. Their upstream identifiers and licenses
103
103
  are recorded in [THIRD_PARTY.md](THIRD_PARTY.md).
104
104
 
105
- The corpus has 383 cases in `iso/` and 789 file-based conformance cases in
105
+ The corpus has 385 cases in `iso/` and 791 file-based conformance cases in
106
106
  total. The generated `conformance-report.md` is the authoritative source for
107
107
  current category totals. Together with regression, documentation-sync, API,
108
108
  example, and book-example checks, `npm test` is the release gate.
@@ -0,0 +1,2 @@
1
+ %% goal: number_chars(_, ['(', '0', ')'])
2
+ answer :- number_chars(_, ['(', '0', ')']).
@@ -0,0 +1,2 @@
1
+ %% goal: number_codes(_, [40, 48, 41])
2
+ answer :- number_codes(_, [40, 48, 41]).
@@ -0,0 +1 @@
1
+ error(syntax_error(number))
@@ -0,0 +1 @@
1
+ error(syntax_error(number))
@@ -36,6 +36,9 @@
36
36
  70 ?- number_chars(N,"0x11").
37
37
  N=17.
38
38
 
39
+ 73 ?- number_chars(N,"(0)").
40
+ syntax_error(...).
41
+
39
42
  4 ?- number_chars(1,"a").
40
43
  syntax_error(...).
41
44
 
@@ -396,9 +396,24 @@ c4 ?- call((!;1)).
396
396
  text: source,
397
397
  filename,
398
398
  }]));
399
- assertEqual(result.total, 72, 'quad total');
400
- assertEqual(result.passed, 72, 'quad passed');
401
- assertEqual(result.stdout, 'quads: 72 run, 72 passed, 0 failed.\n', 'quad report');
399
+ assertEqual(result.total, 73, 'quad total');
400
+ assertEqual(result.passed, 73, 'quad passed');
401
+ assertEqual(result.stdout, 'quads: 73 run, 73 passed, 0 failed.\n', 'quad report');
402
+ },
403
+ },
404
+ {
405
+ name: 'number conversion rejects parenthesized numeric terms',
406
+ run: () => {
407
+ for (const goal of ['number_chars(N,"(0)")', 'number_codes(N,[40,48,41])']) {
408
+ let caught = null;
409
+ try {
410
+ publicApi.run('', { goal });
411
+ } catch (error) {
412
+ caught = error;
413
+ }
414
+ if (caught == null) throw new Error(`${goal} should throw`);
415
+ assertIncludes(String(caught?.message ?? caught), 'syntax_error(number)', goal);
416
+ }
402
417
  },
403
418
  },
404
419
  {
@@ -2078,6 +2093,53 @@ open(X) :- candidate(X), \\+ closed(X).
2078
2093
  assertEqual(answers.join('\n'), 'p(a)', 'answer beyond former default ceiling');
2079
2094
  },
2080
2095
  },
2096
+ {
2097
+ name: 'fresh-variable generation stays bounded under a small host heap',
2098
+ run: () => {
2099
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
2100
+ const programText = 'p(X) :- repeat, q(X).\nq(_).\n';
2101
+ const script = `
2102
+ import { Program, Solver, Env, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
2103
+ const program = Program.parse(${JSON.stringify(programText)});
2104
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
2105
+ const goal = parseGoalText('p(X)');
2106
+ let count = 0;
2107
+ for (const _ of solver.solve([goal], new Env(), 0)) {
2108
+ if (++count === 300000) break;
2109
+ }
2110
+ if (count !== 300000) throw new Error('unexpected answer count: ' + count);
2111
+ process.stdout.write(String(count));
2112
+ `;
2113
+ const result = spawnSync(process.execPath, [
2114
+ '--max-old-space-size=32',
2115
+ '--input-type=module',
2116
+ '--eval',
2117
+ script,
2118
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2119
+ if (result.error) throw result.error;
2120
+ assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2121
+ assertEqual(result.stdout, '300000', 'fresh-variable answer count');
2122
+ },
2123
+ },
2124
+ {
2125
+ name: 'host Map capacity errors become ISO resource errors',
2126
+ run: () => {
2127
+ const registry = new BuiltinRegistry();
2128
+ registry.add('exhaust_map', 0, function* () {
2129
+ throw new RangeError('Map maximum size exceeded');
2130
+ });
2131
+ const solver = new Solver(Program.parse(''), { registry });
2132
+ const goal = parseGoalText('exhaust_map');
2133
+ let caught = null;
2134
+ try {
2135
+ [...solver.solve([goal], new Env(), 0)];
2136
+ } catch (error) {
2137
+ caught = error;
2138
+ }
2139
+ assertEqual(caught?.name, 'PrologError', 'normalized error type');
2140
+ assertEqual(caught?.formal, 'resource_error(finite_memory)', 'normalized resource error');
2141
+ },
2142
+ },
2081
2143
  {
2082
2144
  name: 'solver honors solution limits',
2083
2145
  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
 
@@ -5724,13 +5735,16 @@ quoted_atom("ab"). % quoted_atom(ab)
5724
5735
  | `sub_atom(+Atom,?Before,?Length,?After,?SubAtom)` | Enumerates substrings and their Unicode-code-point offsets. Supplied counts must be nonnegative integers. |
5725
5736
  | `atom_chars(?Atom,?Chars)`, `atom_codes(?Atom,?Codes)` | Convert between an atom and a proper list of one-character atoms or Unicode scalar codes. At least one side must be instantiated. |
5726
5737
  | `char_code(?Character,?Code)` | Converts one character atom and one Unicode scalar code. Surrogates and values outside `0..0x10ffff` raise a representation error. |
5727
- | `number_chars(?Number,?Chars)`, `number_codes(?Number,?Codes)` | Convert finite numbers to canonical text or parse a proper character/code list using ISO numeric-token syntax, including radix integers, character-code constants, and leading layout. At least one side must be instantiated; malformed numeric input raises *syntax_error(number)*. |
5738
+ | `number_chars(?Number,?Chars)`, `number_codes(?Number,?Codes)` | Convert finite numbers to canonical text or parse a proper character/code list using ISO number and negative-number syntax, including radix integers, character-code constants, and leading layout. The input is not parsed as a general term: grouping such as `(0)` is a syntax error. At least one side must be instantiated; malformed numeric input raises *syntax_error(number)*. |
5728
5739
 
5729
5740
  Conversions accept partial output lists when the atomic input is known, but
5730
5741
  constructing an atom or number requires a complete proper list with no unbound
5731
5742
  elements. Numeric parsing accepts leading ISO layout characters, an optional
5732
5743
  sign, decimal fractions, and decimal exponents; it rejects trailing material
5733
- and non-finite values.
5744
+ and non-finite values. The regression gate vendors all 73 numbered cases from
5745
+ Ulrich Neumerkel's contemporary `number_chars/2` comparison, including the
5746
+ Cor.2 error-precedence cases; `number_codes/2` shares the same numeric parser
5747
+ and has mirrored coverage for the parenthesized-number regression.
5734
5748
 
5735
5749
  ### Streams and unit I/O
5736
5750
 
@@ -6975,7 +6989,7 @@ precedence still need one-by-one closure. `test/conformance/ISO-MATRIX.md`
6975
6989
  maps language families to representative executable cases.
6976
6990
 
6977
6991
  The complete suite must pass before release. The file-based conformance corpus
6978
- contains 789 cases, including 383 focused ISO
6992
+ contains 791 cases, including 385 focused ISO
6979
6993
  cases derived from the success, failure, mode, and error behavior in
6980
6994
  ISO/IEC 13211-1 clauses 7 and 8, Part 2 modules, and Part 3 grammar rules.
6981
6995
  Separate exact-output suites check 189 normal