eyeprolog 1.3.85 → 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.85",
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/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 }) {
@@ -2413,6 +2431,11 @@ function callable(term, env) {
2413
2431
  validateControlCallable(term, term, env);
2414
2432
  return term;
2415
2433
  }
2434
+
2435
+ function withPendingState(iterator, state) {
2436
+ iterator.hasPendingAlternatives = () => state.pending;
2437
+ return iterator;
2438
+ }
2416
2439
  function validateControlCallable(term, culprit, env) {
2417
2440
  // Only control constructs need their nested goals validated at meta-call
2418
2441
  // entry. Walk them iteratively and dereference each nested goal lazily so
@@ -2438,9 +2461,14 @@ function validateControlCallable(term, culprit, env) {
2438
2461
  }
2439
2462
  }
2440
2463
  }
2441
- function* callBuiltin({ solver, goal, env }) {
2464
+ function expandCallGoal({ goal, env }) {
2442
2465
  const invoked = callable(goal.args[0], env);
2443
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);
2444
2472
  const child = solver.cloneForInnerGoal();
2445
2473
  try {
2446
2474
  yield* child.solve([invoked], env, 0);
@@ -2448,7 +2476,7 @@ function* callBuiltin({ solver, goal, env }) {
2448
2476
  solver.absorbStatsFrom(child);
2449
2477
  }
2450
2478
  }
2451
- function* callClosureBuiltin({ solver, goal, env }) {
2479
+ function expandCallClosureGoal({ goal, env }) {
2452
2480
  const closure = callable(goal.args[0], env);
2453
2481
  const existing = closure.type === COMPOUND ? closure.args : [];
2454
2482
  const extra = goal.args.slice(1);
@@ -2457,6 +2485,11 @@ function* callClosureBuiltin({ solver, goal, env }) {
2457
2485
  }
2458
2486
  const invoked = compound(closure.name, [...existing, ...extra]);
2459
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);
2460
2493
  const child = solver.cloneForInnerGoal();
2461
2494
  try {
2462
2495
  yield* child.solve([invoked], env, 0);
@@ -2509,7 +2542,12 @@ function writeElapsedTime(solver, startedAt, inferences) {
2509
2542
  );
2510
2543
  }
2511
2544
 
2512
- 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) {
2513
2551
  const invoked = callable(goal.args[0], env);
2514
2552
  const child = solver.cloneForInnerGoal();
2515
2553
  let startedAt = monotonicMilliseconds();
@@ -2519,12 +2557,15 @@ function* timeBuiltin({ solver, goal, env }) {
2519
2557
  for (const answerEnv of child.solve([invoked], env, 0)) {
2520
2558
  writeElapsedTime(solver, startedAt, child.inferenceObservation.value - startedInferences);
2521
2559
  yieldedAny = true;
2560
+ state.pending = child.hasPendingAlternatives();
2522
2561
  yield answerEnv;
2562
+ if (!state.pending) return;
2523
2563
  // A resumed time/1 measures only the work required to reach the next
2524
2564
  // answer, so nondeterministic calls get one timing line per solution.
2525
2565
  startedAt = monotonicMilliseconds();
2526
2566
  startedInferences = child.inferenceObservation.value;
2527
2567
  }
2568
+ state.pending = false;
2528
2569
  // A call that fails without producing an answer still reports the work.
2529
2570
  if (!yieldedAny) writeElapsedTime(solver, startedAt, child.inferenceObservation.value - startedInferences);
2530
2571
  } finally {
@@ -2532,10 +2573,18 @@ function* timeBuiltin({ solver, goal, env }) {
2532
2573
  }
2533
2574
  }
2534
2575
 
2535
- 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) {
2536
2582
  const requestedTerm = deref(goal.args[1], env);
2537
2583
  // Zero is the one Nth value that fails before Goal is inspected.
2538
- 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
+ }
2539
2588
 
2540
2589
  const invoked = callable(goal.args[0], env);
2541
2590
  let requested = null;
@@ -2554,9 +2603,13 @@ function* callNthBuiltin({ solver, goal, env }) {
2554
2603
  nth++;
2555
2604
  if (requested != null && nth < requested) continue;
2556
2605
  const next = answerEnv.clone();
2557
- if (unify(goal.args[1], numberTerm(nth.toString()), next)) yield next;
2558
- 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
+ }
2559
2611
  }
2612
+ state.pending = false;
2560
2613
  } finally {
2561
2614
  solver.absorbStatsFrom(child);
2562
2615
  }
@@ -2648,12 +2701,22 @@ function callResidueVarsBuiltin({ solver, goal, env }) {
2648
2701
  return iterator;
2649
2702
  }
2650
2703
 
2651
- 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) {
2652
2710
  const watched = deref(goal.args[0], env);
2653
2711
  if (watched.type !== VAR) {
2654
2712
  const child = solver.cloneForInnerGoal();
2655
2713
  try {
2656
- 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;
2657
2720
  } finally {
2658
2721
  solver.absorbStatsFrom(child);
2659
2722
  }
@@ -2661,10 +2724,16 @@ function* freezeBuiltin({ solver, goal, env }) {
2661
2724
  }
2662
2725
  const next = env.clone();
2663
2726
  next.delay(watched.name, goal.args[1], goal.module ?? 'user');
2727
+ state.pending = false;
2664
2728
  yield next;
2665
2729
  }
2666
2730
 
2667
- 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) {
2668
2737
  const grammarBody0 = deref(goal.args[0], env);
2669
2738
  if (grammarBody0.type === VAR) throw new PrologError('instantiation_error');
2670
2739
  if (grammarBody0.type !== ATOM && grammarBody0.type !== COMPOUND) {
@@ -2721,7 +2790,12 @@ function* phraseBuiltin({ solver, goal, env }) {
2721
2790
  skipListTailTabling: !repeatedInvocation,
2722
2791
  });
2723
2792
  try {
2724
- 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;
2725
2799
  } finally {
2726
2800
  solver.absorbStatsFrom(child);
2727
2801
  solver.trimInnerTableScope('phrase');
@@ -2781,7 +2855,12 @@ function prologErrorBall(error) {
2781
2855
  if (hasDefaultGroundErrorShape(error) || termIsGround(term)) return term;
2782
2856
  return freshCopy(term, new Env());
2783
2857
  }
2784
- 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) {
2785
2864
  let child = null;
2786
2865
  try {
2787
2866
  // Corrigendum 2 removed catch/3's own callability errors so that errors
@@ -2798,12 +2877,19 @@ function* catchBuiltin({ solver, goal, env }) {
2798
2877
  if (result.done) solver.stats.deterministic_builtin_failures++;
2799
2878
  else {
2800
2879
  solver.stats.deterministic_builtin_successes++;
2880
+ state.pending = false;
2801
2881
  yield result.value;
2802
2882
  }
2883
+ state.pending = false;
2803
2884
  return;
2804
2885
  }
2805
2886
  child = solver.cloneForInnerGoal();
2806
- 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;
2807
2893
  } catch (error) {
2808
2894
  const ball = error instanceof ThrownTerm
2809
2895
  ? error.term
@@ -2814,12 +2900,18 @@ function* catchBuiltin({ solver, goal, env }) {
2814
2900
  const recovered = env.clone();
2815
2901
  if (!unify(goal.args[1], ball, recovered)) throw error;
2816
2902
  const recovery = callable(goal.args[2], recovered);
2817
- 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
+ }
2818
2907
  if (recovery.type === ATOM && recovery.name === 'true') {
2908
+ state.pending = false;
2819
2909
  yield recovered;
2820
2910
  return;
2821
2911
  }
2822
- yield* solver.solve([recovery], recovered, 0);
2912
+ yield* solveControlBranch(solver, recovery, recovered,
2913
+ (pending) => { state.pending = pending; });
2914
+ state.pending = false;
2823
2915
  } finally {
2824
2916
  if (child != null) solver.absorbStatsFrom(child);
2825
2917
  }
@@ -2917,11 +3009,19 @@ function* disjunctionSolutions({ solver, goal, env }, state) {
2917
3009
  (pending) => { state.pending = pending; });
2918
3010
  state.pending = false;
2919
3011
  }
2920
- 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) {
2921
3018
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) {
2922
- yield* solveControlBranch(solver, goal.args[1], conditionEnv);
3019
+ yield* solveControlBranch(solver, goal.args[1], conditionEnv,
3020
+ (pending) => { state.pending = pending; });
3021
+ state.pending = false;
2923
3022
  return;
2924
3023
  }
3024
+ state.pending = false;
2925
3025
  }
2926
3026
 
2927
3027
  export { arithmeticValueTerm, evaluateArithmetic, compareArithmeticValues } from './iso-arithmetic.js';
@@ -2940,6 +3040,7 @@ export class BuiltinRegistry {
2940
3040
  handler,
2941
3041
  deterministic: options.deterministic ?? false,
2942
3042
  deterministicWhen: options.deterministicWhen ?? null,
3043
+ expandGoal: options.expandGoal ?? null,
2943
3044
  ready: options.ready ?? null,
2944
3045
  fallbackWhenNotReady: options.fallbackWhenNotReady ?? false,
2945
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,50 +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
- }
1492
- }
1493
- state.pending = false;
1494
- }
1495
-
1496
- function bundledMemberMayMatch(target, cursor, env) {
1497
- while (isCons(cursor)) {
1498
- if (!termsCannotUnify(target, cursor.args[0], env)) return true;
1499
- cursor = deref(cursor.args[1], env);
1500
- }
1501
- return false;
1502
- }
1503
-
1504
- function termsCannotUnify(left, right, env) {
1505
- const pending = [[left, right]];
1506
- while (pending.length > 0) {
1507
- const [leftItem, rightItem] = pending.pop();
1508
- const actualLeft = derefForLocal(leftItem, env);
1509
- const actualRight = derefForLocal(rightItem, env);
1510
- if (actualLeft.type === VAR || actualRight.type === VAR) continue;
1511
- if (actualLeft.type !== actualRight.type || actualLeft.name !== actualRight.name ||
1512
- actualLeft.arity !== actualRight.arity) return true;
1513
- if (actualLeft.type !== COMPOUND) continue;
1514
- for (let index = 0; index < actualLeft.arity; index++) {
1515
- pending.push([actualLeft.args[index], actualRight.args[index]]);
1516
- }
1488
+ if (unify(goal.args[0], item, next)) yield next;
1517
1489
  }
1518
- return false;
1519
1490
  }
1520
1491
 
1521
1492
  function bundledEllipsisPlan(solver, group, goal, rest, env) {
@@ -1630,13 +1601,7 @@ function bundledLengthIterator(solver, group, goal, env) {
1630
1601
  if (length.type === VAR && cursor.name === length.name) return null;
1631
1602
  }
1632
1603
  }
1633
- const iterator = bundledLengthSolutions(solver, goal, env);
1634
- // A supplied length selects at most one list, and a closed supplied list has
1635
- // exactly one length. Only an open list paired with an output variable can
1636
- // produce another answer after the first one.
1637
- const hasOpenTail = cursor.type === VAR;
1638
- iterator.hasPendingAlternatives = () => length.type === VAR && hasOpenTail;
1639
- return iterator;
1604
+ return bundledLengthSolutions(solver, goal, env);
1640
1605
  }
1641
1606
 
1642
1607
  function* bundledLengthSolutions(solver, goal, env) {
@@ -2210,13 +2175,7 @@ function pushStreamingScalarFactRun(stack, solver, runGoals, groups, rest, env,
2210
2175
  const iterator = scalarFactRunSolutions(solver, runGoals, groups, env, depth, active);
2211
2176
  const first = iterator.next();
2212
2177
  if (first.done) return;
2213
- stack.push({
2214
- kind: 'resumeBuiltin',
2215
- iterator,
2216
- goals: rest,
2217
- depth: depth + runGoals.length,
2218
- active,
2219
- });
2178
+ pushResumeBuiltinFrame(stack, iterator, rest, depth + runGoals.length, active);
2220
2179
  stack.push({
2221
2180
  kind: 'goals',
2222
2181
  goals: rest,
@@ -2854,10 +2813,64 @@ function builtinIsReadyOrAuthoritative(def, solver, goal, env) {
2854
2813
 
2855
2814
  function iteratorMayHavePendingAlternatives(iterator) {
2856
2815
  const predicate = iterator?.hasPendingAlternatives;
2857
- 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);
2858
2870
  }
2859
2871
 
2860
2872
  function pushResumeBuiltinFrame(stack, iterator, goals, depth, active) {
2873
+ iterator = choicepointAwareIterator(iterator);
2861
2874
  if (!iteratorMayHavePendingAlternatives(iterator)) {
2862
2875
  // Finish the suspended generator now so its finally blocks release child
2863
2876
  // solvers, aggregate statistics, and perform predicate-local cleanup.
@@ -1999,11 +1999,13 @@ c4 ?- call((!;1)).
1999
1999
  },
2000
2000
  },
2001
2001
  {
2002
- 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)',
2003
2003
  run: () => {
2004
2004
  const result = runCli([], {
2005
2005
  input:
2006
2006
  'X=1;X=2.\n;\n' +
2007
+ 'call(X=1).\n' +
2008
+ 'call(=(X),1).\n' +
2007
2009
  'setof(X,true,Xs).\n' +
2008
2010
  'findall(t,false,Xs).\n' +
2009
2011
  'use_module(library(lists)).\n' +
@@ -2015,6 +2017,8 @@ c4 ?- call((!;1)).
2015
2017
  assertEqual(result.stdout,
2016
2018
  '?- X = 1\n' +
2017
2019
  '; X = 2.\n' +
2020
+ '?- X = 1.\n' +
2021
+ '?- X = 1.\n' +
2018
2022
  '?- Xs = [_A].\n' +
2019
2023
  '?- Xs = [].\n' +
2020
2024
  '?- true.\n' +
@@ -2044,6 +2048,16 @@ c4 ?- call((!;1)).
2044
2048
  };
2045
2049
 
2046
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);
2047
2061
  finalAnswerHasNoFrames('', 'setof(X,true,Xs)', 1);
2048
2062
  finalAnswerHasNoFrames('', 'findall(t,false,Xs)', 1);
2049
2063
  finalAnswerHasNoFrames(':- use_module(library(lists)).', 'member(a,"ba")', 1, 'lists');
@@ -2056,6 +2070,38 @@ c4 ?- call((!;1)).
2056
2070
  assertEqual(groupedSolutions.next().done, false, 'first grouped setof/3 answer');
2057
2071
  assertEqual(grouped.hasPendingAlternatives(), true, 'genuine grouped setof/3 choicepoint');
2058
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');
2059
2105
  },
2060
2106
  },
2061
2107
  {
@@ -5373,21 +5419,13 @@ answer(ok) :-
5373
5419
  import { Program, Solver, Env, deref, variable, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
5374
5420
  import { usedHeapSize } from ${JSON.stringify(platformUrl)};
5375
5421
  const program = Program.parse(${JSON.stringify(programText)}, { sourceMetadata: false });
5376
- // Exercise EyeProlog's recovery window at a stable threshold instead
5377
- // of relying on V8's version-dependent old-generation promotion.
5378
- const solver = new Solver(program, {
5379
- registry: getEyePrologRegistry(),
5380
- maxMemoryBytes: usedHeapSize() + 20 * 1024 * 1024,
5381
- });
5382
- const execute = (text) => {
5383
- const goal = parseGoalText(text, { operatorDefinitions: [...program.operators.values()] });
5384
- return [...solver.solve([goal], new Env(), 0)];
5385
- };
5422
+ const registry = getEyePrologRegistry();
5386
5423
  const reported = parseGoalText(${JSON.stringify(reportedGoal)}, {
5387
5424
  operatorDefinitions: [...program.operators.values()],
5388
5425
  });
5426
+ const reportedSolver = new Solver(program, { registry, maxMemoryBytes: Infinity });
5389
5427
  let reportedAnswers = 0;
5390
- for (const answer of solver.solve([reported], new Env(), 0)) {
5428
+ for (const answer of reportedSolver.solve([reported], new Env(), 0)) {
5391
5429
  reportedAnswers++;
5392
5430
  if (reportedAnswers !== 14) continue;
5393
5431
  if (deref(variable('I'), answer).name !== '13' || deref(variable('N'), answer).name !== '8192') {
@@ -5396,6 +5434,18 @@ answer(ok) :-
5396
5434
  break;
5397
5435
  }
5398
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
+ };
5399
5449
  let caught = null;
5400
5450
  try { execute('length(L,32768),l(L)'); } catch (error) { caught = error; }
5401
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.">
@@ -6831,11 +6840,15 @@ When another answer exists in an interactive terminal, press `;`, Space, or
6831
6840
  enumeration, `a` enumerates all remaining answers, and `f` advances to the
6832
6841
  next five-answer boundary (5, 10, 15, ... displayed leaf answers), regardless
6833
6842
  of how many answers were stepped through individually beforehand. `h` displays
6834
- the answer-control help. Enumeration is demand-driven: after an answer is
6835
- found, the top level does not pull a successor merely to discover whether the
6836
- current answer is the last one. Search for a later answer, including any side
6837
- effects reached on that path, starts only after an answer-control command asks
6838
- 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
6839
6852
  `setup_call_cleanup/3` protection exactly once; detecting that a choicepoint
6840
6853
  remains does not execute that next branch. If an unresolved alternative
6841
6854
  ultimately has no solution, asking for it may therefore finish with `false.`. In scripted non-TTY input, a new
@@ -7995,8 +8008,11 @@ decidable by structural equality.
7995
8008
  **Clause.** A fact or rule terminated by a period.
7996
8009
 
7997
8010
  **Choicepoint.** A remaining search alternative that may produce another
7998
- answer if the caller asks the solver to continue. EyeProlog's top level reports
7999
- 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.
8000
8016
 
8001
8017
  **Cleanup.** A protected finalization goal installed by normal-mode
8002
8018
  `call_cleanup/2` or `setup_call_cleanup/3`. It runs exactly once when the