eyeprolog 1.6.27 → 1.6.29

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.27",
6
+ "version": "1.6.29",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -21,7 +21,10 @@ higher-level frontends.
21
21
  `datalog-common.js` owns what the two Datalog evaluators share: scalar row
22
22
  keying and the per-argument candidate-bucket narrowing both index their
23
23
  relations with. Their fixpoint algorithms and relation storage stay in their
24
- own modules.
24
+ own modules. Positive Datalog compiles clause variables to numeric slots
25
+ and joins through array bindings; WFS retains its persistent Map bindings.
26
+ Candidate selection has a direct entry point for each binding representation
27
+ so their inner loops do not dispatch through a binding-store abstraction.
25
28
  `cleanup.js` owns lifecycle-aware disposal of protected builtin
26
29
  iterators and registers the normal-profile cleanup controls without making
27
30
  `solver.js` depend back on the language registry.
@@ -36,6 +39,10 @@ Verification walks the supplied certificate and checks source-clause steps again
36
39
  the parsed `Program`; it does not call the solver to rediscover the proof.
37
40
  Built-in and abstract library nodes are explicit trust boundaries, while expanded
38
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.
39
46
 
40
47
  `iso.js` and `program.js` remain facade modules for their existing exports, so
41
48
  this refactor does not change the public JavaScript API.
@@ -113,6 +120,16 @@ construct native propagation/search plans while their Prolog modules retain
113
120
  validation, residual constraints, and portable fallbacks. A cleaner file layout
114
121
  is not worth a runtime regression, so such splits must retain benchmark parity.
115
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
+
116
133
  Performance claims use `npm test`'s own elapsed time across the full corpus,
117
134
  not predicate, inference, or host-call counts as a substitute for elapsed
118
135
  time, and not a separate wall-clock benchmark harness.
@@ -91,3 +91,17 @@ export function selectCandidateIndexes(indexes, args, bindings) {
91
91
  }
92
92
  return selected;
93
93
  }
94
+
95
+ // Positive Datalog compiles clause variables to numeric slots. Keep its hot
96
+ // path direct rather than introducing a binding-store dispatch in each lookup.
97
+ export function selectCandidateIndexesForSlots(indexes, args, bindings) {
98
+ let selected = null;
99
+ for (let i = 0; i < args.length; i++) {
100
+ const value = args[i].type === VAR ? bindings[args[i].slot] : args[i];
101
+ if (value == null) continue;
102
+ const bucket = indexes[i].get(scalarKey(value)) ?? EMPTY_ARRAY;
103
+ if (selected == null || bucket.length < selected.length) selected = bucket;
104
+ if (selected.length === 0) break;
105
+ }
106
+ return selected;
107
+ }
package/src/datalog.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  predicateKey,
15
15
  sameScalar,
16
16
  scalarKey,
17
- selectCandidateIndexes,
17
+ selectCandidateIndexesForSlots,
18
18
  } from './datalog-common.js';
19
19
 
20
20
  class DatalogRelation {
@@ -61,7 +61,7 @@ class DatalogRelation {
61
61
  const tuple = new Array(args.length);
62
62
  const parts = new Array(args.length);
63
63
  for (let i = 0; i < args.length; i++) {
64
- const value = args[i].type === VAR ? bindings.get(args[i].name) : args[i];
64
+ const value = args[i].type === VAR ? bindings[args[i].slot] : args[i];
65
65
  if (value == null || value.type === VAR) return null;
66
66
  tuple[i] = value;
67
67
  parts[i] = scalarKey(value);
@@ -87,7 +87,7 @@ class DatalogRelation {
87
87
  }
88
88
 
89
89
  candidateIndexes(args, bindings) {
90
- return selectCandidateIndexes(this.indexes, args, bindings);
90
+ return selectCandidateIndexesForSlots(this.indexes, args, bindings);
91
91
  }
92
92
  }
93
93
 
@@ -106,11 +106,21 @@ function compileProgram(program, rootGroup) {
106
106
  for (const group of groups) {
107
107
  const headKey = predicateKey(group.module, group.name, group.arity);
108
108
  for (const clause of group.clauses) {
109
- const headArgs = clause.head.args ?? EMPTY_ARRAY;
110
109
  if (clause.body.length === 0) continue;
111
110
  const body = clause.body.map((goal) => directLiteral(goal, group.module));
112
111
  if (body.some((literal) => literal == null)) continue;
113
- const rule = { headKey, headArgs, body };
112
+ // Variable names matter only within this source clause. Compile them to
113
+ // array slots once so each join can bind and undo without Map lookups.
114
+ const variables = new Map();
115
+ const compileArgument = (arg) => {
116
+ if (arg.type !== VAR) return arg;
117
+ let slot = variables.get(arg.name);
118
+ if (slot == null) variables.set(arg.name, slot = variables.size);
119
+ return { type: VAR, slot };
120
+ };
121
+ const headArgs = (clause.head.args ?? EMPTY_ARRAY).map(compileArgument);
122
+ for (const literal of body) literal.args = literal.args.map(compileArgument);
123
+ const rule = { headKey, headArgs, body, variableCount: variables.size };
114
124
  rules.push(rule);
115
125
  for (let index = 0; index < body.length; index++) {
116
126
  const key = body[index].key;
@@ -130,20 +140,20 @@ function matchTupleMutable(args, tuple, bindings) {
130
140
  for (let i = 0; i < args.length; i++) {
131
141
  const pattern = args[i];
132
142
  if (pattern.type === VAR) {
133
- const current = bindings.get(pattern.name);
143
+ const current = bindings[pattern.slot];
134
144
  if (current != null) {
135
145
  if (!sameScalar(current, tuple[i])) {
136
- for (let j = added.length - 1; j >= 0; j--) bindings.delete(added[j]);
146
+ undoBindings(bindings, added);
137
147
  return null;
138
148
  }
139
149
  } else {
140
- bindings.set(pattern.name, tuple[i]);
141
- added.push(pattern.name);
150
+ bindings[pattern.slot] = tuple[i];
151
+ added.push(pattern.slot);
142
152
  }
143
153
  continue;
144
154
  }
145
155
  if (!sameScalar(pattern, tuple[i])) {
146
- for (let j = added.length - 1; j >= 0; j--) bindings.delete(added[j]);
156
+ undoBindings(bindings, added);
147
157
  return null;
148
158
  }
149
159
  }
@@ -151,7 +161,7 @@ function matchTupleMutable(args, tuple, bindings) {
151
161
  }
152
162
 
153
163
  function undoBindings(bindings, added) {
154
- for (let i = added.length - 1; i >= 0; i--) bindings.delete(added[i]);
164
+ for (let i = added.length - 1; i >= 0; i--) bindings[added[i]] = undefined;
155
165
  }
156
166
 
157
167
 
@@ -234,7 +244,7 @@ export function evaluatePositiveDatalog(program, rootGroup) {
234
244
  const triggerEntries = compiled.triggers.get(event.key) ?? EMPTY_ARRAY;
235
245
  for (const { rule, literalIndex } of triggerEntries) {
236
246
  const fixed = rule.body[literalIndex];
237
- const bindings = new Map();
247
+ const bindings = new Array(rule.variableCount);
238
248
  if (!matchTupleMutable(fixed.args, event.tuple, bindings)) continue;
239
249
  const remaining = [];
240
250
  for (let i = 0; i < rule.body.length; i++) if (i !== literalIndex) remaining.push(i);
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
 
@@ -6,10 +6,11 @@ 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';
13
+ import { evaluatePositiveDatalog, relationForDatalogGroup } from '../../src/datalog.js';
13
14
  import { assertEqual, assertIncludes, assertNotIncludes } from '../test-style.mjs';
14
15
  import { goalsFromSource } from '../goal-metadata.mjs';
15
16
  import {
@@ -770,6 +771,44 @@ export function whiteBoxCases() {
770
771
  assertEqual(ground.stats.datalog_evaluations, 0, 'ground query keeps the ordinary indexed chain path');
771
772
  },
772
773
  },
774
+ {
775
+ name: 'Datalog joins preserve clause-local variables and repeated arguments',
776
+ run: () => {
777
+ const program = Program.parse(`
778
+ link(a,b,red).
779
+ link(b,c,red).
780
+ link(c,d,red).
781
+ link(x,x,red).
782
+ link(a,b,blue).
783
+ link(c,c,blue).
784
+ link(a,c,blue).
785
+ reach(X,Y,C) :- link(X,Y,C).
786
+ reach(X,Y,C) :- link(X,Z,C), reach(Z,Y,C).
787
+ back(Y,X) :- reach(X,Y,red).
788
+ back(X,Y) :- reach(X,Y,blue).
789
+ diagonal(X) :- reach(X,X,C).
790
+ marked(tag,X) :- reach(X,X,red).
791
+ wide(X,marker,Y) :- reach(X,Y,red).
792
+ ready :- diagonal(x).
793
+ summary :- ready, back(X,Y), marked(tag,Z), wide(X,marker,Y).
794
+ `);
795
+ const model = evaluatePositiveDatalog(program, program.findGroup('summary', 0));
796
+ const checkRows = (name, arity, expected) => {
797
+ const relation = relationForDatalogGroup(model, program.findGroup(name, arity));
798
+ const actual = relation.rows.map((row) => row.map((value) => value.name).join(':')).sort();
799
+ assertEqual(actual.join('|'), expected.sort().join('|'), `${name}/${arity} rows`);
800
+ };
801
+ checkRows('back', 2, ['b:a', 'c:b', 'd:c', 'x:x', 'c:a', 'd:a', 'd:b', 'a:b', 'c:c', 'a:c']);
802
+ checkRows('diagonal', 1, ['c', 'x']);
803
+ checkRows('marked', 2, ['tag:x']);
804
+ checkRows('wide', 3, [
805
+ 'a:marker:b', 'b:marker:c', 'c:marker:d', 'x:marker:x',
806
+ 'a:marker:c', 'a:marker:d', 'b:marker:d',
807
+ ]);
808
+ checkRows('ready', 0, ['']);
809
+ checkRows('summary', 0, ['']);
810
+ },
811
+ },
773
812
  {
774
813
  name: 'compact finite Datalog planning preserves lazy clause terms',
775
814
  run: () => {
@@ -1026,5 +1065,66 @@ path(X, Z) :- edge(X, Y), path(Y, Z).
1026
1065
  assertIncludes(result.stdout, 'collatzTrajectory(1, [1]).\n', 'stdout');
1027
1066
  },
1028
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
+ },
1029
1129
  ];
1030
1130
  }