eyeprolog 1.5.47 → 1.5.48

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.5.47",
6
+ "version": "1.5.48",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/quads.js CHANGED
@@ -68,7 +68,18 @@ function checkQuadDescription(program, quad, description, options, context) {
68
68
  }
69
69
 
70
70
  function checkDescription(program, quad, description, options, context) {
71
- const alternatives = splitOperator(description, '|');
71
+ const parts = splitOperator(description, '|');
72
+ const alternatives = [];
73
+ const unordered = new Set();
74
+ for (const part of parts) {
75
+ if (part.type === ATOM && part.name === 'other_answer_sequence') {
76
+ const previous = alternatives.at(-1);
77
+ if (previous == null || unordered.has(previous)) {
78
+ return { ok: false, kind: 'malformed', expected: part };
79
+ }
80
+ unordered.add(previous);
81
+ } else alternatives.push(part);
82
+ }
72
83
  // Probe an explicitly accepted nontermination outcome before alternatives
73
84
  // that would run the same query without a bound.
74
85
  const ordered = [...alternatives].sort((left, right) =>
@@ -80,7 +91,7 @@ function checkDescription(program, quad, description, options, context) {
80
91
  let unsupported = null;
81
92
  let undecided = null;
82
93
  for (const alternative of ordered) {
83
- const checked = checkAlternative(program, quad, alternative, options, context);
94
+ const checked = checkAlternative(program, quad, alternative, options, context, unordered.has(alternative));
84
95
  if (checked.ok) return checked;
85
96
  if (checked.kind === 'unsupported') unsupported ??= checked;
86
97
  if (checked.kind === 'undecided') undecided ??= checked;
@@ -88,7 +99,7 @@ function checkDescription(program, quad, description, options, context) {
88
99
  return unsupported ?? undecided ?? { ok: false, kind: 'failed', expected: description };
89
100
  }
90
101
 
91
- function checkAlternative(program, quad, alternative, options, context) {
102
+ function checkAlternative(program, quad, alternative, options, context, unordered = false) {
92
103
  const leaves = splitOperator(alternative, ';').map(describeLeaf);
93
104
  const requiresSto = leaves.some((leaf) => leaf.sto);
94
105
  const unsupported = leaves.find((leaf) => leaf.unsupported != null)?.unsupported;
@@ -96,6 +107,13 @@ function checkAlternative(program, quad, alternative, options, context) {
96
107
  return { ok: false, kind: 'unsupported', expected: unsupported };
97
108
  }
98
109
 
110
+ // A permutation describes a complete sequence, not an arbitrary prefix or
111
+ // a negative assertion. Do not silently weaken those annotations.
112
+ if (unordered && leaves.some((leaf) => leaf.more || leaf.unexpected || leaf.sto ||
113
+ leaf.waits || leaf.input != null || leaf.peek != null)) {
114
+ return { ok: false, kind: 'unsupported', expected: alternative };
115
+ }
116
+
99
117
  const ioLeaves = leaves.filter((leaf) => leaf.input != null || leaf.peek != null);
100
118
  if (ioLeaves.length > 1 || (ioLeaves.length > 0 && leaves.length !== 1)) {
101
119
  return { ok: false, kind: 'malformed', expected: alternative };
@@ -143,6 +161,8 @@ function checkAlternative(program, quad, alternative, options, context) {
143
161
  return { ok: leaf.unexpected ? !matches : matches };
144
162
  }
145
163
 
164
+ if (unordered) return checkAnswerPermutation(program, quad.query, leaves, actual, alternative);
165
+
146
166
  let position = 0;
147
167
  for (const leaf of leaves) {
148
168
  if (leaf.more && !leaf.hasExpectation) return { ok: true };
@@ -186,6 +206,37 @@ function checkAlternative(program, quad, alternative, options, context) {
186
206
  return { ok: ended };
187
207
  }
188
208
 
209
+ // Match a multiset, preserving duplicate counts. A greedy match is insufficient:
210
+ // wildcard and approximate descriptions may overlap more specific descriptions.
211
+ // Augmenting paths find a one-to-one assignment without enumerating permutations.
212
+ function checkAnswerPermutation(program, query, leaves, actual, alternative) {
213
+ const terminalAt = leaves.findIndex((leaf) => leaf.false || leaf.loops || leaf.error != null);
214
+ if (terminalAt >= 0 && terminalAt !== leaves.length - 1) return { ok: false };
215
+ const answers = terminalAt < 0 ? leaves : leaves.slice(0, -1);
216
+ if (actual.solutions.length > answers.length) return { ok: false };
217
+ if (actual.undecided) return undecidedResult(actual, alternative);
218
+ if (actual.solutions.length !== answers.length) return { ok: false };
219
+ if (terminalAt >= 0) {
220
+ if (!matchLeaf(program, query, leaves[terminalAt], actual, answers.length)) return { ok: false };
221
+ } else if (!actual.complete || actual.error != null || actual.loopObserved) return { ok: false };
222
+
223
+ const edges = answers.map((leaf) => actual.solutions.flatMap((_, position) =>
224
+ matchLeaf(program, query, leaf, actual, position) ? [position] : []));
225
+ const owners = Array(answers.length).fill(-1);
226
+ const assign = (index, seen) => {
227
+ for (const position of edges[index]) {
228
+ if (seen.has(position)) continue;
229
+ seen.add(position);
230
+ if (owners[position] < 0 || assign(owners[position], seen)) {
231
+ owners[position] = index;
232
+ return true;
233
+ }
234
+ }
235
+ return false;
236
+ };
237
+ return { ok: answers.every((_, index) => assign(index, new Set())) };
238
+ }
239
+
189
240
  function malformedAlternative(query, alternative) {
190
241
  const queryNames = new Set(namedVariables(query).map((variable) => variable.name));
191
242
  for (const leaf of splitOperator(alternative, ';').map(describeLeaf)) {
@@ -995,6 +995,45 @@ why(
995
995
  assertEqual(result.stdout, 'quads: 13 run, 13 passed, 0 failed.\n', 'quad report');
996
996
  },
997
997
  },
998
+ {
999
+ name: 'runQuads matches other_answer_sequence permutations exactly (issue #95)',
1000
+ run: () => {
1001
+ const cases = [
1002
+ ['setof groups', 'setof(1, (Y=2 ; Y=1), L)', 'Y=2,L=[1];Y=1,L=[1]', true],
1003
+ ['reverse', '(X=1;X=2;X=3)', 'X=3;X=2;X=1', true],
1004
+ ['duplicates', '(X=1;X=1;X=2)', 'X=2;X=1;X=1', true],
1005
+ ['wrong multiplicity', '(X=1;X=1;X=2)', 'X=2;X=2;X=1', false],
1006
+ ['missing', '(X=1;X=2)', 'X=3;X=2;X=1', false],
1007
+ ['extra', '(X=1;X=2;X=3)', 'X=2;X=1', false],
1008
+ ['wrong value', '(X=1;X=2)', 'X=3;X=1', false],
1009
+ ['overlapping patterns', '(X=f(a);X=f(b))', "X=f('...');X=f(a)", true],
1010
+ ['variable sharing', '(X=Y;true)', 'true;X=Y', true],
1011
+ ['wrong sharing', '(X=Y;true)', 'X=Y;X=Y', false],
1012
+ ['per-answer output', '(X=1,write(a);X=2,write(b))', 'X=2,outputs("b");X=1,outputs("a")', true],
1013
+ ['wrong output', '(X=1,write(a);X=2,write(b))', 'X=2,outputs("a");X=1,outputs("b")', false],
1014
+ ['unlisted exception', '(X=1;throw(ball))', 'X=1', false],
1015
+ ['terminal exception', '(X=1;X=2;throw(ball))', 'X=2;X=1;throw(ball)', true],
1016
+ ['terminal failure', '(X=1;X=2)', 'X=2;X=1;false', true],
1017
+ ['empty sequence', 'fail', 'false', true],
1018
+ ['ordinary order retained', '(X=1;X=2)', 'X=2;X=1', false, false],
1019
+ ];
1020
+ for (const [name, query, answers, passes, unordered = true] of cases) {
1021
+ const result = publicApi.runQuads(`?- ${query}.\n ${answers}${unordered ? ' | other_answer_sequence' : ''}.\n`);
1022
+ assertEqual(result.passed, passes ? 1 : 0, name);
1023
+ assertEqual(result.undecided, 0, `${name} decided`);
1024
+ }
1025
+ for (const answers of ['other_answer_sequence', 'other_answer_sequence | X=1',
1026
+ 'X=1 | other_answer_sequence | other_answer_sequence']) {
1027
+ const result = publicApi.runQuads(`?- X=1.\n ${answers}.\n`);
1028
+ assertEqual(result.results[0].kind, 'malformed', 'orphan or repeated marker');
1029
+ }
1030
+ const bounded = publicApi.runQuads(
1031
+ 'p(0). p(X) :- p(Y), X is Y+1.\n?- p(X).\n X=1;X=0 | other_answer_sequence.\n',
1032
+ { quadMaxInferences: 1 },
1033
+ );
1034
+ assertEqual(bounded.undecided, 1, 'bounded execution is not proof of a permutation');
1035
+ },
1036
+ },
998
1037
  {
999
1038
  name: 'runQuads supports realistic decimal-precision float descriptions with ~~ (issue #90)',
1000
1039
  run: () => {
@@ -9663,8 +9663,16 @@ error, including output produced before a later exception. Its argument may be
9663
9663
  an exact character list/string or a DCG body: terminal sequences,
9664
9664
  conjunction/disjunction, `...`/`ad_infinitum` sequence wildcards, and
9665
9665
  user-defined DCG nonterminals are matched against the captured characters.
9666
- The `waits` and unordered `other_answer_sequence` annotations are not executed
9667
- by the current runner.
9666
+ Appending `| other_answer_sequence` accepts any permutation of the preceding
9667
+ complete answer sequence. Substitutions, residual constraints, per-answer output,
9668
+ and duplicate counts must still match; missing or extra answers are failures.
9669
+ A final failure or exception stays at the end. Overlapping wildcard or approximate
9670
+ answer descriptions are matched one-to-one rather than greedily. Search-budget
9671
+ exhaustion remains undecided. Prefix (`...`), negative (`unexpected`), STO, and
9672
+ input/wait annotations are not supported in a permuted sequence.
9673
+
9674
+ For `setof(1, (Y=2 ; Y=1), L)`, the description
9675
+ `Y=2, L=[1] ; Y=1, L=[1] | other_answer_sequence` accepts either group order.
9668
9676
 
9669
9677
  #### STO, loops, and undecided quad results
9670
9678