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.
@@ -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) => isCompactList(term) ? term._compactLength : null;
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
 
@@ -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, 155, 'EyeProlog registry contains ISO definitions, observability extensions, and private library adapters');
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: () => {