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 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?: EyePrologRunOptions & { initialize?: boolean }): EyePrologQuadRunResult;
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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.34",
6
+ "version": "1.3.36",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
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 failed = results.length - passed;
47
- lines.push(`quads: ${results.length} run, ${passed} passed, ${failed} failed.\n`);
48
- return { stdout: lines.join(''), total: results.length, passed, failed, results };
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) return { ok: false };
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
- const text = characterText(item.args[0]);
188
- if (text == null || leaf.output != null) leaf.malformed ??= item;
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 ? (options.loopMaxInferences ?? 10000) : options.maxInferences,
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
- // Accept direct structural evidence from EyeProlog's recursion guard as
251
- // well as bounded-search evidence. A detected active-variant cycle is
252
- // stronger evidence than merely reaching a timeout/inference ceiling.
253
- loops: solver.recursionCycleDetected || solver.depthLimitExceeded || solver.inferenceLimitExceeded,
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 && outputMatches(leaf.output, actual.tailOutput);
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
- return expected == null || expected === actual;
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
- : 'FAILED';
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.lines.next();
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) return this.read(prompt);
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
- // Ordinary queries keep the existing eager look-ahead so deterministic
564
- // answers can end with a full stop without showing an unnecessary answer
565
- // prompt. `time/1` is different: running a future solution changes what is
566
- // being measured and can retain a very large current substitution. Timed
567
- // queries therefore advance only after the user asks for another answer.
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 (demandDriven ? !solver.hasPendingAlternatives() : (!next.error && next.result.done)) {
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
- if (demandDriven) {
622
- // The displayed timed answer is no longer needed. Drop it before
623
- // resuming search so a large list substitution does not remain live only
624
- // because the top level is looking for its successor.
625
- current = null;
626
- const requested = pullSolution(solver, solutions, reader);
627
- if (requested.error) {
628
- if (formattingAfterAdvance) output.write(' ');
629
- formattingAfterAdvance = false;
630
- output.write(requested.output);
631
- if (requested.error?.name === 'HaltSignal') return { halted: true, code: requested.error.code };
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(next.output);
648
- if (next.error?.name === 'HaltSignal') return { halted: true, code: next.error.code };
649
- throw next.error;
666
+ output.write(`${requested.output}false.\n`);
667
+ return null;
650
668
  }
651
- current = next;
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 timed REPL path uses this without speculatively
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
  }
@@ -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.endsWith('\\n; ')) sawComputingPrompt = true;
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\\n;'), 'waiting prompt');
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\\n;'), 'formatted answer');
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');
@@ -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. Once the top-level reader has accepted a complete
6649
- query, the following line begins with two spaces to mark active execution; a
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. Quad mode imports `library(prologue)` as a compatibility
6816
- prelude because the ISO Prolog working-example files use those predicates as
6817
- system predicates without an explicit module directive.
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; `outputs/1` checks characters emitted while reaching
6835
- the described answer or error, including output produced before a later
6836
- exception. `sto` marks an answer description that this finite-tree
6837
- implementation skips. `loops` accepts direct active-variant cycle evidence from
6838
- EyeProlog's normal recursion guard, with bounded depth/inference exhaustion as a
6839
- fallback for loops that have no such structural witness. The advanced stream
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