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 +29 -4
- package/examples/book/chapter-37/01-write_event.pl +8 -5
- package/package.json +1 -1
- package/src/ARCHITECTURE.md +7 -2
- package/src/cleanup.js +339 -0
- package/src/cli.js +5 -1
- package/src/index.js +5 -0
- package/src/quads.js +48 -9
- package/src/solver.js +7 -0
- package/src/standard-library.js +2 -0
- package/test/run-all.mjs +2 -0
- package/test/run-cleanup.mjs +135 -0
- package/test/run-regression.mjs +32 -1
- package/the-art-of-eyeprolog.md +66 -13
- package/why-eyeprolog.md +18 -5
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
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
package/src/ARCHITECTURE.md
CHANGED
|
@@ -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`, `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/standard-library.js
CHANGED
|
@@ -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
|
+
}
|
package/test/run-regression.mjs
CHANGED
|
@@ -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,
|
|
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');
|
package/the-art-of-eyeprolog.md
CHANGED
|
@@ -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
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
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.
|
|
5590
|
-
|
|
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.
|
|
6669
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|