eyeprolog 1.3.3 → 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.
Files changed (38) hide show
  1. package/README.md +78 -4
  2. package/examples/book/README.md +2 -1
  3. package/examples/book/chapter-07/02-move.pl +4 -0
  4. package/examples/book/chapter-08/01-findall.pl +1 -0
  5. package/examples/book/chapter-39/04-cost.pl +1 -0
  6. package/examples/chart-parser.pl +1 -0
  7. package/examples/dog.pl +1 -0
  8. package/examples/donald-gerald-robert.pl +1 -0
  9. package/examples/equality-saturation.pl +1 -0
  10. package/examples/job-shop-scheduling.pl +1 -0
  11. package/examples/knapsack-optimization.pl +1 -0
  12. package/examples/knuth-bendix-completion.pl +1 -0
  13. package/examples/matrix-chain-order.pl +1 -0
  14. package/examples/missionaries-cannibals.pl +1 -0
  15. package/examples/prime-range.pl +1 -0
  16. package/examples/register-allocation.pl +1 -0
  17. package/examples/send-more-money.pl +1 -0
  18. package/examples/stable-marriage.pl +1 -0
  19. package/examples/totient-summatory.pl +1 -0
  20. package/examples/weighted-interval-scheduling.pl +1 -0
  21. package/index.d.ts +7 -0
  22. package/package.json +1 -1
  23. package/src/datalog.js +354 -0
  24. package/src/iso.js +148 -14
  25. package/src/lib/iso_ext.pl +4 -0
  26. package/src/lib/lists.pl +0 -3
  27. package/src/program.js +258 -20
  28. package/src/solver.js +890 -46
  29. package/src/standard-library.js +12 -7
  30. package/src/term.js +1 -1
  31. package/src/wfs.js +338 -0
  32. package/test/conformance/ISO-COMPLIANCE.md +2 -1
  33. package/test/conformance/README.md +4 -1
  34. package/test/run-conformance.mjs +1 -0
  35. package/test/run-regression.mjs +230 -3
  36. package/the-art-of-eyeprolog.md +95 -21
  37. package/why-eyeprolog.md +17 -0
  38. /package/examples/book/chapter-07/{02-all_tests_pass.pl → 03-all_tests_pass.pl} +0 -0
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,7 +474,49 @@ export class Solver {
374
474
  }
375
475
  qualifyMetaArguments(goal, group);
376
476
 
377
- const lengthIterator = prologueLengthIterator(this, group, goal, env);
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
+
502
+ const memberIterator = bundledMemberIterator(this, group, goal, env);
503
+ if (memberIterator != null) {
504
+ const firstResult = memberIterator.next();
505
+ if (firstResult.done) break;
506
+ stack.push({
507
+ kind: 'resumeBuiltin',
508
+ iterator: memberIterator,
509
+ goals: rest,
510
+ depth: depth + 1,
511
+ active,
512
+ });
513
+ goals = rest;
514
+ env = firstResult.value;
515
+ depth++;
516
+ continue;
517
+ }
518
+
519
+ const lengthIterator = bundledLengthIterator(this, group, goal, env);
378
520
  if (lengthIterator != null) {
379
521
  const firstResult = lengthIterator.next();
380
522
  if (firstResult.done) break;
@@ -391,14 +533,29 @@ export class Solver {
391
533
  continue;
392
534
  }
393
535
 
394
- if (group.tabled) {
536
+ if (group.tabled && !(this.skipListTailTabling && group.listTailRecursive)) {
395
537
  const key = memoKey(goal, env, group);
396
538
  if (key.hasBound) {
397
539
  const mapKey = `${group.module}:${goal.name}/${goal.arity}:${key.text}`;
398
- 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
+ }
399
553
  if (!entry) {
400
- entry = makeMemoEntry();
401
- 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
+ }
402
559
  }
403
560
  if (this.tableCoordinator) this.tableCoordinator.entries.add(entry);
404
561
  if (entry.complete) {
@@ -416,7 +573,7 @@ export class Solver {
416
573
  }
417
574
  break;
418
575
  }
419
- if (this.tableCoordinator && activeVariantIn(goal, env, active)) {
576
+ if (this.tableCoordinator && (usingBroadTable || activeVariantIn(goal, env, active))) {
420
577
  this.tableCoordinator.cycleSeen = true;
421
578
  }
422
579
  pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, this);
@@ -449,6 +606,84 @@ export class Solver {
449
606
  }
450
607
  }
451
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
+
452
687
  activeVariant(goal, env) {
453
688
  return activeVariantIn(goal, env, this.active);
454
689
  }
@@ -508,6 +743,14 @@ export class Solver {
508
743
  const group = this.program.findGroup(goal.name, goal.arity, goal.module ?? 'user');
509
744
  if (!group) return;
510
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
+ }
511
754
  if (group.tabled) {
512
755
  yield* this.solveMemoizedGoal(group, goal, rest, env, depth);
513
756
  return;
@@ -515,6 +758,15 @@ export class Solver {
515
758
  yield* this.solveUserGoalUncached(group, goal, rest, env, depth);
516
759
  }
517
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
+
518
770
  *solveMemoizedGoal(group, goal, rest, env, depth) {
519
771
  yield* this.solve([goal, ...rest], env, depth);
520
772
  }
@@ -624,8 +876,30 @@ function defaultPrologFlags(unknown = 'error', strictIso = false) {
624
876
  }
625
877
 
626
878
 
627
- function makeMemoEntry() {
628
- 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;
629
903
  }
630
904
 
631
905
  function scheduleTableFixpointRound(stack, solver, frame) {
@@ -663,10 +937,19 @@ function scheduleTableFixpointRound(stack, solver, frame) {
663
937
 
664
938
 
665
939
  function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solver) {
666
- for (let answerIndex = entry.answers.length - 1; answerIndex >= 0; answerIndex--) {
667
- // Stored table variables belong to the answer template, not to any caller.
668
- // Freshen the complete tuple together so sharing within an answer is kept
669
- // 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) => {
670
953
  const storedArgs = entry.answers[answerIndex];
671
954
  const answerArgs = storedArgs.every((arg) => termIsGround(arg))
672
955
  ? storedArgs
@@ -678,12 +961,19 @@ function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solv
678
961
  if (!unify(goal.args[i], answerArgs[i], next)) { ok = false; break; }
679
962
  }
680
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;
681
969
  }
970
+ for (let answerIndex = entry.answers.length - 1; answerIndex >= 0; answerIndex--) replay(answerIndex);
682
971
  }
683
972
 
684
973
  function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth, active) {
685
974
  if (group.recursive && !group.cutRecursive && !group.linearNumeric && activeVariantIn(goal, env, active)) return;
686
975
  if (group.fastPi && pushFastPiFrames(stack, goal, rest, env, depth, active)) return;
976
+ if (tryPushGroundScalarRuleFrame(stack, solver, group, goal, rest, env, depth, active)) return;
687
977
  if (tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth, active)) return;
688
978
  const candidates = selectClauseCandidates(group, goal, env);
689
979
  const frames = [];
@@ -757,9 +1047,79 @@ function groupNeedsActiveFrame(group) {
757
1047
  return group.cutReachable !== false || (group.recursive && !group.linearNumeric);
758
1048
  }
759
1049
 
760
- function prologueLengthIterator(solver, group, goal, env) {
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
+
1093
+ function bundledMemberIterator(solver, group, goal, env) {
761
1094
  if (solver.registry.eyePrologLibrary !== true ||
762
- group.module !== 'prologue' || group.name !== 'length' || group.arity !== 2 ||
1095
+ !['lists', 'prologue'].includes(group.module) || group.name !== 'member' || group.arity !== 2 ||
1096
+ group.bundledLibrary !== true || group.clauses.length !== 2) {
1097
+ return null;
1098
+ }
1099
+
1100
+ // Keep the relational/open-list cases in ordinary Prolog. When the second
1101
+ // argument is already a finite list, however, the canonical two-clause
1102
+ // member/2 definition is exactly a left-to-right scan and can avoid clause
1103
+ // freshening and recursive solver frames for every element.
1104
+ let cursor = deref(goal.args[1], env);
1105
+ while (isCons(cursor)) cursor = deref(cursor.args[1], env);
1106
+ if (!isEmptyList(cursor)) return null;
1107
+ return bundledMemberSolutions(solver, goal, env);
1108
+ }
1109
+
1110
+ function* bundledMemberSolutions(solver, goal, env) {
1111
+ let cursor = deref(goal.args[1], env);
1112
+ while (isCons(cursor)) {
1113
+ const next = env.clone();
1114
+ solver.stats.unify_calls++;
1115
+ if (unify(goal.args[0], cursor.args[0], next)) yield next;
1116
+ cursor = deref(cursor.args[1], env);
1117
+ }
1118
+ }
1119
+
1120
+ function bundledLengthIterator(solver, group, goal, env) {
1121
+ if (solver.registry.eyePrologLibrary !== true ||
1122
+ !['lists', 'prologue'].includes(group.module) || group.name !== 'length' || group.arity !== 2 ||
763
1123
  group.bundledLibrary !== true || group.clauses.length !== 2) {
764
1124
  return null;
765
1125
  }
@@ -771,17 +1131,22 @@ function prologueLengthIterator(solver, group, goal, env) {
771
1131
  if (length.type === VAR && env._delays?.has(length.name)) return null;
772
1132
 
773
1133
  let cursor = deref(goal.args[0], env);
774
- while (isCons(cursor)) {
775
- cursor = deref(cursor.args[1], env);
776
- }
777
- if (cursor.type === VAR) {
778
- if (env._delays?.has(cursor.name)) return null;
779
- 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
+ }
780
1145
  }
781
- return prologueLengthSolutions(solver, goal, env);
1146
+ return bundledLengthSolutions(solver, goal, env);
782
1147
  }
783
1148
 
784
- function* prologueLengthSolutions(solver, goal, env) {
1149
+ function* bundledLengthSolutions(solver, goal, env) {
785
1150
  const requestedLength = deref(goal.args[1], env);
786
1151
  if (requestedLength.type !== VAR) {
787
1152
  if (requestedLength.type !== NUMBER || !isDecimalInteger(requestedLength.name)) {
@@ -798,6 +1163,11 @@ function* prologueLengthSolutions(solver, goal, env) {
798
1163
 
799
1164
  function* fixedLengthSolutions(solver, list, length, env) {
800
1165
  let cursor = deref(list, env);
1166
+ const compactLength = compactListLength(cursor);
1167
+ if (compactLength != null) {
1168
+ if (compactLength === length) yield env;
1169
+ return;
1170
+ }
801
1171
  let remaining = length;
802
1172
  let steps = 0n;
803
1173
  while (isCons(cursor)) {
@@ -838,6 +1208,12 @@ function* fixedLengthSolutions(solver, list, length, env) {
838
1208
 
839
1209
  function* generatedLengthSolutions(solver, list, length, env) {
840
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
+ }
841
1217
  let count = 0n;
842
1218
  let steps = 0n;
843
1219
  while (isCons(cursor)) {
@@ -908,13 +1284,352 @@ function pushFastPiFrames(stack, goal, rest, env, depth, active) {
908
1284
 
909
1285
 
910
1286
 
911
- 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
+ }
912
1628
 
913
1629
  function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
914
- // Consecutive lookups into predicates that are entirely scalar ground facts
915
- // are common in data-heavy joins. Execute such a prefix as one iterative join
916
- // using local binding arrays, so intermediate fact candidates do not allocate
917
- // 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.
918
1633
  let runLength = 0;
919
1634
  const groups = [];
920
1635
  while (runLength < goals.length) {
@@ -930,10 +1645,16 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
930
1645
  }
931
1646
  if (runLength < 2) return false;
932
1647
 
1648
+ const runGoals = goals.slice(0, runLength);
933
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
+
934
1655
  const localStack = [{ index: 0, names: [], values: [], depth }];
935
1656
  const frames = [];
936
-
1657
+ const frameLimit = 100000;
937
1658
  while (localStack.length) {
938
1659
  const state = localStack.pop();
939
1660
  solver.stats.max_depth = Math.max(solver.stats.max_depth, state.depth);
@@ -941,11 +1662,17 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
941
1662
  const next = env.clone();
942
1663
  for (let i = 0; i < state.names.length; i++) next.bind(state.names[i], state.values[i]);
943
1664
  frames.push({ kind: 'goals', goals: rest, env: next, depth: state.depth, active });
944
- 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
+ }
945
1672
  continue;
946
1673
  }
947
1674
 
948
- const goal = goals[state.index];
1675
+ const goal = runGoals[state.index];
949
1676
  if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) continue;
950
1677
  solver.stats.solve_one_goal_calls++;
951
1678
  const candidates = selectScalarFactCandidates(groups[state.index], goal, env, state.names, state.values);
@@ -967,6 +1694,57 @@ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
967
1694
  return true;
968
1695
  }
969
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
+
970
1748
 
971
1749
  function activeMightContain(goal, active) {
972
1750
  if (active.length === 0 || goal.type !== COMPOUND) return false;
@@ -1146,6 +1924,65 @@ function tryPushCompactBinaryChainFrames(stack, solver, group, goal, rest, env,
1146
1924
  }
1147
1925
  }
1148
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
+
1149
1986
  function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth, active) {
1150
1987
  if (tryPushCompactBinaryChainFrames(stack, solver, group, goal, rest, env, depth, active)) return true;
1151
1988
  // Compress deterministic ground single-goal chains such as deep taxonomy
@@ -1358,7 +2195,20 @@ function rememberMemoAnswer(entry, goal, env) {
1358
2195
  const key = answerKeys.join('\x1f');
1359
2196
  if (entry.answerKeys.has(key)) return;
1360
2197
  entry.answerKeys.add(key);
2198
+ const answerIndex = entry.answers.length;
1361
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
+ }
1362
2212
  }
1363
2213
 
1364
2214
  function activeVariantIn(goal, env, active) {
@@ -1490,19 +2340,13 @@ function derefForLocal(term, env) {
1490
2340
  function memoKey(goal, env, group = null) {
1491
2341
  const required = group?.tableInputPositions ?? [];
1492
2342
  const ground = goal.args.map((arg) => termIsGround(arg, env));
1493
- const hasBound = required.length > 0
2343
+ const hasBound = group?.tableAllVariants === true || (required.length > 0
1494
2344
  ? required.some((index) => ground[index])
1495
- : ground.some(Boolean);
1496
- // Automatic tabling only admits calls with a ground input. Avoid building a
1497
- // potentially huge canonical key for the non-ground structural calls that
1498
- // will use the ordinary active-call guard instead.
2345
+ : ground.some(Boolean));
1499
2346
  if (!hasBound) return { hasBound: false, text: '' };
1500
2347
 
1501
2348
  const variables = new Map();
1502
- const parts = goal.args.map((arg) => {
1503
- const value = derefForLocal(arg, env);
1504
- return value.type === 'var' ? '_' : canonicalTermKey(value, env, variables);
1505
- });
2349
+ const parts = goal.args.map((arg) => canonicalTermKey(arg, env, variables));
1506
2350
  return { hasBound, text: parts.join('|') };
1507
2351
  }
1508
2352
 
@@ -1553,10 +2397,10 @@ function copyResolvedWithKey(term, env, variables) {
1553
2397
  return { term: termModuleCache.variable(value.name), key: `var:${id}` };
1554
2398
  }
1555
2399
  if (!value.args?.length) {
1556
- return {
1557
- term: new termModuleCache.Term(value.type, value.name, value.args),
1558
- key: `${value.type}:${value.name}`,
1559
- };
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}` };
1560
2404
  }
1561
2405
  const children = value.args.map((arg) => copyResolvedWithKey(arg, env, variables));
1562
2406
  return {