eyeprolog 1.2.18 → 1.2.20

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/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.18",
6
+ "version": "1.2.20",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
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/repl.js CHANGED
@@ -488,8 +488,15 @@ async function solveQuery(engine, state, goal, reader, output) {
488
488
  let automatic = 0;
489
489
  let answersShown = 0;
490
490
  let firstAnswer = true;
491
+ let formattingAfterAdvance = false;
491
492
  while (!current.result.done) {
492
493
  const next = pullSolution(solver, solutions, reader);
494
+ // The control prompt has no trailing space while it waits for input. The
495
+ // first space appears as soon as the user requests another solution and
496
+ // remains visible while pullSolution() computes; the second appears only
497
+ // when the requested leaf answer is ready to format.
498
+ if (formattingAfterAdvance) output.write(' ');
499
+ formattingAfterAdvance = false;
493
500
  output.write(current.output);
494
501
  output.write(`${firstAnswer ? ' ' : ''}${formatAnswer(engine, state, variables, current.result.value)}`);
495
502
  answersShown++;
@@ -501,14 +508,15 @@ async function solveQuery(engine, state, goal, reader, output) {
501
508
 
502
509
  if (automatic > 0 || automatic === Infinity) {
503
510
  if (automatic !== Infinity) automatic--;
504
- output.write('\n; ');
511
+ output.write('\n; ');
512
+ formattingAfterAdvance = true;
505
513
  } else {
506
514
  while (true) {
507
- const controlLine = await reader.readControl('\n; ');
515
+ const controlLine = await reader.readControl('\n;');
508
516
  if (controlLine == null || controlLine === '' || controlLine === '\r' || controlLine === '\n' ||
509
517
  controlLine.trimStart().startsWith('.')) {
510
518
  if (typeof solutions.return === 'function') solutions.return();
511
- output.write('... .\n');
519
+ output.write(' ... .\n');
512
520
  return null;
513
521
  }
514
522
  const control = controlLine === ' ' ? ' ' : controlLine.trimStart()[0];
@@ -527,18 +535,22 @@ async function solveQuery(engine, state, goal, reader, output) {
527
535
  break;
528
536
  }
529
537
  if (control === 'w' || control === 'p') {
530
- output.write(`${formatAnswer(engine, state, variables, current.result.value)}`);
538
+ output.write(` ${formatAnswer(engine, state, variables, current.result.value)}`);
531
539
  continue;
532
540
  }
533
541
  if (control === 'h') {
534
542
  output.write(ANSWER_HELP);
535
543
  continue;
536
544
  }
537
- output.write('Action? ');
545
+ output.write(' Action? ');
538
546
  }
547
+ output.write(' ');
548
+ formattingAfterAdvance = true;
539
549
  }
540
550
 
541
551
  if (next.error) {
552
+ if (formattingAfterAdvance) output.write(' ');
553
+ formattingAfterAdvance = false;
542
554
  output.write(next.output);
543
555
  if (next.error?.name === 'HaltSignal') return { halted: true, code: next.error.code };
544
556
  throw next.error;
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) {
@@ -790,6 +790,61 @@ c4 ?- call((!;1)).
790
790
  assertEqual(result.stderr, '', 'stderr');
791
791
  },
792
792
  },
793
+ {
794
+ name: 'REPL answer prompt distinguishes waiting from computation',
795
+ run: () => {
796
+ const helper = `
797
+ import { spawn } from 'node:child_process';
798
+
799
+ const child = spawn(${JSON.stringify(process.execPath)}, [${JSON.stringify(bin)}], {
800
+ cwd: ${JSON.stringify(packageRoot)},
801
+ stdio: ['pipe', 'pipe', 'pipe'],
802
+ });
803
+ child.stdout.setEncoding('utf8');
804
+ child.stderr.setEncoding('utf8');
805
+ let stdout = '';
806
+ let stderr = '';
807
+ let sawComputingPrompt = false;
808
+ child.stdout.on('data', (text) => {
809
+ stdout += text;
810
+ if (stdout.endsWith('\\n; ')) sawComputingPrompt = true;
811
+ });
812
+ child.stderr.on('data', (text) => { stderr += text; });
813
+
814
+ async function waitFor(predicate, label) {
815
+ const deadline = Date.now() + 5000;
816
+ while (!predicate()) {
817
+ if (Date.now() >= deadline) {
818
+ throw new Error(label + ' timeout; stdout=' + JSON.stringify(stdout) + '; stderr=' + JSON.stringify(stderr));
819
+ }
820
+ await new Promise((resolve) => setTimeout(resolve, 10));
821
+ }
822
+ }
823
+
824
+ child.stdin.write('use_module(library(prologue)).\\n');
825
+ await waitFor(() => stdout.includes(' true.\\n?- '), 'module import');
826
+ child.stdin.write('(N = 0; N = 1; (call_nth(repeat, 100000), N = 2)).\\n');
827
+ await waitFor(() => stdout.endsWith(' N = 0\\n;'), 'waiting prompt');
828
+ child.stdin.write(';\\n');
829
+ await waitFor(() => sawComputingPrompt, 'computing prompt');
830
+ await waitFor(() => stdout.endsWith('; N = 1\\n;'), 'formatted answer');
831
+ child.stdin.write('\\n');
832
+ await waitFor(() => stdout.endsWith(' ... .\\n?- '), 'stopped enumeration');
833
+ child.stdin.write('halt.\\n');
834
+ const status = await new Promise((resolve) => child.once('exit', resolve));
835
+ if (status !== 0) throw new Error('child status ' + status + '; stderr=' + stderr);
836
+ process.stdout.write('waiting;computing;formatting');
837
+ `;
838
+ const result = spawnSync(process.execPath, [
839
+ '--input-type=module',
840
+ '--eval',
841
+ helper,
842
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 10000 });
843
+ if (result.error) throw result.error;
844
+ assertEqual(result.status, 0, `prompt helper status; stderr=${result.stderr}`);
845
+ assertEqual(result.stdout, 'waiting;computing;formatting', 'prompt state sequence');
846
+ },
847
+ },
793
848
  {
794
849
  name: 'REPL f stops at five-answer boundaries instead of adding five answers',
795
850
  run: () => {
@@ -2324,6 +2379,35 @@ open(X) :- candidate(X), \\+ closed(X).
2324
2379
  }
2325
2380
  },
2326
2381
  },
2382
+ {
2383
+ name: 'list allocation heap pressure becomes resource_error(memory)',
2384
+ run: () => {
2385
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
2386
+ const programText = ':- use_module(library(prologue)).\n';
2387
+ const goalText = 'length(_, I), I > 9, N is 2^I, \\+ \\+ length(_, N)';
2388
+ const script = `
2389
+ import { Program, Solver, Env, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
2390
+ const program = Program.parse(${JSON.stringify(programText)});
2391
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
2392
+ const goal = parseGoalText(${JSON.stringify(goalText)}, {
2393
+ operatorDefinitions: [...program.operators.values()],
2394
+ });
2395
+ let caught = null;
2396
+ try { [...solver.solve([goal], new Env(), 0)]; } catch (error) { caught = error; }
2397
+ if (caught?.formal !== 'resource_error(memory)') throw caught ?? new Error('no resource error');
2398
+ process.stdout.write(caught.formal);
2399
+ `;
2400
+ const result = spawnSync(process.execPath, [
2401
+ '--max-old-space-size=64',
2402
+ '--input-type=module',
2403
+ '--eval',
2404
+ script,
2405
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2406
+ if (result.error) throw result.error;
2407
+ assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2408
+ assertEqual(result.stdout, 'resource_error(memory)', 'heap pressure resource error');
2409
+ },
2410
+ },
2327
2411
  {
2328
2412
  name: 'solver honors solution limits',
2329
2413
  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.
@@ -6271,7 +6284,9 @@ When another answer exists in an interactive terminal, press `;`, Space, or
6271
6284
  enumeration, `a` enumerates all remaining answers, and `f` advances to the
6272
6285
  next five-answer boundary (5, 10, 15, ... displayed leaf answers), regardless
6273
6286
  of how many answers were stepped through individually beforehand. `h` displays
6274
- the answer-control help. While a query is actively
6287
+ the answer-control help. The answer prompt is `;` with no trailing space while
6288
+ it waits for input; after an advance command, one space marks active search and
6289
+ a second marks an answer ready for formatting. While a query is actively
6275
6290
  computing, EyeProlog releases readline's terminal signal handling: `Ctrl-C`
6276
6291
  therefore terminates the current EyeProlog process immediately, and on POSIX
6277
6292
  terminals `Ctrl-Z` suspends it in the usual shell-managed way. This remains a