eyeprolog 1.6.37 → 1.6.39

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.39",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -212,6 +212,7 @@ export function readProofDocument(text, program, fail = () => {}) {
212
212
  doubleQuotes: program?.doubleQuotes ?? 'chars',
213
213
  operatorDefinitions: [...(program?.operators?.values() ?? [])],
214
214
  sourceMetadata: false,
215
+ generalParser: true,
215
216
  });
216
217
  } catch (error) {
217
218
  fail('C3', `the document does not read: ${error?.message ?? String(error)}`, null);
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
@@ -51,10 +51,14 @@ function scanFastRangeToken(text, i, end) {
51
51
  return i;
52
52
  }
53
53
 
54
+ // The Unicode property tests below are needed only past ASCII: for a code
55
+ // under 128 the plain code tests give exactly the same answer, and these
56
+ // helpers run once per source character.
54
57
  function isWhitespaceCharacter(character) {
55
58
  if (!character) return false;
56
59
  const code = character.charCodeAt(0);
57
- return isWhitespaceCode(code) || /\p{White_Space}/u.test(character);
60
+ if (code < 128) return isWhitespaceCode(code);
61
+ return /\p{White_Space}/u.test(character);
58
62
  }
59
63
 
60
64
  function isUnicodeUpperCharacter(character) {
@@ -82,21 +86,24 @@ function isNameContinueCode(code) {
82
86
  }
83
87
 
84
88
  function isNameContinueCharacter(character) {
85
- return Boolean(character) && (isNameContinueCode(character.charCodeAt(0)) ||
86
- isUnicodeNameContinueCharacter(character));
89
+ if (!character) return false;
90
+ const code = character.charCodeAt(0);
91
+ if (code < 128) return isNameContinueCode(code);
92
+ return isUnicodeNameContinueCharacter(character);
87
93
  }
88
94
 
89
95
  function isVariableStartCharacter(character) {
90
96
  if (!character) return false;
91
97
  const code = character.charCodeAt(0);
92
- return code === 95 || (code >= 65 && code <= 90) || isUnicodeUpperCharacter(character);
98
+ if (code < 128) return code === 95 || (code >= 65 && code <= 90);
99
+ return isUnicodeUpperCharacter(character);
93
100
  }
94
101
 
95
102
  function isPlainAtomStartCharacter(character) {
96
103
  if (!character) return false;
97
104
  const code = character.charCodeAt(0);
98
- return (code >= 97 && code <= 122) ||
99
- (isUnicodeLetterCharacter(character) && !isUnicodeUpperCharacter(character));
105
+ if (code < 128) return code >= 97 && code <= 122;
106
+ return isUnicodeLetterCharacter(character) && !isUnicodeUpperCharacter(character);
100
107
  }
101
108
 
102
109
  const graphicAtomChars = '#$&*+-./<=>?@^~\\:';
@@ -311,13 +318,20 @@ function isCodePointOutOfRange(code) {
311
318
  return code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff);
312
319
  }
313
320
 
321
+ const PUNCTUATION_TOKENS = new Map([
322
+ ['(', TOK.LPAREN], [')', TOK.RPAREN], ['[', TOK.LBRACKET], [']', TOK.RBRACKET],
323
+ ['{', TOK.LBRACE], ['}', TOK.RBRACE], [',', TOK.COMMA], ['|', TOK.BAR], ['.', TOK.DOT],
324
+ ]);
325
+
314
326
  class Parser {
315
327
  constructor(source, options = {}) {
316
328
  this.source = String(source ?? '');
317
329
  this.filename = options.filename ?? '<input>';
318
330
  this.pos = 0;
319
331
  this.line = 1;
320
- this.anonymous = 0;
332
+ // Anonymous variables are numbered through a whole text; a caller parsing
333
+ // one text in pieces continues the count.
334
+ this.anonymous = options.anonymousStart ?? 0;
321
335
  this.variables = new Map();
322
336
  this.sourceMetadata = options.sourceMetadata !== false;
323
337
  this.onWarning = typeof options.onWarning === 'function' ? options.onWarning : null;
@@ -346,8 +360,10 @@ class Parser {
346
360
  this.token = this.nextToken();
347
361
  }
348
362
  convertCharacter(character) {
349
- if (this.parserFlagState.charConversion !== 'on' || !character) return character;
350
- return this.parserFlagState.charConversions.get(character) ?? character;
363
+ const state = this.parserFlagState;
364
+ // Almost no text installs a conversion; skip the map lookup per character.
365
+ if (state.charConversions.size === 0 || state.charConversion !== 'on' || !character) return character;
366
+ return state.charConversions.get(character) ?? character;
351
367
  }
352
368
  rawPeek(offset = 0) {
353
369
  // The processor character set is an implementation-defined processor
@@ -364,6 +380,7 @@ class Parser {
364
380
  return ch;
365
381
  }
366
382
  convertedSlice(start, end) {
383
+ if (this.parserFlagState.charConversions.size === 0) return this.source.slice(start, end);
367
384
  let out = '';
368
385
  for (const ch of this.source.slice(start, end)) out += this.convertCharacter(ch);
369
386
  return out;
@@ -610,13 +627,10 @@ class Parser {
610
627
  return { type: TOK.ATOM, text: ';', line };
611
628
  }
612
629
 
613
- const punct = {
614
- '(': TOK.LPAREN, ')': TOK.RPAREN, '[': TOK.LBRACKET, ']': TOK.RBRACKET,
615
- '{': TOK.LBRACE, '}': TOK.RBRACE, ',': TOK.COMMA, '|': TOK.BAR, '.': TOK.DOT,
616
- };
617
- if (punct[ch]) {
630
+ const punctuation = PUNCTUATION_TOKENS.get(ch);
631
+ if (punctuation) {
618
632
  this.take();
619
- return { type: punct[ch], text: ch, line, precededByLayout };
633
+ return { type: punctuation, text: ch, line, precededByLayout };
620
634
  }
621
635
  if (ch === ':' && this.peek(1) === '-' &&
622
636
  !(isGraphicAtomCode(this.peek(2).charCodeAt(0)) && !this.terminatingFullStop(this.pos + 2))) {
@@ -667,7 +681,9 @@ class Parser {
667
681
  // A signed numeric literal is only recognized where a term may start.
668
682
  // Otherwise the minus is the standard infix operator, so compact ISO
669
683
  // syntax such as `X-1` must not be read as `X` followed by `-1`.
670
- const previousEndsTerm = this.previousToken && (
684
+ // Whether the previous token ends a term matters only for a minus sign
685
+ // before a digit, so it is decided only then.
686
+ const previousEndsTerm = () => this.previousToken && (
671
687
  [TOK.VAR, TOK.NUMBER, TOK.STRING, TOK.RPAREN, TOK.RBRACKET, TOK.RBRACE].includes(this.previousToken.type) ||
672
688
  (this.previousToken.type === TOK.ATOM &&
673
689
  (this.postfixOperators.has(this.previousToken.text) ||
@@ -675,7 +691,7 @@ class Parser {
675
691
  !this.prefixOperators.has(this.previousToken.text))))
676
692
  );
677
693
  if (isDigitCode(ch.charCodeAt(0)) ||
678
- (ch === '-' && isDigitCode(this.peek(1).charCodeAt(0)) && !previousEndsTerm)) {
694
+ (ch === '-' && isDigitCode(this.peek(1).charCodeAt(0)) && !previousEndsTerm())) {
679
695
  const start = this.pos;
680
696
  const negative = this.peek() === '-';
681
697
  if (negative) this.take();
@@ -1431,7 +1447,10 @@ export function parseClauses(source, options = {}) {
1431
1447
  const parserOptions = ownsParserFlagState
1432
1448
  ? { ...options, parserFlagState: { doubleQuotes: initialDoubleQuotes, charConversion: 'on', charConversions: new Map() } }
1433
1449
  : options;
1434
- if (options.sourceMetadata === false && options.readTermEnd == null) {
1450
+ // `generalParser` reads with the general parser from the start: a text of
1451
+ // large, nested terms, such as a proof document, gains nothing from the
1452
+ // line-oriented fast path and pays for a parser per term in it.
1453
+ if (options.sourceMetadata === false && options.readTermEnd == null && options.generalParser !== true) {
1435
1454
  const clauses = parseClausesFastNoSource(source, null, null, parserOptions);
1436
1455
  if (clauses) return clauses;
1437
1456
  if (ownsParserFlagState) {
@@ -1482,6 +1501,18 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1482
1501
  let anonymous = 0;
1483
1502
  let chunk = '';
1484
1503
  let chunkStartLine = 1;
1504
+ let operatorState = null;
1505
+ // With source metadata, this path numbers clauses exactly as the general
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.
1509
+ const trackSource = options.sourceMetadata === true;
1510
+ const sourceFilename = options.filename ?? '<input>';
1511
+ let clauseNumber = 0;
1512
+ const numbered = (clause, line) => {
1513
+ if (trackSource) clause.source = { filename: sourceFilename, line, clause: ++clauseNumber };
1514
+ return clause;
1515
+ };
1485
1516
 
1486
1517
  const cached = (cache, key, create) => {
1487
1518
  const existing = cache.get(key);
@@ -1491,22 +1522,19 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1491
1522
  return value;
1492
1523
  };
1493
1524
 
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.
1525
+ // A number is a value, not a spelling (ISO 7.1.3), so the fast path writes
1526
+ // numbers exactly as the general parser does: an integer in canonical
1527
+ // decimal (a leading zero or "-0" collapses, issue #114), and a float as the
1528
+ // shortest text that reads back to the same double (term.js
1529
+ // numberTextFromDouble), so 0.10 is 0.1 and 6.02e23 is 6.02e+23 whichever
1530
+ // parser read the literal. A run therefore prints the same numbers with and
1531
+ // without --proof.
1506
1532
  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;
1533
+ if (text.includes('.')) {
1534
+ const value = Number(text);
1535
+ return Number.isFinite(value) ? numberTextFromDouble(value) : text;
1509
1536
  }
1537
+ if (text.includes('e') || text.includes('E')) return text;
1510
1538
  const first = text.charCodeAt(0);
1511
1539
  if (first !== 45 && (text.length === 1 || first !== 48)) return text;
1512
1540
  return BigInt(text).toString();
@@ -1826,15 +1854,46 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1826
1854
  const simple = preparationConversionActive() ? null : parseSimple(text);
1827
1855
  if (simple) {
1828
1856
  reportClauseSingletonWarnings(simple, options.onWarning, options.filename ?? '<input>', sourceLine);
1829
- accept(simple);
1857
+ accept(numbered(simple, sourceLine));
1830
1858
  return true;
1831
1859
  }
1832
1860
  try {
1833
1861
  const nestedWarning = typeof options.onWarning === 'function'
1834
1862
  ? (warning) => options.onWarning({ ...warning, line: warning.line + sourceLine - 1 })
1835
1863
  : null;
1836
- const parsed = new Parser(text, { ...options, sourceMetadata: false, onWarning: nestedWarning }).parseProgram();
1837
- for (const clause of parsed) accept(clause);
1864
+ // One operator table serves every clause of this source: building it
1865
+ // per clause dominated the parse of large generated programs, and an
1866
+ // op/3 directive in one clause must govern the clauses after it anyway.
1867
+ operatorState ??= options.operatorState ?? createParserOperatorState(
1868
+ options.operatorDefinitions ?? [],
1869
+ options.includeDefaultOperators !== false,
1870
+ { isoStrict: options.isoStrict === true },
1871
+ );
1872
+ const parser = new Parser(text, {
1873
+ ...options,
1874
+ operatorState,
1875
+ sourceMetadata: trackSource,
1876
+ filename: sourceFilename,
1877
+ onWarning: nestedWarning,
1878
+ anonymousStart: trackSource ? anonymous : 0,
1879
+ });
1880
+ const parsed = parser.parseProgram();
1881
+ if (trackSource) anonymous = parser.anonymous;
1882
+ // The nested parser numbers lines and clauses from the start of this
1883
+ // chunk; place them in the whole source.
1884
+ let counted = 0;
1885
+ for (const clause of parsed) {
1886
+ if (trackSource && clause.source) {
1887
+ const relative = clause.source.clause;
1888
+ clause.source = { ...clause.source, line: clause.source.line + sourceLine - 1 };
1889
+ if (relative != null) {
1890
+ clause.source.clause = relative + clauseNumber;
1891
+ if (relative > counted) counted = relative;
1892
+ }
1893
+ }
1894
+ accept(clause);
1895
+ }
1896
+ clauseNumber += counted;
1838
1897
  return true;
1839
1898
  } catch (_) {
1840
1899
  return false;
@@ -1853,13 +1912,13 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1853
1912
  contentEnd = trimRangeEnd(source, contentStart, contentEnd);
1854
1913
  if (contentStart < contentEnd && source.charCodeAt(contentStart) !== 37) {
1855
1914
  if (!chunk && source.charCodeAt(contentEnd - 1) === 46 && !preparationConversionActive()) {
1856
- if (emitFastBinaryRange(source, contentStart, contentEnd, lineNumber)) {
1915
+ if (!trackSource && emitFastBinaryRange(source, contentStart, contentEnd, lineNumber)) {
1857
1916
  // The program builder accepted a compact binary clause directly.
1858
1917
  } else {
1859
1918
  const simple = parseFastRange(source, contentStart, contentEnd);
1860
1919
  if (simple) {
1861
1920
  reportClauseSingletonWarnings(simple, options.onWarning, options.filename ?? '<input>', lineNumber);
1862
- accept(simple);
1921
+ accept(numbered(simple, lineNumber));
1863
1922
  } else {
1864
1923
  chunkStartLine = lineNumber;
1865
1924
  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
  }
@@ -62,10 +62,27 @@ function copyHookResult(term, env, inputNames) {
62
62
  return copy(term);
63
63
  }
64
64
 
65
+ // Whether some clause of a hook could unify with `input` as its first
66
+ // argument. A clause whose first argument has another type, name or arity
67
+ // cannot, so when none can the hook does not run at all: a library such as
68
+ // CLP(Z) expands only its own goals, and asking a fresh solver about every
69
+ // other clause and goal of a program cost more than loading it.
70
+ function hookMayApply(group, input) {
71
+ if (input?.type === VAR) return true;
72
+ for (const clause of group.clauses) {
73
+ const first = clause.head?.args?.[0];
74
+ if (first == null || first.type === VAR) return true;
75
+ if (first.type !== input.type || first.name !== input.name) continue;
76
+ if (first.type !== COMPOUND || first.args.length === input.args.length) return true;
77
+ }
78
+ return false;
79
+ }
80
+
65
81
  function runHook(program, module, name, input, loadContext = null) {
66
82
  for (const owner of hookModules(module)) {
67
83
  const group = exactHookGroup(program, owner, name);
68
84
  if (group == null || group.clauses.length === 0) continue;
85
+ if (!hookMayApply(group, input)) continue;
69
86
 
70
87
  const output = variable(`\u0000source-expansion:${++expansionFresh}`);
71
88
  const goal = compound(name, [input, output]);
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, []).
@@ -1,7 +1,10 @@
1
1
  // A small ordered worker pool for the suites that do the same work once per
2
2
  // file: run every example, prove every example, check every packaged proof.
3
3
  //
4
- // Results are reported in task order however the workers finish, so a parallel
4
+ // Tasks are handed to workers most expensive first when the caller can
5
+ // estimate a task's cost: a suite whose slowest task starts late spends its
6
+ // last seconds running that one task alone. Results are still reported in task
7
+ // order however the workers finish, so a parallel
5
8
  // suite numbers its tests exactly as the sequential one did and a failure still
6
9
  // stops the run at the same test. Each suite compares inside its worker, so
7
10
  // only a name, an elapsed time, an optional failure, and a small plain-data
@@ -17,6 +20,7 @@ export async function runTasksInParallel({
17
20
  maxWorkers,
18
21
  runLocally,
19
22
  onResult,
23
+ cost = null,
20
24
  }) {
21
25
  if (names.length === 0) return;
22
26
  const parallelism = os.availableParallelism?.() ?? os.cpus().length;
@@ -35,6 +39,13 @@ export async function runTasksInParallel({
35
39
  }
36
40
 
37
41
  const completedResults = new Map();
42
+ // Dispatch order: task indexes by descending estimated cost, ties in task
43
+ // order. Reporting still follows the task order itself.
44
+ const dispatch = names.map((_, index) => index);
45
+ if (typeof cost === 'function') {
46
+ const costs = names.map((name) => cost(name));
47
+ dispatch.sort((left, right) => costs[right] - costs[left] || left - right);
48
+ }
38
49
  let nextTask = 0;
39
50
  let nextReport = 0;
40
51
  let completed = 0;
@@ -72,7 +83,8 @@ export async function runTasksInParallel({
72
83
 
73
84
  const assign = (worker) => {
74
85
  if (nextTask >= names.length || settled) return;
75
- worker.postMessage({ ...message, id: nextTask, name: names[nextTask++] });
86
+ const id = dispatch[nextTask++];
87
+ worker.postMessage({ ...message, id, name: names[id] });
76
88
  };
77
89
 
78
90
  for (const worker of workers) {
@@ -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
  {
@@ -50,6 +50,16 @@ const exampleTaskKinds = {
50
50
  };
51
51
 
52
52
 
53
+ // A packaged proof grows with the derivation it records, so its size is a
54
+ // cheap estimate of how long an example takes to run, prove, and check.
55
+ export function exampleCost(name) {
56
+ try {
57
+ return fs.statSync(path.join(expectedProofDir, name)).size;
58
+ } catch {
59
+ return 0;
60
+ }
61
+ }
62
+
53
63
  function runExampleTasks(names, kind, reporter) {
54
64
  return runTasksInParallel({
55
65
  names,
@@ -57,6 +67,7 @@ function runExampleTasks(names, kind, reporter) {
57
67
  workerData: { exampleWorker: true },
58
68
  message: { kind },
59
69
  maxWorkers: 3,
70
+ cost: exampleCost,
60
71
  runLocally: (name) => exampleTaskKinds[kind](name),
61
72
  onResult: (name, result) => reporter.testResult(name, result),
62
73
  });
@@ -15,7 +15,7 @@ import { isMainThread, parentPort, workerData } from 'node:worker_threads';
15
15
  import { Program } from '../src/index.js';
16
16
  import { checkProofDocument, checkReportTerms, sourceGoals, verdict } from '../src/check-proof.js';
17
17
  import { TestReporter, assertEqual, isMainModule, runStandalone } from './test-style.mjs';
18
- import { proofExamples } from './run-examples.mjs';
18
+ import { exampleCost, proofExamples } from './run-examples.mjs';
19
19
  import { runTasksInParallel, serveTasks } from './parallel-tasks.mjs';
20
20
 
21
21
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -37,6 +37,7 @@ export async function runProofChecking(reporter = new TestReporter()) {
37
37
  workerUrl: new URL(import.meta.url),
38
38
  workerData: { proofCheckingWorker: true },
39
39
  maxWorkers: 3,
40
+ cost: exampleCost,
40
41
  runLocally: (name) => checkPackagedProof(name),
41
42
  onResult: (name, result) => {
42
43
  for (const field of Object.keys(totals)) totals[field] += result.value?.[field] ?? 0;