eyeprolog 1.3.12 → 1.3.14

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
@@ -107,13 +107,14 @@ keeps both long-running filters such as `\+ phrase((..., Pattern), Sequence)`
107
107
  and repeated same-input probes bounded without turning memory safety into a
108
108
  per-call cache-eviction cost.
109
109
 
110
- Finite-tree unification also recognizes a conservative first-use case inspired
111
- by the local-variable optimization used by WAM-family systems. In a freshly
112
- renamed clause, a singleton head variable or a variable that first appears once
113
- in a direct `=/2` goal cannot already occur in the value it is about to receive,
114
- so that one binding can skip an otherwise redundant occurs traversal. Repeated
110
+ Finite-tree unification also accepts an internal proven-nonoccurrence hint: when
111
+ the solver can prove that a variable cannot occur in the value it is about to
112
+ receive, that binding skips an otherwise redundant occurs traversal. The main
113
+ source-level case is conservative first use in a freshly renamed clause, inspired
114
+ by the local-variable optimization used by WAM-family systems; native construction
115
+ paths such as relational `length/2` reuse the same unifier mechanism. Repeated
115
116
  variables, variables seen earlier in the clause, and ordinary public unification
116
- remain fully occurs-checked. This is a source-level proof, not a WAM-style
117
+ remain fully occurs-checked. This is a proof local to one binding, not a WAM-style
117
118
  local/global variable stack. `phrase/2` likewise supplies its fixed `[]`
118
119
  remainder directly to the grammar; `phrase/3` retains its delayed final output
119
120
  unification for steadfastness.
@@ -200,8 +201,8 @@ noun --> [world] | [prolog].
200
201
  %% goal: phrase(sentence, Words)
201
202
  ```
202
203
 
203
- EyeProlog also adds 126 public library predicate indicators to its 129-entry ISO
204
- profile. **87 are implemented entirely as ordinary Prolog clauses** in focused
204
+ EyeProlog also adds 128 public library predicate indicators to its 129-entry ISO
205
+ profile. **88 are implemented entirely as ordinary Prolog clauses** in focused
205
206
  modules under `src/lib/`; the remaining control predicates and finite-domain
206
207
  `library(clpz)` kernel use backtrackable host support.
207
208
  They are ISO/IEC 13211-2 modules loaded explicitly by purpose, such as
@@ -224,8 +225,13 @@ headroom, so an exhausted finite heap is reported as a catchable
224
225
  `resource_error(memory)` instead of degenerating into quadratic list checks.
225
226
 
226
227
  `library(iso_ext)` is also accepted as a common interop module name.
227
- EyeProlog exports `call_nth/2` there, so Scryer-style source can explicitly use
228
- `:- use_module(library(iso_ext)).`; unqualified source may still autoload it.
228
+ EyeProlog exports `call_nth/2`, `time/1`, and the DCG helper `...//0` there.
229
+ The latter describes an arbitrary number of input elements and supports the
230
+ nonterminal hand-off benchmark discussed in issue #49. These common predicates
231
+ may be imported explicitly, while source/CLI/API dependency loading can resolve
232
+ their unqualified forms conservatively. For Trealla-style interactive timing,
233
+ `time/1` is also available directly in the normal EyeProlog runtime; strict ISO
234
+ mode does not expose it.
229
235
  The aligned `library(lists)` and `library(iso_ext)` exports are kept disjoint so
230
236
  they can be imported together without an accidental import conflict. EyeProlog's
231
237
  legacy `library(prologue)` remains a compatibility umbrella and should be
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.12",
6
+ "version": "1.3.14",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
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,13 +176,15 @@ 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
 
182
184
  function* unification({ goal, env }) {
183
185
  const next = env.clone();
184
- const localFreshVariables = goal._localFreshVariables ?? null;
185
- if (unify(goal.args[0], goal.args[1], next, { localFreshVariables })) yield next;
186
+ const knownNonoccurringVariables = goal._knownNonoccurringVariables ?? null;
187
+ if (unify(goal.args[0], goal.args[1], next, { knownNonoccurringVariables })) yield next;
186
188
  }
187
189
  function* unificationWithOccursCheck({ goal, env }) {
188
190
  const next = env.clone();
@@ -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
  }
@@ -804,7 +836,7 @@ export class Solver {
804
836
  attachBodyLocalFreshVariables(freshBody, localFreshPlan.body, freshVariables);
805
837
  const next = env.clone();
806
838
  this.stats.unify_calls++;
807
- if (!unify(goal, freshHead, next, { localFreshVariables: headLocalFresh })) continue;
839
+ if (!unify(goal, freshHead, next, { knownNonoccurringVariables: headLocalFresh })) continue;
808
840
  if (freshBody.length === 0) {
809
841
  yield* this.solve(rest, next, depth + 1);
810
842
  } else if (!groupNeedsActiveFrame(group)) {
@@ -1021,7 +1053,7 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1021
1053
  attachBodyLocalFreshVariables(freshBody, localFreshPlan.body, freshVariables);
1022
1054
  const next = env.clone();
1023
1055
  solver.stats.unify_calls++;
1024
- if (!unify(goal, freshHead, next, { localFreshVariables: headLocalFresh })) continue;
1056
+ if (!unify(goal, freshHead, next, { knownNonoccurringVariables: headLocalFresh })) continue;
1025
1057
  if (freshBody.length === 0) {
1026
1058
  frames.push({
1027
1059
  kind: 'goals',
@@ -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++) {
1086
- const localFreshVariables = freshVariableSet(plan[index] ?? [], freshVariables);
1087
- if (localFreshVariables != null && freshBody[index]?.type === COMPOUND &&
1088
- freshBody[index].name === '=' && freshBody[index].arity === 2) {
1089
- freshBody[index]._localFreshVariables = localFreshVariables;
1118
+ const goal = freshBody[index];
1119
+ const knownNonoccurringVariables = freshVariableSet(plan[index] ?? [], freshVariables);
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 ||
@@ -1257,12 +1410,11 @@ function* fixedLengthSolutions(solver, list, length, env) {
1257
1410
  const suffix = compactVariableList(remaining, `__length${id}_`);
1258
1411
  const next = env.clone();
1259
1412
  solver.stats.unify_calls++;
1260
- // cursor is dereferenced and the compact skeleton contains only freshly
1261
- // generated variables, so this binding cannot create a cycle. Binding it
1262
- // directly avoids traversing and expanding the new skeleton for an occurs
1263
- // check whose result is known by construction.
1264
- next.bind(cursor.name, suffix);
1265
- yield next;
1413
+ // The compact skeleton contains only freshly generated variables, so the
1414
+ // dereferenced tail variable is known not to occur in it. Reuse the same
1415
+ // proven-nonoccurrence path as source-level first-use unification.
1416
+ const knownNonoccurringVariables = new Set([cursor.name]);
1417
+ if (unify(cursor, suffix, next, { knownNonoccurringVariables })) yield next;
1266
1418
  }
1267
1419
 
1268
1420
  function* generatedLengthSolutions(solver, list, length, env) {
@@ -1296,15 +1448,13 @@ function* generatedLengthSolutions(solver, list, length, env) {
1296
1448
 
1297
1449
  const id = nextFreshId();
1298
1450
  let suffix = emptyList();
1451
+ // Every generated suffix is built from fresh variables and therefore cannot
1452
+ // contain the caller's dereferenced tail variable. Share the general
1453
+ // proven-nonoccurrence unification path instead of bypassing unify() here.
1454
+ const knownNonoccurringVariables = new Set([cursor.name]);
1299
1455
  for (let extra = 0n; ; extra++) {
1300
1456
  const next = env.clone();
1301
- // cursor is a dereferenced plain variable and suffix is made only from
1302
- // freshly generated variables, so this binding cannot create a cycle.
1303
- // Binding directly avoids an O(extra) occurs-check over the complete
1304
- // growing suffix for every answer; without this, unbounded length/2
1305
- // generation becomes quadratic and can spend hours before reaching the
1306
- // normal memory resource guard (issue #49).
1307
- next.bind(cursor.name, suffix);
1457
+ if (!unify(cursor, suffix, next, { knownNonoccurringVariables })) return;
1308
1458
  const answer = bindGeneratedLength(solver, length, count + extra, next);
1309
1459
  if (answer != null) yield answer;
1310
1460
  suffix = cons(variable(`__length${id}_${extra}`), suffix);
@@ -2395,7 +2545,7 @@ function selectReadyDeterministicBuiltin(goals, env, registry) {
2395
2545
  // A first-use proof is derived from source goal order. Do not move a later
2396
2546
  // deterministic builtin across that equality: doing so could touch one of
2397
2547
  // its proven-fresh variables before the checked binding executes.
2398
- if (goal?._localFreshVariables != null) return 0;
2548
+ if (goal?._knownNonoccurringVariables != null) return 0;
2399
2549
  if (goal.type !== COMPOUND && goal.type !== 'atom') continue;
2400
2550
  const def = registry.get(goal.name, goal.arity);
2401
2551
  if (!def?.deterministic || typeof def.ready !== 'function') continue;
@@ -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