eyeprolog 1.3.84 → 1.3.86

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.84",
6
+ "version": "1.3.86",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/cleanup.js CHANGED
@@ -237,10 +237,11 @@ function cleanupProtectedIterator(solver, protectedGoal, cleanup, initialEnv) {
237
237
  let unwindToSetupBindings = false;
238
238
 
239
239
  const performCleanup = () => {
240
- if (cleaned) return;
240
+ if (cleaned) return cleanupEnv;
241
241
  // Mark first so a throwing Cleanup is still considered executed exactly once.
242
242
  cleaned = true;
243
- runCleanupOnce(solver, cleanup, cleanupEnv);
243
+ cleanupEnv = runCleanupOnce(solver, cleanup, cleanupEnv);
244
+ return cleanupEnv;
244
245
  };
245
246
 
246
247
  const iterator = (function* protectedWithCleanup() {
@@ -266,7 +267,7 @@ function cleanupProtectedIterator(solver, protectedGoal, cleanup, initialEnv) {
266
267
  performCleanup();
267
268
  }
268
269
 
269
- yield result.value;
270
+ yield pending ? result.value : cleanupEnv;
270
271
  if (!pending) return;
271
272
  }
272
273
  } catch (error) {
@@ -321,8 +322,9 @@ function runCleanupOnce(solver, cleanup, env) {
321
322
  solver.absorbStatsFrom(child);
322
323
  }
323
324
  }
324
- // Cleanup is semidet only as a control operation: failure is ignored, and
325
- // bindings made solely by Cleanup are intentionally not added to an answer
326
- // already produced by Goal.
327
- return !result.done;
325
+ // Cleanup failure is ignored. When Cleanup runs before a deterministic
326
+ // protected answer is yielded, however, its substitutions are part of that
327
+ // answer; callers that prune a pending answer have already observed it and
328
+ // therefore cannot receive later Cleanup substitutions.
329
+ return result.done ? env : result.value;
328
330
  }
package/src/iso.js CHANGED
@@ -137,8 +137,10 @@ export const isoBuiltins = {
137
137
  registry.add('number_chars', 2, numberCharsBuiltin, { deterministic: true });
138
138
  registry.add('number_codes', 2, numberCodesBuiltin, { deterministic: true });
139
139
 
140
- registry.add('call', 1, callBuiltin);
141
- for (let arity = 2; arity <= 8; arity++) registry.add('call', arity, callClosureBuiltin);
140
+ registry.add('call', 1, callBuiltin, { expandGoal: expandCallGoal });
141
+ for (let arity = 2; arity <= 8; arity++) {
142
+ registry.add('call', arity, callClosureBuiltin, { expandGoal: expandCallClosureGoal });
143
+ }
142
144
  registry.add('catch', 3, catchBuiltin);
143
145
  registry.add('throw', 1, throwBuiltin, { deterministic: true });
144
146
  registry.add('\\+', 1, negationBuiltin, { deterministic: true });
@@ -166,7 +168,7 @@ export const eyePrologLibraryBuiltins = {
166
168
  register(registry) {
167
169
  registry.add('eyeprolog__call_nth', 2, callNthBuiltin, { eyePrologLibrary: true });
168
170
  registry.add('eyeprolog__call_residue_vars', 2, callResidueVarsBuiltin, { eyePrologLibrary: true });
169
- registry.add('eyeprolog__countall', 2, countAllBuiltin, { eyePrologLibrary: true });
171
+ registry.add('eyeprolog__countall', 2, countAllBuiltin, { deterministic: true, eyePrologLibrary: true });
170
172
  registry.add('eyeprolog__freeze', 2, freezeBuiltin, { eyePrologLibrary: true });
171
173
  registry.add('dif', 2, difBuiltin, { deterministic: true, eyePrologLibrary: true });
172
174
  registry.add('time', 1, timeBuiltin, { eyePrologLibrary: true });
@@ -712,27 +714,43 @@ function assertBuiltin(atStart) {
712
714
  };
713
715
  }
714
716
 
715
- function* retractBuiltin({ solver, goal, env }) {
717
+ function retractBuiltin(context) {
718
+ const state = { pending: true };
719
+ const iterator = retractSolutions(context, state);
720
+ iterator.hasPendingAlternatives = () => state.pending;
721
+ return iterator;
722
+ }
723
+
724
+ function* retractSolutions({ solver, goal, env }, state) {
716
725
  const parts = clauseParts(goal.args[0], env);
717
726
  requireClauseHead(parts.head);
718
727
  const group = solver.program.findGroup(parts.head.name, parts.head.arity, parts.head.module ?? goal.module ?? 'user');
719
728
  if (isProcessorStaticProcedure(solver, parts.head) || (group && !group.dynamic)) {
720
729
  throw new PrologError('permission_error(modify, static_procedure)', procedureIndicator(parts.head));
721
730
  }
722
- if (!group) return;
731
+ if (!group) {
732
+ state.pending = false;
733
+ return;
734
+ }
723
735
  // ISO logical update view: this call keeps the clauses that were visible
724
736
  // when it began. A later retract/1 may erase one of those clauses from the
725
737
  // live procedure, but must not invalidate this call's pending alternatives.
726
- const candidates = [...group.clauses];
727
- for (const clause of candidates) {
738
+ const matches = [];
739
+ for (const clause of [...group.clauses]) {
728
740
  const copied = freshCopy(compound('$clause', [clause.head, clauseBodyTerm(clause.body)]), new Env());
729
741
  const next = env.clone();
730
742
  if (!unify(parts.head, copied.args[0], next)) continue;
731
743
  if (parts.rule && !unify(parts.body, copied.args[1], next)) continue;
732
744
  if (!parts.rule && !(copied.args[1].type === ATOM && copied.args[1].name === 'true')) continue;
745
+ matches.push({ clause, next });
746
+ }
747
+ for (let index = 0; index < matches.length; index++) {
748
+ const { clause, next } = matches[index];
733
749
  solver.program.removeDynamicClause(group, clause);
750
+ state.pending = index + 1 < matches.length;
734
751
  yield next;
735
752
  }
753
+ state.pending = false;
736
754
  }
737
755
 
738
756
  function* retractAllBuiltin({ solver, goal, env }) {
@@ -1555,6 +1573,24 @@ function scopeReadTerm(term) {
1555
1573
 
1556
1574
  function parseReadTermText(text, solver) {
1557
1575
  const converted = convertedTermText(text, solver);
1576
+ // A standalone number needs no general term parser or operator tables.
1577
+ // Reuse the same bounded numeric scanner as number_chars/2 so stream reads
1578
+ // and number conversion agree on every accepted numeric token. The stream
1579
+ // scanner has already established that the final full stop is a candidate
1580
+ // terminator; trim only ISO layout immediately before that terminator.
1581
+ const numericText = converted.slice(0, -1).replace(/[\u0009-\u000d\u0020]+$/, '');
1582
+ let numericTerm;
1583
+ try {
1584
+ numericTerm = parseIsoNumber(numericText);
1585
+ } catch (error) {
1586
+ // parseIsoNumber/1 exposes Prolog errors to number_chars/2. The stream
1587
+ // reader's candidate loop instead recognizes parser representation errors
1588
+ // and must not reinterpret overflow as a later syntax error.
1589
+ if (error instanceof PrologError) throw new NumberRepresentationError(error.formal);
1590
+ throw error;
1591
+ }
1592
+ if (numericTerm != null) return numericTerm;
1593
+
1558
1594
  const operatorState = createParserOperatorState(solver.program.operators.values(), false);
1559
1595
  return parseTermText(converted, {
1560
1596
  operatorState,
@@ -1720,6 +1756,15 @@ function writeOptionBoolean(value, env, option) {
1720
1756
  return value.name === 'true';
1721
1757
  }
1722
1758
 
1759
+ function writeOptionSpacing(value, env, option) {
1760
+ value = deref(value, env);
1761
+ if (value.type === VAR) throw new PrologError('instantiation_error');
1762
+ if (value.type !== ATOM || !['minimal', 'standard'].includes(value.name)) {
1763
+ throw new PrologError('domain_error(write_option)', copyResolved(option, env));
1764
+ }
1765
+ return value.name;
1766
+ }
1767
+
1723
1768
  function writeVariableNames(value, env, option) {
1724
1769
  value = deref(value, env);
1725
1770
  if (value.type === VAR) throw new PrologError('instantiation_error');
@@ -1760,6 +1805,8 @@ function termWriteOptionsFromItems(options, env, mode = 'write_term', solver = n
1760
1805
  else if (option.name === 'variable_names') result.variableNames = writeVariableNames(option.args[0], env, option);
1761
1806
  else if (option.name === 'double_quotes' && !solver?.isoStrict) {
1762
1807
  result.doubleQuotes = writeOptionBoolean(option.args[0], env, option);
1808
+ } else if (option.name === 'spacing' && !solver?.isoStrict) {
1809
+ result.minimalOperatorSpacing = writeOptionSpacing(option.args[0], env, option) === 'minimal';
1763
1810
  } else {
1764
1811
  throw new PrologError('domain_error(write_option)', copyResolved(option, env));
1765
1812
  }
@@ -2384,6 +2431,11 @@ function callable(term, env) {
2384
2431
  validateControlCallable(term, term, env);
2385
2432
  return term;
2386
2433
  }
2434
+
2435
+ function withPendingState(iterator, state) {
2436
+ iterator.hasPendingAlternatives = () => state.pending;
2437
+ return iterator;
2438
+ }
2387
2439
  function validateControlCallable(term, culprit, env) {
2388
2440
  // Only control constructs need their nested goals validated at meta-call
2389
2441
  // entry. Walk them iteratively and dereference each nested goal lazily so
@@ -2409,9 +2461,14 @@ function validateControlCallable(term, culprit, env) {
2409
2461
  }
2410
2462
  }
2411
2463
  }
2412
- function* callBuiltin({ solver, goal, env }) {
2464
+ function expandCallGoal({ goal, env }) {
2413
2465
  const invoked = callable(goal.args[0], env);
2414
2466
  if (invoked.module == null) invoked.module = goal.module ?? 'user';
2467
+ return invoked;
2468
+ }
2469
+ function* callBuiltin(context) {
2470
+ const { solver, env } = context;
2471
+ const invoked = expandCallGoal(context);
2415
2472
  const child = solver.cloneForInnerGoal();
2416
2473
  try {
2417
2474
  yield* child.solve([invoked], env, 0);
@@ -2419,7 +2476,7 @@ function* callBuiltin({ solver, goal, env }) {
2419
2476
  solver.absorbStatsFrom(child);
2420
2477
  }
2421
2478
  }
2422
- function* callClosureBuiltin({ solver, goal, env }) {
2479
+ function expandCallClosureGoal({ goal, env }) {
2423
2480
  const closure = callable(goal.args[0], env);
2424
2481
  const existing = closure.type === COMPOUND ? closure.args : [];
2425
2482
  const extra = goal.args.slice(1);
@@ -2428,6 +2485,11 @@ function* callClosureBuiltin({ solver, goal, env }) {
2428
2485
  }
2429
2486
  const invoked = compound(closure.name, [...existing, ...extra]);
2430
2487
  invoked.module = closure.module ?? goal.module ?? 'user';
2488
+ return invoked;
2489
+ }
2490
+ function* callClosureBuiltin(context) {
2491
+ const { solver, env } = context;
2492
+ const invoked = expandCallClosureGoal(context);
2431
2493
  const child = solver.cloneForInnerGoal();
2432
2494
  try {
2433
2495
  yield* child.solve([invoked], env, 0);
@@ -2480,7 +2542,12 @@ function writeElapsedTime(solver, startedAt, inferences) {
2480
2542
  );
2481
2543
  }
2482
2544
 
2483
- function* timeBuiltin({ solver, goal, env }) {
2545
+ function timeBuiltin(context) {
2546
+ const state = { pending: true };
2547
+ return withPendingState(timeSolutions(context, state), state);
2548
+ }
2549
+
2550
+ function* timeSolutions({ solver, goal, env }, state) {
2484
2551
  const invoked = callable(goal.args[0], env);
2485
2552
  const child = solver.cloneForInnerGoal();
2486
2553
  let startedAt = monotonicMilliseconds();
@@ -2490,12 +2557,15 @@ function* timeBuiltin({ solver, goal, env }) {
2490
2557
  for (const answerEnv of child.solve([invoked], env, 0)) {
2491
2558
  writeElapsedTime(solver, startedAt, child.inferenceObservation.value - startedInferences);
2492
2559
  yieldedAny = true;
2560
+ state.pending = child.hasPendingAlternatives();
2493
2561
  yield answerEnv;
2562
+ if (!state.pending) return;
2494
2563
  // A resumed time/1 measures only the work required to reach the next
2495
2564
  // answer, so nondeterministic calls get one timing line per solution.
2496
2565
  startedAt = monotonicMilliseconds();
2497
2566
  startedInferences = child.inferenceObservation.value;
2498
2567
  }
2568
+ state.pending = false;
2499
2569
  // A call that fails without producing an answer still reports the work.
2500
2570
  if (!yieldedAny) writeElapsedTime(solver, startedAt, child.inferenceObservation.value - startedInferences);
2501
2571
  } finally {
@@ -2503,10 +2573,18 @@ function* timeBuiltin({ solver, goal, env }) {
2503
2573
  }
2504
2574
  }
2505
2575
 
2506
- function* callNthBuiltin({ solver, goal, env }) {
2576
+ function callNthBuiltin(context) {
2577
+ const state = { pending: true };
2578
+ return withPendingState(callNthSolutions(context, state), state);
2579
+ }
2580
+
2581
+ function* callNthSolutions({ solver, goal, env }, state) {
2507
2582
  const requestedTerm = deref(goal.args[1], env);
2508
2583
  // Zero is the one Nth value that fails before Goal is inspected.
2509
- if (requestedTerm.type === NUMBER && isDecimalInteger(requestedTerm.name) && BigInt(requestedTerm.name) === 0n) return;
2584
+ if (requestedTerm.type === NUMBER && isDecimalInteger(requestedTerm.name) && BigInt(requestedTerm.name) === 0n) {
2585
+ state.pending = false;
2586
+ return;
2587
+ }
2510
2588
 
2511
2589
  const invoked = callable(goal.args[0], env);
2512
2590
  let requested = null;
@@ -2525,9 +2603,13 @@ function* callNthBuiltin({ solver, goal, env }) {
2525
2603
  nth++;
2526
2604
  if (requested != null && nth < requested) continue;
2527
2605
  const next = answerEnv.clone();
2528
- if (unify(goal.args[1], numberTerm(nth.toString()), next)) yield next;
2529
- if (requested != null) return;
2606
+ if (unify(goal.args[1], numberTerm(nth.toString()), next)) {
2607
+ state.pending = requested == null && child.hasPendingAlternatives();
2608
+ yield next;
2609
+ if (requested != null || !state.pending) return;
2610
+ }
2530
2611
  }
2612
+ state.pending = false;
2531
2613
  } finally {
2532
2614
  solver.absorbStatsFrom(child);
2533
2615
  }
@@ -2619,12 +2701,22 @@ function callResidueVarsBuiltin({ solver, goal, env }) {
2619
2701
  return iterator;
2620
2702
  }
2621
2703
 
2622
- function* freezeBuiltin({ solver, goal, env }) {
2704
+ function freezeBuiltin(context) {
2705
+ const state = { pending: true };
2706
+ return withPendingState(freezeSolutions(context, state), state);
2707
+ }
2708
+
2709
+ function* freezeSolutions({ solver, goal, env }, state) {
2623
2710
  const watched = deref(goal.args[0], env);
2624
2711
  if (watched.type !== VAR) {
2625
2712
  const child = solver.cloneForInnerGoal();
2626
2713
  try {
2627
- yield* child.solve([callable(goal.args[1], env)], env, 0);
2714
+ for (const answerEnv of child.solve([callable(goal.args[1], env)], env, 0)) {
2715
+ state.pending = child.hasPendingAlternatives();
2716
+ yield answerEnv;
2717
+ if (!state.pending) return;
2718
+ }
2719
+ state.pending = false;
2628
2720
  } finally {
2629
2721
  solver.absorbStatsFrom(child);
2630
2722
  }
@@ -2632,10 +2724,16 @@ function* freezeBuiltin({ solver, goal, env }) {
2632
2724
  }
2633
2725
  const next = env.clone();
2634
2726
  next.delay(watched.name, goal.args[1], goal.module ?? 'user');
2727
+ state.pending = false;
2635
2728
  yield next;
2636
2729
  }
2637
2730
 
2638
- function* phraseBuiltin({ solver, goal, env }) {
2731
+ function phraseBuiltin(context) {
2732
+ const state = { pending: true };
2733
+ return withPendingState(phraseSolutions(context, state), state);
2734
+ }
2735
+
2736
+ function* phraseSolutions({ solver, goal, env }, state) {
2639
2737
  const grammarBody0 = deref(goal.args[0], env);
2640
2738
  if (grammarBody0.type === VAR) throw new PrologError('instantiation_error');
2641
2739
  if (grammarBody0.type !== ATOM && grammarBody0.type !== COMPOUND) {
@@ -2692,7 +2790,12 @@ function* phraseBuiltin({ solver, goal, env }) {
2692
2790
  skipListTailTabling: !repeatedInvocation,
2693
2791
  });
2694
2792
  try {
2695
- yield* child.solve(finish == null ? [expanded] : [expanded, finish], env, 0);
2793
+ for (const answerEnv of child.solve(finish == null ? [expanded] : [expanded, finish], env, 0)) {
2794
+ state.pending = child.hasPendingAlternatives();
2795
+ yield answerEnv;
2796
+ if (!state.pending) return;
2797
+ }
2798
+ state.pending = false;
2696
2799
  } finally {
2697
2800
  solver.absorbStatsFrom(child);
2698
2801
  solver.trimInnerTableScope('phrase');
@@ -2752,7 +2855,12 @@ function prologErrorBall(error) {
2752
2855
  if (hasDefaultGroundErrorShape(error) || termIsGround(term)) return term;
2753
2856
  return freshCopy(term, new Env());
2754
2857
  }
2755
- function* catchBuiltin({ solver, goal, env }) {
2858
+ function catchBuiltin(context) {
2859
+ const state = { pending: true };
2860
+ return withPendingState(catchSolutions(context, state), state);
2861
+ }
2862
+
2863
+ function* catchSolutions({ solver, goal, env }, state) {
2756
2864
  let child = null;
2757
2865
  try {
2758
2866
  // Corrigendum 2 removed catch/3's own callability errors so that errors
@@ -2769,12 +2877,19 @@ function* catchBuiltin({ solver, goal, env }) {
2769
2877
  if (result.done) solver.stats.deterministic_builtin_failures++;
2770
2878
  else {
2771
2879
  solver.stats.deterministic_builtin_successes++;
2880
+ state.pending = false;
2772
2881
  yield result.value;
2773
2882
  }
2883
+ state.pending = false;
2774
2884
  return;
2775
2885
  }
2776
2886
  child = solver.cloneForInnerGoal();
2777
- yield* child.solve([invoked], env.clone(), 0);
2887
+ for (const answerEnv of child.solve([invoked], env.clone(), 0)) {
2888
+ state.pending = child.hasPendingAlternatives();
2889
+ yield answerEnv;
2890
+ if (!state.pending) return;
2891
+ }
2892
+ state.pending = false;
2778
2893
  } catch (error) {
2779
2894
  const ball = error instanceof ThrownTerm
2780
2895
  ? error.term
@@ -2785,12 +2900,18 @@ function* catchBuiltin({ solver, goal, env }) {
2785
2900
  const recovered = env.clone();
2786
2901
  if (!unify(goal.args[1], ball, recovered)) throw error;
2787
2902
  const recovery = callable(goal.args[2], recovered);
2788
- if (recovery.type === ATOM && (recovery.name === 'false' || recovery.name === 'fail')) return;
2903
+ if (recovery.type === ATOM && (recovery.name === 'false' || recovery.name === 'fail')) {
2904
+ state.pending = false;
2905
+ return;
2906
+ }
2789
2907
  if (recovery.type === ATOM && recovery.name === 'true') {
2908
+ state.pending = false;
2790
2909
  yield recovered;
2791
2910
  return;
2792
2911
  }
2793
- yield* solver.solve([recovery], recovered, 0);
2912
+ yield* solveControlBranch(solver, recovery, recovered,
2913
+ (pending) => { state.pending = pending; });
2914
+ state.pending = false;
2794
2915
  } finally {
2795
2916
  if (child != null) solver.absorbStatsFrom(child);
2796
2917
  }
@@ -2888,11 +3009,19 @@ function* disjunctionSolutions({ solver, goal, env }, state) {
2888
3009
  (pending) => { state.pending = pending; });
2889
3010
  state.pending = false;
2890
3011
  }
2891
- function* ifThenBuiltin({ solver, goal, env }) {
3012
+ function ifThenBuiltin(context) {
3013
+ const state = { pending: true };
3014
+ return withPendingState(ifThenSolutions(context, state), state);
3015
+ }
3016
+
3017
+ function* ifThenSolutions({ solver, goal, env }, state) {
2892
3018
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) {
2893
- yield* solveControlBranch(solver, goal.args[1], conditionEnv);
3019
+ yield* solveControlBranch(solver, goal.args[1], conditionEnv,
3020
+ (pending) => { state.pending = pending; });
3021
+ state.pending = false;
2894
3022
  return;
2895
3023
  }
3024
+ state.pending = false;
2896
3025
  }
2897
3026
 
2898
3027
  export { arithmeticValueTerm, evaluateArithmetic, compareArithmeticValues } from './iso-arithmetic.js';
@@ -2911,6 +3040,7 @@ export class BuiltinRegistry {
2911
3040
  handler,
2912
3041
  deterministic: options.deterministic ?? false,
2913
3042
  deterministicWhen: options.deterministicWhen ?? null,
3043
+ expandGoal: options.expandGoal ?? null,
2914
3044
  ready: options.ready ?? null,
2915
3045
  fallbackWhenNotReady: options.fallbackWhenNotReady ?? false,
2916
3046
  shouldUse: options.shouldUse ?? null,
@@ -207,9 +207,9 @@ function* termCharsBuiltin({ solver, goal, env }) {
207
207
 
208
208
  export const scryerCompatibilityBuiltins = {
209
209
  register(registry) {
210
- registry.add('eyeprolog__bb_get', 2, bbGetBuiltin, { eyePrologLibrary: true });
210
+ registry.add('eyeprolog__bb_get', 2, bbGetBuiltin, { deterministic: true, eyePrologLibrary: true });
211
211
  registry.add('eyeprolog__bb_b_put', 2, bbPutBuiltin, { deterministic: true, eyePrologLibrary: true });
212
- registry.add('eyeprolog__bb_global_get', 2, bbGlobalGetBuiltin, { eyePrologLibrary: true });
212
+ registry.add('eyeprolog__bb_global_get', 2, bbGlobalGetBuiltin, { deterministic: true, eyePrologLibrary: true });
213
213
  registry.add('eyeprolog__bb_global_put', 2, bbGlobalPutBuiltin, { deterministic: true, eyePrologLibrary: true });
214
214
  registry.add('eyeprolog__char_type', 2, charTypeBuiltin, { eyePrologLibrary: true });
215
215
  registry.add('eyeprolog__current_time', 1, currentTimeBuiltin, { deterministic: true, eyePrologLibrary: true });
package/src/solver.js CHANGED
@@ -614,7 +614,21 @@ export class Solver {
614
614
 
615
615
  const def = callable ? this.registry.get(goal.name, goal.arity) : null;
616
616
  this.active = active;
617
- if (def && builtinIsReadyOrAuthoritative(def, this, goal, env)) {
617
+ const builtinReady = def && builtinIsReadyOrAuthoritative(def, this, goal, env);
618
+ if (builtinReady && typeof def.expandGoal === 'function') {
619
+ // Meta-calls execute in this continuation instead of hiding their
620
+ // search behind a host-generator frame. Their real clause, control,
621
+ // and builtin frames therefore determine whether another answer is
622
+ // pending exactly as they do for a direct call. A fresh active marker
623
+ // keeps cut opaque at the call/N boundary.
624
+ const expanded = def.expandGoal({ solver: this, goal, env });
625
+ const invocation = { goal, env };
626
+ active = [...active, invocation];
627
+ goals = [expanded, { kind: 'releaseActive' }, ...rest];
628
+ depth++;
629
+ continue;
630
+ }
631
+ if (builtinReady) {
618
632
  const deterministic = def.deterministic ||
619
633
  def.deterministicWhen?.({ solver: this, goal, env }) === true;
620
634
  const iterator = def.handler({ solver: this, goal, env });
@@ -1414,10 +1428,7 @@ function bundledBetweenIterator(solver, group, goal, env) {
1414
1428
  // the generated value in the caller repeatedly revisits all earlier frames.
1415
1429
  // Enumerate the canonical bundled relation directly while leaving user
1416
1430
  // definitions and non-EyeProlog registries on the ordinary Prolog path.
1417
- const state = { pending: true };
1418
- const iterator = bundledBetweenSolutions(solver, goal, env, state);
1419
- iterator.hasPendingAlternatives = () => state.pending;
1420
- return iterator;
1431
+ return bundledBetweenSolutions(solver, goal, env);
1421
1432
  }
1422
1433
 
1423
1434
  function requireBetweenInteger(term, env) {
@@ -1429,7 +1440,7 @@ function requireBetweenInteger(term, env) {
1429
1440
  return BigInt(value.name);
1430
1441
  }
1431
1442
 
1432
- function* bundledBetweenSolutions(solver, goal, env, state) {
1443
+ function* bundledBetweenSolutions(solver, goal, env) {
1433
1444
  const lower = requireBetweenInteger(goal.args[0], env);
1434
1445
  const upper = requireBetweenInteger(goal.args[1], env);
1435
1446
  const requested = deref(goal.args[2], env);
@@ -1439,23 +1450,15 @@ function* bundledBetweenSolutions(solver, goal, env, state) {
1439
1450
  throw new PrologError('type_error(integer)', requested);
1440
1451
  }
1441
1452
  const value = BigInt(requested.name);
1442
- if (value >= lower && value <= upper) {
1443
- state.pending = false;
1444
- yield env;
1445
- }
1446
- state.pending = false;
1453
+ if (value >= lower && value <= upper) yield env;
1447
1454
  return;
1448
1455
  }
1449
1456
 
1450
1457
  for (let value = lower; value <= upper; value++) {
1451
1458
  const next = env.clone();
1452
1459
  solver.stats.unify_calls++;
1453
- if (unify(goal.args[2], numberTerm(value), next)) {
1454
- state.pending = value < upper;
1455
- yield next;
1456
- }
1460
+ if (unify(goal.args[2], numberTerm(value), next)) yield next;
1457
1461
  }
1458
- state.pending = false;
1459
1462
  }
1460
1463
 
1461
1464
  function bundledMemberIterator(solver, group, goal, env) {
@@ -1472,33 +1475,18 @@ function bundledMemberIterator(solver, group, goal, env) {
1472
1475
  let cursor = deref(goal.args[1], env);
1473
1476
  while (isCons(cursor)) cursor = deref(cursor.args[1], env);
1474
1477
  if (!isEmptyList(cursor)) return null;
1475
- const state = { pending: true };
1476
- const iterator = bundledMemberSolutions(solver, goal, env, state);
1477
- iterator.hasPendingAlternatives = () => state.pending;
1478
- return iterator;
1478
+ return bundledMemberSolutions(solver, goal, env);
1479
1479
  }
1480
1480
 
1481
- function* bundledMemberSolutions(solver, goal, env, state) {
1481
+ function* bundledMemberSolutions(solver, goal, env) {
1482
1482
  let cursor = deref(goal.args[1], env);
1483
1483
  while (isCons(cursor)) {
1484
1484
  const item = cursor.args[0];
1485
1485
  cursor = deref(cursor.args[1], env);
1486
1486
  const next = env.clone();
1487
1487
  solver.stats.unify_calls++;
1488
- if (unify(goal.args[0], item, next)) {
1489
- state.pending = bundledMemberMayMatch(goal.args[0], cursor, env);
1490
- yield next;
1491
- }
1488
+ if (unify(goal.args[0], item, next)) yield next;
1492
1489
  }
1493
- state.pending = false;
1494
- }
1495
-
1496
- function bundledMemberMayMatch(target, cursor, env) {
1497
- while (isCons(cursor)) {
1498
- if (!goalHeadTermsCannotMatch(target, cursor.args[0], env)) return true;
1499
- cursor = deref(cursor.args[1], env);
1500
- }
1501
- return false;
1502
1490
  }
1503
1491
 
1504
1492
  function bundledEllipsisPlan(solver, group, goal, rest, env) {
@@ -2187,13 +2175,7 @@ function pushStreamingScalarFactRun(stack, solver, runGoals, groups, rest, env,
2187
2175
  const iterator = scalarFactRunSolutions(solver, runGoals, groups, env, depth, active);
2188
2176
  const first = iterator.next();
2189
2177
  if (first.done) return;
2190
- stack.push({
2191
- kind: 'resumeBuiltin',
2192
- iterator,
2193
- goals: rest,
2194
- depth: depth + runGoals.length,
2195
- active,
2196
- });
2178
+ pushResumeBuiltinFrame(stack, iterator, rest, depth + runGoals.length, active);
2197
2179
  stack.push({
2198
2180
  kind: 'goals',
2199
2181
  goals: rest,
@@ -2831,10 +2813,64 @@ function builtinIsReadyOrAuthoritative(def, solver, goal, env) {
2831
2813
 
2832
2814
  function iteratorMayHavePendingAlternatives(iterator) {
2833
2815
  const predicate = iterator?.hasPendingAlternatives;
2834
- return typeof predicate !== 'function' || predicate.call(iterator) !== false;
2816
+ return typeof predicate === 'function' && predicate.call(iterator) !== false;
2817
+ }
2818
+
2819
+ function lookaheadChoiceIterator(iterator) {
2820
+ let buffered = null;
2821
+ let bufferedReady = false;
2822
+ let closed = false;
2823
+
2824
+ const fill = () => {
2825
+ if (closed || bufferedReady) return;
2826
+ buffered = iterator.next();
2827
+ bufferedReady = true;
2828
+ if (buffered.done) closed = true;
2829
+ };
2830
+
2831
+ return {
2832
+ next() {
2833
+ fill();
2834
+ if (buffered?.done !== false) return buffered ?? { done: true, value: undefined };
2835
+ const result = buffered;
2836
+ buffered = null;
2837
+ bufferedReady = false;
2838
+ // Ordinary relational iterators are advanced by one answer so their
2839
+ // exhausted state is known before this answer reaches the caller.
2840
+ fill();
2841
+ return result;
2842
+ },
2843
+ return(value) {
2844
+ if (!closed) iterator.return?.();
2845
+ closed = true;
2846
+ buffered = { done: true, value };
2847
+ bufferedReady = true;
2848
+ return buffered;
2849
+ },
2850
+ throw(error) {
2851
+ closed = true;
2852
+ if (typeof iterator.throw === 'function') return iterator.throw(error);
2853
+ throw error;
2854
+ },
2855
+ prepareClose(reason) {
2856
+ iterator.prepareClose?.(reason);
2857
+ },
2858
+ hasPendingAlternatives() {
2859
+ fill();
2860
+ return buffered?.done === false;
2861
+ },
2862
+ [Symbol.iterator]() { return this; },
2863
+ };
2864
+ }
2865
+
2866
+ function choicepointAwareIterator(iterator) {
2867
+ return typeof iterator?.hasPendingAlternatives === 'function'
2868
+ ? iterator
2869
+ : lookaheadChoiceIterator(iterator);
2835
2870
  }
2836
2871
 
2837
2872
  function pushResumeBuiltinFrame(stack, iterator, goals, depth, active) {
2873
+ iterator = choicepointAwareIterator(iterator);
2838
2874
  if (!iteratorMayHavePendingAlternatives(iterator)) {
2839
2875
  // Finish the suspended generator now so its finally blocks release child
2840
2876
  // solvers, aggregate statistics, and perform predicate-local cleanup.
@@ -97,8 +97,8 @@ required permission error. `read_term/3`, `write_term/3`, `op/3`, and
97
97
  EyeProlog's chosen simultaneous-error behavior stable. Section 7.12 makes that
98
98
  choice implementation dependent when several error conditions hold at once. Strict `write_term/2-3` accepts only
99
99
  the Part 1 plus Corrigendum 3 option surface; the normal-profile
100
- `double_quotes/1` write option remains an explicitly documented EyeProlog
101
- extension.
100
+ `double_quotes/1` and `spacing/1` write options remain explicitly documented
101
+ implementation-specific EyeProlog extensions.
102
102
 
103
103
 
104
104
  The preparation, stream, conversion, sorting, and arithmetic audit expands the coverage into Prolog-text,
@@ -124,7 +124,7 @@ families; `--iso-strict` is intended to remove their Part 1 interpretation.
124
124
  | 5.5.9 Built-in predicates | EyeProlog libraries, CLP(Z), statistics, Part 3 `phrase/2-3`, and interop autoloaded predicates | Strict registry contains only the Part 1 + Corrigenda core registry. |
125
125
  | 5.5.10 Evaluable functors | Normal mode additionally accepts the EyeProlog evaluable atom `e`; the remaining arithmetic functors accepted by strict mode are the Part 1 + Corrigenda set. | Strict mode rejects `e/0` as non-evaluable and retains the Corrigendum arithmetic additions. **covered** — strict extension-boundary regression plus `src/iso-arithmetic.js`. |
126
126
  | 5.5.11 Reserved atoms | None | None. |
127
- | Cor.3 5.5.12 Options | Extra library/host options may exist outside core option lists | Normal mode additionally accepts the EyeProlog `write_term/2-3` option `double_quotes(true|false)`. Strict core excludes that extension and accepts only the Part 1 plus Corrigendum 3 write-option surface; unknown extension options raise `domain_error(write_option,...)`. |
127
+ | Cor.3 5.5.12 Options | Extra library/host options may exist outside core option lists | Normal mode additionally accepts the EyeProlog `write_term/2-3` options `double_quotes(true|false)` and `spacing(minimal|standard)`. `minimal` emits only lexically required separators; `standard` adds conventional layout around operators. Strict core excludes these implementation-specific extensions and accepts only the Part 1 plus Corrigendum 3 write-option surface; unknown extension options raise `domain_error(write_option,...)`. |
128
128
 
129
129
  Normal mode provides documented module and DCG compatibility profiles whose
130
130
  features overlap standardized Part 2 and Part 3 facilities. They are extensions
@@ -89,6 +89,18 @@ export function runCleanup(reporter) {
89
89
  assertEqual(result.stderr, '', 'stderr');
90
90
  });
91
91
 
92
+ reporter.test('setup_call_cleanup/3 exposes deterministic Cleanup substitutions (issue #77)', () => {
93
+ const result = runRepl(
94
+ 'setup_call_cleanup(true,A=1,throw(called(A))).\n' +
95
+ 'setup_call_cleanup(true,A=1,B=1).\n' +
96
+ 'halt.\n',
97
+ );
98
+ assertEqual(result.status, 0, 'exit status');
99
+ assertIncludes(result.stdout, 'throw(called(1)).', 'Cleanup sees Goal substitutions');
100
+ assertIncludes(result.stdout, 'A = 1, B = 1.', 'answer includes Cleanup substitutions');
101
+ assertEqual(result.stderr, '', 'stderr');
102
+ });
103
+
92
104
  reporter.test('setup_call_cleanup/3 does not install cleanup when Setup fails', () => {
93
105
  const result = runRepl('setup_call_cleanup(fail,true,write(should_not_run)).\nhalt.\n');
94
106
  assertEqual(result.status, 0, 'status');
@@ -283,8 +283,16 @@ export function runIsoStrict(reporter = new TestReporter()) {
283
283
  }));
284
284
  equal(strictError.formal, 'domain_error(write_option)', 'strict extension rejection');
285
285
 
286
+ const strictSpacingError = capture(() => run('', {
287
+ isoStrict: true,
288
+ goal: 'write_term(1+1,[spacing(minimal)])',
289
+ }));
290
+ equal(strictSpacingError.formal, 'domain_error(write_option)', 'strict spacing extension rejection');
291
+
286
292
  const normal = run('', { goal: 'write_term("ab",[double_quotes(true)])' });
287
293
  includes(normal.stdout, '"ab"', 'normal-profile extension remains available');
294
+ includes(run('', { goal: 'write_term(1+1,[spacing(standard)])' }).stdout,
295
+ '1 + 1', 'normal-profile spacing extension remains available');
288
296
  });
289
297
 
290
298
  reporter.test('follows Corrigendum 3 variable metadata traversal and write naming', () => {
@@ -1032,6 +1032,108 @@ c4 ?- call((!;1)).
1032
1032
  }
1033
1033
  },
1034
1034
  },
1035
+ {
1036
+ name: 'read/2 and number_chars/2 agree on bounded numeric syntax (issue #29)',
1037
+ run: () => {
1038
+ const registry = createDefaultRegistry();
1039
+ const numberChars = registry.get('number_chars', 2).handler;
1040
+ const read = registry.get('read', 2).handler;
1041
+ const solver = new Solver(Program.parse(''), { registry, ioOptions: { input: '' } });
1042
+ const stream = solver.io.resolve(0);
1043
+ const streamTerm = compound('$stream', [numberTerm('0')]);
1044
+ const alphabet = ['0', '1', '2', '7', '8', '9', 'a', 'f', 'x', 'e', 'E', '+', '-', '.', "'", '\\', ' '];
1045
+ let checked = 0;
1046
+ let accepted = 0;
1047
+
1048
+ const visit = (prefix, remaining) => {
1049
+ if (remaining === 0) {
1050
+ checked++;
1051
+ const converted = variable('Converted');
1052
+ const conversionGoal = compound('number_chars', [
1053
+ converted,
1054
+ listFromItems(Array.from(prefix, atom)),
1055
+ ]);
1056
+ let conversion;
1057
+ try {
1058
+ conversion = numberChars({ goal: conversionGoal, env: new Env() }).next();
1059
+ } catch (_) {
1060
+ return;
1061
+ }
1062
+ if (conversion.done) return;
1063
+ accepted++;
1064
+ const expected = copyResolved(converted, conversion.value);
1065
+
1066
+ stream.content = `${prefix}. `;
1067
+ stream.position = 0;
1068
+ stream.pastEnd = false;
1069
+ const readValue = variable('ReadValue');
1070
+ const readGoal = compound('read', [streamTerm, readValue]);
1071
+ const answer = read({ solver, goal: readGoal, env: new Env() }).next();
1072
+ if (answer.done) throw new Error(`read/2 rejected number_chars/2 spelling ${JSON.stringify(prefix)}`);
1073
+ const actual = copyResolved(readValue, answer.value);
1074
+ assertEqual(actual.type, 'number', `read/2 type for ${JSON.stringify(prefix)}`);
1075
+ assertEqual(actual.name, expected.name, `read/2 value for ${JSON.stringify(prefix)}`);
1076
+ return;
1077
+ }
1078
+ for (const character of alphabet) visit(prefix + character, remaining - 1);
1079
+ };
1080
+
1081
+ for (let length = 1; length <= 4; length++) visit('', length);
1082
+ assertEqual(checked, 88740, 'bounded numeric spellings checked');
1083
+ if (accepted < 1000) throw new Error(`unexpectedly small accepted numeric corpus: ${accepted}`);
1084
+ },
1085
+ },
1086
+ {
1087
+ name: 'number_chars/2 to read/2 cross-check stays bounded under a small heap (issue #29)',
1088
+ run: () => {
1089
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
1090
+ const script = `
1091
+ import {
1092
+ Program, Solver, Env, atom, compound, variable, listFromItems,
1093
+ numberTerm, copyResolved, createDefaultRegistry,
1094
+ } from ${JSON.stringify(engineUrl)};
1095
+ const registry = createDefaultRegistry();
1096
+ const numberChars = registry.get('number_chars', 2).handler;
1097
+ const read = registry.get('read', 2).handler;
1098
+ const solver = new Solver(Program.parse(''), { registry, ioOptions: { input: '' } });
1099
+ const stream = solver.io.resolve(0);
1100
+ const streamTerm = compound('$stream', [numberTerm('0')]);
1101
+ for (let i = 0; i < 250000; i++) {
1102
+ const text = String(i);
1103
+ const converted = variable('Converted');
1104
+ const conversionGoal = compound('number_chars', [
1105
+ converted,
1106
+ listFromItems(Array.from(text, atom)),
1107
+ ]);
1108
+ const conversion = numberChars({ goal: conversionGoal, env: new Env() }).next();
1109
+ if (conversion.done) throw new Error('number_chars/2 failed at ' + i);
1110
+ const expected = copyResolved(converted, conversion.value);
1111
+
1112
+ stream.content = text + '. ';
1113
+ stream.position = 0;
1114
+ stream.pastEnd = false;
1115
+ const readValue = variable('ReadValue');
1116
+ const readGoal = compound('read', [streamTerm, readValue]);
1117
+ const answer = read({ solver, goal: readGoal, env: new Env() }).next();
1118
+ if (answer.done) throw new Error('read/2 failed at ' + i);
1119
+ const actual = copyResolved(readValue, answer.value);
1120
+ if (actual.type !== 'number' || actual.name !== expected.name) {
1121
+ throw new Error('number mismatch at ' + i + ': ' + actual.name + ' != ' + expected.name);
1122
+ }
1123
+ }
1124
+ process.stdout.write('250000');
1125
+ `;
1126
+ const result = spawnSync(process.execPath, [
1127
+ '--max-old-space-size=32',
1128
+ '--input-type=module',
1129
+ '--eval',
1130
+ script,
1131
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
1132
+ if (result.error) throw result.error;
1133
+ assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
1134
+ assertEqual(result.stdout, '250000', 'number_chars/read cross-check count');
1135
+ },
1136
+ },
1035
1137
  {
1036
1138
  name: 'number_chars and number_codes keep exponent floats syntactically readable (issue #50)',
1037
1139
  run: () => {
@@ -1897,11 +1999,13 @@ c4 ?- call((!;1)).
1897
1999
  },
1898
2000
  },
1899
2001
  {
1900
- name: 'predicates discard exhausted choicepoints before the REPL observes them (issue #74)',
2002
+ name: 'direct and meta-called predicates discard exhausted choicepoints (issue #74 comment 5427164365)',
1901
2003
  run: () => {
1902
2004
  const result = runCli([], {
1903
2005
  input:
1904
2006
  'X=1;X=2.\n;\n' +
2007
+ 'call(X=1).\n' +
2008
+ 'call(=(X),1).\n' +
1905
2009
  'setof(X,true,Xs).\n' +
1906
2010
  'findall(t,false,Xs).\n' +
1907
2011
  'use_module(library(lists)).\n' +
@@ -1913,6 +2017,8 @@ c4 ?- call((!;1)).
1913
2017
  assertEqual(result.stdout,
1914
2018
  '?- X = 1\n' +
1915
2019
  '; X = 2.\n' +
2020
+ '?- X = 1.\n' +
2021
+ '?- X = 1.\n' +
1916
2022
  '?- Xs = [_A].\n' +
1917
2023
  '?- Xs = [].\n' +
1918
2024
  '?- true.\n' +
@@ -1942,6 +2048,16 @@ c4 ?- call((!;1)).
1942
2048
  };
1943
2049
 
1944
2050
  finalAnswerHasNoFrames('', 'X=1;X=2', 2);
2051
+ finalAnswerHasNoFrames('', 'call(X=1)', 1);
2052
+ finalAnswerHasNoFrames('', 'call(=(X),1)', 1);
2053
+ finalAnswerHasNoFrames('', 'atom_concat(a,X,ab)', 1);
2054
+ finalAnswerHasNoFrames('', 'sub_atom(ab,0,2,0,S)', 1);
2055
+ finalAnswerHasNoFrames('', 'current_prolog_flag(double_quotes,_Value)', 1);
2056
+ finalAnswerHasNoFrames('', "current_op(700,xfx,'=')", 1);
2057
+ finalAnswerHasNoFrames(':- dynamic(p/1).\np(a).', 'clause(p(X),true)', 1);
2058
+ finalAnswerHasNoFrames(':- dynamic(p/1).\np(a).', 'retract(p(X))', 1);
2059
+ finalAnswerHasNoFrames('', 'catch(true,_,fail)', 1);
2060
+ finalAnswerHasNoFrames('', 'phrase([],[])', 1);
1945
2061
  finalAnswerHasNoFrames('', 'setof(X,true,Xs)', 1);
1946
2062
  finalAnswerHasNoFrames('', 'findall(t,false,Xs)', 1);
1947
2063
  finalAnswerHasNoFrames(':- use_module(library(lists)).', 'member(a,"ba")', 1, 'lists');
@@ -1954,6 +2070,82 @@ c4 ?- call((!;1)).
1954
2070
  assertEqual(groupedSolutions.next().done, false, 'first grouped setof/3 answer');
1955
2071
  assertEqual(grouped.hasPendingAlternatives(), true, 'genuine grouped setof/3 choicepoint');
1956
2072
  groupedSolutions.return();
2073
+
2074
+ const called = new Solver(Program.parse(''), { registry: getEyePrologRegistry() });
2075
+ const calledSolutions = called.solve([parseGoalText('call((X=1;X=2))')], new Env(), 0);
2076
+ assertEqual(calledSolutions.next().done, false, 'called disjunction first answer');
2077
+ assertEqual(called.hasPendingAlternatives(), true, 'called disjunction retains its real alternative');
2078
+ assertEqual(calledSolutions.next().done, false, 'called disjunction second answer');
2079
+ assertEqual(called.hasPendingAlternatives(), false, 'called disjunction removes its exhausted alternative');
2080
+ calledSolutions.return();
2081
+
2082
+ const customRegistry = new BuiltinRegistry();
2083
+ customRegistry.add('single', 1, function* ({ goal, env }) {
2084
+ const next = env.clone();
2085
+ if (unify(goal.args[0], atom('only'), next)) yield next;
2086
+ });
2087
+ const custom = new Solver(Program.parse(''), { registry: customRegistry });
2088
+ const customSolutions = custom.solve([parseGoalText('single(X)')], new Env(), 0);
2089
+ assertEqual(customSolutions.next().done, false, 'custom relational iterator answer');
2090
+ assertEqual(custom.hasPendingAlternatives(), false,
2091
+ 'unannotated relational iterator uses the universal lookahead protocol');
2092
+ customSolutions.return();
2093
+
2094
+ const dynamicProgram = Program.parse(':- dynamic(p/1).\np(a).\np(b).\n');
2095
+ const dynamicSolver = new Solver(dynamicProgram, { registry: getEyePrologRegistry() });
2096
+ const dynamicSolutions = dynamicSolver.solve([parseGoalText('catch(retract(p(X)),_,fail)')], new Env(), 0);
2097
+ assertEqual(dynamicSolutions.next().done, false, 'stateful protected iterator first answer');
2098
+ assertEqual(dynamicProgram.findGroup('p', 1).clauses.length, 1,
2099
+ 'lookahead does not perform the next retraction');
2100
+ assertEqual(dynamicSolver.hasPendingAlternatives(), true,
2101
+ 'stateful protected iterator retains its real second retraction');
2102
+ dynamicSolutions.return();
2103
+ assertEqual(dynamicProgram.findGroup('p', 1).clauses.length, 1,
2104
+ 'closing stateful iterator leaves the unrequested clause intact');
2105
+ },
2106
+ },
2107
+ {
2108
+ name: 'bound member tails do not invent predicate choicepoints (issue #74 follow-up)',
2109
+ run: () => {
2110
+ const result = runCli([], {
2111
+ input:
2112
+ 'use_module(library(lists)).\n' +
2113
+ 'L=[i],append(L,_,[i|L]).\n' +
2114
+ 'L=[_],append(L,_,[i|L]).\n' +
2115
+ 'L=[i],append(L,_,[i|L]),member(a,[a|L]).\n' +
2116
+ 'L=[_],append(L,_,[i|L]),member(a,[a|L]).\n' +
2117
+ 'halt.\n',
2118
+ });
2119
+ assertEqual(result.status, 0, 'exit status');
2120
+ assertEqual(result.stdout,
2121
+ '?- true.\n' +
2122
+ '?- L = "i".\n' +
2123
+ '?- L = "i".\n' +
2124
+ '?- L = "i".\n' +
2125
+ '?- L = "i".\n' +
2126
+ '?- ',
2127
+ 'ground and formerly variable list elements have identical determinism');
2128
+ assertEqual(result.stderr, '', 'stderr');
2129
+ },
2130
+ },
2131
+ {
2132
+ name: 'length/2 reports fixed and closed-list answers as deterministic (issue #78)',
2133
+ run: () => {
2134
+ const result = runCli([], {
2135
+ input:
2136
+ 'use_module(library(lists)).\n' +
2137
+ 'length(K,0).\n' +
2138
+ 'length([],N).\n' +
2139
+ 'halt.\n',
2140
+ });
2141
+ assertEqual(result.status, 0, 'exit status');
2142
+ assertEqual(result.stdout,
2143
+ '?- true.\n' +
2144
+ '?- K = [].\n' +
2145
+ '?- N = 0.\n' +
2146
+ '?- ',
2147
+ 'no exhausted native length iterator remains');
2148
+ assertEqual(result.stderr, '', 'stderr');
1957
2149
  },
1958
2150
  },
1959
2151
  {
@@ -3884,6 +4076,15 @@ child.stdin.write(\`consult(${consultedAtom}).\\n\`);
3884
4076
  {
3885
4077
  name: 'REPL answers use only lexically required operator spacing (issue #76)',
3886
4078
  run: () => {
4079
+ const source = [
4080
+ 'emit :-',
4081
+ ' write_term(1+1, [spacing(minimal)]), put_char(\'|\'),',
4082
+ ' write_term(1+1, [spacing(standard)]).',
4083
+ '',
4084
+ ].join('\n');
4085
+ assertEqual(run(source, { goal: 'emit' }).stdout, '1+1|1 + 1emit.\n',
4086
+ 'documented spacing values select write_term layout');
4087
+
3887
4088
  const result = runCli([], {
3888
4089
  input: 'X=1*1.\nX=a + -b.\nhalt.\n',
3889
4090
  });
@@ -3894,6 +4095,24 @@ child.stdin.write(\`consult(${consultedAtom}).\\n\`);
3894
4095
  '?- ',
3895
4096
  'minimal operator layout preserves required token separation');
3896
4097
  assertEqual(result.stderr, '', 'stderr');
4098
+
4099
+ let variableError = null;
4100
+ try {
4101
+ run('', { goal: 'write_term(1+1,[spacing(X)])' });
4102
+ } catch (error) {
4103
+ variableError = error;
4104
+ }
4105
+ assertEqual(variableError?.formal, 'instantiation_error', 'variable spacing value');
4106
+
4107
+ for (const value of ['true', 'false', '1', '0']) {
4108
+ let domainError = null;
4109
+ try {
4110
+ run('', { goal: `write_term(1+1,[spacing(${value})])` });
4111
+ } catch (error) {
4112
+ domainError = error;
4113
+ }
4114
+ assertEqual(domainError?.formal, 'domain_error(write_option)', `spacing(${value}) domain`);
4115
+ }
3897
4116
  },
3898
4117
  },
3899
4118
  {
@@ -5189,6 +5408,7 @@ answer(ok) :-
5189
5408
  name: 'discarded recursive allocations recover after resource_error(memory)',
5190
5409
  run: () => {
5191
5410
  const engineUrl = new URL('../src/index.js', import.meta.url).href;
5411
+ const platformUrl = new URL('../src/platform.js', import.meta.url).href;
5192
5412
  const programText = `
5193
5413
  :- use_module(library(prologue)).
5194
5414
  l([]).
@@ -5197,17 +5417,15 @@ answer(ok) :-
5197
5417
  const reportedGoal = 'length(_,I),N is 2^I,\\+ \\+ (length(L,N),l(L)),L=[_|_]';
5198
5418
  const script = `
5199
5419
  import { Program, Solver, Env, deref, variable, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
5420
+ import { usedHeapSize } from ${JSON.stringify(platformUrl)};
5200
5421
  const program = Program.parse(${JSON.stringify(programText)}, { sourceMetadata: false });
5201
- const solver = new Solver(program, { registry: getEyePrologRegistry() });
5202
- const execute = (text) => {
5203
- const goal = parseGoalText(text, { operatorDefinitions: [...program.operators.values()] });
5204
- return [...solver.solve([goal], new Env(), 0)];
5205
- };
5422
+ const registry = getEyePrologRegistry();
5206
5423
  const reported = parseGoalText(${JSON.stringify(reportedGoal)}, {
5207
5424
  operatorDefinitions: [...program.operators.values()],
5208
5425
  });
5426
+ const reportedSolver = new Solver(program, { registry, maxMemoryBytes: Infinity });
5209
5427
  let reportedAnswers = 0;
5210
- for (const answer of solver.solve([reported], new Env(), 0)) {
5428
+ for (const answer of reportedSolver.solve([reported], new Env(), 0)) {
5211
5429
  reportedAnswers++;
5212
5430
  if (reportedAnswers !== 14) continue;
5213
5431
  if (deref(variable('I'), answer).name !== '13' || deref(variable('N'), answer).name !== '8192') {
@@ -5216,6 +5434,18 @@ answer(ok) :-
5216
5434
  break;
5217
5435
  }
5218
5436
  if (reportedAnswers !== 14) throw new Error('reported query produced too few answers');
5437
+
5438
+ // Exercise EyeProlog's recovery window at a stable threshold relative
5439
+ // to the heap after the preliminary reproduction. Keeping that probe
5440
+ // unlimited prevents host GC timing from exhausting the test early.
5441
+ const solver = new Solver(program, {
5442
+ registry,
5443
+ maxMemoryBytes: usedHeapSize() + 8 * 1024 * 1024,
5444
+ });
5445
+ const execute = (text) => {
5446
+ const goal = parseGoalText(text, { operatorDefinitions: [...program.operators.values()] });
5447
+ return [...solver.solve([goal], new Env(), 0)];
5448
+ };
5219
5449
  let caught = null;
5220
5450
  try { execute('length(L,32768),l(L)'); } catch (error) { caught = error; }
5221
5451
  if (caught?.formal !== 'resource_error(memory)') throw caught ?? new Error('no resource error');
@@ -2017,7 +2017,12 @@ answer(X) :- host_status(service, X).
2017
2017
  ```
2018
2018
 
2019
2019
  Only mark a built-in deterministic when it can produce at most one environment
2020
- for a call. A mode-sensitive extension can additionally provide `ready`,
2020
+ for a call. An unmarked resumable handler is treated as an effect-free relation:
2021
+ the solver may buffer one answer to determine its exact pending state. A
2022
+ stateful or meta-control handler must instead return an iterator with a
2023
+ `hasPendingAlternatives()` method, updated before each yield, so the solver can
2024
+ remain demand-driven without advancing it speculatively. A mode-sensitive
2025
+ extension can additionally provide `ready`,
2021
2026
  `fallbackWhenNotReady`, and `shouldUse` metadata. This metadata affects
2022
2027
  dispatch and safe early filtering, so it belongs to the extension's contract.
2023
2028
 
@@ -5136,8 +5141,12 @@ at a named boundary.
5136
5141
  ## 34. Control, exceptions, and grouped solutions
5137
5142
 
5138
5143
  The control predicates accept goals as arguments. `call/1` invokes a callable
5139
- term, `once/1` keeps its first solution, and `!/0` commits within the clause
5140
- that contains it. If-then-else commits to the first successful condition:
5144
+ term, and `call/2-8` appends arguments to a callable closure. The expanded goal
5145
+ runs in the current search continuation, so a direct goal and its meta-called
5146
+ form expose the same remaining alternatives; the meta-call still establishes
5147
+ its own cut boundary. `once/1` keeps its first solution, and `!/0` commits
5148
+ within the clause that contains it. If-then-else commits to the first successful
5149
+ condition:
5141
5150
 
5142
5151
  <figure>
5143
5152
  <img src="book-assets/iso-control-board.svg" alt="A goal passes through choice and exception recovery before finite solutions enter findall, bagof, and setof collectors.">
@@ -5187,8 +5196,10 @@ pruned, top-level answer enumeration is abandoned, or an exception unwinds the
5187
5196
  search. `setup_call_cleanup/3` runs Setup once and installs Cleanup only after
5188
5197
  Setup succeeds. On cut or ordinary pruning Cleanup sees the current Goal
5189
5198
  bindings; on exception unwind the Goal bindings have been removed and Cleanup
5190
- sees the Setup environment. Cleanup failure is ignored, and an exception already
5191
- being propagated takes precedence over a cleanup exception. Nested cleanups run
5199
+ sees the Setup environment. When a deterministic protected goal runs Cleanup
5200
+ before yielding, substitutions produced by a successful Cleanup are included in
5201
+ that answer. Cleanup failure is ignored, and an exception already being
5202
+ propagated takes precedence over a cleanup exception. Nested cleanups run
5192
5203
  inside-out. These two controls are EyeProlog extensions and are absent from
5193
5204
  `--iso-strict`.
5194
5205
 
@@ -5427,9 +5438,15 @@ still retained where adjacent graphic tokens would otherwise merge, as in
5427
5438
  `variable_names/1`. Normal mode additionally accepts the EyeProlog extension
5428
5439
  `double_quotes(true|false)`: `true` lets eligible character/code lists use the
5429
5440
  current `double_quotes` representation, while strict ISO mode rejects this
5430
- non-standard write option. The REPL follows the same minimal-separator rule as
5431
- term output, so `X=1*1` is displayed as `X = 1*1`, while a separator is retained
5432
- in `X = a+ -b` because the adjacent graphic tokens would otherwise merge.
5441
+ implementation-specific write option. Normal mode also accepts the
5442
+ implementation-specific `spacing(minimal|standard)` option: `minimal` emits
5443
+ only separators required to avoid lexical ambiguity, while `standard` adds
5444
+ conventional layout around operators. For example,
5445
+ `write_term(1+1,[spacing(minimal)])` emits `1+1` and
5446
+ `write_term(1+1,[spacing(standard)])` emits `1 + 1`. The REPL always follows
5447
+ the minimal-separator rule, so `X=1*1` is displayed as `X = 1*1`, while a
5448
+ separator is retained in `X = a+ -b` because the adjacent graphic tokens would
5449
+ otherwise merge. Strict ISO mode rejects both extension options.
5433
5450
 
5434
5451
  Character operations are `get_char`, `peek_char`, `put_char`, `get_code`,
5435
5452
  `peek_code`, and `put_code`; byte streams use the corresponding byte
@@ -6122,7 +6139,7 @@ the beginning. A peek does not mark the stream as past-end.
6122
6139
  | `write(+Term)`, `write(+Stream,+Term)` | Writes readable operator notation without quoting atoms merely because quoting would be required for reparsing. Number variables are enabled. |
6123
6140
  | `writeq(+Term)`, `writeq(+Stream,+Term)` | Like `write`, but quotes atoms when required for unambiguous input syntax. |
6124
6141
  | `write_canonical(+Term)`, `write_canonical(+Stream,+Term)` | Writes quoted canonical functor notation while ignoring operators and without interpreting *$VAR/1*. |
6125
- | `write_term(+Term,+Options)`, `write_term(+Stream,+Term,+Options)` | Writes with *quoted(true or false)*, *ignore_ops(true or false)*, *numbervars(true or false)*, and *variable_names([Name=Variable,...])*. Normal mode also supports *double_quotes(true or false)*. |
6142
+ | `write_term(+Term,+Options)`, `write_term(+Stream,+Term,+Options)` | Writes with *quoted(true or false)*, *ignore_ops(true or false)*, *numbervars(true or false)*, and *variable_names([Name=Variable,...])*. Normal mode also supports *double_quotes(true or false)* and *spacing(minimal or standard)*. |
6126
6143
 
6127
6144
  Term input uses the program's current operator table and the same ISO quoted-character
6128
6145
  syntax as source text, including backslash-terminated octal and hexadecimal
@@ -6132,6 +6149,12 @@ when the `char_conversion` flag is `on`. `variable_names/1` and `singletons/1` o
6132
6149
  predicates do not append a period or newline; call `write/1`, then `write('.')`
6133
6150
  and `nl/0` when emitting a complete source term manually.
6134
6151
 
6152
+ A standalone numeric term read by `read/1-2` or `read_term/2-3` uses the same
6153
+ bounded numeric scanner and canonical value conversion as `number_chars/2`.
6154
+ Consequently every numeric character sequence accepted by `number_chars/2` has
6155
+ the same value when read as a full-stop-terminated term, without requiring EOF
6156
+ after that term.
6157
+
6135
6158
  ### Arithmetic expressions
6136
6159
 
6137
6160
  | Predicate and principal call | Behavior |
@@ -6209,7 +6232,7 @@ so side effects occur in Prolog execution order.
6209
6232
 
6210
6233
  ### Normal-mode cleanup controls
6211
6234
 
6212
- `call_cleanup/2` and `setup_call_cleanup/3` are normal EyeProlog runtime extensions rather than members of the isolated ISO builtin registry. They protect a goal across deterministic completion, exhaustion, cut, top-level abandonment, and exception unwinding, running Cleanup exactly once. `setup_call_cleanup/3` runs Setup once and installs Cleanup only after Setup succeeds. Nested cleanups run inside-out, and strict ISO mode does not provide either predicate.
6235
+ `call_cleanup/2` and `setup_call_cleanup/3` are normal EyeProlog runtime extensions rather than members of the isolated ISO builtin registry. They protect a goal across deterministic completion, exhaustion, cut, top-level abandonment, and exception unwinding, running Cleanup exactly once. `setup_call_cleanup/3` runs Setup once and installs Cleanup only after Setup succeeds. A successful Cleanup run before a deterministic answer contributes its substitutions to that answer. Nested cleanups run inside-out, and strict ISO mode does not provide either predicate.
6213
6236
 
6214
6237
  ### The EyeProlog library
6215
6238
 
@@ -6363,7 +6386,9 @@ conservative cross-engine subset.
6363
6386
  `length(Xs, N)` enumerates `Xs = [], N = 0`, then one-element lists with
6364
6387
  `N = 1`, and so on. Open-ended generation uses the normal memory guard with
6365
6388
  recovery headroom so finite-heap exhaustion remains a catchable
6366
- `resource_error(memory)`.
6389
+ `resource_error(memory)`. A supplied nonnegative length selects at most one
6390
+ answer, and a supplied closed list has exactly one length; these modes do not
6391
+ retain an exhausted choicepoint.
6367
6392
 
6368
6393
  `library(iso_ext)` is a common interop module name, but only part of its
6369
6394
  EyeProlog API belongs to the shared profile. `call_nth/2`, `time/1`, and
@@ -6815,11 +6840,15 @@ When another answer exists in an interactive terminal, press `;`, Space, or
6815
6840
  enumeration, `a` enumerates all remaining answers, and `f` advances to the
6816
6841
  next five-answer boundary (5, 10, 15, ... displayed leaf answers), regardless
6817
6842
  of how many answers were stepped through individually beforehand. `h` displays
6818
- the answer-control help. Enumeration is demand-driven: after an answer is
6819
- found, the top level does not pull a successor merely to discover whether the
6820
- current answer is the last one. Search for a later answer, including any side
6821
- effects reached on that path, starts only after an answer-control command asks
6822
- to continue. Stopping enumeration closes any active `call_cleanup/2` or
6843
+ the answer-control help. Enumeration of goal continuations is demand-driven:
6844
+ after an answer is found, the top level does not execute a later program branch
6845
+ or side effect merely to discover whether the current answer is the last one.
6846
+ The solver's uniform choicepoint protocol uses explicit clause and control
6847
+ frames, permits a one-answer buffer only for effect-free host relations, and
6848
+ requires stateful or meta-control iterators to report their pending state
6849
+ directly. Search that can perform an effect starts only after an answer-control
6850
+ command asks to continue. Stopping enumeration closes any active
6851
+ `call_cleanup/2` or
6823
6852
  `setup_call_cleanup/3` protection exactly once; detecting that a choicepoint
6824
6853
  remains does not execute that next branch. If an unresolved alternative
6825
6854
  ultimately has no solution, asking for it may therefore finish with `false.`. In scripted non-TTY input, a new
@@ -7767,8 +7796,8 @@ Notable implementation boundaries are:
7767
7796
  - `write_term/2-3` implements the Part 1 plus Corrigendum 3 `quoted/1`,
7768
7797
  `ignore_ops/1`, `numbervars/1`, and `variable_names/1` option surface,
7769
7798
  including option validation and traversal rules; normal mode also offers
7770
- `double_quotes(true|false)` as an explicitly non-standard extension, which
7771
- strict mode rejects;
7799
+ `double_quotes(true|false)` and `spacing(minimal|standard)` as explicitly
7800
+ implementation-specific extensions, which strict mode rejects;
7772
7801
  - unification consistently performs an occurs check, rejecting rational-tree
7773
7802
  bindings accepted as extensions by some systems.
7774
7803
 
@@ -7979,8 +8008,11 @@ decidable by structural equality.
7979
8008
  **Clause.** A fact or rule terminated by a period.
7980
8009
 
7981
8010
  **Choicepoint.** A remaining search alternative that may produce another
7982
- answer if the caller asks the solver to continue. EyeProlog's top level reports
7983
- choicepoint availability without speculatively executing the next alternative.
8011
+ answer if the caller asks the solver to continue. Every resumable engine
8012
+ iterator follows the same pending-alternative protocol. Ordinary effect-free
8013
+ host relations may buffer one answer to prove exhaustion; stateful and
8014
+ meta-control iterators report their state without executing an unrequested
8015
+ effect or program branch.
7984
8016
 
7985
8017
  **Cleanup.** A protected finalization goal installed by normal-mode
7986
8018
  `call_cleanup/2` or `setup_call_cleanup/3`. It runs exactly once when the