eyeprolog 1.5.80 → 1.5.82

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.
@@ -7,7 +7,7 @@ when this report is generated; it is not inferred from fixture counts.
7
7
  ## Latest Neumerkel evidence
8
8
 
9
9
  See the tracked [latest Neumerkel conformity report](test/conformance/NEUMERKEL-LATEST.md).
10
- `npm test` fetches all seven TU Wien sources once and executes the discovered inventory.
10
+ `npm test` fetches all eight TU Wien sources once and executes the discovered inventory.
11
11
  The release workflow then synchronizes this tracked report from those exact successful
12
12
  cached source bytes, avoiding a second live fetch and its race window.
13
13
 
@@ -0,0 +1,120 @@
1
+ % Defeasible reasoning: an employee travel-expense reimbursement policy.
2
+ %
3
+ % Defeasible rules state a default and let a more specific rule override it.
4
+ % Most of that is ordinary, stratified negation as failure (7.3, \+/1): compute
5
+ % the exception from facts, then let the default rule negate the exception.
6
+ % Nothing here needs the well-founded semantics (WFS) yet.
7
+ %
8
+ % The last section is different on purpose. It models two independent
9
+ % reimbursement policies that can both claim the same expense with no company
10
+ % rule saying which one wins — team_offsite_hotel qualifies for both the flat
11
+ % per-diem blanket allowance and itemized reimbursement, and neither policy
12
+ % was written anticipating the other. Three ways to handle that:
13
+ %
14
+ % 1) Plain \+/1, each side negating the other. That is an unstratified
15
+ % negation cycle: proving either side requires failing the other, which
16
+ % requires proving the first again. Try it (swap tnot/1 for \+/1 below
17
+ % and run with --warnings): the engine loops until it runs out of stack.
18
+ % 2) tnot/1 and wfs_truth/2. EyeProlog evaluates the same cycle under WFS
19
+ % and reports both sides `undefined` instead of picking one arbitrarily
20
+ % — honest, since the policies themselves do not resolve the conflict,
21
+ % but callers now have to know to ask wfs_truth/2 instead of just
22
+ % calling covered_by_blanket/1 or itemized/1 directly.
23
+ % 3) An explicit conflict predicate, the way examples/nixon-diamond.pl
24
+ % already handles the same shape of problem (two independent defaults,
25
+ % no priority between them): join the two eligibility facts directly and
26
+ % name the outcome. No negation, no cycle, nothing for --warnings to
27
+ % flag, and the result is an ordinary fact any caller can query without
28
+ % having to know WFS exists.
29
+ %
30
+ % Here (3) is the better fit, and is this codebase's actual convention for
31
+ % this situation. Reach for tnot/1 and wfs_truth/2 when the mutual defeat is
32
+ % already how the rules are naturally stated as each other's negation — a
33
+ % permission that holds unless its prohibition holds and vice versa, as
34
+ % examples/odrl-policy-reasoning.pl's profile rules do for a real policy
35
+ % language — not to manufacture a cycle for a conflict a direct join would
36
+ % already detect. And for everyday defeasible overriding, where a more
37
+ % specific rule is meant to win outright, plain stratified \+/1 already says
38
+ % exactly that and is ISO-portable; WFS would not change the answer there,
39
+ % only the machinery needed to get it.
40
+
41
+ %% goal: reimbursementQuestion(X0, X1, X2)
42
+ %% goal: conflictQuestion(X0, X1, X2)
43
+ %% goal: explicitConflictQuestion(X0, X1, X2)
44
+
45
+ % --- Submitted expenses and the facts a claims clerk would see ------------
46
+
47
+ submitted(taxi_receipt).
48
+ submitted(client_dinner_wine).
49
+ submitted(client_dinner_wine_preapproved).
50
+ submitted(team_offsite_hotel).
51
+
52
+ category(client_dinner_wine, alcohol).
53
+ category(client_dinner_wine_preapproved, alcohol).
54
+
55
+ % A manager can preapprove alcohol as part of client entertainment; that is
56
+ % the exception to the exception.
57
+ preapproved_entertainment(client_dinner_wine_preapproved).
58
+
59
+ % --- Ordinary defeasible tier: specificity resolves every case -------------
60
+ %
61
+ % default: an expense is reimbursable
62
+ % exception: ... unless it is alcohol
63
+ % exception to that: ... unless the alcohol was preapproved entertainment
64
+ %
65
+ % Each rule only negates a lower, already-computed layer, so this is
66
+ % stratified and plain \+/1 is all it takes.
67
+
68
+ excluded(Expense) :-
69
+ category(Expense, alcohol),
70
+ \+ preapproved_entertainment(Expense).
71
+
72
+ reimbursable(Expense) :-
73
+ submitted(Expense),
74
+ \+ excluded(Expense).
75
+
76
+ % --- The one case with a genuine, unresolved conflict, two ways ------------
77
+ %
78
+ % team_offsite_hotel independently qualifies for both the flat per-diem
79
+ % blanket allowance and itemized reimbursement.
80
+ blanket_eligible(team_offsite_hotel).
81
+ itemizable(team_offsite_hotel).
82
+
83
+ % 2) WFS via tnot/1: each classification defeats the other, leaving both
84
+ % `undefined` rather than an arbitrary pick.
85
+ covered_by_blanket(Expense) :-
86
+ blanket_eligible(Expense),
87
+ tnot(itemized(Expense)).
88
+ itemized(Expense) :-
89
+ itemizable(Expense),
90
+ tnot(covered_by_blanket(Expense)).
91
+
92
+ % 3) This codebase's usual idiom (examples/nixon-diamond.pl): detect the
93
+ % conflict directly from the two eligibility facts, no negation involved.
94
+ policy_conflict(Expense, blanket_allowance, itemized_reimbursement) :-
95
+ blanket_eligible(Expense),
96
+ itemizable(Expense).
97
+
98
+ % --- Curated questions -----------------------------------------------------
99
+
100
+ % Plain expense, alcohol excluded by default, and the preapproved override —
101
+ % all decided by ordinary negation as failure, no WFS involved.
102
+ reimbursementQuestion(plain, taxi_receipt, Verdict) :-
103
+ wfs_truth(reimbursable(taxi_receipt), Verdict).
104
+ reimbursementQuestion(alcohol_excluded, client_dinner_wine, Verdict) :-
105
+ wfs_truth(reimbursable(client_dinner_wine), Verdict).
106
+ reimbursementQuestion(alcohol_preapproved, client_dinner_wine_preapproved, Verdict) :-
107
+ wfs_truth(reimbursable(client_dinner_wine_preapproved), Verdict).
108
+
109
+ % The unresolved policy conflict under WFS: both classifications come back
110
+ % `undefined`, which only tells a caller anything if it remembers to ask
111
+ % wfs_truth/2 in the first place.
112
+ conflictQuestion(blanket_allowance, team_offsite_hotel, Verdict) :-
113
+ wfs_truth(covered_by_blanket(team_offsite_hotel), Verdict).
114
+ conflictQuestion(itemized_reimbursement, team_offsite_hotel, Verdict) :-
115
+ wfs_truth(itemized(team_offsite_hotel), Verdict).
116
+
117
+ % The same conflict, detected directly: an ordinary fact any caller can query
118
+ % without knowing WFS is involved at all.
119
+ explicitConflictQuestion(team_offsite_hotel, blanket_allowance, itemized_reimbursement) :-
120
+ policy_conflict(team_offsite_hotel, blanket_allowance, itemized_reimbursement).
@@ -0,0 +1,6 @@
1
+ reimbursementQuestion(plain, taxi_receipt, true).
2
+ reimbursementQuestion(alcohol_excluded, client_dinner_wine, false).
3
+ reimbursementQuestion(alcohol_preapproved, client_dinner_wine_preapproved, true).
4
+ conflictQuestion(blanket_allowance, team_offsite_hotel, undefined).
5
+ conflictQuestion(itemized_reimbursement, team_offsite_hotel, undefined).
6
+ explicitConflictQuestion(team_offsite_hotel, blanket_allowance, itemized_reimbursement).
package/index.d.ts CHANGED
@@ -96,6 +96,12 @@ export interface EyePrologQuadResult {
96
96
  kind?: 'failed' | 'malformed' | 'bad_identifier' | 'unsupported' | 'undecided';
97
97
  expected?: EyePrologTerm;
98
98
  reason?: string;
99
+ /** The quad's query term, for a caller building its own per-result label. */
100
+ query?: EyePrologTerm;
101
+ /** The quad's identifier, or null when the quad has none. */
102
+ id?: EyePrologTerm | null;
103
+ /** The source line this answer description starts on, when known. */
104
+ line?: number | null;
99
105
  }
100
106
 
101
107
  export interface EyePrologQuadRunResult {
@@ -312,6 +318,8 @@ export function hasForwardRules(program: Program): boolean;
312
318
  /** Execute EyeProlog `:+/2` rules to closure using an existing solver. */
313
319
  export function executeForwardRules(program: Program, solver: Solver, options?: EyePrologForwardRunOptions): EyePrologForwardRunResult;
314
320
  export function runQuads(source: string | Program, options?: EyePrologQuadRunOptions): EyePrologQuadRunResult;
321
+ /** Render a quad term (query, identifier, or expected answer) the way quad failure reports do. */
322
+ export function formatQuadTerm(program: Program, term: EyePrologTerm): string;
315
323
  export interface EyePrologProofMethod {
316
324
  type: 'source' | 'builtin' | 'library' | 'conjunction';
317
325
  kind?: 'fact' | 'rule';
@@ -433,6 +441,7 @@ declare const eyeprolog: {
433
441
  hasForwardRules: typeof hasForwardRules;
434
442
  executeForwardRules: typeof executeForwardRules;
435
443
  runQuads: typeof runQuads;
444
+ formatQuadTerm: typeof formatQuadTerm;
436
445
  proofCertificate: typeof proofCertificate;
437
446
  proofCertificatesFromText: typeof proofCertificatesFromText;
438
447
  verifyProof: typeof verifyProof;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.5.80",
6
+ "version": "1.5.82",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/playground.html CHANGED
@@ -498,6 +498,7 @@
498
498
  "deep-taxonomy-1000",
499
499
  "deep-taxonomy-10000",
500
500
  "deep-taxonomy-100000",
501
+ "defeasible-reasoning",
501
502
  "delfour",
502
503
  "deontic-logic",
503
504
  "derived-backward-rule",
package/src/explain.js CHANGED
@@ -69,7 +69,7 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
69
69
  env: proofEnv,
70
70
  node: {
71
71
  goal: resolveForProof(goal, proofEnv),
72
- method: builtinMethod(goal),
72
+ method: goalMethod('builtin', goal),
73
73
  sourceHead: resolveForProof(goal, proofEnv),
74
74
  sourceBody: [],
75
75
  bindings: [],
@@ -97,7 +97,7 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
97
97
  env: proofEnv,
98
98
  node: {
99
99
  goal: resolveForProof(goal, proofEnv),
100
- method: libraryMethod(goal),
100
+ method: goalMethod('library', goal),
101
101
  sourceHead: resolveForProof(goal, proofEnv),
102
102
  sourceBody: [],
103
103
  bindings: [],
@@ -254,17 +254,9 @@ function sourceMethod(clause, kind) {
254
254
  };
255
255
  }
256
256
 
257
- function builtinMethod(goal) {
257
+ function goalMethod(type, goal) {
258
258
  return {
259
- type: 'builtin',
260
- name: goal.type === COMPOUND ? goal.name : 'goal',
261
- arity: goal.type === COMPOUND ? goal.arity : 0,
262
- };
263
- }
264
-
265
- function libraryMethod(goal) {
266
- return {
267
- type: 'library',
259
+ type,
268
260
  name: goal.type === COMPOUND ? goal.name : 'goal',
269
261
  arity: goal.type === COMPOUND ? goal.arity : 0,
270
262
  };
@@ -380,15 +372,15 @@ function certificateText(term) {
380
372
  throw new Error('expected certificate text');
381
373
  }
382
374
 
383
- function containsLibraryBoundary(node) {
384
- if (node.method?.type === 'library') return true;
385
- return node.children.some(containsLibraryBoundary);
375
+ function proofTreeContains(node, predicate) {
376
+ return predicate(node) || node.children.some((child) => proofTreeContains(child, predicate));
386
377
  }
387
378
 
388
- function containsExpandedLibrarySource(node) {
389
- if (node.method?.type === 'source' && String(node.method.filename ?? '').startsWith('src/lib/')) return true;
390
- return node.children.some(containsExpandedLibrarySource);
391
- }
379
+ const containsLibraryBoundary = (node) => proofTreeContains(node, (n) => n.method?.type === 'library');
380
+
381
+ const containsExpandedLibrarySource = (node) => proofTreeContains(
382
+ node, (n) => n.method?.type === 'source' && String(n.method.filename ?? '').startsWith('src/lib/'),
383
+ );
392
384
 
393
385
  export function verifyProof(program, input, options = {}) {
394
386
  const certificate = input?.certificate ?? input;
@@ -557,8 +549,9 @@ function verifyBindings(bindings, variables, env, program) {
557
549
 
558
550
  function renderMethodTerm(method) {
559
551
  if (method && method.type === 'source') return `${method.kind}(${quoteString(method.filename)}, clause(${method.clause}))`;
560
- if (method && method.type === 'builtin') return `builtin(${quoteAtomText(method.name)}, ${method.arity})`;
561
- if (method && method.type === 'library') return `library(${quoteAtomText(method.name)}, ${method.arity})`;
552
+ if (method && (method.type === 'builtin' || method.type === 'library')) {
553
+ return `${method.type}(${quoteAtomText(method.name)}, ${method.arity})`;
554
+ }
562
555
  return String(method);
563
556
  }
564
557
 
package/src/index.js CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  eyePrologInteropLibraryModules,
29
29
  } from './standard-library.js';
30
30
  export { StreamManager } from './io.js';
31
- export { runQuads } from './quads.js';
31
+ export { formatQuadTerm, runQuads } from './quads.js';
32
32
  export { executeForwardRules, hasForwardRules } from './execute.js';
33
33
 
34
34
  import { installCleanupLifecycle } from './cleanup.js';
package/src/iso.js CHANGED
@@ -680,10 +680,7 @@ function validPredicateIndicator(term) {
680
680
  (term.args[1].type === NUMBER && isDecimalInteger(term.args[1].name) && BigInt(term.args[1].name) >= 0n));
681
681
  }
682
682
 
683
- function currentPredicateBuiltin(context) {
684
- const state = { pending: false };
685
- return withPendingState(currentPredicateSolutions(context, state), state);
686
- }
683
+ const currentPredicateBuiltin = pendingBuiltin(currentPredicateSolutions);
687
684
 
688
685
  function* currentPredicateSolutions({ solver, goal, env }, state) {
689
686
  const indicator = copyResolved(goal.args[0], env);
@@ -719,10 +716,7 @@ function clauseBodyTerm(body) {
719
716
  return result;
720
717
  }
721
718
 
722
- function clauseBuiltin(context) {
723
- const state = { pending: false };
724
- return withPendingState(clauseSolutions(context, state), state);
725
- }
719
+ const clauseBuiltin = pendingBuiltin(clauseSolutions);
726
720
 
727
721
  function* clauseSolutions({ solver, goal, env }, state) {
728
722
  const head = deref(goal.args[0], env);
@@ -851,12 +845,7 @@ function assertBuiltin(atStart) {
851
845
  };
852
846
  }
853
847
 
854
- function retractBuiltin(context) {
855
- const state = { pending: true };
856
- const iterator = retractSolutions(context, state);
857
- iterator.hasPendingAlternatives = () => state.pending;
858
- return iterator;
859
- }
848
+ const retractBuiltin = pendingBuiltin(retractSolutions, true);
860
849
 
861
850
  function* retractSolutions({ solver, goal, env }, state) {
862
851
  const parts = clauseParts(goal.args[0], env);
@@ -941,10 +930,7 @@ function* abolishBuiltin({ solver, goal, env }) {
941
930
  yield env;
942
931
  }
943
932
 
944
- function currentPrologFlagBuiltin(context) {
945
- const state = { pending: false };
946
- return withPendingState(currentPrologFlagSolutions(context, state), state);
947
- }
933
+ const currentPrologFlagBuiltin = pendingBuiltin(currentPrologFlagSolutions);
948
934
 
949
935
  function* currentPrologFlagSolutions({ solver, goal, env }, state) {
950
936
  const flag = deref(goal.args[0], env);
@@ -1058,10 +1044,7 @@ function* opBuiltin({ solver, goal, env }) {
1058
1044
  yield env;
1059
1045
  }
1060
1046
 
1061
- function currentOpBuiltin(context) {
1062
- const state = { pending: false };
1063
- return withPendingState(currentOpSolutions(context, state), state);
1064
- }
1047
+ const currentOpBuiltin = pendingBuiltin(currentOpSolutions);
1065
1048
 
1066
1049
  function* currentOpSolutions({ solver, goal, env }, state) {
1067
1050
  const priority = deref(goal.args[0], env);
@@ -1124,10 +1107,7 @@ function* charConversionBuiltin({ solver, goal, env }) {
1124
1107
  else solver.charConversions.set(input.name, output.name);
1125
1108
  yield env;
1126
1109
  }
1127
- function currentCharConversionBuiltin(context) {
1128
- const state = { pending: false };
1129
- return withPendingState(currentCharConversionSolutions(context, state), state);
1130
- }
1110
+ const currentCharConversionBuiltin = pendingBuiltin(currentCharConversionSolutions);
1131
1111
 
1132
1112
  function* currentCharConversionSolutions({ solver, goal, env }, state) {
1133
1113
  const input = conversionCharacter(goal.args[0], env, true, solver);
@@ -1355,28 +1335,23 @@ function* closeBuiltin({ solver, goal, env }) {
1355
1335
  yield env;
1356
1336
  }
1357
1337
 
1358
- function* currentInputBuiltin({ solver, goal, env }) {
1359
- const value = deref(goal.args[0], env);
1360
- if (value.type !== VAR) {
1361
- const id = streamTermReference(goal.args[0], env);
1362
- // A closed handle is still a stream-term, but cannot be current (#107).
1363
- // Validate its shape, then test identity rather than requiring an open stream.
1364
- if (id === solver.io.currentInput) yield env;
1365
- return;
1366
- }
1367
- const next = env.clone();
1368
- if (unify(goal.args[0], streamHandle(solver.io.currentInput), next)) yield next;
1369
- }
1370
- function* currentOutputBuiltin({ solver, goal, env }) {
1371
- const value = deref(goal.args[0], env);
1372
- if (value.type !== VAR) {
1373
- const id = streamTermReference(goal.args[0], env);
1374
- if (id === solver.io.currentOutput) yield env;
1375
- return;
1376
- }
1377
- const next = env.clone();
1378
- if (unify(goal.args[0], streamHandle(solver.io.currentOutput), next)) yield next;
1338
+ function currentStreamBuiltin(which) {
1339
+ // A closed handle is still a stream-term, but cannot be current (#107).
1340
+ // Validate its shape, then test identity rather than requiring an open stream.
1341
+ return function* ({ solver, goal, env }) {
1342
+ const value = deref(goal.args[0], env);
1343
+ const current = solver.io[which];
1344
+ if (value.type !== VAR) {
1345
+ const id = streamTermReference(goal.args[0], env);
1346
+ if (id === current) yield env;
1347
+ return;
1348
+ }
1349
+ const next = env.clone();
1350
+ if (unify(goal.args[0], streamHandle(current), next)) yield next;
1351
+ };
1379
1352
  }
1353
+ const currentInputBuiltin = currentStreamBuiltin('currentInput');
1354
+ const currentOutputBuiltin = currentStreamBuiltin('currentOutput');
1380
1355
 
1381
1356
  function setCurrentStreamBuiltin(mode) {
1382
1357
  return function* ({ solver, goal, env }) {
@@ -1456,10 +1431,7 @@ function isStreamPropertyPattern(value) {
1456
1431
  ].includes(value.name);
1457
1432
  }
1458
1433
 
1459
- function streamPropertyBuiltin(context) {
1460
- const state = { pending: false };
1461
- return withPendingState(streamPropertySolutions(context, state), state);
1462
- }
1434
+ const streamPropertyBuiltin = pendingBuiltin(streamPropertySolutions);
1463
1435
 
1464
1436
  function* streamPropertySolutions({ solver, goal, env }, state) {
1465
1437
  const reference = deref(goal.args[0], env);
@@ -2068,14 +2040,6 @@ function* writeTermBuiltin({ solver, goal, env }) {
2068
2040
  yield env;
2069
2041
  }
2070
2042
 
2071
- function resolvedOrVariable(term, env, expected) {
2072
- const value = deref(term, env);
2073
- if (value.type !== VAR && value.type !== expected) {
2074
- throw new PrologError(`type_error(${expected === ATOM ? 'atom' : 'number'})`, value);
2075
- }
2076
- return value;
2077
- }
2078
-
2079
2043
  function characters(text) {
2080
2044
  return Array.from(text);
2081
2045
  }
@@ -2095,10 +2059,7 @@ function* atomLengthBuiltin({ goal, env }) {
2095
2059
  if (unify(goal.args[1], numberTerm(characters(value.name).length), next)) yield next;
2096
2060
  }
2097
2061
 
2098
- function atomConcatBuiltin(context) {
2099
- const state = { pending: false };
2100
- return withPendingState(atomConcatSolutions(context, state), state);
2101
- }
2062
+ const atomConcatBuiltin = pendingBuiltin(atomConcatSolutions);
2102
2063
 
2103
2064
  function* atomConcatSolutions({ goal, env }, state) {
2104
2065
  const first = deref(goal.args[0], env);
@@ -2142,10 +2103,7 @@ function optionalInteger(term, env) {
2142
2103
  return BigInt(value.name);
2143
2104
  }
2144
2105
 
2145
- function subAtomBuiltin(context) {
2146
- const state = { pending: false };
2147
- return withPendingState(subAtomSolutions(context, state), state);
2148
- }
2106
+ const subAtomBuiltin = pendingBuiltin(subAtomSolutions);
2149
2107
 
2150
2108
  function* subAtomSolutions({ goal, env }, state) {
2151
2109
  const source = deref(goal.args[0], env);
@@ -2625,10 +2583,7 @@ function sortedUnique(items) {
2625
2583
  }
2626
2584
 
2627
2585
  function allSolutionsBuiltin(asSet) {
2628
- return function allSolutions(context) {
2629
- const state = { pending: false };
2630
- return withPendingState(allSolutionsGroups(context, asSet, state), state);
2631
- };
2586
+ return pendingBuiltin((context, state) => allSolutionsGroups(context, asSet, state));
2632
2587
  }
2633
2588
 
2634
2589
  function* allSolutionsGroups({ solver, goal, env }, asSet, state) {
@@ -2697,6 +2652,15 @@ function withPendingState(iterator, state) {
2697
2652
  iterator.hasPendingAlternatives = () => state.pending;
2698
2653
  return iterator;
2699
2654
  }
2655
+
2656
+ // Most builtins share this shape: create a { pending } cell, run a solutions
2657
+ // generator against it, and expose that cell through hasPendingAlternatives.
2658
+ function pendingBuiltin(solutionsFn, initialPending = false) {
2659
+ return (context) => {
2660
+ const state = { pending: initialPending };
2661
+ return withPendingState(solutionsFn(context, state), state);
2662
+ };
2663
+ }
2700
2664
  function validateControlCallable(term, culprit, env) {
2701
2665
  // Only control constructs need their nested goals validated at meta-call
2702
2666
  // entry. Walk them iteratively and dereference each nested goal lazily so
@@ -2770,9 +2734,9 @@ function expandCallGoal({ goal, env }) {
2770
2734
  if (converted.module == null) converted.module = module;
2771
2735
  return converted;
2772
2736
  }
2773
- function* callBuiltin(context) {
2737
+ function* invokeExpandedGoal(context, expand) {
2774
2738
  const { solver, env } = context;
2775
- const invoked = expandCallGoal(context);
2739
+ const invoked = expand(context);
2776
2740
  const child = solver.cloneForInnerGoal();
2777
2741
  try {
2778
2742
  yield* child.solve([invoked], env, 0);
@@ -2780,6 +2744,9 @@ function* callBuiltin(context) {
2780
2744
  solver.absorbStatsFrom(child);
2781
2745
  }
2782
2746
  }
2747
+ function* callBuiltin(context) {
2748
+ yield* invokeExpandedGoal(context, expandCallGoal);
2749
+ }
2783
2750
  function expandCallClosureGoal({ goal, env }) {
2784
2751
  const closure = callable(goal.args[0], env);
2785
2752
  const existing = closure.type === COMPOUND ? closure.args : [];
@@ -2792,14 +2759,7 @@ function expandCallClosureGoal({ goal, env }) {
2792
2759
  return invoked;
2793
2760
  }
2794
2761
  function* callClosureBuiltin(context) {
2795
- const { solver, env } = context;
2796
- const invoked = expandCallClosureGoal(context);
2797
- const child = solver.cloneForInnerGoal();
2798
- try {
2799
- yield* child.solve([invoked], env, 0);
2800
- } finally {
2801
- solver.absorbStatsFrom(child);
2802
- }
2762
+ yield* invokeExpandedGoal(context, expandCallClosureGoal);
2803
2763
  }
2804
2764
 
2805
2765
  export function* countAllBuiltin({ solver, goal, env }) {
@@ -2846,10 +2806,7 @@ function writeElapsedTime(solver, startedAt, inferences) {
2846
2806
  );
2847
2807
  }
2848
2808
 
2849
- export function timeBuiltin(context) {
2850
- const state = { pending: true };
2851
- return withPendingState(timeSolutions(context, state), state);
2852
- }
2809
+ export const timeBuiltin = pendingBuiltin(timeSolutions, true);
2853
2810
 
2854
2811
  function* timeSolutions({ solver, goal, env }, state) {
2855
2812
  const invoked = callable(goal.args[0], env);
@@ -2877,10 +2834,7 @@ function* timeSolutions({ solver, goal, env }, state) {
2877
2834
  }
2878
2835
  }
2879
2836
 
2880
- export function callNthBuiltin(context) {
2881
- const state = { pending: true };
2882
- return withPendingState(callNthSolutions(context, state), state);
2883
- }
2837
+ export const callNthBuiltin = pendingBuiltin(callNthSolutions, true);
2884
2838
 
2885
2839
  function* callNthSolutions({ solver, goal, env }, state) {
2886
2840
  const requestedTerm = deref(goal.args[1], env);
@@ -3005,37 +2959,7 @@ export function callResidueVarsBuiltin({ solver, goal, env }) {
3005
2959
  return iterator;
3006
2960
  }
3007
2961
 
3008
- function freezeBuiltin(context) {
3009
- const state = { pending: true };
3010
- return withPendingState(freezeSolutions(context, state), state);
3011
- }
3012
-
3013
- function* freezeSolutions({ solver, goal, env }, state) {
3014
- const watched = deref(goal.args[0], env);
3015
- if (watched.type !== VAR) {
3016
- const child = solver.cloneForInnerGoal();
3017
- try {
3018
- for (const answerEnv of child.solve([callable(goal.args[1], env)], env, 0)) {
3019
- state.pending = child.hasPendingAlternatives();
3020
- yield answerEnv;
3021
- if (!state.pending) return;
3022
- }
3023
- state.pending = false;
3024
- } finally {
3025
- solver.absorbStatsFrom(child);
3026
- }
3027
- return;
3028
- }
3029
- const next = env.clone();
3030
- next.delay(watched.name, goal.args[1], goal.module ?? 'user');
3031
- state.pending = false;
3032
- yield next;
3033
- }
3034
-
3035
- function phraseBuiltin(context) {
3036
- const state = { pending: true };
3037
- return withPendingState(phraseSolutions(context, state), state);
3038
- }
2962
+ const phraseBuiltin = pendingBuiltin(phraseSolutions, true);
3039
2963
 
3040
2964
  function* phraseSolutions({ solver, goal, env }, state) {
3041
2965
  const grammarBody0 = deref(goal.args[0], env);
@@ -3161,10 +3085,7 @@ function prologErrorBall(error) {
3161
3085
  if (hasDefaultGroundErrorShape(error) || termIsGround(term)) return term;
3162
3086
  return freshCopy(term, new Env());
3163
3087
  }
3164
- function catchBuiltin(context) {
3165
- const state = { pending: true };
3166
- return withPendingState(catchSolutions(context, state), state);
3167
- }
3088
+ const catchBuiltin = pendingBuiltin(catchSolutions, true);
3168
3089
 
3169
3090
  function* catchSolutions({ solver, goal, env }, state) {
3170
3091
  let child = null;
@@ -3293,12 +3214,7 @@ function* solveControlBranch(solver, goal, env, observePending = null) {
3293
3214
  yield answer;
3294
3215
  }
3295
3216
  }
3296
- function disjunctionBuiltin(context) {
3297
- const state = { pending: true };
3298
- const iterator = disjunctionSolutions(context, state);
3299
- iterator.hasPendingAlternatives = () => state.pending;
3300
- return iterator;
3301
- }
3217
+ const disjunctionBuiltin = pendingBuiltin(disjunctionSolutions, true);
3302
3218
  function* disjunctionSolutions({ solver, goal, env }, state) {
3303
3219
  const left = deref(goal.args[0], env);
3304
3220
  if (left.type === COMPOUND && left.name === '->' && left.arity === 2) {
@@ -3333,10 +3249,7 @@ function* disjunctionSolutions({ solver, goal, env }, state) {
3333
3249
  (pending) => { state.pending = pending; });
3334
3250
  state.pending = false;
3335
3251
  }
3336
- function ifThenBuiltin(context) {
3337
- const state = { pending: true };
3338
- return withPendingState(ifThenSolutions(context, state), state);
3339
- }
3252
+ const ifThenBuiltin = pendingBuiltin(ifThenSolutions, true);
3340
3253
 
3341
3254
  function* ifThenSolutions({ solver, goal, env }, state) {
3342
3255
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) {