eyeprolog 1.3.33 → 1.3.35

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.33",
6
+ "version": "1.3.35",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/iso.js CHANGED
@@ -1939,7 +1939,13 @@ function validateControlCallable(term, culprit, env) {
1939
1939
  if (current.type !== COMPOUND || ![',', ';', '->'].includes(current.name) || current.arity !== 2) continue;
1940
1940
  for (let index = current.arity - 1; index >= 0; index--) {
1941
1941
  const argument = deref(current.args[index], env);
1942
- if (argument.type === VAR) throw new PrologError('instantiation_error');
1942
+ // A variable nested in a control construct is not an error at call/1
1943
+ // entry: an earlier goal may instantiate it before execution reaches
1944
+ // that position. If it is still unbound when selected, the solver then
1945
+ // raises instantiation_error at that point, after any preceding effects.
1946
+ // Non-variable non-callables are different: ISO call/1 validates those
1947
+ // eagerly and reports the whole control term as the culprit.
1948
+ if (argument.type === VAR) continue;
1943
1949
  if (argument.type !== ATOM && argument.type !== COMPOUND) {
1944
1950
  throw new PrologError('type_error(callable)', culprit);
1945
1951
  }
package/src/quads.js CHANGED
@@ -247,7 +247,10 @@ function executeQuery(program, query, input, maxSolutions, options) {
247
247
  error,
248
248
  tailOutput,
249
249
  inputPosition,
250
- loops: solver.depthLimitExceeded || solver.inferenceLimitExceeded,
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,
251
254
  };
252
255
  }
253
256
 
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
@@ -63,6 +63,10 @@ export class Solver {
63
63
  // limit accounting. time/1 snapshots this counter around the measured goal.
64
64
  this.inferenceObservation = options.inferenceObservation ?? { value: 0 };
65
65
  this.inferenceLimitExceeded = false;
66
+ // Set when the normal-profile recursion guard detects re-entry of an
67
+ // already active variant on the current search path. Quad `loops` checks
68
+ // use this structural evidence in addition to bounded resource probes.
69
+ this.recursionCycleDetected = false;
66
70
  this.maxMemoryBytes = options.maxMemoryBytes ?? softHeapLimit();
67
71
  this.memoryRecovery = options.memoryRecovery ?? {
68
72
  active: false,
@@ -261,6 +265,7 @@ export class Solver {
261
265
  if (!child || child === this || !child.stats) return;
262
266
  this.depthLimitExceeded ||= child.depthLimitExceeded;
263
267
  this.inferenceLimitExceeded ||= child.inferenceLimitExceeded;
268
+ this.recursionCycleDetected ||= child.recursionCycleDetected;
264
269
  for (const [key, value] of Object.entries(child.stats)) {
265
270
  if (key === 'max_depth' || key === 'max_goal_count') {
266
271
  this.stats[key] = Math.max(this.stats[key] ?? 0, value ?? 0);
@@ -674,7 +679,7 @@ export class Solver {
674
679
 
675
680
  hasPendingAlternatives() {
676
681
  // When solve() is suspended at an answer, active solve stacks contain only
677
- // unexplored work. The timed REPL path uses this without speculatively
682
+ // unexplored work. The demand-driven REPL uses this without speculatively
678
683
  // pulling the next answer.
679
684
  return this.solveStacks.some((stack) => stack.length !== 0);
680
685
  }
@@ -845,7 +850,10 @@ export class Solver {
845
850
  }
846
851
 
847
852
  *solveUserGoalUncached(group, goal, rest, env, depth) {
848
- if (group.recursive && !group.cutRecursive && !group.linearNumeric && this.activeVariant(goal, env)) return;
853
+ if (group.recursive && !group.cutRecursive && !group.linearNumeric && this.activeVariant(goal, env)) {
854
+ this.recursionCycleDetected = true;
855
+ return;
856
+ }
849
857
  // Program indexes provide candidate clauses, but every candidate is still
850
858
  // freshened and unified below. The index is a performance hint, not a
851
859
  // semantic shortcut.
@@ -1047,7 +1055,10 @@ function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solv
1047
1055
  }
1048
1056
 
1049
1057
  function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth, active) {
1050
- if (group.recursive && !group.cutRecursive && !group.linearNumeric && activeVariantIn(goal, env, active)) return;
1058
+ if (group.recursive && !group.cutRecursive && !group.linearNumeric && activeVariantIn(goal, env, active)) {
1059
+ solver.recursionCycleDetected = true;
1060
+ return;
1061
+ }
1051
1062
  if (group.fastPi && pushFastPiFrames(stack, goal, rest, env, depth, active)) return;
1052
1063
  if (tryPushGroundScalarRuleFrame(stack, solver, group, goal, rest, env, depth, active)) return;
1053
1064
  if (tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth, active)) return;
@@ -1981,7 +1992,10 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
1981
1992
  }
1982
1993
 
1983
1994
  const goal = runGoals[state.index];
1984
- if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) continue;
1995
+ if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) {
1996
+ solver.recursionCycleDetected = true;
1997
+ continue;
1998
+ }
1985
1999
  solver.stats.solve_one_goal_calls++;
1986
2000
  const candidates = selectScalarFactCandidates(groups[state.index], goal, env, state.names, state.values);
1987
2001
  const nextStates = [];
@@ -2035,7 +2049,10 @@ function* scalarFactRunSolutions(solver, goals, groups, env, depth, active) {
2035
2049
  }
2036
2050
 
2037
2051
  const goal = goals[state.index];
2038
- if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) continue;
2052
+ if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) {
2053
+ solver.recursionCycleDetected = true;
2054
+ continue;
2055
+ }
2039
2056
  solver.stats.solve_one_goal_calls++;
2040
2057
  const candidates = selectScalarFactCandidates(groups[state.index], goal, env, state.names, state.values);
2041
2058
  const nextStates = [];
@@ -2205,7 +2222,11 @@ function tryPushCompactBinaryChainFrames(stack, solver, group, goal, rest, env,
2205
2222
  if (solver.solutionsSeen >= solver.solutionLimit) return true;
2206
2223
  solver.stats.max_depth = Math.max(solver.stats.max_depth, currentDepth);
2207
2224
  const seenSet = seen[secondType];
2208
- if (!seenSet || seenSet.has(secondName)) return true;
2225
+ if (!seenSet) return true;
2226
+ if (seenSet.has(secondName)) {
2227
+ solver.recursionCycleDetected = true;
2228
+ return true;
2229
+ }
2209
2230
  if (cache[secondType].has(secondName)) {
2210
2231
  rememberCompactChainSuccess(cache, seen);
2211
2232
  stack.push({ kind: 'goals', goals: rest, env, depth: depth + 1, active });
@@ -2326,8 +2347,14 @@ function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth,
2326
2347
  if (solver.solutionsSeen >= solver.solutionLimit) return true;
2327
2348
  solver.stats.max_depth = Math.max(solver.stats.max_depth, currentDepth);
2328
2349
  const key = groundChainKey(currentGoal);
2329
- if (seen.has(key)) return true;
2330
- if (activeVariantIn(currentGoal, currentEnv, active)) return true;
2350
+ if (seen.has(key)) {
2351
+ solver.recursionCycleDetected = true;
2352
+ return true;
2353
+ }
2354
+ if (activeVariantIn(currentGoal, currentEnv, active)) {
2355
+ solver.recursionCycleDetected = true;
2356
+ return true;
2357
+ }
2331
2358
  if (solver.groundChainSuccess.has(key)) {
2332
2359
  rememberGroundChainSuccess(solver, seen);
2333
2360
  stack.push({ kind: 'goals', goals: rest, env: baseEnv, depth: depth + 1, active });
@@ -1317,6 +1317,22 @@ c4 ?- call((!;1)).
1317
1317
  assertEqual(result.stdout, 'quads: 1 run, 1 passed, 0 failed.\n', 'quad report');
1318
1318
  },
1319
1319
  },
1320
+ {
1321
+ name: 'runQuads preserves output before a delayed call/1 instantiation error (issue #57)',
1322
+ run: () => {
1323
+ const result = publicApi.runQuads(`16, "7.8.3.4#9"\n?- call((write(3), X)).\n outputs("3"), instantiation_error.\n`);
1324
+ assertEqual(result.passed, 1, 'quad passed');
1325
+ assertEqual(result.stdout, 'quads: 1 run, 1 passed, 0 failed.\n', 'quad report');
1326
+ },
1327
+ },
1328
+ {
1329
+ name: 'runQuads recognizes recursion-guard cycle evidence as loops (issue #58)',
1330
+ run: () => {
1331
+ const result = publicApi.runQuads(`inf :- inf, inf.\n\n23\n?- inf.\n loops.\n`);
1332
+ assertEqual(result.passed, 1, 'quad passed');
1333
+ assertEqual(result.stdout, 'quads: 1 run, 1 passed, 0 failed.\n', 'quad report');
1334
+ },
1335
+ },
1320
1336
  {
1321
1337
  name: '--quads runs embedded tests and reports failures through exit status',
1322
1338
  run: () => {
@@ -1659,6 +1675,52 @@ c4 ?- call((!;1)).
1659
1675
  assertEqual(result.stderr, '', 'stderr');
1660
1676
  },
1661
1677
  },
1678
+ {
1679
+ name: 'REPL does not precompute an unrequested future alternative (issue #48)',
1680
+ run: () => {
1681
+ const result = runCli([], {
1682
+ input: '(X = first; (repeat, fail)).\nhalt.\n',
1683
+ timeout: 2000,
1684
+ });
1685
+ if (result.error) throw result.error;
1686
+ assertEqual(result.status, 0, 'exit status');
1687
+ assertEqual(result.stdout, '?- X = first.\n?- ', 'first answer is immediate');
1688
+ assertEqual(result.stderr, '', 'stderr');
1689
+ },
1690
+ },
1691
+ {
1692
+ name: 'REPL executes future side effects only after another answer is requested (issue #48)',
1693
+ run: () => {
1694
+ const stopped = runCli([], {
1695
+ input:
1696
+ '(X = first; (assertz(issue48_seen), X = second)).\n' +
1697
+ 'current_predicate(issue48_seen/0).\n' +
1698
+ 'halt.\n',
1699
+ });
1700
+ assertEqual(stopped.status, 0, 'stopped status');
1701
+ assertEqual(
1702
+ stopped.stdout,
1703
+ '?- X = first.\n?- false.\n?- ',
1704
+ 'unrequested branch has no side effect',
1705
+ );
1706
+
1707
+ const advanced = runCli([], {
1708
+ input:
1709
+ '(X = first; (assertz(issue48_seen), X = second)).\n' +
1710
+ ';\n' +
1711
+ 'current_predicate(issue48_seen/0).\n' +
1712
+ 'halt.\n',
1713
+ });
1714
+ assertEqual(advanced.status, 0, 'advanced status');
1715
+ assertEqual(
1716
+ advanced.stdout,
1717
+ '?- X = first\n; X = second.\n?- true.\n?- ',
1718
+ 'requested branch performs its side effect',
1719
+ );
1720
+ assertEqual(stopped.stderr, '', 'stopped stderr');
1721
+ assertEqual(advanced.stderr, '', 'advanced stderr');
1722
+ },
1723
+ },
1662
1724
  {
1663
1725
  name: 'REPL bindings use argument syntax for operator atoms',
1664
1726
  run: () => {
@@ -1691,7 +1753,7 @@ c4 ?- call((!;1)).
1691
1753
  child.stdout.on('data', (text) => {
1692
1754
  stdout += text;
1693
1755
  if (stdout.endsWith('?- ')) sawQueryComputingPrompt = true;
1694
- if (stdout.endsWith('\\n; ')) sawComputingPrompt = true;
1756
+ if (stdout.includes('\\n; ')) sawComputingPrompt = true;
1695
1757
  });
1696
1758
  child.stderr.on('data', (text) => { stderr += text; });
1697
1759
 
@@ -1712,10 +1774,10 @@ c4 ?- call((!;1)).
1712
1774
  await waitFor(() => sawQueryComputingPrompt, 'query computing prompt');
1713
1775
  await waitFor(() => stdout.endsWith(' false.\\n?- '), 'query result');
1714
1776
  child.stdin.write('(N = 0; N = 1; (call_nth(repeat, 100000), N = 2)).\\n');
1715
- await waitFor(() => stdout.endsWith(' N = 0\\n;'), 'waiting prompt');
1777
+ await waitFor(() => stdout.endsWith(' N = 0'), 'first answer');
1716
1778
  child.stdin.write(';\\n');
1717
1779
  await waitFor(() => sawComputingPrompt, 'computing prompt');
1718
- await waitFor(() => stdout.endsWith('; N = 1\\n;'), 'formatted answer');
1780
+ await waitFor(() => stdout.endsWith('; N = 1'), 'formatted answer');
1719
1781
  child.stdin.write('\\n');
1720
1782
  await waitFor(() => stdout.endsWith(' ... .\\n?- '), 'stopped enumeration');
1721
1783
  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
@@ -6831,9 +6839,12 @@ variable in the renamed exception term. `...` and `ad_infinitum` accept further
6831
6839
  indented descriptions after one query are independent checks: each re-runs the
6832
6840
  query, each is counted in the `quads:` summary, and a failing description does
6833
6841
  not suppress later descriptions for that query. `inputs/1` supplies and checks
6834
- consumed characters; `outputs/1` checks emitted characters. `sto` marks
6835
- an answer description that this finite-tree implementation skips. `loops` is
6836
- checked with a deterministic solver-depth budget. The advanced stream
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
6837
6848
  annotations `peeks/1` and `waits`, and the unordered `other_answer_sequence`
6838
6849
  annotation, are not executed by the current runner.
6839
6850