eyeprolog 1.6.37 → 1.6.38

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.
@@ -10,7 +10,7 @@ expectedSuccess(oseltamivir, 0.28514101258814745).
10
10
  expectedSuccess(antihistamine, 0.10026891936485297).
11
11
  expectedSuccess(antibiotic, 0.1109525797960936).
12
12
  expectedSuccess(supportiveCare, 0.2915119539701381).
13
- expectedAdverse(paxlovid, 0.10).
13
+ expectedAdverse(paxlovid, 0.1).
14
14
  expectedAdverse(oseltamivir, 0.08).
15
15
  expectedAdverse(antihistamine, 0.03).
16
16
  expectedAdverse(antibiotic, 0.07).
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.6.37",
6
+ "version": "1.6.38",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/explain.js CHANGED
@@ -54,6 +54,10 @@ let liveSolver = null;
54
54
  // sets an explicit `maxDepth` gets that depth as a hard limit instead.
55
55
  let chainBeyondDepth = true;
56
56
 
57
+ // Proofs of ground goals already found while explaining one document, keyed by
58
+ // module and goal; null outside such a document.
59
+ let groundProofs = null;
60
+
57
61
  function hostSolver(program, registry) {
58
62
  return liveSolver && liveSolver.program === program ? liveSolver : new Solver(program, { registry });
59
63
  }
@@ -177,6 +181,23 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
177
181
  }
178
182
  return;
179
183
  }
184
+ // A goal that is already ground binds nothing when proved, so which of its
185
+ // proofs is used cannot matter to the goals after it. Within one proof
186
+ // document its first proof is therefore recorded once and reused: a run
187
+ // whose answers share subderivations -- tabled trajectories, closures --
188
+ // no longer replays the shared part once per answer. This stays inside one
189
+ // generator: a replay nests a generator per level, and one more frame per
190
+ // level would overflow the host stack on deep derivations.
191
+ const memoKey = groundProofs && termIsGround(goal, env)
192
+ ? `${group.module}\u0000${termToString(goal, env, true)}`
193
+ : null;
194
+ if (memoKey) {
195
+ const known = groundProofs.get(memoKey);
196
+ if (known) {
197
+ yield { env, node: known };
198
+ return;
199
+ }
200
+ }
180
201
  // Explanation replay does not use the solver's answer tables, so its cycle
181
202
  // guard applies even when normal execution tables this predicate.
182
203
  if (activeVariant(goal, env, active)) return;
@@ -199,7 +220,7 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
199
220
  const substitutions = collectClauseSubstitutions(clause, freshHead, freshBody);
200
221
 
201
222
  if (freshBody.length === 0) {
202
- yield {
223
+ const proved = {
203
224
  env: next,
204
225
  node: {
205
226
  raw: goal,
@@ -215,6 +236,11 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
215
236
  children: [],
216
237
  },
217
238
  };
239
+ if (memoKey) {
240
+ yield rememberGroundProof(memoKey, proved);
241
+ return;
242
+ }
243
+ yield proved;
218
244
  continue;
219
245
  }
220
246
 
@@ -224,7 +250,7 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
224
250
  for (const proved of proveGoalsAll(program, freshBody, next, depth + 1, maxDepth, registry, active, detail)) {
225
251
  active.pop();
226
252
  activePushed = false;
227
- yield {
253
+ const result = {
228
254
  env: proved.env,
229
255
  node: {
230
256
  raw: goal,
@@ -237,6 +263,11 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
237
263
  children: proved.children,
238
264
  },
239
265
  };
266
+ if (memoKey) {
267
+ yield rememberGroundProof(memoKey, result);
268
+ return;
269
+ }
270
+ yield result;
240
271
  active.push({ goal, env });
241
272
  activePushed = true;
242
273
  }
@@ -472,6 +503,59 @@ function sourceMethod(clause, kind) {
472
503
  };
473
504
  }
474
505
 
506
+ // Built-in and library steps whose replay has no effect beyond its bindings:
507
+ // arithmetic, comparison, term inspection, and pure list relations. Anything
508
+ // else -- I/O, database updates, constraint stores, generated names -- keeps a
509
+ // derivation from being reused across answers.
510
+ const PURE_REPLAY_STEPS = new Set([
511
+ 'true/0', 'is/2', '=:=/2', '=\\=/2', '</2', '>/2', '=</2', '>=/2',
512
+ '=/2', '\\=/2', '==/2', '\\==/2', '@</2', '@>/2', '@=</2', '@>=/2', 'compare/3',
513
+ 'var/1', 'nonvar/1', 'atom/1', 'number/1', 'integer/1', 'float/1', 'atomic/1',
514
+ 'compound/1', 'callable/1', 'ground/1', 'is_list/1',
515
+ 'functor/3', 'arg/3', '=../2', 'copy_term/2', 'succ/2', 'plus/3',
516
+ 'char_code/2', 'atom_codes/2', 'atom_chars/2', 'atom_length/2', 'atom_concat/3',
517
+ 'sub_atom/5', 'number_codes/2', 'number_chars/2', 'atom_number/2',
518
+ 'sort/2', 'msort/2', 'keysort/2', 'between/3', 'length/2',
519
+ 'member/2', 'memberchk/2', 'append/3', 'append/2', 'reverse/2', 'nth0/3', 'nth1/3',
520
+ 'last/2', 'select/3', 'sum_list/2', 'max_list/2', 'min_list/2', 'list_to_set/2',
521
+ 'once/1', 'findall/3', 'forall/2', '\\+/1',
522
+ ]);
523
+
524
+ // Settle a ground goal's first proof while its environment still holds every
525
+ // binding inside it, and keep it for reuse when replaying it has no effects:
526
+ // a proof that writes a stream or updates the database must perform those
527
+ // effects again, so it is not recorded.
528
+ function rememberGroundProof(key, proved) {
529
+ const node = settleProofNode(proved.node, proved.env);
530
+ if (replayIsPure(node)) groundProofs.set(key, node);
531
+ return { env: proved.env, node };
532
+ }
533
+
534
+ // The answer is cached on the node, so a deep derivation whose every level is
535
+ // remembered asks it once per level rather than once per level per descendant.
536
+ function replayIsPure(root) {
537
+ if (root.pureReplay !== undefined) return root.pureReplay;
538
+ const stack = [root];
539
+ let pure = true;
540
+ while (stack.length > 0) {
541
+ const node = stack.pop();
542
+ if (node !== root && node.pureReplay !== undefined) {
543
+ if (node.pureReplay) continue;
544
+ pure = false;
545
+ break;
546
+ }
547
+ const method = node.method;
548
+ if (method && typeof method === 'object' && (method.type === 'builtin' || method.type === 'library') &&
549
+ !PURE_REPLAY_STEPS.has(`${method.name}/${method.arity}`)) {
550
+ pure = false;
551
+ break;
552
+ }
553
+ for (const child of node.children ?? []) stack.push(child);
554
+ }
555
+ root.pureReplay = pure;
556
+ return pure;
557
+ }
558
+
475
559
  function goalMethod(type, goal) {
476
560
  return {
477
561
  type,
@@ -946,12 +1030,18 @@ export function clauseNumbering(program) {
946
1030
  offset += span;
947
1031
  }
948
1032
 
1033
+ // Keyed by file, then by clause position: one string key per clause cost
1034
+ // more than the numbering itself on programs with hundreds of thousands of
1035
+ // clauses.
949
1036
  const bySource = new Map();
950
1037
  const byNumber = new Map();
951
1038
  for (const clause of program.clauses ?? []) {
952
1039
  if (!isProgramClause(clause) || isUnnumberedDeclaration(clause)) continue;
953
- const number = (offsets.get(clause.source.filename) ?? 0) + clause.source.clause;
954
- bySource.set(`${clause.source.filename}\u0000${clause.source.clause}`, number);
1040
+ const { filename, clause: position } = clause.source;
1041
+ const number = (offsets.get(filename) ?? 0) + position;
1042
+ let numbers = bySource.get(filename);
1043
+ if (!numbers) bySource.set(filename, numbers = new Map());
1044
+ numbers.set(position, number);
955
1045
  byNumber.set(number, { head: clause.head, body: clause.body ?? [] });
956
1046
  }
957
1047
  return { bySource, byNumber };
@@ -971,7 +1061,7 @@ export function isLibraryClause(clause) {
971
1061
  }
972
1062
 
973
1063
  function clauseNumberFor(numbering, method) {
974
- return numbering.bySource.get(`${method.filename}\u0000${method.clause}`) ?? null;
1064
+ return numbering.bySource.get(method.filename)?.get(method.clause) ?? null;
975
1065
  }
976
1066
 
977
1067
  // The conclusion a node contributes, which is how a step names what it used.
@@ -1125,6 +1215,10 @@ function settleProofNode(root, env) {
1125
1215
  const stack = [root];
1126
1216
  while (stack.length) {
1127
1217
  const node = stack.pop();
1218
+ // A settled node was settled with its whole subtree; a remembered ground
1219
+ // proof reached again from a deeper level need not be walked again.
1220
+ if (node.settled) continue;
1221
+ node.settled = true;
1128
1222
  if (node.raw) {
1129
1223
  node.goal = resolve(node.raw);
1130
1224
  if (node.method?.type === 'builtin' || node.method?.type === 'library') node.sourceHead = node.goal;
@@ -1210,6 +1304,7 @@ function settlingResolver(env) {
1210
1304
  export function proofNodeFor(program, goal, options = {}) {
1211
1305
  liveSolver = options.solver ?? null;
1212
1306
  chainBeyondDepth = options.maxDepth == null;
1307
+ groundProofs = options.groundProofs ?? null;
1213
1308
  const maxDepth = options.maxDepth ?? 256;
1214
1309
  const registry = options.registry ?? getEyePrologRegistry();
1215
1310
  const env = options.env ?? new Env();
package/src/parser.js CHANGED
@@ -317,7 +317,9 @@ class Parser {
317
317
  this.filename = options.filename ?? '<input>';
318
318
  this.pos = 0;
319
319
  this.line = 1;
320
- this.anonymous = 0;
320
+ // Anonymous variables are numbered through a whole text; a caller parsing
321
+ // one text in pieces continues the count.
322
+ this.anonymous = options.anonymousStart ?? 0;
321
323
  this.variables = new Map();
322
324
  this.sourceMetadata = options.sourceMetadata !== false;
323
325
  this.onWarning = typeof options.onWarning === 'function' ? options.onWarning : null;
@@ -346,8 +348,10 @@ class Parser {
346
348
  this.token = this.nextToken();
347
349
  }
348
350
  convertCharacter(character) {
349
- if (this.parserFlagState.charConversion !== 'on' || !character) return character;
350
- return this.parserFlagState.charConversions.get(character) ?? character;
351
+ const state = this.parserFlagState;
352
+ // Almost no text installs a conversion; skip the map lookup per character.
353
+ if (state.charConversions.size === 0 || state.charConversion !== 'on' || !character) return character;
354
+ return state.charConversions.get(character) ?? character;
351
355
  }
352
356
  rawPeek(offset = 0) {
353
357
  // The processor character set is an implementation-defined processor
@@ -1482,6 +1486,18 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1482
1486
  let anonymous = 0;
1483
1487
  let chunk = '';
1484
1488
  let chunkStartLine = 1;
1489
+ let operatorState = null;
1490
+ // With source metadata, this path numbers clauses exactly as the general
1491
+ // parser does: one number per clause and per counted directive, none for a
1492
+ // query or a quad. Compact binary clauses carry no source, so they are not
1493
+ // used then.
1494
+ const trackSource = options.sourceMetadata === true;
1495
+ const sourceFilename = options.filename ?? '<input>';
1496
+ let clauseNumber = 0;
1497
+ const numbered = (clause, line) => {
1498
+ if (trackSource) clause.source = { filename: sourceFilename, line, clause: ++clauseNumber };
1499
+ return clause;
1500
+ };
1485
1501
 
1486
1502
  const cached = (cache, key, create) => {
1487
1503
  const existing = cache.get(key);
@@ -1491,22 +1507,19 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1491
1507
  return value;
1492
1508
  };
1493
1509
 
1494
- // SIMPLE_NUMBER-matched text is already lexically valid Prolog number
1495
- // syntax, and this fast path deliberately leaves a numeric literal's
1496
- // spelling otherwise untouched (test/conformance/cases/arithmetic/
1497
- // 024_numeric_literal_readback.pl: decimal and exponent literals retain
1498
- // their lexical form here, so e.g. 6.02e23 must not become 6.02e+23).
1499
- // Two things still cannot be left as raw matched text, though: an integer
1500
- // is never left non-canonical anywhere else in the language (a leading
1501
- // zero, or the "-0" from issue #114, must collapse the same way the
1502
- // scanner's own adjacent-minus BigInt construction already does), and a
1503
- // float whose value is exactly zero must never print with a stray minus --
1504
- // this implementation deliberately never produces -0.0 (term.js
1505
- // numberTextFromDouble) -- without reformatting any non-zero float.
1510
+ // A number is a value, not a spelling (ISO 7.1.3), so the fast path writes
1511
+ // numbers exactly as the general parser does: an integer in canonical
1512
+ // decimal (a leading zero or "-0" collapses, issue #114), and a float as the
1513
+ // shortest text that reads back to the same double (term.js
1514
+ // numberTextFromDouble), so 0.10 is 0.1 and 6.02e23 is 6.02e+23 whichever
1515
+ // parser read the literal. A run therefore prints the same numbers with and
1516
+ // without --proof.
1506
1517
  const canonicalFastNumberText = (text) => {
1507
- if (text.includes('.') || text.includes('e') || text.includes('E')) {
1508
- return (text.charCodeAt(0) === 45 && Number(text) === 0) ? text.slice(1) : text;
1518
+ if (text.includes('.')) {
1519
+ const value = Number(text);
1520
+ return Number.isFinite(value) ? numberTextFromDouble(value) : text;
1509
1521
  }
1522
+ if (text.includes('e') || text.includes('E')) return text;
1510
1523
  const first = text.charCodeAt(0);
1511
1524
  if (first !== 45 && (text.length === 1 || first !== 48)) return text;
1512
1525
  return BigInt(text).toString();
@@ -1826,15 +1839,46 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1826
1839
  const simple = preparationConversionActive() ? null : parseSimple(text);
1827
1840
  if (simple) {
1828
1841
  reportClauseSingletonWarnings(simple, options.onWarning, options.filename ?? '<input>', sourceLine);
1829
- accept(simple);
1842
+ accept(numbered(simple, sourceLine));
1830
1843
  return true;
1831
1844
  }
1832
1845
  try {
1833
1846
  const nestedWarning = typeof options.onWarning === 'function'
1834
1847
  ? (warning) => options.onWarning({ ...warning, line: warning.line + sourceLine - 1 })
1835
1848
  : null;
1836
- const parsed = new Parser(text, { ...options, sourceMetadata: false, onWarning: nestedWarning }).parseProgram();
1837
- for (const clause of parsed) accept(clause);
1849
+ // One operator table serves every clause of this source: building it
1850
+ // per clause dominated the parse of large generated programs, and an
1851
+ // op/3 directive in one clause must govern the clauses after it anyway.
1852
+ operatorState ??= options.operatorState ?? createParserOperatorState(
1853
+ options.operatorDefinitions ?? [],
1854
+ options.includeDefaultOperators !== false,
1855
+ { isoStrict: options.isoStrict === true },
1856
+ );
1857
+ const parser = new Parser(text, {
1858
+ ...options,
1859
+ operatorState,
1860
+ sourceMetadata: trackSource,
1861
+ filename: sourceFilename,
1862
+ onWarning: nestedWarning,
1863
+ anonymousStart: trackSource ? anonymous : 0,
1864
+ });
1865
+ const parsed = parser.parseProgram();
1866
+ if (trackSource) anonymous = parser.anonymous;
1867
+ // The nested parser numbers lines and clauses from the start of this
1868
+ // chunk; place them in the whole source.
1869
+ let counted = 0;
1870
+ for (const clause of parsed) {
1871
+ if (trackSource && clause.source) {
1872
+ const relative = clause.source.clause;
1873
+ clause.source = { ...clause.source, line: clause.source.line + sourceLine - 1 };
1874
+ if (relative != null) {
1875
+ clause.source.clause = relative + clauseNumber;
1876
+ if (relative > counted) counted = relative;
1877
+ }
1878
+ }
1879
+ accept(clause);
1880
+ }
1881
+ clauseNumber += counted;
1838
1882
  return true;
1839
1883
  } catch (_) {
1840
1884
  return false;
@@ -1853,13 +1897,13 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1853
1897
  contentEnd = trimRangeEnd(source, contentStart, contentEnd);
1854
1898
  if (contentStart < contentEnd && source.charCodeAt(contentStart) !== 37) {
1855
1899
  if (!chunk && source.charCodeAt(contentEnd - 1) === 46 && !preparationConversionActive()) {
1856
- if (emitFastBinaryRange(source, contentStart, contentEnd, lineNumber)) {
1900
+ if (!trackSource && emitFastBinaryRange(source, contentStart, contentEnd, lineNumber)) {
1857
1901
  // The program builder accepted a compact binary clause directly.
1858
1902
  } else {
1859
1903
  const simple = parseFastRange(source, contentStart, contentEnd);
1860
1904
  if (simple) {
1861
1905
  reportClauseSingletonWarnings(simple, options.onWarning, options.filename ?? '<input>', lineNumber);
1862
- accept(simple);
1906
+ accept(numbered(simple, lineNumber));
1863
1907
  } else {
1864
1908
  chunkStartLine = lineNumber;
1865
1909
  chunk = source.slice(lineStart, lineEnd) + '\n';
package/src/program.js CHANGED
@@ -958,6 +958,19 @@ function buildProgramFromSources(sources, options) {
958
958
  const builder = new ProgramBuilder(options);
959
959
  if (loadSourcesIntoBuilder(builder, sources, options, 'hybrid')) return builder.finish();
960
960
  }
961
+ // With source metadata the compact parser still applies; it numbers clauses
962
+ // as the general parser does, and falls back to it for anything it cannot
963
+ // read. Strict ISO keeps the general parser throughout, and so does a text
964
+ // with a `?-` outside a comment: a quad's indented answer lines are layout
965
+ // the line-oriented compact parser does not read.
966
+ const hasQueries = sources.some((source) => {
967
+ const text = typeof source === 'string' ? source : source?.text ?? source?.source ?? '';
968
+ return /^[^%\n]*\?-/m.test(text);
969
+ });
970
+ if (options.sourceMetadata !== false && !hasModuleDirectives && !hasQueries && options.isoStrict !== true) {
971
+ const builder = new ProgramBuilder(options);
972
+ if (loadSourcesIntoBuilder(builder, sources, options, true)) return builder.finish();
973
+ }
961
974
 
962
975
  const builder = new ProgramBuilder(options);
963
976
  loadSourcesIntoBuilder(builder, sources, options, false);
@@ -36,8 +36,9 @@ export function checkedProofBlocks(program, claimed, { registry, proofDetail = '
36
36
  const roots = [];
37
37
  const unexplained = [];
38
38
  const questions = goals.filter((goal) => typeof goal !== 'string');
39
+ const groundProofs = new Map();
39
40
  for (const fact of claimed) {
40
- const node = proofNodeFor(program, fact, { registry, proofDetail, solver, questions });
41
+ const node = proofNodeFor(program, fact, { registry, proofDetail, solver, questions, groundProofs });
41
42
  if (node) roots.push(node);
42
43
  else unexplained.push(fact);
43
44
  }
package/src/write.js CHANGED
@@ -211,7 +211,18 @@ function operatorName(name) {
211
211
  return quoteAtom(name);
212
212
  }
213
213
 
214
+ // Writers pass the same definitions array for every term of a run, so build
215
+ // its lookup table once instead of once per written term.
216
+ const operatorTables = new WeakMap();
217
+
214
218
  function operatorTable(definitions) {
219
+ if (!definitions) return buildOperatorTable(definitions);
220
+ let table = operatorTables.get(definitions);
221
+ if (!table) operatorTables.set(definitions, table = buildOperatorTable(definitions));
222
+ return table;
223
+ }
224
+
225
+ function buildOperatorTable(definitions) {
215
226
  const table = new Map();
216
227
  for (const definition of definitions ?? []) {
217
228
  if (!definition || definition.priority === 0) continue;
@@ -1,4 +1,4 @@
1
- % Reference 3.3, 11: signed, decimal, and exponent numeric literals retain lexical form.
1
+ % Signed, decimal, and exponent numeric literals read back as their values (ISO 7.1.3, 7.10.5 c).
2
2
  raw(negative_decimal, -3.5).
3
3
  raw(positive_exp, 6.02e23).
4
4
  raw(negative_exp, -1.0e-3).
@@ -1,3 +1,3 @@
1
1
  answer(negative_decimal, -3.5).
2
- answer(positive_exp, 6.02e23).
3
- answer(negative_exp, -1.0e-3).
2
+ answer(positive_exp, 6.02e+23).
3
+ answer(negative_exp, -0.001).
@@ -5,7 +5,7 @@ value(empty_atom, '').
5
5
  value(char_list, "line\nquote: \"ok\"").
6
6
  value(integer, -42).
7
7
  value(decimal, 0.25).
8
- value(scientific, 1.25e-3).
8
+ value(scientific, 0.00125).
9
9
  value(compound, pair(3, nested(atom, "xy"))).
10
10
  value(arity_zero_atom, nil).
11
11
  value(empty_list, []).
@@ -6013,21 +6013,16 @@ answer(Result) :- countdown(2048, Result), Result = 2048.
6013
6013
  const stdout = run(program, { goal }).stdout;
6014
6014
  assertEqual(stdout.split('\n')[0], expected, `${program.trim()}: ${goal}`);
6015
6015
  }
6016
- // The fast loader also deliberately retains a numeric literal's exact
6017
- // lexical spelling otherwise (test/conformance/cases/arithmetic/
6018
- // 024_numeric_literal_readback.pl): the zero-canonicalization above
6019
- // must not turn into a general reformatting pass that rewrites a
6020
- // non-zero float's notation.
6021
- assertEqual(
6022
- run('raw(a, 6.02e23).\n', { goal: 'raw(_, B), write_canonical(B), nl' }).stdout.split('\n')[0],
6023
- '6.02e23',
6024
- 'non-zero exponent notation is preserved, not reformatted to 6.02e+23',
6025
- );
6026
- assertEqual(
6027
- run('raw(a, -1.0e-3).\n', { goal: 'raw(_, B), write_canonical(B), nl' }).stdout.split('\n')[0],
6028
- '-1.0e-3',
6029
- 'non-zero negative exponent notation is preserved, not reformatted to -0.001',
6030
- );
6016
+ // A float is a value, not a spelling (ISO 7.1.3, 7.10.5 c): the fast
6017
+ // loader writes it as the general parser does, so a run prints the same
6018
+ // number with and without --proof. A three-argument fact is outside the
6019
+ // compact shape and so is read by the general parser.
6020
+ for (const [literal, expected] of [['6.02e23', '6.02e+23'], ['-1.0e-3', '-0.001'], ['0.10', '0.1']]) {
6021
+ const fast = run(`raw(a, ${literal}).\n`, { goal: 'raw(_, B), write_canonical(B), nl' }).stdout.split('\n')[0];
6022
+ const general = run(`raw(a, b, ${literal}).\n`, { goal: 'raw(_, _, B), write_canonical(B), nl' }).stdout.split('\n')[0];
6023
+ assertEqual(fast, expected, `${literal} through the fast loader`);
6024
+ assertEqual(general, expected, `${literal} through the general parser`);
6025
+ }
6031
6026
  },
6032
6027
  },
6033
6028
  {