eyeprolog 1.3.37 → 1.3.39

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
@@ -84,7 +84,13 @@ use any normal Prolog term syntax; EyeProlog only requires it to be ground when
84
84
  the quad is checked. A non-ground label is a quad failure, not a source syntax
85
85
  error, and later quads still run. When a query has multiple indented answer
86
86
  descriptions, each description is checked and counted independently, so one
87
- failed expectation does not prevent the later ones from running.
87
+ failed expectation does not prevent the later ones from running. Following the
88
+ Trealla quad convention, `sto` declares that the query is subject to occurs-check.
89
+ EyeProlog now uses an occurs-check event observed during the query's ordinary
90
+ execution as positive STO evidence, and rejects `sto` when a finite execution
91
+ completes without such an event (for example `?- true. sto.`). If execution is
92
+ cut short by a search/resource boundary, the STO claim remains conservatively
93
+ unverified rather than being guessed.
88
94
 
89
95
  ## Tabling and well-founded negation
90
96
 
@@ -149,6 +155,24 @@ statistics(wfs_undefined_answers, UndefinedObservations).
149
155
  Both automatic tabling and `tnot/1` are EyeProlog extensions. Strict ISO mode
150
156
  disables automatic tabling and does not provide `tnot/1`.
151
157
 
158
+ ## Cleanup-aware control
159
+
160
+ Normal mode provides `call_cleanup/2` and `setup_call_cleanup/3` for resource
161
+ lifetimes that follow Prolog search. Cleanup runs exactly once when the protected
162
+ goal completes deterministically, is exhausted, is cut or otherwise pruned, the
163
+ top level stops answer enumeration, or an exception unwinds the search.
164
+ `setup_call_cleanup/3` runs Setup once and installs Cleanup only after Setup
165
+ succeeds. On ordinary pruning Cleanup sees the current goal bindings; during
166
+ exception unwinding bindings made by the protected goal have already been
167
+ unwound. Cleanup failure is ignored, and an exception already being propagated
168
+ takes precedence over a cleanup exception. Nested cleanups run inside-out.
169
+
170
+ These predicates are EyeProlog normal-mode extensions and are absent from
171
+ `--iso-strict`. Their implementation is lifecycle-aware: the interactive top
172
+ level can report a remaining choicepoint without speculatively requesting the
173
+ next solution, while abandoning that choicepoint still closes protected
174
+ resources.
175
+
152
176
  ## OpenRuleBench portable profile
153
177
 
154
178
  The `openrulebench/` directory contains a deterministic four-engine adaptation
@@ -176,9 +200,10 @@ eyeprolog --iso-strict --goal 'p(X)' program.pl
176
200
 
177
201
  The equivalent JavaScript option is `isoStrict: true`. Strict mode rejects
178
202
  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.
203
+ expansion/`phrase/2-3`; it also removes the EyeProlog `occurs_check` flag,
204
+ `call_cleanup/2`, and `setup_call_cleanup/3`, and disables automatic tabling.
205
+ Normal mode is unchanged and continues to support modules, DCGs, quads,
206
+ libraries, proofs, cleanup-aware control, and the other documented extensions.
182
207
 
183
208
  The auditable processor-requirement checklist lives in
184
209
  [`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.39",
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;
package/src/quads.js CHANGED
@@ -36,11 +36,15 @@ export function runQuads(source, options = {}) {
36
36
  const results = [];
37
37
  const lines = [];
38
38
  for (const quad of quads) {
39
+ // `sto` declares a property of the query, not merely of the leaf in which
40
+ // the annotation happens to be written. Preserve that context while each
41
+ // answer description is still checked independently.
42
+ const context = { declaresSto: quad.answers.some(descriptionDeclaresSto) };
39
43
  // Every indented answer description is an independent portable quad test.
40
44
  // Re-run the query for each description so a failed expectation does not
41
45
  // prevent later expectations for the same query from being checked.
42
46
  for (const description of quad.answers) {
43
- const result = checkQuadDescription(program, quad, description, options);
47
+ const result = checkQuadDescription(program, quad, description, options, context);
44
48
  results.push(result);
45
49
  if (!result.ok) lines.push(formatFailure(program, quad, result, description));
46
50
  }
@@ -53,14 +57,14 @@ export function runQuads(source, options = {}) {
53
57
  return { stdout: lines.join(''), total: results.length, passed, failed, undecided, results };
54
58
  }
55
59
 
56
- function checkQuadDescription(program, quad, description, options) {
60
+ function checkQuadDescription(program, quad, description, options, context) {
57
61
  if (quad.id != null && !termIsGround(quad.id, new Env())) {
58
62
  return { ok: false, kind: 'bad_identifier', expected: quad.id };
59
63
  }
60
- return checkDescription(program, quad, description, options);
64
+ return checkDescription(program, quad, description, options, context);
61
65
  }
62
66
 
63
- function checkDescription(program, quad, description, options) {
67
+ function checkDescription(program, quad, description, options, context) {
64
68
  const alternatives = splitOperator(description, '|');
65
69
  // Probe an explicitly accepted nontermination outcome before alternatives
66
70
  // that would run the same query without a bound.
@@ -73,7 +77,7 @@ function checkDescription(program, quad, description, options) {
73
77
  let unsupported = null;
74
78
  let undecided = null;
75
79
  for (const alternative of ordered) {
76
- const checked = checkAlternative(program, quad, alternative, options);
80
+ const checked = checkAlternative(program, quad, alternative, options, context);
77
81
  if (checked.ok) return checked;
78
82
  if (checked.kind === 'unsupported') unsupported ??= checked;
79
83
  if (checked.kind === 'undecided') undecided ??= checked;
@@ -81,9 +85,9 @@ function checkDescription(program, quad, description, options) {
81
85
  return unsupported ?? undecided ?? { ok: false, kind: 'failed', expected: description };
82
86
  }
83
87
 
84
- function checkAlternative(program, quad, alternative, options) {
88
+ function checkAlternative(program, quad, alternative, options, context) {
85
89
  const leaves = splitOperator(alternative, ';').map(describeLeaf);
86
- if (leaves.some((leaf) => leaf.sto)) return { ok: true };
90
+ const requiresSto = leaves.some((leaf) => leaf.sto);
87
91
  const unsupported = leaves.find((leaf) => leaf.unsupported != null)?.unsupported;
88
92
  if (unsupported != null) {
89
93
  return { ok: false, kind: 'unsupported', expected: unsupported };
@@ -103,6 +107,18 @@ function checkAlternative(program, quad, alternative, options) {
103
107
  detectLoops: leaves.some((leaf) => leaf.loops),
104
108
  });
105
109
 
110
+ if (requiresSto) {
111
+ // Trealla currently treats the answer part of an `sto`-annotated leaf as
112
+ // implementation-dependent and skips it. EyeProlog can strengthen that
113
+ // conservatively: an observed occurs-check event proves the STO claim, and
114
+ // a naturally completed finite execution without one disproves it. When
115
+ // execution was cut short by a search/resource boundary, leave the claim
116
+ // unchecked rather than pretending to have proved NSTO.
117
+ if (actual.stoObserved) return { ok: true };
118
+ if (actual.nstoObserved) return { ok: false };
119
+ return { ok: true };
120
+ }
121
+
106
122
  if (inputSpecs.length > 0) {
107
123
  const leaf = leaves[0];
108
124
  const matches = actual.inputPosition === input.length && matchLeaf(program, quad.query, leaf, actual, 0);
@@ -114,7 +130,12 @@ function checkAlternative(program, quad, alternative, options) {
114
130
  for (const leaf of leaves) {
115
131
  if (leaf.more && !leaf.hasExpectation) return { ok: true };
116
132
  const matches = matchLeaf(program, quad.query, leaf, actual, position);
117
- if (leaf.unexpected ? matches : !matches) {
133
+ // Once a quad explicitly declares the query STO and this execution has
134
+ // observed an occurs-check event, an unannotated `unexpected` leaf cannot
135
+ // portably outlaw the implementation's chosen STO outcome. This is the
136
+ // case behind issue #60's `false, unexpected` example.
137
+ const stoPermitsUnexpected = context.declaresSto && actual.stoObserved && leaf.unexpected && !leaf.sto;
138
+ if (!stoPermitsUnexpected && (leaf.unexpected ? matches : !matches)) {
118
139
  if (actual.undecided && leafNeedsMoreSearch(leaf, actual, position)) {
119
140
  return undecidedResult(actual, alternative);
120
141
  }
@@ -245,19 +266,28 @@ function executeQuery(program, query, input, maxSolutions, options) {
245
266
  const solutions = [];
246
267
  let error = null;
247
268
  let tailOutput = '';
269
+ let complete = false;
270
+ let resourceInterrupted = false;
271
+ let iterator = null;
248
272
  try {
249
- const iterator = solver.solve([query], new Env(), 0);
273
+ iterator = solver.solve([query], new Env(), 0);
250
274
  while (solutions.length < maxSolutions) {
251
275
  pendingOutput = '';
252
276
  const result = iterator.next();
253
277
  if (result.done) {
254
278
  tailOutput += pendingOutput;
279
+ complete = true;
255
280
  break;
256
281
  }
257
282
  solutions.push({ env: result.value, output: pendingOutput });
258
283
  }
259
284
  } catch (caught) {
260
285
  error = { term: errorTerm(caught), output: pendingOutput };
286
+ resourceInterrupted = caught?.name === 'PrologError' &&
287
+ String(caught.formal ?? '').startsWith('resource_error(');
288
+ complete = true;
289
+ } finally {
290
+ if (!complete) iterator?.return?.();
261
291
  }
262
292
  const inputPosition = solver.io.resolve('user_input')?.position ?? 0;
263
293
  const bounded = solver.depthLimitExceeded || solver.inferenceLimitExceeded;
@@ -267,6 +297,10 @@ function executeQuery(program, query, input, maxSolutions, options) {
267
297
  error,
268
298
  tailOutput,
269
299
  inputPosition,
300
+ complete,
301
+ stoObserved: solver.occursCheckObserved,
302
+ nstoObserved: complete && !solver.occursCheckObserved && !solver.recursionCycleDetected &&
303
+ !bounded && !resourceInterrupted,
270
304
  // A loops expectation explicitly asks for bounded nontermination evidence.
271
305
  // Other descriptions treat the same exhausted search budget as undecided:
272
306
  // a timeout cannot establish finite failure or an exact answer sequence.
@@ -294,6 +328,11 @@ function matchLeaf(program, query, leaf, actual, position) {
294
328
  return substitutionMatches(query, leaf.bindings, solution.env);
295
329
  }
296
330
 
331
+ function descriptionDeclaresSto(description) {
332
+ return splitOperator(description, '|').some((alternative) =>
333
+ splitOperator(alternative, ';').some((term) => describeLeaf(term).sto));
334
+ }
335
+
297
336
  function alternativeDescribesLoop(alternative) {
298
337
  return splitOperator(alternative, ';').some((term) => describeLeaf(term).loops);
299
338
  }
package/src/solver.js CHANGED
@@ -88,7 +88,13 @@ export class Solver {
88
88
  if (!ISO_CORE_FLAG_NAMES.has(name)) this.prologFlags.delete(name);
89
89
  }
90
90
  }
91
+ // Record a concrete occurs-check event even when the configured action is
92
+ // finite-tree failure rather than an exception. Quad `sto` checks can then
93
+ // use the query's real execution as evidence without running it a second
94
+ // time (which could repeat side effects).
95
+ this.occursCheckObserved = false;
91
96
  this.occursCheckHandler = (left, right, env) => {
97
+ this.occursCheckObserved = true;
92
98
  if (this.prologFlags.get('occurs_check')?.value?.name === 'error') {
93
99
  raiseOccursCheckError(left, right, env);
94
100
  }
@@ -266,6 +272,7 @@ export class Solver {
266
272
  this.depthLimitExceeded ||= child.depthLimitExceeded;
267
273
  this.inferenceLimitExceeded ||= child.inferenceLimitExceeded;
268
274
  this.recursionCycleDetected ||= child.recursionCycleDetected;
275
+ this.occursCheckObserved ||= child.occursCheckObserved;
269
276
  for (const [key, value] of Object.entries(child.stats)) {
270
277
  if (key === 'max_depth' || key === 'max_goal_count') {
271
278
  this.stats[key] = Math.max(this.stats[key] ?? 0, value ?? 0);
@@ -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
+ }
@@ -1356,6 +1356,33 @@ c4 ?- call((!;1)).
1356
1356
  assertEqual(result.stderr, '', 'stderr');
1357
1357
  },
1358
1358
  },
1359
+ {
1360
+ name: 'quad sto uses observed occurs-check evidence instead of unconditional acceptance (issue #60)',
1361
+ run: () => {
1362
+ const sto = publicApi.runQuads(String.raw`33
1363
+ ?- X = s(X).
1364
+ X = ..., unexpected.
1365
+ false, unexpected.
1366
+ sto, false
1367
+ | sto, true.
1368
+ `);
1369
+ assertEqual(sto.total, 3, 'STO description total');
1370
+ assertEqual(sto.passed, 3, 'STO descriptions passed');
1371
+ assertEqual(sto.failed, 0, 'STO descriptions failed');
1372
+ assertEqual(sto.undecided, 0, 'STO descriptions undecided');
1373
+ assertEqual(sto.stdout, 'quads: 3 run, 3 passed, 0 failed.\n', 'STO report');
1374
+
1375
+ const nsto = publicApi.runQuads(String.raw`34
1376
+ ?- true.
1377
+ sto.
1378
+ `);
1379
+ assertEqual(nsto.total, 1, 'NSTO description total');
1380
+ assertEqual(nsto.passed, 0, 'NSTO description passed');
1381
+ assertEqual(nsto.failed, 1, 'NSTO description failed');
1382
+ assertEqual(nsto.undecided, 0, 'NSTO description undecided');
1383
+ assertIncludes(nsto.stdout, 'quads: FAILED 34, <input>:1', 'NSTO diagnostic');
1384
+ },
1385
+ },
1359
1386
  {
1360
1387
  name: 'outputs/1 accepts DCG bodies over captured characters (issue #59)',
1361
1388
  run: () => {
@@ -4471,7 +4498,7 @@ answer(ok) :-
4471
4498
  assertEqual(Boolean(registry.get('is', 2)), true, 'ISO is/2 exists');
4472
4499
  assertEqual(Boolean(registry.get('append', 3)), false, 'append/3 is not ISO core');
4473
4500
  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');
4501
+ assertEqual(library.defs.size, 160, 'EyeProlog registry contains ISO definitions, cleanup controls, observability extensions, WFS tnot/1, and private library adapters');
4475
4502
  assertEqual(Boolean(registry.get('phrase', 2)), true, 'Part 3 phrase/2 exists');
4476
4503
  assertEqual(Boolean(registry.get('phrase', 3)), true, 'Part 3 phrase/3 exists');
4477
4504
  assertEqual(registry.get('statistics', 0), null, 'statistics/0 is absent from the ISO registry');
@@ -4482,6 +4509,10 @@ answer(ok) :-
4482
4509
  assertEqual(Boolean(library.get('tnot', 1)), true, 'tnot/1 is an EyeProlog WFS extension');
4483
4510
  assertEqual(registry.get('time', 1), null, 'time/1 is absent from the ISO registry');
4484
4511
  assertEqual(Boolean(library.get('time', 1)), true, 'time/1 is an EyeProlog timing extension');
4512
+ assertEqual(registry.get('call_cleanup', 2), null, 'call_cleanup/2 is absent from the ISO registry');
4513
+ assertEqual(Boolean(library.get('call_cleanup', 2)), true, 'call_cleanup/2 is an EyeProlog cleanup control');
4514
+ assertEqual(registry.get('setup_call_cleanup', 3), null, 'setup_call_cleanup/3 is absent from the ISO registry');
4515
+ assertEqual(Boolean(library.get('setup_call_cleanup', 3)), true, 'setup_call_cleanup/3 is an EyeProlog cleanup control');
4485
4516
  assertEqual(registeredNativeEyePrologLibraryNames().length, 41, 'public native EyeProlog builtin count');
4486
4517
  assertEqual(eyePrologPortableLibraryIndicators.length, 87, 'portable Prolog library count');
4487
4518
  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
@@ -6862,7 +6896,17 @@ described answer or error, including output produced before a later exception.
6862
6896
  Its argument may be an exact character list/string or a DCG body: terminal
6863
6897
  sequences, conjunction/disjunction, `...`/`ad_infinitum` sequence wildcards,
6864
6898
  and user-defined DCG nonterminals are matched against the captured characters.
6865
- `sto` marks an answer description that this finite-tree implementation skips.
6899
+ Following Trealla's quad convention, `sto` declares that the query is subject
6900
+ to occurs-check; the answer portion of an `sto`-annotated leaf remains
6901
+ implementation-dependent and is not compared. EyeProlog can nevertheless check
6902
+ some of the declaration without a second execution: the normal finite-tree
6903
+ unifier records a concrete occurs-check event as positive STO evidence. A
6904
+ naturally completed finite execution with no such event disproves `sto` (so
6905
+ `?- true. sto.` fails), while a search/resource boundary leaves the declaration
6906
+ conservatively unchecked. When the same quad declares STO and an occurs-check
6907
+ event is observed, an unannotated `unexpected` leaf does not reject an outcome
6908
+ that is implementation-dependent precisely because the query is STO. This is
6909
+ partial STO detection, not a decision procedure for the full STO/NSTO property.
6866
6910
  `loops` explicitly asks for bounded nontermination evidence and accepts direct
6867
6911
  active-variant cycle evidence from EyeProlog's normal recursion guard, with the
6868
6912
  loop depth/inference bounds as a fallback. Ordinary quad descriptions also have
@@ -7510,7 +7554,8 @@ Corrigenda 1–3. Corrigendum 2 additions—including `subsumes_term/2`,
7510
7554
  `acyclic_term/1`, `sort/2`, `keysort/2`, `term_variables/2`, `retractall/1`,
7511
7555
  and `call/2-8`—remain part of that strict baseline. Part 2 modules, Part 3 DCG
7512
7556
  expansion/`phrase/2-3`, quads, EyeProlog libraries, the `occurs_check` flag,
7513
- and automatic tabling are outside that Part 1 strict surface.
7557
+ automatic tabling, `call_cleanup/2`, and `setup_call_cleanup/3` are outside
7558
+ that Part 1 strict surface.
7514
7559
 
7515
7560
  This release establishes the strict-mode mechanism required for the ongoing
7516
7561
  conformance audit; it does **not** yet claim that every processor requirement
@@ -7739,6 +7784,14 @@ decidable by structural equality.
7739
7784
 
7740
7785
  **Clause.** A fact or rule terminated by a period.
7741
7786
 
7787
+ **Choicepoint.** A remaining search alternative that may produce another
7788
+ answer if the caller asks the solver to continue. EyeProlog's top level reports
7789
+ choicepoint availability without speculatively executing the next alternative.
7790
+
7791
+ **Cleanup.** A protected finalization goal installed by normal-mode
7792
+ `call_cleanup/2` or `setup_call_cleanup/3`. It runs exactly once when the
7793
+ protected search ends, is pruned or abandoned, or unwinds through an exception.
7794
+
7742
7795
  **Closed-world assumption.** The decision to treat failure to derive a
7743
7796
  sufficiently scoped claim as evidence for its absence. EyeProlog's `\+/1` performs
7744
7797
  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