eyeprolog 1.5.41 → 1.5.42

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.
@@ -252,3 +252,4 @@ npm run generate
252
252
  - [02-program.pl](chapter-40/02-program.pl)
253
253
  - [03-program-2.pl](chapter-40/03-program-2.pl)
254
254
  - [04-program-3.pl](chapter-40/04-program-3.pl)
255
+ - [05-program-4.pl](chapter-40/05-program-4.pl)
@@ -1,5 +1,3 @@
1
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.
2
+ ?- V is 0+(3.2+11).
3
+ V ~ '14.2000'.
@@ -1,5 +1,5 @@
1
1
  % From The Art of EyeProlog, Chapter 40.
2
- inf :- inf, inf.
3
-
4
- ?- inf.
5
- loops.
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.5.41",
6
+ "version": "1.5.42",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/parser.js CHANGED
@@ -1051,10 +1051,21 @@ class Parser {
1051
1051
  this.advance();
1052
1052
 
1053
1053
  const answers = [];
1054
- while (this.token.type !== TOK.EOF && this.sourceLineIsIndented(this.token.line)) {
1055
- answers.push(this.parseTerm(0, true));
1056
- this.expect(TOK.DOT, '.');
1057
- this.advance();
1054
+ // Issue #90 extends only the embedded answer-description grammar with
1055
+ // decimal-precision approximate equality. Do not leak `~` into ordinary
1056
+ // EyeProlog source syntax: temporarily parse it like the standard 700 xfx
1057
+ // comparison operators while consuming the indented quad answers.
1058
+ const previousApproximateOperator = this.infixOperators.get('~');
1059
+ this.infixOperators.set('~', { precedence: operatorStrength(700), associativity: 'none' });
1060
+ try {
1061
+ while (this.token.type !== TOK.EOF && this.sourceLineIsIndented(this.token.line)) {
1062
+ answers.push(this.parseTerm(0, true));
1063
+ this.expect(TOK.DOT, '.');
1064
+ this.advance();
1065
+ }
1066
+ } finally {
1067
+ if (previousApproximateOperator == null) this.infixOperators.delete('~');
1068
+ else this.infixOperators.set('~', previousApproximateOperator);
1058
1069
  }
1059
1070
  if (answers.length === 0) throw new Error(`parse line ${line}: quad requires an indented answer description`);
1060
1071
 
package/src/quads.js CHANGED
@@ -4,8 +4,8 @@
4
4
  // A `maybe` annotation denotes a successful answer with residual constraints;
5
5
  // it does not relax the ordinary answer-substitution comparison.
6
6
  import {
7
- ATOM, COMPOUND, VAR, Env, atom, compound, copyResolved, deref,
8
- flattenConjunction, listFromItems, properListItems, termIsGround,
7
+ ATOM, COMPOUND, NUMBER, VAR, Env, atom, compound, copyResolved, deref,
8
+ flattenConjunction, isDecimalInteger, listFromItems, properListItems, termIsGround,
9
9
  unify, variable,
10
10
  } from './term.js';
11
11
  import { parseGoalText } from './parser.js';
@@ -191,13 +191,14 @@ function malformedAlternative(query, alternative) {
191
191
  for (const leaf of splitOperator(alternative, ';').map(describeLeaf)) {
192
192
  if (leaf.malformed != null) return leaf.malformed;
193
193
  const names = new Set();
194
- for (const binding of leaf.bindings) {
194
+ const substitutions = [...leaf.bindings, ...leaf.approximations];
195
+ for (const binding of substitutions) {
195
196
  const name = binding.args[0].name;
196
197
  if (!queryNames.has(name) || names.has(name)) return binding;
197
198
  names.add(name);
198
199
  }
199
200
  if (!leaf.sto) {
200
- for (const binding of leaf.bindings) {
201
+ for (const binding of substitutions) {
201
202
  if (namedVariables(binding.args[1]).some((variable) => names.has(variable.name))) return binding;
202
203
  }
203
204
  }
@@ -208,6 +209,7 @@ function malformedAlternative(query, alternative) {
208
209
  function describeLeaf(term) {
209
210
  const leaf = {
210
211
  bindings: [],
212
+ approximations: [],
211
213
  unexpected: false,
212
214
  more: false,
213
215
  sto: false,
@@ -245,6 +247,11 @@ function describeLeaf(term) {
245
247
  else leaf.bindings.push(item);
246
248
  continue;
247
249
  }
250
+ if (item.type === COMPOUND && item.name === '~' && item.arity === 2) {
251
+ if (item.args[0].type !== VAR || approximateDecimalInterval(item.args[1]) == null) leaf.malformed ??= item;
252
+ else leaf.approximations.push(item);
253
+ continue;
254
+ }
248
255
  if (item.type === COMPOUND && item.name === 'inputs' && item.arity === 1) {
249
256
  const text = characterText(item.args[0]);
250
257
  if (text == null || leaf.input != null) leaf.malformed ??= item;
@@ -265,7 +272,8 @@ function describeLeaf(term) {
265
272
  if (isErrorDescription(item)) leaf.error = item;
266
273
  else leaf.malformed ??= item;
267
274
  }
268
- leaf.hasExpectation = leaf.bindings.length > 0 || leaf.truth || leaf.false || leaf.maybe || leaf.loops || leaf.waits ||
275
+ leaf.hasExpectation = leaf.bindings.length > 0 || leaf.approximations.length > 0 ||
276
+ leaf.truth || leaf.false || leaf.maybe || leaf.loops || leaf.waits ||
269
277
  leaf.error != null || leaf.output != null;
270
278
  if (!leaf.hasExpectation && !leaf.more && !leaf.sto && leaf.unsupported == null) leaf.malformed ??= term;
271
279
  if ([leaf.false, leaf.truth, leaf.error != null].filter(Boolean).length > 1) leaf.malformed ??= term;
@@ -404,7 +412,7 @@ function matchLeaf(program, query, leaf, actual, position) {
404
412
  // its absence requires an unconstrained answer. In either case the stated
405
413
  // substitutions remain exact and are checked below.
406
414
  if (leaf.maybe !== hasPendingConstraints(solution.env)) return false;
407
- return substitutionMatches(query, leaf.bindings, solution.env);
415
+ return substitutionMatches(query, leaf.bindings, leaf.approximations, solution.env);
408
416
  }
409
417
 
410
418
  function hasPendingConstraints(env) {
@@ -428,22 +436,73 @@ function alternativeDescribesLoop(alternative) {
428
436
  return splitOperator(alternative, ';').some((term) => describeLeaf(term).loops);
429
437
  }
430
438
 
431
- function substitutionMatches(query, bindings, actualEnv) {
439
+ function substitutionMatches(query, bindings, approximations, actualEnv) {
432
440
  const queryVariables = namedVariables(query);
433
441
  const queryNames = new Set(queryVariables.map((variable) => variable.name));
442
+ const queryVariablesByName = new Map(queryVariables.map((variable) => [variable.name, variable]));
434
443
  const expectedEnv = new Env();
435
444
  const rebound = new Set();
436
- for (const binding of bindings) {
445
+ for (const binding of [...bindings, ...approximations]) {
437
446
  const variable = binding.args[0];
438
447
  if (!queryNames.has(variable.name) || rebound.has(variable.name)) return false;
439
448
  rebound.add(variable.name);
440
- if (!unify(variable, binding.args[1], expectedEnv)) return false;
449
+ if (binding.name === '=') {
450
+ if (!unify(variable, binding.args[1], expectedEnv)) return false;
451
+ continue;
452
+ }
453
+ const actualVariable = queryVariablesByName.get(variable.name);
454
+ const actualValue = deref(actualVariable, actualEnv);
455
+ if (!approximatelyMatches(actualValue, binding.args[1])) return false;
456
+ // Once the approximate predicate has accepted the actual float, bind the
457
+ // expected-side variable to that exact observed term. This lets the
458
+ // ordinary variant matcher continue to check the rest of the answer,
459
+ // including variable sharing, without turning `~` into a fuzzy unifier.
460
+ if (!unify(variable, copyResolved(actualValue, actualEnv), expectedEnv)) return false;
441
461
  }
442
462
  const expected = compound('$quad_answer', queryVariables.map((variable) => copyResolved(variable, expectedEnv)));
443
463
  const actual = compound('$quad_answer', queryVariables.map((variable) => copyResolved(variable, actualEnv)));
444
464
  return patternVariant(expected, new Env(), actual, new Env());
445
465
  }
446
466
 
467
+ // Parse a quoted decimal spelling as the interval represented by rounding to
468
+ // its final written mantissa digit. For example, '14.2000' denotes
469
+ // [14.19995, 14.20005], and '1.42000e1' denotes the same interval. Construct
470
+ // the exact decimal endpoints before converting them to binary floats instead
471
+ // of adding a tiny tolerance to an already-rounded Number.
472
+ function approximateDecimalInterval(term) {
473
+ if (term?.type !== ATOM) return null;
474
+ const text = term.name;
475
+ const match = /^([+-]?)(\d+)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text);
476
+ if (match == null) return null;
477
+
478
+ const [, sign, integerDigits, fraction = '', exponentText = '0'] = match;
479
+ const digits = `${integerDigits}${fraction}`;
480
+ const unsignedMantissa = BigInt(digits || '0');
481
+ const mantissa = sign === '-' ? -unsignedMantissa : unsignedMantissa;
482
+ const exponent = Number(exponentText);
483
+ if (!Number.isSafeInteger(exponent)) return null;
484
+ const scale = exponent - fraction.length;
485
+
486
+ // center = mantissa * 10^scale; half an ulp of the written decimal is
487
+ // 5 * 10^(scale-1). Keeping the coefficient integral preserves every
488
+ // significant trailing zero in the expected spelling.
489
+ const lowerCoefficient = mantissa * 10n - 5n;
490
+ const upperCoefficient = mantissa * 10n + 5n;
491
+ const boundExponent = scale - 1;
492
+ const lower = Number(`${lowerCoefficient}e${boundExponent}`);
493
+ const upper = Number(`${upperCoefficient}e${boundExponent}`);
494
+ if (!Number.isFinite(lower) || !Number.isFinite(upper)) return null;
495
+ return { lower: Math.min(lower, upper), upper: Math.max(lower, upper) };
496
+ }
497
+
498
+ function approximatelyMatches(actual, expectedAtom) {
499
+ if (actual?.type !== NUMBER || isDecimalInteger(actual.name)) return false;
500
+ const actualValue = Number(actual.name);
501
+ if (!Number.isFinite(actualValue)) return false;
502
+ const interval = approximateDecimalInterval(expectedAtom);
503
+ return interval != null && actualValue >= interval.lower && actualValue <= interval.upper;
504
+ }
505
+
447
506
  function namedVariables(term) {
448
507
  const found = [];
449
508
  const seen = new Set();
@@ -727,8 +786,12 @@ function formatFailure(program, quad, result, description = quad.answers[0]) {
727
786
  }
728
787
 
729
788
  function formatQuadTerm(program, term) {
789
+ const operators = [...program.operators.values()];
790
+ if (!operators.some(({ name, specifier }) => name === '~' && ['xfx', 'xfy', 'yfx'].includes(specifier))) {
791
+ operators.push({ priority: 700, specifier: 'xfx', name: '~' });
792
+ }
730
793
  return formatTermForWrite(term, new Env(), {
731
794
  quoted: true,
732
- operators: [...program.operators.values()],
795
+ operators,
733
796
  });
734
797
  }
@@ -994,6 +994,88 @@ why(
994
994
  assertEqual(result.stdout, 'quads: 13 run, 13 passed, 0 failed.\n', 'quad report');
995
995
  },
996
996
  },
997
+ {
998
+ name: 'runQuads supports decimal-precision approximate float descriptions (issue #90)',
999
+ run: () => {
1000
+ const source = `?- V is 0+(3.2+11).
1001
+ ` +
1002
+ ` V ~ '14.2000'.
1003
+
1004
+ ` +
1005
+ `?- V is 0+(3.2+11).
1006
+ ` +
1007
+ ` V ~ '1.42000e1'.
1008
+
1009
+ ` +
1010
+ `?- V is -14.2.
1011
+ ` +
1012
+ ` V ~ '-14.2000'.
1013
+
1014
+ ` +
1015
+ `?- V is 1.0e-3.
1016
+ ` +
1017
+ ` V ~ '1.000e-3'.
1018
+
1019
+ ` +
1020
+ `?- V is 14.19995.
1021
+ ` +
1022
+ ` V ~ '14.2000'.
1023
+
1024
+ ` +
1025
+ `?- V is 14.20005.
1026
+ ` +
1027
+ ` V ~ '14.2000'.
1028
+
1029
+ ` +
1030
+ `?- V is 14.2.
1031
+ ` +
1032
+ ` V ~ '14.1999', unexpected.
1033
+
1034
+ ` +
1035
+ `?- V is 14.
1036
+ ` +
1037
+ ` V ~ '14.0', unexpected.
1038
+
1039
+ ` +
1040
+ `?- V is float(14).
1041
+ ` +
1042
+ ` V ~ '14.0'.
1043
+ `;
1044
+ const result = publicApi.runQuads(Program.parseSources([{ text: source, filename: 'approx-quad.pl' }]));
1045
+ assertEqual(result.total, 9, 'approximate answer-description total');
1046
+ assertEqual(result.passed, 9, 'approximate answer-description passed');
1047
+ assertEqual(result.failed, 0, 'approximate answer-description failed');
1048
+ assertEqual(result.stdout, 'quads: 9 run, 9 passed, 0 failed.\n', 'approximate quad report');
1049
+
1050
+ const malformed = publicApi.runQuads(`?- V is 14.2.
1051
+ V ~ 14.2000.
1052
+ `);
1053
+ assertEqual(malformed.total, 1, 'numeric approximation total');
1054
+ assertEqual(malformed.failed, 1, 'numeric RHS is rejected');
1055
+ assertIncludes(malformed.stdout, 'MALFORMED', 'numeric RHS diagnostic');
1056
+
1057
+ const malformedAtom = publicApi.runQuads(`?- V is 14.2.
1058
+ V ~ 'fourteen'.
1059
+ `);
1060
+ assertEqual(malformedAtom.failed, 1, 'non-decimal atom is rejected');
1061
+ assertIncludes(malformedAtom.stdout, 'MALFORMED', 'non-decimal atom diagnostic');
1062
+
1063
+ const duplicate = publicApi.runQuads(`?- V is 14.2.
1064
+ V = 14.2, V ~ '14.2000'.
1065
+ `);
1066
+ assertEqual(duplicate.failed, 1, 'duplicate exact/approximate binding is rejected');
1067
+ assertIncludes(duplicate.stdout, 'MALFORMED', 'duplicate binding diagnostic');
1068
+
1069
+ let ordinaryApproximateParsed = false;
1070
+ try {
1071
+ Program.parse(`p(X,Y) :- X ~ Y.\n`);
1072
+ ordinaryApproximateParsed = true;
1073
+ } catch (_) {
1074
+ // `~` is answer-description syntax, not a normal-profile operator.
1075
+ }
1076
+ assertEqual(ordinaryApproximateParsed, false, 'quad approximate operator stays scoped to answer descriptions');
1077
+ },
1078
+ },
997
1079
  {
998
1080
  name: 'runQuads treats unexpected as a negative assertion on the next leaf (issue #83)',
999
1081
  run: () => {
@@ -9597,6 +9597,26 @@ constraint remain. For example, a pending `dif/2` constraint can be checked as:
9597
9597
  maybe, unexpected.
9598
9598
  ```
9599
9599
 
9600
+ ISO arithmetic examples sometimes describe a floating result only as
9601
+ "approximately equal" to a written decimal. Quad answer descriptions preserve
9602
+ the precision of that spelling with `~`: the right-hand side is a decimal atom,
9603
+ so trailing zeroes remain significant. `V ~ '14.2000'` accepts a **float** in
9604
+ the closed interval `14.19995` through `14.20005`; it does not accept an integer
9605
+ term, even when that integer has the same mathematical value. Exponent notation
9606
+ uses the last written mantissa digit in the same way, so `'1.42000e1'` denotes
9607
+ the same interval. For example:
9608
+
9609
+ ```eyeprolog
9610
+ ?- V is 0+(3.2+11).
9611
+ V ~ '14.2000'.
9612
+ ```
9613
+
9614
+ This `~` notation belongs only to indented quad answer descriptions. It does
9615
+ not install an approximate-equality predicate or operator in ordinary Prolog
9616
+ source. A numeric right-hand side such as `V ~ 14.2000` is deliberately
9617
+ rejected because parsing it as a float would discard the written decimal
9618
+ precision that the check is intended to retain.
9619
+
9600
9620
  EyeProlog treats native variable constraints, attributed-variable residue, and
9601
9621
  delayed goals as pending residue for this purpose. Multiple
9602
9622
  indented descriptions after one query are independent checks: each re-runs the