eyeprolog 1.3.37 → 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).
@@ -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.37",
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",
@@ -11,7 +11,10 @@ higher-level frontends.
11
11
  `program-indexing.js`. Static recursion/Datalog/WFS classification lives in
12
12
  `program-analysis.js`; compact clauses and candidate indexes live in
13
13
  `program-indexing.js`.
14
- 3. **Execution** — `solver.js`, `io.js`, `datalog.js`, `wfs.js`, `clpz.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.
15
18
  4. **Language services** — `iso.js`, `iso-arithmetic.js`, `dcg.js`,
16
19
  `standard-library.js`, and `src/lib/`.
17
20
  5. **Frontends/tools** — `execute.js`, `repl.js`, `cli.js`, `quads.js`,
@@ -30,7 +33,9 @@ report processor errors without importing `iso.js` and creating an
30
33
 
31
34
  The JavaScript runtime stays flat directly under `src/`; the existing `src/lib/`
32
35
  contains Prolog library sources rather than JavaScript runtime modules. The architecture
33
- test rejects JavaScript import cycles under `src/`.
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.
34
39
 
35
40
  ## Performance rule
36
41
 
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/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;
@@ -4,6 +4,7 @@
4
4
  // source-level interop autoloader declared below.
5
5
  import { PrologError, createDefaultRegistry, eyePrologLibraryBuiltins } from './iso.js';
6
6
  import { clpzBuiltins } from './clpz.js';
7
+ import { registerCleanupBuiltins } from './cleanup.js';
7
8
  import { fs, isNode, memoryStatistics } from './platform.js';
8
9
  import { ATOM, VAR, atom, deref, numberTerm, unify } from './term.js';
9
10
 
@@ -181,6 +182,7 @@ export function createEyePrologRegistry() {
181
182
  registry.add('statistics', 0, statisticsBuiltin, { deterministic: true });
182
183
  registry.add('statistics', 2, statisticsValueBuiltin);
183
184
  registry.add('tnot', 1, tabledNegationBuiltin, { deterministic: true });
185
+ registerCleanupBuiltins(registry);
184
186
  eyePrologLibraryBuiltins.register(registry);
185
187
  clpzBuiltins.register(registry);
186
188
  registry.eyePrologLibrary = true;
package/test/run-all.mjs CHANGED
@@ -12,6 +12,7 @@ import { runBookExamples } from './run-book-examples.mjs';
12
12
  import { runWg17 } from './run-wg17.mjs';
13
13
  import { runOpenRuleBenchChecks } from './run-openrulebench.mjs';
14
14
  import { runArchitecture } from './run-architecture.mjs';
15
+ import { runCleanup } from './run-cleanup.mjs';
15
16
 
16
17
  await runStandalone(async (reporter) => {
17
18
  runConformance(reporter);
@@ -19,6 +20,7 @@ await runStandalone(async (reporter) => {
19
20
  runWg17(reporter);
20
21
  runOpenRuleBenchChecks(reporter);
21
22
  runArchitecture(reporter);
23
+ runCleanup(reporter);
22
24
  runRegression(reporter);
23
25
  await runPlayground(reporter);
24
26
  runExamples(reporter);
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // Regression coverage for call_cleanup/2 and setup_call_cleanup/3.
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ assertEqual,
8
+ assertIncludes,
9
+ assertNotIncludes,
10
+ isMainModule,
11
+ runStandalone,
12
+ } from './test-style.mjs';
13
+
14
+ const testRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
15
+ const packageRoot = path.resolve(testRoot, '..');
16
+ const bin = path.join(packageRoot, 'bin', 'eyeprolog.js');
17
+
18
+ export function runCleanup(reporter) {
19
+ reporter.section('Cleanup control');
20
+
21
+ reporter.test('call_cleanup/2 deterministic success has no leftover choicepoint', () => {
22
+ const result = runRepl('call_cleanup(true,true).\nhalt.\n');
23
+ assertEqual(result.status, 0, 'status');
24
+ assertIncludes(result.stdout, 'true.', 'stdout');
25
+ assertNotIncludes(result.stdout, '\n;', 'stdout');
26
+ assertEqual(result.stderr, '', 'stderr');
27
+ });
28
+
29
+ reporter.test('call_cleanup/2 runs cleanup when the user stops enumeration', () => {
30
+ const result = runRepl('call_cleanup((X=one;X=two),write(issue48_cleanup)).\n.\nhalt.\n');
31
+ assertEqual(result.status, 0, 'status');
32
+ assertIncludes(result.stdout, 'X = one', 'first answer');
33
+ assertIncludes(result.stdout, 'issue48_cleanup', 'cleanup output');
34
+ assertNotIncludes(result.stdout, 'X = two', 'unrequested answer');
35
+ assertEqual(count(result.stdout, 'issue48_cleanup'), 1, 'cleanup count');
36
+ assertEqual(result.stderr, '', 'stderr');
37
+ });
38
+
39
+ reporter.test('call_cleanup/2 runs before continuation after cut and sees current bindings', () => {
40
+ const result = runRepl(
41
+ 'call_cleanup((X=one;X=two),assertz(issue48_saved(X))),!,issue48_saved(Y).\nhalt.\n',
42
+ );
43
+ assertEqual(result.status, 0, 'status');
44
+ assertIncludes(result.stdout, 'X = one, Y = one.', 'cut cleanup answer');
45
+ assertNotIncludes(result.stdout, 'false.', 'stdout');
46
+ assertEqual(result.stderr, '', 'stderr');
47
+ });
48
+
49
+ reporter.test('call_cleanup/2 preserves protected exception over cleanup exception', () => {
50
+ const result = runRepl('catch(call_cleanup(throw(original),throw(cleanup)),E,true).\nhalt.\n');
51
+ assertEqual(result.status, 0, 'status');
52
+ assertIncludes(result.stdout, 'E = original.', 'caught exception');
53
+ assertNotIncludes(result.stdout, 'E = cleanup', 'exception priority');
54
+ assertEqual(result.stderr, '', 'stderr');
55
+ });
56
+
57
+ reporter.test('exception unwind removes Goal bindings before Cleanup', () => {
58
+ const result = runRepl(
59
+ 'catch((setup_call_cleanup(true,(G=bound;G=other),(var(G)->write(cleanup_unbound);write(cleanup_bound))),throw(cont)),E,true).\nhalt.\n',
60
+ );
61
+ assertEqual(result.status, 0, 'status');
62
+ assertIncludes(result.stdout, 'cleanup_unbound', 'cleanup binding state');
63
+ assertNotIncludes(result.stdout, 'cleanup_bound', 'unwound Goal binding');
64
+ assertIncludes(result.stdout, 'E = cont.', 'continuation exception');
65
+ assertEqual(result.stderr, '', 'stderr');
66
+ });
67
+
68
+ reporter.test('nested call_cleanup/2 cleanups run inside-out on cut', () => {
69
+ const result = runRepl(
70
+ 'call_cleanup(call_cleanup((X=one;X=two),write(inner_cleanup)),write(outer_cleanup)),!,true.\nhalt.\n',
71
+ );
72
+ assertEqual(result.status, 0, 'status');
73
+ const inner = result.stdout.indexOf('inner_cleanup');
74
+ const outer = result.stdout.indexOf('outer_cleanup');
75
+ if (inner < 0 || outer < 0 || inner >= outer) {
76
+ throw new Error(`cleanup order mismatch\nstdout: ${JSON.stringify(result.stdout)}`);
77
+ }
78
+ assertEqual(count(result.stdout, 'inner_cleanup'), 1, 'inner cleanup count');
79
+ assertEqual(count(result.stdout, 'outer_cleanup'), 1, 'outer cleanup count');
80
+ assertEqual(result.stderr, '', 'stderr');
81
+ });
82
+
83
+ reporter.test('setup_call_cleanup/3 calls Setup once and ignores cleanup failure', () => {
84
+ const result = runRepl('setup_call_cleanup((X=one;X=two),true,fail).\nhalt.\n');
85
+ assertEqual(result.status, 0, 'status');
86
+ assertIncludes(result.stdout, 'X = one.', 'setup result');
87
+ assertNotIncludes(result.stdout, 'X = two', 'second setup solution');
88
+ assertNotIncludes(result.stdout, '\n;', 'leftover choicepoint');
89
+ assertEqual(result.stderr, '', 'stderr');
90
+ });
91
+
92
+ reporter.test('setup_call_cleanup/3 does not install cleanup when Setup fails', () => {
93
+ const result = runRepl('setup_call_cleanup(fail,true,write(should_not_run)).\nhalt.\n');
94
+ assertEqual(result.status, 0, 'status');
95
+ assertIncludes(result.stdout, 'false.', 'failed setup');
96
+ assertNotIncludes(result.stdout, 'should_not_run', 'cleanup output');
97
+ assertEqual(result.stderr, '', 'stderr');
98
+ });
99
+
100
+ reporter.test('setup_call_cleanup/3 validates Cleanup after successful Setup', () => {
101
+ const result = runRepl('catch(setup_call_cleanup(true,true,_),E,true).\nhalt.\n');
102
+ assertEqual(result.status, 0, 'status');
103
+ assertIncludes(result.stdout, 'instantiation_error', 'cleanup validation error');
104
+ assertEqual(result.stderr, '', 'stderr');
105
+ });
106
+
107
+ reporter.test('cleanup predicates remain outside strict ISO core', () => {
108
+ const result = runRepl('catch(call_cleanup(true,true),E,true).\nhalt.\n', ['--iso-strict']);
109
+ assertEqual(result.status, 0, 'status');
110
+ assertIncludes(result.stdout, 'existence_error(procedure', 'strict ISO error');
111
+ assertIncludes(result.stdout, 'call_cleanup', 'strict ISO predicate');
112
+ assertEqual(result.stderr, '', 'stderr');
113
+ });
114
+
115
+ reporter.sectionTotal('cleanup');
116
+ }
117
+
118
+ function runRepl(input, args = []) {
119
+ const result = spawnSync(process.execPath, [bin, ...args], {
120
+ cwd: packageRoot,
121
+ input,
122
+ encoding: 'utf8',
123
+ timeout: 10000,
124
+ });
125
+ if (result.error) throw result.error;
126
+ return result;
127
+ }
128
+
129
+ function count(text, needle) {
130
+ return String(text).split(needle).length - 1;
131
+ }
132
+
133
+ if (isMainModule(import.meta.url)) {
134
+ await runStandalone((reporter) => runCleanup(reporter));
135
+ }
@@ -4471,7 +4471,7 @@ answer(ok) :-
4471
4471
  assertEqual(Boolean(registry.get('is', 2)), true, 'ISO is/2 exists');
4472
4472
  assertEqual(Boolean(registry.get('append', 3)), false, 'append/3 is not ISO core');
4473
4473
  assertEqual(library.eyePrologLibrary, true, 'complete registry marker');
4474
- assertEqual(library.defs.size, 158, 'EyeProlog registry contains ISO definitions, observability extensions, WFS tnot/1, and private library adapters');
4474
+ assertEqual(library.defs.size, 160, 'EyeProlog registry contains ISO definitions, cleanup controls, observability extensions, WFS tnot/1, and private library adapters');
4475
4475
  assertEqual(Boolean(registry.get('phrase', 2)), true, 'Part 3 phrase/2 exists');
4476
4476
  assertEqual(Boolean(registry.get('phrase', 3)), true, 'Part 3 phrase/3 exists');
4477
4477
  assertEqual(registry.get('statistics', 0), null, 'statistics/0 is absent from the ISO registry');
@@ -4482,6 +4482,10 @@ answer(ok) :-
4482
4482
  assertEqual(Boolean(library.get('tnot', 1)), true, 'tnot/1 is an EyeProlog WFS extension');
4483
4483
  assertEqual(registry.get('time', 1), null, 'time/1 is absent from the ISO registry');
4484
4484
  assertEqual(Boolean(library.get('time', 1)), true, 'time/1 is an EyeProlog timing extension');
4485
+ assertEqual(registry.get('call_cleanup', 2), null, 'call_cleanup/2 is absent from the ISO registry');
4486
+ assertEqual(Boolean(library.get('call_cleanup', 2)), true, 'call_cleanup/2 is an EyeProlog cleanup control');
4487
+ assertEqual(registry.get('setup_call_cleanup', 3), null, 'setup_call_cleanup/3 is absent from the ISO registry');
4488
+ assertEqual(Boolean(library.get('setup_call_cleanup', 3)), true, 'setup_call_cleanup/3 is an EyeProlog cleanup control');
4485
4489
  assertEqual(registeredNativeEyePrologLibraryNames().length, 41, 'public native EyeProlog builtin count');
4486
4490
  assertEqual(eyePrologPortableLibraryIndicators.length, 87, 'portable Prolog library count');
4487
4491
  assertEqual(eyePrologInteropLibraryIndicators.length, 29, 'cross-implementation interop profile count');
@@ -1947,6 +1947,13 @@ implements the shared ISO Part 3 grammar-rule and dynamic-body expansion
1947
1947
  without depending back on the ISO registry. This keeps the low-level syntax and
1948
1948
  error layers acyclic while preserving the existing `src/iso.js` exports.
1949
1949
 
1950
+ `src/cleanup.js` is an execution-layer sibling of the solver. It installs
1951
+ lifecycle-aware closing of protected builtin iterators from the supported API
1952
+ and CLI entry paths and registers `call_cleanup/2` and
1953
+ `setup_call_cleanup/3` for the normal EyeProlog profile. The standard-library
1954
+ layer does not import the solver back through this module, preserving the
1955
+ acyclic source graph.
1956
+
1950
1957
  Program preparation follows the same pattern. `src/program.js` remains the
1951
1958
  `Program` facade and source/module loader. Static recursion, Datalog, WFS, and
1952
1959
  negation-stratification analysis is isolated in `src/program-analysis.js`,
@@ -5167,6 +5174,18 @@ for example malformed input or an unavailable required resource. ISO
5167
5174
  instantiation, type, domain, permission, representation, and evaluation errors
5168
5175
  follow this same exception path.
5169
5176
 
5177
+ Normal EyeProlog also provides `call_cleanup(Goal, Cleanup)` and
5178
+ `setup_call_cleanup(Setup, Goal, Cleanup)`. Cleanup is run exactly once when the
5179
+ protected search completes deterministically, is exhausted, is cut or otherwise
5180
+ pruned, top-level answer enumeration is abandoned, or an exception unwinds the
5181
+ search. `setup_call_cleanup/3` runs Setup once and installs Cleanup only after
5182
+ Setup succeeds. On cut or ordinary pruning Cleanup sees the current Goal
5183
+ bindings; on exception unwind the Goal bindings have been removed and Cleanup
5184
+ sees the Setup environment. Cleanup failure is ignored, and an exception already
5185
+ being propagated takes precedence over a cleanup exception. Nested cleanups run
5186
+ inside-out. These two controls are EyeProlog extensions and are absent from
5187
+ `--iso-strict`.
5188
+
5170
5189
  Collection also makes search boundaries explicit. `findall/3` returns one list
5171
5190
  and existentially closes variables that occur only in its goal. `bagof/3`
5172
5191
  instead creates a group for each binding of a free variable and fails when
@@ -5372,13 +5391,21 @@ application code.
5372
5391
 
5373
5392
  ```eyeprolog
5374
5393
  write_event(Path, Event) :-
5375
- open(Path, write, Stream, [type(text)]),
5376
- write_canonical(Stream, Event),
5377
- put_char(Stream, '.'),
5378
- nl(Stream),
5379
- close(Stream).
5394
+ setup_call_cleanup(
5395
+ open(Path, write, Stream, [type(text)]),
5396
+ ( write_canonical(Stream, Event),
5397
+ put_char(Stream, '.'),
5398
+ nl(Stream)
5399
+ ),
5400
+ close(Stream)
5401
+ ).
5380
5402
  ```
5381
5403
 
5404
+ In normal mode, `setup_call_cleanup/3` is the preferred lifecycle boundary for
5405
+ resources such as streams: `close(Stream)` still runs if the protected work
5406
+ fails, throws, is cut, or its remaining alternatives are abandoned. Strict ISO
5407
+ mode does not provide this EyeProlog extension.
5408
+
5382
5409
  The period is essential when another Prolog processor will read the result as
5383
5410
  a term. `write/1-2` uses readable conventional syntax, `writeq/1-2` quotes
5384
5411
  where needed, and `write_canonical/1-2` exposes canonical structure. Dotted
@@ -5586,8 +5613,10 @@ negation.
5586
5613
 
5587
5614
  EyeProlog supports cut, operator declarations, dynamic database updates, grouped
5588
5615
  solutions, exceptions, flags, initialization and inclusion directives, and
5589
- standard stream and term I/O. ISO Part 2 modules and Part 3 definite clause
5590
- grammars complement this Part 1 core.
5616
+ standard stream and term I/O. Normal mode additionally provides lifecycle-aware
5617
+ `call_cleanup/2` and `setup_call_cleanup/3`; these cleanup controls are
5618
+ EyeProlog extensions and are excluded by `--iso-strict`. ISO Part 2 modules and
5619
+ Part 3 definite clause grammars complement this Part 1 core.
5591
5620
 
5592
5621
  ### ISO Part 2 modules
5593
5622
 
@@ -5838,8 +5867,7 @@ collision because other Prolog systems commonly reject it while loading.
5838
5867
  `;/2` recognizes an `->/2` term on its left and implements the ISO
5839
5868
  if-then-else commitment described above. Cuts and committed conditions are
5840
5869
  operational controls; use ordinary relations when all alternatives should
5841
- remain observable.
5842
-
5870
+ remain observable.
5843
5871
  ### Definite clause grammar processing
5844
5872
 
5845
5873
  | Predicate and principal call | Behavior |
@@ -6142,6 +6170,10 @@ The JavaScript `ioOptions.input` and `ioOptions.write` hooks connect standard
6142
6170
  streams to an embedder. File-backed streams use synchronous lifecycle semantics
6143
6171
  so side effects occur in Prolog execution order.
6144
6172
 
6173
+ ### Normal-mode cleanup controls
6174
+
6175
+ `call_cleanup/2` and `setup_call_cleanup/3` are normal EyeProlog runtime extensions rather than members of the isolated ISO builtin registry. They protect a goal across deterministic completion, exhaustion, cut, top-level abandonment, and exception unwinding, running Cleanup exactly once. `setup_call_cleanup/3` runs Setup once and installs Cleanup only after Setup succeeds. Nested cleanups run inside-out, and strict ISO mode does not provide either predicate.
6176
+
6145
6177
  ### The EyeProlog library
6146
6178
 
6147
6179
  EyeProlog exposes **128 library predicate indicators** in addition to the 129
@@ -6665,8 +6697,10 @@ the answer-control help. Enumeration is demand-driven: after an answer is
6665
6697
  found, the top level does not pull a successor merely to discover whether the
6666
6698
  current answer is the last one. Search for a later answer, including any side
6667
6699
  effects reached on that path, starts only after an answer-control command asks
6668
- to continue. If an unresolved alternative ultimately has no solution, asking
6669
- for it may therefore finish with `false.`. In scripted non-TTY input, a new
6700
+ to continue. Stopping enumeration closes any active `call_cleanup/2` or
6701
+ `setup_call_cleanup/3` protection exactly once; detecting that a choicepoint
6702
+ remains does not execute that next branch. If an unresolved alternative
6703
+ ultimately has no solution, asking for it may therefore finish with `false.`. In scripted non-TTY input, a new
6670
6704
  query line implicitly stops the preceding answer enumeration without consuming
6671
6705
  the new query; explicit `;`, `n`, Space, `a`, or `f` still requests more
6672
6706
  answers. Once the top-level reader has accepted a complete query, the following
@@ -7510,7 +7544,8 @@ Corrigenda 1–3. Corrigendum 2 additions—including `subsumes_term/2`,
7510
7544
  `acyclic_term/1`, `sort/2`, `keysort/2`, `term_variables/2`, `retractall/1`,
7511
7545
  and `call/2-8`—remain part of that strict baseline. Part 2 modules, Part 3 DCG
7512
7546
  expansion/`phrase/2-3`, quads, EyeProlog libraries, the `occurs_check` flag,
7513
- and automatic tabling are outside that Part 1 strict surface.
7547
+ automatic tabling, `call_cleanup/2`, and `setup_call_cleanup/3` are outside
7548
+ that Part 1 strict surface.
7514
7549
 
7515
7550
  This release establishes the strict-mode mechanism required for the ongoing
7516
7551
  conformance audit; it does **not** yet claim that every processor requirement
@@ -7739,6 +7774,14 @@ decidable by structural equality.
7739
7774
 
7740
7775
  **Clause.** A fact or rule terminated by a period.
7741
7776
 
7777
+ **Choicepoint.** A remaining search alternative that may produce another
7778
+ answer if the caller asks the solver to continue. EyeProlog's top level reports
7779
+ choicepoint availability without speculatively executing the next alternative.
7780
+
7781
+ **Cleanup.** A protected finalization goal installed by normal-mode
7782
+ `call_cleanup/2` or `setup_call_cleanup/3`. It runs exactly once when the
7783
+ protected search ends, is pruned or abandoned, or unwinds through an exception.
7784
+
7742
7785
  **Closed-world assumption.** The decision to treat failure to derive a
7743
7786
  sufficiently scoped claim as evidence for its absence. EyeProlog's `\+/1` performs
7744
7787
  negation as failure; the modeler is responsible for justifying the scope.
package/why-eyeprolog.md CHANGED
@@ -36,6 +36,7 @@ keeps a narrow architecture:
36
36
  - the ISO built-in registry;
37
37
  - lean portable ISO Part 2 library modules;
38
38
  - ISO Part 3 definite clause grammars and `phrase/2-3`;
39
+ - lifecycle-aware `call_cleanup/2` and `setup_call_cleanup/3` in normal mode;
39
40
  - optional proof explanations; and
40
41
  - the same implementation in Node.js and the browser.
41
42
 
@@ -48,11 +49,13 @@ their ISO definitions directly.
48
49
  The implementation follows the same compactness rule. JavaScript runtime
49
50
  modules stay flat under `src/`: `program.js` and `iso.js` remain stable facade
50
51
  modules, while static program analysis, clause indexing, arithmetic evaluation,
51
- and processor error types are factored into focused sibling files. The
52
- execution fast paths remain direct code in `solver.js`; source cleanup is not
53
- allowed to add dispatch or abstraction overhead merely to make that file
54
- smaller. `src/ARCHITECTURE.md` records these boundaries and an automated test
55
- rejects JavaScript import cycles.
52
+ processor error types, and cleanup lifecycle handling are factored into focused
53
+ sibling files. `cleanup.js` closes protected builtin iterators when search is
54
+ committed, abandoned, or unwound without making `solver.js` depend on the
55
+ language registry. The execution fast paths remain direct code in `solver.js`;
56
+ source cleanup is not allowed to add dispatch or abstraction overhead merely to
57
+ make that file smaller. `src/ARCHITECTURE.md` records these boundaries and an
58
+ automated test rejects JavaScript import cycles.
56
59
 
57
60
 
58
61
  ## Why keep well-founded negation explicit?
@@ -77,6 +80,16 @@ checked `examples/dcg-expression-language.pl` program shows the declarative side
77
80
  of that design: one grammar builds precedence-aware syntax trees and another
78
81
  generates minimally parenthesized token sequences back from them.
79
82
 
83
+ ## Why cleanup follows search lifecycle?
84
+
85
+ A Prolog resource lifetime is tied to search, not just to ordinary function
86
+ return. A protected goal can finish, fail, be cut, be abandoned at the top
87
+ level while alternatives remain, or unwind through an exception. Normal-mode
88
+ `call_cleanup/2` and `setup_call_cleanup/3` make those exits explicit and run
89
+ Cleanup exactly once. This also preserves demand-driven answer interaction: the
90
+ top level need not execute a successor merely to decide whether a choicepoint
91
+ exists. Strict ISO mode leaves these predicates out.
92
+
80
93
  ## Why proofs?
81
94
 
82
95
  An answer says that a goal succeeded. A proof records one successful route