eyeprolog 1.6.28 → 1.6.30

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/index.d.ts CHANGED
@@ -214,6 +214,7 @@ export type BuiltinHandler =
214
214
  export class BuiltinRegistry {
215
215
  constructor();
216
216
  defs: Map<string, BuiltinDefinition>;
217
+ byName: Map<string, Map<number, BuiltinDefinition>>;
217
218
  eyePrologLibrary?: boolean;
218
219
  add(name: string, arity: number, handler: BuiltinHandler, options?: Partial<BuiltinDefinition>): this;
219
220
  get(name: string, arity: number): BuiltinDefinition | null;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.6.28",
6
+ "version": "1.6.30",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -39,6 +39,10 @@ Verification walks the supplied certificate and checks source-clause steps again
39
39
  the parsed `Program`; it does not call the solver to rediscover the proof.
40
40
  Built-in and abstract library nodes are explicit trust boundaries, while expanded
41
41
  proofs expose bundled Prolog-library clauses as ordinary source steps.
42
+ Proof nodes share the runtime terms they resolve. Runtime terms are structurally
43
+ immutable, so a ground subtree already is the snapshot a proof needs, and
44
+ `resolveForProof` copies only the ancestors of an argument that variable
45
+ resolution actually changed. Nothing anywhere may rewrite a term in place.
42
46
 
43
47
  `iso.js` and `program.js` remain facade modules for their existing exports, so
44
48
  this refactor does not change the public JavaScript API.
@@ -116,6 +120,16 @@ construct native propagation/search plans while their Prolog modules retain
116
120
  validation, residual constraints, and portable fallbacks. A cleaner file layout
117
121
  is not worth a runtime regression, so such splits must retain benchmark parity.
118
122
 
123
+ Hot paths follow one allocation discipline: the state a walk or a comparison
124
+ needs only for its hard case -- a cycle guard, a traversal stack, a variable
125
+ pairing -- is allocated when that case actually appears rather than on entry.
126
+ `deref`, `variantTerms`, and `goalHeadTermsCannotMatch` therefore decide the
127
+ shallow, acyclic, scalar-argument case, which is the overwhelming majority of
128
+ their calls, without allocating anything. A key a table or an index derives
129
+ from a term is built once into one fragment array for the whole term: a key
130
+ composed from a key per subterm makes recording one answer quadratic in that
131
+ answer's own size.
132
+
119
133
  Performance claims use `npm test`'s own elapsed time across the full corpus,
120
134
  not predicate, inference, or host-call counts as a substitute for elapsed
121
135
  time, and not a separate wall-clock benchmark harness.
package/src/explain.js CHANGED
@@ -131,12 +131,15 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
131
131
  const id = nextFreshId();
132
132
  const freshVariables = new Map();
133
133
  const freshHead = freshTerm(clause.head, id, freshVariables);
134
- const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
135
134
  const next = env.clone();
136
135
  if (!unify(goal, freshHead, next)) continue;
137
136
 
137
+ // Rename the body only once the head has matched. The shared variable
138
+ // map carries the head's renamings, so a body renamed now is the same
139
+ // body renaming as before -- a clause rejected on its head simply no
140
+ // longer pays for one.
141
+ const freshBody = clause.body.map((term) => freshTerm(term, id, freshVariables));
138
142
  const substitutions = collectClauseSubstitutions(clause, freshHead, freshBody);
139
- const bindings = resolvedSubstitutions(substitutions, next);
140
143
 
141
144
  if (freshBody.length === 0) {
142
145
  yield {
@@ -146,7 +149,10 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, de
146
149
  method: sourceMethod(clause, 'fact'),
147
150
  sourceHead: clause.head,
148
151
  sourceBody: [],
149
- bindings,
152
+ // A fact binds everything it is going to bind at head unification.
153
+ // A rule's substitutions are only final once its body is proved,
154
+ // so that branch resolves them against the proving environment.
155
+ bindings: resolvedSubstitutions(substitutions, next),
150
156
  children: [],
151
157
  },
152
158
  };
@@ -254,7 +260,17 @@ function builtinChildren(program, goal, env, depth, maxDepth, registry, active,
254
260
  }
255
261
 
256
262
  function activeVariant(goal, env, active) {
257
- return active.some((entry) => variantTerms(goal, env, entry.goal, entry.env));
263
+ // Only a goal for the same predicate can be a variant of this one, so reject
264
+ // the rest on their functor instead of on a structural comparison. Scan from
265
+ // the innermost entry outwards: a replay that is about to cycle repeats its
266
+ // nearest ancestor, not the goal the whole replay started from.
267
+ for (let index = active.length - 1; index >= 0; index--) {
268
+ const entry = active[index];
269
+ const candidate = entry.goal;
270
+ if (candidate.type !== goal.type || candidate.name !== goal.name || candidate.arity !== goal.arity) continue;
271
+ if (variantTerms(goal, env, candidate, entry.env)) return true;
272
+ }
273
+ return false;
258
274
  }
259
275
 
260
276
  function sourceMethod(clause, kind) {
package/src/iso.js CHANGED
@@ -3302,10 +3302,15 @@ import { isBuiltin, arithmeticComparison } from './iso-arithmetic.js';
3302
3302
  export class BuiltinRegistry {
3303
3303
  constructor() {
3304
3304
  this.defs = new Map();
3305
+ // `defs` stays keyed by the printable predicate indicator because callers
3306
+ // enumerate the catalog from it. Lookups, though, happen at least once per
3307
+ // goal on the solver's hot path, so they go through a name/arity index
3308
+ // rather than formatting -- and allocating -- an indicator string each time.
3309
+ this.byName = new Map();
3305
3310
  }
3306
3311
 
3307
3312
  add(name, arity, handler, options = {}) {
3308
- this.defs.set(`${name}/${arity}`, {
3313
+ const def = {
3309
3314
  name,
3310
3315
  arity,
3311
3316
  handler,
@@ -3317,16 +3322,25 @@ export class BuiltinRegistry {
3317
3322
  fallbackWhenNotReady: options.fallbackWhenNotReady ?? false,
3318
3323
  shouldUse: options.shouldUse ?? null,
3319
3324
  eyePrologLibrary: options.eyePrologLibrary ?? false,
3320
- });
3325
+ };
3326
+ this.defs.set(`${name}/${arity}`, def);
3327
+ let byArity = this.byName.get(name);
3328
+ if (byArity == null) this.byName.set(name, byArity = new Map());
3329
+ byArity.set(arity, def);
3321
3330
  return this;
3322
3331
  }
3323
3332
 
3324
3333
  get(name, arity) {
3325
- return this.defs.get(`${name}/${arity}`) ?? null;
3334
+ return this.byName.get(name)?.get(arity) ?? null;
3326
3335
  }
3327
3336
 
3328
3337
  remove(name, arity) {
3329
3338
  this.defs.delete(`${name}/${arity}`);
3339
+ const byArity = this.byName.get(name);
3340
+ if (byArity != null) {
3341
+ byArity.delete(arity);
3342
+ if (byArity.size === 0) this.byName.delete(name);
3343
+ }
3330
3344
  return this;
3331
3345
  }
3332
3346
  }
package/src/solver.js CHANGED
@@ -3223,13 +3223,13 @@ function rememberGroundChainSuccess(solver, seen) {
3223
3223
 
3224
3224
  function rememberMemoAnswer(entry, goal, env) {
3225
3225
  const variables = new Map();
3226
- const answerKeys = [];
3227
- const answerArgs = goal.args.map((arg) => {
3228
- const answer = copyResolvedWithKey(arg, env, variables);
3229
- answerKeys.push(answer.key);
3230
- return answer.term;
3231
- });
3232
- const key = answerKeys.join('\x1f');
3226
+ const keyParts = [];
3227
+ const answerArgs = new Array(goal.args.length);
3228
+ for (let position = 0; position < goal.args.length; position++) {
3229
+ if (position > 0) keyParts.push('\x1f');
3230
+ answerArgs[position] = copyResolvedWithKey(goal.args[position], env, variables, keyParts);
3231
+ }
3232
+ const key = keyParts.join('');
3233
3233
  if (entry.answerKeys.has(key)) return;
3234
3234
  entry.answerKeys.add(key);
3235
3235
  const answerIndex = entry.answers.length;
@@ -3373,7 +3373,13 @@ function canonicalTermKey(term, env, variables) {
3373
3373
  return key.join('');
3374
3374
  }
3375
3375
 
3376
- function copyResolvedWithKey(term, env, variables) {
3376
+ // Copies one answer argument and appends its variant key to `keyParts`.
3377
+ // Writing fragments into one shared array keeps a tabled answer linear in its
3378
+ // own size: returning a key per subterm instead made every enclosing term
3379
+ // rebuild the key of everything below it, so a predicate tabling long lists --
3380
+ // a Collatz trajectory, a transitive closure path -- paid quadratic string
3381
+ // building for each answer it recorded.
3382
+ function copyResolvedWithKey(term, env, variables, keyParts) {
3377
3383
  const value = derefForLocal(term, env);
3378
3384
  if (value.type === 'var') {
3379
3385
  let id = variables.get(value.name);
@@ -3381,19 +3387,24 @@ function copyResolvedWithKey(term, env, variables) {
3381
3387
  id = variables.size;
3382
3388
  variables.set(value.name, id);
3383
3389
  }
3384
- return { term: termModuleCache.variable(value.name), key: `var:${id}` };
3390
+ keyParts.push('var:', id);
3391
+ return termModuleCache.variable(value.name);
3385
3392
  }
3386
3393
  if (!value.args?.length) {
3387
3394
  // Atomic terms are immutable in the solver. Share them across table
3388
3395
  // answers instead of allocating a fresh host object for every cell of a
3389
3396
  // large closure such as tc/2.
3390
- return { term: value, key: `${value.type}:${value.name}` };
3397
+ keyParts.push(value.type, ':', value.name);
3398
+ return value;
3391
3399
  }
3392
- const children = value.args.map((arg) => copyResolvedWithKey(arg, env, variables));
3393
- return {
3394
- term: termModuleCache.compound(value.name, children.map((child) => child.term)),
3395
- key: `${value.type}:${value.name}(${children.map((child) => child.key).join(',')})`,
3396
- };
3400
+ keyParts.push(value.type, ':', value.name, '(');
3401
+ const children = new Array(value.args.length);
3402
+ for (let index = 0; index < value.args.length; index++) {
3403
+ if (index > 0) keyParts.push(',');
3404
+ children[index] = copyResolvedWithKey(value.args[index], env, variables, keyParts);
3405
+ }
3406
+ keyParts.push(')');
3407
+ return termModuleCache.compound(value.name, children);
3397
3408
  }
3398
3409
 
3399
3410
  // Avoid circular import surprises in older Node loaders.
package/src/term.js CHANGED
@@ -19,6 +19,10 @@ const ATTRIBUTED_ENV_FLATTEN_DEPTH = 256;
19
19
  // binding is just as safe to cache as a present one. Negative lookups are
20
20
  // especially common while tabling and constraint code probes fresh variables.
21
21
  const ENV_UNBOUND = Symbol('environment-unbound');
22
+ // Dereferencing walks a binding chain. Chains are one or two hops in practice
23
+ // and cyclic only when a term was built incorrectly, so the cycle guard starts
24
+ // recording visited names only after a chain is long enough to be suspect.
25
+ const DEREF_CYCLE_GUARD_HOPS = 16;
22
26
  // Runtime terms are structurally immutable: environments hold bindings beside
23
27
  // them rather than rewriting their argument arrays. Cache only the syntactic
24
28
  // variable names; binding reachability is still checked against each Env.
@@ -885,20 +889,34 @@ class SolverEnv extends Env {
885
889
  }
886
890
 
887
891
  export function deref(term, env) {
888
- // Follow variable bindings until a non-variable term is reached. The seen set
889
- // protects readback from accidental cycles in partially constructed terms.
892
+ // Follow variable bindings until a non-variable term is reached. This is the
893
+ // most frequently executed function in the runtime, so nothing it needs only
894
+ // occasionally may be paid for on every call: most terms are not variables
895
+ // at all, most environments declare no DCG locals, and almost every chain is
896
+ // one or two hops long.
897
+ if (term?.type !== VAR || env == null) return term;
898
+ // A live compiler-proven DCG local is the current unbound representative.
899
+ // No older Env layer can contain a binding for it. Both Env representations
900
+ // answer that question from this one set, so read it once per call instead
901
+ // of dispatching a method call per hop.
902
+ const locals = env._localVariables;
890
903
  let current = term;
891
904
  let seen = null;
892
- while (current?.type === VAR) {
893
- // A live compiler-proven DCG local is the current unbound representative.
894
- // No older Env layer can contain a binding for it.
895
- if (env?.isLocalVariable?.(current.name) === true) break;
896
- const next = env?.get(current.name);
905
+ let hops = 0;
906
+ do {
907
+ if (locals != null && locals.has(current.name)) break;
908
+ const next = env.get(current.name);
897
909
  if (next === undefined) break;
898
- if (seen?.has(current.name)) break;
899
- (seen ??= new Set()).add(current.name);
910
+ // The seen set protects readback from accidental cycles in partially
911
+ // constructed terms. A cycle still terminates the walk once the guard
912
+ // starts recording, so ordinary chains never allocate it.
913
+ if (++hops > DEREF_CYCLE_GUARD_HOPS) {
914
+ if (seen == null) seen = new Set();
915
+ if (seen.has(current.name)) break;
916
+ seen.add(current.name);
917
+ }
900
918
  current = next;
901
- }
919
+ } while (current?.type === VAR);
902
920
  return current;
903
921
  }
904
922
 
@@ -1519,40 +1537,65 @@ export function termSignature(term) {
1519
1537
  return term?.type === COMPOUND ? `${term.name}/${term.arity}` : null;
1520
1538
  }
1521
1539
 
1522
- export function variantTerms(left, leftEnv, right, rightEnv, pairs = new Map(), reverse = new Map()) {
1523
- // Variant checks sit on the recursive-call hot path. Use an explicit work
1524
- // stack so long lists do not consume the JavaScript call stack.
1525
- const pending = [[left, right]];
1526
- const seen = new WeakMap();
1527
- while (pending.length > 0) {
1528
- [left, right] = pending.pop();
1529
- left = deref(left, leftEnv);
1530
- right = deref(right, rightEnv);
1531
- if (left.type === VAR || right.type === VAR) {
1532
- if (left.type !== VAR || right.type !== VAR) return false;
1533
- if (pairs.has(left.name) || reverse.has(right.name)) {
1534
- if (pairs.get(left.name) !== right.name || reverse.get(right.name) !== left.name) return false;
1535
- continue;
1540
+ export function variantTerms(left, leftEnv, right, rightEnv, pairs = null, reverse = null) {
1541
+ // Variant checks sit on the recursive-call hot path, where the usual answer
1542
+ // is "not a variant" and the usual reason is one mismatched scalar argument.
1543
+ // Settle scalar and variable arguments as they are derefed and defer only
1544
+ // compound ones to an explicit work stack, which long lists still need so
1545
+ // they do not consume the JavaScript call stack. The stack, the variable
1546
+ // pairing, and the cycle guard are therefore allocated only once something
1547
+ // actually needs them, which a rejected comparison usually never does.
1548
+ let leftStack = null;
1549
+ let rightStack = null;
1550
+ let seen = null;
1551
+ let current = deref(left, leftEnv);
1552
+ let other = deref(right, rightEnv);
1553
+ for (;;) {
1554
+ if (current.type === VAR || other.type === VAR) {
1555
+ if (current.type !== VAR || other.type !== VAR) return false;
1556
+ pairs ??= new Map();
1557
+ reverse ??= new Map();
1558
+ const forward = pairs.get(current.name);
1559
+ const backward = reverse.get(other.name);
1560
+ if (forward !== undefined || backward !== undefined) {
1561
+ if (forward !== other.name || backward !== current.name) return false;
1562
+ } else {
1563
+ pairs.set(current.name, other.name);
1564
+ reverse.set(other.name, current.name);
1565
+ }
1566
+ } else if (current.type !== other.type || current.arity !== other.arity) {
1567
+ return false;
1568
+ } else if (current.type === NUMBER ? !sameNumberValue(current.name, other.name) : current.name !== other.name) {
1569
+ return false;
1570
+ } else if (current.type === COMPOUND && seen?.get(current)?.has(other) !== true) {
1571
+ let recorded = false;
1572
+ for (let i = 0; i < current.arity; i++) {
1573
+ const leftArg = deref(current.args[i], leftEnv);
1574
+ const rightArg = deref(other.args[i], rightEnv);
1575
+ if (leftArg.type !== COMPOUND && rightArg.type !== COMPOUND
1576
+ && leftArg.type !== VAR && rightArg.type !== VAR) {
1577
+ if (leftArg.type !== rightArg.type) return false;
1578
+ if (leftArg.type === NUMBER
1579
+ ? !sameNumberValue(leftArg.name, rightArg.name)
1580
+ : leftArg.name !== rightArg.name) return false;
1581
+ continue;
1582
+ }
1583
+ if (!recorded) {
1584
+ recorded = true;
1585
+ let rights = (seen ??= new WeakMap()).get(current);
1586
+ if (rights == null) seen.set(current, rights = new WeakSet());
1587
+ rights.add(other);
1588
+ leftStack ??= [];
1589
+ rightStack ??= [];
1590
+ }
1591
+ leftStack.push(leftArg);
1592
+ rightStack.push(rightArg);
1536
1593
  }
1537
- pairs.set(left.name, right.name);
1538
- reverse.set(right.name, left.name);
1539
- continue;
1540
- }
1541
-
1542
- if (left.type !== right.type || left.arity !== right.arity) return false;
1543
- if (left.type === NUMBER ? !sameNumberValue(left.name, right.name) : left.name !== right.name) return false;
1544
- if (left.type !== COMPOUND) continue;
1545
-
1546
- let rights = seen.get(left);
1547
- if (rights?.has(right)) continue;
1548
- if (rights == null) {
1549
- rights = new WeakSet();
1550
- seen.set(left, rights);
1551
1594
  }
1552
- rights.add(right);
1553
- for (let i = left.arity - 1; i >= 0; i--) pending.push([left.args[i], right.args[i]]);
1595
+ if (leftStack == null || leftStack.length === 0) return true;
1596
+ current = leftStack.pop();
1597
+ other = rightStack.pop();
1554
1598
  }
1555
- return true;
1556
1599
  }
1557
1600
 
1558
1601
 
@@ -0,0 +1,113 @@
1
+ // A small ordered worker pool for the suites that do the same work once per
2
+ // file: run every example, prove every example, check every packaged proof.
3
+ //
4
+ // Results are reported in task order however the workers finish, so a parallel
5
+ // suite numbers its tests exactly as the sequential one did and a failure still
6
+ // stops the run at the same test. Each suite compares inside its worker, so
7
+ // only a name, an elapsed time, an optional failure, and a small plain-data
8
+ // value cross the thread boundary.
9
+ import os from 'node:os';
10
+ import { Worker } from 'node:worker_threads';
11
+
12
+ export async function runTasksInParallel({
13
+ names,
14
+ workerUrl,
15
+ workerData,
16
+ message = {},
17
+ maxWorkers,
18
+ runLocally,
19
+ onResult,
20
+ }) {
21
+ if (names.length === 0) return;
22
+ const parallelism = os.availableParallelism?.() ?? os.cpus().length;
23
+ const workerCount = Math.min(names.length, Math.max(1, Math.min(maxWorkers, parallelism - 1)));
24
+ if (workerCount === 1) {
25
+ for (const name of names) {
26
+ const startedAt = performance.now();
27
+ try {
28
+ const value = runLocally(name) ?? null;
29
+ onResult(name, { ms: Math.round(performance.now() - startedAt), value });
30
+ } catch (error) {
31
+ onResult(name, { ms: Math.round(performance.now() - startedAt), error });
32
+ }
33
+ }
34
+ return;
35
+ }
36
+
37
+ const completedResults = new Map();
38
+ let nextTask = 0;
39
+ let nextReport = 0;
40
+ let completed = 0;
41
+ let settled = false;
42
+
43
+ await new Promise((resolve, reject) => {
44
+ const workers = Array.from({ length: workerCount }, () => new Worker(workerUrl, { workerData }));
45
+
46
+ const stopWorkers = () => Promise.all(workers.map((worker) => worker.terminate()));
47
+
48
+ const fail = (error) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ stopWorkers().finally(() => reject(error));
52
+ };
53
+
54
+ const finish = () => {
55
+ if (settled || completed !== names.length || nextReport !== names.length) return;
56
+ settled = true;
57
+ stopWorkers().then(() => resolve(), reject);
58
+ };
59
+
60
+ const reportReady = () => {
61
+ try {
62
+ while (completedResults.has(nextReport)) {
63
+ const result = completedResults.get(nextReport);
64
+ completedResults.delete(nextReport);
65
+ onResult(names[nextReport], result);
66
+ nextReport++;
67
+ }
68
+ } catch (error) {
69
+ fail(error);
70
+ }
71
+ };
72
+
73
+ const assign = (worker) => {
74
+ if (nextTask >= names.length || settled) return;
75
+ worker.postMessage({ ...message, id: nextTask, name: names[nextTask++] });
76
+ };
77
+
78
+ for (const worker of workers) {
79
+ worker.on('message', ({ id, ms, error, value }) => {
80
+ if (settled) return;
81
+ completedResults.set(id, {
82
+ ms,
83
+ value: value ?? null,
84
+ error: error == null ? null : Object.assign(new Error(error.message), { stack: error.stack }),
85
+ });
86
+ completed++;
87
+ reportReady();
88
+ assign(worker);
89
+ finish();
90
+ });
91
+ worker.on('error', fail);
92
+ assign(worker);
93
+ }
94
+ });
95
+ }
96
+
97
+ // The worker side of the same protocol. `runTask` receives the posted message
98
+ // and may return a plain-data value for the pool to hand back with the result.
99
+ export function serveTasks(parentPort, runTask) {
100
+ parentPort.on('message', (task) => {
101
+ const startedAt = performance.now();
102
+ try {
103
+ const value = runTask(task) ?? null;
104
+ parentPort.postMessage({ id: task.id, ms: Math.round(performance.now() - startedAt), error: null, value });
105
+ } catch (error) {
106
+ parentPort.postMessage({
107
+ id: task.id,
108
+ ms: Math.round(performance.now() - startedAt),
109
+ error: { message: error?.message ?? String(error), stack: error?.stack ?? String(error) },
110
+ });
111
+ }
112
+ });
113
+ }
@@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process';
6
6
  import * as publicApi from '../../src/index.js';
7
7
  import { Env, Program, atom, compound, copyResolved, flattenConjunction, listFromItems, numberTerm, parseProgramText, properListItems, stringTerm, termIsGround, termToString, unify, variable, variantTerms } from '../../src/index.js';
8
8
  import { ISO_OPERATOR_DEFINITIONS, parseGoalText, parseNumberTokenText } from '../../src/parser.js';
9
- import { compareTerms } from '../../src/term.js';
9
+ import { compareTerms, deref } from '../../src/term.js';
10
10
  import { checkProofDocument, verdict } from '../../src/check-proof.js';
11
11
  import { formatTermForWrite } from '../../src/write.js';
12
12
  import { selectClauseCandidates } from '../../src/program.js';
@@ -1065,5 +1065,66 @@ path(X, Z) :- edge(X, Y), path(Y, Z).
1065
1065
  assertIncludes(result.stdout, 'collatzTrajectory(1, [1]).\n', 'stdout');
1066
1066
  },
1067
1067
  },
1068
+ {
1069
+ // The hot paths below settle the ordinary case without allocating the
1070
+ // traversal state the hard case needs. These cases pin the hard cases:
1071
+ // the state has to appear exactly when the comparison stops being
1072
+ // shallow, acyclic, and decidable on a scalar.
1073
+ name: 'variantTerms separates renaming from sharing and still rejects on scalars',
1074
+ run: () => {
1075
+ const goal = parseGoalText('step(1, X, point(Y, X))');
1076
+ const renamed = parseGoalText('step(1, A, point(B, A))');
1077
+ const shared = parseGoalText('step(1, A, point(B, B))');
1078
+ const otherScalar = parseGoalText('step(2, A, point(B, A))');
1079
+ assertEqual(variantTerms(goal, new Env(), renamed, new Env()), true, 'a consistent renaming is a variant');
1080
+ assertEqual(variantTerms(goal, new Env(), shared, new Env()), false, 'sharing a variable is not a renaming');
1081
+ assertEqual(variantTerms(goal, new Env(), otherScalar, new Env()), false, 'a differing scalar argument rejects');
1082
+ assertEqual(
1083
+ variantTerms(parseGoalText('step(1.0, A)'), new Env(), parseGoalText('step(1, A)'), new Env()),
1084
+ false,
1085
+ 'a float and an integer argument stay distinct',
1086
+ );
1087
+ },
1088
+ },
1089
+ {
1090
+ name: 'variantTerms and deref terminate on cyclic bindings',
1091
+ run: () => {
1092
+ const leftEnv = new Env();
1093
+ leftEnv.bind('X', compound('f', [variable('X')]));
1094
+ const rightEnv = new Env();
1095
+ rightEnv.bind('Y', compound('f', [variable('Y')]));
1096
+ assertEqual(variantTerms(variable('X'), leftEnv, variable('Y'), rightEnv), true, 'cyclic terms of one shape are variants');
1097
+
1098
+ const chain = new Env();
1099
+ for (let index = 0; index < 40; index++) chain.bind(`V${index}`, variable(`V${index + 1}`));
1100
+ chain.bind('V40', atom('done'));
1101
+ assertEqual(deref(variable('V0'), chain).name, 'done', 'a forty-hop chain resolves to its value');
1102
+
1103
+ const cyclicChain = new Env();
1104
+ cyclicChain.bind('A', variable('B'));
1105
+ cyclicChain.bind('B', variable('A'));
1106
+ assertEqual(deref(variable('A'), cyclicChain).type, 'var', 'a cyclic chain stops at a variable');
1107
+ },
1108
+ },
1109
+ {
1110
+ name: 'tabled answers separate on a deep list element and still merge on a repeated derivation',
1111
+ run: () => {
1112
+ const program = Program.parse(`
1113
+ :- table suffixed/2.
1114
+ :- table duplicated/1.
1115
+ mark(a).
1116
+ mark(b).
1117
+ chain(0, [E], E).
1118
+ chain(N, [x|T], E) :- N > 0, M is N - 1, chain(M, T, E).
1119
+ suffixed(E, L) :- mark(E), chain(40, L, E).
1120
+ duplicated(L) :- chain(40, L, a).
1121
+ duplicated(L) :- chain(40, L, a).
1122
+ `);
1123
+ const distinct = run(program, { goal: 'suffixed(E, L)' });
1124
+ assertEqual(distinct.stdout.trim().split('\n').length, 2, 'answers differing only past forty cells stay distinct');
1125
+ const merged = run(program, { goal: 'duplicated(L)' });
1126
+ assertEqual(merged.stdout.trim().split('\n').length, 1, 'one answer derived twice is tabled once');
1127
+ },
1128
+ },
1068
1129
  ];
1069
1130
  }
package/test/run-all.mjs CHANGED
@@ -35,6 +35,6 @@ await runStandalone(async (reporter) => {
35
35
  await runRegression(reporter);
36
36
  await runPlayground(reporter);
37
37
  await runExamples(reporter);
38
- runProofChecking(reporter);
38
+ await runProofChecking(reporter);
39
39
  runBookExamples(reporter);
40
40
  });
@@ -2,13 +2,13 @@
2
2
  // Example-output test runner.
3
3
  // It compares examples byte-for-byte against golden output so answer and proof changes cannot silently alter results.
4
4
  import fs from 'node:fs';
5
- import os from 'node:os';
6
5
  import path from 'node:path';
7
6
  import { spawnSync } from 'node:child_process';
8
- import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
7
+ import { isMainThread, parentPort, workerData } from 'node:worker_threads';
9
8
  import { Program, run } from '../src/index.js';
10
9
  import { fileURLToPath } from 'node:url';
11
10
  import { TestReporter, isMainModule, runStandalone } from './test-style.mjs';
11
+ import { runTasksInParallel, serveTasks } from './parallel-tasks.mjs';
12
12
  import { goalsInProgramOrder } from './goal-metadata.mjs';
13
13
 
14
14
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
@@ -31,107 +31,39 @@ export async function runExamples(reporter = new TestReporter()) {
31
31
  .sort();
32
32
 
33
33
  reporter.section('Examples');
34
- await runExampleTasks(files, (name, result) => reporter.testResult(name, result), 3);
34
+ await runExampleTasks(files, 'output', reporter);
35
35
  reporter.sectionTotal('examples');
36
36
 
37
+ // Proving an example costs several times what running it does, so this is
38
+ // the longer of the two passes over the same corpus. It is the same work per
39
+ // program and the same isolation, so it goes through the same worker pool.
37
40
  reporter.section('Proof examples');
38
- for (const name of proofExamples) reporter.test(name, () => runProofExample(name));
41
+ await runExampleTasks(proofExamples, 'proof', reporter);
39
42
  reporter.sectionTotal('proof examples');
40
43
  }
41
44
 
42
-
43
- async function runExampleTasks(tasks, onResult, maxWorkers) {
44
- if (tasks.length === 0) return;
45
- const parallelism = os.availableParallelism?.() ?? os.cpus().length;
46
- const workerCount = Math.min(tasks.length, Math.max(1, Math.min(maxWorkers, parallelism - 1)));
47
- if (workerCount === 1) {
48
- for (const name of tasks) {
49
- const startedAt = performance.now();
50
- try {
51
- runExample(name);
52
- onResult(name, { ms: Math.round(performance.now() - startedAt) });
53
- } catch (error) {
54
- onResult(name, { ms: Math.round(performance.now() - startedAt), error });
55
- }
56
- }
57
- return;
58
- }
59
-
60
- const completedResults = new Map();
61
- let nextTask = 0;
62
- let nextReport = 0;
63
- let completed = 0;
64
- let settled = false;
65
-
66
- await new Promise((resolve, reject) => {
67
- const workers = Array.from({ length: workerCount }, () => new Worker(new URL(import.meta.url), {
68
- workerData: { exampleWorker: true },
69
- }));
70
-
71
- const stopWorkers = () => Promise.all(workers.map((worker) => worker.terminate()));
72
-
73
- const fail = (error) => {
74
- if (settled) return;
75
- settled = true;
76
- stopWorkers().finally(() => reject(error));
77
- };
78
-
79
- const finish = () => {
80
- if (settled || completed !== tasks.length || nextReport !== tasks.length) return;
81
- settled = true;
82
- stopWorkers().then(() => resolve(), reject);
83
- };
84
-
85
- const reportReady = () => {
86
- try {
87
- while (completedResults.has(nextReport)) {
88
- const result = completedResults.get(nextReport);
89
- completedResults.delete(nextReport);
90
- onResult(tasks[nextReport], result);
91
- nextReport++;
92
- }
93
- } catch (error) {
94
- fail(error);
95
- }
96
- };
97
-
98
- const assign = (worker) => {
99
- if (nextTask >= tasks.length || settled) return;
100
- worker.postMessage({ id: nextTask, name: tasks[nextTask++] });
101
- };
102
-
103
- for (const worker of workers) {
104
- worker.on('message', ({ id, ms, error }) => {
105
- if (settled) return;
106
- completedResults.set(id, {
107
- ms,
108
- error: error == null ? null : Object.assign(new Error(error.message), { stack: error.stack }),
109
- });
110
- completed++;
111
- reportReady();
112
- assign(worker);
113
- finish();
114
- });
115
- worker.on('error', fail);
116
- assign(worker);
117
- }
45
+ // What a worker is asked to do with one example. Results are compared inside
46
+ // the worker, so only a name, an elapsed time, and a failure cross the thread.
47
+ const exampleTaskKinds = {
48
+ output: runExample,
49
+ proof: runProofExample,
50
+ };
51
+
52
+
53
+ function runExampleTasks(names, kind, reporter) {
54
+ return runTasksInParallel({
55
+ names,
56
+ workerUrl: new URL(import.meta.url),
57
+ workerData: { exampleWorker: true },
58
+ message: { kind },
59
+ maxWorkers: 3,
60
+ runLocally: (name) => exampleTaskKinds[kind](name),
61
+ onResult: (name, result) => reporter.testResult(name, result),
118
62
  });
119
63
  }
120
64
 
121
65
  function runExampleWorker() {
122
- parentPort.on('message', ({ id, name }) => {
123
- const startedAt = performance.now();
124
- try {
125
- runExample(name);
126
- parentPort.postMessage({ id, ms: Math.round(performance.now() - startedAt), error: null });
127
- } catch (error) {
128
- parentPort.postMessage({
129
- id,
130
- ms: Math.round(performance.now() - startedAt),
131
- error: { message: error?.message ?? String(error), stack: error?.stack ?? String(error) },
132
- });
133
- }
134
- });
66
+ serveTasks(parentPort, ({ kind, name }) => exampleTaskKinds[kind](name));
135
67
  }
136
68
 
137
69
 
@@ -9,11 +9,13 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { isMainThread, parentPort, workerData } from 'node:worker_threads';
12
13
 
13
14
  import { Program } from '../src/index.js';
14
15
  import { checkProofDocument, checkReportTerms, verdict } from '../src/check-proof.js';
15
16
  import { TestReporter, assertEqual, isMainModule, runStandalone } from './test-style.mjs';
16
17
  import { proofExamples } from './run-examples.mjs';
18
+ import { runTasksInParallel, serveTasks } from './parallel-tasks.mjs';
17
19
 
18
20
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
21
  const examplesDir = path.join(root, 'examples');
@@ -43,17 +45,24 @@ const KNOWN_GAPS = new Map([
43
45
  // checked here. The list is read from the directory rather than written out,
44
46
  // so a proof cannot be added without being checked.
45
47
 
46
- const totals = { steps: 0, verified: 0, redecided: 0, trusted: 0 };
47
-
48
- export function runProofChecking(reporter = new TestReporter()) {
48
+ export async function runProofChecking(reporter = new TestReporter()) {
49
49
  reporter.section('Proof checking');
50
- totals.steps = 0;
51
- totals.verified = 0;
52
- totals.redecided = 0;
53
- totals.trusted = 0;
54
- for (const name of [...proofExamples].sort()) {
55
- reporter.test(name, () => checkPackagedProof(name));
56
- }
50
+ // Each document is checked on its own against its own program, so the corpus
51
+ // is checked in parallel and the counts it establishes are summed as the
52
+ // results come back, in task order, rather than accumulated in a shared
53
+ // tally a worker could not reach.
54
+ const totals = { steps: 0, verified: 0, redecided: 0, trusted: 0 };
55
+ await runTasksInParallel({
56
+ names: [...proofExamples].sort(),
57
+ workerUrl: new URL(import.meta.url),
58
+ workerData: { proofCheckingWorker: true },
59
+ maxWorkers: 3,
60
+ runLocally: (name) => checkPackagedProof(name),
61
+ onResult: (name, result) => {
62
+ for (const field of Object.keys(totals)) totals[field] += result.value?.[field] ?? 0;
63
+ reporter.testResult(name, result);
64
+ },
65
+ });
57
66
  reporter.sectionTotal('proof checking');
58
67
  // What the corpus establishes, in the terms the conditions use: a verified
59
68
  // step was re-performed against its source clause, a recomputed one was run
@@ -83,19 +92,27 @@ function checkPackagedProof(name) {
83
92
  if (expected !== actual) {
84
93
  throw new Error(`check report mismatch for ${name}\nexpected:\n${expected}\nactual:\n${actual}`);
85
94
  }
86
- totals.steps += report.steps;
87
- totals.verified += report.verified;
88
- totals.redecided += report.redecided;
89
- totals.trusted += report.trusted.length;
95
+ // What this document contributes to the corpus totals, returned rather than
96
+ // added to a module-level tally so the same function is correct whether it
97
+ // runs here or in a worker.
98
+ const contribution = {
99
+ steps: report.steps,
100
+ verified: report.verified,
101
+ redecided: report.redecided,
102
+ trusted: report.trusted.length,
103
+ };
90
104
  const gap = KNOWN_GAPS.get(name);
91
105
  if (gap) {
92
106
  assertEqual(report.valid, false, `${name} now checks; remove it from KNOWN_GAPS (${gap})`);
93
- return;
107
+ return contribution;
94
108
  }
95
109
  const detail = report.failures.slice(0, 3).map((failure) => `[${failure.condition}] ${failure.conclusion} -- ${failure.detail}`).join('; ');
96
110
  assertEqual(report.valid, true, `${name}: ${verdict(report)}${detail ? ` -- ${detail}` : ''}`);
111
+ return contribution;
97
112
  }
98
113
 
99
- if (isMainModule(import.meta.url)) {
114
+ if (!isMainThread && workerData?.proofCheckingWorker) {
115
+ serveTasks(parentPort, ({ name }) => checkPackagedProof(name));
116
+ } else if (isMainModule(import.meta.url)) {
100
117
  await runStandalone(runProofChecking);
101
118
  }