eyeprolog 1.3.35 → 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.35",
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) {
@@ -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: () => {
@@ -6820,9 +6820,11 @@ requires that argument to be ground; a non-ground label is reported as a quad
6820
6820
  failure rather than aborting source parsing. Loading the file normally only
6821
6821
  records its quads; it does not execute them or add their queries and answers as
6822
6822
  program clauses. A quad run prints a summary and exits with status `1` when any
6823
- description fails. Quad mode imports `library(prologue)` as a compatibility
6824
- prelude because the ISO Prolog working-example files use those predicates as
6825
- 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.
6826
6828
 
6827
6829
  Unless the source explicitly selects another `unknown` flag, quad execution
6828
6830
  uses `unknown=error`, so an undefined predicate is reported rather than being
@@ -6839,12 +6841,19 @@ variable in the renamed exception term. `...` and `ad_infinitum` accept further
6839
6841
  indented descriptions after one query are independent checks: each re-runs the
6840
6842
  query, each is counted in the `quads:` summary, and a failing description does
6841
6843
  not suppress later descriptions for that query. `inputs/1` supplies and checks
6842
- consumed characters; `outputs/1` checks characters emitted while reaching
6843
- the described answer or error, including output produced before a later
6844
- exception. `sto` marks an answer description that this finite-tree
6845
- implementation skips. `loops` accepts direct active-variant cycle evidence from
6846
- EyeProlog's normal recursion guard, with bounded depth/inference exhaustion as a
6847
- 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
6848
6857
  annotations `peeks/1` and `waits`, and the unordered `other_answer_sequence`
6849
6858
  annotation, are not executed by the current runner.
6850
6859
 
@@ -6855,7 +6864,7 @@ import { Program, runQuads } from 'eyeprolog';
6855
6864
 
6856
6865
  const program = Program.parse(source);
6857
6866
  const report = runQuads(program);
6858
- console.log(report.passed, report.failed, report.stdout);
6867
+ console.log(report.passed, report.failed, report.undecided, report.stdout);
6859
6868
  ```
6860
6869
 
6861
6870
  The syntax follows the “queries using answer descriptions” convention used by