eyeprolog 1.3.4 → 1.3.7

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/src/solver.js CHANGED
@@ -5,15 +5,18 @@ import {
5
5
  flattenConjunction, freshTerm, isCons, isDecimalInteger, isEmptyList,
6
6
  numberTerm, numberTextFromDouble, termIsGround, termToString, unify, variable, variantTerms,
7
7
  } from './term.js';
8
- import { sameNumberValue } from './number-value.js';
8
+ import { numberValueKey, sameNumberValue } from './number-value.js';
9
9
  import { PrologError, getStrictIsoRegistry } from './iso.js';
10
10
  import { getEyePrologRegistry } from './standard-library.js';
11
11
  import { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program.js';
12
12
  import { StreamManager } from './io.js';
13
13
  import { clpzStateConsistent } from './clpz.js';
14
14
  import { hardHeapLimit, softHeapLimit, usedHeapSize } from './platform.js';
15
+ import { evaluateWfs, relationForGroup, truthOfGroundGoal } from './wfs.js';
16
+ import { evaluatePositiveDatalog, relationForDatalogGroup, datalogCandidateIndexes } from './datalog.js';
15
17
 
16
18
  let freshCounter = 0;
19
+ const DEFAULT_INNER_TABLE_SCOPE_LIMIT = 1024;
17
20
 
18
21
  function qualifyTerm(term, module) {
19
22
  if (!term || (term.type !== COMPOUND && term.type !== 'atom')) return term;
@@ -62,6 +65,9 @@ export class Solver {
62
65
  // still supply solutionLimit explicitly.
63
66
  this.solutionLimit = options.solutionLimit ?? Infinity;
64
67
  this.solutionsSeen = 0;
68
+ // Meta-call fast path: a caller may bypass automatic tabling only for
69
+ // recursion proven to consume a list tail on every recursive step.
70
+ this.skipListTailTabling = options.skipListTailTabling === true;
65
71
  this.prologFlags = options.prologFlags ?? defaultPrologFlags('error', this.isoStrict);
66
72
  if (this.isoStrict) {
67
73
  for (const name of [...this.prologFlags.keys()]) {
@@ -104,9 +110,17 @@ export class Solver {
104
110
  this.active = [];
105
111
  this.cutEpoch = 0;
106
112
  this.memo = new Map();
113
+ this.subsumptiveMemo = new Map();
114
+ this.wfsModels = new Map();
115
+ this.datalogModels = new Map();
107
116
  this.tableCoordinator = null;
108
117
  this.groundChainSuccess = new Set();
109
118
  this.compactChainSuccess = new Map();
119
+ // Bounded table caches for isolated meta-call domains such as phrase/2-3.
120
+ // They deliberately do not share the caller's general memo: a stream of
121
+ // distinct meta-call inputs must not turn completed table variants into
122
+ // process-lifetime roots.
123
+ this.innerTableScopes = options.innerTableScopes ?? new Map();
110
124
  this.stats = {
111
125
  completed_goal_lists: 0,
112
126
  solve_goals_calls: 0,
@@ -117,10 +131,15 @@ export class Solver {
117
131
  deterministic_builtin_successes: 0,
118
132
  deterministic_builtin_failures: 0,
119
133
  table_fixpoint_rounds: 0,
134
+ datalog_evaluations: 0,
135
+ datalog_rule_firings: 0,
136
+ datalog_facts_derived: 0,
137
+ wfs_fixpoint_rounds: 0,
138
+ wfs_undefined_answers: 0,
120
139
  };
121
140
  }
122
141
 
123
- cloneForInnerGoal(solutionLimit = this.solutionLimit) {
142
+ cloneForInnerGoal(solutionLimit = this.solutionLimit, options = {}) {
124
143
  const solver = new Solver(this.program, {
125
144
  registry: this.registry,
126
145
  maxDepth: this.maxDepth,
@@ -132,13 +151,74 @@ export class Solver {
132
151
  prologFlags: this.prologFlags,
133
152
  charConversions: this.charConversions,
134
153
  io: this.io,
154
+ innerTableScopes: this.innerTableScopes,
155
+ skipListTailTabling: options.skipListTailTabling ?? this.skipListTailTabling,
135
156
  });
136
- solver.memo = this.memo;
157
+ if (options.tableScope != null) {
158
+ const scope = this.innerTableScope(options.tableScope, options.tableScopeSignature ?? null);
159
+ solver.memo = scope.memo;
160
+ solver.subsumptiveMemo = scope.subsumptiveMemo;
161
+ } else {
162
+ solver.memo = this.memo;
163
+ solver.subsumptiveMemo = this.subsumptiveMemo;
164
+ }
165
+ solver.wfsModels = this.wfsModels;
166
+ solver.datalogModels = this.datalogModels;
137
167
  solver.groundChainSuccess = this.groundChainSuccess;
138
168
  solver.compactChainSuccess = this.compactChainSuccess;
139
169
  return solver;
140
170
  }
141
171
 
172
+ innerTableScope(name, signature = null) {
173
+ let scope = this.innerTableScopes.get(name);
174
+ if (scope == null) {
175
+ scope = { memo: new Map(), subsumptiveMemo: new Map(), signature };
176
+ this.innerTableScopes.set(name, scope);
177
+ return scope;
178
+ }
179
+ if (signature == null || scope.signature === signature) return scope;
180
+
181
+ // Keep at most one completed invocation variant for isolated meta-call
182
+ // domains. Reusing the same phrase/2-3 call remains fast, while a stream of
183
+ // distinct inputs releases the previous table as one unit instead of
184
+ // churning thousands of individual memo entries. A nested meta-call must
185
+ // not invalidate an active fixed point, so it receives a temporary scope.
186
+ for (const entry of scope.memo.values()) {
187
+ if (entry?.computing === true) {
188
+ return { memo: new Map(), subsumptiveMemo: new Map(), signature };
189
+ }
190
+ }
191
+ scope.memo.clear();
192
+ scope.subsumptiveMemo.clear();
193
+ scope.signature = signature;
194
+ return scope;
195
+ }
196
+
197
+ innerTableSignature(terms, env, prefix = '') {
198
+ const variables = new Map();
199
+ return prefix + terms.map((term) => canonicalTermKey(term, env, variables)).join('|');
200
+ }
201
+
202
+ trimInnerTableScope(name, limit = DEFAULT_INNER_TABLE_SCOPE_LIMIT) {
203
+ const scope = this.innerTableScopes.get(name);
204
+ if (scope == null || scope.memo.size <= limit) return;
205
+ // Map iteration is insertion ordered. Delete completed entries directly by
206
+ // key as we encounter them so a steady stream of distinct meta-call inputs
207
+ // pays O(evictions), not an O(cache-size) rescan for every phrase/2 call.
208
+ // Active fixed-point entries are skipped and can only make the cache
209
+ // temporarily exceed its soft bound.
210
+ for (const [key, entry] of scope.memo) {
211
+ if (scope.memo.size <= limit) break;
212
+ if (entry?.complete !== true || entry?.computing === true) continue;
213
+ scope.memo.delete(key);
214
+ if (scope.subsumptiveMemo.size !== 0) {
215
+ for (const [broadKey, broadEntry] of scope.subsumptiveMemo) {
216
+ if (broadEntry === entry) scope.subsumptiveMemo.delete(broadKey);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
142
222
  syncProgramRevision() {
143
223
  if (!this.mutableProgram) {
144
224
  if (this.program.mutable !== true) return;
@@ -148,9 +228,16 @@ export class Solver {
148
228
  if (revision === this.programRevision) return;
149
229
  this.programRevision = revision;
150
230
  this.memo.clear();
231
+ this.subsumptiveMemo.clear();
232
+ this.wfsModels.clear();
233
+ this.datalogModels.clear();
151
234
  this.tableCoordinator = null;
152
235
  this.groundChainSuccess.clear();
153
236
  this.compactChainSuccess.clear();
237
+ for (const scope of this.innerTableScopes.values()) {
238
+ scope.memo.clear();
239
+ scope.subsumptiveMemo.clear();
240
+ }
154
241
  }
155
242
 
156
243
  absorbStatsFrom(child) {
@@ -330,6 +417,19 @@ export class Solver {
330
417
  if (goal.type === 'var') throw new PrologError('instantiation_error');
331
418
  const callable = goal.type === COMPOUND || goal.type === 'atom';
332
419
  if (!callable) throw new PrologError('type_error(callable)', goal);
420
+
421
+ if (selectedIndex === 0) {
422
+ const fused = findallLengthFusion(this, goal, rest, env);
423
+ if (fused != null) {
424
+ const firstResult = fused.iterator.next();
425
+ if (firstResult.done) break;
426
+ goals = fused.rest;
427
+ env = firstResult.value;
428
+ depth++;
429
+ continue;
430
+ }
431
+ }
432
+
333
433
  const def = callable ? this.registry.get(goal.name, goal.arity) : null;
334
434
  this.active = active;
335
435
  if (def && builtinIsReadyOrAuthoritative(def, this, goal, env)) {
@@ -374,6 +474,31 @@ export class Solver {
374
474
  }
375
475
  qualifyMetaArguments(goal, group);
376
476
 
477
+ if (group.datalogLeastModel === true && !termIsGround(goal, env)) {
478
+ const model = this.datalogModelFor(group);
479
+ const relation = relationForDatalogGroup(model, group);
480
+ const iterator = datalogAnswerSolutions(this, relation, goal, env);
481
+ const firstResult = iterator.next();
482
+ if (firstResult.done) break;
483
+ stack.push({
484
+ kind: 'resumeBuiltin',
485
+ iterator,
486
+ goals: rest,
487
+ depth: depth + 1,
488
+ active,
489
+ });
490
+ goals = rest;
491
+ env = firstResult.value;
492
+ depth++;
493
+ continue;
494
+ }
495
+
496
+ if (group.wfsDatalog === true) {
497
+ const model = this.wfsModelFor(group);
498
+ pushWfsAnswerFrames(stack, model, group, goal, rest, env, depth, active, this);
499
+ break;
500
+ }
501
+
377
502
  const memberIterator = bundledMemberIterator(this, group, goal, env);
378
503
  if (memberIterator != null) {
379
504
  const firstResult = memberIterator.next();
@@ -408,14 +533,29 @@ export class Solver {
408
533
  continue;
409
534
  }
410
535
 
411
- if (group.tabled) {
536
+ if (group.tabled && !(this.skipListTailTabling && group.listTailRecursive)) {
412
537
  const key = memoKey(goal, env, group);
413
538
  if (key.hasBound) {
414
539
  const mapKey = `${group.module}:${goal.name}/${goal.arity}:${key.text}`;
415
- let entry = this.memo.get(mapKey);
540
+ const broadKey = `${group.module}:${goal.name}/${goal.arity}`;
541
+ let entry = null;
542
+ let usingBroadTable = false;
543
+ if (group.tableAllVariants) {
544
+ entry = this.subsumptiveMemo.get(broadKey) ?? null;
545
+ usingBroadTable = entry != null;
546
+ if (!entry && isMostGeneralTableGoal(goal, env)) {
547
+ entry = makeMemoEntry(goal.arity);
548
+ this.subsumptiveMemo.set(broadKey, entry);
549
+ this.memo.set(mapKey, entry);
550
+ usingBroadTable = true;
551
+ }
552
+ }
416
553
  if (!entry) {
417
- entry = makeMemoEntry();
418
- this.memo.set(mapKey, entry);
554
+ entry = this.memo.get(mapKey);
555
+ if (!entry) {
556
+ entry = makeMemoEntry(goal.arity);
557
+ this.memo.set(mapKey, entry);
558
+ }
419
559
  }
420
560
  if (this.tableCoordinator) this.tableCoordinator.entries.add(entry);
421
561
  if (entry.complete) {
@@ -433,7 +573,7 @@ export class Solver {
433
573
  }
434
574
  break;
435
575
  }
436
- if (this.tableCoordinator && activeVariantIn(goal, env, active)) {
576
+ if (this.tableCoordinator && (usingBroadTable || activeVariantIn(goal, env, active))) {
437
577
  this.tableCoordinator.cycleSeen = true;
438
578
  }
439
579
  pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, this);
@@ -466,6 +606,84 @@ export class Solver {
466
606
  }
467
607
  }
468
608
 
609
+ fastCountGoal(goal, env) {
610
+ return fastCountPureGoal(this, goal, env);
611
+ }
612
+
613
+ fastGroundGoalTruth(goal, env) {
614
+ return fastGroundPureGoalTruth(this, goal, env);
615
+ }
616
+
617
+ datalogModelFor(group) {
618
+ let model = this.datalogModels.get(group);
619
+ if (model) return model;
620
+ model = evaluatePositiveDatalog(this.program, group);
621
+ this.stats.datalog_evaluations++;
622
+ this.stats.datalog_rule_firings += model.ruleFirings;
623
+ this.stats.datalog_facts_derived += model.derivedFacts;
624
+ for (const member of model.groups) {
625
+ if (!this.datalogModels.has(member)) this.datalogModels.set(member, model);
626
+ }
627
+ this.datalogModels.set(group, model);
628
+ return model;
629
+ }
630
+
631
+ wfsModelFor(group) {
632
+ let model = this.wfsModels.get(group);
633
+ if (model) return model;
634
+ model = evaluateWfs(this.program, group);
635
+ this.stats.wfs_fixpoint_rounds += model.rounds;
636
+ this.wfsModels.set(group, model);
637
+ return model;
638
+ }
639
+
640
+ *solveWfsGoal(group, goal, rest, env, depth) {
641
+ const model = this.wfsModelFor(group);
642
+ const relation = relationForGroup(model, group, 'upper');
643
+ const lower = relationForGroup(model, group, 'lower');
644
+ if (!relation) return;
645
+ for (const row of relation.rows) {
646
+ const next = env.clone();
647
+ let ok = true;
648
+ for (let i = 0; i < goal.arity; i++) {
649
+ this.stats.unify_calls++;
650
+ if (!unify(goal.args[i], row[i], next)) { ok = false; break; }
651
+ }
652
+ if (!ok) continue;
653
+ if (!lower?.has(row)) this.stats.wfs_undefined_answers++;
654
+ yield* this.solve(rest, next, depth + 1);
655
+ if (this.solutionsSeen >= this.solutionLimit) return;
656
+ }
657
+ }
658
+
659
+ *solveTabledNegation(argument, env) {
660
+ const invoked = copyResolved(argument, env);
661
+ if (invoked.type === VAR) throw new PrologError('instantiation_error');
662
+ if (invoked.type !== COMPOUND && invoked.type !== 'atom') {
663
+ throw new PrologError('type_error(callable)', invoked);
664
+ }
665
+ if (!termIsGround(invoked)) throw new PrologError('instantiation_error');
666
+ const group = this.program.findGroup(invoked.name, invoked.arity, invoked.module ?? 'user');
667
+ if (group?.wfsDatalog === true) {
668
+ const model = this.wfsModelFor(group);
669
+ const truth = truthOfGroundGoal(model, invoked);
670
+ if (truth === 'true') return;
671
+ if (truth === 'undefined') this.stats.wfs_undefined_answers++;
672
+ yield env;
673
+ return;
674
+ }
675
+
676
+ // Outside an unstratified WFS component, a ground tabled negation has the
677
+ // ordinary two-valued result after the positive goal is evaluated.
678
+ const child = this.cloneForInnerGoal(1);
679
+ for (const _ of child.solve([invoked], env.clone(), 0)) {
680
+ this.absorbStatsFrom(child);
681
+ return;
682
+ }
683
+ this.absorbStatsFrom(child);
684
+ yield env;
685
+ }
686
+
469
687
  activeVariant(goal, env) {
470
688
  return activeVariantIn(goal, env, this.active);
471
689
  }
@@ -525,6 +743,14 @@ export class Solver {
525
743
  const group = this.program.findGroup(goal.name, goal.arity, goal.module ?? 'user');
526
744
  if (!group) return;
527
745
  qualifyMetaArguments(goal, group);
746
+ if (group.datalogLeastModel === true && !termIsGround(goal, env)) {
747
+ yield* this.solveDatalogGoal(group, goal, rest, env, depth);
748
+ return;
749
+ }
750
+ if (group.wfsDatalog === true) {
751
+ yield* this.solveWfsGoal(group, goal, rest, env, depth);
752
+ return;
753
+ }
528
754
  if (group.tabled) {
529
755
  yield* this.solveMemoizedGoal(group, goal, rest, env, depth);
530
756
  return;
@@ -532,6 +758,15 @@ export class Solver {
532
758
  yield* this.solveUserGoalUncached(group, goal, rest, env, depth);
533
759
  }
534
760
 
761
+ *solveDatalogGoal(group, goal, rest, env, depth) {
762
+ const model = this.datalogModelFor(group);
763
+ const relation = relationForDatalogGroup(model, group);
764
+ for (const next of datalogAnswerSolutions(this, relation, goal, env)) {
765
+ yield* this.solve(rest, next, depth + 1);
766
+ if (this.solutionsSeen >= this.solutionLimit) return;
767
+ }
768
+ }
769
+
535
770
  *solveMemoizedGoal(group, goal, rest, env, depth) {
536
771
  yield* this.solve([goal, ...rest], env, depth);
537
772
  }
@@ -641,8 +876,30 @@ function defaultPrologFlags(unknown = 'error', strictIso = false) {
641
876
  }
642
877
 
643
878
 
644
- function makeMemoEntry() {
645
- return { computing: false, complete: false, answers: [], answerKeys: new Set() };
879
+ function makeMemoEntry(arity = 0) {
880
+ return {
881
+ computing: false,
882
+ complete: false,
883
+ answers: [],
884
+ answerKeys: new Set(),
885
+ answerIndexes: Array.from({ length: arity }, () => new Map()),
886
+ answerVariableFallbacks: Array.from({ length: arity }, () => []),
887
+ };
888
+ }
889
+
890
+ function memoAnswerScalarKey(term) {
891
+ if (term?.type !== 'atom' && term?.type !== 'string' && term?.type !== 'number') return null;
892
+ return `${term.type}\u0000${term.type === 'number' ? numberValueKey(term.name) : term.name}`;
893
+ }
894
+
895
+ function isMostGeneralTableGoal(goal, env) {
896
+ const seen = new Set();
897
+ for (const arg of goal.args) {
898
+ const value = derefForLocal(arg, env);
899
+ if (value.type !== 'var' || seen.has(value.name)) return false;
900
+ seen.add(value.name);
901
+ }
902
+ return true;
646
903
  }
647
904
 
648
905
  function scheduleTableFixpointRound(stack, solver, frame) {
@@ -680,10 +937,19 @@ function scheduleTableFixpointRound(stack, solver, frame) {
680
937
 
681
938
 
682
939
  function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solver) {
683
- for (let answerIndex = entry.answers.length - 1; answerIndex >= 0; answerIndex--) {
684
- // Stored table variables belong to the answer template, not to any caller.
685
- // Freshen the complete tuple together so sharing within an answer is kept
686
- // while separate replays cannot alias otherwise independent call variables.
940
+ let selected = null;
941
+ for (let position = 0; position < goal.arity; position++) {
942
+ const value = derefForLocal(goal.args[position], env);
943
+ const key = memoAnswerScalarKey(value);
944
+ if (key == null) continue;
945
+ const bucket = entry.answerIndexes[position]?.get(key) ?? [];
946
+ const fallback = entry.answerVariableFallbacks[position] ?? [];
947
+ const candidateLength = bucket.length + fallback.length;
948
+ if (selected == null || candidateLength < selected.length) {
949
+ selected = { bucket, fallback, length: candidateLength };
950
+ }
951
+ }
952
+ const replay = (answerIndex) => {
687
953
  const storedArgs = entry.answers[answerIndex];
688
954
  const answerArgs = storedArgs.every((arg) => termIsGround(arg))
689
955
  ? storedArgs
@@ -695,12 +961,19 @@ function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solv
695
961
  if (!unify(goal.args[i], answerArgs[i], next)) { ok = false; break; }
696
962
  }
697
963
  if (ok) stack.push({ kind: 'goals', goals: rest, env: next, depth: depth + 1, active });
964
+ };
965
+ if (selected != null) {
966
+ for (let i = selected.fallback.length - 1; i >= 0; i--) replay(selected.fallback[i]);
967
+ for (let i = selected.bucket.length - 1; i >= 0; i--) replay(selected.bucket[i]);
968
+ return;
698
969
  }
970
+ for (let answerIndex = entry.answers.length - 1; answerIndex >= 0; answerIndex--) replay(answerIndex);
699
971
  }
700
972
 
701
973
  function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth, active) {
702
974
  if (group.recursive && !group.cutRecursive && !group.linearNumeric && activeVariantIn(goal, env, active)) return;
703
975
  if (group.fastPi && pushFastPiFrames(stack, goal, rest, env, depth, active)) return;
976
+ if (tryPushGroundScalarRuleFrame(stack, solver, group, goal, rest, env, depth, active)) return;
704
977
  if (tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth, active)) return;
705
978
  const candidates = selectClauseCandidates(group, goal, env);
706
979
  const frames = [];
@@ -774,6 +1047,49 @@ function groupNeedsActiveFrame(group) {
774
1047
  return group.cutReachable !== false || (group.recursive && !group.linearNumeric);
775
1048
  }
776
1049
 
1050
+ function* datalogAnswerSolutions(solver, relation, goal, env) {
1051
+ if (!relation) return;
1052
+ const candidates = datalogCandidateIndexes(
1053
+ relation,
1054
+ goal.args ?? [],
1055
+ env,
1056
+ derefForLocal,
1057
+ memoAnswerScalarKey,
1058
+ );
1059
+ const visit = function* (rowIndex) {
1060
+ const row = relation.rows[rowIndex];
1061
+ const next = env.clone();
1062
+ for (let i = 0; i < goal.arity; i++) {
1063
+ solver.stats.unify_calls++;
1064
+ if (!unify(goal.args[i], row[i], next)) return;
1065
+ }
1066
+ yield next;
1067
+ };
1068
+ if (candidates == null) {
1069
+ for (let rowIndex = 0; rowIndex < relation.rows.length; rowIndex++) yield* visit(rowIndex);
1070
+ return;
1071
+ }
1072
+ for (const rowIndex of candidates) yield* visit(rowIndex);
1073
+ }
1074
+
1075
+ function pushWfsAnswerFrames(stack, model, group, goal, rest, env, depth, active, solver) {
1076
+ const relation = relationForGroup(model, group, 'upper');
1077
+ const lower = relationForGroup(model, group, 'lower');
1078
+ if (!relation) return;
1079
+ for (let rowIndex = relation.rows.length - 1; rowIndex >= 0; rowIndex--) {
1080
+ const row = relation.rows[rowIndex];
1081
+ const next = env.clone();
1082
+ let ok = true;
1083
+ for (let i = 0; i < goal.arity; i++) {
1084
+ solver.stats.unify_calls++;
1085
+ if (!unify(goal.args[i], row[i], next)) { ok = false; break; }
1086
+ }
1087
+ if (!ok) continue;
1088
+ if (!lower?.has(row)) solver.stats.wfs_undefined_answers++;
1089
+ stack.push({ kind: 'goals', goals: rest, env: next, depth: depth + 1, active });
1090
+ }
1091
+ }
1092
+
777
1093
  function bundledMemberIterator(solver, group, goal, env) {
778
1094
  if (solver.registry.eyePrologLibrary !== true ||
779
1095
  !['lists', 'prologue'].includes(group.module) || group.name !== 'member' || group.arity !== 2 ||
@@ -815,12 +1131,17 @@ function bundledLengthIterator(solver, group, goal, env) {
815
1131
  if (length.type === VAR && env._delays?.has(length.name)) return null;
816
1132
 
817
1133
  let cursor = deref(goal.args[0], env);
818
- while (isCons(cursor)) {
819
- cursor = deref(cursor.args[1], env);
820
- }
821
- if (cursor.type === VAR) {
822
- if (env._delays?.has(cursor.name)) return null;
823
- if (length.type === VAR && cursor.name === length.name) return null;
1134
+ // findall/3 can return an internal compact proper-list spine. Its length is
1135
+ // already known; do not expand millions of lazy cons cells merely to decide
1136
+ // whether the bundled length/2 fast path is applicable.
1137
+ if (compactListLength(cursor) == null) {
1138
+ while (isCons(cursor)) {
1139
+ cursor = deref(cursor.args[1], env);
1140
+ }
1141
+ if (cursor.type === VAR) {
1142
+ if (env._delays?.has(cursor.name)) return null;
1143
+ if (length.type === VAR && cursor.name === length.name) return null;
1144
+ }
824
1145
  }
825
1146
  return bundledLengthSolutions(solver, goal, env);
826
1147
  }
@@ -842,6 +1163,11 @@ function* bundledLengthSolutions(solver, goal, env) {
842
1163
 
843
1164
  function* fixedLengthSolutions(solver, list, length, env) {
844
1165
  let cursor = deref(list, env);
1166
+ const compactLength = compactListLength(cursor);
1167
+ if (compactLength != null) {
1168
+ if (compactLength === length) yield env;
1169
+ return;
1170
+ }
845
1171
  let remaining = length;
846
1172
  let steps = 0n;
847
1173
  while (isCons(cursor)) {
@@ -882,6 +1208,12 @@ function* fixedLengthSolutions(solver, list, length, env) {
882
1208
 
883
1209
  function* generatedLengthSolutions(solver, list, length, env) {
884
1210
  let cursor = deref(list, env);
1211
+ const compactLength = compactListLength(cursor);
1212
+ if (compactLength != null) {
1213
+ const next = bindGeneratedLength(solver, length, compactLength, env);
1214
+ if (next != null) yield next;
1215
+ return;
1216
+ }
885
1217
  let count = 0n;
886
1218
  let steps = 0n;
887
1219
  while (isCons(cursor)) {
@@ -952,13 +1284,352 @@ function pushFastPiFrames(stack, goal, rest, env, depth, active) {
952
1284
 
953
1285
 
954
1286
 
955
- const SCALAR_FACT_RUN_FRAME_LIMIT = 100000;
1287
+ function fastGroundPureGoalTruth(solver, goal, env = new Env(), visiting = new Set()) {
1288
+ if (!termIsGround(goal, env)) return null;
1289
+ const resolved = copyResolved(goal, env);
1290
+ if (resolved.type !== COMPOUND && resolved.type !== 'atom') return null;
1291
+ const group = solver.program.findGroup(resolved.name, resolved.arity, resolved.module ?? 'user');
1292
+ if (!group || group.hasCut || group.wfsDatalog) return null;
1293
+
1294
+ if (group.datalogLeastModel === true) {
1295
+ const model = solver.datalogModelFor(group);
1296
+ const relation = relationForDatalogGroup(model, group);
1297
+ return relation?.has(resolved.args ?? []) ?? false;
1298
+ }
1299
+
1300
+ if (group.scalarFactsOnly) {
1301
+ const candidates = selectGroundClauseCandidates(group, resolved);
1302
+ for (let index = 0; index < clauseCandidateLength(candidates); index++) {
1303
+ const clause = clauseCandidateAt(candidates, index);
1304
+ if (matchGroundClause(resolved, clause)?.done) return true;
1305
+ }
1306
+ return false;
1307
+ }
1308
+
1309
+ if (group.recursive || group.tabled) return null;
1310
+ const key = `${group.module}:${group.name}/${group.arity}`;
1311
+ if (visiting.has(key)) return null;
1312
+ const nextVisiting = new Set(visiting);
1313
+ nextVisiting.add(key);
1314
+
1315
+ const candidates = selectGroundClauseCandidates(group, resolved);
1316
+ for (let candidateIndex = 0; candidateIndex < clauseCandidateLength(candidates); candidateIndex++) {
1317
+ const clause = clauseCandidateAt(candidates, candidateIndex);
1318
+ const bindings = new Map();
1319
+ let headMatches = true;
1320
+ for (let i = 0; i < resolved.arity; i++) {
1321
+ const pattern = clause.head.args[i];
1322
+ const value = resolved.args[i];
1323
+ if (pattern.type === 'var') {
1324
+ const previous = bindings.get(pattern.name);
1325
+ if (previous == null) bindings.set(pattern.name, value);
1326
+ else if (!sameGroundTerm(previous, value)) { headMatches = false; break; }
1327
+ } else if (isScalarTerm(pattern)) {
1328
+ if (!sameScalarTerm(pattern, value)) { headMatches = false; break; }
1329
+ } else {
1330
+ return null;
1331
+ }
1332
+ }
1333
+ if (!headMatches) continue;
1334
+
1335
+ let clauseTrue = true;
1336
+ for (const body of clause.body) {
1337
+ if (body.type !== COMPOUND && body.type !== 'atom') return null;
1338
+ const args = [];
1339
+ for (const arg of body.args ?? []) {
1340
+ if (arg.type === 'var') {
1341
+ const value = bindings.get(arg.name);
1342
+ if (value == null) return null;
1343
+ args.push(value);
1344
+ } else if (isScalarTerm(arg)) {
1345
+ args.push(arg);
1346
+ } else {
1347
+ return null;
1348
+ }
1349
+ }
1350
+ const bodyGoal = body.type === 'atom' ? body : compound(body.name, args);
1351
+ if (body.module != null) bodyGoal.module = body.module;
1352
+ const truth = fastGroundPureGoalTruth(solver, bodyGoal, new Env(), nextVisiting);
1353
+ if (truth == null) return null;
1354
+ if (!truth) { clauseTrue = false; break; }
1355
+ }
1356
+ if (clauseTrue) return true;
1357
+ }
1358
+ return false;
1359
+ }
1360
+
1361
+ function termReferencesResolvedVariable(term, variableName, env) {
1362
+ const pending = [term];
1363
+ const seen = new Set();
1364
+ while (pending.length > 0) {
1365
+ const value = derefForLocal(pending.pop(), env);
1366
+ if (value.type === 'var') {
1367
+ if (value.name === variableName) return true;
1368
+ continue;
1369
+ }
1370
+ if (value.type !== COMPOUND || seen.has(value)) continue;
1371
+ seen.add(value);
1372
+ for (const arg of value.args) pending.push(arg);
1373
+ }
1374
+ return false;
1375
+ }
1376
+
1377
+ function envHasObservableAliasTo(variableName, env) {
1378
+ for (let state = env?._state; state != null; state = state.parent) {
1379
+ if (state.bindingName != null && state.bindingName !== variableName &&
1380
+ termReferencesResolvedVariable(state.bindingValue, variableName, env)) return true;
1381
+ if (state.bindings) {
1382
+ for (const [name, value] of state.bindings) {
1383
+ if (name !== variableName && termReferencesResolvedVariable(value, variableName, env)) return true;
1384
+ }
1385
+ }
1386
+ }
1387
+ return false;
1388
+ }
1389
+
1390
+ function* fusedFindallLengthSolutions(solver, innerGoal, countArg, env) {
1391
+ const invoked = copyResolved(innerGoal, env);
1392
+ if (invoked.type !== COMPOUND && invoked.type !== 'atom') return;
1393
+ let count = fastCountPureGoal(solver, invoked, env);
1394
+ if (count != null) {
1395
+ // findall/3's existing collector intentionally carries a ten-million
1396
+ // solution safety cap. Preserve that observable bound in the fused path.
1397
+ if (count > 10000000n) count = 10000000n;
1398
+ } else {
1399
+ const child = solver.cloneForInnerGoal(10000000);
1400
+ count = 0n;
1401
+ try {
1402
+ for (const _ of child.solve([invoked], env.clone(), 0)) count++;
1403
+ } finally {
1404
+ solver.absorbStatsFrom(child);
1405
+ }
1406
+ }
1407
+ const next = env.clone();
1408
+ if (unify(countArg, numberTerm(count), next)) yield next;
1409
+ }
1410
+
1411
+ function findallLengthFusion(solver, goal, rest, env) {
1412
+ if (goal.type !== COMPOUND || goal.name !== 'findall' || goal.arity !== 3 || rest.length === 0) return null;
1413
+ const bag = derefForLocal(goal.args[2], env);
1414
+ if (bag.type !== 'var') return null;
1415
+
1416
+ const lengthGoal = derefForLocal(rest[0], env);
1417
+ if (lengthGoal.type !== COMPOUND || lengthGoal.name !== 'length' || lengthGoal.arity !== 2) return null;
1418
+ const lengthGroup = solver.program.findGroup('length', 2, lengthGoal.module ?? 'user');
1419
+ if (!lengthGroup || lengthGroup.bundledLibrary !== true ||
1420
+ !['lists', 'prologue'].includes(lengthGroup.module)) return null;
1421
+ const lengthList = derefForLocal(lengthGoal.args[0], env);
1422
+ const countArg = derefForLocal(lengthGoal.args[1], env);
1423
+ if (lengthList.type !== 'var' || lengthList.name !== bag.name || countArg.type !== 'var') return null;
1424
+ if (envHasObservableAliasTo(bag.name, env)) return null;
1425
+
1426
+ // The bag may be elided only when no other part of the computation can
1427
+ // observe it. In particular, sharing it with Template or Goal changes
1428
+ // findall/3's variable-scoping behavior and must stay on the ordinary path.
1429
+ if (termReferencesResolvedVariable(goal.args[0], bag.name, env) ||
1430
+ termReferencesResolvedVariable(goal.args[1], bag.name, env) ||
1431
+ termReferencesResolvedVariable(lengthGoal.args[1], bag.name, env)) return null;
1432
+ for (let i = 1; i < rest.length; i++) {
1433
+ if (termReferencesResolvedVariable(rest[i], bag.name, env)) return null;
1434
+ }
1435
+
1436
+ return {
1437
+ iterator: fusedFindallLengthSolutions(solver, goal.args[1], lengthGoal.args[1], env),
1438
+ rest: rest.slice(1),
1439
+ };
1440
+ }
1441
+
1442
+ function countPlanTermIsFlat(term) {
1443
+ return term?.type === 'var' || isScalarTerm(term);
1444
+ }
1445
+
1446
+ function expandPureCountGoal(solver, goal, callerModule, visiting, budget) {
1447
+ if ((goal.type !== COMPOUND && goal.type !== 'atom') ||
1448
+ !(goal.args ?? []).every(countPlanTermIsFlat)) return null;
1449
+ const group = solver.program.findGroup(goal.name, goal.arity, goal.module ?? callerModule ?? 'user');
1450
+ if (!group || group.hasCut || group.recursive || group.tabled || group.wfsDatalog || group.datalogLeastModel) return null;
1451
+ if (group.scalarFactsOnly) return [{ equalities: [], leaves: [{ goal, group }] }];
1452
+
1453
+ const key = `${group.module}:${group.name}/${group.arity}`;
1454
+ if (visiting.has(key)) return null;
1455
+ const nextVisiting = new Set(visiting);
1456
+ nextVisiting.add(key);
1457
+ const out = [];
1458
+
1459
+ for (const clause of group.clauses) {
1460
+ if (budget.remaining-- <= 0) return null;
1461
+ const id = nextFreshId();
1462
+ const variables = new Map();
1463
+ const head = freshTerm(clause.head, `count${id}`, variables);
1464
+ const body = clause.body.map((term) => freshTerm(term, `count${id}`, variables));
1465
+ if (!(head.args ?? []).every(countPlanTermIsFlat)) return null;
1466
+ let branches = [{
1467
+ equalities: (goal.args ?? []).map((arg, index) => [arg, head.args[index]]),
1468
+ leaves: [],
1469
+ }];
1470
+
1471
+ for (const bodyGoal of body) {
1472
+ const expanded = expandPureCountGoal(solver, bodyGoal, group.module, nextVisiting, budget);
1473
+ if (expanded == null) return null;
1474
+ const combined = [];
1475
+ for (const left of branches) {
1476
+ for (const right of expanded) {
1477
+ combined.push({
1478
+ equalities: [...left.equalities, ...right.equalities],
1479
+ leaves: [...left.leaves, ...right.leaves],
1480
+ });
1481
+ if (combined.length > 4096) return null;
1482
+ }
1483
+ }
1484
+ branches = combined;
1485
+ }
1486
+ out.push(...branches);
1487
+ if (out.length > 4096) return null;
1488
+ }
1489
+ return out;
1490
+ }
1491
+
1492
+ function countPlanDeref(term, env, bindings) {
1493
+ let current = term;
1494
+ const seen = new Set();
1495
+ while (current?.type === 'var') {
1496
+ if (seen.has(current.name)) break;
1497
+ seen.add(current.name);
1498
+ const local = bindings.get(current.name);
1499
+ if (local !== undefined) {
1500
+ current = local;
1501
+ continue;
1502
+ }
1503
+ const outer = env.get(current.name);
1504
+ if (outer !== undefined) {
1505
+ current = outer;
1506
+ continue;
1507
+ }
1508
+ break;
1509
+ }
1510
+ return current;
1511
+ }
1512
+
1513
+ function countPlanUnify(left, right, env, bindings, trail) {
1514
+ const a = countPlanDeref(left, env, bindings);
1515
+ const b = countPlanDeref(right, env, bindings);
1516
+ if (a.type === 'var') {
1517
+ if (b.type === 'var' && a.name === b.name) return true;
1518
+ bindings.set(a.name, b);
1519
+ trail.push(a.name);
1520
+ return true;
1521
+ }
1522
+ if (b.type === 'var') {
1523
+ bindings.set(b.name, a);
1524
+ trail.push(b.name);
1525
+ return true;
1526
+ }
1527
+ return sameScalarTerm(a, b);
1528
+ }
1529
+
1530
+ function undoCountBindings(bindings, trail, start) {
1531
+ for (let i = trail.length - 1; i >= start; i--) bindings.delete(trail[i]);
1532
+ trail.length = start;
1533
+ }
1534
+
1535
+ function countLeafCandidateParts(leaf, env, bindings) {
1536
+ const positions = [];
1537
+ const values = [];
1538
+ for (let i = 0; i < leaf.goal.arity; i++) {
1539
+ const value = countPlanDeref(leaf.goal.args[i], env, bindings);
1540
+ if (!isScalarTerm(value)) continue;
1541
+ positions.push(i);
1542
+ values.push(value);
1543
+ }
1544
+ return selectClauseCandidatesForValues(leaf.group, positions, values);
1545
+ }
1546
+
1547
+ function countCandidateTotal(parts) {
1548
+ return clauseCandidateLength(parts.primary) + clauseCandidateLength(parts.fallback);
1549
+ }
1550
+
1551
+ function fastCountBranch(branch, env) {
1552
+ const bindings = new Map();
1553
+ const trail = [];
1554
+ for (const [left, right] of branch.equalities) {
1555
+ if (!countPlanUnify(left, right, env, bindings, trail)) return 0n;
1556
+ }
1557
+
1558
+ let numberCount = 0;
1559
+ let bigCount = null;
1560
+ const increment = () => {
1561
+ if (bigCount != null) {
1562
+ bigCount++;
1563
+ } else if (numberCount < Number.MAX_SAFE_INTEGER) {
1564
+ numberCount++;
1565
+ } else {
1566
+ bigCount = BigInt(numberCount) + 1n;
1567
+ }
1568
+ };
1569
+
1570
+ const remaining = branch.leaves.map((_, index) => index);
1571
+ const visit = (active) => {
1572
+ if (active.length === 0) {
1573
+ increment();
1574
+ return;
1575
+ }
1576
+
1577
+ let bestPosition = 0;
1578
+ let bestParts = null;
1579
+ let bestLength = Infinity;
1580
+ for (let position = 0; position < active.length; position++) {
1581
+ const leaf = branch.leaves[active[position]];
1582
+ const parts = countLeafCandidateParts(leaf, env, bindings);
1583
+ const length = countCandidateTotal(parts);
1584
+ if (length < bestLength) {
1585
+ bestLength = length;
1586
+ bestPosition = position;
1587
+ bestParts = parts;
1588
+ if (length === 0) return;
1589
+ }
1590
+ }
1591
+
1592
+ const leafIndex = active[bestPosition];
1593
+ const leaf = branch.leaves[leafIndex];
1594
+ const nextActive = active.length === 1
1595
+ ? []
1596
+ : [...active.slice(0, bestPosition), ...active.slice(bestPosition + 1)];
1597
+ for (const pass of [bestParts.primary, bestParts.fallback]) {
1598
+ for (let candidateIndex = 0; candidateIndex < clauseCandidateLength(pass); candidateIndex++) {
1599
+ const clause = clauseCandidateAt(pass, candidateIndex);
1600
+ const start = trail.length;
1601
+ let ok = true;
1602
+ for (let i = 0; i < leaf.goal.arity; i++) {
1603
+ if (!countPlanUnify(leaf.goal.args[i], clause.head.args[i], env, bindings, trail)) {
1604
+ ok = false;
1605
+ break;
1606
+ }
1607
+ }
1608
+ if (ok) visit(nextActive);
1609
+ undoCountBindings(bindings, trail, start);
1610
+ }
1611
+ }
1612
+ };
1613
+
1614
+ visit(remaining);
1615
+ return bigCount ?? BigInt(numberCount);
1616
+ }
1617
+
1618
+ function fastCountPureGoal(solver, goal, env) {
1619
+ if (solver.isoStrict || solver.solutionLimit !== Infinity || solver.maxInferences !== Infinity ||
1620
+ env._clpz != null || (env._delays != null && env._delays.size !== 0)) return null;
1621
+ const budget = { remaining: 8192 };
1622
+ const branches = expandPureCountGoal(solver, goal, goal.module ?? 'user', new Set(), budget);
1623
+ if (branches == null) return null;
1624
+ let count = 0n;
1625
+ for (const branch of branches) count += fastCountBranch(branch, env);
1626
+ return count;
1627
+ }
956
1628
 
957
1629
  function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
958
- // Consecutive lookups into predicates that are entirely scalar ground facts
959
- // are common in data-heavy joins. Execute such a prefix as one iterative join
960
- // using local binding arrays, so intermediate fact candidates do not allocate
961
- // cloned Env maps.
1630
+ // Consecutive scalar-fact lookups are common in data-heavy joins. Short
1631
+ // joins are fastest when their continuation frames are materialized locally;
1632
+ // wide joins can explode combinatorially, so they are streamed lazily.
962
1633
  let runLength = 0;
963
1634
  const groups = [];
964
1635
  while (runLength < goals.length) {
@@ -974,10 +1645,16 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
974
1645
  }
975
1646
  if (runLength < 2) return false;
976
1647
 
1648
+ const runGoals = goals.slice(0, runLength);
977
1649
  const rest = goals.slice(runLength);
1650
+ if (runLength >= 4) {
1651
+ pushStreamingScalarFactRun(stack, solver, runGoals, groups, rest, env, depth, active);
1652
+ return true;
1653
+ }
1654
+
978
1655
  const localStack = [{ index: 0, names: [], values: [], depth }];
979
1656
  const frames = [];
980
-
1657
+ const frameLimit = 100000;
981
1658
  while (localStack.length) {
982
1659
  const state = localStack.pop();
983
1660
  solver.stats.max_depth = Math.max(solver.stats.max_depth, state.depth);
@@ -985,11 +1662,17 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
985
1662
  const next = env.clone();
986
1663
  for (let i = 0; i < state.names.length; i++) next.bind(state.names[i], state.values[i]);
987
1664
  frames.push({ kind: 'goals', goals: rest, env: next, depth: state.depth, active });
988
- if (frames.length > SCALAR_FACT_RUN_FRAME_LIMIT) return false;
1665
+ if (frames.length > frameLimit) {
1666
+ // Do not repeat the old bug of abandoning the optimization and then
1667
+ // re-solving through the generic engine. Restart this rare oversized
1668
+ // short join as a streaming local join instead.
1669
+ pushStreamingScalarFactRun(stack, solver, runGoals, groups, rest, env, depth, active);
1670
+ return true;
1671
+ }
989
1672
  continue;
990
1673
  }
991
1674
 
992
- const goal = goals[state.index];
1675
+ const goal = runGoals[state.index];
993
1676
  if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) continue;
994
1677
  solver.stats.solve_one_goal_calls++;
995
1678
  const candidates = selectScalarFactCandidates(groups[state.index], goal, env, state.names, state.values);
@@ -1011,6 +1694,57 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
1011
1694
  return true;
1012
1695
  }
1013
1696
 
1697
+ function pushStreamingScalarFactRun(stack, solver, runGoals, groups, rest, env, depth, active) {
1698
+ const iterator = scalarFactRunSolutions(solver, runGoals, groups, env, depth, active);
1699
+ const first = iterator.next();
1700
+ if (first.done) return;
1701
+ stack.push({
1702
+ kind: 'resumeBuiltin',
1703
+ iterator,
1704
+ goals: rest,
1705
+ depth: depth + runGoals.length,
1706
+ active,
1707
+ });
1708
+ stack.push({
1709
+ kind: 'goals',
1710
+ goals: rest,
1711
+ env: first.value,
1712
+ depth: depth + runGoals.length,
1713
+ active,
1714
+ });
1715
+ }
1716
+
1717
+ function* scalarFactRunSolutions(solver, goals, groups, env, depth, active) {
1718
+ const localStack = [{ index: 0, names: [], values: [] }];
1719
+ while (localStack.length) {
1720
+ const state = localStack.pop();
1721
+ solver.stats.max_depth = Math.max(solver.stats.max_depth, depth + state.index);
1722
+ if (state.index === goals.length) {
1723
+ const next = env.clone();
1724
+ for (let i = 0; i < state.names.length; i++) next.bind(state.names[i], state.values[i]);
1725
+ yield next;
1726
+ continue;
1727
+ }
1728
+
1729
+ const goal = goals[state.index];
1730
+ if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) continue;
1731
+ solver.stats.solve_one_goal_calls++;
1732
+ const candidates = selectScalarFactCandidates(groups[state.index], goal, env, state.names, state.values);
1733
+ const nextStates = [];
1734
+ for (const pass of [candidates.primary, candidates.fallback]) {
1735
+ for (let candidateIndex = 0; candidateIndex < clauseCandidateLength(pass); candidateIndex++) {
1736
+ const clause = clauseCandidateAt(pass, candidateIndex);
1737
+ const match = matchScalarFactLocal(goal, clause.head, env, state.names, state.values);
1738
+ if (!match) continue;
1739
+ solver.stats.unify_calls++;
1740
+ nextStates.push({ index: state.index + 1, names: match.names, values: match.values });
1741
+ }
1742
+ }
1743
+ for (let i = nextStates.length - 1; i >= 0; i--) localStack.push(nextStates[i]);
1744
+ if (solver.solutionsSeen >= solver.solutionLimit) return;
1745
+ }
1746
+ }
1747
+
1014
1748
 
1015
1749
  function activeMightContain(goal, active) {
1016
1750
  if (active.length === 0 || goal.type !== COMPOUND) return false;
@@ -1190,6 +1924,65 @@ function tryPushCompactBinaryChainFrames(stack, solver, group, goal, rest, env,
1190
1924
  }
1191
1925
  }
1192
1926
 
1927
+ function tryPushGroundScalarRuleFrame(stack, solver, group, goal, rest, env, depth, active) {
1928
+ // A fully-ground call to a single flat rule can be checked without freshening
1929
+ // the rule or allocating an Env for every body literal. This is especially
1930
+ // valuable for data-validation joins such as rb(A,B,C,D,E) in ORB Join2.
1931
+ if (!termIsGround(goal, env) || group.clauses.length !== 1) return false;
1932
+ const clause = group.clauses[0];
1933
+ if (clause?.compactBinary === true || clause.body.length < 2 || clause.head.type !== COMPOUND) return false;
1934
+ if (clause.head.name !== goal.name || clause.head.arity !== goal.arity) return false;
1935
+
1936
+ const bindings = new Map();
1937
+ const resolvedGoal = copyResolved(goal, env);
1938
+ for (let i = 0; i < clause.head.arity; i++) {
1939
+ const pattern = clause.head.args[i];
1940
+ const value = resolvedGoal.args[i];
1941
+ if (pattern.type === 'var') {
1942
+ const previous = bindings.get(pattern.name);
1943
+ if (previous == null) bindings.set(pattern.name, value);
1944
+ else if (!sameGroundTerm(previous, value)) return true;
1945
+ } else if (isScalarTerm(pattern)) {
1946
+ if (!sameGroundTerm(pattern, value)) return true;
1947
+ } else {
1948
+ return false;
1949
+ }
1950
+ }
1951
+
1952
+ for (const body of clause.body) {
1953
+ if (body.type !== COMPOUND) return false;
1954
+ const args = [];
1955
+ for (const arg of body.args) {
1956
+ if (arg.type === 'var') {
1957
+ const value = bindings.get(arg.name);
1958
+ if (value == null) return false;
1959
+ args.push(value);
1960
+ } else if (isScalarTerm(arg)) {
1961
+ args.push(arg);
1962
+ } else {
1963
+ return false;
1964
+ }
1965
+ }
1966
+ const bodyGroup = solver.program.findGroup(body.name, body.arity, body.module ?? group.module);
1967
+ if (!bodyGroup || bodyGroup.tabled || !bodyGroup.scalarFactsOnly) return false;
1968
+ const bodyGoal = compound(body.name, args);
1969
+ const candidates = selectGroundClauseCandidates(bodyGroup, bodyGoal);
1970
+ let matches = 0;
1971
+ for (let candidateIndex = 0; candidateIndex < clauseCandidateLength(candidates); candidateIndex++) {
1972
+ const fact = clauseCandidateAt(candidates, candidateIndex);
1973
+ const matched = matchGroundClause(bodyGoal, fact);
1974
+ if (matched?.done) {
1975
+ matches++;
1976
+ if (matches > 1) return false; // Preserve duplicate-proof semantics.
1977
+ }
1978
+ }
1979
+ if (matches === 0) return true;
1980
+ }
1981
+
1982
+ stack.push({ kind: 'goals', goals: rest, env, depth: depth + 1, active });
1983
+ return true;
1984
+ }
1985
+
1193
1986
  function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth, active) {
1194
1987
  if (tryPushCompactBinaryChainFrames(stack, solver, group, goal, rest, env, depth, active)) return true;
1195
1988
  // Compress deterministic ground single-goal chains such as deep taxonomy
@@ -1402,7 +2195,20 @@ function rememberMemoAnswer(entry, goal, env) {
1402
2195
  const key = answerKeys.join('\x1f');
1403
2196
  if (entry.answerKeys.has(key)) return;
1404
2197
  entry.answerKeys.add(key);
2198
+ const answerIndex = entry.answers.length;
1405
2199
  entry.answers.push(answerArgs);
2200
+ for (let position = 0; position < answerArgs.length; position++) {
2201
+ const value = answerArgs[position];
2202
+ const scalarKey = memoAnswerScalarKey(value);
2203
+ if (scalarKey != null) {
2204
+ const index = entry.answerIndexes[position];
2205
+ let bucket = index.get(scalarKey);
2206
+ if (bucket == null) index.set(scalarKey, bucket = []);
2207
+ bucket.push(answerIndex);
2208
+ } else if (value.type === 'var') {
2209
+ entry.answerVariableFallbacks[position].push(answerIndex);
2210
+ }
2211
+ }
1406
2212
  }
1407
2213
 
1408
2214
  function activeVariantIn(goal, env, active) {
@@ -1534,19 +2340,13 @@ function derefForLocal(term, env) {
1534
2340
  function memoKey(goal, env, group = null) {
1535
2341
  const required = group?.tableInputPositions ?? [];
1536
2342
  const ground = goal.args.map((arg) => termIsGround(arg, env));
1537
- const hasBound = required.length > 0
2343
+ const hasBound = group?.tableAllVariants === true || (required.length > 0
1538
2344
  ? required.some((index) => ground[index])
1539
- : ground.some(Boolean);
1540
- // Automatic tabling only admits calls with a ground input. Avoid building a
1541
- // potentially huge canonical key for the non-ground structural calls that
1542
- // will use the ordinary active-call guard instead.
2345
+ : ground.some(Boolean));
1543
2346
  if (!hasBound) return { hasBound: false, text: '' };
1544
2347
 
1545
2348
  const variables = new Map();
1546
- const parts = goal.args.map((arg) => {
1547
- const value = derefForLocal(arg, env);
1548
- return value.type === 'var' ? '_' : canonicalTermKey(value, env, variables);
1549
- });
2349
+ const parts = goal.args.map((arg) => canonicalTermKey(arg, env, variables));
1550
2350
  return { hasBound, text: parts.join('|') };
1551
2351
  }
1552
2352
 
@@ -1597,10 +2397,10 @@ function copyResolvedWithKey(term, env, variables) {
1597
2397
  return { term: termModuleCache.variable(value.name), key: `var:${id}` };
1598
2398
  }
1599
2399
  if (!value.args?.length) {
1600
- return {
1601
- term: new termModuleCache.Term(value.type, value.name, value.args),
1602
- key: `${value.type}:${value.name}`,
1603
- };
2400
+ // Atomic terms are immutable in the solver. Share them across table
2401
+ // answers instead of allocating a fresh host object for every cell of a
2402
+ // large closure such as tc/2.
2403
+ return { term: value, key: `${value.type}:${value.name}` };
1604
2404
  }
1605
2405
  const children = value.args.map((arg) => copyResolvedWithKey(arg, env, variables));
1606
2406
  return {