eyeprolog 1.3.34 → 1.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +14 -2
- package/package.json +1 -1
- package/src/cli.js +1 -0
- package/src/quads.js +208 -27
- package/src/repl.js +55 -48
- package/src/solver.js +1 -1
- package/test/run-regression.mjs +96 -3
- package/the-art-of-eyeprolog.md +29 -12
package/index.d.ts
CHANGED
|
@@ -77,8 +77,9 @@ export interface EyePrologQuad {
|
|
|
77
77
|
|
|
78
78
|
export interface EyePrologQuadResult {
|
|
79
79
|
ok: boolean;
|
|
80
|
-
kind?: 'failed' | 'malformed' | 'bad_identifier' | 'unsupported';
|
|
80
|
+
kind?: 'failed' | 'malformed' | 'bad_identifier' | 'unsupported' | 'undecided';
|
|
81
81
|
expected?: EyePrologTerm;
|
|
82
|
+
reason?: string;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
export interface EyePrologQuadRunResult {
|
|
@@ -86,9 +87,20 @@ export interface EyePrologQuadRunResult {
|
|
|
86
87
|
total: number;
|
|
87
88
|
passed: number;
|
|
88
89
|
failed: number;
|
|
90
|
+
undecided: number;
|
|
89
91
|
results: EyePrologQuadResult[];
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
export interface EyePrologQuadRunOptions extends EyePrologRunOptions {
|
|
95
|
+
initialize?: boolean;
|
|
96
|
+
/** Search budget for ordinary quad descriptions before reporting an undecided result. */
|
|
97
|
+
quadMaxInferences?: number;
|
|
98
|
+
/** Depth bound used when a quad explicitly expects loops. */
|
|
99
|
+
loopMaxDepth?: number;
|
|
100
|
+
/** Inference bound used when a quad explicitly expects loops. */
|
|
101
|
+
loopMaxInferences?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
export interface EyePrologPredicateGroup {
|
|
93
105
|
name: string;
|
|
94
106
|
arity: number;
|
|
@@ -271,7 +283,7 @@ export class HaltSignal extends Error {
|
|
|
271
283
|
constructor(code?: number);
|
|
272
284
|
}
|
|
273
285
|
export function run(source: string | Program, options?: EyePrologRunOptions): EyePrologRunResult;
|
|
274
|
-
export function runQuads(source: string | Program, options?:
|
|
286
|
+
export function runQuads(source: string | Program, options?: EyePrologQuadRunOptions): EyePrologQuadRunResult;
|
|
275
287
|
export function whyProof(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): { ok: boolean; text: string };
|
|
276
288
|
export function whyNoProof(goal: EyePrologTerm): string;
|
|
277
289
|
export function explainProof(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): { ok: boolean; text: string };
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -168,6 +168,7 @@ export async function main(argv) {
|
|
|
168
168
|
const result = engine.runQuads(program, { initialize: options.goals.length === 0 });
|
|
169
169
|
process.stdout.write(result.stdout);
|
|
170
170
|
if (result.failed > 0) process.exitCode = 1;
|
|
171
|
+
else if (result.undecided > 0) process.exitCode = 2;
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
|
package/src/quads.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// used by Trealla and the ISO Prolog working examples linked from issue #1.
|
|
4
4
|
import {
|
|
5
5
|
ATOM, COMPOUND, VAR, Env, atom, compound, copyResolved, deref,
|
|
6
|
-
flattenConjunction, properListItems, termIsGround,
|
|
6
|
+
flattenConjunction, listFromItems, properListItems, termIsGround,
|
|
7
7
|
unify, variable,
|
|
8
8
|
} from './term.js';
|
|
9
9
|
import { parseGoalText } from './parser.js';
|
|
@@ -12,13 +12,16 @@ import { Solver } from './solver.js';
|
|
|
12
12
|
import { getEyePrologRegistry } from './standard-library.js';
|
|
13
13
|
import { formatTermForWrite } from './write.js';
|
|
14
14
|
|
|
15
|
+
const DEFAULT_QUAD_MAX_INFERENCES = 100000;
|
|
16
|
+
const DEFAULT_LOOP_MAX_INFERENCES = 10000;
|
|
17
|
+
|
|
15
18
|
export function runQuads(source, options = {}) {
|
|
16
19
|
const program = source instanceof Program
|
|
17
20
|
? source
|
|
18
21
|
: Program.parse(source, { ...options, sourceMetadata: true });
|
|
19
22
|
const quads = program.quads ?? [];
|
|
20
23
|
if (quads.length === 0) {
|
|
21
|
-
return { stdout: 'quads: nothing to run.\n', total: 0, passed: 0, failed: 0, results: [] };
|
|
24
|
+
return { stdout: 'quads: nothing to run.\n', total: 0, passed: 0, failed: 0, undecided: 0, results: [] };
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
if (options.initialize !== false) {
|
|
@@ -43,9 +46,11 @@ export function runQuads(source, options = {}) {
|
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
const passed = results.filter((result) => result.ok).length;
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
const undecided = results.filter((result) => result.kind === 'undecided').length;
|
|
50
|
+
const failed = results.length - passed - undecided;
|
|
51
|
+
const undecidedSummary = undecided === 0 ? '' : `, ${undecided} undecided`;
|
|
52
|
+
lines.push(`quads: ${results.length} run, ${passed} passed, ${failed} failed${undecidedSummary}.\n`);
|
|
53
|
+
return { stdout: lines.join(''), total: results.length, passed, failed, undecided, results };
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
function checkQuadDescription(program, quad, description, options) {
|
|
@@ -66,12 +71,14 @@ function checkDescription(program, quad, description, options) {
|
|
|
66
71
|
if (malformed != null) return { ok: false, kind: 'malformed', expected: malformed };
|
|
67
72
|
}
|
|
68
73
|
let unsupported = null;
|
|
74
|
+
let undecided = null;
|
|
69
75
|
for (const alternative of ordered) {
|
|
70
76
|
const checked = checkAlternative(program, quad, alternative, options);
|
|
71
77
|
if (checked.ok) return checked;
|
|
72
78
|
if (checked.kind === 'unsupported') unsupported ??= checked;
|
|
79
|
+
if (checked.kind === 'undecided') undecided ??= checked;
|
|
73
80
|
}
|
|
74
|
-
return unsupported ?? { ok: false, kind: 'failed', expected: description };
|
|
81
|
+
return unsupported ?? undecided ?? { ok: false, kind: 'failed', expected: description };
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
function checkAlternative(program, quad, alternative, options) {
|
|
@@ -98,15 +105,21 @@ function checkAlternative(program, quad, alternative, options) {
|
|
|
98
105
|
|
|
99
106
|
if (inputSpecs.length > 0) {
|
|
100
107
|
const leaf = leaves[0];
|
|
101
|
-
const matches = actual.inputPosition === input.length && matchLeaf(quad.query, leaf, actual, 0);
|
|
108
|
+
const matches = actual.inputPosition === input.length && matchLeaf(program, quad.query, leaf, actual, 0);
|
|
109
|
+
if (actual.undecided && !matches) return undecidedResult(actual, alternative);
|
|
102
110
|
return { ok: leaf.unexpected ? !matches : matches };
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
let position = 0;
|
|
106
114
|
for (const leaf of leaves) {
|
|
107
115
|
if (leaf.more && !leaf.hasExpectation) return { ok: true };
|
|
108
|
-
const matches = matchLeaf(quad.query, leaf, actual, position);
|
|
109
|
-
if (leaf.unexpected ? matches : !matches)
|
|
116
|
+
const matches = matchLeaf(program, quad.query, leaf, actual, position);
|
|
117
|
+
if (leaf.unexpected ? matches : !matches) {
|
|
118
|
+
if (actual.undecided && leafNeedsMoreSearch(leaf, actual, position)) {
|
|
119
|
+
return undecidedResult(actual, alternative);
|
|
120
|
+
}
|
|
121
|
+
return { ok: false };
|
|
122
|
+
}
|
|
110
123
|
// An unexpected error description is a negative assertion about that
|
|
111
124
|
// particular error pattern. A different exception may be present and is
|
|
112
125
|
// checked by other descriptions; do not make the final 'no error' test
|
|
@@ -114,12 +127,16 @@ function checkAlternative(program, quad, alternative, options) {
|
|
|
114
127
|
if (leaf.unexpected && leaf.error != null) return { ok: true };
|
|
115
128
|
if (leaf.more) return { ok: true };
|
|
116
129
|
if (!leaf.unexpected && (leaf.false || leaf.error != null)) {
|
|
130
|
+
if (actual.undecided) return undecidedResult(actual, alternative);
|
|
117
131
|
return { ok: position === leaves.length - 1 };
|
|
118
132
|
}
|
|
119
133
|
position++;
|
|
120
134
|
}
|
|
121
135
|
|
|
122
|
-
const ended = position >= actual.solutions.length && actual.error == null;
|
|
136
|
+
const ended = position >= actual.solutions.length && actual.error == null && !actual.undecided;
|
|
137
|
+
if (!ended && actual.undecided && position >= actual.solutions.length && actual.error == null) {
|
|
138
|
+
return undecidedResult(actual, alternative);
|
|
139
|
+
}
|
|
123
140
|
return { ok: ended };
|
|
124
141
|
}
|
|
125
142
|
|
|
@@ -184,9 +201,8 @@ function describeLeaf(term) {
|
|
|
184
201
|
continue;
|
|
185
202
|
}
|
|
186
203
|
if (item.type === COMPOUND && item.name === 'outputs' && item.arity === 1) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
else leaf.output = text;
|
|
204
|
+
if (leaf.output != null) leaf.malformed ??= item;
|
|
205
|
+
else leaf.output = item.args[0];
|
|
190
206
|
continue;
|
|
191
207
|
}
|
|
192
208
|
if (item.type === COMPOUND && item.name === 'peeks' && item.arity === 1) {
|
|
@@ -208,7 +224,9 @@ function executeQuery(program, query, input, maxSolutions, options) {
|
|
|
208
224
|
...options,
|
|
209
225
|
registry: options.registry ?? getEyePrologRegistry(),
|
|
210
226
|
maxDepth: options.detectLoops ? (options.loopMaxDepth ?? 1000) : options.maxDepth,
|
|
211
|
-
maxInferences: options.detectLoops
|
|
227
|
+
maxInferences: options.detectLoops
|
|
228
|
+
? (options.loopMaxInferences ?? DEFAULT_LOOP_MAX_INFERENCES)
|
|
229
|
+
: (options.quadMaxInferences ?? options.maxInferences ?? DEFAULT_QUAD_MAX_INFERENCES),
|
|
212
230
|
// The solver's counter also observes completed nested searches (for
|
|
213
231
|
// example each arm of a DCG disjunction). Bound the public iterator here
|
|
214
232
|
// instead of letting those internal completions consume the quad's answer
|
|
@@ -242,29 +260,37 @@ function executeQuery(program, query, input, maxSolutions, options) {
|
|
|
242
260
|
error = { term: errorTerm(caught), output: pendingOutput };
|
|
243
261
|
}
|
|
244
262
|
const inputPosition = solver.io.resolve('user_input')?.position ?? 0;
|
|
263
|
+
const bounded = solver.depthLimitExceeded || solver.inferenceLimitExceeded;
|
|
264
|
+
const undecided = !options.detectLoops && (solver.recursionCycleDetected || bounded);
|
|
245
265
|
return {
|
|
246
266
|
solutions,
|
|
247
267
|
error,
|
|
248
268
|
tailOutput,
|
|
249
269
|
inputPosition,
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
loops:
|
|
270
|
+
// A loops expectation explicitly asks for bounded nontermination evidence.
|
|
271
|
+
// Other descriptions treat the same exhausted search budget as undecided:
|
|
272
|
+
// a timeout cannot establish finite failure or an exact answer sequence.
|
|
273
|
+
loops: options.detectLoops && (solver.recursionCycleDetected || bounded),
|
|
274
|
+
undecided,
|
|
275
|
+
undecidedReason: solver.inferenceLimitExceeded ? 'inference limit reached'
|
|
276
|
+
: solver.depthLimitExceeded ? 'depth limit reached'
|
|
277
|
+
: solver.recursionCycleDetected ? 'recursion cycle encountered'
|
|
278
|
+
: null,
|
|
254
279
|
};
|
|
255
280
|
}
|
|
256
281
|
|
|
257
|
-
function matchLeaf(query, leaf, actual, position) {
|
|
282
|
+
function matchLeaf(program, query, leaf, actual, position) {
|
|
258
283
|
if (leaf.loops) return actual.loops;
|
|
259
284
|
if (leaf.false) {
|
|
260
|
-
return position >= actual.solutions.length && actual.error == null &&
|
|
285
|
+
return position >= actual.solutions.length && actual.error == null && !actual.undecided &&
|
|
286
|
+
outputMatches(program, leaf.output, actual.tailOutput);
|
|
261
287
|
}
|
|
262
288
|
if (leaf.error != null) {
|
|
263
289
|
return position === actual.solutions.length && actual.error != null &&
|
|
264
|
-
errorMatches(query, leaf.error, actual.error.term) && outputMatches(leaf.output, actual.error.output);
|
|
290
|
+
errorMatches(query, leaf.error, actual.error.term) && outputMatches(program, leaf.output, actual.error.output);
|
|
265
291
|
}
|
|
266
292
|
const solution = actual.solutions[position];
|
|
267
|
-
if (!solution || !outputMatches(leaf.output, solution.output)) return false;
|
|
293
|
+
if (!solution || !outputMatches(program, leaf.output, solution.output)) return false;
|
|
268
294
|
return substitutionMatches(query, leaf.bindings, solution.env);
|
|
269
295
|
}
|
|
270
296
|
|
|
@@ -391,10 +417,162 @@ function characterText(term) {
|
|
|
391
417
|
return text;
|
|
392
418
|
}
|
|
393
419
|
|
|
394
|
-
function outputMatches(expected, actual) {
|
|
395
|
-
|
|
420
|
+
function outputMatches(program, expected, actual) {
|
|
421
|
+
if (expected == null) return true;
|
|
422
|
+
|
|
423
|
+
// Preserve the original exact character-list/code-list shorthand first.
|
|
424
|
+
const exactText = characterText(expected);
|
|
425
|
+
if (exactText != null && exactText === actual) return true;
|
|
426
|
+
|
|
427
|
+
// Trealla's portable quad convention also permits the captured character
|
|
428
|
+
// sequence to unify directly with outputs/1's argument before trying it as a
|
|
429
|
+
// DCG body. This makes outputs(Cs) and partially instantiated character lists
|
|
430
|
+
// useful without weakening the DCG interpretation.
|
|
431
|
+
const actualList = listFromItems(Array.from(actual, (character) => atom(character)));
|
|
432
|
+
if (unify(expected, actualList, new Env())) return true;
|
|
433
|
+
|
|
434
|
+
return outputDcgMatches(program, expected, actual);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function outputDcgMatches(program, body, actual) {
|
|
438
|
+
const characters = Array.from(actual);
|
|
439
|
+
for (const state of matchOutputDcg(program, body, characters, 0, new Env())) {
|
|
440
|
+
if (state.position === characters.length) return true;
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
396
443
|
}
|
|
397
444
|
|
|
445
|
+
function* matchOutputDcg(program, body, characters, position, env) {
|
|
446
|
+
body = deref(body, env);
|
|
447
|
+
|
|
448
|
+
if (body.type === ATOM && (body.name === '...' || body.name === 'ad_infinitum')) {
|
|
449
|
+
for (let next = position; next <= characters.length; next++) {
|
|
450
|
+
yield { position: next, env: env.clone() };
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (body.type === ATOM && body.name === '[]') {
|
|
456
|
+
yield { position, env };
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (body.type === COMPOUND && body.name === '.' && body.arity === 2) {
|
|
461
|
+
const items = properListItems(body, env);
|
|
462
|
+
if (items == null) return;
|
|
463
|
+
let states = [{ position, env }];
|
|
464
|
+
for (const item of items) {
|
|
465
|
+
const nextStates = [];
|
|
466
|
+
for (const state of states) {
|
|
467
|
+
if (state.position >= characters.length) continue;
|
|
468
|
+
const expected = outputTerminalTerm(item, state.env);
|
|
469
|
+
if (expected == null) continue;
|
|
470
|
+
const nextEnv = state.env.clone();
|
|
471
|
+
if (unify(expected, atom(characters[state.position]), nextEnv)) {
|
|
472
|
+
nextStates.push({ position: state.position + 1, env: nextEnv });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
states = nextStates;
|
|
476
|
+
if (states.length === 0) return;
|
|
477
|
+
}
|
|
478
|
+
yield* states;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (body.type === COMPOUND && body.name === ',' && body.arity === 2) {
|
|
483
|
+
for (const left of matchOutputDcg(program, body.args[0], characters, position, env)) {
|
|
484
|
+
yield* matchOutputDcg(program, body.args[1], characters, left.position, left.env);
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (body.type === COMPOUND && [';', '|'].includes(body.name) && body.arity === 2) {
|
|
490
|
+
yield* matchOutputDcg(program, body.args[0], characters, position, env.clone());
|
|
491
|
+
yield* matchOutputDcg(program, body.args[1], characters, position, env.clone());
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if ((body.type === ATOM && ['!', '{}'].includes(body.name)) ||
|
|
496
|
+
(body.type === COMPOUND && body.name === '{}' && body.arity === 1 &&
|
|
497
|
+
body.args[0].type === ATOM && body.args[0].name === 'true')) {
|
|
498
|
+
yield { position, env };
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (body.type === COMPOUND && body.name === '{}' && body.arity === 1 &&
|
|
503
|
+
body.args[0].type === ATOM && body.args[0].name === 'fail') return;
|
|
504
|
+
|
|
505
|
+
// A nonterminal in an outputs/1 DCG body is interpreted against the same
|
|
506
|
+
// program as the query. Ask its expanded /2 relation how much of the
|
|
507
|
+
// remaining captured character list it consumes. This covers user-defined
|
|
508
|
+
// DCGs while the structural cases above handle terminals and ... without
|
|
509
|
+
// requiring a harness-only library import.
|
|
510
|
+
if (body.type === ATOM || body.type === COMPOUND) {
|
|
511
|
+
yield* matchOutputNonterminal(program, body, characters, position, env);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let outputDcgFresh = 0;
|
|
516
|
+
function* matchOutputNonterminal(program, body, characters, position, env) {
|
|
517
|
+
const input = listFromItems(characters.slice(position).map((character) => atom(character)));
|
|
518
|
+
const output = variable(`\u0000quad-output:${++outputDcgFresh}`);
|
|
519
|
+
let goal;
|
|
520
|
+
if (body.type === ATOM) goal = compound(body.name, [input, output]);
|
|
521
|
+
else goal = compound(body.name, [...body.args, input, output]);
|
|
522
|
+
goal.module = body.module ?? 'user';
|
|
523
|
+
|
|
524
|
+
const solver = new Solver(program, {
|
|
525
|
+
registry: getEyePrologRegistry(),
|
|
526
|
+
maxInferences: DEFAULT_QUAD_MAX_INFERENCES,
|
|
527
|
+
solutionLimit: characters.length + 2,
|
|
528
|
+
ioOptions: { write: () => {} },
|
|
529
|
+
});
|
|
530
|
+
try {
|
|
531
|
+
for (const solutionEnv of solver.solve([goal], env.clone(), 0)) {
|
|
532
|
+
const tail = characterText(deref(output, solutionEnv));
|
|
533
|
+
if (tail == null) continue;
|
|
534
|
+
const remaining = characters.slice(position).join('');
|
|
535
|
+
if (!remaining.endsWith(tail)) continue;
|
|
536
|
+
yield {
|
|
537
|
+
position: characters.length - Array.from(tail).length,
|
|
538
|
+
env: solutionEnv,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
} catch (_) {
|
|
542
|
+
// A DCG body that raises while being used as an output matcher simply does
|
|
543
|
+
// not match, mirroring catch(phrase(Expected, Cs), _, fail).
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function outputTerminalTerm(term, env) {
|
|
548
|
+
term = deref(term, env);
|
|
549
|
+
if (term.type === 'number' && /^\d+$/.test(term.name)) {
|
|
550
|
+
const code = Number(term.name);
|
|
551
|
+
if (!Number.isSafeInteger(code) || code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
return atom(String.fromCodePoint(code));
|
|
555
|
+
}
|
|
556
|
+
return term;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function leafNeedsMoreSearch(leaf, actual, position) {
|
|
560
|
+
if (!actual.undecided) return false;
|
|
561
|
+
if (leaf.false) return true;
|
|
562
|
+
if (leaf.error != null) return actual.error == null && position >= actual.solutions.length;
|
|
563
|
+
return position >= actual.solutions.length;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function undecidedResult(actual, expected) {
|
|
567
|
+
return {
|
|
568
|
+
ok: false,
|
|
569
|
+
kind: 'undecided',
|
|
570
|
+
expected,
|
|
571
|
+
reason: actual.undecidedReason ?? 'search budget exhausted',
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
398
576
|
function splitOperator(term, name) {
|
|
399
577
|
if (term.type === COMPOUND && term.name === name && term.arity === 2) {
|
|
400
578
|
return [term.args[0], ...splitOperator(term.args[1], name)];
|
|
@@ -408,11 +586,14 @@ function formatFailure(program, quad, result, description = quad.answers[0]) {
|
|
|
408
586
|
const reason = result.kind === 'malformed' ? 'MALFORMED'
|
|
409
587
|
: result.kind === 'bad_identifier' ? 'BAD_ID'
|
|
410
588
|
: result.kind === 'unsupported' ? 'UNSUPPORTED'
|
|
411
|
-
: '
|
|
589
|
+
: result.kind === 'undecided' ? 'UNDECIDED'
|
|
590
|
+
: 'FAILED';
|
|
412
591
|
const expected = result.expected ?? description;
|
|
592
|
+
const detail = result.kind === 'undecided'
|
|
593
|
+
? ` undecided: ${result.reason}.\n`
|
|
594
|
+
: ` expected: ${formatQuadTerm(program, expected)}.\n`;
|
|
413
595
|
return `quads: ${reason} ${label}${source.filename}:${source.line}\n` +
|
|
414
|
-
` ?- ${formatQuadTerm(program, quad.query)}.\n` +
|
|
415
|
-
` expected: ${formatQuadTerm(program, expected)}.\n`;
|
|
596
|
+
` ?- ${formatQuadTerm(program, quad.query)}.\n` + detail;
|
|
416
597
|
}
|
|
417
598
|
|
|
418
599
|
function formatQuadTerm(program, term) {
|
package/src/repl.js
CHANGED
|
@@ -18,6 +18,8 @@ RETURN or ".": stop enumeration
|
|
|
18
18
|
"p": print terms with depth limit
|
|
19
19
|
`;
|
|
20
20
|
|
|
21
|
+
const SCRIPTED_NEXT_QUERY = Symbol('scripted-next-query');
|
|
22
|
+
|
|
21
23
|
export async function runRepl(engine, options = {}) {
|
|
22
24
|
const input = options.input ?? process.stdin;
|
|
23
25
|
const output = options.output ?? process.stdout;
|
|
@@ -102,6 +104,7 @@ class LineReader {
|
|
|
102
104
|
this.output = output;
|
|
103
105
|
this.terminal = Boolean(input.isTTY && output.isTTY && typeof input.setRawMode === 'function');
|
|
104
106
|
this.history = [];
|
|
107
|
+
this.pendingLines = [];
|
|
105
108
|
this.currentPrompt = '?- ';
|
|
106
109
|
this.open();
|
|
107
110
|
}
|
|
@@ -119,16 +122,30 @@ class LineReader {
|
|
|
119
122
|
this.lines = this.readline[Symbol.asyncIterator]();
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
async nextLine() {
|
|
126
|
+
if (this.pendingLines.length > 0) return { done: false, value: this.pendingLines.shift() };
|
|
127
|
+
return this.lines.next();
|
|
128
|
+
}
|
|
129
|
+
|
|
122
130
|
async read(prompt) {
|
|
123
131
|
this.currentPrompt = prompt;
|
|
124
132
|
this.readline.setPrompt(prompt);
|
|
125
133
|
this.output.write(prompt);
|
|
126
|
-
const result = await this.
|
|
134
|
+
const result = await this.nextLine();
|
|
127
135
|
return result.done ? null : result.value;
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
async readControl(prompt) {
|
|
131
|
-
if (!this.terminal)
|
|
139
|
+
if (!this.terminal) {
|
|
140
|
+
const result = await this.nextLine();
|
|
141
|
+
if (result.done) return null;
|
|
142
|
+
if (!isScriptedAnswerControl(result.value)) {
|
|
143
|
+
this.pendingLines.unshift(result.value);
|
|
144
|
+
return SCRIPTED_NEXT_QUERY;
|
|
145
|
+
}
|
|
146
|
+
this.output.write(prompt);
|
|
147
|
+
return result.value;
|
|
148
|
+
}
|
|
132
149
|
this.output.write(prompt);
|
|
133
150
|
this.history = [...this.readline.history];
|
|
134
151
|
this.currentPrompt = '?- ';
|
|
@@ -250,6 +267,12 @@ class LineReader {
|
|
|
250
267
|
}
|
|
251
268
|
}
|
|
252
269
|
|
|
270
|
+
function isScriptedAnswerControl(line) {
|
|
271
|
+
if (line == null || line === '' || line === '\r' || line === '\n' || line === ' ') return true;
|
|
272
|
+
const control = line.trim();
|
|
273
|
+
return control.startsWith('.') || [';', 'n', 'a', 'f', 'w', 'p', 'h'].includes(control);
|
|
274
|
+
}
|
|
275
|
+
|
|
253
276
|
function runWithTerminalSignals(reader, operation) {
|
|
254
277
|
const suspended = reader.suspendForComputation();
|
|
255
278
|
try {
|
|
@@ -541,7 +564,6 @@ async function readSource(designation) {
|
|
|
541
564
|
async function solveQuery(engine, state, goal, reader, output) {
|
|
542
565
|
const variables = queryVariables(goal);
|
|
543
566
|
const solver = state.solver;
|
|
544
|
-
const demandDriven = containsTimedGoal(goal);
|
|
545
567
|
solver.solutionsSeen = 0;
|
|
546
568
|
const solutions = solver.solve([goal], new engine.Env(), 0);
|
|
547
569
|
let current = pullSolution(solver, solutions, reader);
|
|
@@ -560,12 +582,11 @@ async function solveQuery(engine, state, goal, reader, output) {
|
|
|
560
582
|
let firstAnswer = true;
|
|
561
583
|
let formattingAfterAdvance = false;
|
|
562
584
|
while (!current.result.done) {
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
const next = demandDriven ? null : pullSolution(solver, solutions, reader);
|
|
585
|
+
// Enumeration is demand-driven: never execute search for a future answer
|
|
586
|
+
// merely to decide how to punctuate the current one. That search may have
|
|
587
|
+
// side effects, and it belongs only to an explicit request for another
|
|
588
|
+
// answer. A scripted non-TTY session may start its next query directly;
|
|
589
|
+
// LineReader treats that as an implicit stop without consuming the query.
|
|
569
590
|
if (formattingAfterAdvance) output.write(' ');
|
|
570
591
|
formattingAfterAdvance = false;
|
|
571
592
|
output.write(current.output);
|
|
@@ -574,7 +595,11 @@ async function solveQuery(engine, state, goal, reader, output) {
|
|
|
574
595
|
answersShown++;
|
|
575
596
|
firstAnswer = false;
|
|
576
597
|
|
|
577
|
-
if (
|
|
598
|
+
if (!solver.hasPendingAlternatives()) {
|
|
599
|
+
// The solver is suspended at the yielded answer even though no work is
|
|
600
|
+
// left. Close the generator to run its cleanup/finally blocks without
|
|
601
|
+
// advancing search or executing future side effects.
|
|
602
|
+
if (typeof solutions.return === 'function') solutions.return();
|
|
578
603
|
output.write(`${continuesGraphicToken(answer, answer.length) ? ' ' : ''}.\n`);
|
|
579
604
|
return null;
|
|
580
605
|
}
|
|
@@ -586,6 +611,11 @@ async function solveQuery(engine, state, goal, reader, output) {
|
|
|
586
611
|
} else {
|
|
587
612
|
while (true) {
|
|
588
613
|
const controlLine = await reader.readControl('\n;');
|
|
614
|
+
if (controlLine === SCRIPTED_NEXT_QUERY) {
|
|
615
|
+
if (typeof solutions.return === 'function') solutions.return();
|
|
616
|
+
output.write(`${continuesGraphicToken(answer, answer.length) ? ' ' : ''}.\n`);
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
589
619
|
if (controlLine == null || controlLine === '' || controlLine === '\r' || controlLine === '\n' ||
|
|
590
620
|
controlLine.trimStart().startsWith('.')) {
|
|
591
621
|
if (typeof solutions.return === 'function') solutions.return();
|
|
@@ -618,52 +648,29 @@ async function solveQuery(engine, state, goal, reader, output) {
|
|
|
618
648
|
formattingAfterAdvance = true;
|
|
619
649
|
}
|
|
620
650
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
throw requested.error;
|
|
633
|
-
}
|
|
634
|
-
if (requested.result.done) {
|
|
635
|
-
if (formattingAfterAdvance) output.write(' ');
|
|
636
|
-
formattingAfterAdvance = false;
|
|
637
|
-
output.write(`${requested.output}false.\n`);
|
|
638
|
-
return null;
|
|
639
|
-
}
|
|
640
|
-
current = requested;
|
|
641
|
-
continue;
|
|
651
|
+
// Drop the displayed substitution before resuming search. The next search
|
|
652
|
+
// step, including any side effects, happens only after the user asked for
|
|
653
|
+
// another answer (or selected automatic enumeration).
|
|
654
|
+
current = null;
|
|
655
|
+
const requested = pullSolution(solver, solutions, reader);
|
|
656
|
+
if (requested.error) {
|
|
657
|
+
if (formattingAfterAdvance) output.write(' ');
|
|
658
|
+
formattingAfterAdvance = false;
|
|
659
|
+
output.write(requested.output);
|
|
660
|
+
if (requested.error?.name === 'HaltSignal') return { halted: true, code: requested.error.code };
|
|
661
|
+
throw requested.error;
|
|
642
662
|
}
|
|
643
|
-
|
|
644
|
-
if (next.error) {
|
|
663
|
+
if (requested.result.done) {
|
|
645
664
|
if (formattingAfterAdvance) output.write(' ');
|
|
646
665
|
formattingAfterAdvance = false;
|
|
647
|
-
output.write(
|
|
648
|
-
|
|
649
|
-
throw next.error;
|
|
666
|
+
output.write(`${requested.output}false.\n`);
|
|
667
|
+
return null;
|
|
650
668
|
}
|
|
651
|
-
current =
|
|
669
|
+
current = requested;
|
|
652
670
|
}
|
|
653
671
|
return null;
|
|
654
672
|
}
|
|
655
673
|
|
|
656
|
-
function containsTimedGoal(goal) {
|
|
657
|
-
const stack = [goal];
|
|
658
|
-
while (stack.length !== 0) {
|
|
659
|
-
const term = stack.pop();
|
|
660
|
-
if (term?.type !== 'compound') continue;
|
|
661
|
-
if (term.name === 'time' && term.arity === 1) return true;
|
|
662
|
-
for (let index = term.args.length - 1; index >= 0; index--) stack.push(term.args[index]);
|
|
663
|
-
}
|
|
664
|
-
return false;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
674
|
function pullSolution(solver, solutions, reader) {
|
|
668
675
|
const stream = solver.io.resolve('user_output');
|
|
669
676
|
const originalWrite = stream?.write;
|
package/src/solver.js
CHANGED
|
@@ -679,7 +679,7 @@ export class Solver {
|
|
|
679
679
|
|
|
680
680
|
hasPendingAlternatives() {
|
|
681
681
|
// When solve() is suspended at an answer, active solve stacks contain only
|
|
682
|
-
// unexplored work. The
|
|
682
|
+
// unexplored work. The demand-driven REPL uses this without speculatively
|
|
683
683
|
// pulling the next answer.
|
|
684
684
|
return this.solveStacks.some((stack) => stack.length !== 0);
|
|
685
685
|
}
|
package/test/run-regression.mjs
CHANGED
|
@@ -1333,6 +1333,53 @@ c4 ?- call((!;1)).
|
|
|
1333
1333
|
assertEqual(result.stdout, 'quads: 1 run, 1 passed, 0 failed.\n', 'quad report');
|
|
1334
1334
|
},
|
|
1335
1335
|
},
|
|
1336
|
+
{
|
|
1337
|
+
name: 'quad search-budget exhaustion is undecided rather than loops or failure (issue #58)',
|
|
1338
|
+
run: () => {
|
|
1339
|
+
const result = runCli(['--quads', '-'], {
|
|
1340
|
+
input:
|
|
1341
|
+
'24,passes/too_expensive\n' +
|
|
1342
|
+
'?- N is 10^9, between(1,N,I), I = 1.\n' +
|
|
1343
|
+
' N = ..., I = 1\n' +
|
|
1344
|
+
'; false.\n',
|
|
1345
|
+
timeout: 5000,
|
|
1346
|
+
});
|
|
1347
|
+
if (result.error) throw result.error;
|
|
1348
|
+
assertEqual(result.status, 2, 'undecided exit status');
|
|
1349
|
+
assertIncludes(result.stdout,
|
|
1350
|
+
'quads: UNDECIDED 24, passes / too_expensive, <stdin>:1',
|
|
1351
|
+
'undecided diagnostic');
|
|
1352
|
+
assertIncludes(result.stdout, 'undecided: inference limit reached.', 'undecided reason');
|
|
1353
|
+
assertIncludes(result.stdout,
|
|
1354
|
+
'quads: 1 run, 0 passed, 0 failed, 1 undecided.',
|
|
1355
|
+
'undecided summary');
|
|
1356
|
+
assertEqual(result.stderr, '', 'stderr');
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
name: 'outputs/1 accepts DCG bodies over captured characters (issue #59)',
|
|
1361
|
+
run: () => {
|
|
1362
|
+
const source = [
|
|
1363
|
+
'pair --> "_", "A".',
|
|
1364
|
+
'22',
|
|
1365
|
+
"?- write('_A').",
|
|
1366
|
+
' outputs("_A").',
|
|
1367
|
+
' outputs("_"), unexpected.',
|
|
1368
|
+
' outputs(("_","A")).',
|
|
1369
|
+
' outputs(("_",...,"A")).',
|
|
1370
|
+
' outputs(("_",...,"B")), unexpected.',
|
|
1371
|
+
' outputs(("_",[_],[_])), unexpected.',
|
|
1372
|
+
' outputs(pair).',
|
|
1373
|
+
'',
|
|
1374
|
+
].join('\n');
|
|
1375
|
+
const result = publicApi.runQuads(source);
|
|
1376
|
+
assertEqual(result.total, 7, 'quad total');
|
|
1377
|
+
assertEqual(result.passed, 7, 'quad passed');
|
|
1378
|
+
assertEqual(result.failed, 0, 'quad failed');
|
|
1379
|
+
assertEqual(result.undecided, 0, 'quad undecided');
|
|
1380
|
+
assertEqual(result.stdout, 'quads: 7 run, 7 passed, 0 failed.\n', 'quad report');
|
|
1381
|
+
},
|
|
1382
|
+
},
|
|
1336
1383
|
{
|
|
1337
1384
|
name: '--quads runs embedded tests and reports failures through exit status',
|
|
1338
1385
|
run: () => {
|
|
@@ -1675,6 +1722,52 @@ c4 ?- call((!;1)).
|
|
|
1675
1722
|
assertEqual(result.stderr, '', 'stderr');
|
|
1676
1723
|
},
|
|
1677
1724
|
},
|
|
1725
|
+
{
|
|
1726
|
+
name: 'REPL does not precompute an unrequested future alternative (issue #48)',
|
|
1727
|
+
run: () => {
|
|
1728
|
+
const result = runCli([], {
|
|
1729
|
+
input: '(X = first; (repeat, fail)).\nhalt.\n',
|
|
1730
|
+
timeout: 2000,
|
|
1731
|
+
});
|
|
1732
|
+
if (result.error) throw result.error;
|
|
1733
|
+
assertEqual(result.status, 0, 'exit status');
|
|
1734
|
+
assertEqual(result.stdout, '?- X = first.\n?- ', 'first answer is immediate');
|
|
1735
|
+
assertEqual(result.stderr, '', 'stderr');
|
|
1736
|
+
},
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
name: 'REPL executes future side effects only after another answer is requested (issue #48)',
|
|
1740
|
+
run: () => {
|
|
1741
|
+
const stopped = runCli([], {
|
|
1742
|
+
input:
|
|
1743
|
+
'(X = first; (assertz(issue48_seen), X = second)).\n' +
|
|
1744
|
+
'current_predicate(issue48_seen/0).\n' +
|
|
1745
|
+
'halt.\n',
|
|
1746
|
+
});
|
|
1747
|
+
assertEqual(stopped.status, 0, 'stopped status');
|
|
1748
|
+
assertEqual(
|
|
1749
|
+
stopped.stdout,
|
|
1750
|
+
'?- X = first.\n?- false.\n?- ',
|
|
1751
|
+
'unrequested branch has no side effect',
|
|
1752
|
+
);
|
|
1753
|
+
|
|
1754
|
+
const advanced = runCli([], {
|
|
1755
|
+
input:
|
|
1756
|
+
'(X = first; (assertz(issue48_seen), X = second)).\n' +
|
|
1757
|
+
';\n' +
|
|
1758
|
+
'current_predicate(issue48_seen/0).\n' +
|
|
1759
|
+
'halt.\n',
|
|
1760
|
+
});
|
|
1761
|
+
assertEqual(advanced.status, 0, 'advanced status');
|
|
1762
|
+
assertEqual(
|
|
1763
|
+
advanced.stdout,
|
|
1764
|
+
'?- X = first\n; X = second.\n?- true.\n?- ',
|
|
1765
|
+
'requested branch performs its side effect',
|
|
1766
|
+
);
|
|
1767
|
+
assertEqual(stopped.stderr, '', 'stopped stderr');
|
|
1768
|
+
assertEqual(advanced.stderr, '', 'advanced stderr');
|
|
1769
|
+
},
|
|
1770
|
+
},
|
|
1678
1771
|
{
|
|
1679
1772
|
name: 'REPL bindings use argument syntax for operator atoms',
|
|
1680
1773
|
run: () => {
|
|
@@ -1707,7 +1800,7 @@ c4 ?- call((!;1)).
|
|
|
1707
1800
|
child.stdout.on('data', (text) => {
|
|
1708
1801
|
stdout += text;
|
|
1709
1802
|
if (stdout.endsWith('?- ')) sawQueryComputingPrompt = true;
|
|
1710
|
-
if (stdout.
|
|
1803
|
+
if (stdout.includes('\\n; ')) sawComputingPrompt = true;
|
|
1711
1804
|
});
|
|
1712
1805
|
child.stderr.on('data', (text) => { stderr += text; });
|
|
1713
1806
|
|
|
@@ -1728,10 +1821,10 @@ c4 ?- call((!;1)).
|
|
|
1728
1821
|
await waitFor(() => sawQueryComputingPrompt, 'query computing prompt');
|
|
1729
1822
|
await waitFor(() => stdout.endsWith(' false.\\n?- '), 'query result');
|
|
1730
1823
|
child.stdin.write('(N = 0; N = 1; (call_nth(repeat, 100000), N = 2)).\\n');
|
|
1731
|
-
await waitFor(() => stdout.endsWith(' N = 0
|
|
1824
|
+
await waitFor(() => stdout.endsWith(' N = 0'), 'first answer');
|
|
1732
1825
|
child.stdin.write(';\\n');
|
|
1733
1826
|
await waitFor(() => sawComputingPrompt, 'computing prompt');
|
|
1734
|
-
await waitFor(() => stdout.endsWith('; N = 1
|
|
1827
|
+
await waitFor(() => stdout.endsWith('; N = 1'), 'formatted answer');
|
|
1735
1828
|
child.stdin.write('\\n');
|
|
1736
1829
|
await waitFor(() => stdout.endsWith(' ... .\\n?- '), 'stopped enumeration');
|
|
1737
1830
|
child.stdin.write('halt.\\n');
|
package/the-art-of-eyeprolog.md
CHANGED
|
@@ -6645,8 +6645,16 @@ When another answer exists in an interactive terminal, press `;`, Space, or
|
|
|
6645
6645
|
enumeration, `a` enumerates all remaining answers, and `f` advances to the
|
|
6646
6646
|
next five-answer boundary (5, 10, 15, ... displayed leaf answers), regardless
|
|
6647
6647
|
of how many answers were stepped through individually beforehand. `h` displays
|
|
6648
|
-
the answer-control help.
|
|
6649
|
-
|
|
6648
|
+
the answer-control help. Enumeration is demand-driven: after an answer is
|
|
6649
|
+
found, the top level does not pull a successor merely to discover whether the
|
|
6650
|
+
current answer is the last one. Search for a later answer, including any side
|
|
6651
|
+
effects reached on that path, starts only after an answer-control command asks
|
|
6652
|
+
to continue. If an unresolved alternative ultimately has no solution, asking
|
|
6653
|
+
for it may therefore finish with `false.`. In scripted non-TTY input, a new
|
|
6654
|
+
query line implicitly stops the preceding answer enumeration without consuming
|
|
6655
|
+
the new query; explicit `;`, `n`, Space, `a`, or `f` still requests more
|
|
6656
|
+
answers. Once the top-level reader has accepted a complete query, the following
|
|
6657
|
+
line begins with two spaces to mark active execution; a
|
|
6650
6658
|
third space appears when its result is ready for formatting. The answer prompt
|
|
6651
6659
|
is `;` with no trailing space while it waits for input; after an advance
|
|
6652
6660
|
command, one space marks active search and a second marks an answer ready for
|
|
@@ -6812,9 +6820,11 @@ requires that argument to be ground; a non-ground label is reported as a quad
|
|
|
6812
6820
|
failure rather than aborting source parsing. Loading the file normally only
|
|
6813
6821
|
records its quads; it does not execute them or add their queries and answers as
|
|
6814
6822
|
program clauses. A quad run prints a summary and exits with status `1` when any
|
|
6815
|
-
description fails.
|
|
6816
|
-
|
|
6817
|
-
|
|
6823
|
+
description fails. If no description fails but a bounded search cannot decide
|
|
6824
|
+
an exact answer sequence, the case is reported separately as `UNDECIDED` and
|
|
6825
|
+
the CLI exits with status `2`. Quad mode imports `library(prologue)` as a
|
|
6826
|
+
compatibility prelude because the ISO Prolog working-example files use those
|
|
6827
|
+
predicates as system predicates without an explicit module directive.
|
|
6818
6828
|
|
|
6819
6829
|
Unless the source explicitly selects another `unknown` flag, quad execution
|
|
6820
6830
|
uses `unknown=error`, so an undefined predicate is reported rather than being
|
|
@@ -6831,12 +6841,19 @@ variable in the renamed exception term. `...` and `ad_infinitum` accept further
|
|
|
6831
6841
|
indented descriptions after one query are independent checks: each re-runs the
|
|
6832
6842
|
query, each is counted in the `quads:` summary, and a failing description does
|
|
6833
6843
|
not suppress later descriptions for that query. `inputs/1` supplies and checks
|
|
6834
|
-
consumed characters
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6844
|
+
consumed characters. `outputs/1` checks characters emitted while reaching the
|
|
6845
|
+
described answer or error, including output produced before a later exception.
|
|
6846
|
+
Its argument may be an exact character list/string or a DCG body: terminal
|
|
6847
|
+
sequences, conjunction/disjunction, `...`/`ad_infinitum` sequence wildcards,
|
|
6848
|
+
and user-defined DCG nonterminals are matched against the captured characters.
|
|
6849
|
+
`sto` marks an answer description that this finite-tree implementation skips.
|
|
6850
|
+
`loops` explicitly asks for bounded nontermination evidence and accepts direct
|
|
6851
|
+
active-variant cycle evidence from EyeProlog's normal recursion guard, with the
|
|
6852
|
+
loop depth/inference bounds as a fallback. Ordinary quad descriptions also have
|
|
6853
|
+
a finite inference budget (100000 by default); exhausting it does **not** mean
|
|
6854
|
+
`loops` or `false`, but produces an `UNDECIDED` result. The JavaScript API may
|
|
6855
|
+
override this with `quadMaxInferences`, while `loopMaxDepth` and
|
|
6856
|
+
`loopMaxInferences` control the explicit `loops` probe. The advanced stream
|
|
6840
6857
|
annotations `peeks/1` and `waits`, and the unordered `other_answer_sequence`
|
|
6841
6858
|
annotation, are not executed by the current runner.
|
|
6842
6859
|
|
|
@@ -6847,7 +6864,7 @@ import { Program, runQuads } from 'eyeprolog';
|
|
|
6847
6864
|
|
|
6848
6865
|
const program = Program.parse(source);
|
|
6849
6866
|
const report = runQuads(program);
|
|
6850
|
-
console.log(report.passed, report.failed, report.stdout);
|
|
6867
|
+
console.log(report.passed, report.failed, report.undecided, report.stdout);
|
|
6851
6868
|
```
|
|
6852
6869
|
|
|
6853
6870
|
The syntax follows the “queries using answer descriptions” convention used by
|