eyeprolog 1.5.31 → 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.31",
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/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
@@ -295,6 +299,37 @@ export class Solver {
295
299
  }
296
300
  }
297
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
+
298
333
  syncProgramRevision() {
299
334
  if (!this.mutableProgram) {
300
335
  if (this.program.mutable !== true) return;
@@ -303,6 +338,9 @@ export class Solver {
303
338
  const revision = this.program.revision ?? 0;
304
339
  if (revision === this.programRevision) return;
305
340
  this.programRevision = revision;
341
+ this.userGroupCache = Object.create(null);
342
+ this.moduleGroupCache.clear();
343
+ this.builtinCache = Object.create(null);
306
344
  this.memo.clear();
307
345
  this.subsumptiveMemo.clear();
308
346
  this.wfsModels.clear();
@@ -512,13 +550,7 @@ export class Solver {
512
550
  });
513
551
  continue;
514
552
  }
515
- const id = nextFreshId();
516
- const freshVariables = new Map();
517
- const freshHead = freshTerm(clause.head, id, freshVariables);
518
- const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
519
- const localFreshPlan = clauseLocalFreshPlan(clause);
520
- const headLocalFresh = freshVariableSet(localFreshPlan.head, freshVariables);
521
- attachBodyLocalFreshVariables(freshBody, localFreshPlan.body, freshVariables);
553
+ const { freshHead, freshBody, headLocalFresh } = instantiateClause(clause, nextFreshId());
522
554
  next = clauseEnv.clone();
523
555
  this.stats.unify_calls++;
524
556
  if (!unify(clauseGoal, freshHead, next, { knownNonoccurringVariables: headLocalFresh })) continue;
@@ -654,7 +686,7 @@ export class Solver {
654
686
  // EyeProlog normally solves left-to-right, but ready deterministic builtins can
655
687
  // be run early as pure filters. Stop at internal sentinels so rule-body
656
688
  // active guards are released before the caller's remaining goals are seen.
657
- const selectedIndex = selectReadyDeterministicBuiltin(goals, env, this.registry);
689
+ const selectedIndex = 0;
658
690
  const goal = deref(goals[selectedIndex], env);
659
691
  const rest = selectedIndex === 0 ? goals.slice(1) : [...goals.slice(0, selectedIndex), ...goals.slice(selectedIndex + 1)];
660
692
  prepareLocalVariablesForGoal(goal, env);
@@ -706,7 +738,7 @@ export class Solver {
706
738
  }
707
739
  }
708
740
 
709
- const def = callable ? this.registry.get(goal.name, goal.arity) : null;
741
+ const def = callable ? this.lookupBuiltin(goal.name, goal.arity) : null;
710
742
  this.active = active;
711
743
  const builtinReady = def && builtinIsReadyOrAuthoritative(def, this, goal, env);
712
744
  if (builtinReady && typeof def.expandGoal === 'function') {
@@ -740,7 +772,7 @@ export class Solver {
740
772
  }
741
773
 
742
774
  this.stats.solve_one_goal_calls++;
743
- const group = this.program.findGroup(goal.name, goal.arity, goal.module ?? 'user');
775
+ const group = this.lookupGroup(goal.name, goal.arity, goal.module ?? 'user');
744
776
  if (!group) {
745
777
  if (goal.name === '-->' && goal.arity === 2) {
746
778
  throw new PrologError(
@@ -870,6 +902,35 @@ export class Solver {
870
902
  }
871
903
 
872
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
+ }
873
934
  pushUserGoalUncachedFrames(stack, this, group, goal, rest, env, depth, active);
874
935
  break;
875
936
  }
@@ -1110,13 +1171,7 @@ export class Solver {
1110
1171
  continue;
1111
1172
  }
1112
1173
  if (headCannotMatch(goal, clause.head, env)) continue;
1113
- const id = nextFreshId();
1114
- const freshVariables = new Map();
1115
- const freshHead = freshTerm(clause.head, id, freshVariables);
1116
- const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
1117
- const localFreshPlan = clauseLocalFreshPlan(clause);
1118
- const headLocalFresh = freshVariableSet(localFreshPlan.head, freshVariables);
1119
- attachBodyLocalFreshVariables(freshBody, localFreshPlan.body, freshVariables);
1174
+ const { freshHead, freshBody, headLocalFresh } = instantiateClause(clause, nextFreshId());
1120
1175
  const next = env.clone();
1121
1176
  this.stats.unify_calls++;
1122
1177
  if (!unify(goal, freshHead, next, { knownNonoccurringVariables: headLocalFresh })) continue;
@@ -1301,16 +1356,15 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1301
1356
  if (tryPushGroundScalarRuleFrame(stack, solver, group, goal, rest, env, depth, active)) return;
1302
1357
  if (tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth, active)) return;
1303
1358
  const candidates = selectClauseCandidates(group, goal, env);
1359
+ const candidateCount = clauseCandidateLength(candidates.primary) + clauseCandidateLength(candidates.fallback);
1304
1360
  const frames = [];
1305
- const invocation = { goal, env: env._snapshotForSolverRead(), tableMapKey };
1306
1361
  // Active frames serve two purposes: they delimit cut and detect variants in
1307
- // recursive user predicates. Cut-free, non-recursive library helpers need
1308
- // neither. Copying their full active stack at every recursive step made
1309
- // 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.
1310
1364
  for (const pass of [candidates.primary, candidates.fallback]) {
1311
1365
  for (let candidateIndex = 0; candidateIndex < clauseCandidateLength(pass); candidateIndex++) {
1312
1366
  const clause = clauseCandidateAt(pass, candidateIndex);
1313
- if (headCannotMatch(goal, clause.head, env)) continue;
1367
+ if (candidateCount > 1 && headCannotMatch(goal, clause.head, env)) continue;
1314
1368
  // Do not speculatively unify alternative heads. With attributed variables,
1315
1369
  // head unification can run verify_attributes/3, queue wakeups, fail, or
1316
1370
  // throw. Those effects are observable and must occur only if Prolog search
@@ -1331,10 +1385,12 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1331
1385
  // recursive clause; do not copy the whole active path at every such layer.
1332
1386
  const traditionalDepthFirst = !group.tabled;
1333
1387
  const guarded = traditionalDepthFirst
1334
- ? frames.some((frame) => clauseHasCutForTailRelease(frame.clause))
1388
+ ? (group.hasCut === true && frames.some((frame) => clauseHasCutForTailRelease(frame.clause)))
1335
1389
  : groupNeedsActiveFrame(group);
1336
1390
  const release = guarded ? [{ kind: 'releaseActive' }] : [];
1337
- const nextActive = guarded ? [...active, invocation] : active;
1391
+ const nextActive = guarded
1392
+ ? [...active, { goal, env: env._snapshotForSolverRead(), tableMapKey }]
1393
+ : active;
1338
1394
  for (const frame of frames) {
1339
1395
  frame.active = nextActive;
1340
1396
  frame.release = release;
@@ -1347,6 +1403,104 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
1347
1403
  }
1348
1404
 
1349
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
+
1350
1504
  function clauseLocalFreshPlan(clause) {
1351
1505
  if (clause._localFreshPlan != null) return clause._localFreshPlan;
1352
1506
  const seen = new Set();
@@ -2332,9 +2486,9 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
2332
2486
  const goal = goals[runLength];
2333
2487
  if (!goal || goal.kind === 'releaseActive' || goal.kind === 'memoStore' || goal.kind === 'continueGoals') break;
2334
2488
  if (goal.type !== COMPOUND) break;
2335
- const def = solver.registry.get(goal.name, goal.arity);
2489
+ const def = solver.lookupBuiltin(goal.name, goal.arity);
2336
2490
  if (def) break;
2337
- const group = solver.program.findGroup(goal.name, goal.arity, goal.module ?? 'user');
2491
+ const group = solver.lookupGroup(goal.name, goal.arity, goal.module ?? 'user');
2338
2492
  if (!group || group.tabled || !group.scalarFactsOnly) break;
2339
2493
  groups.push(group);
2340
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