eyeprolog 1.3.4 → 1.3.7
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 +66 -0
- package/examples/book/README.md +2 -1
- package/examples/book/chapter-07/02-move.pl +4 -0
- package/index.d.ts +7 -0
- package/package.json +1 -1
- package/src/datalog.js +354 -0
- package/src/iso.js +107 -9
- package/src/program.js +258 -20
- package/src/solver.js +841 -41
- package/src/standard-library.js +5 -0
- package/src/term.js +1 -1
- package/src/wfs.js +338 -0
- package/test/conformance/ISO-COMPLIANCE.md +2 -1
- package/test/conformance/README.md +4 -1
- package/test/run-regression.mjs +188 -1
- package/the-art-of-eyeprolog.md +70 -8
- package/why-eyeprolog.md +17 -0
- /package/examples/book/chapter-07/{02-all_tests_pass.pl → 03-all_tests_pass.pl} +0 -0
package/src/standard-library.js
CHANGED
|
@@ -129,6 +129,10 @@ export const eyePrologInteropLibraryIndicators = Object.freeze(
|
|
|
129
129
|
// modules outside eyePrologInteropLibraryIndicators are still diagnosed when used.
|
|
130
130
|
export const eyePrologInteropLibraryModules = Object.freeze(['lists', 'iso_ext']);
|
|
131
131
|
|
|
132
|
+
function* tabledNegationBuiltin({ solver, goal, env }) {
|
|
133
|
+
yield* solver.solveTabledNegation(goal.args[0], env);
|
|
134
|
+
}
|
|
135
|
+
|
|
132
136
|
function runtimeStatistics(solver) {
|
|
133
137
|
return { ...solver.stats, ...memoryStatistics() };
|
|
134
138
|
}
|
|
@@ -167,6 +171,7 @@ export function createEyePrologRegistry() {
|
|
|
167
171
|
const registry = createDefaultRegistry();
|
|
168
172
|
registry.add('statistics', 0, statisticsBuiltin, { deterministic: true });
|
|
169
173
|
registry.add('statistics', 2, statisticsValueBuiltin);
|
|
174
|
+
registry.add('tnot', 1, tabledNegationBuiltin, { deterministic: true });
|
|
170
175
|
eyePrologLibraryBuiltins.register(registry);
|
|
171
176
|
clpzBuiltins.register(registry);
|
|
172
177
|
registry.eyePrologLibrary = true;
|
package/src/term.js
CHANGED
|
@@ -82,7 +82,7 @@ export const compactVariableList = (length, variablePrefix) => {
|
|
|
82
82
|
return size === 0n ? emptyList() : new CompactListTerm(size, variablePrefix);
|
|
83
83
|
};
|
|
84
84
|
export const isCompactList = (term) => term instanceof CompactListTerm;
|
|
85
|
-
export const compactListLength = (term) =>
|
|
85
|
+
export const compactListLength = (term) => typeof term?._compactLength === 'bigint' ? term._compactLength : null;
|
|
86
86
|
|
|
87
87
|
export class Env {
|
|
88
88
|
constructor(bindings) {
|
package/src/wfs.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Finite Datalog well-founded semantics (WFS) evaluator.
|
|
2
|
+
//
|
|
3
|
+
// This module deliberately targets the function-free, range-restricted subset
|
|
4
|
+
// marked by Program#markRecursivePredicates as group.wfsDatalog. It uses the
|
|
5
|
+
// alternating-fixpoint characterization of WFS: starting from an upper bound on
|
|
6
|
+
// possible atoms, repeatedly compute the least model of the Gelfond-Lifschitz
|
|
7
|
+
// reduct. The final lower relation contains unconditional truths; the final
|
|
8
|
+
// upper relation contains truths plus undefined (conditional) answers.
|
|
9
|
+
//
|
|
10
|
+
// Keeping this separate from ordinary \+/1 is intentional. Only explicit
|
|
11
|
+
// tnot/1 participates in WFS, matching the common tabled-negation convention.
|
|
12
|
+
|
|
13
|
+
import { ATOM, COMPOUND, VAR } from './term.js';
|
|
14
|
+
import { numberValueKey } from './number-value.js';
|
|
15
|
+
|
|
16
|
+
function predicateKey(module, name, arity) {
|
|
17
|
+
return `${module ?? 'user'}:${name}/${arity}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scalarKey(term) {
|
|
21
|
+
if (term.type === 'number') return `number\u0000${numberValueKey(term.name)}`;
|
|
22
|
+
return `${term.type}\u0000${term.name}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sameScalar(left, right) {
|
|
26
|
+
return scalarKey(left) === scalarKey(right);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tupleKey(tuple) {
|
|
30
|
+
return tuple.map(scalarKey).join('\u0001');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class Relation {
|
|
34
|
+
constructor(arity) {
|
|
35
|
+
this.arity = arity;
|
|
36
|
+
this.rows = [];
|
|
37
|
+
this.keys = new Set();
|
|
38
|
+
this.indexes = Array.from({ length: arity }, () => new Map());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
add(tuple) {
|
|
42
|
+
const key = tupleKey(tuple);
|
|
43
|
+
if (this.keys.has(key)) return false;
|
|
44
|
+
const rowIndex = this.rows.length;
|
|
45
|
+
this.keys.add(key);
|
|
46
|
+
this.rows.push(tuple);
|
|
47
|
+
for (let i = 0; i < tuple.length; i++) {
|
|
48
|
+
const keyPart = scalarKey(tuple[i]);
|
|
49
|
+
let bucket = this.indexes[i].get(keyPart);
|
|
50
|
+
if (!bucket) {
|
|
51
|
+
bucket = [];
|
|
52
|
+
this.indexes[i].set(keyPart, bucket);
|
|
53
|
+
}
|
|
54
|
+
bucket.push(rowIndex);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
has(tuple) {
|
|
60
|
+
return this.keys.has(tupleKey(tuple));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
candidateIndexes(args, bindings) {
|
|
64
|
+
let selected = null;
|
|
65
|
+
for (let i = 0; i < args.length; i++) {
|
|
66
|
+
const value = resolvePatternTerm(args[i], bindings);
|
|
67
|
+
if (value == null) continue;
|
|
68
|
+
const bucket = this.indexes[i].get(scalarKey(value)) ?? EMPTY_ARRAY;
|
|
69
|
+
if (selected == null || bucket.length < selected.length) selected = bucket;
|
|
70
|
+
if (selected.length === 0) break;
|
|
71
|
+
}
|
|
72
|
+
return selected ?? null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const EMPTY_ARRAY = Object.freeze([]);
|
|
77
|
+
|
|
78
|
+
function emptyRelations(groups) {
|
|
79
|
+
const relations = new Map();
|
|
80
|
+
for (const group of groups) {
|
|
81
|
+
relations.set(predicateKey(group.module, group.name, group.arity), new Relation(group.arity));
|
|
82
|
+
}
|
|
83
|
+
return relations;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function copyBaseRelations(base, groups) {
|
|
87
|
+
const relations = emptyRelations(groups);
|
|
88
|
+
for (const [key, relation] of base) {
|
|
89
|
+
const target = relations.get(key);
|
|
90
|
+
if (!target) continue;
|
|
91
|
+
for (const row of relation.rows) target.add(row);
|
|
92
|
+
}
|
|
93
|
+
return relations;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function directLiteral(goal, module) {
|
|
97
|
+
if (goal?.type !== COMPOUND && goal?.type !== ATOM) return null;
|
|
98
|
+
return {
|
|
99
|
+
key: predicateKey(goal.module ?? module, goal.name, goal.arity),
|
|
100
|
+
name: goal.name,
|
|
101
|
+
arity: goal.arity,
|
|
102
|
+
module: goal.module ?? module,
|
|
103
|
+
args: goal.args ?? EMPTY_ARRAY,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function negativeLiteral(goal, module) {
|
|
108
|
+
if (goal?.type !== COMPOUND || goal.name !== 'tnot' || goal.arity !== 1) return null;
|
|
109
|
+
return directLiteral(goal.args[0], module);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function dependencyCone(program, rootGroup) {
|
|
113
|
+
const groups = [];
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
const stack = [rootGroup];
|
|
116
|
+
while (stack.length > 0) {
|
|
117
|
+
const group = stack.pop();
|
|
118
|
+
const key = predicateKey(group.module, group.name, group.arity);
|
|
119
|
+
if (seen.has(key)) continue;
|
|
120
|
+
seen.add(key);
|
|
121
|
+
groups.push(group);
|
|
122
|
+
|
|
123
|
+
for (const clause of group.clauses) {
|
|
124
|
+
for (const goal of clause.body) {
|
|
125
|
+
const neg = negativeLiteral(goal, group.module);
|
|
126
|
+
const literal = neg ?? directLiteral(goal, group.module);
|
|
127
|
+
if (!literal) continue;
|
|
128
|
+
const target = program.findGroup(literal.name, literal.arity, literal.module);
|
|
129
|
+
if (target) stack.push(target);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return groups;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function compileProgram(program, rootGroup) {
|
|
137
|
+
const groups = dependencyCone(program, rootGroup);
|
|
138
|
+
const base = emptyRelations(groups);
|
|
139
|
+
const rules = [];
|
|
140
|
+
|
|
141
|
+
for (const group of groups) {
|
|
142
|
+
const headKey = predicateKey(group.module, group.name, group.arity);
|
|
143
|
+
for (const clause of group.clauses) {
|
|
144
|
+
if (clause.body.length === 0) {
|
|
145
|
+
const tuple = clause.head.args ?? EMPTY_ARRAY;
|
|
146
|
+
// Program analysis already guarantees ground scalar facts in the WFS
|
|
147
|
+
// cone. Keep the guard so malformed dynamic input cannot corrupt a model.
|
|
148
|
+
if (tuple.every((term) => term.type !== VAR && (term.type === ATOM || term.type === 'number' || term.type === 'string'))) {
|
|
149
|
+
base.get(headKey)?.add(tuple);
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const positives = [];
|
|
155
|
+
const negatives = [];
|
|
156
|
+
for (const goal of clause.body) {
|
|
157
|
+
const neg = negativeLiteral(goal, group.module);
|
|
158
|
+
if (neg) negatives.push(neg);
|
|
159
|
+
else {
|
|
160
|
+
const pos = directLiteral(goal, group.module);
|
|
161
|
+
if (pos) positives.push(pos);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
rules.push({
|
|
165
|
+
head: {
|
|
166
|
+
key: headKey,
|
|
167
|
+
args: clause.head.args ?? EMPTY_ARRAY,
|
|
168
|
+
},
|
|
169
|
+
positives,
|
|
170
|
+
negatives,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { groups, base, rules };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolvePatternTerm(term, bindings) {
|
|
179
|
+
if (term.type === VAR) return bindings.get(term.name) ?? null;
|
|
180
|
+
return term;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function matchTuple(args, tuple, bindings) {
|
|
184
|
+
let next = null;
|
|
185
|
+
for (let i = 0; i < args.length; i++) {
|
|
186
|
+
const pattern = args[i];
|
|
187
|
+
if (pattern.type === VAR) {
|
|
188
|
+
const current = (next ?? bindings).get(pattern.name);
|
|
189
|
+
if (current != null) {
|
|
190
|
+
if (!sameScalar(current, tuple[i])) return null;
|
|
191
|
+
} else {
|
|
192
|
+
if (next == null) next = new Map(bindings);
|
|
193
|
+
next.set(pattern.name, tuple[i]);
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!sameScalar(pattern, tuple[i])) return null;
|
|
198
|
+
}
|
|
199
|
+
return next ?? bindings;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function estimateLiteral(literal, relation, bindings) {
|
|
203
|
+
const candidates = relation.candidateIndexes(literal.args, bindings);
|
|
204
|
+
return candidates == null ? relation.rows.length : candidates.length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function forEachPositiveBinding(positives, relations, callback, bindings = new Map(), remaining = null) {
|
|
208
|
+
if (remaining == null) remaining = positives.map((_, index) => index);
|
|
209
|
+
if (remaining.length === 0) {
|
|
210
|
+
callback(bindings);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let bestPos = 0;
|
|
215
|
+
let bestEstimate = Infinity;
|
|
216
|
+
for (let position = 0; position < remaining.length; position++) {
|
|
217
|
+
const literal = positives[remaining[position]];
|
|
218
|
+
const relation = relations.get(literal.key);
|
|
219
|
+
const estimate = relation ? estimateLiteral(literal, relation, bindings) : 0;
|
|
220
|
+
if (estimate < bestEstimate) {
|
|
221
|
+
bestEstimate = estimate;
|
|
222
|
+
bestPos = position;
|
|
223
|
+
if (estimate === 0) return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const literalIndex = remaining[bestPos];
|
|
228
|
+
const literal = positives[literalIndex];
|
|
229
|
+
const relation = relations.get(literal.key);
|
|
230
|
+
if (!relation) return;
|
|
231
|
+
const candidates = relation.candidateIndexes(literal.args, bindings);
|
|
232
|
+
const nextRemaining = remaining.length === 1
|
|
233
|
+
? EMPTY_ARRAY
|
|
234
|
+
: [...remaining.slice(0, bestPos), ...remaining.slice(bestPos + 1)];
|
|
235
|
+
|
|
236
|
+
if (candidates == null) {
|
|
237
|
+
for (let rowIndex = 0; rowIndex < relation.rows.length; rowIndex++) {
|
|
238
|
+
const next = matchTuple(literal.args, relation.rows[rowIndex], bindings);
|
|
239
|
+
if (next) forEachPositiveBinding(positives, relations, callback, next, nextRemaining);
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
for (const rowIndex of candidates) {
|
|
244
|
+
const next = matchTuple(literal.args, relation.rows[rowIndex], bindings);
|
|
245
|
+
if (next) forEachPositiveBinding(positives, relations, callback, next, nextRemaining);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function instantiateTuple(args, bindings) {
|
|
250
|
+
const tuple = [];
|
|
251
|
+
for (const arg of args) {
|
|
252
|
+
const value = arg.type === VAR ? bindings.get(arg.name) : arg;
|
|
253
|
+
if (value == null || value.type === VAR) return null;
|
|
254
|
+
tuple.push(value);
|
|
255
|
+
}
|
|
256
|
+
return tuple;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function blockedByNegative(negatives, blockerRelations, bindings) {
|
|
260
|
+
if (!blockerRelations) return false;
|
|
261
|
+
for (const literal of negatives) {
|
|
262
|
+
const relation = blockerRelations.get(literal.key);
|
|
263
|
+
if (!relation) continue;
|
|
264
|
+
const tuple = instantiateTuple(literal.args, bindings);
|
|
265
|
+
if (tuple == null) return true; // range restriction should make this unreachable
|
|
266
|
+
if (relation.has(tuple)) return true;
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function gamma(compiled, blockerRelations = null) {
|
|
272
|
+
const relations = copyBaseRelations(compiled.base, compiled.groups);
|
|
273
|
+
let changed = true;
|
|
274
|
+
while (changed) {
|
|
275
|
+
changed = false;
|
|
276
|
+
for (const rule of compiled.rules) {
|
|
277
|
+
const headRelation = relations.get(rule.head.key);
|
|
278
|
+
if (!headRelation) continue;
|
|
279
|
+
forEachPositiveBinding(rule.positives, relations, (bindings) => {
|
|
280
|
+
if (blockedByNegative(rule.negatives, blockerRelations, bindings)) return;
|
|
281
|
+
const tuple = instantiateTuple(rule.head.args, bindings);
|
|
282
|
+
if (tuple != null && headRelation.add(tuple)) changed = true;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return relations;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function relationSetsEqual(left, right) {
|
|
290
|
+
if (left.size !== right.size) return false;
|
|
291
|
+
for (const [key, leftRelation] of left) {
|
|
292
|
+
const rightRelation = right.get(key);
|
|
293
|
+
if (!rightRelation || leftRelation.keys.size !== rightRelation.keys.size) return false;
|
|
294
|
+
for (const tuple of leftRelation.keys) if (!rightRelation.keys.has(tuple)) return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function evaluateWfs(program, rootGroup) {
|
|
300
|
+
const compiled = compileProgram(program, rootGroup);
|
|
301
|
+
|
|
302
|
+
// Positive closure with tnot erased is an upper bound on every atom that can
|
|
303
|
+
// participate in the WFS model, avoiding construction of the full Herbrand
|
|
304
|
+
// cross product for predicates of arity > 1.
|
|
305
|
+
let upper = gamma(compiled, null);
|
|
306
|
+
let lower = emptyRelations(compiled.groups);
|
|
307
|
+
let rounds = 0;
|
|
308
|
+
|
|
309
|
+
while (true) {
|
|
310
|
+
rounds++;
|
|
311
|
+
const nextLower = gamma(compiled, upper);
|
|
312
|
+
const nextUpper = gamma(compiled, nextLower);
|
|
313
|
+
if (relationSetsEqual(lower, nextLower) && relationSetsEqual(upper, nextUpper)) {
|
|
314
|
+
lower = nextLower;
|
|
315
|
+
upper = nextUpper;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
lower = nextLower;
|
|
319
|
+
upper = nextUpper;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { lower, upper, rounds, groups: compiled.groups };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function relationForGroup(model, group, kind = 'upper') {
|
|
326
|
+
const relations = kind === 'lower' ? model.lower : model.upper;
|
|
327
|
+
return relations.get(predicateKey(group.module, group.name, group.arity)) ?? null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function truthOfGroundGoal(model, goal) {
|
|
331
|
+
const key = predicateKey(goal.module ?? 'user', goal.name, goal.arity);
|
|
332
|
+
const tuple = goal.args ?? EMPTY_ARRAY;
|
|
333
|
+
const lower = model.lower.get(key);
|
|
334
|
+
if (lower?.has(tuple)) return 'true';
|
|
335
|
+
const upper = model.upper.get(key);
|
|
336
|
+
if (upper?.has(tuple)) return 'undefined';
|
|
337
|
+
return 'false';
|
|
338
|
+
}
|
|
@@ -56,7 +56,8 @@ not interpret the following as core-language features:
|
|
|
56
56
|
- the implementation-specific `occurs_check` Prolog flag;
|
|
57
57
|
- Part 2 module directives (`module/2`, `use_module/1-2`, `meta_predicate/1`);
|
|
58
58
|
- Part 3 grammar-rule expansion and `phrase/2-3`;
|
|
59
|
-
- automatic tabling, cycle guards, and recursive numeric execution shortcuts
|
|
59
|
+
- automatic tabling, cycle guards, and recursive numeric execution shortcuts;
|
|
60
|
+
- EyeProlog well-founded negation via `tnot/1` and its WFS runtime statistics.
|
|
60
61
|
|
|
61
62
|
The predefined Part 1 `1200 fx` `?-` operator and `1200 xfx` `-->` operator
|
|
62
63
|
remain ordinary operator syntax in strict core mode. A conforming `op/3`
|
|
@@ -21,7 +21,10 @@ processor-requirement ledger for the Part 1 strict-core audit. This suite is
|
|
|
21
21
|
still not an independent certification of every processor requirement, lexical
|
|
22
22
|
edge, option combination, or prescribed error precedence. Cases under `iso/`
|
|
23
23
|
identify standards-derived behavior; other directories cover EyeProlog host
|
|
24
|
-
contracts and extensions.
|
|
24
|
+
contracts and extensions. EyeProlog-only execution features such as automatic
|
|
25
|
+
tabling and `tnot/1` well-founded negation are outside the Part 1 strict-core
|
|
26
|
+
claim. Their focused semantic coverage lives primarily in regression tests;
|
|
27
|
+
`tnot/1` is absent from the strict ISO registry.
|
|
25
28
|
|
|
26
29
|
All conformance files live under topic directories such as `arithmetic/`, `lists/`, `syntax/`, or `variables/`; new top-level numbered files should not be added. The report uses those directories as coverage categories.
|
|
27
30
|
|
package/test/run-regression.mjs
CHANGED
|
@@ -3444,6 +3444,103 @@ check(A, B, C, D, E, F) :-
|
|
|
3444
3444
|
assertEqual(result.stdout, '250000', 'distinct number syntax attempts');
|
|
3445
3445
|
},
|
|
3446
3446
|
},
|
|
3447
|
+
{
|
|
3448
|
+
name: 'recursive phrase tables stay bounded across distinct inputs (issues #28/#48)',
|
|
3449
|
+
run: () => {
|
|
3450
|
+
const engineUrl = new URL('../src/index.js', import.meta.url).href;
|
|
3451
|
+
const programText = `
|
|
3452
|
+
:- use_module(library(prologue)).
|
|
3453
|
+
... --> [].
|
|
3454
|
+
... --> [_], ... .
|
|
3455
|
+
|
|
3456
|
+
vchars("0123456789.").
|
|
3457
|
+
|
|
3458
|
+
ti(Nth:E:Chs, Pot, Start) :-
|
|
3459
|
+
length(Chs, _),
|
|
3460
|
+
call_nth(chs(Chs), Nth), Nth >= Start,
|
|
3461
|
+
Nth mod 10^Pot =:= 0,
|
|
3462
|
+
E = cnt.
|
|
3463
|
+
|
|
3464
|
+
chs(Chs) :-
|
|
3465
|
+
vchars(VChs),
|
|
3466
|
+
maplistch(Chs, VChs),
|
|
3467
|
+
catch(number_chars(_, Chs), error(syntax_error(_), _), false),
|
|
3468
|
+
\\+ phrase((..., ("//"|"++")), Chs).
|
|
3469
|
+
|
|
3470
|
+
maplistch([], _).
|
|
3471
|
+
maplistch([Ch|Chs], VChs) :-
|
|
3472
|
+
member(Ch, VChs),
|
|
3473
|
+
maplistch(Chs, VChs).
|
|
3474
|
+
`;
|
|
3475
|
+
const script = `
|
|
3476
|
+
import { Program, Solver, Env, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
|
|
3477
|
+
const program = Program.parse(${JSON.stringify(programText)}, { autoloadGoals: ['ti(R,3,0)'] });
|
|
3478
|
+
const listTailGroup = program.findGroup('...', 2, 'user');
|
|
3479
|
+
if (listTailGroup?.listTailRecursive !== true) {
|
|
3480
|
+
throw new Error('recursive DCG tail-consumption was not recognized');
|
|
3481
|
+
}
|
|
3482
|
+
const solver = new Solver(program, { registry: getEyePrologRegistry(), maxMemoryBytes: Infinity });
|
|
3483
|
+
const goal = parseGoalText('ti(R,3,0)', {
|
|
3484
|
+
doubleQuotes: 'chars',
|
|
3485
|
+
operatorDefinitions: [...program.operators.values()],
|
|
3486
|
+
});
|
|
3487
|
+
let count = 0;
|
|
3488
|
+
for (const _ of solver.solve([goal], new Env(), 0)) {
|
|
3489
|
+
if (++count === 20) break;
|
|
3490
|
+
}
|
|
3491
|
+
if (count !== 20) throw new Error('unexpected checkpoint count: ' + count);
|
|
3492
|
+
const scope = solver.innerTableScopes.get('phrase');
|
|
3493
|
+
if (scope == null) throw new Error('missing phrase table scope');
|
|
3494
|
+
if (scope.memo.size !== 0) throw new Error('distinct tail-DCG inputs should not retain phrase tables: ' + scope.memo.size);
|
|
3495
|
+
process.stdout.write(count + ':' + scope.memo.size);
|
|
3496
|
+
`;
|
|
3497
|
+
const result = spawnSync(process.execPath, [
|
|
3498
|
+
// This corresponds to 20,000 accepted number candidates. Before the
|
|
3499
|
+
// phrase-local cache, every distinct recursive DCG tail stayed rooted
|
|
3500
|
+
// in the caller memo and this workload exhausted a 32 MiB old space.
|
|
3501
|
+
'--max-old-space-size=32',
|
|
3502
|
+
'--input-type=module',
|
|
3503
|
+
'--eval',
|
|
3504
|
+
script,
|
|
3505
|
+
], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
|
|
3506
|
+
if (result.error) throw result.error;
|
|
3507
|
+
assertEqual(result.status, 0, `bounded phrase-table child status; stderr=${result.stderr}`);
|
|
3508
|
+
const [countText, cacheText] = result.stdout.trim().split(':');
|
|
3509
|
+
assertEqual(countText, '20', 'recursive phrase checkpoints');
|
|
3510
|
+
assertEqual(cacheText, '0', 'distinct tail-DCG phrase cache');
|
|
3511
|
+
},
|
|
3512
|
+
},
|
|
3513
|
+
{
|
|
3514
|
+
name: 'repeated identical phrase invocation reuses a compact table (issue #48)',
|
|
3515
|
+
run: () => {
|
|
3516
|
+
const program = Program.parse(`
|
|
3517
|
+
:- use_module(library(prologue)).
|
|
3518
|
+
... --> [].
|
|
3519
|
+
... --> [_], ... .
|
|
3520
|
+
`, { autoloadGoals: ['phrase((...,("ba"|"gh")),"abcdef")'] });
|
|
3521
|
+
const solver = new Solver(program, { registry: getEyePrologRegistry(), maxMemoryBytes: Infinity });
|
|
3522
|
+
const goal = parseGoalText('phrase((...,("ba"|"gh")),"abcdef")', {
|
|
3523
|
+
doubleQuotes: 'chars',
|
|
3524
|
+
operatorDefinitions: [...program.operators.values()],
|
|
3525
|
+
});
|
|
3526
|
+
const runFailure = () => {
|
|
3527
|
+
let answers = 0;
|
|
3528
|
+
for (const _ of solver.solve([goal], new Env(), 0)) answers++;
|
|
3529
|
+
assertEqual(answers, 0, 'failing phrase answer count');
|
|
3530
|
+
};
|
|
3531
|
+
runFailure();
|
|
3532
|
+
assertEqual(solver.stats.table_fixpoint_rounds, 0, 'first distinct tail-DCG invocation stays untabled');
|
|
3533
|
+
runFailure();
|
|
3534
|
+
const roundsAfterSecond = solver.stats.table_fixpoint_rounds;
|
|
3535
|
+
if (roundsAfterSecond <= 0) throw new Error('repeated phrase invocation did not build reusable table');
|
|
3536
|
+
const scope = solver.innerTableScopes.get('phrase');
|
|
3537
|
+
if (scope == null || scope.memo.size <= 0 || scope.memo.size > 1024) {
|
|
3538
|
+
throw new Error('unexpected repeated phrase table size: ' + (scope?.memo.size ?? 'missing'));
|
|
3539
|
+
}
|
|
3540
|
+
runFailure();
|
|
3541
|
+
assertEqual(solver.stats.table_fixpoint_rounds, roundsAfterSecond, 'third identical phrase call reuses completed table');
|
|
3542
|
+
},
|
|
3543
|
+
},
|
|
3447
3544
|
{
|
|
3448
3545
|
name: 'host Map/Set capacity errors become resource_error(memory)',
|
|
3449
3546
|
run: () => {
|
|
@@ -3626,13 +3723,15 @@ check(A, B, C, D, E, F) :-
|
|
|
3626
3723
|
assertEqual(Boolean(registry.get('is', 2)), true, 'ISO is/2 exists');
|
|
3627
3724
|
assertEqual(Boolean(registry.get('append', 3)), false, 'append/3 is not ISO core');
|
|
3628
3725
|
assertEqual(library.eyePrologLibrary, true, 'complete registry marker');
|
|
3629
|
-
assertEqual(library.defs.size,
|
|
3726
|
+
assertEqual(library.defs.size, 156, 'EyeProlog registry contains ISO definitions, observability extensions, WFS tnot/1, and private library adapters');
|
|
3630
3727
|
assertEqual(Boolean(registry.get('phrase', 2)), true, 'Part 3 phrase/2 exists');
|
|
3631
3728
|
assertEqual(Boolean(registry.get('phrase', 3)), true, 'Part 3 phrase/3 exists');
|
|
3632
3729
|
assertEqual(registry.get('statistics', 0), null, 'statistics/0 is absent from the ISO registry');
|
|
3633
3730
|
assertEqual(registry.get('statistics', 2), null, 'statistics/2 is absent from the ISO registry');
|
|
3634
3731
|
assertEqual(Boolean(library.get('statistics', 0)), true, 'statistics/0 is an EyeProlog observability extension');
|
|
3635
3732
|
assertEqual(Boolean(library.get('statistics', 2)), true, 'statistics/2 is an EyeProlog observability extension');
|
|
3733
|
+
assertEqual(registry.get('tnot', 1), null, 'tnot/1 is absent from the ISO registry');
|
|
3734
|
+
assertEqual(Boolean(library.get('tnot', 1)), true, 'tnot/1 is an EyeProlog WFS extension');
|
|
3636
3735
|
assertEqual(registeredNativeEyePrologLibraryNames().length, 40, 'public native EyeProlog builtin count');
|
|
3637
3736
|
assertEqual(eyePrologPortableLibraryIndicators.length, 62, 'portable Prolog library count');
|
|
3638
3737
|
assertEqual(eyePrologInteropLibraryIndicators.length, 27, 'cross-implementation interop profile count');
|
|
@@ -4266,6 +4365,94 @@ function whiteBoxCases() {
|
|
|
4266
4365
|
assertEqual(program.findGroup('q', 1).tabled, false, 'q/1 not positively tabled');
|
|
4267
4366
|
},
|
|
4268
4367
|
},
|
|
4368
|
+
{
|
|
4369
|
+
name: 'large finite Datalog uses an indexed least model only for broad calls',
|
|
4370
|
+
run: () => {
|
|
4371
|
+
const edges = Array.from({ length: 130 }, (_, i) => `edge(n${i}, n${i + 1}).`).join('\n');
|
|
4372
|
+
const source = `${edges}\npath(X,Y) :- edge(X,Y).\npath(X,Y) :- edge(X,Z), path(Z,Y).\n`;
|
|
4373
|
+
const program = Program.parse(source);
|
|
4374
|
+
const group = program.findGroup('path', 2);
|
|
4375
|
+
assertEqual(group.datalogLeastModel, true, 'large range-restricted Datalog is eligible');
|
|
4376
|
+
|
|
4377
|
+
const broad = run(program, { goal: 'path(X,Y)' });
|
|
4378
|
+
assertEqual(broad.stdout.trim().split('\n').length, 8515, 'semi-naive closure has every chain pair');
|
|
4379
|
+
assertEqual(broad.stats.datalog_evaluations, 1, 'broad query builds one least model');
|
|
4380
|
+
|
|
4381
|
+
const ground = run(program, { goal: 'path(n0,n130)' });
|
|
4382
|
+
assertEqual(ground.stdout, 'path(n0, n130).\n', 'ground chain still succeeds');
|
|
4383
|
+
assertEqual(ground.stats.datalog_evaluations, 0, 'ground query keeps the ordinary indexed chain path');
|
|
4384
|
+
},
|
|
4385
|
+
},
|
|
4386
|
+
{
|
|
4387
|
+
name: 'findall length counting preserves multiplicity and observable bags',
|
|
4388
|
+
run: () => {
|
|
4389
|
+
const source = `
|
|
4390
|
+
p(a).
|
|
4391
|
+
p(b).
|
|
4392
|
+
q(X) :- p(X).
|
|
4393
|
+
q(X) :- p(X).
|
|
4394
|
+
bench(N) :- findall(X, q(X), Bag), length(Bag, N).
|
|
4395
|
+
collect(Bag,N) :- findall(X, q(X), Bag), length(Bag, N).
|
|
4396
|
+
`;
|
|
4397
|
+
assertEqual(run(source, { goal: 'bench(N)' }).stdout, 'bench(4).\n', 'dead bag counts all four proof solutions');
|
|
4398
|
+
assertEqual(run(source, { goal: 'collect(Bag,N)' }).stdout, 'collect("abab", 4).\n', 'bag exposed through the rule head is materialized');
|
|
4399
|
+
},
|
|
4400
|
+
},
|
|
4401
|
+
{
|
|
4402
|
+
name: 'ground negation probes a complete positive Datalog model',
|
|
4403
|
+
run: () => {
|
|
4404
|
+
const edges = Array.from({ length: 130 }, (_, i) => `edge(n${i}, n${i + 1}).`).join('\n');
|
|
4405
|
+
const source = `${edges}
|
|
4406
|
+
path(X,Y) :- edge(X,Y).
|
|
4407
|
+
path(X,Y) :- edge(X,Z), path(Z,Y).
|
|
4408
|
+
blocked(X,Y) :- path(X,Y).
|
|
4409
|
+
candidate(n0,n130).
|
|
4410
|
+
candidate(n130,n0).
|
|
4411
|
+
keep(X,Y) :- candidate(X,Y), \\+ blocked(X,Y).
|
|
4412
|
+
`;
|
|
4413
|
+
const result = run(source, { goal: 'keep(X,Y)' });
|
|
4414
|
+
assertEqual(result.stdout, 'keep(n130, n0).\n', 'negation sees the complete transitive closure');
|
|
4415
|
+
assertEqual(result.stats.datalog_evaluations, 1, 'ground truth probe reuses one complete least model');
|
|
4416
|
+
},
|
|
4417
|
+
},
|
|
4418
|
+
{
|
|
4419
|
+
name: 'tnot enables finite Datalog well-founded semantics without changing ISO negation',
|
|
4420
|
+
run: () => {
|
|
4421
|
+
const program = Program.parse(`
|
|
4422
|
+
move(1, 2).
|
|
4423
|
+
move(2, 3).
|
|
4424
|
+
move(3, 4).
|
|
4425
|
+
move(4, 1).
|
|
4426
|
+
win(X) :- move(X, Y), tnot(win(Y)).
|
|
4427
|
+
`);
|
|
4428
|
+
assertEqual(program.findGroup('win', 1).wfsDatalog, true, 'win/1 uses finite WFS evaluation');
|
|
4429
|
+
assertEqual(program.findGroup('win', 1).tabled, false, 'win/1 is not positive least-model tabled');
|
|
4430
|
+
const result = run(program, { goal: 'win(X)' });
|
|
4431
|
+
assertEqual(result.stdout.trim().split('\n').length, 4, 'negative cycle yields four conditional WFS answers');
|
|
4432
|
+
assertEqual(result.stats.wfs_fixpoint_rounds, 2, 'alternating fixed point converges in two rounds');
|
|
4433
|
+
assertEqual(result.stats.wfs_undefined_answers, 4, 'cycle answers are undefined rather than unconditional truths');
|
|
4434
|
+
|
|
4435
|
+
const nafProgram = Program.parse('p(X) :- \\+ q(X).\nq(X) :- p(X).\n');
|
|
4436
|
+
assertEqual(nafProgram.findGroup('p', 1).wfsDatalog, false, '\\+/1 remains ordinary negation as failure');
|
|
4437
|
+
},
|
|
4438
|
+
},
|
|
4439
|
+
{
|
|
4440
|
+
name: 'finite WFS distinguishes true false and undefined for ground tnot calls',
|
|
4441
|
+
run: () => {
|
|
4442
|
+
const source = `
|
|
4443
|
+
move(a, b).
|
|
4444
|
+
move(c, d).
|
|
4445
|
+
move(d, c).
|
|
4446
|
+
win(X) :- move(X, Y), tnot(win(Y)).
|
|
4447
|
+
true_case :- win(a).
|
|
4448
|
+
false_case :- tnot(win(a)).
|
|
4449
|
+
undefined_case :- tnot(win(c)).
|
|
4450
|
+
`;
|
|
4451
|
+
assertEqual(run(source, { goal: 'true_case' }).stdout, 'true_case.\n', 'win(a) is true because b is false');
|
|
4452
|
+
assertEqual(run(source, { goal: 'false_case' }).stdout, '', 'tnot(win(a)) fails for a true atom');
|
|
4453
|
+
assertEqual(run(source, { goal: 'undefined_case' }).stdout, 'undefined_case.\n', 'tnot of an undefined WFS atom is a conditional success');
|
|
4454
|
+
},
|
|
4455
|
+
},
|
|
4269
4456
|
{
|
|
4270
4457
|
name: 'cyclic tabling reaches a complete fixed point',
|
|
4271
4458
|
run: () => {
|