eyeprolog 1.3.36 → 1.3.38

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/README.md CHANGED
@@ -149,6 +149,24 @@ statistics(wfs_undefined_answers, UndefinedObservations).
149
149
  Both automatic tabling and `tnot/1` are EyeProlog extensions. Strict ISO mode
150
150
  disables automatic tabling and does not provide `tnot/1`.
151
151
 
152
+ ## Cleanup-aware control
153
+
154
+ Normal mode provides `call_cleanup/2` and `setup_call_cleanup/3` for resource
155
+ lifetimes that follow Prolog search. Cleanup runs exactly once when the protected
156
+ goal completes deterministically, is exhausted, is cut or otherwise pruned, the
157
+ top level stops answer enumeration, or an exception unwinds the search.
158
+ `setup_call_cleanup/3` runs Setup once and installs Cleanup only after Setup
159
+ succeeds. On ordinary pruning Cleanup sees the current goal bindings; during
160
+ exception unwinding bindings made by the protected goal have already been
161
+ unwound. Cleanup failure is ignored, and an exception already being propagated
162
+ takes precedence over a cleanup exception. Nested cleanups run inside-out.
163
+
164
+ These predicates are EyeProlog normal-mode extensions and are absent from
165
+ `--iso-strict`. Their implementation is lifecycle-aware: the interactive top
166
+ level can report a remaining choicepoint without speculatively requesting the
167
+ next solution, while abandoning that choicepoint still closes protected
168
+ resources.
169
+
152
170
  ## OpenRuleBench portable profile
153
171
 
154
172
  The `openrulebench/` directory contains a deterministic four-engine adaptation
@@ -176,9 +194,10 @@ eyeprolog --iso-strict --goal 'p(X)' program.pl
176
194
 
177
195
  The equivalent JavaScript option is `isoStrict: true`. Strict mode rejects
178
196
  EyeProlog language extensions, Part 2 module directives, and Part 3 grammar-rule
179
- expansion/`phrase/2-3`; it also removes the EyeProlog `occurs_check` flag and
180
- disables automatic tabling. Normal mode is unchanged and continues to support
181
- modules, DCGs, quads, libraries, proofs, and the other documented extensions.
197
+ expansion/`phrase/2-3`; it also removes the EyeProlog `occurs_check` flag,
198
+ `call_cleanup/2`, and `setup_call_cleanup/3`, and disables automatic tabling.
199
+ Normal mode is unchanged and continues to support modules, DCGs, quads,
200
+ libraries, proofs, cleanup-aware control, and the other documented extensions.
182
201
 
183
202
  The auditable processor-requirement checklist lives in
184
203
  [`test/conformance/ISO-COMPLIANCE.md`](test/conformance/ISO-COMPLIANCE.md).
@@ -312,4 +331,10 @@ The GitHub test workflow runs the complete suite and an npm package dry-run on
312
331
  both the minimum supported Node.js 18 release line and Node.js 24. Publishing
313
332
  repeats those release checks before uploading the package.
314
333
 
334
+ The runtime JavaScript modules stay flat under `src/`; the existing `src/lib/`
335
+ directory contains the portable Prolog library modules. See
336
+ [`src/ARCHITECTURE.md`](src/ARCHITECTURE.md) for the source-layer boundaries,
337
+ facade modules, dependency rule, and the requirement that architectural cleanup
338
+ must preserve the existing solver hot paths and benchmark performance.
339
+
315
340
  EyeProlog is released under the [MIT License](LICENSE.md).
@@ -1,7 +1,10 @@
1
1
  % From The Art of EyeProlog, Chapter 37.
2
2
  write_event(Path, Event) :-
3
- open(Path, write, Stream, [type(text)]),
4
- write_canonical(Stream, Event),
5
- put_char(Stream, '.'),
6
- nl(Stream),
7
- close(Stream).
3
+ setup_call_cleanup(
4
+ open(Path, write, Stream, [type(text)]),
5
+ ( write_canonical(Stream, Event),
6
+ put_char(Stream, '.'),
7
+ nl(Stream)
8
+ ),
9
+ close(Stream)
10
+ ).
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.36",
6
+ "version": "1.3.38",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -64,6 +64,7 @@
64
64
  "report:wg17": "node tools/report-wg17-syntax-coverage.mjs",
65
65
  "report:wg17-syntax": "node tools/report-wg17-syntax-coverage.mjs",
66
66
  "preversion": "npm test && node test/run-conformance-report.mjs conformance-report.md",
67
- "postversion": "git push origin HEAD --follow-tags"
67
+ "postversion": "git push origin HEAD --follow-tags",
68
+ "test:architecture": "node test/run-architecture.mjs"
68
69
  }
69
70
  }
@@ -0,0 +1,49 @@
1
+ # EyeProlog source architecture
2
+
3
+ The runtime is intentionally layered so semantic modules do not depend back on
4
+ higher-level frontends.
5
+
6
+ ## Layers
7
+
8
+ 1. **Kernel representation and syntax** — `term.js`, `number-value.js`,
9
+ `syntax-scan.js`, `parser.js`, `write.js`, `errors.js`.
10
+ 2. **Program preparation** — `program.js` plus `program-analysis.js` and
11
+ `program-indexing.js`. Static recursion/Datalog/WFS classification lives in
12
+ `program-analysis.js`; compact clauses and candidate indexes live in
13
+ `program-indexing.js`.
14
+ 3. **Execution** — `solver.js`, `cleanup.js`, `io.js`, `datalog.js`, `wfs.js`,
15
+ `clpz.js`. `cleanup.js` owns lifecycle-aware disposal of protected builtin
16
+ iterators and registers the normal-profile cleanup controls without making
17
+ `solver.js` depend back on the language registry.
18
+ 4. **Language services** — `iso.js`, `iso-arithmetic.js`, `dcg.js`,
19
+ `standard-library.js`, and `src/lib/`.
20
+ 5. **Frontends/tools** — `execute.js`, `repl.js`, `cli.js`, `quads.js`,
21
+ `explain.js`, and the playground worker.
22
+
23
+ `iso.js` and `program.js` remain facade modules for their existing exports, so
24
+ this refactor does not change the public JavaScript API.
25
+
26
+ ## Dependency rule
27
+
28
+ Dependencies should point down or sideways within a layer, never back from a
29
+ kernel component into the ISO registry or a frontend. In particular,
30
+ `errors.js` owns `PrologError` and `HaltSignal`; DCG expansion can therefore
31
+ report processor errors without importing `iso.js` and creating an
32
+ `iso.js <-> dcg.js` cycle.
33
+
34
+ The JavaScript runtime stays flat directly under `src/`; the existing `src/lib/`
35
+ contains Prolog library sources rather than JavaScript runtime modules. The architecture
36
+ test rejects JavaScript import cycles under `src/`. Cleanup lifecycle hooks are
37
+ installed from the public API and CLI entry paths; `standard-library.js` only
38
+ registers the predicates, so the execution layer remains acyclic.
39
+
40
+ ## Performance rule
41
+
42
+ Architecture changes must not add runtime strategy objects, callbacks, or
43
+ extra dispatch in solver hot paths. Existing scalar/indexed solver paths stay
44
+ as direct function calls. Candidate indexing is separated physically but
45
+ retains the same data structures and selection functions.
46
+
47
+ Large solver fast paths deliberately remain co-located in `solver.js` until a
48
+ split can demonstrate benchmark parity. A cleaner file layout is not worth a
49
+ runtime regression.
package/src/cleanup.js ADDED
@@ -0,0 +1,339 @@
1
+ // call_cleanup/2 and setup_call_cleanup/3 plus solver lifecycle support.
2
+ //
3
+ // EyeProlog's solver is demand-driven: a yielded builtin remains represented by
4
+ // a resumeBuiltin frame until its next answer is requested. Cleanup predicates
5
+ // therefore need two pieces of host support:
6
+ // * discarded resumeBuiltin iterators must be closed when search is pruned;
7
+ // * a cleanup iterator can report that its protected goal has no alternatives,
8
+ // so the REPL does not need speculative look-ahead to suppress a choicepoint.
9
+ import { PrologError } from './errors.js';
10
+ import { deref } from './term.js';
11
+
12
+ const cleanupLifecycleInstalled = Symbol('eyeprolog.cleanupLifecycleInstalled');
13
+ const cleanupLifecycleState = Symbol('eyeprolog.cleanupLifecycleState');
14
+ const searchStackPatched = Symbol('eyeprolog.cleanupSearchStackPatched');
15
+
16
+ export function installCleanupLifecycle(Solver) {
17
+ const prototype = Solver.prototype;
18
+ if (prototype[cleanupLifecycleInstalled]) return;
19
+ Object.defineProperty(prototype, cleanupLifecycleInstalled, { value: true });
20
+
21
+ const originalSolve = prototype.solve;
22
+ prototype.solve = function* cleanupAwareSolve(...args) {
23
+ const state = ensureLifecycleState(this);
24
+ const owner = {};
25
+ const iterator = originalSolve.apply(this, args);
26
+ let completed = false;
27
+ let thrown = null;
28
+
29
+ try {
30
+ while (true) {
31
+ const result = withOwner(state, owner, () => iterator.next());
32
+ if (result.done) {
33
+ completed = true;
34
+ return result.value;
35
+ }
36
+ yield result.value;
37
+ }
38
+ } catch (error) {
39
+ thrown = error;
40
+ throw error;
41
+ } finally {
42
+ // If the consumer prunes this solve() while it is suspended at an answer,
43
+ // close pending builtin iterators before the original solver removes its
44
+ // explicit search stack. Cleanup exceptions are observable on an ordinary
45
+ // cut/return, but never replace an exception already propagating outward.
46
+ if (!completed) {
47
+ let closeError = null;
48
+ try {
49
+ closeOwnedSearchStacks(this, state, owner, thrown != null);
50
+ } catch (error) {
51
+ closeError = error;
52
+ }
53
+ try {
54
+ withOwner(state, owner, () => iterator.return?.());
55
+ } catch (error) {
56
+ if (closeError == null) closeError = error;
57
+ }
58
+ if (thrown == null && closeError != null) throw closeError;
59
+ }
60
+ }
61
+ };
62
+
63
+ prototype.hasPendingAlternatives = function cleanupAwarePendingAlternatives() {
64
+ return this.solveStacks.some((stack) => stack.some(frameHasPendingAlternative));
65
+ };
66
+ }
67
+
68
+ export function registerCleanupBuiltins(registry) {
69
+ registry.add('call_cleanup', 2, callCleanupBuiltin);
70
+ registry.add('setup_call_cleanup', 3, setupCallCleanupBuiltin);
71
+ }
72
+
73
+ function ensureLifecycleState(solver) {
74
+ if (solver[cleanupLifecycleState] != null) return solver[cleanupLifecycleState];
75
+ const state = {
76
+ owner: null,
77
+ stackOwners: new WeakMap(),
78
+ };
79
+ Object.defineProperty(solver, cleanupLifecycleState, { value: state });
80
+
81
+ const solveStacks = solver.solveStacks;
82
+ const originalPush = solveStacks.push;
83
+ const originalSplice = solveStacks.splice;
84
+
85
+ solveStacks.push = function cleanupAwarePush(...stacks) {
86
+ for (const stack of stacks) {
87
+ state.stackOwners.set(stack, state.owner);
88
+ patchSearchStack(stack);
89
+ }
90
+ return originalPush.apply(this, stacks);
91
+ };
92
+
93
+ solveStacks.splice = function cleanupAwareSplice(start, deleteCount, ...items) {
94
+ const removed = originalSplice.call(this, start, deleteCount, ...items);
95
+ // This path is used by Solver.solve()'s finally block. If the solve is
96
+ // unwinding an exception, a cleanup exception must not replace it. Normal
97
+ // consumer pruning closes frames earlier in cleanupAwareSolve(), where
98
+ // cleanup errors are allowed to propagate.
99
+ for (let index = removed.length - 1; index >= 0; index--) {
100
+ closeSearchStack(removed[index], true);
101
+ }
102
+ return removed;
103
+ };
104
+
105
+ return state;
106
+ }
107
+
108
+ function withOwner(state, owner, operation) {
109
+ const previous = state.owner;
110
+ state.owner = owner;
111
+ try {
112
+ return operation();
113
+ } finally {
114
+ state.owner = previous;
115
+ }
116
+ }
117
+
118
+ function patchSearchStack(stack) {
119
+ if (stack[searchStackPatched]) return;
120
+ Object.defineProperty(stack, searchStackPatched, { value: true });
121
+ const originalSplice = stack.splice;
122
+ stack.splice = function cleanupAwareSearchSplice(start, deleteCount, ...items) {
123
+ const removed = originalSplice.call(this, start, deleteCount, ...items);
124
+ // A cut prunes explicit search frames synchronously. Closing a discarded
125
+ // builtin iterator here gives call_cleanup/2 its required cut semantics and
126
+ // also lets existing builtin finally blocks release their child solvers.
127
+ closeFrames(removed, false);
128
+ return removed;
129
+ };
130
+ }
131
+
132
+ function closeOwnedSearchStacks(solver, state, owner, suppressErrors) {
133
+ const stacks = solver.solveStacks
134
+ .filter((stack) => state.stackOwners.get(stack) === owner)
135
+ .slice()
136
+ .reverse();
137
+ let firstError = null;
138
+ for (const stack of stacks) {
139
+ try {
140
+ closeSearchStack(stack, suppressErrors);
141
+ } catch (error) {
142
+ if (firstError == null) firstError = error;
143
+ }
144
+ }
145
+ if (!suppressErrors && firstError != null) throw firstError;
146
+ }
147
+
148
+ function closeSearchStack(stack, suppressErrors) {
149
+ if (stack == null || stack.length === 0) return;
150
+ // Use Array.prototype directly: calling the patched splice would always use
151
+ // cut semantics (propagating errors) even while an exception is unwinding.
152
+ const removed = Array.prototype.splice.call(stack, 0, stack.length);
153
+ closeFrames(removed, suppressErrors);
154
+ }
155
+
156
+ function closeFrames(frames, suppressErrors) {
157
+ let firstError = null;
158
+ for (let index = frames.length - 1; index >= 0; index--) {
159
+ const frame = frames[index];
160
+ if (frame?.kind !== 'resumeBuiltin' || typeof frame.iterator?.return !== 'function') continue;
161
+ try {
162
+ frame.iterator.prepareClose?.({ unwinding: suppressErrors });
163
+ frame.iterator.return();
164
+ } catch (error) {
165
+ if (firstError == null) firstError = error;
166
+ }
167
+ }
168
+ if (!suppressErrors && firstError != null) throw firstError;
169
+ }
170
+
171
+ function frameHasPendingAlternative(frame) {
172
+ if (frame?.kind !== 'resumeBuiltin') return true;
173
+ const predicate = frame.iterator?.hasPendingAlternatives;
174
+ if (typeof predicate !== 'function') return true;
175
+ return predicate.call(frame.iterator);
176
+ }
177
+
178
+ function callableTerm(term, env) {
179
+ const value = deref(term, env);
180
+ if (value.type === 'var') throw new PrologError('instantiation_error');
181
+ if (value.type !== 'atom' && value.type !== 'compound') {
182
+ throw new PrologError('type_error(callable)', value);
183
+ }
184
+ return value;
185
+ }
186
+
187
+ function firstSetupSolution(solver, setup, env) {
188
+ const child = solver.cloneForInnerGoal(1);
189
+ const iterator = child.solve([callableTerm(setup, env)], env.clone(), 0);
190
+ try {
191
+ const result = iterator.next();
192
+ return result.done ? null : result.value;
193
+ } finally {
194
+ try {
195
+ iterator.return?.();
196
+ } finally {
197
+ solver.absorbStatsFrom(child);
198
+ }
199
+ }
200
+ }
201
+
202
+ function callCleanupBuiltin({ solver, goal, env }) {
203
+ const cleanup = callableTerm(goal.args[1], env);
204
+ return cleanupProtectedIterator(solver, goal.args[0], cleanup, env);
205
+ }
206
+
207
+ function setupCallCleanupBuiltin({ solver, goal, env }) {
208
+ let protectedIterator = null;
209
+ let pending = true;
210
+ const iterator = (function* setupThenProtected() {
211
+ const setupEnv = firstSetupSolution(solver, goal.args[0], env);
212
+ if (setupEnv == null) {
213
+ pending = false;
214
+ return;
215
+ }
216
+ // The cleanup is validated after Setup succeeds and before Goal starts, as
217
+ // in the WG17 cleanup proposal. A variable bound by Setup may therefore be
218
+ // used as Cleanup.
219
+ const cleanup = callableTerm(goal.args[2], setupEnv);
220
+ protectedIterator = cleanupProtectedIterator(solver, goal.args[1], cleanup, setupEnv);
221
+ try {
222
+ while (true) {
223
+ const result = protectedIterator.next();
224
+ pending = protectedIterator.hasPendingAlternatives();
225
+ if (result.done) {
226
+ pending = false;
227
+ return;
228
+ }
229
+ yield result.value;
230
+ if (!pending) return;
231
+ }
232
+ } finally {
233
+ protectedIterator.return?.();
234
+ }
235
+ })();
236
+ iterator.hasPendingAlternatives = () => pending;
237
+ iterator.prepareClose = (reason) => protectedIterator?.prepareClose?.(reason);
238
+ return iterator;
239
+ }
240
+
241
+ function cleanupProtectedIterator(solver, protectedGoal, cleanup, initialEnv) {
242
+ const child = solver.cloneForInnerGoal();
243
+ let childIterator = null;
244
+ let cleanupEnv = initialEnv;
245
+ let pending = true;
246
+ let cleaned = false;
247
+ let bodyError = null;
248
+ let unwindToSetupBindings = false;
249
+
250
+ const performCleanup = () => {
251
+ if (cleaned) return;
252
+ // Mark first so a throwing Cleanup is still considered executed exactly once.
253
+ cleaned = true;
254
+ runCleanupOnce(solver, cleanup, cleanupEnv);
255
+ };
256
+
257
+ const iterator = (function* protectedWithCleanup() {
258
+ try {
259
+ childIterator = child.solve([callableTerm(protectedGoal, initialEnv)], initialEnv.clone(), 0);
260
+ while (true) {
261
+ const result = childIterator.next();
262
+ if (result.done) {
263
+ pending = false;
264
+ // At the latest cleanup moment, Goal bindings have been undone.
265
+ cleanupEnv = initialEnv;
266
+ return;
267
+ }
268
+
269
+ cleanupEnv = result.value;
270
+ pending = child.hasPendingAlternatives();
271
+ if (!pending) {
272
+ // A deterministic final answer is known from the solver's explicit
273
+ // pending-frame state. Finalize the protected iterator and cleanup now,
274
+ // before yielding the answer, without computing a speculative answer.
275
+ childIterator.return?.();
276
+ childIterator = null;
277
+ performCleanup();
278
+ }
279
+
280
+ yield result.value;
281
+ if (!pending) return;
282
+ }
283
+ } catch (error) {
284
+ bodyError = error;
285
+ // Exceptions from Goal unwind its bindings before Cleanup is called.
286
+ cleanupEnv = initialEnv;
287
+ throw error;
288
+ } finally {
289
+ let childCloseError = null;
290
+ if (childIterator != null) {
291
+ try {
292
+ childIterator.return?.();
293
+ } catch (error) {
294
+ childCloseError = error;
295
+ }
296
+ childIterator = null;
297
+ }
298
+ solver.absorbStatsFrom(child);
299
+
300
+ if (unwindToSetupBindings) cleanupEnv = initialEnv;
301
+ if (!cleaned) {
302
+ try {
303
+ performCleanup();
304
+ } catch (cleanupError) {
305
+ // When the protected goal (or an inner cleanup) already raised, it has
306
+ // priority over this cleanup exception. An exception from an outer
307
+ // continuation is preserved by cleanup-aware stack teardown too.
308
+ if (bodyError == null && childCloseError == null) throw cleanupError;
309
+ }
310
+ }
311
+ if (bodyError == null && childCloseError != null) throw childCloseError;
312
+ }
313
+ })();
314
+
315
+ iterator.hasPendingAlternatives = () => pending;
316
+ iterator.prepareClose = ({ unwinding } = {}) => {
317
+ unwindToSetupBindings = unwinding === true;
318
+ };
319
+ return iterator;
320
+ }
321
+
322
+ function runCleanupOnce(solver, cleanup, env) {
323
+ const child = solver.cloneForInnerGoal(1);
324
+ const iterator = child.solve([cleanup], env.clone(), 0);
325
+ let result;
326
+ try {
327
+ result = iterator.next();
328
+ } finally {
329
+ try {
330
+ iterator.return?.();
331
+ } finally {
332
+ solver.absorbStatsFrom(child);
333
+ }
334
+ }
335
+ // Cleanup is semidet only as a control operation: failure is ignored, and
336
+ // bindings made solely by Cleanup are intentionally not added to an answer
337
+ // already produced by Goal.
338
+ return !result.done;
339
+ }
package/src/cli.js CHANGED
@@ -174,7 +174,7 @@ export async function main(argv) {
174
174
 
175
175
  async function loadEngine() {
176
176
  if (engineModule == null) {
177
- const [term, parser, program, solver, iso, library, write, quads, execute] = await Promise.all([
177
+ const [term, parser, program, solver, iso, library, write, quads, execute, cleanup] = await Promise.all([
178
178
  import('./term.js'),
179
179
  import('./parser.js'),
180
180
  import('./program.js'),
@@ -184,7 +184,11 @@ async function loadEngine() {
184
184
  import('./write.js'),
185
185
  import('./quads.js'),
186
186
  import('./execute.js'),
187
+ import('./cleanup.js'),
187
188
  ]);
189
+ // CLI loading is an entry-point layer above solver.js and the standard
190
+ // registry, so lifecycle installation stays acyclic.
191
+ cleanup.installCleanupLifecycle(solver.Solver);
188
192
  engineModule = { ...term, ...parser, ...program, ...solver, ...iso, ...library, ...write, ...quads, ...execute };
189
193
  }
190
194
  return engineModule;
package/src/dcg.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  ATOM, COMPOUND, VAR, Env, atom, compactListLength, compound, deref, emptyList,
6
6
  flattenConjunction, variable,
7
7
  } from './term.js';
8
- import { PrologError } from './iso.js';
8
+ import { PrologError } from './errors.js';
9
9
 
10
10
  let dcgFresh = 0;
11
11
 
package/src/errors.js ADDED
@@ -0,0 +1,23 @@
1
+ // Runtime control and ISO processor error types shared across subsystems.
2
+ // Keep these independent of the ISO builtin registry so syntax, DCG, program,
3
+ // and solver layers can report Prolog errors without importing the whole ISO
4
+ // implementation (and without creating semantic-layer import cycles).
5
+ import { termToString } from './term.js';
6
+
7
+ export class PrologError extends Error {
8
+ constructor(formal, culprit = null) {
9
+ const detail = culprit == null ? formal : `${formal}, ${termToString(culprit)}`;
10
+ super(`error(${detail})`);
11
+ this.name = 'PrologError';
12
+ this.formal = formal;
13
+ this.culprit = culprit;
14
+ }
15
+ }
16
+
17
+ export class HaltSignal extends Error {
18
+ constructor(code = 0) {
19
+ super(`halt(${code})`);
20
+ this.name = 'HaltSignal';
21
+ this.code = code;
22
+ }
23
+ }
package/src/index.js CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  export { StreamManager } from './io.js';
28
28
  export { runQuads } from './quads.js';
29
29
 
30
+ import { installCleanupLifecycle } from './cleanup.js';
30
31
  import { Program, autoloadProgramGoals } from './program.js';
31
32
  import { Solver } from './solver.js';
32
33
  import { whyNoProof, whyProof } from './explain.js';
@@ -34,6 +35,10 @@ import { getStrictIsoRegistry } from './iso.js';
34
35
  import { getEyePrologRegistry } from './standard-library.js';
35
36
  import { executeGoals, normalizeGoals } from './execute.js';
36
37
 
38
+ // The public API is an entry point above the solver/registry layers, so it can
39
+ // install pruning-aware iterator disposal without introducing an import cycle.
40
+ installCleanupLifecycle(Solver);
41
+
37
42
  export function run(source, options = {}) {
38
43
  const includeWhy = options.proof === true || options.why === true || options.explain === true;
39
44
  const requestedStrictIso = options.isoStrict === true;