eyeprolog 1.6.39 → 1.6.41

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.
@@ -0,0 +1 @@
1
+ chain9998(ready).
@@ -0,0 +1 @@
1
+ chain4998(ready).
@@ -0,0 +1,8 @@
1
+ chain9998(ready).
2
+
3
+ clause(9999, chain9998(var('X')), chain9999(var('X'))).
4
+ clause(10000, chain9999(ready), !).
5
+
6
+ step(chain9998(ready), rule(9999), ['X' = ready], [chain9999(ready)]).
7
+ step(chain9999(ready), rule(10000), [], "!").
8
+ step(!, builtin, [], []).
@@ -0,0 +1,7 @@
1
+ chain4998(ready).
2
+
3
+ clause(4999, chain4998(var('X')), chain4999(var('X'))).
4
+ clause(5000, chain4999(ready), true).
5
+
6
+ step(chain4998(ready), rule(4999), ['X' = ready], [chain4999(ready)]).
7
+ step(chain4999(ready), fact(5000), [], []).
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.6.39",
6
+ "version": "1.6.41",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/playground.html CHANGED
@@ -504,6 +504,8 @@
504
504
  "defeasible-reasoning",
505
505
  "delfour",
506
506
  "deontic-logic",
507
+ "dependency-depth-10000",
508
+ "dependency-preparation-5000",
507
509
  "derived-backward-rule",
508
510
  "derived-rule",
509
511
  "diamond-property",
@@ -152,8 +152,19 @@ function isAtom(term, name) {
152
152
  return term?.type === ATOM && term.name === name;
153
153
  }
154
154
 
155
+ // While a proof is checked, each term is spelled once: a step's conclusion is
156
+ // also the use of the step that rests on it, and the coverage, relevance and
157
+ // well-foundedness checks each look it up.
158
+ let keyMemo = null;
159
+
155
160
  function key(term) {
156
- return termToString(term, new Env(), true);
161
+ if (keyMemo == null || term === null || typeof term !== 'object') return termToString(term, new Env(), true);
162
+ let spelled = keyMemo.get(term);
163
+ if (spelled === undefined) {
164
+ spelled = termToString(term, new Env(), true);
165
+ keyMemo.set(term, spelled);
166
+ }
167
+ return spelled;
157
168
  }
158
169
 
159
170
  // A term is identical to another when it needs no further instantiation to
@@ -455,7 +466,17 @@ export function checkProofDocument(program, text, options = {}) {
455
466
  // its own proofs this way before writing them (SPEC.md, Section 7.1); that the
456
467
  // text it writes reads back as the same structures is what checking the
457
468
  // packaged documents establishes.
458
- export function checkProof(program, { claims, records = [], steps: allSteps }, options = {}, failures = []) {
469
+ export function checkProof(program, document, options = {}, failures = []) {
470
+ const outer = keyMemo;
471
+ keyMemo ??= new WeakMap();
472
+ try {
473
+ return checkProofWithKeys(program, document, options, failures);
474
+ } finally {
475
+ keyMemo = outer;
476
+ }
477
+ }
478
+
479
+ function checkProofWithKeys(program, { claims, records = [], steps: allSteps }, options, failures) {
459
480
  const writeOptions = {
460
481
  doubleQuotes: program?.doubleQuotes ?? 'chars', doubleBar: true, quoted: true,
461
482
  operators: [...(program?.operators?.values() ?? [])],
@@ -472,7 +493,13 @@ export function checkProof(program, { claims, records = [], steps: allSteps }, o
472
493
  for (const record of records) {
473
494
  const number = record.args[0]?.type === NUMBER ? Number(record.args[0].name) : NaN;
474
495
  const clause = Number.isInteger(number) ? numbering.get(number) : null;
475
- if (!clause || !identical(normalizedClauseRecord(record), normalizedClauseRecord(clauseRecordTerm(number, clause)))) {
496
+ if (!clause) {
497
+ fail('C1', 'clause record differs from source', record);
498
+ continue;
499
+ }
500
+ // A record that is the rebuilt record itself restates the clause.
501
+ const rebuilt = clauseRecordTerm(number, clause);
502
+ if (record !== rebuilt && !identical(normalizedClauseRecord(record), normalizedClauseRecord(rebuilt))) {
476
503
  fail('C1', 'clause record differs from source', record);
477
504
  }
478
505
  }
package/src/explain.js CHANGED
@@ -1009,6 +1009,7 @@ function resolvedSubstitutions(substitutions, env) {
1009
1009
  // would displace the real clause and every step citing it would stop
1010
1010
  // resolving. Such a declaration is never what a rule(N) or fact(N) cites.
1011
1011
  function isUnnumberedDeclaration(clause) {
1012
+ if (clause?.compactBinary === true) return false;
1012
1013
  const head = clause?.head;
1013
1014
  if (head?.type !== COMPOUND || head.name !== ':-' || head.arity !== 1) return false;
1014
1015
  const directive = head.args[0];
@@ -1016,7 +1017,36 @@ function isUnnumberedDeclaration(clause) {
1016
1017
  return ['module', 'use_module', 'meta_predicate', 'attribute'].includes(directive.name);
1017
1018
  }
1018
1019
 
1020
+ // A Map from small positive integers, kept in an array.
1021
+ class NumberedTable {
1022
+ constructor() {
1023
+ this.items = [];
1024
+ }
1025
+
1026
+ get(number) {
1027
+ return Number.isInteger(number) && number >= 0 ? this.items[number] : undefined;
1028
+ }
1029
+
1030
+ set(number, value) {
1031
+ this.items[number] = value;
1032
+ }
1033
+ }
1034
+
1035
+ // A proof is numbered when it is flattened and again when it is checked, and
1036
+ // both see the same program; the numbering is kept until the program's
1037
+ // clauses change.
1038
+ const numberings = new WeakMap();
1039
+
1019
1040
  export function clauseNumbering(program) {
1041
+ const clauses = program.clauses ?? [];
1042
+ const cached = numberings.get(program);
1043
+ if (cached && cached.clauses === clauses && cached.length === clauses.length) return cached.numbering;
1044
+ const numbering = computeClauseNumbering(program);
1045
+ numberings.set(program, { clauses, length: clauses.length, numbering });
1046
+ return numbering;
1047
+ }
1048
+
1049
+ function computeClauseNumbering(program) {
1020
1050
  const spans = new Map();
1021
1051
  for (const clause of program.clauses ?? []) {
1022
1052
  if (!isProgramClause(clause)) continue;
@@ -1032,17 +1062,19 @@ export function clauseNumbering(program) {
1032
1062
 
1033
1063
  // Keyed by file, then by clause position: one string key per clause cost
1034
1064
  // more than the numbering itself on programs with hundreds of thousands of
1035
- // clauses.
1065
+ // clauses. Positions and numbers count up from 1, so arrays hold them.
1036
1066
  const bySource = new Map();
1037
- const byNumber = new Map();
1067
+ const byNumber = new NumberedTable();
1038
1068
  for (const clause of program.clauses ?? []) {
1039
1069
  if (!isProgramClause(clause) || isUnnumberedDeclaration(clause)) continue;
1040
1070
  const { filename, clause: position } = clause.source;
1041
1071
  const number = (offsets.get(filename) ?? 0) + position;
1042
1072
  let numbers = bySource.get(filename);
1043
- if (!numbers) bySource.set(filename, numbers = new Map());
1073
+ if (!numbers) bySource.set(filename, numbers = new NumberedTable());
1044
1074
  numbers.set(position, number);
1045
- byNumber.set(number, { head: clause.head, body: clause.body ?? [] });
1075
+ // The clause itself, not a copy of its head and body: a compact clause
1076
+ // builds those terms only when a proof cites it.
1077
+ byNumber.set(number, clause.compactBinary === true || clause.body != null ? clause : { head: clause.head, body: [] });
1046
1078
  }
1047
1079
  return { bySource, byNumber };
1048
1080
  }
package/src/parser.js CHANGED
@@ -1504,8 +1504,8 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1504
1504
  let operatorState = null;
1505
1505
  // With source metadata, this path numbers clauses exactly as the general
1506
1506
  // parser does: one number per clause and per counted directive, none for a
1507
- // query or a quad. Compact binary clauses carry no source, so they are not
1508
- // used then.
1507
+ // query or a quad. A compact binary clause is numbered the same way, through
1508
+ // the clause object its builder hands back.
1509
1509
  const trackSource = options.sourceMetadata === true;
1510
1510
  const sourceFilename = options.filename ?? '<input>';
1511
1511
  let clauseNumber = 0;
@@ -1774,18 +1774,20 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1774
1774
  if (rule < 0) {
1775
1775
  if (!parseBinaryRawRange(text, start, termEnd, rawHead)) return false;
1776
1776
  reportFastBinarySingletonWarnings(rawHead, null, line);
1777
- emitBinary(rawHead.name,
1777
+ const clause = emitBinary(rawHead.name,
1778
1778
  rawHead.arg0Type, rawHead.arg0Name, rawHead.arg1Type, rawHead.arg1Name,
1779
1779
  null, null, null, null, null);
1780
+ if (clause) numbered(clause, line);
1780
1781
  return true;
1781
1782
  }
1782
1783
  if (findRuleInRange(text, rule + 2, termEnd) >= 0 ||
1783
1784
  !parseBinaryRawRange(text, start, rule, rawHead) ||
1784
1785
  !parseBinaryRawRange(text, rule + 2, termEnd, rawBody)) return false;
1785
1786
  reportFastBinarySingletonWarnings(rawHead, rawBody, line);
1786
- emitBinary(rawHead.name,
1787
+ const clause = emitBinary(rawHead.name,
1787
1788
  rawHead.arg0Type, rawHead.arg0Name, rawHead.arg1Type, rawHead.arg1Name,
1788
1789
  rawBody.name, rawBody.arg0Type, rawBody.arg0Name, rawBody.arg1Type, rawBody.arg1Name);
1790
+ if (clause) numbered(clause, line);
1789
1791
  return true;
1790
1792
  };
1791
1793
 
@@ -1851,12 +1853,14 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1851
1853
  // subsequent source character must pass through the full tokenizer. The
1852
1854
  // compact parser deliberately operates on raw source ranges, so using it
1853
1855
  // here would silently bypass Convc for otherwise simple clauses.
1856
+ const anonymousBefore = anonymous;
1854
1857
  const simple = preparationConversionActive() ? null : parseSimple(text);
1855
1858
  if (simple) {
1856
1859
  reportClauseSingletonWarnings(simple, options.onWarning, options.filename ?? '<input>', sourceLine);
1857
1860
  accept(numbered(simple, sourceLine));
1858
1861
  return true;
1859
1862
  }
1863
+ anonymous = anonymousBefore;
1860
1864
  try {
1861
1865
  const nestedWarning = typeof options.onWarning === 'function'
1862
1866
  ? (warning) => options.onWarning({ ...warning, line: warning.line + sourceLine - 1 })
@@ -1912,14 +1916,19 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1912
1916
  contentEnd = trimRangeEnd(source, contentStart, contentEnd);
1913
1917
  if (contentStart < contentEnd && source.charCodeAt(contentStart) !== 37) {
1914
1918
  if (!chunk && source.charCodeAt(contentEnd - 1) === 46 && !preparationConversionActive()) {
1915
- if (!trackSource && emitFastBinaryRange(source, contentStart, contentEnd, lineNumber)) {
1919
+ // A reader that declines a clause may have named some of its
1920
+ // anonymous variables already; the next one starts the count again.
1921
+ const anonymousBefore = anonymous;
1922
+ if (emitFastBinaryRange(source, contentStart, contentEnd, lineNumber)) {
1916
1923
  // The program builder accepted a compact binary clause directly.
1917
1924
  } else {
1925
+ anonymous = anonymousBefore;
1918
1926
  const simple = parseFastRange(source, contentStart, contentEnd);
1919
1927
  if (simple) {
1920
1928
  reportClauseSingletonWarnings(simple, options.onWarning, options.filename ?? '<input>', lineNumber);
1921
1929
  accept(numbered(simple, lineNumber));
1922
1930
  } else {
1931
+ anonymous = anonymousBefore;
1923
1932
  chunkStartLine = lineNumber;
1924
1933
  chunk = source.slice(lineStart, lineEnd) + '\n';
1925
1934
  if (!flush()) return null;
@@ -44,8 +44,12 @@ export function componentHasCut(start, deps, groups) {
44
44
 
45
45
 
46
46
  export function reachableIndexes(start, deps) {
47
+ return reachableFromRoots([start], deps);
48
+ }
49
+
50
+ function reachableFromRoots(roots, deps) {
47
51
  const seen = new Set();
48
- const stack = [start];
52
+ const stack = [...roots];
49
53
  while (stack.length) {
50
54
  const current = stack.pop();
51
55
  if (seen.has(current)) continue;
@@ -55,6 +59,49 @@ export function reachableIndexes(start, deps) {
55
59
  return seen;
56
60
  }
57
61
 
62
+ // Classify the whole dependency graph once. Repeating a reachability walk for
63
+ // every predicate makes loading a long acyclic chain quadratic, even though
64
+ // recursion and cut propagation are properties of its components and edges.
65
+ export function analyzeDependencyGraph(deps, cutDeps, groups, multiplicityRoots, wfsEdges) {
66
+ const components = stronglyConnectedComponents(deps);
67
+ const componentByIndex = new Int32Array(groups.length);
68
+ const componentCuts = new Uint8Array(components.length);
69
+ const reverse = groups.map(() => []);
70
+ const reverseCuts = groups.map(() => []);
71
+ const cutRoots = [];
72
+ for (let component = 0; component < components.length; component++) {
73
+ for (const index of components[component]) {
74
+ componentByIndex[index] = component;
75
+ const group = groups[index];
76
+ if (!group.hasCut) continue;
77
+ // Preserve the distinction between a cut in a base clause and a cut
78
+ // in a directly recursive clause used by the solver's recursion plans.
79
+ const directRecursive = group.clauses.some((clause) => clauseIsDirectRecursive(clause, group));
80
+ if (group.clauses.some((clause) =>
81
+ (!directRecursive || clauseIsDirectRecursive(clause, group)) && clauseHasCut(clause))) {
82
+ componentCuts[component] = 1;
83
+ }
84
+ }
85
+ }
86
+ for (let from = 0; from < groups.length; from++) {
87
+ for (const to of deps[from]) reverse[to].push(from);
88
+ for (const to of cutDeps[from]) reverseCuts[to].push(from);
89
+ if (groups[from].hasCut) cutRoots.push(from);
90
+ }
91
+ const wfsRoots = [];
92
+ for (const [from, to] of wfsEdges) {
93
+ if (componentByIndex[from] === componentByIndex[to]) wfsRoots.push(from, to);
94
+ }
95
+ return {
96
+ components,
97
+ componentByIndex,
98
+ componentCuts,
99
+ cutReachable: reachableFromRoots(cutRoots, reverseCuts),
100
+ multiplicitySensitive: reachableFromRoots(multiplicityRoots, deps),
101
+ wfsReachable: reachableFromRoots(wfsRoots, reverse),
102
+ };
103
+ }
104
+
58
105
  // Returns the set of nodes in `candidates` that can reach `target` by
59
106
  // traversing `deps` in reverse. This is equivalent to asking which nodes
60
107
  // in the forward-reachable set from `target` also have `target` in their
@@ -509,36 +556,47 @@ export function stronglyConnectedComponents(adjacency) {
509
556
  const lowlinks = new Map();
510
557
  const components = [];
511
558
 
512
- function visit(v) {
559
+ function enter(v) {
513
560
  indexes.set(v, index);
514
561
  lowlinks.set(v, index);
515
562
  index++;
516
563
  stack.push(v);
517
564
  onStack.add(v);
518
565
 
519
- for (const w of adjacency[v]) {
520
- if (!indexes.has(w)) {
521
- visit(w);
522
- lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
523
- } else if (onStack.has(w)) {
524
- lowlinks.set(v, Math.min(lowlinks.get(v), indexes.get(w)));
525
- }
526
- }
527
-
528
- if (lowlinks.get(v) === indexes.get(v)) {
529
- const component = [];
530
- while (true) {
531
- const w = stack.pop();
532
- onStack.delete(w);
533
- component.push(w);
534
- if (w === v) break;
535
- }
536
- components.push(component);
537
- }
566
+ return { node: v, edges: adjacency[v][Symbol.iterator]() };
538
567
  }
539
568
 
540
569
  for (let v = 0; v < adjacency.length; v++) {
541
- if (!indexes.has(v)) visit(v);
570
+ if (indexes.has(v)) continue;
571
+ // Explicit DFS frames keep preparation safe for dependency chains deeper
572
+ // than the JavaScript stack, including large generated taxonomies.
573
+ const frames = [enter(v)];
574
+ while (frames.length > 0) {
575
+ const frame = frames[frames.length - 1];
576
+ const next = frame.edges.next();
577
+ if (!next.done) {
578
+ const w = next.value;
579
+ if (!indexes.has(w)) frames.push(enter(w));
580
+ else if (onStack.has(w)) lowlinks.set(frame.node, Math.min(lowlinks.get(frame.node), indexes.get(w)));
581
+ continue;
582
+ }
583
+ const node = frame.node;
584
+ if (lowlinks.get(node) === indexes.get(node)) {
585
+ const component = [];
586
+ let w;
587
+ do {
588
+ w = stack.pop();
589
+ onStack.delete(w);
590
+ component.push(w);
591
+ } while (w !== node);
592
+ components.push(component);
593
+ }
594
+ frames.pop();
595
+ if (frames.length > 0) {
596
+ const parent = frames[frames.length - 1].node;
597
+ lowlinks.set(parent, Math.min(lowlinks.get(parent), lowlinks.get(node)));
598
+ }
599
+ }
542
600
  }
543
601
  return components;
544
602
  }
package/src/program.js CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  export { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program-indexing.js';
33
33
  import {
34
34
 
35
- componentHasNegativeEdge, componentHasCut, reachableIndexes, datalogDependencyClauseCount, isFiniteDatalogGroup, isRangeRestrictedFiniteDatalogGroup, isFiniteWfsDatalogGroup, inferStructuralInputPositions, hasStrictListTailRecursion, hasLinearNumericRecursion, isPiAccumulator, isPortableBetweenGenerator, directGoalDependencyKey, collectGoalDependencies, stronglyConnectedComponents, computeNegationStrata,
35
+ analyzeDependencyGraph, datalogDependencyClauseCount, isFiniteDatalogGroup, isRangeRestrictedFiniteDatalogGroup, isFiniteWfsDatalogGroup, inferStructuralInputPositions, hasStrictListTailRecursion, hasLinearNumericRecursion, isPiAccumulator, isPortableBetweenGenerator, directGoalDependencyKey, collectGoalDependencies, stronglyConnectedComponents, computeNegationStrata,
36
36
  } from './program-analysis.js';
37
37
 
38
38
  const RE_DECIMAL_INT = /^\d+$/;
@@ -434,7 +434,6 @@ export class Program {
434
434
  const deps = groups.map(() => new Set());
435
435
  const cutDeps = groups.map(() => new Set());
436
436
  const multiplicitySensitiveRoots = new Set();
437
- const negativeEdges = [];
438
437
  const wfsNegativeEdges = [];
439
438
  for (const group of groups) {
440
439
  const groupIndex = indexByGroup.get(group);
@@ -465,7 +464,6 @@ export class Program {
465
464
  const dependencyIndex = indexByGroup.get(dep);
466
465
  deps[groupIndex].add(dependencyIndex);
467
466
  if (dependency.negative) {
468
- negativeEdges.push([groupIndex, dependencyIndex]);
469
467
  if (dependency.wfs === true) wfsNegativeEdges.push([groupIndex, dependencyIndex]);
470
468
  }
471
469
  }
@@ -480,6 +478,7 @@ export class Program {
480
478
  cutDeps[groupIndex].add(dependencyIndex);
481
479
  }
482
480
  }
481
+ const graph = analyzeDependencyGraph(deps, cutDeps, groups, multiplicitySensitiveRoots, wfsNegativeEdges);
483
482
  const finiteDatalogCache = new Map();
484
483
  const rangeRestrictedDatalogCache = new Map();
485
484
  for (const group of groups) {
@@ -487,19 +486,10 @@ export class Program {
487
486
  const standardLibraryModule = group.module !== 'user' &&
488
487
  this.modules.get(group.module)?.filename?.startsWith('src/lib/');
489
488
  group.bundledLibrary = standardLibraryModule === true;
490
- group.cutReachable = [...reachableIndexes(start, cutDeps)].some((index) => groups[index].hasCut);
491
- const seen = new Set();
492
- const stack = [start];
493
- let recursive = false;
494
- while (stack.length && !recursive) {
495
- const current = stack.pop();
496
- if (seen.has(current)) continue;
497
- seen.add(current);
498
- for (const next of deps[current]) {
499
- if (next === start) { recursive = true; break; }
500
- if (!seen.has(next)) stack.push(next);
501
- }
502
- }
489
+ group.cutReachable = graph.cutReachable.has(start);
490
+ const component = graph.componentByIndex[start];
491
+ const componentSize = graph.components[component].length;
492
+ const recursive = componentSize > 1 || deps[start].has(start);
503
493
  // Bundled libraries use their written ISO control directly. User modules
504
494
  // are analyzed for recursion, but tabling itself is strictly opt-in.
505
495
  const plannedRecursive = recursive && !standardLibraryModule;
@@ -509,18 +499,16 @@ export class Program {
509
499
  group.tableInputPositions = explicitlyTabled && plannedRecursive
510
500
  ? inferStructuralInputPositions(group)
511
501
  : [];
512
- group.cutRecursive = plannedRecursive && componentHasCut(start, deps, groups);
502
+ group.cutRecursive = plannedRecursive && graph.componentCuts[component] === 1;
513
503
  const linearNumeric = plannedRecursive && !explicitlyTabled && hasLinearNumericRecursion(group) &&
514
504
  (isPiAccumulator(group) || isPortableBetweenGenerator(group));
515
505
  group.linearNumeric = linearNumeric;
516
506
  group.fastPi = linearNumeric && isPiAccumulator(group);
517
- const directRecursiveComponent = plannedRecursive && [...deps[start]].every((dependency) =>
518
- dependency === start || !reachableIndexes(dependency, deps).has(start)
519
- );
507
+ const directRecursiveComponent = plannedRecursive && componentSize === 1;
520
508
  group.listTailRecursive = directRecursiveComponent && !group.cutRecursive &&
521
509
  hasStrictListTailRecursion(group);
522
510
  const cutTailRecursion = plannedRecursive && hasDirectCutTailRecursion(group);
523
- const multiplicitySensitive = [...multiplicitySensitiveRoots].some((root) => reachableIndexes(root, deps).has(start));
511
+ const multiplicitySensitive = graph.multiplicitySensitive.has(start);
524
512
  group.cutTailRecursion = cutTailRecursion;
525
513
  group.multiplicitySensitive = multiplicitySensitive;
526
514
  group.depthFirstRecursive = plannedRecursive && !explicitlyTabled;
@@ -542,17 +530,10 @@ export class Program {
542
530
  // not silently change ordinary Prolog programs. A group is WFS-backed when
543
531
  // its dependency cone reaches an SCC containing an edge introduced by
544
532
  // tnot/1 and the whole cone is range-restricted finite Datalog.
545
- const wfsCycleNodes = new Set();
546
- for (const [from, to] of wfsNegativeEdges) {
547
- if (!reachableIndexes(to, deps).has(from)) continue;
548
- wfsCycleNodes.add(from);
549
- wfsCycleNodes.add(to);
550
- }
551
533
  const finiteWfsCache = new Map();
552
534
  for (const group of groups) {
553
535
  const start = indexByGroup.get(group);
554
- const reachable = reachableIndexes(start, deps);
555
- const reachesWfsCycle = [...wfsCycleNodes].some((index) => reachable.has(index));
536
+ const reachesWfsCycle = graph.wfsReachable.has(start);
556
537
  group.wfsDatalog = reachesWfsCycle && isFiniteWfsDatalogGroup(this, group, finiteWfsCache);
557
538
  }
558
539
  }
@@ -1288,18 +1269,27 @@ function collectAutoloadClosureDependencies(closure, extraArguments, out, module
1288
1269
 
1289
1270
  function expandAutoloadMetaDependencies(program, dependencies, module = 'user') {
1290
1271
  const seen = new WeakMap();
1272
+ // Most dependencies name a predicate without closure arguments; look each
1273
+ // predicate up once instead of once per call site.
1274
+ const closureModes = new Map();
1291
1275
  for (let i = 0; i < dependencies.length; i++) {
1292
1276
  const dependency = dependencies[i];
1293
1277
  if (dependency.goal == null) continue;
1294
1278
  const caller = dependency.module ?? module;
1295
1279
  const visitKey = `${caller}\u0000${dependency.key}`;
1280
+ let modes = closureModes.get(visitKey);
1281
+ if (modes === undefined) {
1282
+ const group = program.findGroup(dependency.name, dependency.arity, caller);
1283
+ modes = (group?.metaArgumentModes ?? []).filter((mode) =>
1284
+ mode.kind === 'closure' && Number.isSafeInteger(mode.extraArguments) && mode.extraArguments >= 0);
1285
+ closureModes.set(visitKey, modes);
1286
+ }
1287
+ if (modes.length === 0) continue;
1296
1288
  const visits = seen.get(dependency.goal) ?? new Set();
1297
1289
  if (visits.has(visitKey)) continue;
1298
1290
  visits.add(visitKey);
1299
1291
  seen.set(dependency.goal, visits);
1300
- const group = program.findGroup(dependency.name, dependency.arity, caller);
1301
- for (const mode of group?.metaArgumentModes ?? []) {
1302
- if (mode.kind !== 'closure' || !Number.isSafeInteger(mode.extraArguments) || mode.extraArguments < 0) continue;
1292
+ for (const mode of modes) {
1303
1293
  collectAutoloadClosureDependencies(dependency.goal?.args[mode.index], mode.extraArguments, dependencies, caller);
1304
1294
  }
1305
1295
  }
@@ -1735,13 +1725,17 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1735
1725
  if (fast) {
1736
1726
  const acceptBinary = (headName, head0Type, head0Name, head1Type, head1Name,
1737
1727
  bodyName, body0Type, body0Name, body1Type, body1Name) => {
1738
- batch.push(new CompactBinaryClause(
1728
+ if (batch.length >= PROGRAM_BUILD_BATCH_SIZE) flush();
1729
+ const clause = new CompactBinaryClause(
1739
1730
  headName, head0Type, head0Name, head1Type, head1Name,
1740
1731
  bodyName, body0Type, body0Name, body1Type, body1Name,
1741
- ));
1742
- batch[batch.length - 1].module = context.module;
1743
- batch[batch.length - 1].textUnit = context.textUnit;
1744
- if (batch.length >= PROGRAM_BUILD_BATCH_SIZE) flush();
1732
+ );
1733
+ clause.module = context.module;
1734
+ clause.textUnit = context.textUnit;
1735
+ batch.push(clause);
1736
+ // The parser numbers the clause for its source metadata once it is
1737
+ // returned, so the batch is flushed at the next clause, not here.
1738
+ return clause;
1745
1739
  };
1746
1740
  const parsed = tryParseClausesFastInto(source, processClause, acceptBinary, options);
1747
1741
  if (parsed) flush();
@@ -158,11 +158,24 @@ function renameMinted(value, minted) {
158
158
  if (!minted.has(key)) minted.set(key, minted.size + 1);
159
159
  return compound('$stream', [numberTerm(BigInt(minted.get(key)))]);
160
160
  }
161
- return compound(value.name, value.args.map((arg) => renameMinted(arg, minted)));
161
+ return sameOrRebuilt(value, (arg) => renameMinted(arg, minted));
162
162
  }
163
163
  return value;
164
164
  }
165
165
 
166
+ // A compound with `rewrite` applied to its arguments: the compound itself
167
+ // when no argument changes, so ground subterms are shared, not copied.
168
+ function sameOrRebuilt(value, rewrite) {
169
+ let args = null;
170
+ for (let i = 0; i < value.args.length; i++) {
171
+ const arg = value.args[i];
172
+ const rewritten = rewrite(arg);
173
+ if (args == null && rewritten !== arg) args = value.args.slice(0, i);
174
+ if (args != null) args.push(rewritten);
175
+ }
176
+ return args == null ? value : compound(value.name, args);
177
+ }
178
+
166
179
  function templateTerm(value, anonymous, minted) {
167
180
  if (value.type === VAR) {
168
181
  if (value.name !== '_' && !value.name.startsWith('_')) {
@@ -172,7 +185,7 @@ function templateTerm(value, anonymous, minted) {
172
185
  if (!anonymous.has(value.name)) anonymous.set(value.name, anonymous.size + 1);
173
186
  return compound('anonymous', [numberTerm(BigInt(anonymous.get(value.name)))]);
174
187
  }
175
- if (value.type === COMPOUND) return compound(value.name, value.args.map((arg) => templateTerm(arg, anonymous, minted)));
188
+ if (value.type === COMPOUND) return sameOrRebuilt(value, (arg) => templateTerm(arg, anonymous, minted));
176
189
  return value;
177
190
  }
178
191
 
@@ -192,8 +205,16 @@ function clauseTerm(number, clause, minted) {
192
205
 
193
206
  // The `clause/3` record a proof writes for clause `number`, which a checker
194
207
  // rebuilds from the source to compare with the document's (C1).
208
+ // The record is kept with the clause, so a proof checked as it is written
209
+ // compares its records with the very terms the checker rebuilds.
210
+ const clauseRecords = new WeakMap();
211
+
195
212
  export function clauseRecordTerm(number, clause) {
196
- return clauseTerm(number, clause, new Map());
213
+ const cached = clauseRecords.get(clause);
214
+ if (cached?.number === number) return cached.record;
215
+ const record = clauseTerm(number, clause, new Map());
216
+ clauseRecords.set(clause, { number, record });
217
+ return record;
197
218
  }
198
219
 
199
220
  // A `clause/3` record with its minted variable names renumbered from the
package/src/write.js CHANGED
@@ -250,7 +250,11 @@ function printableReadVariableNames(term, env, explicit) {
250
250
  const used = new Set(names.values());
251
251
  const suffixes = new Map();
252
252
  const seenVariables = new Set();
253
- const seenTerms = new Set();
253
+ // A subterm shared many times over is walked once. Most terms are small
254
+ // trees, where walking a subterm again changes nothing, so the set is only
255
+ // kept once a term turns out to be large.
256
+ let seenTerms = null;
257
+ let walked = 0;
254
258
  const stack = [term];
255
259
 
256
260
  while (stack.length) {
@@ -268,8 +272,12 @@ function printableReadVariableNames(term, env, explicit) {
268
272
  used.add(candidate);
269
273
  continue;
270
274
  }
271
- if (current.type !== COMPOUND || seenTerms.has(current)) continue;
272
- seenTerms.add(current);
275
+ if (current.type !== COMPOUND) continue;
276
+ if (seenTerms == null && ++walked > 4096) seenTerms = new Set();
277
+ if (seenTerms != null) {
278
+ if (seenTerms.has(current)) continue;
279
+ seenTerms.add(current);
280
+ }
273
281
  for (let i = current.arity - 1; i >= 0; i--) stack.push(current.args[i]);
274
282
  }
275
283
 
package/test/README.md CHANGED
@@ -58,4 +58,8 @@ deliberately, and say why, never to make a transient failure disappear.
58
58
  There is no separate wall-clock benchmark harness: `npm test`'s own elapsed
59
59
  time is the project's performance indicator.
60
60
 
61
+ Example, proof-example, and proof-checking tests print results as they finish,
62
+ so long-running proofs do not hide completed tests behind alphabetical order.
63
+ Every test still compares its results with the same golden files.
64
+
61
65
  See the [conformance guide](conformance/README.md) for report maintenance.