eyeprolog 1.3.39 → 1.3.41

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/README.md CHANGED
@@ -91,6 +91,11 @@ execution as positive STO evidence, and rejects `sto` when a finite execution
91
91
  completes without such an event (for example `?- true. sto.`). If execution is
92
92
  cut short by a search/resource boundary, the STO claim remains conservatively
93
93
  unverified rather than being guessed.
94
+ For nontermination expectations, `loops` is kept distinct from resource
95
+ exhaustion: structural loop evidence may satisfy `loops`, while a bounded
96
+ search that cannot establish the requested answer sequence is reported as
97
+ `UNDECIDED`. In CLI quad mode, failures use exit status `1`; if there are no
98
+ failures but at least one undecided quad, the exit status is `2`.
94
99
 
95
100
  ## Tabling and well-founded negation
96
101
 
@@ -248,3 +248,5 @@ npm run generate
248
248
  ## Chapter 40: Running EyeProlog: command line and corpus
249
249
 
250
250
  - [01-color.pl](chapter-40/01-color.pl) — Embedded quad tests
251
+ - [02-program.pl](chapter-40/02-program.pl)
252
+ - [03-program-2.pl](chapter-40/03-program-2.pl)
@@ -0,0 +1,5 @@
1
+ % From The Art of EyeProlog, Chapter 40.
2
+ ?- read(X).
3
+ inputs("1."), X = 1, unexpected.
4
+ inputs("1."), peeks(" "), X = 1.
5
+ inputs("1. "), peeks(" "), X = 1, unexpected.
@@ -0,0 +1,5 @@
1
+ % From The Art of EyeProlog, Chapter 40.
2
+ inf :- inf, inf.
3
+
4
+ ?- inf.
5
+ loops.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.39",
6
+ "version": "1.3.41",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/io.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Synchronous ISO stream state shared by a solver and all of its inner solvers.
2
2
  import { BufferCtor, fs } from './platform.js';
3
3
 
4
- const INVALID_UTF8_SENTINEL = '\udc00';
4
+ export const INVALID_UTF8_SENTINEL = '\udc00';
5
5
 
6
6
  export class InvalidCharacterEncodingError extends Error {
7
7
  constructor() {
package/src/iso.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from './parser.js';
13
13
  import { formatTermForWrite } from './write.js';
14
14
  import { emptyTerminalSequence, expandDcgBody, isListOrPartialList, validateDcgEmbeddedGoals } from './dcg.js';
15
+ import { INVALID_UTF8_SENTINEL } from './io.js';
15
16
  import {
16
17
  characterCodeConstantEnd, continuesGraphicToken, isTerminatingFullStop, quotedEscapeEnd,
17
18
  } from './syntax-scan.js';
@@ -1089,6 +1090,13 @@ function* termTextCandidates(stream, solver) {
1089
1090
  let quote = null, lineComment = false, blockComment = false;
1090
1091
  for (let i = stream.position; i < source.length; i++) {
1091
1092
  const ch = source[i], next = source[i + 1];
1093
+ // Text streams preserve invalid UTF-8 bytes as an impossible Unicode
1094
+ // sentinel. read/1-2 and read_term/2-3 must surface the same character
1095
+ // representation error as get_char/1-2 instead of misclassifying the
1096
+ // undecodable byte as malformed Prolog syntax (issue #64).
1097
+ if (stream.strictUtf8 && ch === INVALID_UTF8_SENTINEL) {
1098
+ throw new PrologError('representation_error(character)');
1099
+ }
1092
1100
  if (lineComment) { if (ch === '\n') lineComment = false; continue; }
1093
1101
  if (blockComment) { if (ch === '*' && next === '/') { blockComment = false; i++; } continue; }
1094
1102
  if (quote) {
@@ -1305,10 +1313,10 @@ function* readTermBuiltin({ solver, goal, env }) {
1305
1313
  yield next;
1306
1314
  }
1307
1315
  function defaultTermWriteOptions(mode) {
1308
- if (mode === 'writeq') return { quoted: true, ignoreOps: false, numbervars: true, variableNames: new Map(), compact: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1309
- if (mode === 'canonical') return { quoted: true, ignoreOps: true, numbervars: false, variableNames: new Map(), compact: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1310
- if (mode === 'write_term') return { quoted: false, ignoreOps: false, numbervars: false, variableNames: new Map(), compact: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1311
- return { quoted: false, ignoreOps: false, numbervars: true, variableNames: new Map(), compact: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1316
+ if (mode === 'writeq') return { quoted: true, ignoreOps: false, numbervars: true, variableNames: new Map(), compact: true, minimalOperatorSpacing: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1317
+ if (mode === 'canonical') return { quoted: true, ignoreOps: true, numbervars: false, variableNames: new Map(), compact: true, minimalOperatorSpacing: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1318
+ if (mode === 'write_term') return { quoted: false, ignoreOps: false, numbervars: false, variableNames: new Map(), compact: true, minimalOperatorSpacing: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1319
+ return { quoted: false, ignoreOps: false, numbervars: true, variableNames: new Map(), compact: true, minimalOperatorSpacing: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1312
1320
  }
1313
1321
 
1314
1322
  function writeOptionBoolean(value, env, option) {
package/src/quads.js CHANGED
@@ -11,6 +11,7 @@ import { Program } from './program.js';
11
11
  import { Solver } from './solver.js';
12
12
  import { getEyePrologRegistry } from './standard-library.js';
13
13
  import { formatTermForWrite } from './write.js';
14
+ import { INVALID_UTF8_SENTINEL } from './io.js';
14
15
 
15
16
  const DEFAULT_QUAD_MAX_INFERENCES = 100000;
16
17
  const DEFAULT_LOOP_MAX_INFERENCES = 10000;
@@ -93,18 +94,24 @@ function checkAlternative(program, quad, alternative, options, context) {
93
94
  return { ok: false, kind: 'unsupported', expected: unsupported };
94
95
  }
95
96
 
96
- const inputSpecs = [...new Set(leaves.filter((leaf) => leaf.input != null).map((leaf) => leaf.input))];
97
- if (inputSpecs.length > 1) return { ok: false, kind: 'malformed', expected: alternative };
98
- const input = inputSpecs[0] ?? '';
99
- if (inputSpecs.length > 0 && leaves.length !== 1) return { ok: false };
97
+ const ioLeaves = leaves.filter((leaf) => leaf.input != null || leaf.peek != null);
98
+ if (ioLeaves.length > 1 || (ioLeaves.length > 0 && leaves.length !== 1)) {
99
+ return { ok: false, kind: 'malformed', expected: alternative };
100
+ }
101
+ const hasInputSpec = ioLeaves.length === 1;
102
+ const inputLeaf = ioLeaves[0] ?? null;
103
+ const input = inputLeaf?.input ?? '';
104
+ const peek = inputLeaf?.peek ?? null;
100
105
  const moreAt = leaves.findIndex((leaf) => leaf.more);
101
106
  const describedCount = moreAt < 0 ? leaves.length : moreAt + (leaves[moreAt].hasExpectation ? 1 : 0);
102
- const maxSolutions = inputSpecs.length > 0
107
+ const maxSolutions = hasInputSpec
103
108
  ? 1
104
109
  : moreAt < 0 ? describedCount + 1 : Math.max(describedCount, 1);
105
110
  const actual = executeQuery(program, quad.query, input, maxSolutions, {
106
111
  ...options,
107
112
  detectLoops: leaves.some((leaf) => leaf.loops),
113
+ inputBoundary: hasInputSpec,
114
+ inputPeek: peek,
108
115
  });
109
116
 
110
117
  if (requiresSto) {
@@ -119,9 +126,12 @@ function checkAlternative(program, quad, alternative, options, context) {
119
126
  return { ok: true };
120
127
  }
121
128
 
122
- if (inputSpecs.length > 0) {
129
+ if (hasInputSpec) {
123
130
  const leaf = leaves[0];
124
- const matches = actual.inputPosition === input.length && matchLeaf(program, quad.query, leaf, actual, 0);
131
+ const exactConsumption = actual.inputPosition === input.length;
132
+ const expectedRemainder = `${peek ?? ''}${INVALID_UTF8_SENTINEL}`;
133
+ const boundaryPreserved = actual.inputRemainder === expectedRemainder;
134
+ const matches = exactConsumption && boundaryPreserved && matchLeaf(program, quad.query, leaf, actual, 0);
125
135
  if (actual.undecided && !matches) return undecidedResult(actual, alternative);
126
136
  return { ok: leaf.unexpected ? !matches : matches };
127
137
  }
@@ -147,14 +157,14 @@ function checkAlternative(program, quad, alternative, options, context) {
147
157
  // turn a successful negative match back into a failure.
148
158
  if (leaf.unexpected && leaf.error != null) return { ok: true };
149
159
  if (leaf.more) return { ok: true };
150
- if (!leaf.unexpected && (leaf.false || leaf.error != null)) {
160
+ if (!leaf.unexpected && (leaf.false || leaf.loops || leaf.error != null)) {
151
161
  if (actual.undecided) return undecidedResult(actual, alternative);
152
162
  return { ok: position === leaves.length - 1 };
153
163
  }
154
164
  position++;
155
165
  }
156
166
 
157
- const ended = position >= actual.solutions.length && actual.error == null && !actual.undecided;
167
+ const ended = position >= actual.solutions.length && actual.error == null && !actual.undecided && !actual.loopObserved;
158
168
  if (!ended && actual.undecided && position >= actual.solutions.length && actual.error == null) {
159
169
  return undecidedResult(actual, alternative);
160
170
  }
@@ -191,6 +201,7 @@ function describeLeaf(term) {
191
201
  loops: false,
192
202
  error: null,
193
203
  input: null,
204
+ peek: null,
194
205
  output: null,
195
206
  unsupported: null,
196
207
  malformed: null,
@@ -227,7 +238,9 @@ function describeLeaf(term) {
227
238
  continue;
228
239
  }
229
240
  if (item.type === COMPOUND && item.name === 'peeks' && item.arity === 1) {
230
- leaf.unsupported ??= item;
241
+ const text = characterText(item.args[0]);
242
+ if (text == null || Array.from(text).length !== 1 || leaf.peek != null) leaf.malformed ??= item;
243
+ else leaf.peek = text;
231
244
  continue;
232
245
  }
233
246
  if (isErrorDescription(item)) leaf.error = item;
@@ -241,6 +254,9 @@ function describeLeaf(term) {
241
254
 
242
255
  function executeQuery(program, query, input, maxSolutions, options) {
243
256
  let pendingOutput = '';
257
+ const boundedInput = options.inputBoundary
258
+ ? `${input}${options.inputPeek ?? ''}${INVALID_UTF8_SENTINEL}`
259
+ : input;
244
260
  const solver = new Solver(program, {
245
261
  ...options,
246
262
  registry: options.registry ?? getEyePrologRegistry(),
@@ -254,10 +270,18 @@ function executeQuery(program, query, input, maxSolutions, options) {
254
270
  // allowance.
255
271
  solutionLimit: Math.max(maxSolutions, options.solutionLimit ?? 10000000),
256
272
  ioOptions: {
257
- input,
273
+ input: boundedInput,
258
274
  write: (text) => { pendingOutput += String(text); },
259
275
  },
260
276
  });
277
+ if (options.inputBoundary) {
278
+ // Quads need a boundary that is neither EOF nor a valid character. This
279
+ // mirrors Trealla's sentinel technique: inputs/1 says exactly what was
280
+ // consumed, while peeks/1 supplies one additional character that must
281
+ // remain unread. If the query asks for anything beyond that boundary it
282
+ // sees representation_error(character), not an artificial EOF.
283
+ solver.io.resolve('user_input').strictUtf8 = true;
284
+ }
261
285
  // Undefined predicates are test failures rather than silent negative
262
286
  // answers unless the source explicitly selected another unknown policy.
263
287
  if (!(program.prologFlagDirectives ?? []).some(([flag]) => flag.type === ATOM && flag.name === 'unknown')) {
@@ -273,7 +297,15 @@ function executeQuery(program, query, input, maxSolutions, options) {
273
297
  iterator = solver.solve([query], new Env(), 0);
274
298
  while (solutions.length < maxSolutions) {
275
299
  pendingOutput = '';
300
+ const cycleBefore = solver.recursionCycleDetected;
276
301
  const result = iterator.next();
302
+ if (!cycleBefore && solver.recursionCycleDetected) {
303
+ // The recursion guard found an operational loop wall while advancing
304
+ // to this result. Any answer produced only after that guard pruned the
305
+ // looping branch is not reachable by ordinary Prolog search, so do not
306
+ // count it as a leaf answer (issue #58 comment 5381101420).
307
+ break;
308
+ }
277
309
  if (result.done) {
278
310
  tailOutput += pendingOutput;
279
311
  complete = true;
@@ -289,22 +321,30 @@ function executeQuery(program, query, input, maxSolutions, options) {
289
321
  } finally {
290
322
  if (!complete) iterator?.return?.();
291
323
  }
292
- const inputPosition = solver.io.resolve('user_input')?.position ?? 0;
324
+ const inputStream = solver.io.resolve('user_input');
325
+ const inputPosition = inputStream?.position ?? 0;
326
+ const inputRemainder = String(inputStream?.content ?? '').slice(inputPosition);
293
327
  const bounded = solver.depthLimitExceeded || solver.inferenceLimitExceeded;
294
- const undecided = !options.detectLoops && (solver.recursionCycleDetected || bounded);
328
+ const loopObserved = solver.recursionCycleDetected;
329
+ // A structural recursion-cycle witness is stronger than a resource limit:
330
+ // it establishes an operational loop wall and can therefore refute a finite
331
+ // answer description. Pure depth/inference exhaustion remains undecided.
332
+ const undecided = !options.detectLoops && !loopObserved && bounded;
295
333
  return {
296
334
  solutions,
297
335
  error,
298
336
  tailOutput,
299
337
  inputPosition,
338
+ inputRemainder,
300
339
  complete,
340
+ loopObserved,
301
341
  stoObserved: solver.occursCheckObserved,
302
- nstoObserved: complete && !solver.occursCheckObserved && !solver.recursionCycleDetected &&
342
+ nstoObserved: complete && !solver.occursCheckObserved && !loopObserved &&
303
343
  !bounded && !resourceInterrupted,
304
344
  // A loops expectation explicitly asks for bounded nontermination evidence.
305
345
  // Other descriptions treat the same exhausted search budget as undecided:
306
346
  // a timeout cannot establish finite failure or an exact answer sequence.
307
- loops: options.detectLoops && (solver.recursionCycleDetected || bounded),
347
+ loops: options.detectLoops && (loopObserved || bounded),
308
348
  undecided,
309
349
  undecidedReason: solver.inferenceLimitExceeded ? 'inference limit reached'
310
350
  : solver.depthLimitExceeded ? 'depth limit reached'
@@ -316,7 +356,7 @@ function executeQuery(program, query, input, maxSolutions, options) {
316
356
  function matchLeaf(program, query, leaf, actual, position) {
317
357
  if (leaf.loops) return actual.loops;
318
358
  if (leaf.false) {
319
- return position >= actual.solutions.length && actual.error == null && !actual.undecided &&
359
+ return position >= actual.solutions.length && actual.error == null && !actual.undecided && !actual.loopObserved &&
320
360
  outputMatches(program, leaf.output, actual.tailOutput);
321
361
  }
322
362
  if (leaf.error != null) {
package/src/write.js CHANGED
@@ -58,6 +58,51 @@ function isDottedGraphicAtom(name) {
58
58
  [...name].every((ch) => dottedGraphicAtomCharacters.has(ch));
59
59
  }
60
60
 
61
+ function compactBoundaryNeedsSpace(left, right) {
62
+ const leftChars = Array.from(left);
63
+ const rightChars = Array.from(right);
64
+ const a = leftChars[leftChars.length - 1] ?? '';
65
+ const b = rightChars[0] ?? '';
66
+ if (!a || !b) return false;
67
+ // Adjacent quoted atoms need layout: `''` is part of quoted-atom syntax,
68
+ // so concatenating two quoted operator/argument tokens would change the
69
+ // tokenization (WG17 #169).
70
+ if (a === "'" && b === "'") return true;
71
+ // Adjacent graphic characters form one maximal graphic token. Keep them
72
+ // apart when an operator boundary would otherwise disappear (`a+ -b`,
73
+ // `a/ *b`, etc.).
74
+ if (graphicAtomCharacters.has(a) && graphicAtomCharacters.has(b)) return true;
75
+ // Alphanumeric operator names similarly need a lexical boundary from a
76
+ // neighbouring identifier/number token. Most predefined word operators
77
+ // are handled explicitly below; this also protects quoted/custom cases that
78
+ // render without punctuation.
79
+ if (/^[A-Za-z0-9_]$/.test(a) && /^[A-Za-z0-9_]$/.test(b)) return true;
80
+ return false;
81
+ }
82
+
83
+ function isWordOperatorToken(token) {
84
+ return /^[a-z][A-Za-z0-9_]*$/.test(token);
85
+ }
86
+
87
+ function compactPrefixOperator(token, argument) {
88
+ if (isWordOperatorToken(token)) return `${token} ${argument}`;
89
+ return `${token}${compactBoundaryNeedsSpace(token, argument) ? ' ' : ''}${argument}`;
90
+ }
91
+
92
+ function compactPostfixOperator(argument, token) {
93
+ if (isWordOperatorToken(token)) return `${argument} ${token}`;
94
+ return `${argument}${compactBoundaryNeedsSpace(argument, token) ? ' ' : ''}${token}`;
95
+ }
96
+
97
+ function compactInfixOperator(left, token, right) {
98
+ // Solo punctuation operators already delimit themselves.
99
+ if (token === ',' || token === ';') return `${left}${token}${right}`;
100
+ if (isWordOperatorToken(token)) return `${left} ${token} ${right}`;
101
+ const before = compactBoundaryNeedsSpace(left, token) ? ' ' : '';
102
+ const after = compactBoundaryNeedsSpace(token, right) ? ' ' : '';
103
+ return `${left}${before}${token}${after}${right}`;
104
+ }
105
+
61
106
  function legacyVariableToIso(name) {
62
107
  if (name === '?') return '_';
63
108
  const tail = name.slice(1);
@@ -313,9 +358,11 @@ function format(term, env, options, table, maxPriority = 1200, context = 'term')
313
358
  // numeric-leading argument so writeq/write_term remain read-back safe
314
359
  // (WG17 #135, #183, #215, #216, and #248).
315
360
  if (resolved.name === '-' && startsWithUnsignedNumber(resolved.args[0], env, table, options)) {
316
- text = `${token} (${format(resolved.args[0], env, options, table, 1200)})`;
361
+ const argument = format(resolved.args[0], env, options, table, 1200);
362
+ text = options.minimalOperatorSpacing ? `${token}(${argument})` : `${token} (${argument})`;
317
363
  } else {
318
- text = `${token} ${format(resolved.args[0], env, options, table, argumentPriority)}`;
364
+ const argument = format(resolved.args[0], env, options, table, argumentPriority);
365
+ text = options.minimalOperatorSpacing ? compactPrefixOperator(token, argument) : `${token} ${argument}`;
319
366
  }
320
367
  } else if (specifier === 'xf' || specifier === 'yf') {
321
368
  let argumentPriority = specifier === 'xf' ? priority - 1 : priority;
@@ -324,7 +371,8 @@ function format(term, env, options, table, maxPriority = 1200, context = 'term')
324
371
  ['fx', 'fy', 'xfx', 'xfy', 'yfx'].includes(childDefinition.specifier)) {
325
372
  argumentPriority = priority - 1;
326
373
  }
327
- text = `${format(resolved.args[0], env, options, table, argumentPriority)} ${token}`;
374
+ const argument = format(resolved.args[0], env, options, table, argumentPriority);
375
+ text = options.minimalOperatorSpacing ? compactPostfixOperator(argument, token) : `${argument} ${token}`;
328
376
  } else {
329
377
  let leftPriority = specifier === 'yfx' ? priority : priority - 1;
330
378
  const rightPriority = specifier === 'xfy' ? priority : priority - 1;
@@ -334,9 +382,13 @@ function format(term, env, options, table, maxPriority = 1200, context = 'term')
334
382
  }
335
383
  const left = format(resolved.args[0], env, options, table, leftPriority);
336
384
  const right = format(resolved.args[1], env, options, table, rightPriority);
337
- text = resolved.name === ',' ? `${left}, ${right}`
338
- : compactInfixOperators.has(resolved.name) ? `${left}${token}${right}`
339
- : `${left} ${token} ${right}`;
385
+ if (options.minimalOperatorSpacing) {
386
+ text = resolved.name === ',' ? `${left},${right}` : compactInfixOperator(left, token, right);
387
+ } else if (resolved.name === ',') {
388
+ text = `${left}, ${right}`;
389
+ } else {
390
+ text = compactInfixOperators.has(resolved.name) ? `${left}${token}${right}` : `${left} ${token} ${right}`;
391
+ }
340
392
  }
341
393
  return priority > maxPriority ? `(${text})` : text;
342
394
  }
@@ -358,6 +410,7 @@ export function formatTermForWrite(term, env = new Env(), options = {}) {
358
410
  ? printableGeneratedVariableNames(term, env, explicitVariableNames, options.variableNameState)
359
411
  : printableReadVariableNames(term, env, explicitVariableNames),
360
412
  compact: options.compact === true,
413
+ minimalOperatorSpacing: options.minimalOperatorSpacing === true,
361
414
  operatorAtomsAsArgs: options.operatorAtomsAsArgs === true,
362
415
  dottedGraphicAtoms: options.dottedGraphicAtoms === true,
363
416
  };
@@ -21,8 +21,8 @@ upstream assertion.
21
21
  The matrix runs in strict ISO stream-reader mode as part of `npm test`. The
22
22
  3 upstream `waits` cases are checked through EyeProlog's interactive input
23
23
  hook. All 366 executable cases are independently checked against the
24
- upstream Codex expectation. 365 cases additionally retain exact reviewed
25
- outcomes for stronger regression checking; 1 case currently relies on the upstream assertion alone.
24
+ upstream Codex expectation. 366 cases additionally retain exact reviewed
25
+ outcomes for stronger regression checking; 0 cases currently rely on the upstream assertion alone.
26
26
 
27
27
  ## Traceable evidence
28
28
 
@@ -6,8 +6,8 @@ wg17_hex_escape.
6
6
  f(*)
7
7
  f(;,'|',';;')
8
8
  wg17_operator_arguments.
9
- a * (b + c)
10
- a :- b, c
9
+ a*(b+c)
10
+ a:-b,c
11
11
  wg17_operator_precedence.
12
12
  wg17_spaced_prefix_operator((a, b)).
13
13
  '.'(a,[])