eyelang 4.0.7 → 4.0.8

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/docs/guide.md CHANGED
@@ -604,7 +604,7 @@ path(a, Y).
604
604
  status(Case, accepted).
605
605
  ```
606
606
 
607
- Ground facts use a fast path that avoids freshening and copying a rule body. Recursive-predicate detection uses an explicit work stack, which keeps large predicate chains safer in the browser. eyelang automatically tables recursive helper groups, including dependencies reached through supported meta-goals. Directly materialized recursive relations and other goals use ordinary depth-first resolution; this preserves cyclic open-relation traversal. Fully open calls retain the ordinary path rather than materializing an entire relation. Authors do not need a search-control declaration.
607
+ Ground facts use a fast path that avoids freshening and copying a rule body. Recursive-predicate detection uses an explicit work stack, which keeps large predicate chains safer in the browser. eyelang automatically tables positive recursive groups, including dependencies reached through supported meta-goals and directly materialized relations. Cyclic calls iterate to an answer fixed point before replay. Recursive components containing a negative dependency retain guarded ordinary resolution because positive fixed-point tabling is not a semantics for unstratified negation. The engine also infers common structurally decreasing input positions; calls with an unbound structural input and fully open calls retain ordinary resolution rather than trying to materialize a potentially infinite relation. Authors do not need a search-control declaration.
608
608
 
609
609
  Predicates can also carry advisory mode and determinism declarations for documentation and host tooling:
610
610
 
@@ -639,16 +639,20 @@ Declarations are written as ordinary facts, but the host treats them specially.
639
639
  eyelang automatically combines ordinary goal-directed resolution with tabled
640
640
  resolution. Predicate dependency cycles are detected when a program is loaded,
641
641
  including dependencies inside conjunctions, negation, `once/1`, `forall/2`, and
642
- aggregation goals. Recursive helper predicate groups are tabled automatically;
643
- directly materialized recursive relations and other groups use ordinary
644
- depth-first resolution and indexed fact lookup. This conservative boundary
645
- avoids prematurely completing bound subcalls when an open output relation walks
646
- cyclic data. Authors do not select an execution strategy in source code.
647
-
648
- For a tabled call with at least one bound argument, answers may be cached and
649
- reused within a solver run. Fully open calls continue with ordinary resolution
650
- so that tabling does not force the whole relation to be materialized. This is a
651
- search-control strategy and does not change the logical meaning of a program.
642
+ aggregation goals. Positive recursive predicate groups, including directly
643
+ materialized relations, are tabled automatically. Cyclic calls are evaluated to
644
+ an answer fixed point, so a table is complete before its answers are replayed.
645
+ Recursive components containing a negative dependency retain guarded ordinary
646
+ resolution because positive least-fixed-point tabling does not define
647
+ unstratified negation. Other groups use ordinary depth-first resolution and
648
+ indexed fact lookup. Authors do not select an execution strategy in source code.
649
+
650
+ For a tabled call with a ground input argument, answers may be cached and reused
651
+ within a solver run. The engine infers common structurally decreasing input
652
+ positions from recursive clause heads. Calls whose inferred structural input is
653
+ not ground, and fully open calls, continue with ordinary resolution so that an
654
+ infinite relation is not forced into a table. This is a search-control strategy
655
+ and does not change the logical meaning of a program.
652
656
 
653
657
  ### 11.2 Default-output materialization
654
658
 
@@ -3,4 +3,4 @@ chart_parser_answer(parsed, ambiguous_pp).
3
3
  chart_parser_answer(parse_count, count(command, 1)).
4
4
  chart_parser_answer(parse_count, count(ambiguous_pp, 1)).
5
5
  chart_parser_answer(noun_phrase_count, count(command, 2)).
6
- chart_parser_answer(noun_phrase_count, count(ambiguous_pp, 3)).
6
+ chart_parser_answer(noun_phrase_count, count(ambiguous_pp, 4)).
@@ -421,7 +421,21 @@ why(
421
421
  chart_parser_answer(parse_count, count(ambiguous_pp, 1)).
422
422
  why(
423
423
  chart_parser_answer(parse_count, count(ambiguous_pp, 1)),
424
- no_proof
424
+ proof(
425
+ goal(chart_parser_answer(parse_count, count(ambiguous_pp, 1))),
426
+ by(rule("chart-parser.pl", clause(33))),
427
+ bindings([binding("Sentence", ambiguous_pp), binding("Count", 1), binding("Length", 8)]),
428
+ uses([
429
+ proof(
430
+ goal(sentence(ambiguous_pp, 8)),
431
+ by(fact("chart-parser.pl", clause(3)))
432
+ ),
433
+ proof(
434
+ goal(countall(span(ambiguous_pp, s, 0, 8), 1)),
435
+ by(builtin(countall, 2))
436
+ )
437
+ ])
438
+ )
425
439
  ).
426
440
 
427
441
  chart_parser_answer(noun_phrase_count, count(command, 2)).
@@ -448,20 +462,20 @@ why(
448
462
  )
449
463
  ).
450
464
 
451
- chart_parser_answer(noun_phrase_count, count(ambiguous_pp, 3)).
465
+ chart_parser_answer(noun_phrase_count, count(ambiguous_pp, 4)).
452
466
  why(
453
- chart_parser_answer(noun_phrase_count, count(ambiguous_pp, 3)),
467
+ chart_parser_answer(noun_phrase_count, count(ambiguous_pp, 4)),
454
468
  proof(
455
- goal(chart_parser_answer(noun_phrase_count, count(ambiguous_pp, 3))),
469
+ goal(chart_parser_answer(noun_phrase_count, count(ambiguous_pp, 4))),
456
470
  by(rule("chart-parser.pl", clause(34))),
457
- bindings([binding("Sentence", ambiguous_pp), binding("Count", 3), binding("Length", 8)]),
471
+ bindings([binding("Sentence", ambiguous_pp), binding("Count", 4), binding("Length", 8)]),
458
472
  uses([
459
473
  proof(
460
474
  goal(sentence(ambiguous_pp, 8)),
461
475
  by(fact("chart-parser.pl", clause(3)))
462
476
  ),
463
477
  proof(
464
- goal(countall(span(ambiguous_pp, np, _start, _end), 3)),
478
+ goal(countall(span(ambiguous_pp, np, _start, _end), 4)),
465
479
  by(builtin(countall, 2))
466
480
  ),
467
481
  proof(
package/index.d.ts CHANGED
@@ -45,6 +45,7 @@ export interface EyelangPredicateGroup {
45
45
  mode: string[] | null;
46
46
  determinism: 'det' | 'semidet' | null;
47
47
  recursive: boolean;
48
+ tableInputPositions: number[];
48
49
  negationStratum: number | null;
49
50
  }
50
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyelang",
3
- "version": "4.0.7",
3
+ "version": "4.0.8",
4
4
  "description": "A small reasoning language with ISO Prolog-compatible surface syntax.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/program.js CHANGED
@@ -44,6 +44,7 @@ export class Program {
44
44
  mode: null,
45
45
  determinism: null,
46
46
  recursive: false,
47
+ tableInputPositions: [],
47
48
  scalarFactsOnly: true,
48
49
  negationStratum: null,
49
50
  };
@@ -117,13 +118,18 @@ export class Program {
117
118
  const groups = [...this.groups.values()];
118
119
  const indexByGroup = new Map(groups.map((group, i) => [group, i]));
119
120
  const deps = groups.map(() => new Set());
121
+ const negativeEdges = [];
120
122
  for (const group of groups) {
121
123
  const groupIndex = indexByGroup.get(group);
122
124
  for (const clause of group.clauses) {
123
125
  for (const goal of clause.body) {
124
126
  for (const dependency of collectGoalDependencies(goal, false)) {
125
127
  const dep = this.groups.get(dependency.key);
126
- if (dep) deps[groupIndex].add(indexByGroup.get(dep));
128
+ if (dep) {
129
+ const dependencyIndex = indexByGroup.get(dep);
130
+ deps[groupIndex].add(dependencyIndex);
131
+ if (dependency.negative) negativeEdges.push([groupIndex, dependencyIndex]);
132
+ }
127
133
  }
128
134
  }
129
135
  }
@@ -143,13 +149,14 @@ export class Program {
143
149
  }
144
150
  }
145
151
  group.recursive = recursive;
152
+ group.tableInputPositions = recursive
153
+ ? inferStructuralInputPositions(group)
154
+ : [];
146
155
  // Recursive predicates are proved with tabling automatically, keeping
147
- // search control inside the engine.
148
- // A directly materialized recursive relation is normally entered as a
149
- // fully open call. Keep that relation on ordinary resolution: tabling its
150
- // later bound subcalls can prematurely complete answers on cyclic data.
151
- // Recursive helpers demanded by an output predicate are safe candidates.
152
- group.tabled = recursive && !this.materializedGroups.has(`${group.name}/${group.arity}`);
156
+ // search control inside the engine. Cycles through negation retain
157
+ // guarded resolution because positive least-fixed-point tabling is not
158
+ // sound for an unstratified negative component.
159
+ group.tabled = recursive && !componentHasNegativeEdge(start, deps, negativeEdges);
153
160
  }
154
161
  }
155
162
 
@@ -275,8 +282,65 @@ export class Program {
275
282
  }
276
283
  }
277
284
 
285
+ function componentHasNegativeEdge(start, deps, negativeEdges) {
286
+ const forward = reachableIndexes(start, deps);
287
+ const component = new Set([...forward].filter((index) => reachableIndexes(index, deps).has(start)));
288
+ return negativeEdges.some(([from, to]) => component.has(from) && component.has(to));
289
+ }
290
+
291
+ function reachableIndexes(start, deps) {
292
+ const seen = new Set();
293
+ const stack = [start];
294
+ while (stack.length) {
295
+ const current = stack.pop();
296
+ if (seen.has(current)) continue;
297
+ seen.add(current);
298
+ for (const next of deps[current]) if (!seen.has(next)) stack.push(next);
299
+ }
300
+ return seen;
301
+ }
302
+
303
+ function inferStructuralInputPositions(group) {
304
+ const patternedPositions = new Set();
305
+ const linkedInputPositions = new Set();
306
+ for (const clause of group.clauses) {
307
+ const recursiveGoals = clause.body.filter((goal) =>
308
+ goal.type === COMPOUND && goal.name === group.name && goal.arity === group.arity
309
+ );
310
+ if (recursiveGoals.length === 0) continue;
311
+ const clauseChangedPositions = new Set();
312
+ for (let index = 0; index < clause.head.args.length; index++) {
313
+ if (clause.head.args[index].type !== 'var') patternedPositions.add(index);
314
+ if (recursiveGoals.some((goal) => !sameClauseTerm(clause.head.args[index], goal.args[index]))) {
315
+ clauseChangedPositions.add(index);
316
+ }
317
+ }
318
+ for (let index = 0; index < clause.head.args.length; index++) {
319
+ const headArg = clause.head.args[index];
320
+ if (headArg.type !== 'var' || !clauseChangedPositions.has(index)) continue;
321
+ if (clause.head.args.some((pattern, patternIndex) =>
322
+ patternIndex !== index && pattern.type !== 'var' && termContainsVariable(pattern, headArg.name)
323
+ )) linkedInputPositions.add(index);
324
+ }
325
+ }
326
+ if (linkedInputPositions.size > 0) {
327
+ return [[...linkedInputPositions].sort((left, right) => left - right)[0]];
328
+ }
329
+ if (patternedPositions.size > 0) {
330
+ return [[...patternedPositions].sort((left, right) => left - right)[0]];
331
+ }
332
+ return Array.from({ length: group.arity }, (_, index) => index);
333
+ }
278
334
 
335
+ function termContainsVariable(term, name) {
336
+ if (term.type === 'var') return term.name === name;
337
+ return term.args.some((arg) => termContainsVariable(arg, name));
338
+ }
279
339
 
340
+ function sameClauseTerm(left, right) {
341
+ if (left.type !== right.type || left.name !== right.name || left.args.length !== right.args.length) return false;
342
+ return left.args.every((arg, index) => sameClauseTerm(arg, right.args[index]));
343
+ }
280
344
 
281
345
  function termHasNoVariables(term) {
282
346
  if (!term || term.type === 'var') return false;
package/src/solver.js CHANGED
@@ -19,6 +19,7 @@ export class Solver {
19
19
  this.solutionsSeen = 0;
20
20
  this.active = [];
21
21
  this.memo = new Map();
22
+ this.tableCoordinator = null;
22
23
  this.groundChainSuccess = new Set();
23
24
  this.stats = {
24
25
  completed_goal_lists: 0,
@@ -29,6 +30,7 @@ export class Solver {
29
30
  max_goal_count: 0,
30
31
  deterministic_builtin_successes: 0,
31
32
  deterministic_builtin_failures: 0,
33
+ table_fixpoint_rounds: 0,
32
34
  };
33
35
  }
34
36
 
@@ -58,6 +60,21 @@ export class Solver {
58
60
  const stack = [{ kind: 'goals', goals, env, depth, active: savedActive.slice() }];
59
61
  while (stack.length) {
60
62
  const frame = stack.pop();
63
+ if (frame.kind === 'completeTableFixpointRound') {
64
+ frame.entry.computing = false;
65
+ const answerCount = frame.entry.answers.length;
66
+ if (this.tableCoordinator?.cycleSeen && answerCount > frame.answerCountBefore) {
67
+ scheduleTableFixpointRound(stack, this, frame);
68
+ } else {
69
+ for (const entry of this.tableCoordinator?.entries ?? [frame.entry]) {
70
+ entry.computing = false;
71
+ entry.complete = true;
72
+ }
73
+ this.tableCoordinator = null;
74
+ pushMemoAnswerFrames(stack, frame.entry, frame.goal, frame.rest, frame.env, frame.depth, frame.active, this);
75
+ }
76
+ continue;
77
+ }
61
78
  if (frame.kind === 'completeMemo') {
62
79
  frame.entry.computing = false;
63
80
  frame.entry.complete = true;
@@ -91,6 +108,7 @@ export class Solver {
91
108
  }
92
109
  if (first?.kind === 'memoStore') {
93
110
  rememberMemoAnswer(first.entry, first.goal, env);
111
+ if (goals.length === 1) break;
94
112
  goals = goals.slice(1);
95
113
  continue;
96
114
  }
@@ -135,24 +153,35 @@ export class Solver {
135
153
  if (!group) break;
136
154
 
137
155
  if (group.tabled) {
138
- const key = memoKey(goal, env);
156
+ const key = memoKey(goal, env, group);
139
157
  if (key.hasBound) {
140
158
  const mapKey = `${goal.name}/${goal.arity}:${key.text}`;
141
159
  let entry = this.memo.get(mapKey);
142
160
  if (!entry) {
143
- entry = { computing: false, complete: false, answers: [], answerKeys: new Set() };
161
+ entry = makeMemoEntry();
144
162
  this.memo.set(mapKey, entry);
145
163
  }
164
+ if (this.tableCoordinator) this.tableCoordinator.entries.add(entry);
146
165
  if (entry.complete) {
147
166
  pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, this);
148
167
  break;
149
168
  }
150
169
  if (!entry.computing) {
151
- entry.computing = true;
152
- stack.push({ kind: 'completeMemo', entry });
153
- pushUserGoalUncachedFrames(stack, this, group, goal, [{ kind: 'memoStore', entry, goal }, ...rest], env, depth, active);
170
+ if (!this.tableCoordinator) {
171
+ this.tableCoordinator = { entry, cycleSeen: false, entries: new Set([entry]) };
172
+ scheduleTableFixpointRound(stack, this, { entry, group, goal, rest, env, depth, active });
173
+ } else {
174
+ entry.computing = true;
175
+ stack.push({ kind: 'completeMemo', entry });
176
+ pushUserGoalUncachedFrames(stack, this, group, goal, [{ kind: 'memoStore', entry, goal }, ...rest], env, depth, active);
177
+ }
154
178
  break;
155
179
  }
180
+ if (this.tableCoordinator && activeVariantIn(goal, env, active)) {
181
+ this.tableCoordinator.cycleSeen = true;
182
+ }
183
+ pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, this);
184
+ break;
156
185
  }
157
186
  }
158
187
 
@@ -184,43 +213,7 @@ export class Solver {
184
213
  }
185
214
 
186
215
  *solveMemoizedGoal(group, goal, rest, env, depth) {
187
- // Memoization only pays off when at least one argument is bound. A fully
188
- // open call would require storing the entire relation and can mask useful
189
- // goal-ordering behavior, so it falls back to the normal path.
190
- const key = memoKey(goal, env);
191
- if (!key.hasBound) {
192
- yield* this.solveUserGoalUncached(group, goal, rest, env, depth);
193
- return;
194
- }
195
- const mapKey = `${goal.name}/${goal.arity}:${key.text}`;
196
- let entry = this.memo.get(mapKey);
197
- if (!entry) {
198
- entry = { computing: false, complete: false, answers: [] };
199
- this.memo.set(mapKey, entry);
200
- }
201
- if (!entry.complete && !entry.computing) {
202
- entry.computing = true;
203
- const collector = this.cloneForInnerGoal();
204
- for (const answerEnv of collector.solveUserGoalUncached(group, goal, [], env.clone(), depth)) {
205
- entry.answers.push(goal.args.map((arg) => importResolved(arg, answerEnv)));
206
- }
207
- entry.computing = false;
208
- entry.complete = true;
209
- }
210
- if (!entry.complete && entry.computing) {
211
- yield* this.solveUserGoalUncached(group, goal, rest, env, depth);
212
- return;
213
- }
214
- for (const answerArgs of entry.answers) {
215
- const next = env.clone();
216
- let ok = true;
217
- for (let i = 0; i < goal.arity; i++) {
218
- this.stats.unify_calls++;
219
- if (!unify(goal.args[i], answerArgs[i], next)) { ok = false; break; }
220
- }
221
- if (ok) yield* this.solve(rest, next, depth + 1);
222
- if (this.solutionsSeen >= this.solutionLimit) return;
223
- }
216
+ yield* this.solve([goal, ...rest], env, depth);
224
217
  }
225
218
 
226
219
  *solveUserGoalUncached(group, goal, rest, env, depth) {
@@ -273,6 +266,42 @@ export class Solver {
273
266
 
274
267
  }
275
268
 
269
+ function makeMemoEntry() {
270
+ return { computing: false, complete: false, answers: [], answerKeys: new Set() };
271
+ }
272
+
273
+ function scheduleTableFixpointRound(stack, solver, frame) {
274
+ solver.stats.table_fixpoint_rounds++;
275
+ solver.tableCoordinator.cycleSeen = false;
276
+ for (const entry of solver.tableCoordinator.entries) {
277
+ entry.computing = false;
278
+ entry.complete = false;
279
+ }
280
+ frame.entry.computing = true;
281
+ const nextFrame = {
282
+ kind: 'completeTableFixpointRound',
283
+ entry: frame.entry,
284
+ group: frame.group,
285
+ goal: frame.goal,
286
+ rest: frame.rest,
287
+ env: frame.env,
288
+ depth: frame.depth,
289
+ active: frame.active,
290
+ answerCountBefore: frame.entry.answers.length,
291
+ };
292
+ stack.push(nextFrame);
293
+ pushUserGoalUncachedFrames(
294
+ stack,
295
+ solver,
296
+ frame.group,
297
+ frame.goal,
298
+ [{ kind: 'memoStore', entry: frame.entry, goal: frame.goal }],
299
+ frame.env,
300
+ frame.depth,
301
+ frame.active,
302
+ );
303
+ }
304
+
276
305
 
277
306
  function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solver) {
278
307
  for (let answerIndex = entry.answers.length - 1; answerIndex >= 0; answerIndex--) {
@@ -639,7 +668,8 @@ function rememberGroundChainSuccess(solver, seen) {
639
668
 
640
669
  function rememberMemoAnswer(entry, goal, env) {
641
670
  const answerArgs = goal.args.map((arg) => importResolved(arg, env));
642
- const key = answerArgs.map((arg) => termToString(arg, new Env(), true)).join('\x1f');
671
+ const variables = new Map();
672
+ const key = answerArgs.map((arg) => canonicalTermKey(arg, new Env(), variables)).join('\x1f');
643
673
  if (entry.answerKeys.has(key)) return;
644
674
  entry.answerKeys.add(key);
645
675
  entry.answers.push(answerArgs);
@@ -689,16 +719,35 @@ function derefForLocal(term, env) {
689
719
  return current;
690
720
  }
691
721
 
692
- function memoKey(goal, env) {
722
+ function memoKey(goal, env, group = null) {
693
723
  let hasBound = false;
724
+ const variables = new Map();
725
+ const required = group?.tableInputPositions ?? [];
694
726
  const parts = goal.args.map((arg) => {
695
727
  const value = derefForLocal(arg, env);
696
- if (value.type !== 'var') hasBound = true;
697
- return value.type === 'var' ? '_' : termToString(value, env, true);
728
+ if (termIsGround(value, env)) hasBound = true;
729
+ return value.type === 'var' ? '_' : canonicalTermKey(value, env, variables);
698
730
  });
731
+ if (required.length > 0) {
732
+ hasBound = required.some((index) => termIsGround(goal.args[index], env));
733
+ }
699
734
  return { hasBound, text: parts.join('|') };
700
735
  }
701
736
 
737
+ function canonicalTermKey(term, env, variables) {
738
+ const value = derefForLocal(term, env);
739
+ if (value.type === 'var') {
740
+ let id = variables.get(value.name);
741
+ if (id == null) {
742
+ id = variables.size;
743
+ variables.set(value.name, id);
744
+ }
745
+ return `var:${id}`;
746
+ }
747
+ if (!value.args?.length) return `${value.type}:${value.name}`;
748
+ return `${value.type}:${value.name}(${value.args.map((arg) => canonicalTermKey(arg, env, variables)).join(',')})`;
749
+ }
750
+
702
751
  function importResolved(term, env) {
703
752
  const { copyResolved } = termModuleCache;
704
753
  return copyResolved(term, env);
@@ -757,6 +757,44 @@ function whiteBoxCases() {
757
757
  assertEqual(group.tabled, true, 'path/2 tabled automatically');
758
758
  },
759
759
  },
760
+ {
761
+ name: 'directly materialized recursive groups are tabled automatically',
762
+ run: () => {
763
+ const program = Program.parse('materialize(path, 2).\nedge(a, b).\npath(X, Y) :- edge(X, Y).\npath(X, Z) :- edge(X, Y), path(Y, Z).\n');
764
+ const group = program.findGroup('path', 2);
765
+ assertEqual(group.tabled, true, 'materialized path/2 tabled automatically');
766
+ },
767
+ },
768
+ {
769
+ name: 'cycles through negation retain guarded resolution',
770
+ run: () => {
771
+ const program = Program.parse('p(X) :- not(q(X)).\nq(X) :- p(X).\n');
772
+ assertEqual(program.findGroup('p', 1).recursive, true, 'p/1 recursive');
773
+ assertEqual(program.findGroup('q', 1).recursive, true, 'q/1 recursive');
774
+ assertEqual(program.findGroup('p', 1).tabled, false, 'p/1 not positively tabled');
775
+ assertEqual(program.findGroup('q', 1).tabled, false, 'q/1 not positively tabled');
776
+ },
777
+ },
778
+ {
779
+ name: 'cyclic tabling reaches a complete fixed point',
780
+ run: () => {
781
+ const result = run(Program.parse(`
782
+ materialize(path, 2).
783
+ edge(a, b).
784
+ edge(b, c).
785
+ edge(c, d).
786
+ edge(d, a).
787
+ path(X, Y) :- edge(X, Y).
788
+ path(X, Z) :- edge(X, Y), path(Y, Z).
789
+ `));
790
+ const answers = result.stdout.trim().split('\n');
791
+ assertEqual(answers.length, 16, 'four-node cycle transitive closure size');
792
+ for (const node of ['a', 'b', 'c', 'd']) {
793
+ assertIncludes(result.stdout, `path(${node}, ${node}).\n`, `${node} reaches itself`);
794
+ }
795
+ assertEqual(result.stats.table_fixpoint_rounds > 1, true, 'cyclic table required multiple rounds');
796
+ },
797
+ },
760
798
  {
761
799
  name: 'ordinary declaration-like facts do not table predicates',
762
800
  run: () => {
@@ -823,6 +861,8 @@ function whiteBoxCases() {
823
861
  assertEqual(Boolean(group), true, 'no_diagonal_attack/3 group exists');
824
862
  assertEqual(group.tabled, true, 'no_diagonal_attack/3 tabled');
825
863
  assertEqual(group.recursive, true, 'no_diagonal_attack/3 recursive');
864
+ assertEqual(group.tableInputPositions.join(','), '2', 'diagonal scan uses the remaining rows as input');
865
+ assertEqual(program.findGroup('perm', 2).tableInputPositions.join(','), '1', 'permutation output stays untabled while open');
826
866
  },
827
867
  },
828
868
  {
@@ -834,6 +874,7 @@ function whiteBoxCases() {
834
874
  assertEqual(Boolean(group), true, 'collatz/2 group exists');
835
875
  assertEqual(group.tabled, true, 'collatz/2 tabled');
836
876
  assertEqual(group.recursive, true, 'collatz/2 recursive');
877
+ assertEqual(group.tableInputPositions.join(','), '0', 'collatz uses its numeric seed as input');
837
878
  },
838
879
  },
839
880
  {