eyeprolog 1.3.13 → 1.3.15

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/README.md CHANGED
@@ -201,8 +201,25 @@ noun --> [world] | [prolog].
201
201
  %% goal: phrase(sentence, Words)
202
202
  ```
203
203
 
204
- EyeProlog also adds 126 public library predicate indicators to its 129-entry ISO
205
- profile. **87 are implemented entirely as ordinary Prolog clauses** in focused
204
+ For a larger bidirectional example, see
205
+ [`examples/dcg-expression-language.pl`](examples/dcg-expression-language.pl).
206
+ It parses precedence-sensitive arithmetic into an AST, evaluates expressions
207
+ with variables, generates tokens back from an AST with only the necessary
208
+ parentheses, round-trips the generated form, and demonstrates `phrase/3`
209
+ remainder handling. The example uses accumulator nonterminals for
210
+ left-associative operators, so state is handed repeatedly from one nonterminal
211
+ to the next rather than hidden in host code.
212
+
213
+ Deep finite DCG traversal is kept relational but does not have to consume one
214
+ general solver frame per token. In particular, the interoperable `...//0`
215
+ helper from `library(iso_ext)` can scan a finite compact list iteratively, and a
216
+ following grammar that is statically known to leave the DCG state unchanged can
217
+ be continued without rebuilding a full clause-resolution frame for every
218
+ suffix. Open and remainder-producing uses still retain the ordinary DCG
219
+ solutions and backtracking behavior.
220
+
221
+ EyeProlog also adds 128 public library predicate indicators to its 129-entry ISO
222
+ profile. **88 are implemented entirely as ordinary Prolog clauses** in focused
206
223
  modules under `src/lib/`; the remaining control predicates and finite-domain
207
224
  `library(clpz)` kernel use backtrackable host support.
208
225
  They are ISO/IEC 13211-2 modules loaded explicitly by purpose, such as
@@ -225,8 +242,13 @@ headroom, so an exhausted finite heap is reported as a catchable
225
242
  `resource_error(memory)` instead of degenerating into quadratic list checks.
226
243
 
227
244
  `library(iso_ext)` is also accepted as a common interop module name.
228
- EyeProlog exports `call_nth/2` there, so Scryer-style source can explicitly use
229
- `:- use_module(library(iso_ext)).`; unqualified source may still autoload it.
245
+ EyeProlog exports `call_nth/2`, `time/1`, and the DCG helper `...//0` there.
246
+ The latter describes an arbitrary number of input elements and supports the
247
+ nonterminal hand-off benchmark discussed in issue #49. These common predicates
248
+ may be imported explicitly, while source/CLI/API dependency loading can resolve
249
+ their unqualified forms conservatively. For Trealla-style interactive timing,
250
+ `time/1` is also available directly in the normal EyeProlog runtime; strict ISO
251
+ mode does not expose it.
230
252
  The aligned `library(lists)` and `library(iso_ext)` exports are kept disjoint so
231
253
  they can be imported together without an accidental import conflict. EyeProlog's
232
254
  legacy `library(prologue)` remains a compatibility umbrella and should be
@@ -0,0 +1,147 @@
1
+ % Parse, evaluate, and regenerate arithmetic expressions with a DCG.
2
+ %
3
+ % The parser builds an AST while respecting precedence and left associativity.
4
+ % The additive_tail//2 and multiplicative_tail//2 nonterminals use an
5
+ % accumulator instead of left recursion. A second DCG pretty-prints an AST
6
+ % with only the parentheses required to preserve its structure, making the
7
+ % example useful in both directions.
8
+ %% goal: dcg_expression_example(X0, X1)
9
+
10
+ % expression//1: + and - are the lowest-precedence operators.
11
+ expression(AST) -->
12
+ term(First),
13
+ additive_tail(First, AST).
14
+
15
+ additive_tail(Left, AST) -->
16
+ ['+'],
17
+ term(Right),
18
+ { Next = add(Left, Right) },
19
+ additive_tail(Next, AST).
20
+ additive_tail(Left, AST) -->
21
+ ['-'],
22
+ term(Right),
23
+ { Next = sub(Left, Right) },
24
+ additive_tail(Next, AST).
25
+ additive_tail(AST, AST) --> [].
26
+
27
+ % term//1: * and / bind more tightly than + and -.
28
+ term(AST) -->
29
+ unary(First),
30
+ multiplicative_tail(First, AST).
31
+
32
+ multiplicative_tail(Left, AST) -->
33
+ ['*'],
34
+ unary(Right),
35
+ { Next = mul(Left, Right) },
36
+ multiplicative_tail(Next, AST).
37
+ multiplicative_tail(Left, AST) -->
38
+ ['/'],
39
+ unary(Right),
40
+ { Next = div(Left, Right) },
41
+ multiplicative_tail(Next, AST).
42
+ multiplicative_tail(AST, AST) --> [].
43
+
44
+ % Unary minus nests, so --x is represented as neg(neg(var(x))).
45
+ unary(neg(AST)) --> ['-'], unary(AST).
46
+ unary(AST) --> primary(AST).
47
+
48
+ primary(lit(Number)) -->
49
+ [Number],
50
+ { number(Number) }.
51
+ primary(var(Name)) -->
52
+ [Name],
53
+ { variable_name(Name) }.
54
+ primary(AST) --> ['('], expression(AST), [')'].
55
+
56
+ variable_name(x).
57
+ variable_name(y).
58
+ variable_name(z).
59
+
60
+ % A small evaluator for the AST produced by the grammar.
61
+ evaluate(lit(Number), _, Number).
62
+ evaluate(var(Name), Environment, Value) :-
63
+ lookup(Name, Environment, Value).
64
+ evaluate(neg(AST), Environment, Value) :-
65
+ evaluate(AST, Environment, Inner),
66
+ Value is -Inner.
67
+ evaluate(add(Left, Right), Environment, Value) :-
68
+ evaluate(Left, Environment, L),
69
+ evaluate(Right, Environment, R),
70
+ Value is L + R.
71
+ evaluate(sub(Left, Right), Environment, Value) :-
72
+ evaluate(Left, Environment, L),
73
+ evaluate(Right, Environment, R),
74
+ Value is L - R.
75
+ evaluate(mul(Left, Right), Environment, Value) :-
76
+ evaluate(Left, Environment, L),
77
+ evaluate(Right, Environment, R),
78
+ Value is L * R.
79
+ evaluate(div(Left, Right), Environment, Value) :-
80
+ evaluate(Left, Environment, L),
81
+ evaluate(Right, Environment, R),
82
+ Value is L / R.
83
+
84
+ lookup(Name, [Name-Value|_], Value).
85
+ lookup(Name, [_|Rest], Value) :-
86
+ lookup(Name, Rest, Value).
87
+
88
+ % Precedence-aware generation. The right operand is emitted at a stricter
89
+ % minimum precedence than the left operand, preserving left associativity.
90
+ emit_expression(AST) --> emit(AST, 0).
91
+
92
+ emit(AST, Minimum) -->
93
+ { precedence(AST, Precedence),
94
+ parenthesize(Precedence, Minimum, Wrap) },
95
+ emit_wrapped(Wrap, AST).
96
+
97
+ emit_wrapped(yes, AST) --> ['('], emit_node(AST), [')'].
98
+ emit_wrapped(no, AST) --> emit_node(AST).
99
+
100
+ emit_node(lit(Number)) --> [Number].
101
+ emit_node(var(Name)) --> [Name].
102
+ emit_node(neg(AST)) --> ['-'], emit(AST, 30).
103
+ emit_node(add(Left, Right)) -->
104
+ emit(Left, 10), ['+'], emit(Right, 11).
105
+ emit_node(sub(Left, Right)) -->
106
+ emit(Left, 10), ['-'], emit(Right, 11).
107
+ emit_node(mul(Left, Right)) -->
108
+ emit(Left, 20), ['*'], emit(Right, 21).
109
+ emit_node(div(Left, Right)) -->
110
+ emit(Left, 20), ['/'], emit(Right, 21).
111
+
112
+ precedence(add(_, _), 10).
113
+ precedence(sub(_, _), 10).
114
+ precedence(mul(_, _), 20).
115
+ precedence(div(_, _), 20).
116
+ precedence(neg(_), 30).
117
+ precedence(lit(_), 40).
118
+ precedence(var(_), 40).
119
+
120
+ parenthesize(Precedence, Minimum, yes) :- Precedence < Minimum.
121
+ parenthesize(Precedence, Minimum, no) :- Precedence >= Minimum.
122
+
123
+ % Parse a mixed-precedence expression.
124
+ dcg_expression_example(parsed, AST) :-
125
+ phrase(expression(AST),
126
+ [2, '+', 3, '*', '(', 4, '-', 1, ')']).
127
+
128
+ % Evaluate a parsed expression with variables.
129
+ dcg_expression_example(evaluated, Value) :-
130
+ phrase(expression(AST), [x, '*', '(', y, '+', 2, ')', '-', z]),
131
+ evaluate(AST, [x-4, y-3, z-1], Value).
132
+
133
+ % Generate the minimal parentheses needed to preserve a right-nested
134
+ % subtraction tree, then parse the generated tokens back to the same AST.
135
+ dcg_expression_example(round_trip, Tokens) :-
136
+ AST = sub(lit(20), sub(lit(5), lit(3))),
137
+ phrase(emit_expression(AST), Tokens),
138
+ phrase(expression(AST), Tokens).
139
+
140
+ % phrase/3 lets a larger language parse an expression prefix and keep the rest.
141
+ dcg_expression_example(remainder, Rest) :-
142
+ phrase(expression(mul(var(x), lit(2))),
143
+ [x, '*', 2, then, stop], Rest).
144
+
145
+ % Missing closing parentheses are rejected.
146
+ dcg_expression_example(rejected, malformed_parentheses) :-
147
+ \+ phrase(expression(_), [2, '*', '(', 3, '+', 4]).
@@ -0,0 +1,5 @@
1
+ dcg_expression_example(parsed, add(lit(2), mul(lit(3), sub(lit(4), lit(1))))).
2
+ dcg_expression_example(evaluated, 19).
3
+ dcg_expression_example(round_trip, [20, -, '(', 5, -, 3, ')']).
4
+ dcg_expression_example(remainder, [then, stop]).
5
+ dcg_expression_example(rejected, malformed_parentheses).
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.13",
6
+ "version": "1.3.15",
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
@@ -481,6 +481,7 @@
481
481
  "critical-path-schedule",
482
482
  "cyclic-path",
483
483
  "dcg-command-parser",
484
+ "dcg-expression-language",
484
485
  "d3-group",
485
486
  "dairy-energy-balance",
486
487
  "data-negotiation",
package/src/dcg.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Grammar rules are lowered to ordinary clauses during program preparation;
3
3
  // phrase/2-3 use the same body expansion for dynamically supplied grammars.
4
4
  import {
5
- ATOM, COMPOUND, VAR, Env, atom, compound, deref, emptyList,
5
+ ATOM, COMPOUND, VAR, Env, atom, compactListLength, compound, deref, emptyList,
6
6
  flattenConjunction, variable,
7
7
  } from './term.js';
8
8
  import { PrologError } from './iso.js';
@@ -280,6 +280,11 @@ export function expandDcgRuleClause(clause, defaultModule = 'user') {
280
280
  export function isListOrPartialList(term, env) {
281
281
  const seen = new Set();
282
282
  let cursor = deref(term, env);
283
+ // A compact list skeleton has a fixed proper spine by construction. Its
284
+ // elements may later receive bindings, but those bindings cannot change the
285
+ // list tail shape, so phrase/2-3 need not expand the entire lazy spine merely
286
+ // to validate it as a list.
287
+ if (compactListLength(cursor) != null) return true;
283
288
  while (cursor.type === COMPOUND && cursor.name === '.' && cursor.arity === 2) {
284
289
  if (seen.has(cursor)) return false;
285
290
  seen.add(cursor);
package/src/iso.js CHANGED
@@ -176,6 +176,8 @@ export const eyePrologLibraryBuiltins = {
176
176
  registry.add('eyeprolog__call_nth', 2, callNthBuiltin, { eyePrologLibrary: true });
177
177
  registry.add('eyeprolog__countall', 2, countAllBuiltin, { eyePrologLibrary: true });
178
178
  registry.add('eyeprolog__freeze', 2, freezeBuiltin, { eyePrologLibrary: true });
179
+ registry.add('time', 1, timeBuiltin, { eyePrologLibrary: true });
180
+ registry.add('eyeprolog__time', 1, timeBuiltin, { eyePrologLibrary: true });
179
181
  },
180
182
  };
181
183
 
@@ -1899,29 +1901,31 @@ const bagofBuiltin = allSolutionsBuiltin(false);
1899
1901
  const setofBuiltin = allSolutionsBuiltin(true);
1900
1902
 
1901
1903
  function callable(term, env) {
1902
- term = resolveCallable(term, env);
1904
+ term = deref(term, env);
1903
1905
  if (term.type === VAR) throw new PrologError('instantiation_error');
1904
1906
  if (term.type !== ATOM && term.type !== COMPOUND) throw new PrologError('type_error(callable)', term);
1905
- validateControlCallable(term, term);
1907
+ validateControlCallable(term, term, env);
1906
1908
  return term;
1907
1909
  }
1908
- function validateControlCallable(term, culprit) {
1909
- if (term.type !== COMPOUND || ![',', ';', '->'].includes(term.name) || term.arity !== 2) return;
1910
- for (const argument of term.args) {
1911
- if (argument.type === VAR) throw new PrologError('instantiation_error');
1912
- if (argument.type !== ATOM && argument.type !== COMPOUND) {
1913
- throw new PrologError('type_error(callable)', culprit);
1910
+ function validateControlCallable(term, culprit, env) {
1911
+ // Only control constructs need their nested goals validated at meta-call
1912
+ // entry. Walk them iteratively and dereference each nested goal lazily so
1913
+ // passing a callable that contains a very deep data term (for example
1914
+ // phrase(a, List) with an 8k-cell List) never consumes the JavaScript stack.
1915
+ const pending = [term];
1916
+ while (pending.length > 0) {
1917
+ const current = deref(pending.pop(), env);
1918
+ if (current.type !== COMPOUND || ![',', ';', '->'].includes(current.name) || current.arity !== 2) continue;
1919
+ for (let index = current.arity - 1; index >= 0; index--) {
1920
+ const argument = deref(current.args[index], env);
1921
+ if (argument.type === VAR) throw new PrologError('instantiation_error');
1922
+ if (argument.type !== ATOM && argument.type !== COMPOUND) {
1923
+ throw new PrologError('type_error(callable)', culprit);
1924
+ }
1925
+ pending.push(argument);
1914
1926
  }
1915
- validateControlCallable(argument, culprit);
1916
1927
  }
1917
1928
  }
1918
- function resolveCallable(term, env) {
1919
- const resolved = deref(term, env);
1920
- if (resolved.type !== COMPOUND) return resolved;
1921
- const callable = compound(resolved.name, resolved.args.map((arg) => resolveCallable(arg, env)));
1922
- if (resolved.module != null) callable.module = resolved.module;
1923
- return callable;
1924
- }
1925
1929
  function* callBuiltin({ solver, goal, env }) {
1926
1930
  const child = solver.cloneForInnerGoal();
1927
1931
  try {
@@ -1972,6 +1976,44 @@ function* countAllBuiltin({ solver, goal, env }) {
1972
1976
  if (unify(goal.args[1], numberTerm(count), next)) yield next;
1973
1977
  }
1974
1978
 
1979
+ function monotonicMilliseconds() {
1980
+ return globalThis.performance?.now?.() ?? Date.now();
1981
+ }
1982
+
1983
+ function writeElapsedTime(solver, startedAt, inferences) {
1984
+ const stream = solver.io.resolve(solver.io.currentOutput);
1985
+ if (stream?.type !== 'text') throw new PrologError('permission_error(output, binary_stream)');
1986
+ const elapsedSeconds = Math.max(0, monotonicMilliseconds() - startedAt) / 1000;
1987
+ const mlips = elapsedSeconds > 0 ? inferences / elapsedSeconds / 1_000_000 : 0;
1988
+ solver.io.writeUnit(
1989
+ stream,
1990
+ `% Time elapsed ${elapsedSeconds.toFixed(3)}s, ${inferences} Inferences, ${mlips.toFixed(3)} MLips\n`,
1991
+ );
1992
+ }
1993
+
1994
+ function* timeBuiltin({ solver, goal, env }) {
1995
+ const invoked = callable(goal.args[0], env);
1996
+ const child = solver.cloneForInnerGoal();
1997
+ let startedAt = monotonicMilliseconds();
1998
+ let startedInferences = child.inferenceObservation.value;
1999
+ let yieldedAny = false;
2000
+ try {
2001
+ for (const answerEnv of child.solve([invoked], env, 0)) {
2002
+ writeElapsedTime(solver, startedAt, child.inferenceObservation.value - startedInferences);
2003
+ yieldedAny = true;
2004
+ yield answerEnv;
2005
+ // A resumed time/1 measures only the work required to reach the next
2006
+ // answer, so nondeterministic calls get one timing line per solution.
2007
+ startedAt = monotonicMilliseconds();
2008
+ startedInferences = child.inferenceObservation.value;
2009
+ }
2010
+ // A call that fails without producing an answer still reports the work.
2011
+ if (!yieldedAny) writeElapsedTime(solver, startedAt, child.inferenceObservation.value - startedInferences);
2012
+ } finally {
2013
+ solver.absorbStatsFrom(child);
2014
+ }
2015
+ }
2016
+
1975
2017
  function* callNthBuiltin({ solver, goal, env }) {
1976
2018
  const requestedTerm = deref(goal.args[1], env);
1977
2019
  // Zero is the one Nth value that fails before Goal is inspected.
@@ -7,17 +7,21 @@
7
7
  succ/2,
8
8
  cfor/3,
9
9
  findall/4,
10
- variant/2
10
+ variant/2,
11
+ time/1,
12
+ '...'/2
11
13
  ]).
12
14
 
13
15
  :- meta_predicate(call_nth(0, '?')).
14
16
  :- meta_predicate(countall(0, '?')).
15
17
  :- meta_predicate(forall(0, 0)).
16
18
  :- meta_predicate(findall('?', 0, '?', '?')).
19
+ :- meta_predicate(time(0)).
17
20
 
18
21
  % The organization and predicate contracts follow library(iso_ext) in
19
- % Trealla. These definitions use only EyeProlog's ISO profile; extensions that
20
- % require runtime cleanup, choice-point, alarm, or timeout hooks are omitted.
22
+ % Trealla. Most definitions use only EyeProlog's ISO profile. time/1 is the
23
+ % deliberate exception: its private adapter supplies monotonic host timing and
24
+ % writes the measurement while the public wrapper keeps normal meta semantics.
21
25
 
22
26
  call_nth(Goal, Nth) :- eyeprolog__call_nth(Goal, Nth).
23
27
 
@@ -59,6 +63,16 @@ variant(X, Y) :-
59
63
  subsumes_term(CopyX, CopyY),
60
64
  subsumes_term(CopyY, CopyX).
61
65
 
66
+ % Trealla-compatible timing wrapper. The private adapter measures the callable
67
+ % while this Prolog wrapper supplies normal module/meta-predicate semantics.
68
+ time(Goal) :- eyeprolog__time(Goal).
69
+
70
+ % Trealla/Scryer DCG helper: describes an arbitrary number of elements.
71
+ % EyeProlog's two-clause form has the same finite-input relation without the
72
+ % Trealla-specific empty-input cut guard, so recursive calls stay cut-free.
73
+ '...' --> [].
74
+ '...' --> [_], '...' .
75
+
62
76
  iso_ext__call_all([]).
63
77
  iso_ext__call_all([Goal|Goals]) :-
64
78
  call(Goal),
package/src/repl.js CHANGED
@@ -526,6 +526,7 @@ async function readSource(designation) {
526
526
  async function solveQuery(engine, state, goal, reader, output) {
527
527
  const variables = queryVariables(goal);
528
528
  const solver = state.solver;
529
+ const demandDriven = containsTimedGoal(goal);
529
530
  solver.solutionsSeen = 0;
530
531
  const solutions = solver.solve([goal], new engine.Env(), 0);
531
532
  let current = pullSolution(solver, solutions, reader);
@@ -544,11 +545,12 @@ async function solveQuery(engine, state, goal, reader, output) {
544
545
  let firstAnswer = true;
545
546
  let formattingAfterAdvance = false;
546
547
  while (!current.result.done) {
547
- const next = pullSolution(solver, solutions, reader);
548
- // The control prompt has no trailing space while it waits for input. The
549
- // first space appears as soon as the user requests another solution and
550
- // remains visible while pullSolution() computes; the second appears only
551
- // when the requested leaf answer is ready to format.
548
+ // Ordinary queries keep the existing eager look-ahead so deterministic
549
+ // answers can end with a full stop without showing an unnecessary answer
550
+ // prompt. `time/1` is different: running a future solution changes what is
551
+ // being measured and can retain a very large current substitution. Timed
552
+ // queries therefore advance only after the user asks for another answer.
553
+ const next = demandDriven ? null : pullSolution(solver, solutions, reader);
552
554
  if (formattingAfterAdvance) output.write(' ');
553
555
  formattingAfterAdvance = false;
554
556
  output.write(current.output);
@@ -556,10 +558,8 @@ async function solveQuery(engine, state, goal, reader, output) {
556
558
  output.write(`${firstAnswer ? ' ' : ''}${answer}`);
557
559
  answersShown++;
558
560
  firstAnswer = false;
559
- if (!next.error && next.result.done) {
560
- // A terminal full stop cannot immediately follow a graphic token: the
561
- // scanner would absorb it into that token. Insert layout so the printed
562
- // answer remains valid Prolog text (issue #44).
561
+
562
+ if (demandDriven ? !solver.hasPendingAlternatives() : (!next.error && next.result.done)) {
563
563
  output.write(`${continuesGraphicToken(answer, answer.length) ? ' ' : ''}.\n`);
564
564
  return null;
565
565
  }
@@ -584,9 +584,6 @@ async function solveQuery(engine, state, goal, reader, output) {
584
584
  break;
585
585
  }
586
586
  if (control === 'f') {
587
- // `f` groups leaf answers in blocks of five, rather than merely
588
- // adding five more answers after whatever the user has already
589
- // inspected. Stop for control again at the next 5-answer boundary.
590
587
  const remainder = answersShown % 5;
591
588
  const answersToBoundary = remainder === 0 ? 5 : 5 - remainder;
592
589
  automatic = answersToBoundary - 1;
@@ -606,6 +603,29 @@ async function solveQuery(engine, state, goal, reader, output) {
606
603
  formattingAfterAdvance = true;
607
604
  }
608
605
 
606
+ if (demandDriven) {
607
+ // The displayed timed answer is no longer needed. Drop it before
608
+ // resuming search so a large list substitution does not remain live only
609
+ // because the top level is looking for its successor.
610
+ current = null;
611
+ const requested = pullSolution(solver, solutions, reader);
612
+ if (requested.error) {
613
+ if (formattingAfterAdvance) output.write(' ');
614
+ formattingAfterAdvance = false;
615
+ output.write(requested.output);
616
+ if (requested.error?.name === 'HaltSignal') return { halted: true, code: requested.error.code };
617
+ throw requested.error;
618
+ }
619
+ if (requested.result.done) {
620
+ if (formattingAfterAdvance) output.write(' ');
621
+ formattingAfterAdvance = false;
622
+ output.write(`${requested.output}false.\n`);
623
+ return null;
624
+ }
625
+ current = requested;
626
+ continue;
627
+ }
628
+
609
629
  if (next.error) {
610
630
  if (formattingAfterAdvance) output.write(' ');
611
631
  formattingAfterAdvance = false;
@@ -618,6 +638,17 @@ async function solveQuery(engine, state, goal, reader, output) {
618
638
  return null;
619
639
  }
620
640
 
641
+ function containsTimedGoal(goal) {
642
+ const stack = [goal];
643
+ while (stack.length !== 0) {
644
+ const term = stack.pop();
645
+ if (term?.type !== 'compound') continue;
646
+ if (term.name === 'time' && term.arity === 1) return true;
647
+ for (let index = term.args.length - 1; index >= 0; index--) stack.push(term.args[index]);
648
+ }
649
+ return false;
650
+ }
651
+
621
652
  function pullSolution(solver, solutions, reader) {
622
653
  const stream = solver.io.resolve('user_output');
623
654
  const originalWrite = stream?.write;
package/src/solver.js CHANGED
@@ -58,6 +58,10 @@ export class Solver {
58
58
  this.depthLimitExceeded = false;
59
59
  this.maxInferences = options.maxInferences ?? Infinity;
60
60
  this.inferences = 0;
61
+ // Shared only for observability: nested meta-call solvers contribute to the
62
+ // same measurement counter without changing each solver's local inference
63
+ // limit accounting. time/1 snapshots this counter around the measured goal.
64
+ this.inferenceObservation = options.inferenceObservation ?? { value: 0 };
61
65
  this.inferenceLimitExceeded = false;
62
66
  this.maxMemoryBytes = options.maxMemoryBytes ?? softHeapLimit();
63
67
  this.memoryRecovery = options.memoryRecovery ?? {
@@ -158,6 +162,7 @@ export class Solver {
158
162
  charConversions: this.charConversions,
159
163
  io: this.io,
160
164
  innerTableScopes: this.innerTableScopes,
165
+ inferenceObservation: this.inferenceObservation,
161
166
  skipListTailTabling: options.skipListTailTabling ?? this.skipListTailTabling,
162
167
  });
163
168
  if (options.tableScope != null) {
@@ -282,6 +287,7 @@ export class Solver {
282
287
  this.solveStacks.push(stack);
283
288
  while (stack.length) {
284
289
  this.inferences++;
290
+ this.inferenceObservation.value++;
285
291
  this.checkMemoryLimit();
286
292
  if (this.inferences > this.maxInferences) {
287
293
  this.inferenceLimitExceeded = true;
@@ -334,6 +340,7 @@ export class Solver {
334
340
 
335
341
  while (true) {
336
342
  this.inferences++;
343
+ this.inferenceObservation.value++;
337
344
  this.checkMemoryLimit();
338
345
  if (this.inferences > this.maxInferences) {
339
346
  this.inferenceLimitExceeded = true;
@@ -388,6 +395,7 @@ export class Solver {
388
395
  const selectedIndex = selectReadyDeterministicBuiltin(goals, env, this.registry);
389
396
  const goal = deref(goals[selectedIndex], env);
390
397
  const rest = selectedIndex === 0 ? goals.slice(1) : [...goals.slice(0, selectedIndex), ...goals.slice(selectedIndex + 1)];
398
+ prepareLocalVariablesForGoal(goal, env);
391
399
  if (goal.type === 'atom' && goal.name === '!' && goal.arity === 0) {
392
400
  const marker = active[active.length - 1] ?? null;
393
401
  if (marker) marker.cutEpoch = (marker.cutEpoch ?? 0) + 1;
@@ -539,6 +547,23 @@ export class Solver {
539
547
  continue;
540
548
  }
541
549
 
550
+ const ellipsisPlan = bundledEllipsisPlan(this, group, goal, rest, env);
551
+ if (ellipsisPlan != null) {
552
+ const firstResult = ellipsisPlan.iterator.next();
553
+ if (firstResult.done) break;
554
+ stack.push({
555
+ kind: 'resumeBuiltin',
556
+ iterator: ellipsisPlan.iterator,
557
+ goals: ellipsisPlan.rest,
558
+ depth: depth + 1,
559
+ active,
560
+ });
561
+ goals = ellipsisPlan.rest;
562
+ env = firstResult.value;
563
+ depth++;
564
+ continue;
565
+ }
566
+
542
567
  if (group.tabled && !(this.skipListTailTabling && group.listTailRecursive)) {
543
568
  const key = memoKey(goal, env, group);
544
569
  if (key.hasBound) {
@@ -612,6 +637,13 @@ export class Solver {
612
637
  }
613
638
  }
614
639
 
640
+ hasPendingAlternatives() {
641
+ // When solve() is suspended at an answer, active solve stacks contain only
642
+ // unexplored work. The timed REPL path uses this without speculatively
643
+ // pulling the next answer.
644
+ return this.solveStacks.some((stack) => stack.length !== 0);
645
+ }
646
+
615
647
  fastCountGoal(goal, env) {
616
648
  return fastCountPureGoal(this, goal, env);
617
649
  }
@@ -1083,12 +1115,47 @@ function freshVariableSet(names, freshVariables) {
1083
1115
 
1084
1116
  function attachBodyLocalFreshVariables(freshBody, plan, freshVariables) {
1085
1117
  for (let index = 0; index < freshBody.length; index++) {
1118
+ const goal = freshBody[index];
1086
1119
  const knownNonoccurringVariables = freshVariableSet(plan[index] ?? [], freshVariables);
1087
- if (knownNonoccurringVariables != null && freshBody[index]?.type === COMPOUND &&
1088
- freshBody[index].name === '=' && freshBody[index].arity === 2) {
1089
- freshBody[index]._knownNonoccurringVariables = knownNonoccurringVariables;
1120
+ if (knownNonoccurringVariables == null || goal?.type !== COMPOUND) continue;
1121
+ if (goal.name === '=' && goal.arity === 2) {
1122
+ goal._knownNonoccurringVariables = knownNonoccurringVariables;
1123
+ continue;
1124
+ }
1125
+ // Only a compiler-generated DCG state first handed as a complete argument
1126
+ // to another callable receives a longer-lived local marker. Equality-created
1127
+ // sequence variables keep the existing one-unification proof instead.
1128
+ const localFirstUseVariables = new Set();
1129
+ for (const argument of goal.args) {
1130
+ if (argument?.type === VAR && argument.name.startsWith('\u0000dcg') &&
1131
+ knownNonoccurringVariables.has(argument.name)) {
1132
+ localFirstUseVariables.add(argument.name);
1133
+ }
1134
+ }
1135
+ if (localFirstUseVariables.size !== 0) goal._localFirstUseVariables = localFirstUseVariables;
1136
+ }
1137
+ }
1138
+
1139
+
1140
+ function prepareLocalVariablesForGoal(goal, env) {
1141
+ // A DCG local is globalized if later source places it inside a structure.
1142
+ // Inspect the small fresh goal syntax rather than dereferencing large data.
1143
+ if (env.hasLocalVariables() && goal?.type === COMPOUND) {
1144
+ const pending = [];
1145
+ for (const argument of goal.args) if (argument?.type === COMPOUND) pending.push(argument);
1146
+ while (pending.length !== 0) {
1147
+ const current = pending.pop();
1148
+ for (const argument of current.args ?? []) {
1149
+ if (argument?.type === VAR) {
1150
+ const root = derefForLocal(argument, env);
1151
+ if (root.type === VAR && env.isLocalVariable(root.name)) env.demoteLocalVariable(root.name);
1152
+ } else if (argument?.type === COMPOUND) {
1153
+ pending.push(argument);
1154
+ }
1155
+ }
1090
1156
  }
1091
1157
  }
1158
+ env.markLocalVariables(goal?._localFirstUseVariables ?? null);
1092
1159
  }
1093
1160
 
1094
1161
  function groupNeedsActiveFrame(group) {
@@ -1176,6 +1243,92 @@ function* bundledMemberSolutions(solver, goal, env) {
1176
1243
  }
1177
1244
  }
1178
1245
 
1246
+ function bundledEllipsisPlan(solver, group, goal, rest, env) {
1247
+ if (solver.registry.eyePrologLibrary !== true ||
1248
+ group.module !== 'iso_ext' || group.name !== '...' || group.arity !== 2 ||
1249
+ group.bundledLibrary !== true || group.clauses.length !== 2) {
1250
+ return null;
1251
+ }
1252
+
1253
+ // length/2 and other native constructors can leave a known finite list as a
1254
+ // compact spine. The ordinary ...//0 relation simply enumerates every
1255
+ // suffix of such a list; doing that directly avoids clause freshening and
1256
+ // recursive solver depth for every consumed element. Non-compact and open
1257
+ // list cases retain the ordinary Prolog definition.
1258
+ const input = deref(goal.args[0], env);
1259
+ if (compactListLength(input) == null) return null;
1260
+
1261
+ // A following binary identity relation is just a zero-width DCG hand-off.
1262
+ // Fuse it by asking .../2 for that relation's required output directly.
1263
+ // This is a structural optimization: any one-clause p(X,Y):-X=Y (or p(X,X).)
1264
+ // qualifies, not just a predicate named epsilon/2.
1265
+ if (rest.length > 0) {
1266
+ const continuation = deref(rest[0], env);
1267
+ if (continuation?.type === COMPOUND && continuation.arity === 2) {
1268
+ const continuationGroup = solver.program.findGroup(
1269
+ continuation.name, continuation.arity, continuation.module ?? goal.module ?? 'user',
1270
+ );
1271
+ if (isBinaryIdentityGroup(continuationGroup)) {
1272
+ const output = deref(goal.args[1], env);
1273
+ const continuationInput = deref(continuation.args[0], env);
1274
+ if (output.type === VAR && continuationInput.type === VAR && output.name === continuationInput.name &&
1275
+ output.name.startsWith('\u0000dcg')) {
1276
+ return {
1277
+ iterator: bundledEllipsisSolutions(solver, continuation.args[1], input, env),
1278
+ rest: rest.slice(1),
1279
+ };
1280
+ }
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ return { iterator: bundledEllipsisSolutions(solver, goal.args[1], input, env), rest };
1286
+ }
1287
+
1288
+ function isBinaryIdentityGroup(group) {
1289
+ if (group == null || group.arity !== 2 || group.clauses.length !== 1 || group.hasCut === true) return false;
1290
+ const clause = group.clauses[0];
1291
+ const left = clause.head?.args?.[0];
1292
+ const right = clause.head?.args?.[1];
1293
+ if (left?.type !== VAR || right?.type !== VAR) return false;
1294
+ if (clause.body.length === 0) return left.name === right.name;
1295
+ if (clause.body.length !== 1) return false;
1296
+ const equality = clause.body[0];
1297
+ if (equality?.type !== COMPOUND || equality.name !== '=' || equality.arity !== 2) return false;
1298
+ const a = equality.args[0];
1299
+ const b = equality.args[1];
1300
+ if (a?.type !== VAR || b?.type !== VAR) return false;
1301
+ return (a.name === left.name && b.name === right.name) ||
1302
+ (a.name === right.name && b.name === left.name);
1303
+ }
1304
+
1305
+ function* bundledEllipsisSolutions(solver, output, input, env) {
1306
+ let cursor = input;
1307
+ const requestedOutput = deref(output, env);
1308
+
1309
+ // A fixed empty remainder is the important DCG scanner case. Still walk the
1310
+ // actual compact spine so this remains a real tail-consumption benchmark,
1311
+ // but avoid allocating a speculative environment for every suffix that is
1312
+ // structurally incapable of matching [].
1313
+ if (isEmptyList(requestedOutput)) {
1314
+ while (!isEmptyList(cursor)) {
1315
+ if (!isCons(cursor)) return;
1316
+ cursor = deref(cursor.args[1], env);
1317
+ }
1318
+ yield env;
1319
+ return;
1320
+ }
1321
+
1322
+ while (true) {
1323
+ const next = env.clone();
1324
+ solver.stats.unify_calls++;
1325
+ if (unify(output, cursor, next)) yield next;
1326
+ if (isEmptyList(cursor)) return;
1327
+ if (!isCons(cursor)) return;
1328
+ cursor = deref(cursor.args[1], env);
1329
+ }
1330
+ }
1331
+
1179
1332
  function bundledLengthIterator(solver, group, goal, env) {
1180
1333
  if (solver.registry.eyePrologLibrary !== true ||
1181
1334
  !['lists', 'prologue'].includes(group.module) || group.name !== 'length' || group.arity !== 2 ||
@@ -47,7 +47,7 @@ function libraryUrl(filename) {
47
47
  }
48
48
 
49
49
  export const eyePrologNativeLibraryIndicators = Object.freeze([
50
- 'call_nth/2', 'freeze/2', 'countall/2',
50
+ 'call_nth/2', 'freeze/2', 'countall/2', 'time/1',
51
51
  '#>/2', '#</2', '#>=/2', '#=</2', '#=/2', '#\\=/2', '#\\/1',
52
52
  '#<==>/2', '#==>/2', '#<==/2', '#\\//2', '#\\/2', '#/\\/2',
53
53
  'in/2', 'ins/2', 'all_different/1', 'all_distinct/1', 'nvalue/2', 'sum/3',
@@ -68,7 +68,7 @@ export const eyePrologPortableLibraryIndicators = Object.freeze([
68
68
  'nth0/3', 'nth0/4', 'nth1/3', 'nth1/4', 'set_nth0/4', 'take/3', 'drop/3', 'slice/4', 'reverse/2',
69
69
  'length/2', 'sum_list/2', 'min_list/2', 'max_list/2', 'list_to_set/2',
70
70
  'succ/2', 'foldl/4', 'foldl/5', 'foldl/6',
71
- 'forall/2', 'cfor/3', 'findall/4', 'variant/2', 'uuid/3',
71
+ 'forall/2', 'cfor/3', 'findall/4', 'variant/2', '.../2', 'uuid/3',
72
72
  '^/3', '^/4', '^/5', '^/6', '^/7', '^/8', '^/9', '^/10',
73
73
  '\\/1', '\\/2', '\\/3', '\\/4', '\\/5', '\\/6', '\\/7', '\\/8',
74
74
  '+\\/2', '+\\/3', '+\\/4', '+\\/5', '+\\/6', '+\\/7', '+\\/8', '+\\/9',
@@ -117,6 +117,11 @@ export const eyePrologInteropAutoload = Object.freeze({
117
117
  // it from library(iso_ext), matching the explicit Scryer import while still
118
118
  // allowing Trealla-style unqualified source to use the same autoload entry.
119
119
  'call_nth/2': 'iso_ext',
120
+ // Trealla exposes time/1 as a meta timing predicate and library(iso_ext)
121
+ // supplies ...//0. Autoload both so UWN's DCG hand-off benchmark runs
122
+ // unchanged while their implementations remain outside the ISO core.
123
+ 'time/1': 'iso_ext',
124
+ '.../2': 'iso_ext',
120
125
  // Trealla and Scryer expose between/3 without an EyeProlog-style
121
126
  // library(prologue) dependency. EyeProlog keeps its implementation in the
122
127
  // Prologue module but autoloads it so portable source need not name that
package/src/term.js CHANGED
@@ -99,6 +99,7 @@ export class Env {
99
99
  this._delays = null;
100
100
  this._clpz = null;
101
101
  this._occursCheckHandler = null;
102
+ this._localVariables = null;
102
103
  }
103
104
  clone() {
104
105
  // Most speculative environments are either rejected without a binding or
@@ -111,12 +112,43 @@ export class Env {
111
112
  clone._delays = this._delays;
112
113
  clone._clpz = this._clpz;
113
114
  clone._occursCheckHandler = this._occursCheckHandler;
115
+ clone._localVariables = this._localVariables;
114
116
  return clone;
115
117
  }
116
118
  setOccursCheckHandler(handler) {
117
119
  this._occursCheckHandler = typeof handler === 'function' ? handler : null;
118
120
  return this;
119
121
  }
122
+ hasLocalVariables() {
123
+ return this._localVariables != null && this._localVariables.size !== 0;
124
+ }
125
+ isLocalVariable(name) {
126
+ return this._localVariables?.has(name) === true;
127
+ }
128
+ markLocalVariables(names) {
129
+ if (names == null || names.size === 0) return;
130
+ let next = this._localVariables;
131
+ for (const name of names) {
132
+ const root = deref(variable(name), this);
133
+ if (root.type !== VAR || next?.has(root.name)) continue;
134
+ if (next === this._localVariables) next = new Set(this._localVariables ?? []);
135
+ next.add(root.name);
136
+ }
137
+ this._localVariables = next;
138
+ }
139
+ demoteLocalVariable(name) {
140
+ const root = deref(variable(name), this);
141
+ if (root.type !== VAR || this._localVariables?.has(root.name) !== true) return;
142
+ const next = new Set(this._localVariables);
143
+ next.delete(root.name);
144
+ this._localVariables = next.size === 0 ? null : next;
145
+ }
146
+ forgetLocalVariable(name) {
147
+ if (this._localVariables?.has(name) !== true) return;
148
+ const next = new Set(this._localVariables);
149
+ next.delete(name);
150
+ this._localVariables = next.size === 0 ? null : next;
151
+ }
120
152
  has(name) {
121
153
  return this.get(name) !== undefined;
122
154
  }
@@ -212,6 +244,9 @@ export function deref(term, env) {
212
244
  let current = term;
213
245
  let seen = null;
214
246
  while (current?.type === VAR) {
247
+ // A live compiler-proven DCG local is the current unbound representative.
248
+ // No older Env layer can contain a binding for it.
249
+ if (env?.isLocalVariable?.(current.name) === true) break;
215
250
  const next = env?.get(current.name);
216
251
  if (next === undefined) break;
217
252
  if (seen?.has(current.name)) break;
@@ -284,28 +319,40 @@ export function unify(left, right, env, options = {}) {
284
319
 
285
320
  if (a.type === VAR && b.type === VAR && a.name === b.name) continue;
286
321
  if (a.type === VAR && b.type === VAR) {
287
- // Both variables are already dereferenced and unbound, so linking them
288
- // cannot create a cycle and needs no occurs-check traversal.
289
- markCompactVariableBound(a);
290
- env.bind(a.name, b);
322
+ // For a compiler-generated DCG state handed directly to another
323
+ // nonterminal, keep the local caller variable as representative. Ordinary
324
+ // aliases retain the established direction and observable conventions.
325
+ const aLocalDcg = env?.isLocalVariable(a.name) === true &&
326
+ a.name.startsWith('\u0000dcg') && b.name.startsWith('\u0000dcg');
327
+ if (aLocalDcg) {
328
+ markCompactVariableBound(b);
329
+ env.bind(b.name, a);
330
+ } else {
331
+ markCompactVariableBound(a);
332
+ env.bind(a.name, b);
333
+ }
291
334
  continue;
292
335
  }
293
336
  if (a.type === VAR) {
294
- if (!knownNonoccurringVariables?.has(a.name) && occurs(a.name, b, env)) {
337
+ const aLocal = env?.isLocalVariable(a.name) === true;
338
+ if (!aLocal && !knownNonoccurringVariables?.has(a.name) && occurs(a.name, b, env)) {
295
339
  occursCheckHandler?.(a, b, env);
296
340
  return false;
297
341
  }
298
342
  markCompactVariableBound(a);
299
343
  env.bind(a.name, b);
344
+ if (aLocal) env.forgetLocalVariable(a.name);
300
345
  continue;
301
346
  }
302
347
  if (b.type === VAR) {
303
- if (!knownNonoccurringVariables?.has(b.name) && occurs(b.name, a, env)) {
348
+ const bLocal = env?.isLocalVariable(b.name) === true;
349
+ if (!bLocal && !knownNonoccurringVariables?.has(b.name) && occurs(b.name, a, env)) {
304
350
  occursCheckHandler?.(b, a, env);
305
351
  return false;
306
352
  }
307
353
  markCompactVariableBound(b);
308
354
  env.bind(b.name, a);
355
+ if (bLocal) env.forgetLocalVariable(b.name);
309
356
  continue;
310
357
  }
311
358
 
@@ -1324,6 +1324,9 @@ c4 ?- call((!;1)).
1324
1324
  first_use_cycle :- X = f(Y), Y = g(X).
1325
1325
  repeated_cycle :- X = f(X).
1326
1326
  first_use_ok(T) :- X = f(Y), Y = a, T = X.
1327
+ pass(_).
1328
+ handed_off_cycle :- pass(X), Y = f(X), X = g(Y).
1329
+ handed_off_alias_cycle :- pass(X), Y = X, X = f(Y).
1327
1330
  `);
1328
1331
  const solver = new Solver(program);
1329
1332
  const solveCount = (text) => {
@@ -1338,6 +1341,8 @@ c4 ?- call((!;1)).
1338
1341
  assertEqual(solveCount('first_use_cycle'), 0, 'cycle across later first-use binding');
1339
1342
  assertEqual(solveCount('repeated_cycle'), 0, 'same-goal repeated variable still checks occurs');
1340
1343
  assertEqual(solveCount('first_use_ok(f(a))'), 1, 'acyclic first-use bindings still succeed');
1344
+ assertEqual(solveCount('handed_off_cycle'), 0, 'nested use globalizes a handed-off local before a cycle');
1345
+ assertEqual(solveCount('handed_off_alias_cycle'), 0, 'aliasing does not hide a later cycle');
1341
1346
  },
1342
1347
  },
1343
1348
  {
@@ -1379,6 +1384,106 @@ c4 ?- call((!;1)).
1379
1384
  assertEqual(result.stdout, 'ok', 'deep DCG result');
1380
1385
  },
1381
1386
  },
1387
+ {
1388
+ name: 'Trealla-style DCG hand-off autoloads time/1 and ...//0 without quadratic occurs checks (issue #49)',
1389
+ run: () => {
1390
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
1391
+ const script = `
1392
+ import { run } from ${JSON.stringify(engineUrl)};
1393
+ const source = ${JSON.stringify('a --> ..., epsilon.\nepsilon --> [].\n')};
1394
+ const result = run(source, {
1395
+ goal: ${JSON.stringify('length(_,E), E>12, N is 2^E, \\+ \\+ (length(L,N), time(phrase(a,L)))')},
1396
+ solutionLimit: 1,
1397
+ });
1398
+ if (!result.stdout.startsWith('% Time elapsed ') || !result.stdout.endsWith('s\\n')) {
1399
+ throw new Error('unexpected time/1 output: ' + JSON.stringify(result.stdout));
1400
+ }
1401
+ process.stdout.write('ok');
1402
+ `;
1403
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
1404
+ cwd: packageRoot,
1405
+ encoding: 'utf8',
1406
+ timeout: 10000,
1407
+ });
1408
+ if (result.error) throw result.error;
1409
+ assertEqual(result.status, 0, `DCG hand-off child status; stderr=${result.stderr}`);
1410
+ assertEqual(result.stdout, 'ok', 'DCG hand-off benchmark result');
1411
+ },
1412
+ },
1413
+ {
1414
+ name: 'Trealla-style DCG hand-off reaches 65536 cells without the solver depth ceiling',
1415
+ run: () => {
1416
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
1417
+ const script = `
1418
+ import { run } from ${JSON.stringify(engineUrl)};
1419
+ const source = ${JSON.stringify(':- set_prolog_flag(occurs_check, true).\na --> ..., epsilon.\nepsilon --> [].\n')};
1420
+ const result = run(source, {
1421
+ goal: ${JSON.stringify('\\+ \\+ (length(L,65536), time(phrase(a,L)))')},
1422
+ solutionLimit: 1,
1423
+ });
1424
+ if (!result.stdout.startsWith('% Time elapsed ')) {
1425
+ throw new Error('65536-cell hand-off did not succeed: ' + JSON.stringify(result.stdout));
1426
+ }
1427
+ process.stdout.write('ok');
1428
+ `;
1429
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
1430
+ cwd: packageRoot,
1431
+ encoding: 'utf8',
1432
+ timeout: 5000,
1433
+ });
1434
+ if (result.error) throw result.error;
1435
+ assertEqual(result.status, 0, `65536-cell hand-off status; stderr=${result.stderr}`);
1436
+ assertEqual(result.stdout, 'ok', '65536-cell hand-off result');
1437
+ },
1438
+ },
1439
+ {
1440
+ name: 'REPL applies conservative autoloading to interactive time/1 and consulted ...//0',
1441
+ run: () => {
1442
+ const filename = path.join(tmp, `issue49-handoff-${tmpCounter++}.pl`);
1443
+ fs.writeFileSync(filename, 'a --> ..., epsilon.\nepsilon --> [].\n');
1444
+ const result = runCli([], {
1445
+ input:
1446
+ `[${sourceAtom(filename)}].\n` +
1447
+ 'use_module(library(lists)).\n' +
1448
+ 'length(_,E),E>12,N is 2^E,\\+ \\+ (length(L,N),time(phrase(a,L))).\n' +
1449
+ '\n' +
1450
+ 'halt.\n',
1451
+ timeout: 10000,
1452
+ });
1453
+ if (result.error) throw result.error;
1454
+ assertEqual(result.status, 0, `REPL hand-off status; stderr=${result.stderr}`);
1455
+ assertIncludes(result.stdout, '% Time elapsed ', 'REPL time/1 output');
1456
+ assertEqual((result.stdout.match(/% Time elapsed /g) ?? []).length, 1,
1457
+ 'REPL timed query does not prefetch an unrequested next answer');
1458
+ assertIncludes(result.stdout, 'E = 13, N = 8192', 'REPL first benchmark answer');
1459
+ assertNotIncludes(result.stdout, 'existence_error(procedure', 'REPL autoload errors');
1460
+ assertEqual(result.stderr, '', 'REPL hand-off stderr');
1461
+ },
1462
+ },
1463
+ {
1464
+ name: 'REPL Trealla hand-off benchmark reaches E=16 on demand without OOM fallthrough',
1465
+ run: () => {
1466
+ const filename = path.join(tmp, `issue49-handoff-deep-${tmpCounter++}.pl`);
1467
+ fs.writeFileSync(filename, ':- set_prolog_flag(occurs_check, true).\na --> ..., epsilon.\nepsilon --> [].\n');
1468
+ const result = runCli([], {
1469
+ input:
1470
+ `[${sourceAtom(filename)}].\n` +
1471
+ 'use_module(library(lists)).\n' +
1472
+ 'length(_,E),E>12,N is 2^E,\\+ \\+ (length(L,N),time(phrase(a,L))).\n' +
1473
+ ';\n;\n;\n\n' +
1474
+ 'halt.\n',
1475
+ timeout: 5000,
1476
+ });
1477
+ if (result.error) throw result.error;
1478
+ assertEqual(result.status, 0, `deep REPL hand-off status; stderr=${result.stderr}`);
1479
+ for (const [e, n] of [[13, 8192], [14, 16384], [15, 32768], [16, 65536]]) {
1480
+ assertIncludes(result.stdout, `E = ${e}, N = ${n}`, `REPL hand-off E=${e}`);
1481
+ }
1482
+ assertEqual((result.stdout.match(/% Time elapsed /g) ?? []).length, 4,
1483
+ 'exactly four requested timed answers');
1484
+ assertEqual(result.stderr, '', 'deep REPL hand-off stderr');
1485
+ },
1486
+ },
1382
1487
  {
1383
1488
  name: 'REPL enumerates and stops answers like the Scryer top level',
1384
1489
  run: () => {
@@ -3916,7 +4021,7 @@ answer(ok) :-
3916
4021
  assertEqual(Boolean(registry.get('is', 2)), true, 'ISO is/2 exists');
3917
4022
  assertEqual(Boolean(registry.get('append', 3)), false, 'append/3 is not ISO core');
3918
4023
  assertEqual(library.eyePrologLibrary, true, 'complete registry marker');
3919
- assertEqual(library.defs.size, 156, 'EyeProlog registry contains ISO definitions, observability extensions, WFS tnot/1, and private library adapters');
4024
+ assertEqual(library.defs.size, 158, 'EyeProlog registry contains ISO definitions, observability extensions, WFS tnot/1, and private library adapters');
3920
4025
  assertEqual(Boolean(registry.get('phrase', 2)), true, 'Part 3 phrase/2 exists');
3921
4026
  assertEqual(Boolean(registry.get('phrase', 3)), true, 'Part 3 phrase/3 exists');
3922
4027
  assertEqual(registry.get('statistics', 0), null, 'statistics/0 is absent from the ISO registry');
@@ -3925,17 +4030,21 @@ answer(ok) :-
3925
4030
  assertEqual(Boolean(library.get('statistics', 2)), true, 'statistics/2 is an EyeProlog observability extension');
3926
4031
  assertEqual(registry.get('tnot', 1), null, 'tnot/1 is absent from the ISO registry');
3927
4032
  assertEqual(Boolean(library.get('tnot', 1)), true, 'tnot/1 is an EyeProlog WFS extension');
3928
- assertEqual(registeredNativeEyePrologLibraryNames().length, 40, 'public native EyeProlog builtin count');
3929
- assertEqual(eyePrologPortableLibraryIndicators.length, 86, 'portable Prolog library count');
3930
- assertEqual(eyePrologInteropLibraryIndicators.length, 27, 'cross-implementation interop profile count');
4033
+ assertEqual(registry.get('time', 1), null, 'time/1 is absent from the ISO registry');
4034
+ assertEqual(Boolean(library.get('time', 1)), true, 'time/1 is an EyeProlog timing extension');
4035
+ assertEqual(registeredNativeEyePrologLibraryNames().length, 41, 'public native EyeProlog builtin count');
4036
+ assertEqual(eyePrologPortableLibraryIndicators.length, 87, 'portable Prolog library count');
4037
+ assertEqual(eyePrologInteropLibraryIndicators.length, 29, 'cross-implementation interop profile count');
3931
4038
  assertEqual(eyePrologInteropLibraryModules.join(','), 'lists,iso_ext,lambda', 'common explicit library module profile');
3932
4039
  assertEqual(eyePrologInteropAutoload['member/2'], 'lists', 'member/2 canonical autoload');
3933
4040
  assertEqual(eyePrologInteropAutoload['between/3'], 'prologue', 'between/3 canonical internal autoload');
3934
4041
  assertEqual(eyePrologInteropAutoload['call_nth/2'], 'iso_ext', 'call_nth/2 canonical interop autoload');
4042
+ assertEqual(eyePrologInteropAutoload['time/1'], 'iso_ext', 'time/1 canonical interop autoload');
4043
+ assertEqual(eyePrologInteropAutoload['.../2'], 'iso_ext', '.../2 canonical interop autoload');
3935
4044
  assertEqual(eyePrologInteropAutoload['set_nth0/4'] ?? null, null, 'EyeProlog-only set_nth0/4 is not autoloadable');
3936
- assertEqual(eyePrologNativeLibraryIndicators.length, 40, 'native host library count');
4045
+ assertEqual(eyePrologNativeLibraryIndicators.length, 41, 'native host library count');
3937
4046
  assertEqual(eyePrologNativeLibraryIndicators.slice(0, 2).join(','), 'call_nth/2,freeze/2', 'control predicates requiring host support');
3938
- assertEqual(eyePrologLibraryIndicators.length, 126, 'complete EyeProlog library surface');
4047
+ assertEqual(eyePrologLibraryIndicators.length, 128, 'complete EyeProlog library surface');
3939
4048
  assertEqual(registry.get('eyeprolog__call_nth', 2), null, 'private call_nth adapter is absent from ISO registry');
3940
4049
  assertEqual(Boolean(library.get('eyeprolog__call_nth', 2)), true, 'private call_nth adapter is registered for EyeProlog');
3941
4050
  assertEqual(library.get('eyeprolog__call_nth', 2)?.eyePrologLibrary, true, 'private adapter is marked as library support');
@@ -3943,6 +4052,7 @@ answer(ok) :-
3943
4052
  assertEqual(Boolean(library.get('eyeprolog__freeze', 2)), true, 'private freeze adapter is registered for EyeProlog');
3944
4053
  assertEqual(registry.get('eyeprolog__countall', 2), null, 'private countall adapter is absent from ISO registry');
3945
4054
  assertEqual(Boolean(library.get('eyeprolog__countall', 2)), true, 'private countall adapter is registered for EyeProlog');
4055
+ assertEqual(Boolean(library.get('eyeprolog__time', 1)), true, 'private time adapter is registered for EyeProlog');
3946
4056
  assertEqual(Boolean(library.get('eyeprolog__clpz_labeling', 2)), true, 'private CLP(Z) labeling adapter is registered');
3947
4057
  assertEqual(Boolean(library.get('eyeprolog__clpz_global_cardinality', 3)), true, 'private CLP(Z) cardinality adapter is registered');
3948
4058
  assertEqual(library.get('between', 3), null, 'between/3 remains portable Prolog');
@@ -5632,6 +5632,62 @@ that argument. A variable body raises `instantiation_error`; a non-callable
5632
5632
  body raises `type_error(callable)`. EyeProlog performs terminal-sequence checks
5633
5633
  and reports the portable ISO `type_error(list)` error term.
5634
5634
 
5635
+ #### A bidirectional expression grammar
5636
+
5637
+ DCGs become more useful when the grammar produces a structured term rather than
5638
+ merely accepting a token list. The checked
5639
+ [`dcg-expression-language.pl`](https://github.com/eyereasoner/eyeprolog/blob/main/examples/dcg-expression-language.pl)
5640
+ example implements a small arithmetic language in both directions. Its parser
5641
+ respects precedence and left associativity while constructing an abstract syntax
5642
+ tree:
5643
+
5644
+ ```text
5645
+ expression(AST) -->
5646
+ term(First),
5647
+ additive_tail(First, AST).
5648
+
5649
+ additive_tail(Left, AST) -->
5650
+ ['+'], term(Right),
5651
+ { Next = add(Left, Right) },
5652
+ additive_tail(Next, AST).
5653
+ additive_tail(AST, AST) --> [].
5654
+ ```
5655
+
5656
+ The accumulator removes left recursion without moving parsing into JavaScript.
5657
+ A second DCG walks the AST in the other direction and emits only the parentheses
5658
+ needed to preserve its structure. The example therefore exercises parsing,
5659
+ semantic actions, nonterminal-to-nonterminal state hand-off, generation,
5660
+ backtracking, `phrase/3` remainder handling, and AST-to-token-to-AST
5661
+ round-tripping. The checked answers are in
5662
+ [`examples/output/dcg-expression-language.pl`](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/dcg-expression-language.pl).
5663
+
5664
+ #### Deep sequence hand-off
5665
+
5666
+ `library(iso_ext)` provides the common `...//0` helper, which describes an
5667
+ arbitrary number of input elements. It is not part of ISO Part 3, but it is a
5668
+ useful interoperability and stress-test relation. A compact hand-off test is:
5669
+
5670
+ ```text
5671
+ a --> ..., epsilon.
5672
+ epsilon --> [].
5673
+ ```
5674
+
5675
+ Here the remaining sequence is repeatedly passed from `...//0` to another
5676
+ nonterminal. For a finite compact list, EyeProlog can scan the arbitrary
5677
+ sequence iteratively instead of consuming one ordinary solver depth level per
5678
+ list cell. If the continuation is structurally proven to be a zero-width
5679
+ identity grammar such as `epsilon//0`, the hand-off can be continued without
5680
+ constructing a fresh general clause-resolution frame at every suffix. The list
5681
+ spine is still traversed; this is a control/allocation optimization rather than
5682
+ an O(1) semantic shortcut.
5683
+
5684
+ The optimization is deliberately narrow. `phrase(..., Sequence, Rest)` still
5685
+ enumerates the valid remainders, open or non-compact inputs retain ordinary
5686
+ relational behavior, and grammars that can consume or constrain the remainder
5687
+ are not treated as identity continuations. `time/1` can be used in normal mode
5688
+ to measure such runs; its inference counter records solver-level inferences and
5689
+ does not count every internal step of an optimized scanner.
5690
+
5635
5691
  Part 3 leaves `\+//1` and standalone `->//2` implementation dependent.
5636
5692
  EyeProlog uses non-consuming negation (`\+ Body` tests from the current state)
5637
5693
  and threads the state produced by the condition into the then-grammar.
@@ -6067,14 +6123,16 @@ so side effects occur in Prolog execution order.
6067
6123
 
6068
6124
  ### The EyeProlog library
6069
6125
 
6070
- EyeProlog exposes **126 library predicate indicators** in addition to the 129
6071
- indicators in its isolated ISO profile. **87 are defined as ordinary Prolog
6072
- clauses** in focused modules under `src/lib/`. The remaining 39
6073
- are public wrappers around backtrackable host support: Prologue `call_nth/2` and
6074
- `freeze/2`, plus the 37-predicate finite-domain `library(clpz)` kernel. The
6075
- resulting normal EyeProlog language surface is therefore **255 public predicate
6076
- indicators**. Internally, the runtime registry contains the 129 ISO definitions
6077
- plus 23 private library adapters; public relations remain module source clauses.
6126
+ EyeProlog exposes **128 library predicate indicators** in addition to the 129
6127
+ indicators in its isolated ISO profile. **88 are defined entirely as ordinary
6128
+ Prolog clauses** in focused modules under `src/lib/`. The remaining public
6129
+ relations use small private host adapters where control, constraints, or host
6130
+ observability cannot be expressed by ordinary clauses alone; `time/1` is one
6131
+ such relation. The resulting normal EyeProlog language surface is therefore
6132
+ **257 public predicate indicators**. Most library relations remain module source
6133
+ clauses over private adapters. `time/1` is additionally registered directly in
6134
+ the normal EyeProlog runtime so Trealla-style timing works at the interactive
6135
+ top level without an import; it is absent from the strict ISO registry.
6078
6136
 
6079
6137
  The sources are `src/lib/aggregate.pl`, `src/lib/clpz.pl`, `src/lib/comparison.pl`,
6080
6138
  `src/lib/dates.pl`, `src/lib/iso_ext.pl`, `src/lib/lambda.pl`,
@@ -6110,7 +6168,7 @@ between solution branches.
6110
6168
  | `library(clpz)` | `#>/2`, `#</2`, `#>=/2`, `#=</2`, `#=/2`, `#\=/2`, `#\/1`, `#<==>/2`, `#==>/2`, `#<==/2`, `#\//2`, `#\/2`, `#/\/2`, `in/2`, `ins/2`, `all_different/1`, `all_distinct/1`, `nvalue/2`, `sum/3`, `scalar_product/4`, `tuples_in/2`, `labeling/2`, `label/1`, `indomain/1`, `lex_chain/1`, `serialized/2`, `global_cardinality/2`, `global_cardinality/3`, `circuit/1`, `chain/2`, `element/3`, `zcompare/3`, `fd_var/1`, `fd_inf/2`, `fd_sup/2`, `fd_size/2`, `fd_dom/2` |
6111
6169
  | `library(comparison)` | `lt/2`, `gt/2`, `le/2`, `ge/2` |
6112
6170
  | `library(dates)` | `difference/3` |
6113
- | `library(iso_ext)` | `call_nth/2`, `countall/2`, `forall/2`, `succ/2`, `cfor/3`, `findall/4`, `variant/2` |
6171
+ | `library(iso_ext)` | `call_nth/2`, `countall/2`, `forall/2`, `succ/2`, `cfor/3`, `findall/4`, `variant/2`, `time/1`, `.../2` |
6114
6172
  | `library(lambda)` | `^/3`, `^/4`, `^/5`, `^/6`, `^/7`, `^/8`, `^/9`, `^/10`, `\/1`, `\/2`, `\/3`, `\/4`, `\/5`, `\/6`, `\/7`, `\/8`, `+\/2`, `+\/3`, `+\/4`, `+\/5`, `+\/6`, `+\/7`, `+\/8`, `+\/9` |
6115
6173
  | `library(lists)` | `member/2`, `memberchk/2`, `select/3`, `append/2`, `append/3`, `last/2`, `same_length/2`, `nth0/3`, `nth0/4`, `nth1/3`, `nth1/4`, `reverse/2`, `length/2`, `maplist/2`, `maplist/3`, `maplist/4`, `maplist/5`, `maplist/6`, `maplist/7`, `maplist/8`, `foldl/4`, `foldl/5`, `foldl/6`, `sum_list/2`, `min_list/2`, `max_list/2`, `list_to_set/2`, `set_nth0/4`, `take/3`, `drop/3`, `slice/4` |
6116
6174
  | `library(primes)` | `smallest_divisor_from/3` |
@@ -6144,7 +6202,7 @@ The current interoperability profile recognizes these library roles:
6144
6202
  | Library | Role in the interoperability profile |
6145
6203
  | --- | --- |
6146
6204
  | `library(lists)` | Common list module. A conservative subset of its exports is in the shared predicate profile. |
6147
- | `library(iso_ext)` | Common extension-module name. `call_nth/2` is currently its autoloaded cross-engine predicate. |
6205
+ | `library(iso_ext)` | Common extension-module name. `call_nth/2`, `time/1`, and the `...//0` arbitrary-sequence helper are conservatively autoloaded for cross-engine source. |
6148
6206
  | `library(lambda)` | Scryer-aligned higher-order notation. It is imported explicitly because loading it also installs the `+\` operator. |
6149
6207
  | `library(prologue)` | EyeProlog compatibility module, not a common interop library name. `between/3` is nevertheless autoloaded from it so portable source need not name this EyeProlog-specific provider. |
6150
6208
 
@@ -6168,9 +6226,14 @@ recovery headroom so finite-heap exhaustion remains a catchable
6168
6226
  `resource_error(memory)`.
6169
6227
 
6170
6228
  `library(iso_ext)` is a common interop module name, but only part of its
6171
- EyeProlog API belongs to the shared profile. `call_nth/2` is mapped there to
6172
- match Scryer's explicit import organization while also permitting portable
6173
- unqualified source to autoload it. The interop exports of `library(lists)` and
6229
+ EyeProlog API belongs to the shared profile. `call_nth/2`, `time/1`, and
6230
+ `.../2` are mapped there. `time/1` measures each solution of a meta-call and
6231
+ prints elapsed time, EyeProlog inference count, and MLips in Trealla-style form,
6232
+ for example `% Time elapsed 0.832s, 65551 Inferences, 0.079 MLips`; `...//0`
6233
+ describes an arbitrary number of input elements. Together they let the
6234
+ Trealla/Scryer DCG hand-off benchmark run in EyeProlog without source changes
6235
+ (assuming the usual list library is already imported in an interactive
6236
+ session). The interop exports of `library(lists)` and
6174
6237
  `library(iso_ext)` are kept disjoint, so both modules can be imported together
6175
6238
  without an accidental collision. `library(prologue)` remains a compatibility
6176
6239
  umbrella and overlaps them; use selective imports when legacy code combines it
@@ -6206,14 +6269,19 @@ uses its ISO `copy_term/2` implementation for the fresh-copy step and does not
6206
6269
  require a separate `copy_term_nat/2` predicate.
6207
6270
 
6208
6271
  Autoloading is a convenience layered on top of the interoperability profile; it
6209
- is not a general search through all EyeProlog libraries. During normal
6210
- execution, an otherwise undefined **unqualified** predicate may be autoloaded
6211
- only when the interop table assigns it one canonical provider. For example:
6272
+ is not a general search through all EyeProlog libraries. When a source program
6273
+ or an explicit CLI/API goal is built, an otherwise undefined **unqualified**
6274
+ predicate may be autoloaded only when the interop table assigns it one canonical
6275
+ provider. The interactive top level keeps ordinary library imports explicit;
6276
+ `time/1` is available there because it is also a normal EyeProlog runtime
6277
+ extension. For example, the canonical build-time providers are:
6212
6278
 
6213
6279
  | Predicate | Canonical autoload provider |
6214
6280
  | --- | --- |
6215
6281
  | `member/2` | `library(lists)` |
6216
6282
  | `call_nth/2` | `library(iso_ext)` |
6283
+ | `time/1` | `library(iso_ext)` |
6284
+ | `.../2` | `library(iso_ext)` |
6217
6285
  | `between/3` | `library(prologue)` |
6218
6286
 
6219
6287
  Predicates outside that table require an explicit import even when EyeProlog
@@ -6255,9 +6323,9 @@ into later branches. Trealla's larger library also contains facilities such as
6255
6323
  automata, cumulative and two-dimensional scheduling constraints, and
6256
6324
  unbounded-domain propagation that EyeProlog does not currently export.
6257
6325
 
6258
- Beyond its interop entry for `call_nth/2`, `library(iso_ext)` also exports
6259
- EyeProlog's extension relations `countall/2`, `forall/2`, `succ/2`, `cfor/3`,
6260
- `findall/4`, and `variant/2`. `forall/2` checks an action for every solution of a
6326
+ Alongside its interop entries `call_nth/2`, `time/1`, and `.../2`,
6327
+ `library(iso_ext)` also exports EyeProlog's extension relations `countall/2`,
6328
+ `forall/2`, `succ/2`, `cfor/3`, `findall/4`, and `variant/2`. `forall/2` checks an action for every solution of a
6261
6329
  condition; `cfor/3` enumerates an inclusive evaluated integer range; `succ/2`
6262
6330
  relates adjacent nonnegative integers; `findall/4` collects into a difference
6263
6331
  list; and `variant/2` recognizes terms equal up to variable renaming.
@@ -6883,7 +6951,7 @@ Review questions:
6883
6951
  </figure>
6884
6952
 
6885
6953
  The [examples directory](https://github.com/eyereasoner/eyeprolog/tree/main/examples/) is the book's executable companion. The
6886
- top-level directory contains **211 self-contained runnable programs**. Every
6954
+ top-level directory contains **212 self-contained runnable programs**. Every
6887
6955
  source program has an exact answer file under
6888
6956
  [examples/output](https://github.com/eyereasoner/eyeprolog/tree/main/examples/output/), and **61 selected programs** have a checked
6889
6957
  explanation under [examples/proof](https://github.com/eyereasoner/eyeprolog/tree/main/examples/proof/). The thematic tables below link every top-level program and open the program
@@ -6941,6 +7009,7 @@ mode at a time.
6941
7009
  | [Atomic conversion](https://github.com/eyereasoner/eyeprolog/blob/main/examples/iso-atomic-conversion.pl) | Atom splitting, character atoms, Unicode codes, and numeric parsing. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/iso-atomic-conversion.pl) |
6942
7010
  | [Control and errors](https://github.com/eyereasoner/eyeprolog/blob/main/examples/iso-control-and-errors.pl) | `call/1`, `once/1`, cut, if-then-else, `throw/1`, and `catch/3`. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/iso-control-and-errors.pl) |
6943
7011
  | [DCG command parser](https://github.com/eyereasoner/eyeprolog/blob/main/examples/dcg-command-parser.pl) | A Part 3 grammar parses token lists into application terms, generates tokens, preserves a remainder, and rejects malformed input. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/dcg-command-parser.pl) |
7012
+ | [DCG expression language](https://github.com/eyereasoner/eyeprolog/blob/main/examples/dcg-expression-language.pl) | A precedence-aware bidirectional grammar builds arithmetic ASTs, evaluates variable expressions, regenerates minimally parenthesized tokens, round-trips syntax, and preserves a remainder. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/dcg-expression-language.pl) |
6944
7013
  | [Dynamic database](https://github.com/eyereasoner/eyeprolog/blob/main/examples/iso-dynamic-database.pl) | Initialization and ordered updates to a declared dynamic procedure. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/iso-dynamic-database.pl) · [proof](https://github.com/eyereasoner/eyeprolog/blob/main/examples/proof/iso-dynamic-database.pl) |
6945
7014
  | [Grouped solutions](https://github.com/eyereasoner/eyeprolog/blob/main/examples/iso-grouped-solutions.pl) | `findall/3`, `bagof/3`, `setof/3`, existential qualification, and `clause/2`. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/iso-grouped-solutions.pl) |
6946
7015
  | [Integer arithmetic](https://github.com/eyereasoner/eyeprolog/blob/main/examples/iso-integer-arithmetic.pl) | Integer quotient/remainder choices plus bit operations. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/iso-integer-arithmetic.pl) |
@@ -7285,7 +7354,7 @@ hand.
7285
7354
 
7286
7355
  #### Running and extending the corpus
7287
7356
 
7288
- Run all 211 normal answer goldens and the 61 selected proof goldens with:
7357
+ Run all 212 normal answer goldens and the 61 selected proof goldens with:
7289
7358
 
7290
7359
  ```sh
7291
7360
  npm run test:examples
@@ -7349,7 +7418,7 @@ The complete suite must pass before release. The file-based conformance corpus
7349
7418
  contains 791 cases, including 386 focused ISO
7350
7419
  cases derived from the success, failure, mode, and error behavior in
7351
7420
  ISO/IEC 13211-1 clauses 7 and 8, Part 2 modules, and Part 3 grammar rules.
7352
- Separate exact-output suites check 211 normal
7421
+ Separate exact-output suites check 212 normal
7353
7422
  examples and 61 proof examples; all extracted book programs are parsed and
7354
7423
  their declared goals are executed. The eight-case
7355
7424
  playground contract suite imports the production worker, sends real reasoning
package/why-eyeprolog.md CHANGED
@@ -60,6 +60,14 @@ positive Datalog closures with a shared relation-wide table, but the admission
60
60
  heuristics are implementation details. Programs should rely on the documented
61
61
  semantics and finiteness conditions, not on a particular internal threshold.
62
62
 
63
+ DCGs follow the same rule. Their meaning remains the ISO Part 3 difference-list
64
+ model, while finite sequence scans and proven zero-width hand-offs may use
65
+ lighter internal control paths so deep grammars do not pay one general solver
66
+ frame per token. Relational remainder-producing modes are preserved. The
67
+ checked `examples/dcg-expression-language.pl` program shows the declarative side
68
+ of that design: one grammar builds precedence-aware syntax trees and another
69
+ generates minimally parenthesized token sequences back from them.
70
+
63
71
  ## Why proofs?
64
72
 
65
73
  An answer says that a goal succeeded. A proof records one successful route