eyeprolog 1.3.11 → 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 +11 -0
- package/package.json +1 -1
- package/src/iso.js +10 -6
- package/src/solver.js +75 -7
- package/src/term.js +6 -2
- package/test/run-regression.mjs +62 -0
- package/the-art-of-eyeprolog.md +24 -4
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
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
|
-
|
|
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
|
-
//
|
|
2037
|
-
//
|
|
2038
|
-
|
|
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
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
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 =
|
|
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
|
}
|
package/test/run-regression.mjs
CHANGED
|
@@ -1317,6 +1317,68 @@ c4 ?- call((!;1)).
|
|
|
1317
1317
|
assertEqual(result.stderr, '', 'stderr');
|
|
1318
1318
|
},
|
|
1319
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
|
+
},
|
|
1320
1382
|
{
|
|
1321
1383
|
name: 'REPL enumerates and stops answers like the Scryer top level',
|
|
1322
1384
|
run: () => {
|
package/the-art-of-eyeprolog.md
CHANGED
|
@@ -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.
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
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
|
|