eyeprolog 1.3.10 → 1.3.12

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,6 +107,17 @@ 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
115
+ 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
+ local/global variable stack. `phrase/2` likewise supplies its fixed `[]`
118
+ remainder directly to the grammar; `phrase/3` retains its delayed final output
119
+ unification for steadfastness.
120
+
110
121
  Recursion through negation is explicit. EyeProlog provides `tnot/1` for
111
122
  well-founded negation over finite, range-restricted, function-free Datalog
112
123
  components. Ordinary `\+/1` remains negation-as-failure and is not silently
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.10",
6
+ "version": "1.3.12",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/iso.js CHANGED
@@ -181,7 +181,8 @@ export const eyePrologLibraryBuiltins = {
181
181
 
182
182
  function* unification({ goal, env }) {
183
183
  const next = env.clone();
184
- if (unify(goal.args[0], goal.args[1], next)) yield next;
184
+ const localFreshVariables = goal._localFreshVariables ?? null;
185
+ if (unify(goal.args[0], goal.args[1], next, { localFreshVariables })) yield next;
185
186
  }
186
187
  function* unificationWithOccursCheck({ goal, env }) {
187
188
  const next = env.clone();
@@ -2033,14 +2034,17 @@ function* phraseBuiltin({ solver, goal, env }) {
2033
2034
  throw new PrologError('type_error(list)', deref(requestedOutput, env));
2034
2035
  }
2035
2036
 
2036
- // Delay the final output unification to keep phrase/3 steadfast in its
2037
- // third argument, as required by the Part 3 execution model.
2038
- const finalOutput = variable(`\u0000phrase:${++isoFresh}`);
2037
+ // phrase/2 fixes the remainder to [] from the outset. phrase/3 keeps a
2038
+ // private output variable and delays the final unification so its third
2039
+ // argument remains steadfast as required by the Part 3 execution model.
2040
+ const finalOutput = goal.arity === 2
2041
+ ? requestedOutput
2042
+ : variable(`\u0000phrase:${++isoFresh}`);
2039
2043
  const expanded = expandDcgBody(grammarBody, input, finalOutput, {
2040
2044
  env,
2041
2045
  module: goal.module ?? grammarBody.module ?? 'user',
2042
2046
  });
2043
- const finish = compound('=', [finalOutput, requestedOutput]);
2047
+ const finish = goal.arity === 2 ? null : compound('=', [finalOutput, requestedOutput]);
2044
2048
  // Recursive DCGs are automatically tabled in normal mode. Keep tables in a
2045
2049
  // phrase-local scope keyed by the whole invocation. Repeatedly testing the
2046
2050
  // same grammar/input (issue #48) reuses its completed table, while switching
@@ -2064,7 +2068,7 @@ function* phraseBuiltin({ solver, goal, env }) {
2064
2068
  skipListTailTabling: !repeatedInvocation,
2065
2069
  });
2066
2070
  try {
2067
- yield* child.solve([expanded, finish], env, 0);
2071
+ yield* child.solve(finish == null ? [expanded] : [expanded, finish], env, 0);
2068
2072
  } finally {
2069
2073
  solver.absorbStatsFrom(child);
2070
2074
  solver.trimInnerTableScope('phrase');
package/src/solver.js CHANGED
@@ -799,9 +799,12 @@ export class Solver {
799
799
  const freshVariables = new Map();
800
800
  const freshHead = freshTerm(clause.head, id, freshVariables);
801
801
  const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
802
+ const localFreshPlan = clauseLocalFreshPlan(clause);
803
+ const headLocalFresh = freshVariableSet(localFreshPlan.head, freshVariables);
804
+ attachBodyLocalFreshVariables(freshBody, localFreshPlan.body, freshVariables);
802
805
  const next = env.clone();
803
806
  this.stats.unify_calls++;
804
- if (!unify(goal, freshHead, next)) continue;
807
+ if (!unify(goal, freshHead, next, { localFreshVariables: headLocalFresh })) continue;
805
808
  if (freshBody.length === 0) {
806
809
  yield* this.solve(rest, next, depth + 1);
807
810
  } else if (!groupNeedsActiveFrame(group)) {
@@ -1013,9 +1016,12 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1013
1016
  const freshVariables = new Map();
1014
1017
  const freshHead = freshTerm(clause.head, id, freshVariables);
1015
1018
  const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
1019
+ const localFreshPlan = clauseLocalFreshPlan(clause);
1020
+ const headLocalFresh = freshVariableSet(localFreshPlan.head, freshVariables);
1021
+ attachBodyLocalFreshVariables(freshBody, localFreshPlan.body, freshVariables);
1016
1022
  const next = env.clone();
1017
1023
  solver.stats.unify_calls++;
1018
- if (!unify(goal, freshHead, next)) continue;
1024
+ if (!unify(goal, freshHead, next, { localFreshVariables: headLocalFresh })) continue;
1019
1025
  if (freshBody.length === 0) {
1020
1026
  frames.push({
1021
1027
  kind: 'goals',
@@ -1038,6 +1044,53 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1038
1044
  for (let i = frames.length - 1; i >= 0; i--) stack.push(frames[i]);
1039
1045
  }
1040
1046
 
1047
+ function clauseLocalFreshPlan(clause) {
1048
+ if (clause._localFreshPlan != null) return clause._localFreshPlan;
1049
+ const seen = new Set();
1050
+ const planForTerm = (term) => {
1051
+ const counts = new Map();
1052
+ const stack = [term];
1053
+ while (stack.length > 0) {
1054
+ const current = stack.pop();
1055
+ if (current?.type === VAR) {
1056
+ counts.set(current.name, (counts.get(current.name) ?? 0) + 1);
1057
+ continue;
1058
+ }
1059
+ if (current?.type !== COMPOUND) continue;
1060
+ for (let index = 0; index < current.arity; index++) stack.push(current.args[index]);
1061
+ }
1062
+ const local = [];
1063
+ for (const [name, count] of counts) {
1064
+ if (!seen.has(name) && count === 1) local.push(name);
1065
+ seen.add(name);
1066
+ }
1067
+ return local;
1068
+ };
1069
+ const head = planForTerm(clause.head);
1070
+ const body = clause.body.map(planForTerm);
1071
+ return clause._localFreshPlan = { head, body };
1072
+ }
1073
+
1074
+ function freshVariableSet(names, freshVariables) {
1075
+ if (names.length === 0) return null;
1076
+ const fresh = new Set();
1077
+ for (const name of names) {
1078
+ const term = freshVariables.get(name);
1079
+ if (term != null) fresh.add(term.name);
1080
+ }
1081
+ return fresh.size === 0 ? null : fresh;
1082
+ }
1083
+
1084
+ function attachBodyLocalFreshVariables(freshBody, plan, freshVariables) {
1085
+ 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;
1090
+ }
1091
+ }
1092
+ }
1093
+
1041
1094
  function groupNeedsActiveFrame(group) {
1042
1095
  // A direct recursive call that consumes the tail of a matched list cannot
1043
1096
  // revisit an earlier finite-tree call. It needs neither a cycle guard nor an
@@ -1900,10 +1953,14 @@ function compactIndexBucket(index, type, name) {
1900
1953
 
1901
1954
  function tryPushCompactBinaryChainFrames(stack, solver, group, goal, rest, env, depth, active) {
1902
1955
  if (active.length !== 0 || goal.type !== COMPOUND || goal.arity !== 2) return false;
1903
- const resolved = copyResolved(goal, env);
1904
- const first = resolved.args[0];
1905
- let secondType = resolved.args[1]?.type;
1906
- let secondName = resolved.args[1]?.name;
1956
+ // This fast path only accepts scalar arguments. Dereference those arguments
1957
+ // directly instead of deep-copying the whole goal before discovering that a
1958
+ // list/compound argument is ineligible. Besides avoiding wasted work, this
1959
+ // keeps deep DCG state lists away from recursive copyResolved().
1960
+ const first = derefForLocal(goal.args[0], env);
1961
+ const second = derefForLocal(goal.args[1], env);
1962
+ let secondType = second?.type;
1963
+ let secondName = second?.name;
1907
1964
  if (!isScalarTerm(first) || !['atom', 'string', 'number'].includes(secondType)) return false;
1908
1965
 
1909
1966
  const index = group.argIndexes[1];
@@ -2014,10 +2071,17 @@ function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth,
2014
2071
  // has exactly one matching clause and a single ground body goal; otherwise the
2015
2072
  // normal clause path below remains authoritative.
2016
2073
  if (!termIsGround(goal, env)) return false;
2074
+ // The chain matcher below only propagates variables bound to flat scalar
2075
+ // head arguments (or literal scalars). Keep this optimization on that flat
2076
+ // domain instead of recursively copying an arbitrary ground compound just
2077
+ // to discover later that a rule shape is unsupported. Deep list arguments
2078
+ // such as DCG state therefore fall straight through to normal resolution.
2079
+ const resolvedArgs = goal.args.map((arg) => derefForLocal(arg, env));
2080
+ if (!resolvedArgs.every(isScalarTerm)) return false;
2017
2081
 
2018
2082
  const baseEnv = env;
2019
2083
  let currentGroup = group;
2020
- let currentGoal = copyResolved(goal, env);
2084
+ let currentGoal = compound(goal.name, resolvedArgs);
2021
2085
  let currentDepth = depth;
2022
2086
  const currentEnv = new Env();
2023
2087
  const seen = new Set();
@@ -2328,6 +2392,10 @@ function selectReadyDeterministicBuiltin(goals, env, registry) {
2328
2392
  for (let i = 0; i < goals.length; i++) {
2329
2393
  const goal = goals[i];
2330
2394
  if (goal?.kind === 'releaseActive' || goal?.kind === 'memoStore') return 0;
2395
+ // A first-use proof is derived from source goal order. Do not move a later
2396
+ // deterministic builtin across that equality: doing so could touch one of
2397
+ // its proven-fresh variables before the checked binding executes.
2398
+ if (goal?._localFreshVariables != null) return 0;
2331
2399
  if (goal.type !== COMPOUND && goal.type !== 'atom') continue;
2332
2400
  const def = registry.get(goal.name, goal.arity);
2333
2401
  if (!def?.deterministic || typeof def.ready !== 'function') continue;
package/src/term.js CHANGED
@@ -271,6 +271,10 @@ export function unify(left, right, env, options = {}) {
271
271
  // unification: a variable cannot be bound to a term containing itself.
272
272
  // Bindings are written into the supplied Env.
273
273
  const occursCheckHandler = options.occursCheck === 'fail' ? null : env?._occursCheckHandler;
274
+ // A solver-proven first-use singleton variable cannot already occur in the
275
+ // term it is about to receive. This internal proof lets that one binding
276
+ // skip the occurs traversal without weakening ordinary unification.
277
+ const localFreshVariables = options.localFreshVariables ?? null;
274
278
  const stack = [[left, right]];
275
279
  while (stack.length) {
276
280
  let [a, b] = stack.pop();
@@ -286,7 +290,7 @@ export function unify(left, right, env, options = {}) {
286
290
  continue;
287
291
  }
288
292
  if (a.type === VAR) {
289
- if (occurs(a.name, b, env)) {
293
+ if (!localFreshVariables?.has(a.name) && occurs(a.name, b, env)) {
290
294
  occursCheckHandler?.(a, b, env);
291
295
  return false;
292
296
  }
@@ -295,7 +299,7 @@ export function unify(left, right, env, options = {}) {
295
299
  continue;
296
300
  }
297
301
  if (b.type === VAR) {
298
- if (occurs(b.name, a, env)) {
302
+ if (!localFreshVariables?.has(b.name) && occurs(b.name, a, env)) {
299
303
  occursCheckHandler?.(b, a, env);
300
304
  return false;
301
305
  }
@@ -683,7 +687,13 @@ export function numberTextFromDouble(value) {
683
687
  if (Object.is(value, -0)) value = 0;
684
688
  let text = Number(value).toPrecision(17);
685
689
  if (text.includes('e') || text.includes('E')) {
686
- text = text.replace(/(\.\d*?[1-9])0+(e[+-]?\d+)$/i, '$1$2').replace(/\.0+(e[+-]?\d+)$/i, '$1');
690
+ text = text
691
+ .replace(/(\.\d*?[1-9])0+(e[+-]?\d+)$/i, '$1$2')
692
+ // ISO floating-point syntax requires a fractional part before the
693
+ // exponent. Keep one zero when the fraction is otherwise all zeros so
694
+ // generated text remains readable by EyeProlog itself (for example
695
+ // 1.0e-8 rather than JavaScript's 1e-8).
696
+ .replace(/\.0+(e[+-]?\d+)$/i, '.0$1');
687
697
  } else if (text.includes('.')) {
688
698
  text = text.replace(/0+$/, '').replace(/\.$/, '');
689
699
  }
@@ -31,6 +31,7 @@ import {
31
31
  compound,
32
32
  listFromItems,
33
33
  numberTerm,
34
+ numberTextFromDouble,
34
35
  stringTerm,
35
36
  variable,
36
37
  copyResolved,
@@ -697,6 +698,35 @@ c4 ?- call((!;1)).
697
698
  }
698
699
  },
699
700
  },
701
+ {
702
+ name: 'number_chars and number_codes keep exponent floats syntactically readable (issue #50)',
703
+ run: () => {
704
+ const source = `
705
+ ?- number_chars(1.0e-8,Cs).
706
+ Cs = "1.0e-8".
707
+ ?- number_chars(N,"1.0e-8"), number_chars(N,Cs).
708
+ N = 1.0e-8, Cs = "1.0e-8".
709
+ ?- number_codes(N,[49,46,48,101,45,56]), number_codes(N,Codes).
710
+ N = 1.0e-8, Codes = [49,46,48,101,45,56].
711
+ `;
712
+ const result = publicApi.runQuads(source);
713
+ assertEqual(result.total, 3, 'quad total');
714
+ assertEqual(result.passed, 3, 'quad passed');
715
+ assertEqual(result.failed, 0, 'quad failed');
716
+
717
+ const generated = numberTerm(numberTextFromDouble(1e-8));
718
+ assertEqual(generated.name, '1.0e-8', 'generated float spelling');
719
+ assertEqual(parseNumberTokenText(generated.name).name, '1.0e-8', 'generated spelling parses as a float');
720
+
721
+ for (const value of [1e-8, -1e-8, 1e20, 1e21, Number.MIN_VALUE, Number.MAX_VALUE]) {
722
+ const text = numberTextFromDouble(value);
723
+ if (/[eE]/.test(text)) {
724
+ assertEqual(/^-?\d+\.\d+[eE][+-]?\d+$/.test(text), true, `exponent float syntax ${text}`);
725
+ }
726
+ assertEqual(Number(parseNumberTokenText(text).name), value, `generated float round-trip ${text}`);
727
+ }
728
+ },
729
+ },
700
730
  {
701
731
  name: 'number syntax and number_chars normalize floating-point negative zero',
702
732
  run: () => {
@@ -1287,6 +1317,68 @@ c4 ?- call((!;1)).
1287
1317
  assertEqual(result.stderr, '', 'stderr');
1288
1318
  },
1289
1319
  },
1320
+ {
1321
+ name: 'first-use local equality shortcut preserves finite-tree occurs checking',
1322
+ run: () => {
1323
+ const program = Program.parse(`
1324
+ first_use_cycle :- X = f(Y), Y = g(X).
1325
+ repeated_cycle :- X = f(X).
1326
+ first_use_ok(T) :- X = f(Y), Y = a, T = X.
1327
+ `);
1328
+ const solver = new Solver(program);
1329
+ const solveCount = (text) => {
1330
+ const goal = parseGoalText(text, {
1331
+ doubleQuotes: 'chars',
1332
+ operatorDefinitions: [...program.operators.values()],
1333
+ });
1334
+ let count = 0;
1335
+ for (const _ of solver.solve([goal], new Env(), 0)) count++;
1336
+ return count;
1337
+ };
1338
+ assertEqual(solveCount('first_use_cycle'), 0, 'cycle across later first-use binding');
1339
+ assertEqual(solveCount('repeated_cycle'), 0, 'same-goal repeated variable still checks occurs');
1340
+ assertEqual(solveCount('first_use_ok(f(a))'), 1, 'acyclic first-use bindings still succeed');
1341
+ },
1342
+ },
1343
+ {
1344
+ name: 'phrase/2 fixes the final remainder before running the grammar',
1345
+ run: () => {
1346
+ const program = Program.parse('probe(_, Out) :- var(Out).\n');
1347
+ const solver = new Solver(program);
1348
+ const goal = parseGoalText('phrase(probe, [])', {
1349
+ doubleQuotes: 'chars',
1350
+ operatorDefinitions: [...program.operators.values()],
1351
+ });
1352
+ let count = 0;
1353
+ for (const _ of solver.solve([goal], new Env(), 0)) count++;
1354
+ assertEqual(count, 0, 'phrase/2 exposes [] rather than a temporary output variable');
1355
+ },
1356
+ },
1357
+ {
1358
+ name: 'deep tail-consuming DCG avoids quadratic occurs scans and recursive ground-goal copying',
1359
+ run: () => {
1360
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
1361
+ const script = `
1362
+ import { Program, Solver, Env, atom, compound, listFromItems } from ${JSON.stringify(engineUrl)};
1363
+ const program = Program.parse(${JSON.stringify('s --> [].\ns --> [x], s.\n')});
1364
+ const input = listFromItems(Array.from({ length: 2500 }, () => atom('x')));
1365
+ const solver = new Solver(program, { solutionLimit: 1, maxMemoryBytes: Infinity });
1366
+ const goal = compound('phrase', [atom('s'), input]);
1367
+ let count = 0;
1368
+ for (const _ of solver.solve([goal], new Env(), 0)) { count++; break; }
1369
+ if (count !== 1) throw new Error('deep DCG did not succeed');
1370
+ process.stdout.write('ok');
1371
+ `;
1372
+ const result = spawnSync(process.execPath, [
1373
+ '--input-type=module',
1374
+ '--eval',
1375
+ script,
1376
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 10000 });
1377
+ if (result.error) throw result.error;
1378
+ assertEqual(result.status, 0, `deep DCG child status; stderr=${result.stderr}`);
1379
+ assertEqual(result.stdout, 'ok', 'deep DCG result');
1380
+ },
1381
+ },
1290
1382
  {
1291
1383
  name: 'REPL enumerates and stops answers like the Scryer top level',
1292
1384
  run: () => {
@@ -1905,10 +1905,30 @@ bundled path binds each fresh generated spine directly instead of re-running an
1905
1905
  occurs-check over the whole growing list. It also reserves recovery headroom
1906
1906
  proportional to the retained spine, so a finite heap limit is raised inside the
1907
1907
  `length/2` search as a catchable `resource_error(memory)` rather than allowing
1908
- an outer solver frame to encounter the limit first. The ordinary clauses remain
1909
- the authoritative module definition and are used unchanged by the ISO-only
1910
- registry and whenever delays or finite-domain constraints require their normal
1911
- wake-up points.
1908
+ an outer solver frame to encounter the limit first.
1909
+
1910
+ The same principle now has a conservative general form for freshly renamed
1911
+ clauses. A singleton variable in the clause head, or a variable that has not
1912
+ appeared in the head or any earlier body goal and occurs exactly once in a
1913
+ direct `=/2` goal, cannot already be a subterm of the value it is about to
1914
+ receive. EyeProlog marks only that binding as locally fresh and skips its occurs
1915
+ traversal. A repeated variable such as the `X` in `X = f(X)`, a variable already
1916
+ seen earlier in the clause, and `unify_with_occurs_check/2` all keep the normal
1917
+ finite-tree check. The solver also treats such a first-use equality as a
1918
+ source-order barrier for its deterministic-goal scheduling, so the freshness
1919
+ proof cannot be invalidated by moving a later goal ahead of it. This recovers
1920
+ much of the classic WAM-family "local variable" optimization for DCG tail
1921
+ variables without introducing a WAM local/global stack distinction into the
1922
+ JavaScript term model.
1923
+
1924
+ For grammar execution, `phrase/2` passes its fixed final remainder `[]` directly
1925
+ into the expanded grammar. Besides matching the two-argument contract, this
1926
+ avoids repeatedly trying an empty production against a temporary output
1927
+ variable. `phrase/3` still uses a private final-output variable and delays its
1928
+ last unification, preserving the existing steadfast treatment of its explicit
1929
+ third argument. The ordinary `length/2` clauses remain the authoritative module
1930
+ definition and are used unchanged by the ISO-only registry and whenever delays
1931
+ or finite-domain constraints require their normal wake-up points.
1912
1932
 
1913
1933
  ### Implementation boundary
1914
1934