eyeprolog 1.2.17 → 1.2.19

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 | 218 | 0 | 0 | 385 |
14
+ | iso | 168 | 218 | 0 | 0 | 386 |
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** | **269** | **19** | **21** | **791** |
26
+ | **Total** | **483** | **269** | **19** | **21** | **792** |
package/index.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface EyePrologRunOptions {
12
12
  explain?: boolean;
13
13
  maxDepth?: number;
14
14
  maxInferences?: number;
15
+ /** Soft JavaScript heap ceiling in bytes; exhaustion raises resource_error(memory). */
16
+ maxMemoryBytes?: number;
15
17
  solutionLimit?: number;
16
18
  registry?: BuiltinRegistry;
17
19
  sourceMetadata?: boolean;
@@ -171,6 +173,7 @@ export class Solver {
171
173
  maxInferences: number;
172
174
  inferences: number;
173
175
  inferenceLimitExceeded: boolean;
176
+ maxMemoryBytes: number;
174
177
  solutionLimit: number;
175
178
  solutionsSeen: number;
176
179
  active: unknown[];
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.17",
6
+ "version": "1.2.19",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/iso.js CHANGED
@@ -1556,7 +1556,15 @@ function parseIsoNumber(text) {
1556
1556
  let sign = '';
1557
1557
 
1558
1558
  if (text[position] === '-') {
1559
- if (/[\u0009-\u000d\u0020]/.test(text[position + 1] ?? '')) {
1559
+ const next = text[position + 1] ?? '';
1560
+ // Every token class may carry leading layout (6.4). Thus a negative
1561
+ // number may have layout between the name `-` and its numeric token. A
1562
+ // `%...\n` comment can start immediately after `-` because `%` cannot
1563
+ // continue a graphic name token. In contrast `/*...*/` cannot start
1564
+ // there without separating layout: `/` *can* continue the graphic token,
1565
+ // and the eager-consumer rule therefore keeps `-/**/1` ill-formed (the
1566
+ // number_chars continuation corpus case 24).
1567
+ if (/[\u0009-\u000d\u0020]/.test(next) || next === '%') {
1560
1568
  position = skipNumberLayout(text, position + 1);
1561
1569
  sign = '-';
1562
1570
  }
package/src/platform.js CHANGED
@@ -6,10 +6,12 @@ const isNode = typeof process !== 'undefined' && Boolean(process.versions?.node)
6
6
  let fs = null;
7
7
  let path = null;
8
8
  let BufferCtor = null;
9
+ let v8 = null;
9
10
 
10
11
  if (isNode) {
11
12
  ({ default: fs } = await import('node:fs'));
12
13
  ({ default: path } = await import('node:path'));
14
+ ({ default: v8 } = await import('node:v8'));
13
15
  BufferCtor = globalThis.Buffer ?? null;
14
16
  }
15
17
 
@@ -18,3 +20,43 @@ export { fs, path, BufferCtor, isNode };
18
20
  export function currentWorkingDirectory() {
19
21
  return isNode && typeof process.cwd === 'function' ? process.cwd() : '/';
20
22
  }
23
+
24
+ export function usedHeapSize() {
25
+ if (isNode && typeof process.memoryUsage === 'function') {
26
+ return process.memoryUsage().heapUsed;
27
+ }
28
+ const memory = globalThis.performance?.memory;
29
+ return Number.isFinite(memory?.usedJSHeapSize) ? memory.usedJSHeapSize : null;
30
+ }
31
+
32
+ export function softHeapLimit() {
33
+ let limit = null;
34
+ if (isNode) {
35
+ limit = v8?.getHeapStatistics?.().heap_size_limit ?? null;
36
+ const configuredOldSpace = configuredOldSpaceBytes();
37
+ if (configuredOldSpace != null) limit = Math.min(limit ?? Infinity, configuredOldSpace);
38
+ } else {
39
+ const memory = globalThis.performance?.memory;
40
+ if (Number.isFinite(memory?.jsHeapSizeLimit)) limit = memory.jsHeapSizeLimit;
41
+ }
42
+ // Leave ample room for the generator stack to unwind and for the top level
43
+ // to construct and print resource_error(memory). Fatal V8 OOMs cannot be
44
+ // caught after the heap limit itself has been reached.
45
+ return Number.isFinite(limit) && limit > 0 ? Math.floor(limit * 0.75) : Infinity;
46
+ }
47
+
48
+ function configuredOldSpaceBytes() {
49
+ if (!isNode) return null;
50
+ const argumentsText = [
51
+ ...(process.execArgv ?? []),
52
+ ...(String(process.env?.NODE_OPTIONS ?? '').match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []),
53
+ ];
54
+ for (let index = 0; index < argumentsText.length; index++) {
55
+ const argument = argumentsText[index];
56
+ const match = /^--max[-_]old[-_]space[-_]size(?:=(\d+))?$/.exec(argument);
57
+ if (!match) continue;
58
+ const megabytes = match[1] ?? argumentsText[index + 1];
59
+ if (/^\d+$/.test(megabytes ?? '')) return Number(megabytes) * 1024 * 1024;
60
+ }
61
+ return null;
62
+ }
package/src/solver.js CHANGED
@@ -9,6 +9,7 @@ import { getEyePrologRegistry } from './standard-library.js';
9
9
  import { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program.js';
10
10
  import { StreamManager } from './io.js';
11
11
  import { clpzStateConsistent } from './clpz.js';
12
+ import { softHeapLimit, usedHeapSize } from './platform.js';
12
13
 
13
14
  let freshCounter = 0;
14
15
 
@@ -47,6 +48,8 @@ export class Solver {
47
48
  this.maxInferences = options.maxInferences ?? Infinity;
48
49
  this.inferences = 0;
49
50
  this.inferenceLimitExceeded = false;
51
+ this.maxMemoryBytes = options.maxMemoryBytes ?? softHeapLimit();
52
+ this.nextMemoryCheck = 0;
50
53
  // Do not impose an implicit answer cap. Infinite and very large searches are
51
54
  // part of normal Prolog semantics; callers that need a resource bound can
52
55
  // still supply solutionLimit explicitly.
@@ -115,6 +118,7 @@ export class Solver {
115
118
  registry: this.registry,
116
119
  maxDepth: this.maxDepth,
117
120
  maxInferences: this.maxInferences,
121
+ maxMemoryBytes: this.maxMemoryBytes,
118
122
  solutionLimit,
119
123
  isoStrict: this.isoStrict,
120
124
  prologFlags: this.prologFlags,
@@ -177,6 +181,7 @@ export class Solver {
177
181
  this.solveStacks.push(stack);
178
182
  while (stack.length) {
179
183
  this.inferences++;
184
+ this.checkMemoryLimit();
180
185
  if (this.inferences > this.maxInferences) {
181
186
  this.inferenceLimitExceeded = true;
182
187
  break;
@@ -228,6 +233,7 @@ export class Solver {
228
233
 
229
234
  while (true) {
230
235
  this.inferences++;
236
+ this.checkMemoryLimit();
231
237
  if (this.inferences > this.maxInferences) {
232
238
  this.inferenceLimitExceeded = true;
233
239
  stack.length = 0;
@@ -411,6 +417,16 @@ export class Solver {
411
417
  return activeVariantIn(goal, env, this.active);
412
418
  }
413
419
 
420
+ checkMemoryLimit() {
421
+ if (this.inferences < this.nextMemoryCheck) return;
422
+ this.nextMemoryCheck = this.inferences + 256;
423
+ if (!Number.isFinite(this.maxMemoryBytes)) return;
424
+ const used = usedHeapSize();
425
+ if (used != null && used >= this.maxMemoryBytes) {
426
+ throw new PrologError('resource_error(memory)');
427
+ }
428
+ }
429
+
414
430
  *solveUserGoal(goal, rest, env, depth) {
415
431
  this.stats.solve_one_goal_calls++;
416
432
  if (depth > this.maxDepth) {
@@ -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 385 cases in `iso/` and 791 file-based conformance cases in
105
+ The corpus has 386 cases in `iso/` and 792 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,8 @@
1
+ % ISO 13211-1 8.16.7/8.16.8 with 6.3.1 and token layout from 6.4.
2
+ %% goal: answer(X0, X1, X2, X3)
3
+
4
+ answer(CharZero, CharOne, CodeZero, CodeOne) :-
5
+ number_chars(CharZero, "-%\n0"),
6
+ number_chars(CharOne, "-% comment\n1"),
7
+ number_codes(CodeZero, [45, 37, 10, 48]),
8
+ number_codes(CodeOne, [45, 37, 32, 99, 111, 109, 109, 101, 110, 116, 10, 49]).
@@ -0,0 +1 @@
1
+ answer(0, -1, 0, -1).
@@ -39,6 +39,9 @@
39
39
  73 ?- number_chars(N,"(0)").
40
40
  syntax_error(...).
41
41
 
42
+ 74 ?- number_chars(N,"-%\n0").
43
+ N = 0.
44
+
42
45
  4 ?- number_chars(1,"a").
43
46
  syntax_error(...).
44
47
 
@@ -491,13 +491,51 @@ c4 ?- call((!;1)).
491
491
  run: () => {
492
492
  const filename = path.join(testRoot, 'fixtures', 'number_chars_cont_quad.pl');
493
493
  const source = fs.readFileSync(filename, 'utf8');
494
+ const numbered = new Set([...source.matchAll(/^(\d+)\s+\?-/gm)].map((match) => Number(match[1])));
495
+ assertEqual(numbered.size, 74, 'numbered case total');
496
+ for (let id = 1; id <= 74; id++) {
497
+ if (!numbered.has(id)) throw new Error(`number_chars continuation case #${id} is missing`);
498
+ }
494
499
  const result = publicApi.runQuads(Program.parseSources([{
495
500
  text: source,
496
501
  filename,
497
502
  }]));
498
- assertEqual(result.total, 77, 'answer-description total');
499
- assertEqual(result.passed, 77, 'answer-description passed');
500
- assertEqual(result.stdout, 'quads: 77 run, 77 passed, 0 failed.\n', 'quad report');
503
+ assertEqual(result.total, 78, 'answer-description total');
504
+ assertEqual(result.passed, 78, 'answer-description passed');
505
+ assertEqual(result.stdout, 'quads: 78 run, 78 passed, 0 failed.\n', 'quad report');
506
+ },
507
+ },
508
+ {
509
+ name: 'number conversion accepts line-comment layout after a minus token',
510
+ run: () => {
511
+ const source = String.raw`
512
+ ?- number_chars(N,"-%\n0").
513
+ N = 0.
514
+ ?- number_chars(N,"-% comment\n1").
515
+ N = -1.
516
+ ?- number_codes(N,[45,37,10,48]).
517
+ N = 0.
518
+ ?- number_codes(N,[45,37,32,99,111,109,109,101,110,116,10,49]).
519
+ N = -1.
520
+ `;
521
+ const result = publicApi.runQuads(source);
522
+ assertEqual(result.total, 4, 'quad total');
523
+ assertEqual(result.passed, 4, 'quad passed');
524
+ assertEqual(result.stdout, 'quads: 4 run, 4 passed, 0 failed.\n', 'quad report');
525
+
526
+ // Keep the eager-consumer distinction from continuation case #24:
527
+ // an adjacent bracketed comment can be consumed as part of a graphic
528
+ // token after `-`, so it is not equivalent to the `%` line comment.
529
+ for (const goal of ['number_chars(N,"-/**/1")', 'number_codes(N,[45,47,42,42,47,49])']) {
530
+ let caught = null;
531
+ try {
532
+ publicApi.run('', { goal });
533
+ } catch (error) {
534
+ caught = error;
535
+ }
536
+ if (caught == null) throw new Error(`${goal} should throw`);
537
+ assertIncludes(String(caught?.message ?? caught), 'syntax_error(number)', goal);
538
+ }
501
539
  },
502
540
  },
503
541
  {
@@ -2286,6 +2324,35 @@ open(X) :- candidate(X), \\+ closed(X).
2286
2324
  }
2287
2325
  },
2288
2326
  },
2327
+ {
2328
+ name: 'list allocation heap pressure becomes resource_error(memory)',
2329
+ run: () => {
2330
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
2331
+ const programText = ':- use_module(library(prologue)).\n';
2332
+ const goalText = 'length(_, I), I > 9, N is 2^I, \\+ \\+ length(_, N)';
2333
+ const script = `
2334
+ import { Program, Solver, Env, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
2335
+ const program = Program.parse(${JSON.stringify(programText)});
2336
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
2337
+ const goal = parseGoalText(${JSON.stringify(goalText)}, {
2338
+ operatorDefinitions: [...program.operators.values()],
2339
+ });
2340
+ let caught = null;
2341
+ try { [...solver.solve([goal], new Env(), 0)]; } catch (error) { caught = error; }
2342
+ if (caught?.formal !== 'resource_error(memory)') throw caught ?? new Error('no resource error');
2343
+ process.stdout.write(caught.formal);
2344
+ `;
2345
+ const result = spawnSync(process.execPath, [
2346
+ '--max-old-space-size=64',
2347
+ '--input-type=module',
2348
+ '--eval',
2349
+ script,
2350
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2351
+ if (result.error) throw result.error;
2352
+ assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2353
+ assertEqual(result.stdout, 'resource_error(memory)', 'heap pressure resource error');
2354
+ },
2355
+ },
2289
2356
  {
2290
2357
  name: 'solver honors solution limits',
2291
2358
  run: () => {
@@ -1725,6 +1725,8 @@ const result = run(reasoningSource(record), {
1725
1725
  goal: `thermal_alert(${record.sensor})`,
1726
1726
  proof: true,
1727
1727
  maxDepth: 10_000,
1728
+ maxInferences: 100_000,
1729
+ maxMemoryBytes: 256 * 1024 * 1024,
1728
1730
  solutionLimit: 10
1729
1731
  });
1730
1732
 
@@ -1774,10 +1776,10 @@ prints numeric work counters; those counters describe this run rather than an
1774
1776
  additional logical answer.
1775
1777
 
1776
1778
  `run/2` accepts source text or an already parsed `Program`. Its options include
1777
- `proof` (with `why` and `explain` as aliases), `maxDepth`, `solutionLimit`, a
1778
- custom `registry`, and `strictNegation` or `analyzeNegation`. It returns
1779
- `stdout`, the solver's numeric `stats`, and a nullable `haltCode`; it does not
1780
- write to the process streams.
1779
+ `proof` (with `why` and `explain` as aliases), `maxDepth`, `maxInferences`,
1780
+ `maxMemoryBytes`, `solutionLimit`, a custom `registry`, and `strictNegation` or
1781
+ `analyzeNegation`. It returns `stdout`, the solver's numeric `stats`, and a
1782
+ nullable `haltCode`; it does not write to the process streams.
1781
1783
 
1782
1784
  For applications that inspect or prepare a theory before running it, use
1783
1785
  `Program` directly:
@@ -1800,18 +1802,24 @@ console.log(path?.recursive, path?.tabled, path?.tableInputPositions);
1800
1802
 
1801
1803
  const solver = new Solver(program, {
1802
1804
  maxDepth: 50_000,
1805
+ maxInferences: 1_000_000,
1806
+ maxMemoryBytes: 256 * 1024 * 1024,
1803
1807
  solutionLimit: 100_000
1804
1808
  });
1805
1809
  ```
1806
1810
 
1807
- The limits are safety ceilings, not logical declarations. Reaching one may
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.
1811
+ The limits are safety ceilings, not logical declarations. Reaching the depth,
1812
+ inference, or solution ceiling may truncate search; it does not prove that no
1813
+ further answer exists. Reaching `maxMemoryBytes` instead raises
1814
+ `resource_error(memory)`, because continuing until the JavaScript engine's hard
1815
+ heap limit would let the host abort before Prolog could report an exception. At
1816
+ the `Solver` API boundary, `solutionLimit` is opt-in: if it is omitted, ordinary
1817
+ solving and child searches that inherit the solver limit do not stop after a
1818
+ fixed number of solutions. This matters for re-executable goals such as
1819
+ `repeat/0` and for library relations such as `call_nth/2`; an implementation
1820
+ safety threshold must not turn a still re-executable search into logical
1821
+ failure. Embedders that need a finite answer budget should pass `solutionLimit`
1822
+ explicitly.
1815
1823
 
1816
1824
  Variable term order is deliberately scoped rather than stored as a permanent
1817
1825
  property of a variable. ISO 13211-1 section 7.2.1 leaves the order of two
@@ -1822,10 +1830,15 @@ sorting step of `setof/3` share one ranking for the duration of that single
1822
1830
  sorted-list operation. No process-global variable registry or creation ordinal
1823
1831
  is retained or exposed through later comparisons.
1824
1832
 
1825
- Host capacity failures that V8 reports as `Map maximum size exceeded` or `Set`
1826
- `maximum size exceeded` are normalized at the solver boundary to
1827
- `resource_error(memory)` instead of leaking a JavaScript `RangeError`. ISO
1828
- 13211-1 leaves the resource atom implementation dependent. EyeProlog uses
1833
+ EyeProlog periodically checks detectable JavaScript heap use and keeps a quarter
1834
+ of the host heap ceiling in reserve so the solver can unwind and report
1835
+ `resource_error(memory)` before a fatal host out-of-memory abort. Embedders may
1836
+ replace that automatically derived soft ceiling with `maxMemoryBytes`; setting
1837
+ it to `Infinity` disables the proactive check. Environments that do not expose
1838
+ heap use cannot provide the proactive check. Host capacity failures that V8
1839
+ reports as `Map maximum size exceeded` or `Set maximum size exceeded` are also
1840
+ normalized at the solver boundary instead of leaking a JavaScript `RangeError`.
1841
+ ISO 13211-1 leaves the resource atom implementation dependent. EyeProlog uses
1829
1842
  `memory` for a finite host allocation/capacity ceiling and reserves the
1830
1843
  `finite_memory` spelling for the distinct convention where no finite amount of
1831
1844
  memory could complete the computation.
@@ -5758,12 +5771,16 @@ quoted_atom("ab"). % quoted_atom(ab)
5758
5771
 
5759
5772
  Conversions accept partial output lists when the atomic input is known, but
5760
5773
  constructing an atom or number requires a complete proper list with no unbound
5761
- elements. Numeric parsing accepts leading ISO layout characters, an optional
5762
- sign, decimal fractions, and decimal exponents; it rejects trailing material
5763
- and non-finite values. The regression gate vendors all 73 numbered cases from
5764
- Ulrich Neumerkel's contemporary `number_chars/2` comparison, including the
5765
- Cor.2 error-precedence cases; `number_codes/2` shares the same numeric parser
5766
- and has mirrored coverage for the parenthesized-number regression.
5774
+ elements. Numeric parsing accepts ISO layout before tokens, including layout between a
5775
+ minus token and the following numeric token. A single-line `%...` comment may
5776
+ therefore follow `-` directly because `%` cannot continue a graphic token; an
5777
+ adjacent bracketed comment in `-/**/1` remains a syntax error under the eager
5778
+ token-consumer rule. Decimal fractions and decimal exponents are supported;
5779
+ trailing material and non-finite values are rejected. The regression gate
5780
+ vendors all 74 numbered cases from Ulrich Neumerkel's contemporary
5781
+ `number_chars/2` comparison, including the Cor.2 error-precedence cases;
5782
+ `number_codes/2` shares the same numeric parser and has mirrored coverage for
5783
+ the recent numeric-syntax regressions.
5767
5784
 
5768
5785
  ### Streams and unit I/O
5769
5786
 
@@ -7020,7 +7037,7 @@ precedence still need one-by-one closure. `test/conformance/ISO-MATRIX.md`
7020
7037
  maps language families to representative executable cases.
7021
7038
 
7022
7039
  The complete suite must pass before release. The file-based conformance corpus
7023
- contains 791 cases, including 385 focused ISO
7040
+ contains 792 cases, including 386 focused ISO
7024
7041
  cases derived from the success, failure, mode, and error behavior in
7025
7042
  ISO/IEC 13211-1 clauses 7 and 8, Part 2 modules, and Part 3 grammar rules.
7026
7043
  Separate exact-output suites check 189 normal