eyeprolog 1.5.30 → 1.5.32

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/README.md CHANGED
@@ -86,9 +86,9 @@ The checked [Symbiotic Knowledge Graphs example](examples/symbiotic-knowledge-gr
86
86
  The same RDF → Prolog → RDF boundary is exercised by five additional checked scenarios: [cross-organization data sharing](https://eyereasoner.github.io/eyeprolog/examples/deck/cross-organization-data-sharing), [explainable EV-depot configuration](https://eyereasoner.github.io/eyeprolog/examples/deck/explainable-ev-depot-configuration), [operational incident response](https://eyereasoner.github.io/eyeprolog/examples/deck/operational-incident-response), [software supply-chain vulnerability response](https://eyereasoner.github.io/eyeprolog/examples/deck/sbom-vulnerability-response), and a [scientific evidence graph](https://eyereasoner.github.io/eyeprolog/examples/deck/scientific-evidence-graph). Together they cover policy decisions, reversible configuration reasoning, dependency-graph diagnosis, transitive SBOM exposure, and evidence aggregation with explicit disagreement.
87
87
 
88
88
  ## Benchmarks
89
- EyeProlog has 20 checksum-protected wall-clock benchmarks spanning recursion/indexing, constraints, tabling/WFS, DCGs, Eyelet, search, term I/O, attributes, and rewriting. Short workloads are adaptively batched before timing so millisecond-scale noise is not mistaken for a regression. Run `npm run benchmark`; create a machine-local comparison point with `npm run benchmark:baseline`; use `npm run test:benchmark` for harness checks. Details are in [*The Art of EyeProlog*](the-art-of-eyeprolog.md).
89
+ EyeProlog has 21 checksum-protected wall-clock benchmarks spanning recursion/indexing, constraints, tabling/WFS, DCGs, Eyelet, search, term I/O, attributes, rewriting, and the classic Prolog naive-reverse workload. Short workloads are adaptively batched before timing so millisecond-scale noise is not mistaken for a regression. Run `npm run benchmark`; create a machine-local comparison point with `npm run benchmark:baseline`; use `npm run test:benchmark` for harness checks. The checked [`examples/bench.pl`](examples/bench.pl) preserves the classic Quintus 1984 `nrev/2` workload on a 30-element list. For a comparable LIPS number, run `npm run benchmark:lips`: it executes the classic failure-driven `dobench/1` and `dodummy/1` loops in Prolog, subtracts dummy-loop CPU time, and applies the historical 496 procedure calls per reversal. The generic benchmark table still shows a quick wall-clock LIPS estimate for `classic-nrev`, but `benchmark:lips` is the canonical engine-speed measurement. LIPS is a historical basic-engine-speed indicator, not a whole-system performance score. Details are in [*The Art of EyeProlog*](the-art-of-eyeprolog.md).
90
+ For the project policy on post-ISO-standard and WG17 compatibility features such as digit separators, see [ISO/WG17 compatibility extensions](test/conformance/ISO-WG17-EXTENSIONS.md).
90
91
  ## Development
91
-
92
92
  ```sh
93
93
  git clone https://github.com/eyereasoner/eyeprolog.git
94
94
  cd eyeprolog
@@ -0,0 +1,57 @@
1
+ % Classic Prolog "naive reverse" benchmark (Quintus, 1984), adapted as a
2
+ % deterministic EyeProlog example. The traditional workload reverses a
3
+ % 30-element list with nrev/2 and concatenate/3. It takes 496 Prolog
4
+ % procedure calls per reversal in the original LIPS accounting.
5
+ %
6
+ % `npm run benchmark:lips` uses the classic dobench/dodummy control subtraction
7
+ % with Node's process CPU clock, avoiding a dependency on a Prolog-specific
8
+ % statistics/2 clock while preserving the benchmark methodology. See README.md.
9
+ %
10
+ % Source/history:
11
+ % https://gerrit.googlesource.com/prolog-cafe/+/73b5ce4f5fbef086a22de3292a53e1ffe2947fab/examples/benchmarks/src/bench.pl
12
+
13
+ %% goal: bench_result(X0)
14
+
15
+ nrev([], []).
16
+ nrev([X|Rest], Ans) :-
17
+ nrev(Rest, L),
18
+ concatenate(L, [X], Ans).
19
+
20
+ concatenate([], L, L).
21
+ concatenate([X|L1], L2, [X|L3]) :-
22
+ concatenate(L1, L2, L3).
23
+
24
+ data([1,2,3,4,5,6,7,8,9,10,
25
+ 11,12,13,14,15,16,17,18,19,20,
26
+ 21,22,23,24,25,26,27,28,29,30]).
27
+
28
+ bench_result(Reversed) :-
29
+ data(List),
30
+ nrev(List, Reversed).
31
+
32
+ % Supporting predicates from the classic benchmark harness. The JavaScript
33
+ % benchmark driver times dodummy/1 and dobench/1 separately and subtracts the
34
+ % dummy time, matching the Quintus methodology without requiring a particular
35
+ % Prolog statistics/2 clock implementation.
36
+
37
+ dobench(Count) :-
38
+ data(List),
39
+ bench_repeat(Count),
40
+ nrev(List, _),
41
+ fail.
42
+ dobench(_).
43
+
44
+ dodummy(Count) :-
45
+ data(List),
46
+ bench_repeat(Count),
47
+ dummy(List, _),
48
+ fail.
49
+ dodummy(_).
50
+
51
+ dummy(_, _).
52
+
53
+ bench_repeat(_N).
54
+ bench_repeat(N) :-
55
+ N > 1,
56
+ N1 is N - 1,
57
+ bench_repeat(N1).
@@ -0,0 +1 @@
1
+ bench_result([30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]).
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.5.30",
6
+ "version": "1.5.32",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -70,6 +70,7 @@
70
70
  "benchmark": "node test/benchmark.mjs",
71
71
  "benchmark:baseline": "node test/benchmark.mjs --save .benchmarks/baseline.json",
72
72
  "test:benchmark": "node test/run-benchmark-tests.mjs",
73
- "generate:reference": "node tools/generate-predicate-reference.mjs"
73
+ "generate:reference": "node tools/generate-predicate-reference.mjs",
74
+ "benchmark:lips": "node test/lips-benchmark.mjs"
74
75
  }
75
76
  }
package/playground.html CHANGED
@@ -453,6 +453,7 @@
453
453
  "bayes-diagnosis",
454
454
  "bayes-therapy",
455
455
  "beam-deflection",
456
+ "bench",
456
457
  "binomial-vandermonde",
457
458
  "blocks-world-planning",
458
459
  "bmi",
package/src/parser.js CHANGED
@@ -1382,10 +1382,14 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1382
1382
  return atom(text);
1383
1383
  };
1384
1384
 
1385
- const trimRange = (text, start, end) => {
1385
+ const trimRangeStart = (text, start, end) => {
1386
1386
  while (start < end && isWhitespaceCode(text.charCodeAt(start))) start++;
1387
+ return start;
1388
+ };
1389
+
1390
+ const trimRangeEnd = (text, start, end) => {
1387
1391
  while (end > start && isWhitespaceCode(text.charCodeAt(end - 1))) end--;
1388
- return [start, end];
1392
+ return end;
1389
1393
  };
1390
1394
 
1391
1395
  const tokenKindInRange = (text, start, end) => {
@@ -1427,7 +1431,8 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1427
1431
  };
1428
1432
 
1429
1433
  const scalarOrVariableRange = (text, start, end) => {
1430
- [start, end] = trimRange(text, start, end);
1434
+ start = trimRangeStart(text, start, end);
1435
+ end = trimRangeEnd(text, start, end);
1431
1436
  const kind = tokenKindInRange(text, start, end);
1432
1437
  const value = text.slice(start, end);
1433
1438
  if (kind === 'var') {
@@ -1447,20 +1452,27 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1447
1452
  const rawBody = {};
1448
1453
 
1449
1454
  const scalarTokenRange = (text, start, end, out, slot) => {
1450
- [start, end] = trimRange(text, start, end);
1455
+ start = trimRangeStart(text, start, end);
1456
+ end = trimRangeEnd(text, start, end);
1451
1457
  const kind = tokenKindInRange(text, start, end);
1452
1458
  let type = kind;
1453
1459
  if (kind == null && simpleNumberInRange(text, start, end)) type = 'number';
1454
1460
  if (type == null) return false;
1455
1461
  let name = text.slice(start, end);
1456
1462
  if (type === 'var' && name === '_') name = `__anon${anonymous++}`;
1457
- out[`arg${slot}Type`] = type;
1458
- out[`arg${slot}Name`] = name;
1463
+ if (slot === 0) {
1464
+ out.arg0Type = type;
1465
+ out.arg0Name = name;
1466
+ } else {
1467
+ out.arg1Type = type;
1468
+ out.arg1Name = name;
1469
+ }
1459
1470
  return true;
1460
1471
  };
1461
1472
 
1462
1473
  const parseBinaryRawRange = (text, start, end, out) => {
1463
- [start, end] = trimRange(text, start, end);
1474
+ start = trimRangeStart(text, start, end);
1475
+ end = trimRangeEnd(text, start, end);
1464
1476
  let i = start;
1465
1477
  const first = text.charCodeAt(i);
1466
1478
  if (!(first >= 97 && first <= 122)) return false;
@@ -1487,7 +1499,8 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1487
1499
  };
1488
1500
 
1489
1501
  const parseBinaryCompoundRange = (text, start = 0, end = text.length) => {
1490
- [start, end] = trimRange(text, start, end);
1502
+ start = trimRangeStart(text, start, end);
1503
+ end = trimRangeEnd(text, start, end);
1491
1504
  let i = start;
1492
1505
  const first = text.charCodeAt(i);
1493
1506
  if (!(first >= 97 && first <= 122)) return null;
@@ -1680,7 +1693,8 @@ function parseClausesFastNoSource(source, emit = null, emitBinary = null, option
1680
1693
  let contentStart = lineStart;
1681
1694
  let contentEnd = lineEnd;
1682
1695
  if (contentEnd > contentStart && source.charCodeAt(contentEnd - 1) === 13) contentEnd--;
1683
- [contentStart, contentEnd] = trimRange(source, contentStart, contentEnd);
1696
+ contentStart = trimRangeStart(source, contentStart, contentEnd);
1697
+ contentEnd = trimRangeEnd(source, contentStart, contentEnd);
1684
1698
  if (contentStart < contentEnd && source.charCodeAt(contentStart) !== 37) {
1685
1699
  if (!chunk && source.charCodeAt(contentEnd - 1) === 46 && !preparationConversionActive()) {
1686
1700
  if (emitFastBinaryRange(source, contentStart, contentEnd, lineNumber)) {
package/src/program.js CHANGED
@@ -36,6 +36,11 @@ const DEFER_PROGRAM_BUILD = Symbol('deferProgramBuild');
36
36
  const FAST_PARSE_ABORT = Symbol('fastParseAbort');
37
37
  const PROGRAM_BUILD_BATCH_SIZE = 16384;
38
38
  const preparedBundledLibraryCache = new Map();
39
+ const NORMAL_OPERATOR_DEFINITIONS = [
40
+ ...ISO_OPERATOR_DEFINITIONS,
41
+ ...PART3_OPERATOR_DEFINITIONS,
42
+ ...QUAD_OPERATOR_DEFINITIONS,
43
+ ];
39
44
 
40
45
  function collectMultiplicitySensitiveDependencies(goal, out = []) {
41
46
  if (goal?.type !== COMPOUND) return out;
@@ -151,13 +156,12 @@ export class Program {
151
156
  // recursive predicates retain standard depth-first Prolog control.
152
157
  this.tabledPredicates = new Set();
153
158
  this.operators = new Map();
154
- const predefinedOperatorSets = this.strictIso
155
- ? [ISO_OPERATOR_DEFINITIONS]
156
- : [ISO_OPERATOR_DEFINITIONS, PART3_OPERATOR_DEFINITIONS, QUAD_OPERATOR_DEFINITIONS];
157
- for (const definitions of predefinedOperatorSets) {
158
- for (const [priority, specifier, name] of definitions) {
159
- this.defineOperator(priority, specifier, name);
160
- }
159
+ const predefinedOperators = this.strictIso ? ISO_OPERATOR_DEFINITIONS : NORMAL_OPERATOR_DEFINITIONS;
160
+ // The predefined lists contain no competing declarations in the same
161
+ // prefix, infix, or postfix class. Populate them directly; defineOperator
162
+ // still performs replacement semantics for source-level op/3 directives.
163
+ for (const [priority, specifier, name] of predefinedOperators) {
164
+ this.operators.set(`${specifier}\u0000${name}`, { priority, specifier, name });
161
165
  }
162
166
  this.initializations = [];
163
167
  // Track how many initialization/1 directives have run so a REPL can
@@ -251,12 +255,15 @@ export class Program {
251
255
  }
252
256
  findGroup(name, arity, module = 'user') {
253
257
  const indicator = `${name}/${arity}`;
254
- const visited = new Set();
255
- let current = module;
258
+ const direct = this.groups.get(module === 'user' ? indicator : `${module}:${indicator}`);
259
+ if (direct) return direct;
260
+ let current = this.moduleImports.get(module)?.get(indicator);
261
+ if (current == null) return null;
262
+ const visited = new Set([module]);
256
263
  while (!visited.has(current)) {
257
264
  visited.add(current);
258
- const direct = this.groups.get(modulePredicateKey(current, name, arity));
259
- if (direct) return direct;
265
+ const imported = this.groups.get(current === 'user' ? indicator : `${current}:${indicator}`);
266
+ if (imported) return imported;
260
267
  const importedModule = this.moduleImports.get(current)?.get(indicator);
261
268
  if (importedModule == null) return null;
262
269
  current = importedModule;
package/src/solver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Depth-first EyeProlog solver with builtin dispatch, memoization, and guarded recursion handling.
2
2
  // Most semantic decisions still flow through unification; optimizations only select candidates earlier.
3
3
  import {
4
- ATOM, COMPOUND, NUMBER, STRING, VAR, Env, compactListLength, compactVariableList, compound, cons, copyResolved, deref, emptyList,
4
+ ATOM, COMPOUND, NUMBER, STRING, VAR, Env, Term, compactListLength, compactVariableList, compound, cons, copyResolved, deref, emptyList,
5
5
  flattenConjunction, freshTerm, isCons, isDecimalInteger, isEmptyList,
6
6
  numberTerm, numberTextFromDouble, properListItems, termIsGround, termToString, unify, variable, variantTerms,
7
7
  } from './term.js';
@@ -18,6 +18,7 @@ import { evaluatePositiveDatalog, relationForDatalogGroup, datalogCandidateIndex
18
18
  let freshCounter = 0;
19
19
  const DEFAULT_INNER_TABLE_SCOPE_LIMIT = 1024;
20
20
  const GOAL_CONTINUATION_THRESHOLD = 64;
21
+ const NO_CALL_TARGET = Symbol('no-call-target');
21
22
  // Conservative live-storage estimate for one generated length/2 list cell
22
23
  // (cons object, argument vector, fresh variable, and its generated name).
23
24
  const GENERATED_LENGTH_CELL_RESERVE_BYTES = 256;
@@ -90,6 +91,9 @@ export class Solver {
90
91
  // implementation-specific predicates. Use the Part 1 + corrigenda
91
92
  // registry even when an embedder supplied the normal EyeProlog registry.
92
93
  this.registry = this.isoStrict ? getStrictIsoRegistry() : (options.registry ?? getEyePrologRegistry());
94
+ this.userGroupCache = Object.create(null);
95
+ this.moduleGroupCache = new Map();
96
+ this.builtinCache = Object.create(null);
93
97
  this.mutableProgram = program.mutable === true;
94
98
  this.programRevision = this.program.revision ?? 0;
95
99
  // Normal Prolog execution must not silently acquire a semantic depth
@@ -115,7 +119,11 @@ export class Solver {
115
119
  reservationBytes: 0,
116
120
  checks: 0,
117
121
  };
118
- this.nextMemoryCheck = 0;
122
+ // Nested meta-call solvers contribute to one execution and inspect the
123
+ // same process heap. Share their sampling deadline so each short-lived
124
+ // child does not repeat an expensive host memory query before the parent
125
+ // has advanced the guard interval.
126
+ this.memoryCheckState = options.memoryCheckState ?? { nextObservation: 0 };
119
127
  // Do not impose an implicit answer cap. Infinite and very large searches are
120
128
  // part of normal Prolog semantics; callers that need a resource bound can
121
129
  // still supply solutionLimit explicitly.
@@ -215,6 +223,7 @@ export class Solver {
215
223
  maxInferences: this.maxInferences,
216
224
  maxMemoryBytes: this.maxMemoryBytes,
217
225
  memoryRecovery: this.memoryRecovery,
226
+ memoryCheckState: this.memoryCheckState,
218
227
  solutionLimit,
219
228
  isoStrict: this.isoStrict,
220
229
  prologFlags: this.prologFlags,
@@ -290,6 +299,37 @@ export class Solver {
290
299
  }
291
300
  }
292
301
 
302
+ lookupGroup(name, arity, module = 'user') {
303
+ if (module === 'user') {
304
+ let slots = this.userGroupCache[name];
305
+ if (slots === undefined) this.userGroupCache[name] = slots = [];
306
+ const cached = slots[arity];
307
+ if (cached !== undefined) return cached === NO_CALL_TARGET ? null : cached;
308
+ const group = this.program.findGroup(name, arity, module);
309
+ slots[arity] = group ?? NO_CALL_TARGET;
310
+ return group;
311
+ }
312
+ let byName = this.moduleGroupCache.get(module);
313
+ if (byName == null) this.moduleGroupCache.set(module, byName = new Map());
314
+ let byArity = byName.get(name);
315
+ if (byArity == null) byName.set(name, byArity = []);
316
+ const cached = byArity[arity];
317
+ if (cached !== undefined) return cached === NO_CALL_TARGET ? null : cached;
318
+ const group = this.program.findGroup(name, arity, module);
319
+ byArity[arity] = group ?? NO_CALL_TARGET;
320
+ return group;
321
+ }
322
+
323
+ lookupBuiltin(name, arity) {
324
+ let slots = this.builtinCache[name];
325
+ if (slots === undefined) this.builtinCache[name] = slots = [];
326
+ const cached = slots[arity];
327
+ if (cached !== undefined) return cached === NO_CALL_TARGET ? null : cached;
328
+ const def = this.registry.get(name, arity);
329
+ slots[arity] = def ?? NO_CALL_TARGET;
330
+ return def;
331
+ }
332
+
293
333
  syncProgramRevision() {
294
334
  if (!this.mutableProgram) {
295
335
  if (this.program.mutable !== true) return;
@@ -298,6 +338,9 @@ export class Solver {
298
338
  const revision = this.program.revision ?? 0;
299
339
  if (revision === this.programRevision) return;
300
340
  this.programRevision = revision;
341
+ this.userGroupCache = Object.create(null);
342
+ this.moduleGroupCache.clear();
343
+ this.builtinCache = Object.create(null);
301
344
  this.memo.clear();
302
345
  this.subsumptiveMemo.clear();
303
346
  this.wfsModels.clear();
@@ -507,13 +550,7 @@ export class Solver {
507
550
  });
508
551
  continue;
509
552
  }
510
- const id = nextFreshId();
511
- const freshVariables = new Map();
512
- const freshHead = freshTerm(clause.head, id, freshVariables);
513
- const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
514
- const localFreshPlan = clauseLocalFreshPlan(clause);
515
- const headLocalFresh = freshVariableSet(localFreshPlan.head, freshVariables);
516
- attachBodyLocalFreshVariables(freshBody, localFreshPlan.body, freshVariables);
553
+ const { freshHead, freshBody, headLocalFresh } = instantiateClause(clause, nextFreshId());
517
554
  next = clauseEnv.clone();
518
555
  this.stats.unify_calls++;
519
556
  if (!unify(clauseGoal, freshHead, next, { knownNonoccurringVariables: headLocalFresh })) continue;
@@ -649,7 +686,7 @@ export class Solver {
649
686
  // EyeProlog normally solves left-to-right, but ready deterministic builtins can
650
687
  // be run early as pure filters. Stop at internal sentinels so rule-body
651
688
  // active guards are released before the caller's remaining goals are seen.
652
- const selectedIndex = selectReadyDeterministicBuiltin(goals, env, this.registry);
689
+ const selectedIndex = 0;
653
690
  const goal = deref(goals[selectedIndex], env);
654
691
  const rest = selectedIndex === 0 ? goals.slice(1) : [...goals.slice(0, selectedIndex), ...goals.slice(selectedIndex + 1)];
655
692
  prepareLocalVariablesForGoal(goal, env);
@@ -701,7 +738,7 @@ export class Solver {
701
738
  }
702
739
  }
703
740
 
704
- const def = callable ? this.registry.get(goal.name, goal.arity) : null;
741
+ const def = callable ? this.lookupBuiltin(goal.name, goal.arity) : null;
705
742
  this.active = active;
706
743
  const builtinReady = def && builtinIsReadyOrAuthoritative(def, this, goal, env);
707
744
  if (builtinReady && typeof def.expandGoal === 'function') {
@@ -735,7 +772,7 @@ export class Solver {
735
772
  }
736
773
 
737
774
  this.stats.solve_one_goal_calls++;
738
- const group = this.program.findGroup(goal.name, goal.arity, goal.module ?? 'user');
775
+ const group = this.lookupGroup(goal.name, goal.arity, goal.module ?? 'user');
739
776
  if (!group) {
740
777
  if (goal.name === '-->' && goal.arity === 2) {
741
778
  throw new PrologError(
@@ -865,6 +902,35 @@ export class Solver {
865
902
  }
866
903
 
867
904
  if (!group.tabled && tryPushScalarFactRunFrames(stack, this, [goal, ...rest], env, depth, active)) break;
905
+ // When indexing leaves exactly one cut-free clause, enter it directly
906
+ // instead of allocating a resumable clause-choice frame. This keeps
907
+ // ordinary deterministic recursion on the interpreter's tight loop.
908
+ if (!group.tabled && groupNeedsActiveFrame(group) === false) {
909
+ const candidates = selectClauseCandidates(group, goal, env);
910
+ const candidateCount = clauseCandidateLength(candidates.primary) +
911
+ clauseCandidateLength(candidates.fallback);
912
+ if (candidateCount === 1) {
913
+ const clause = clauseCandidateLength(candidates.primary) === 1
914
+ ? clauseCandidateAt(candidates.primary, 0)
915
+ : clauseCandidateAt(candidates.fallback, 0);
916
+ const { freshHead, freshBody, headLocalFresh } = instantiateClause(clause, nextFreshId());
917
+ const next = env.clone();
918
+ this.stats.unify_calls++;
919
+ if (!unify(goal, freshHead, next, { knownNonoccurringVariables: headLocalFresh })) break;
920
+ if (freshBody.length === 0) {
921
+ goals = rest;
922
+ } else if (rest.length >= GOAL_CONTINUATION_THRESHOLD ||
923
+ rest.some((pending) => pending?.kind === 'continueGoals')) {
924
+ next.compactForDeepContinuation?.();
925
+ goals = [...freshBody, { kind: 'continueGoals', goals: rest, depth, releaseActive: false }];
926
+ } else {
927
+ goals = [...freshBody, ...rest];
928
+ }
929
+ env = next;
930
+ depth++;
931
+ continue;
932
+ }
933
+ }
868
934
  pushUserGoalUncachedFrames(stack, this, group, goal, rest, env, depth, active);
869
935
  break;
870
936
  }
@@ -1005,8 +1071,9 @@ export class Solver {
1005
1071
  }
1006
1072
 
1007
1073
  checkMemoryLimit(force = false) {
1008
- if (!force && this.inferences < this.nextMemoryCheck) return;
1009
- this.nextMemoryCheck = this.inferences + 256;
1074
+ const observation = this.inferenceObservation.value;
1075
+ if (!force && observation < this.memoryCheckState.nextObservation) return;
1076
+ this.memoryCheckState.nextObservation = observation + 256;
1010
1077
  if (!Number.isFinite(this.maxMemoryBytes)) return;
1011
1078
  const used = usedHeapSize();
1012
1079
  if (used != null && used < this.maxMemoryBytes) this.finishMemoryRecovery();
@@ -1104,13 +1171,7 @@ export class Solver {
1104
1171
  continue;
1105
1172
  }
1106
1173
  if (headCannotMatch(goal, clause.head, env)) continue;
1107
- const id = nextFreshId();
1108
- const freshVariables = new Map();
1109
- const freshHead = freshTerm(clause.head, id, freshVariables);
1110
- const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
1111
- const localFreshPlan = clauseLocalFreshPlan(clause);
1112
- const headLocalFresh = freshVariableSet(localFreshPlan.head, freshVariables);
1113
- attachBodyLocalFreshVariables(freshBody, localFreshPlan.body, freshVariables);
1174
+ const { freshHead, freshBody, headLocalFresh } = instantiateClause(clause, nextFreshId());
1114
1175
  const next = env.clone();
1115
1176
  this.stats.unify_calls++;
1116
1177
  if (!unify(goal, freshHead, next, { knownNonoccurringVariables: headLocalFresh })) continue;
@@ -1295,16 +1356,15 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1295
1356
  if (tryPushGroundScalarRuleFrame(stack, solver, group, goal, rest, env, depth, active)) return;
1296
1357
  if (tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth, active)) return;
1297
1358
  const candidates = selectClauseCandidates(group, goal, env);
1359
+ const candidateCount = clauseCandidateLength(candidates.primary) + clauseCandidateLength(candidates.fallback);
1298
1360
  const frames = [];
1299
- const invocation = { goal, env: env._snapshotForSolverRead(), tableMapKey };
1300
1361
  // Active frames serve two purposes: they delimit cut and detect variants in
1301
- // recursive user predicates. Cut-free, non-recursive library helpers need
1302
- // neither. Copying their full active stack at every recursive step made
1303
- // otherwise linear relations such as length/2 retain O(depth^2) references.
1362
+ // recursive user predicates. Delay the invocation snapshot until a reachable
1363
+ // cut/guard actually needs one; ordinary cut-free recursion avoids it.
1304
1364
  for (const pass of [candidates.primary, candidates.fallback]) {
1305
1365
  for (let candidateIndex = 0; candidateIndex < clauseCandidateLength(pass); candidateIndex++) {
1306
1366
  const clause = clauseCandidateAt(pass, candidateIndex);
1307
- if (headCannotMatch(goal, clause.head, env)) continue;
1367
+ if (candidateCount > 1 && headCannotMatch(goal, clause.head, env)) continue;
1308
1368
  // Do not speculatively unify alternative heads. With attributed variables,
1309
1369
  // head unification can run verify_attributes/3, queue wakeups, fail, or
1310
1370
  // throw. Those effects are observable and must occur only if Prolog search
@@ -1325,10 +1385,12 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1325
1385
  // recursive clause; do not copy the whole active path at every such layer.
1326
1386
  const traditionalDepthFirst = !group.tabled;
1327
1387
  const guarded = traditionalDepthFirst
1328
- ? frames.some((frame) => clauseHasCutForTailRelease(frame.clause))
1388
+ ? (group.hasCut === true && frames.some((frame) => clauseHasCutForTailRelease(frame.clause)))
1329
1389
  : groupNeedsActiveFrame(group);
1330
1390
  const release = guarded ? [{ kind: 'releaseActive' }] : [];
1331
- const nextActive = guarded ? [...active, invocation] : active;
1391
+ const nextActive = guarded
1392
+ ? [...active, { goal, env: env._snapshotForSolverRead(), tableMapKey }]
1393
+ : active;
1332
1394
  for (const frame of frames) {
1333
1395
  frame.active = nextActive;
1334
1396
  frame.release = release;
@@ -1341,6 +1403,104 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1341
1403
  }
1342
1404
 
1343
1405
 
1406
+ function instantiatePlanNode(node, variables) {
1407
+ if (node.variableSlot != null) return variables[node.variableSlot];
1408
+ const args = new Array(node.args.length);
1409
+ for (let index = 0; index < args.length; index++) {
1410
+ args[index] = instantiatePlanNode(node.args[index], variables);
1411
+ }
1412
+ const term = new Term(node.type, node.name, args);
1413
+ if (node.module != null) term.module = node.module;
1414
+ return term;
1415
+ }
1416
+
1417
+ function freshSetForVariableSlots(slots, variables) {
1418
+ if (slots.length === 0) return null;
1419
+ const names = new Array(slots.length);
1420
+ for (let index = 0; index < slots.length; index++) names[index] = variables[slots[index]].name;
1421
+ return names.length <= 4 ? new SmallFreshVariableSet(names) : new Set(names);
1422
+ }
1423
+
1424
+ function instantiateClause(clause, id) {
1425
+ let plan = clause._instantiationPlan;
1426
+ if (plan == null) {
1427
+ const variableSlots = new Map();
1428
+ const variableNames = [];
1429
+ const compile = (term) => {
1430
+ if (term.type === VAR) {
1431
+ let slot = variableSlots.get(term.name);
1432
+ if (slot == null) {
1433
+ slot = variableNames.length;
1434
+ variableSlots.set(term.name, slot);
1435
+ variableNames.push(term.name);
1436
+ }
1437
+ return { variableSlot: slot };
1438
+ }
1439
+ return {
1440
+ type: term.type,
1441
+ name: term.name,
1442
+ module: term.module,
1443
+ args: term.args.map(compile),
1444
+ };
1445
+ };
1446
+ const localFreshPlan = clauseLocalFreshPlan(clause);
1447
+ const head = compile(clause.head);
1448
+ const body = clause.body.map(compile);
1449
+ const slotsFor = (names) => names.map((name) => variableSlots.get(name)).filter((slot) => slot != null);
1450
+ const bodyProofIndexes = [];
1451
+ for (let index = 0; index < clause.body.length; index++) {
1452
+ if (goalUsesFirstUseProof(clause.body[index])) bodyProofIndexes.push(index);
1453
+ }
1454
+ plan = clause._instantiationPlan = {
1455
+ head,
1456
+ body,
1457
+ variableNames,
1458
+ headLocalSlots: slotsFor(localFreshPlan.head),
1459
+ bodyLocalSlots: localFreshPlan.body.map(slotsFor),
1460
+ bodyProofIndexes,
1461
+ };
1462
+ }
1463
+
1464
+ const variables = new Array(plan.variableNames.length);
1465
+ for (let index = 0; index < variables.length; index++) {
1466
+ variables[index] = variable(`${plan.variableNames[index]}#${id}`);
1467
+ }
1468
+ const freshHead = instantiatePlanNode(plan.head, variables);
1469
+ const freshBody = new Array(plan.body.length);
1470
+ for (let index = 0; index < freshBody.length; index++) {
1471
+ freshBody[index] = instantiatePlanNode(plan.body[index], variables);
1472
+ }
1473
+ const headLocalFresh = freshSetForVariableSlots(plan.headLocalSlots, variables);
1474
+ for (let proofIndex = 0; proofIndex < plan.bodyProofIndexes.length; proofIndex++) {
1475
+ const index = plan.bodyProofIndexes[proofIndex];
1476
+ const goal = freshBody[index];
1477
+ const knownNonoccurringVariables = freshSetForVariableSlots(plan.bodyLocalSlots[index] ?? [], variables);
1478
+ if (knownNonoccurringVariables == null || goal?.type !== COMPOUND) continue;
1479
+ attachKnownNonoccurringVariables(goal, knownNonoccurringVariables);
1480
+ }
1481
+ return { freshHead, freshBody, headLocalFresh };
1482
+ }
1483
+
1484
+ function attachKnownNonoccurringVariables(term, knownNonoccurringVariables) {
1485
+ if (term?.type !== COMPOUND) return;
1486
+ if (term.name === '=' && term.arity === 2) {
1487
+ term._knownNonoccurringVariables = knownNonoccurringVariables;
1488
+ } else if ((term.name === 'get_atts' && term.arity === 2)
1489
+ || (term.name === 'get_attr' && term.arity === 3)
1490
+ || (term.name === '$get_attr_list' && term.arity === 2)
1491
+ || (term.name === '$get_from_attr_list' && term.arity === 3)) {
1492
+ term._firstUseVariables = knownNonoccurringVariables;
1493
+ }
1494
+ if ((term.name === ',' || term.name === ';' || term.name === '->') && term.arity === 2) {
1495
+ attachKnownNonoccurringVariables(term.args[0], knownNonoccurringVariables);
1496
+ attachKnownNonoccurringVariables(term.args[1], knownNonoccurringVariables);
1497
+ } else if (term.name === '\\+' && term.arity === 1) {
1498
+ attachKnownNonoccurringVariables(term.args[0], knownNonoccurringVariables);
1499
+ } else if (term.name === ':' && term.arity === 2) {
1500
+ attachKnownNonoccurringVariables(term.args[1], knownNonoccurringVariables);
1501
+ }
1502
+ }
1503
+
1344
1504
  function clauseLocalFreshPlan(clause) {
1345
1505
  if (clause._localFreshPlan != null) return clause._localFreshPlan;
1346
1506
  const seen = new Set();
@@ -2326,9 +2486,9 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
2326
2486
  const goal = goals[runLength];
2327
2487
  if (!goal || goal.kind === 'releaseActive' || goal.kind === 'memoStore' || goal.kind === 'continueGoals') break;
2328
2488
  if (goal.type !== COMPOUND) break;
2329
- const def = solver.registry.get(goal.name, goal.arity);
2489
+ const def = solver.lookupBuiltin(goal.name, goal.arity);
2330
2490
  if (def) break;
2331
- const group = solver.program.findGroup(goal.name, goal.arity, goal.module ?? 'user');
2491
+ const group = solver.lookupGroup(goal.name, goal.arity, goal.module ?? 'user');
2332
2492
  if (!group || group.tabled || !group.scalarFactsOnly) break;
2333
2493
  groups.push(group);
2334
2494
  runLength++;
package/src/term.js CHANGED
@@ -682,12 +682,10 @@ class SolverBindingStore {
682
682
  this.baseValues = [];
683
683
  this.slotLastTrail = [];
684
684
  this.freeSlots = [];
685
- this.trailNames = [];
686
685
  this.trailSlots = [];
687
686
  this.trailOldValues = [];
688
687
  this.trailNewValues = [];
689
688
  this.trailPreviousForSlot = [];
690
- this.trailCreated = [];
691
689
  if (bindings != null) {
692
690
  for (const [name, value] of bindings) {
693
691
  const slot = this.names.length;
@@ -709,15 +707,13 @@ class SolverBindingStore {
709
707
  throw new Error('stale solver binding mark');
710
708
  }
711
709
  while (this.trailSlots.length > mark) {
712
- const created = this.trailCreated.pop();
713
710
  const previousForSlot = this.trailPreviousForSlot.pop();
714
711
  this.trailNewValues.pop();
715
712
  const oldValue = this.trailOldValues.pop();
716
713
  const slot = this.trailSlots.pop();
717
- const name = this.trailNames.pop();
718
714
  this.slotLastTrail[slot] = previousForSlot;
719
- if (created) {
720
- this.slots.delete(name);
715
+ if (oldValue === ENV_UNBOUND) {
716
+ this.slots.delete(this.names[slot]);
721
717
  this.values[slot] = ENV_UNBOUND;
722
718
  this.baseValues[slot] = ENV_UNBOUND;
723
719
  this.names[slot] = null;
@@ -740,12 +736,10 @@ class SolverBindingStore {
740
736
  this.slotLastTrail[slot] = -1;
741
737
  }
742
738
  const entry = this.trailSlots.length;
743
- this.trailNames.push(name);
744
739
  this.trailSlots.push(slot);
745
740
  this.trailOldValues.push(this.values[slot]);
746
741
  this.trailNewValues.push(value);
747
742
  this.trailPreviousForSlot.push(this.slotLastTrail[slot] ?? -1);
748
- this.trailCreated.push(created);
749
743
  this.slotLastTrail[slot] = entry;
750
744
  this.values[slot] = value;
751
745
  }
@@ -862,7 +856,9 @@ class SolverEnv extends Env {
862
856
  }
863
857
 
864
858
  get(name) {
865
- this._activateBindings();
859
+ if (this._solverBindingMark !== this._solverBindings.trailSlots.length) {
860
+ this._solverBindings.rollback(this._solverBindingMark);
861
+ }
866
862
  const slot = this._solverBindings.slots.get(name);
867
863
  if (slot === undefined) return undefined;
868
864
  const value = this._solverBindings.values[slot];
@@ -878,9 +874,11 @@ class SolverEnv extends Env {
878
874
  }
879
875
 
880
876
  bind(name, term) {
881
- this._activateBindings();
877
+ if (this._solverBindingMark !== this._solverBindings.trailSlots.length) {
878
+ this._solverBindings.rollback(this._solverBindingMark);
879
+ }
882
880
  this._solverBindings.bind(name, term);
883
- this._solverBindingMark = this._solverBindings.mark();
881
+ this._solverBindingMark = this._solverBindings.trailSlots.length;
884
882
  }
885
883
 
886
884
  compactForDeepContinuation() {
@@ -933,6 +931,21 @@ function structuralVariableNames(term) {
933
931
  if (term?.type === VAR) return [term.name];
934
932
  if (term?.type !== COMPOUND) return EMPTY_ARGS;
935
933
 
934
+ if (!isCompactList(term) && term.arity === 2) {
935
+ const left = term.args[0];
936
+ const right = term.args[1];
937
+ const leftSimple = left?.type === VAR || isScalar(left);
938
+ const rightSimple = right?.type === VAR || isScalar(right);
939
+ if (leftSimple && rightSimple) {
940
+ if (left?.type === VAR) {
941
+ if (right?.type === VAR) return left.name === right.name ? [left.name] : [left.name, right.name];
942
+ return [left.name];
943
+ }
944
+ if (right?.type === VAR) return [right.name];
945
+ return EMPTY_ARGS;
946
+ }
947
+ }
948
+
936
949
  const cached = structuralVariableCache.get(term);
937
950
  if (cached !== undefined) return cached;
938
951
 
@@ -1013,12 +1026,17 @@ function occurs(variableName, term, env) {
1013
1026
  }
1014
1027
 
1015
1028
  const pending = initial.slice();
1016
- const seenVariables = new Set();
1029
+ const seenSmall = [];
1030
+ let seenLarge = null;
1017
1031
  for (let index = 0; index < pending.length; index++) {
1018
1032
  const name = pending[index];
1019
1033
  if (name === variableName) return true;
1020
- if (seenVariables.has(name)) continue;
1021
- seenVariables.add(name);
1034
+ let alreadySeen = false;
1035
+ if (seenLarge != null) alreadySeen = seenLarge.has(name);
1036
+ else for (let j = 0; j < seenSmall.length; j++) { if (seenSmall[j] === name) { alreadySeen = true; break; } }
1037
+ if (alreadySeen) continue;
1038
+ if (seenLarge != null) seenLarge.add(name);
1039
+ else { seenSmall.push(name); if (seenSmall.length === 8) seenLarge = new Set(seenSmall); }
1022
1040
  const binding = env?.get(name);
1023
1041
  if (binding === undefined) continue;
1024
1042
  const names = structuralVariableNames(binding);
@@ -1040,9 +1058,10 @@ export function unify(left, right, env, options = {}) {
1040
1058
  // construction fast paths share this internal proof; ordinary unification
1041
1059
  // remains fully occurs-checked.
1042
1060
  const knownNonoccurringVariables = options.knownNonoccurringVariables ?? null;
1043
- const stack = [[left, right]];
1061
+ const stack = [left, right];
1044
1062
  while (stack.length) {
1045
- let [a, b] = stack.pop();
1063
+ let b = stack.pop();
1064
+ let a = stack.pop();
1046
1065
  a = deref(a, env);
1047
1066
  b = deref(b, env);
1048
1067
 
@@ -1053,7 +1072,7 @@ export function unify(left, right, env, options = {}) {
1053
1072
  a = deref(a, env);
1054
1073
  b = deref(b, env);
1055
1074
  if (a.type !== VAR || b.type !== VAR || a.name === b.name) {
1056
- stack.push([a, b]);
1075
+ stack.push(a, b);
1057
1076
  continue;
1058
1077
  }
1059
1078
  }
@@ -1083,7 +1102,7 @@ export function unify(left, right, env, options = {}) {
1083
1102
  if (!env.preparePrologAttributeUnification(a, b)) return false;
1084
1103
  a = deref(a, env);
1085
1104
  b = deref(b, env);
1086
- if (a.type !== VAR) { stack.push([a, b]); continue; }
1105
+ if (a.type !== VAR) { stack.push(a, b); continue; }
1087
1106
  }
1088
1107
  markCompactVariableBound(a, b, env);
1089
1108
  env?.dropPrologAttributes?.(a.name);
@@ -1101,7 +1120,7 @@ export function unify(left, right, env, options = {}) {
1101
1120
  if (!env.preparePrologAttributeUnification(b, a)) return false;
1102
1121
  b = deref(b, env);
1103
1122
  a = deref(a, env);
1104
- if (b.type !== VAR) { stack.push([a, b]); continue; }
1123
+ if (b.type !== VAR) { stack.push(a, b); continue; }
1105
1124
  }
1106
1125
  markCompactVariableBound(b, a, env);
1107
1126
  env?.dropPrologAttributes?.(b.name);
@@ -1121,7 +1140,7 @@ export function unify(left, right, env, options = {}) {
1121
1140
 
1122
1141
  if (a.type === COMPOUND) {
1123
1142
  if (a.name !== b.name || a.arity !== b.arity) return false;
1124
- for (let i = a.arity - 1; i >= 0; i--) stack.push([a.args[i], b.args[i]]);
1143
+ for (let i = a.arity - 1; i >= 0; i--) stack.push(a.args[i], b.args[i]);
1125
1144
  continue;
1126
1145
  }
1127
1146
 
@@ -8,6 +8,16 @@
8
8
  ],
9
9
  "expectedSha256": "a0aa24fcbc87c394a4da789ac861b8e18cabad5e0d73b25ddc02df5dd4376c1b"
10
10
  },
11
+ {
12
+ "name": "classic-nrev",
13
+ "group": "recursion/lips",
14
+ "file": "examples/bench.pl",
15
+ "goals": [
16
+ "bench_result(X0)"
17
+ ],
18
+ "expectedSha256": "8045dc0730e4996a7b01fb2348311fb175ed53f4c49d14f82e01a3d470602255",
19
+ "logicalInferences": 496
20
+ },
11
21
  {
12
22
  "name": "clpz-sudoku",
13
23
  "group": "clpz",
@@ -69,6 +69,9 @@ for (const item of manifest) {
69
69
  if (!/^[0-9a-f]{64}$/.test(item.expectedSha256)) {
70
70
  throw new Error(`invalid expectedSha256 for benchmark ${item.name}`);
71
71
  }
72
+ if (item.logicalInferences != null && (!Number.isInteger(item.logicalInferences) || item.logicalInferences <= 0)) {
73
+ throw new Error(`invalid logicalInferences for benchmark ${item.name}`);
74
+ }
72
75
  }
73
76
 
74
77
  const selected = manifest.filter((item) => {
@@ -115,6 +118,16 @@ function formatMs(value) {
115
118
  return `${value.toFixed(1)} ms`;
116
119
  }
117
120
 
121
+ function lipsFor(logicalInferences, medianMs) {
122
+ if (!Number.isFinite(logicalInferences) || !Number.isFinite(medianMs) || medianMs <= 0) return null;
123
+ return logicalInferences * 1000 / medianMs;
124
+ }
125
+
126
+ function formatLips(value) {
127
+ if (value == null) return '—';
128
+ return Math.round(value).toLocaleString('en-US');
129
+ }
130
+
118
131
  function changePercent(medianMs, baselineMs) {
119
132
  if (baselineMs == null || baselineMs === 0) return null;
120
133
  return ((medianMs - baselineMs) / baselineMs) * 100;
@@ -213,6 +226,8 @@ for (const item of selected) {
213
226
  answerLines: worker.answerLines,
214
227
  outputBytes: worker.outputBytes,
215
228
  sha256: worker.digest,
229
+ logicalInferences: item.logicalInferences ?? null,
230
+ lips: lipsFor(item.logicalInferences, medianMs),
216
231
  baselineMs: baselineItem?.medianMs ?? null,
217
232
  changePercent: changePercent(medianMs, baselineItem?.medianMs ?? null),
218
233
  });
@@ -250,10 +265,11 @@ if (options.json) {
250
265
  results,
251
266
  }, null, 2)}\n`);
252
267
  } else {
253
- const headers = ['Benchmark', 'Median/op', 'Range/op', 'Batch', 'Baseline', 'Change', 'Answers'];
268
+ const headers = ['Benchmark', 'Median/op', 'LIPS', 'Range/op', 'Batch', 'Baseline', 'Change', 'Answers'];
254
269
  const rows = results.map((item) => [
255
270
  item.name,
256
271
  formatMs(item.medianMs),
272
+ formatLips(item.lips),
257
273
  `${formatMs(item.minMs)}–${formatMs(item.maxMs)}`,
258
274
  String(item.batchSize),
259
275
  formatMs(item.baselineMs),
@@ -283,6 +299,7 @@ if (options.json) {
283
299
  if (baselinePath == null && !baselineWarning) process.stdout.write('No timing baseline found; run npm run benchmark:baseline to create .benchmarks/baseline.json.\n');
284
300
  if (options.save != null) process.stdout.write(`Saved timing baseline: ${path.relative(root, options.save)}\n`);
285
301
  process.stdout.write('Change compares the current median/op directly with the saved baseline median/op; the measured range is shown separately.\n');
302
+ process.stdout.write('The classic-nrev LIPS column is a quick wall-clock estimate. Use npm run benchmark:lips for the Quintus-style dummy-subtracted CPU measurement.\n');
286
303
  if (summary.comparable > 0) {
287
304
  process.stdout.write('Suite score is the geometric mean of current/baseline ratios, so every benchmark has equal relative weight; Time-weighted total compares summed medians and is dominated by longer workloads.\n');
288
305
  }
@@ -254,7 +254,7 @@ not interpret the following as core-language features:
254
254
  - Part 3 grammar-rule expansion and `phrase/2-3`;
255
255
  - explicit tabling, EyeProlog well-founded evaluation via `tnot/1` and
256
256
  `wfs_truth/2`, WFS runtime statistics, and recursive numeric execution shortcuts;
257
- - normal-profile integer digit separators and Trealla-compatible
257
+ - normal-profile integer digit separators (the accepted WG17 single-underscore/layout form documented at <https://www.complang.tuwien.ac.at/ulrich/iso-prolog/digit_separators>) and Trealla-compatible
258
258
  `"text"||Tail` double-quoted-list splicing;
259
259
  - the JavaScript API `stringTerm(Text)` additional term type (normal mode only).
260
260
 
@@ -0,0 +1,68 @@
1
+ # ISO/WG17 compatibility extensions
2
+
3
+ EyeProlog separates its strict ISO core from carefully selected compatibility
4
+ extensions. `isoStrict: true` targets ISO/IEC 13211-1:1995 plus Corrigenda 1-3;
5
+ newer or non-core features belong in the normal profile until they are part of
6
+ the claimed strict baseline.
7
+
8
+ ## Selection rule
9
+
10
+ A proposed extension is a good candidate when it has all of these properties:
11
+
12
+ 1. it solves a concrete portability or usability problem;
13
+ 2. its syntax/semantics are precise enough to test independently;
14
+ 3. it has clear WG17 momentum, a published ISO technical specification, or
15
+ established interoperable implementation practice; and
16
+ 4. it does not silently change strict ISO behavior.
17
+
18
+ This intentionally excludes adopting every discussion item on the ISO Prolog
19
+ working-notes pages. Draft/editorial proposals can change, and controversial
20
+ changes are more valuable as tracked tests/documentation than as premature
21
+ language behavior.
22
+
23
+ ## Implemented high-value items
24
+
25
+ - **Digit separators (normal profile).** Decimal, binary, octal, and hexadecimal
26
+ integer constants accept a single underscore between digit groups. Layout,
27
+ line comments, and block comments may follow the underscore before the next
28
+ digit, matching the accepted WG17 options described at
29
+ <https://www.complang.tuwien.ac.at/ulrich/iso-prolog/digit_separators>.
30
+ Consecutive/trailing separators and separators inside floats/exponents are
31
+ rejected. Strict ISO mode rejects the extension.
32
+ - **Corrigenda 1-3.** The strict suite explicitly covers the published
33
+ corrections, including `variable_names/1` write behavior.
34
+ - **Cleanup.** `setup_call_cleanup/3` and `call_cleanup/2` are available in the
35
+ normal compatibility/library surface and are regression tested.
36
+ - **DCGs.** EyeProlog supports grammar-rule expansion and `phrase/2-3` in the
37
+ normal profile, aligned with ISO/IEC TS 13211-3:2025. These remain outside
38
+ the Part 1 strict-core claim.
39
+ - **Prologue/library portability.** The normal profile includes a portable
40
+ standard-library surface and autoloading rather than treating those
41
+ predicates as Part 1 core built-ins.
42
+
43
+ ## What should be considered next?
44
+
45
+ The best next work is evidence-driven rather than feature-count driven:
46
+
47
+ - keep running the public WG17 syntax/conformance corpora and close concrete
48
+ interoperability gaps they expose;
49
+ - prefer published specifications (for example additional fully testable parts
50
+ of the DCG TS) over draft syntax experiments;
51
+ - evaluate Prolog-prologue predicates when they materially improve source
52
+ portability and can be implemented with compatible errors/modes;
53
+ - keep module conformance as a separate Part 2 effort instead of mixing it into
54
+ the Part 1 strict claim; and
55
+ - track draft STC/WG17 proposals in tests/status documents until their intended
56
+ behavior is stable enough to adopt explicitly.
57
+
58
+ In particular, the digit-separator page currently leaves float separator forms
59
+ such as `1_1.25`, `11.2_5`, and `1.0e1_0` unresolved. EyeProlog therefore does
60
+ **not** generalize integer separators into float syntax.
61
+
62
+ ## Regression expectations
63
+
64
+ Every accepted compatibility extension should have tests for positive syntax,
65
+ negative/boundary syntax, stream or conversion entry points where applicable,
66
+ and an explicit strict-mode rejection when the feature is not in the strict
67
+ baseline. This keeps the normal profile useful without weakening the meaning of
68
+ `isoStrict: true`.
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { performance } from 'node:perf_hooks';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { Program, parseGoalText, run } from '../index.js';
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+
10
+ function integer(value, name, minimum) {
11
+ const n = Number(value);
12
+ if (!Number.isInteger(n) || n < minimum) throw new Error(`${name} must be an integer >= ${minimum}`);
13
+ return n;
14
+ }
15
+
16
+ const options = { count: 50, runs: 5, warmup: 2, json: false };
17
+ for (let i = 2; i < process.argv.length; i++) {
18
+ const arg = process.argv[i];
19
+ if (arg === '--count') options.count = integer(process.argv[++i], '--count', 1);
20
+ else if (arg === '--runs') options.runs = integer(process.argv[++i], '--runs', 1);
21
+ else if (arg === '--warmup') options.warmup = integer(process.argv[++i], '--warmup', 0);
22
+ else if (arg === '--json') options.json = true;
23
+ else if (arg === '-h' || arg === '--help') {
24
+ process.stdout.write('Usage: node test/lips-benchmark.mjs [--count N] [--runs N] [--warmup N] [--json]\n');
25
+ process.exit(0);
26
+ } else throw new Error(`unknown option: ${arg}`);
27
+ }
28
+
29
+ const source = await fs.readFile(path.join(root, 'examples', 'bench.pl'), 'utf8');
30
+ const program = Program.parse(source);
31
+ const dobench = parseGoalText(`dobench(${options.count})`);
32
+ const dodummy = parseGoalText(`dodummy(${options.count})`);
33
+
34
+ function execute(goal) {
35
+ const cpuStart = process.cpuUsage();
36
+ const wallStart = performance.now();
37
+ const result = run(program, { goals: [goal] });
38
+ const wallMs = performance.now() - wallStart;
39
+ const cpu = process.cpuUsage(cpuStart);
40
+ if (result.haltCode != null) throw new Error(`benchmark halted with code ${result.haltCode}`);
41
+ if (!result.stdout) throw new Error('benchmark goal produced no success answer');
42
+ return { cpuMs: (cpu.user + cpu.system) / 1000, wallMs };
43
+ }
44
+
45
+ for (let i = 0; i < options.warmup; i++) {
46
+ execute(dodummy);
47
+ execute(dobench);
48
+ }
49
+
50
+ const samples = [];
51
+ for (let i = 0; i < options.runs; i++) {
52
+ const dummy = execute(dodummy);
53
+ const bench = execute(dobench);
54
+ const netCpuMs = bench.cpuMs - dummy.cpuMs;
55
+ const netWallMs = bench.wallMs - dummy.wallMs;
56
+ if (netCpuMs <= 0) throw new Error(`non-positive control-subtracted CPU time (${netCpuMs.toFixed(3)} ms); increase --count`);
57
+ samples.push({
58
+ dummyCpuMs: dummy.cpuMs,
59
+ benchCpuMs: bench.cpuMs,
60
+ netCpuMs,
61
+ netWallMs,
62
+ lips: 496 * options.count * 1000 / netCpuMs,
63
+ wallLips: netWallMs > 0 ? 496 * options.count * 1000 / netWallMs : null,
64
+ });
65
+ }
66
+
67
+ function median(values) {
68
+ const sorted = [...values].sort((a, b) => a - b);
69
+ const middle = Math.floor(sorted.length / 2);
70
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
71
+ }
72
+
73
+ const result = {
74
+ methodology: 'Quintus 1984 naive reverse; dobench minus dodummy; 496 calls/reversal',
75
+ count: options.count,
76
+ runs: options.runs,
77
+ warmup: options.warmup,
78
+ lips: median(samples.map((sample) => sample.lips)),
79
+ wallLips: median(samples.map((sample) => sample.wallLips).filter(Number.isFinite)),
80
+ netCpuMs: median(samples.map((sample) => sample.netCpuMs)),
81
+ samples,
82
+ };
83
+
84
+ if (options.json) {
85
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
86
+ } else {
87
+ process.stdout.write(`EyeProlog classic nrev: ${Math.round(result.lips).toLocaleString('en-US')} LIPS\n`);
88
+ process.stdout.write(` ${options.count} reversals/run, ${options.runs} measured runs, ${options.warmup} warmups\n`);
89
+ process.stdout.write(` median control-subtracted CPU: ${result.netCpuMs.toFixed(1)} ms\n`);
90
+ if (Number.isFinite(result.wallLips)) {
91
+ process.stdout.write(` wall-clock cross-check: ${Math.round(result.wallLips).toLocaleString('en-US')} LIPS\n`);
92
+ }
93
+ }
@@ -42,8 +42,10 @@ function runWorker(item) {
42
42
  ]);
43
43
  }
44
44
 
45
- ok(Array.isArray(manifest) && manifest.length === 20, 'benchmark manifest should contain exactly 20 representative workloads');
45
+ ok(Array.isArray(manifest) && manifest.length === 21, 'benchmark manifest should contain exactly 21 representative workloads');
46
46
  ok(new Set(manifest.map((item) => item.name)).size === manifest.length, 'benchmark names should be unique');
47
+ const classicNrev = manifest.find((item) => item.name === 'classic-nrev');
48
+ ok(classicNrev?.logicalInferences === 496, 'classic nrev should retain the traditional 496-call LIPS accounting');
47
49
 
48
50
  for (const item of manifest) {
49
51
  ok(/^[0-9a-f]{64}$/.test(item.expectedSha256), `${item.name} should have a committed semantic checksum`);
@@ -70,4 +72,28 @@ ok(adaptive.results[0].batchSize > 1, 'adaptive benchmark smoke test should batc
70
72
  ok(adaptive.results[0].sha256 === manifest.find((item) => item.name === 'dcg-command').expectedSha256,
71
73
  'adaptive batching should preserve the semantic checksum');
72
74
 
75
+ const nrev = await spawnJson([
76
+ path.join(root, 'test', 'benchmark.mjs'),
77
+ '--filter', 'classic-nrev',
78
+ '--runs', '1',
79
+ '--warmup', '0',
80
+ '--target-ms', '0',
81
+ '--json',
82
+ ]);
83
+ ok(nrev.results.length === 1, 'classic nrev benchmark should be selectable');
84
+ ok(nrev.results[0].logicalInferences === 496, 'classic nrev JSON should expose its logical-inference count');
85
+ ok(Number.isFinite(nrev.results[0].lips) && nrev.results[0].lips > 0, 'classic nrev JSON should report positive LIPS');
86
+
87
+ const classicLips = await spawnJson([
88
+ path.join(root, 'test', 'lips-benchmark.mjs'),
89
+ '--count', '20',
90
+ '--runs', '1',
91
+ '--warmup', '1',
92
+ '--json',
93
+ ]);
94
+ ok(classicLips.count === 20, 'classic LIPS harness should honor the requested reversal count');
95
+ ok(classicLips.methodology.includes('496 calls/reversal'), 'classic LIPS harness should identify the historical accounting');
96
+ ok(Number.isFinite(classicLips.lips) && classicLips.lips > 0, 'classic LIPS harness should report positive CPU LIPS');
97
+ ok(Number.isFinite(classicLips.netCpuMs) && classicLips.netCpuMs > 0, 'classic LIPS harness should subtract positive CPU time');
98
+
73
99
  process.stdout.write(`Benchmark harness tests: ${passed}/${passed} passed.\n`);
@@ -7330,11 +7330,19 @@ function whiteBoxCases() {
7330
7330
  'program radix literal');
7331
7331
  assertEqual(parseNumberTokenText('1_ /* group */ 000').name, '1000',
7332
7332
  'number-token conversion with layout');
7333
+ assertEqual(parseNumberTokenText('0b1010_0101').name, '165', 'binary number-token conversion');
7334
+ assertEqual(parseNumberTokenText('0o7_% group\n7').name, '63', 'octal number-token conversion with line comment');
7335
+ assertEqual(parseNumberTokenText('0xCA_/* group */FE').name, '51966', 'hex number-token conversion with block comment');
7333
7336
  assertEqual(
7334
7337
  run('', { goal: 'number_chars(N,"1_000")' }).stdout,
7335
7338
  'number_chars(1000, "1_000").\n',
7336
7339
  'normal number_chars input',
7337
7340
  );
7341
+ assertEqual(
7342
+ run('', { goal: 'number_codes(N,[48,120,67,65,95,70,69])' }).stdout,
7343
+ 'number_codes(51966, [48, 120, 67, 65, 95, 70, 69]).\n',
7344
+ 'normal number_codes radix input',
7345
+ );
7338
7346
  assertEqual(
7339
7347
  run('', { goal: 'read_term(N, [])', ioOptions: { input: '1_ /* group */ 000. ' } }).stdout,
7340
7348
  'read_term(1000, []).\n',
@@ -9743,7 +9743,7 @@ Review questions:
9743
9743
  </figure>
9744
9744
 
9745
9745
  The [examples directory](https://github.com/eyereasoner/eyeprolog/tree/main/examples/) is the book's executable companion. The
9746
- top-level directory contains **224 self-contained runnable programs**. Every
9746
+ top-level directory contains **225 self-contained runnable programs**. Every
9747
9747
  source program has an exact answer file under
9748
9748
  [examples/output](https://github.com/eyereasoner/eyeprolog/tree/main/examples/output/), and **61 selected programs** have a checked
9749
9749
  explanation under [examples/proof](https://github.com/eyereasoner/eyeprolog/tree/main/examples/proof/). The thematic lists link every top-level program and open the program
@@ -10173,9 +10173,10 @@ and fast:
10173
10173
  ```sh
10174
10174
  npm run benchmark
10175
10175
  npm run benchmark:baseline
10176
+ npm run benchmark:lips
10176
10177
  ```
10177
10178
 
10178
- The benchmark suite contains 20 representative workloads and stores their
10179
+ The benchmark suite contains 21 representative workloads and stores their
10179
10180
  semantic output digests in the repository, while wall-clock baselines remain
10180
10181
  machine-local under `.benchmarks/` because absolute timings are machine-specific.
10181
10182
  Each benchmark runs in its own fresh Node worker. Inside that worker, one untimed
@@ -10184,6 +10185,15 @@ with independent `run()` calls until a batch is roughly 400 ms long. After one
10184
10185
  warm-up batch, five measured batches are reported as milliseconds per workload
10185
10186
  execution. Naturally long workloads keep a batch size of one.
10186
10187
 
10188
+ The classic `examples/bench.pl` workload also has a dedicated LIPS harness.
10189
+ `npm run benchmark:lips` follows the 1984 Quintus method more closely than the
10190
+ generic runner: `dobench/1` and `dodummy/1` execute failure-driven loops inside
10191
+ Prolog, the dummy CPU time is subtracted, and the remaining time is converted
10192
+ using 496 procedure calls for one reversal of the 30-element list. Node's
10193
+ process CPU clock is used for the primary figure, with wall-clock LIPS printed
10194
+ as a cross-check. Because LIPS measures a deliberately small recursive kernel,
10195
+ it is useful for engine tuning but is not a complete application benchmark.
10196
+
10187
10197
  The report shows the median, per-operation range, chosen batch size, saved
10188
10198
  baseline median, and the percentage change between the current median and baseline
10189
10199
  median. The range remains visible as context, but it does not suppress or reinterpret