eyeprolog 1.6.40 → 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.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.6.40",
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",
@@ -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;
package/src/program.js CHANGED
@@ -1269,18 +1269,27 @@ function collectAutoloadClosureDependencies(closure, extraArguments, out, module
1269
1269
 
1270
1270
  function expandAutoloadMetaDependencies(program, dependencies, module = 'user') {
1271
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();
1272
1275
  for (let i = 0; i < dependencies.length; i++) {
1273
1276
  const dependency = dependencies[i];
1274
1277
  if (dependency.goal == null) continue;
1275
1278
  const caller = dependency.module ?? module;
1276
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;
1277
1288
  const visits = seen.get(dependency.goal) ?? new Set();
1278
1289
  if (visits.has(visitKey)) continue;
1279
1290
  visits.add(visitKey);
1280
1291
  seen.set(dependency.goal, visits);
1281
- const group = program.findGroup(dependency.name, dependency.arity, caller);
1282
- for (const mode of group?.metaArgumentModes ?? []) {
1283
- if (mode.kind !== 'closure' || !Number.isSafeInteger(mode.extraArguments) || mode.extraArguments < 0) continue;
1292
+ for (const mode of modes) {
1284
1293
  collectAutoloadClosureDependencies(dependency.goal?.args[mode.index], mode.extraArguments, dependencies, caller);
1285
1294
  }
1286
1295
  }
@@ -1716,13 +1725,17 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1716
1725
  if (fast) {
1717
1726
  const acceptBinary = (headName, head0Type, head0Name, head1Type, head1Name,
1718
1727
  bodyName, body0Type, body0Name, body1Type, body1Name) => {
1719
- batch.push(new CompactBinaryClause(
1728
+ if (batch.length >= PROGRAM_BUILD_BATCH_SIZE) flush();
1729
+ const clause = new CompactBinaryClause(
1720
1730
  headName, head0Type, head0Name, head1Type, head1Name,
1721
1731
  bodyName, body0Type, body0Name, body1Type, body1Name,
1722
- ));
1723
- batch[batch.length - 1].module = context.module;
1724
- batch[batch.length - 1].textUnit = context.textUnit;
1725
- 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;
1726
1739
  };
1727
1740
  const parsed = tryParseClausesFastInto(source, processClause, acceptBinary, options);
1728
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
 
@@ -6025,6 +6025,40 @@ answer(Result) :- countdown(2048, Result), Result = 2048.
6025
6025
  }
6026
6026
  },
6027
6027
  },
6028
+ {
6029
+ name: 'with source metadata, compact binary clauses are numbered and named exactly as the general parser reads them',
6030
+ run: () => {
6031
+ // A proof cites clauses by the numbers source metadata gives them, so
6032
+ // a program loaded through the compact binary-clause path for --proof
6033
+ // must carry the numbers, lines and anonymous-variable names the
6034
+ // general parser gives the same text -- including after a clause the
6035
+ // compact path reads part of and then declines.
6036
+ const source = [
6037
+ 'a(ind, n0).',
6038
+ 'a(X, n1) :- a(X, n0).',
6039
+ 'b(_, _).',
6040
+ 'c(X, Y) :- a(X, _), b(Y, _).',
6041
+ 'a(_, n2) :- a(_, n1).',
6042
+ 'd(f(_), _).',
6043
+ 'p(_, x) :- q(f(_), x).',
6044
+ 'e(X, _) :- a(X, n2).',
6045
+ '',
6046
+ ].join('\n');
6047
+ // Variables by their internal names, which a printed term hides.
6048
+ const named = (term) => term.type === 'var'
6049
+ ? term.name
6050
+ : term.type === 'compound' ? `${term.name}(${term.args.map(named).join(',')})` : String(term.name);
6051
+ const spelled = (clause) => [
6052
+ clause.source?.line,
6053
+ clause.source?.clause,
6054
+ named(clause.head),
6055
+ (clause.body ?? []).map(named).join(', '),
6056
+ ].join(' | ');
6057
+ const loaded = Program.parse(source, { sourceMetadata: true }).clauses.map(spelled);
6058
+ const general = parseProgramText(source, { sourceMetadata: true, generalParser: true }).map(spelled);
6059
+ assertEqual(loaded.join('\n'), general.join('\n'), 'the loaded clauses');
6060
+ },
6061
+ },
6028
6062
  {
6029
6063
  // https://github.com/eyereasoner/eyeprolog/issues/114#issuecomment-5663598188
6030
6064
  name: 'a genuine IEEE-754 negative zero produced by arithmetic (not just parsed from text) still prints as 0.0',