eyeprolog 1.5.43 → 1.5.44

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.
@@ -1,3 +1,3 @@
1
1
  % From The Art of EyeProlog, Chapter 40.
2
2
  ?- V is 0+(3.2+11).
3
- V ~ '14.2000'.
3
+ V ~~ '14.2000'.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.5.43",
6
+ "version": "1.5.44",
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
@@ -176,6 +176,12 @@ export const PART3_OPERATOR_DEFINITIONS = [
176
176
  // ISO 1200 fx definition.
177
177
  export const QUAD_OPERATOR_DEFINITIONS = [
178
178
  [1200, 'xfx', '?-'],
179
+ // Quad approximate-answer notation. Keep this visible through current_op/3
180
+ // at the same priority/specifier as the ISO comparison operators. There is
181
+ // deliberately no built-in predicate behind it: ordinary source may define
182
+ // or call ~~ / 2 in the usual way, while quads interpret it specially only
183
+ // inside answer descriptions.
184
+ [700, 'xfx', '~~'],
179
185
  ];
180
186
 
181
187
  const CLPZ_OPERATOR_DEFINITIONS = [
@@ -1053,21 +1059,10 @@ class Parser {
1053
1059
  this.advance();
1054
1060
 
1055
1061
  const answers = [];
1056
- // Issue #90 extends only the embedded answer-description grammar with
1057
- // decimal-precision approximate equality. Do not leak `~` into ordinary
1058
- // EyeProlog source syntax: temporarily parse it like the standard 700 xfx
1059
- // comparison operators while consuming the indented quad answers.
1060
- const previousApproximateOperator = this.infixOperators.get('~');
1061
- this.infixOperators.set('~', { precedence: operatorStrength(700), associativity: 'none' });
1062
- try {
1063
- while (this.token.type !== TOK.EOF && this.sourceLineIsIndented(this.token.line)) {
1064
- answers.push(this.parseTerm(0, true));
1065
- this.expect(TOK.DOT, '.');
1066
- this.advance();
1067
- }
1068
- } finally {
1069
- if (previousApproximateOperator == null) this.infixOperators.delete('~');
1070
- else this.infixOperators.set('~', previousApproximateOperator);
1062
+ while (this.token.type !== TOK.EOF && this.sourceLineIsIndented(this.token.line)) {
1063
+ answers.push(this.parseTerm(0, true));
1064
+ this.expect(TOK.DOT, '.');
1065
+ this.advance();
1071
1066
  }
1072
1067
  if (answers.length === 0) throw new Error(`parse line ${line}: quad requires an indented answer description`);
1073
1068
 
package/src/quads.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // it does not relax the ordinary answer-substitution comparison.
6
6
  import {
7
7
  ATOM, COMPOUND, NUMBER, VAR, Env, atom, compound, copyResolved, deref,
8
- flattenConjunction, isDecimalInteger, listFromItems, properListItems, termIsGround,
8
+ flattenConjunction, isDecimalInteger, listFromItems, numberTextFromDouble, properListItems, termIsGround,
9
9
  unify, variable,
10
10
  } from './term.js';
11
11
  import { parseGoalText } from './parser.js';
@@ -247,7 +247,7 @@ function describeLeaf(term) {
247
247
  else leaf.bindings.push(item);
248
248
  continue;
249
249
  }
250
- if (item.type === COMPOUND && item.name === '~' && item.arity === 2) {
250
+ if (item.type === COMPOUND && item.name === '~~' && item.arity === 2) {
251
251
  if (item.args[0].type !== VAR || approximateDecimalInterval(item.args[1]) == null) leaf.malformed ??= item;
252
252
  else leaf.approximations.push(item);
253
253
  continue;
@@ -456,7 +456,7 @@ function substitutionMatches(query, bindings, approximations, actualEnv) {
456
456
  // Once the approximate predicate has accepted the actual float, bind the
457
457
  // expected-side variable to that exact observed term. This lets the
458
458
  // ordinary variant matcher continue to check the rest of the answer,
459
- // including variable sharing, without turning `~` into a fuzzy unifier.
459
+ // including variable sharing, without turning `~~` into a fuzzy unifier.
460
460
  if (!unify(variable, copyResolved(actualValue, actualEnv), expectedEnv)) return false;
461
461
  }
462
462
  const expected = compound('$quad_answer', queryVariables.map((variable) => copyResolved(variable, expectedEnv)));
@@ -464,11 +464,18 @@ function substitutionMatches(query, bindings, approximations, actualEnv) {
464
464
  return patternVariant(expected, new Env(), actual, new Env());
465
465
  }
466
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.
467
+ // A quad approximation atom denotes the closed decimal interval obtained by
468
+ // rounding to its final written mantissa digit. For example, '14.2000' denotes
469
+ // [14.19995, 14.20005], and '1.42000e1' denotes the same interval.
470
+ //
471
+ // The description is useful only if that exact decimal interval has meaningful
472
+ // resolution in EyeProlog's finite float set. Convert the lower endpoint,
473
+ // written midpoint, and upper endpoint to actual representable floats, using
474
+ // directed adjustment at the endpoints so the selected minimum/maximum remain
475
+ // inside the exact decimal interval. Require all three to be finite and
476
+ // strictly ascending. This rejects over-precise "fake float" descriptions whose
477
+ // decimal distinctions collapse to one implementation float, as well as
478
+ // overflow/continuation-value ranges.
472
479
  function approximateDecimalInterval(term) {
473
480
  if (term?.type !== ATOM) return null;
474
481
  const text = term.name;
@@ -483,16 +490,112 @@ function approximateDecimalInterval(term) {
483
490
  if (!Number.isSafeInteger(exponent)) return null;
484
491
  const scale = exponent - fraction.length;
485
492
 
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) };
493
+ // center = mantissa * 10^scale; half a unit in the final written decimal
494
+ // place is 5 * 10^(scale-1). Keep the decimal coefficients exact until the
495
+ // representable-float selection below.
496
+ const lowerDecimal = { coefficient: mantissa * 10n - 5n, exponent: scale - 1 };
497
+ const middleDecimal = { coefficient: mantissa, exponent: scale };
498
+ const upperDecimal = { coefficient: mantissa * 10n + 5n, exponent: scale - 1 };
499
+
500
+ const rawLower = decimalToFiniteFloat(lowerDecimal);
501
+ const middle = decimalToFiniteFloat(middleDecimal);
502
+ const rawUpper = decimalToFiniteFloat(upperDecimal);
503
+ if (rawLower == null || middle == null || rawUpper == null) return null;
504
+ // If nearest rounding already collapses any two of the three points, no
505
+ // directed endpoint adjustment can create three floats inside the interval.
506
+ // Reject here before exact decimal comparison, which also keeps absurdly
507
+ // over-precise exponents from constructing enormous BigInt powers.
508
+ if (!(rawLower.value < middle.value && middle.value < rawUpper.value)) return null;
509
+
510
+ // Number() rounds to nearest. If an endpoint rounded outside the closed
511
+ // decimal interval, move one representable float inward. This avoids
512
+ // accepting a float that is merely close to a decimal boundary but lies
513
+ // mathematically outside it.
514
+ let minimum = rawLower;
515
+ if (compareFiniteFloatToDecimal(minimum.value, lowerDecimal) < 0) {
516
+ minimum = canonicalFiniteFloat(nextUp(minimum.value));
517
+ }
518
+ let maximum = rawUpper;
519
+ if (compareFiniteFloatToDecimal(maximum.value, upperDecimal) > 0) {
520
+ maximum = canonicalFiniteFloat(nextDown(maximum.value));
521
+ }
522
+ if (minimum == null || maximum == null) return null;
523
+
524
+ if (!(minimum.value < middle.value && middle.value < maximum.value)) return null;
525
+ return { minimum, middle, maximum };
526
+ }
527
+
528
+ function decimalToFiniteFloat(decimal) {
529
+ const value = Number(`${decimal.coefficient}e${decimal.exponent}`);
530
+ return canonicalFiniteFloat(value);
531
+ }
532
+
533
+ function canonicalFiniteFloat(value) {
534
+ if (!Number.isFinite(value)) return null;
535
+ const text = numberTextFromDouble(value);
536
+ if (text == null || isDecimalInteger(text) || Number(text) !== value) return null;
537
+ return { value, text };
538
+ }
539
+
540
+ // Compare an IEEE-754 binary64 value with coefficient * 10^exponent exactly.
541
+ // The float is converted to an integer over a power-of-two denominator, so no
542
+ // second floating rounding is involved in deciding whether a rounded boundary
543
+ // candidate lies inside or outside the decimal interval.
544
+ const FLOAT_BITS = new DataView(new ArrayBuffer(8));
545
+ function finiteFloatRatio(value) {
546
+ FLOAT_BITS.setFloat64(0, value, false);
547
+ const bits = FLOAT_BITS.getBigUint64(0, false);
548
+ const negative = (bits >> 63n) !== 0n;
549
+ const exponentBits = Number((bits >> 52n) & 0x7ffn);
550
+ const fraction = bits & 0xfffffffffffffn;
551
+ if (exponentBits === 0 && fraction === 0n) return { numerator: 0n, denominatorPower: 0 };
552
+
553
+ let significand;
554
+ let exponent2;
555
+ if (exponentBits === 0) {
556
+ significand = fraction;
557
+ exponent2 = -1074;
558
+ } else {
559
+ significand = (1n << 52n) | fraction;
560
+ exponent2 = exponentBits - 1023 - 52;
561
+ }
562
+ if (negative) significand = -significand;
563
+ if (exponent2 >= 0) {
564
+ return { numerator: significand << BigInt(exponent2), denominatorPower: 0 };
565
+ }
566
+ return { numerator: significand, denominatorPower: -exponent2 };
567
+ }
568
+
569
+ function compareFiniteFloatToDecimal(value, decimal) {
570
+ const { numerator, denominatorPower } = finiteFloatRatio(value);
571
+ let left;
572
+ let right;
573
+ if (decimal.exponent >= 0) {
574
+ left = numerator;
575
+ right = decimal.coefficient * (10n ** BigInt(decimal.exponent));
576
+ right <<= BigInt(denominatorPower);
577
+ } else {
578
+ const decimalDenominator = 10n ** BigInt(-decimal.exponent);
579
+ left = numerator * decimalDenominator;
580
+ right = decimal.coefficient << BigInt(denominatorPower);
581
+ }
582
+ return left < right ? -1 : left > right ? 1 : 0;
583
+ }
584
+
585
+ const NEXT_FLOAT_BITS = new DataView(new ArrayBuffer(8));
586
+ function nextUp(value) {
587
+ if (Number.isNaN(value) || value === Infinity) return value;
588
+ if (value === -Infinity) return -Number.MAX_VALUE;
589
+ if (value === 0) return Number.MIN_VALUE;
590
+ NEXT_FLOAT_BITS.setFloat64(0, value, false);
591
+ let bits = NEXT_FLOAT_BITS.getBigUint64(0, false);
592
+ bits += value > 0 ? 1n : -1n;
593
+ NEXT_FLOAT_BITS.setBigUint64(0, bits, false);
594
+ return NEXT_FLOAT_BITS.getFloat64(0, false);
595
+ }
596
+
597
+ function nextDown(value) {
598
+ return -nextUp(-value);
496
599
  }
497
600
 
498
601
  function approximatelyMatches(actual, expectedAtom) {
@@ -500,7 +603,8 @@ function approximatelyMatches(actual, expectedAtom) {
500
603
  const actualValue = Number(actual.name);
501
604
  if (!Number.isFinite(actualValue)) return false;
502
605
  const interval = approximateDecimalInterval(expectedAtom);
503
- return interval != null && actualValue >= interval.lower && actualValue <= interval.upper;
606
+ return interval != null &&
607
+ actualValue >= interval.minimum.value && actualValue <= interval.maximum.value;
504
608
  }
505
609
 
506
610
  function namedVariables(term) {
@@ -995,51 +995,51 @@ why(
995
995
  },
996
996
  },
997
997
  {
998
- name: 'runQuads supports decimal-precision approximate float descriptions (issue #90)',
998
+ name: 'runQuads supports realistic decimal-precision float descriptions with ~~ (issue #90)',
999
999
  run: () => {
1000
1000
  const source = `?- V is 0+(3.2+11).
1001
1001
  ` +
1002
- ` V ~ '14.2000'.
1002
+ ` V ~~ '14.2000'.
1003
1003
 
1004
1004
  ` +
1005
1005
  `?- V is 0+(3.2+11).
1006
1006
  ` +
1007
- ` V ~ '1.42000e1'.
1007
+ ` V ~~ '1.42000e1'.
1008
1008
 
1009
1009
  ` +
1010
1010
  `?- V is -14.2.
1011
1011
  ` +
1012
- ` V ~ '-14.2000'.
1012
+ ` V ~~ '-14.2000'.
1013
1013
 
1014
1014
  ` +
1015
1015
  `?- V is 1.0e-3.
1016
1016
  ` +
1017
- ` V ~ '1.000e-3'.
1017
+ ` V ~~ '1.000e-3'.
1018
1018
 
1019
1019
  ` +
1020
- `?- V is 14.19995.
1020
+ `?- V is 14.199950000000001.
1021
1021
  ` +
1022
- ` V ~ '14.2000'.
1022
+ ` V ~~ '14.2000'.
1023
1023
 
1024
1024
  ` +
1025
1025
  `?- V is 14.20005.
1026
1026
  ` +
1027
- ` V ~ '14.2000'.
1027
+ ` V ~~ '14.2000'.
1028
1028
 
1029
1029
  ` +
1030
1030
  `?- V is 14.2.
1031
1031
  ` +
1032
- ` V ~ '14.1999', unexpected.
1032
+ ` V ~~ '14.1999', unexpected.
1033
1033
 
1034
1034
  ` +
1035
1035
  `?- V is 14.
1036
1036
  ` +
1037
- ` V ~ '14.0', unexpected.
1037
+ ` V ~~ '14.0', unexpected.
1038
1038
 
1039
1039
  ` +
1040
1040
  `?- V is float(14).
1041
1041
  ` +
1042
- ` V ~ '14.0'.
1042
+ ` V ~~ '14.0'.
1043
1043
  `;
1044
1044
  const result = publicApi.runQuads(Program.parseSources([{ text: source, filename: 'approx-quad.pl' }]));
1045
1045
  assertEqual(result.total, 9, 'approximate answer-description total');
@@ -1048,32 +1048,70 @@ why(
1048
1048
  assertEqual(result.stdout, 'quads: 9 run, 9 passed, 0 failed.\n', 'approximate quad report');
1049
1049
 
1050
1050
  const malformed = publicApi.runQuads(`?- V is 14.2.
1051
- V ~ 14.2000.
1051
+ V ~~ 14.2000.
1052
1052
  `);
1053
1053
  assertEqual(malformed.total, 1, 'numeric approximation total');
1054
1054
  assertEqual(malformed.failed, 1, 'numeric RHS is rejected');
1055
1055
  assertIncludes(malformed.stdout, 'MALFORMED', 'numeric RHS diagnostic');
1056
1056
 
1057
1057
  const malformedAtom = publicApi.runQuads(`?- V is 14.2.
1058
- V ~ 'fourteen'.
1058
+ V ~~ 'fourteen'.
1059
1059
  `);
1060
1060
  assertEqual(malformedAtom.failed, 1, 'non-decimal atom is rejected');
1061
1061
  assertIncludes(malformedAtom.stdout, 'MALFORMED', 'non-decimal atom diagnostic');
1062
1062
 
1063
1063
  const duplicate = publicApi.runQuads(`?- V is 14.2.
1064
- V = 14.2, V ~ '14.2000'.
1064
+ V = 14.2, V ~~ '14.2000'.
1065
1065
  `);
1066
1066
  assertEqual(duplicate.failed, 1, 'duplicate exact/approximate binding is rejected');
1067
1067
  assertIncludes(duplicate.stdout, 'MALFORMED', 'duplicate binding diagnostic');
1068
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');
1069
+ const fakeFloat = publicApi.runQuads(`?- V is 1.0.
1070
+ V ~~ '1.0000000000000001'.
1071
+ `);
1072
+ assertEqual(fakeFloat.failed, 1, 'fake-float midpoint spelling is malformed');
1073
+ assertIncludes(fakeFloat.stdout, 'MALFORMED', 'fake-float midpoint diagnostic');
1074
+
1075
+ const overPrecise = publicApi.runQuads(`?- V is 1.0000000000000002.
1076
+ V ~~ '1.0000000000000002'.
1077
+ `);
1078
+ assertEqual(overPrecise.failed, 1, 'interval without three distinct floats is malformed');
1079
+ assertIncludes(overPrecise.stdout, 'MALFORMED', 'over-precise interval diagnostic');
1080
+
1081
+ const continuationRange = publicApi.runQuads(`?- V is 1.0e308.
1082
+ V ~~ '1.0e309'.
1083
+ `);
1084
+ assertEqual(continuationRange.failed, 1, 'non-finite continuation-value interval is malformed');
1085
+ assertIncludes(continuationRange.stdout, 'MALFORMED', 'continuation-value diagnostic');
1086
+
1087
+ assertEqual(
1088
+ run('', { goal: 'current_op(Pri,Fix,=), current_op(Pri,Fix,~~)' }).stdout,
1089
+ 'current_op(700, xfx, =), current_op(700, xfx, ~~).\n',
1090
+ '~~ shares the ISO equality operator priority and specifier',
1091
+ );
1092
+ assertEqual(
1093
+ run('', { goal: 'current_op(700,xfx,~~)' }).stats.completed_goal_lists,
1094
+ 1,
1095
+ '~~ is visible through current_op/3 in the normal profile',
1096
+ );
1097
+ assertEqual(
1098
+ run('', { goal: 'current_op(_,_,~)' }).stats.completed_goal_lists,
1099
+ 0,
1100
+ '~ is not installed by the quad approximation feature',
1101
+ );
1102
+ assertEqual(
1103
+ run('', { isoStrict: true, goal: "current_op(700,xfx,'~~')" }).stats.completed_goal_lists,
1104
+ 0,
1105
+ 'strict ISO Part 1 does not predefine the ~~ extension',
1106
+ );
1107
+
1108
+ const ordinaryApproximate = Program.parse(`approx(X,Y) :- X ~~ Y.\n`);
1109
+ assertEqual(ordinaryApproximate.groups.has('user:~~/2'), false, '~~ has no built-in or implicit predicate definition');
1110
+ assertEqual(
1111
+ run(`~~(my,definition).\n`, { goal: 'my ~~ definition' }).stats.completed_goal_lists,
1112
+ 1,
1113
+ '~~ remains available as an ordinary user-defined predicate',
1114
+ );
1077
1115
  },
1078
1116
  },
1079
1117
  {
@@ -9608,23 +9608,37 @@ constraint remain. For example, a pending `dif/2` constraint can be checked as:
9608
9608
 
9609
9609
  ISO arithmetic examples sometimes describe a floating result only as
9610
9610
  "approximately equal" to a written decimal. Quad answer descriptions preserve
9611
- the precision of that spelling with `~`: the right-hand side is a decimal atom,
9612
- so trailing zeroes remain significant. `V ~ '14.2000'` accepts a **float** in
9613
- the closed interval `14.19995` through `14.20005`; it does not accept an integer
9614
- term, even when that integer has the same mathematical value. Exponent notation
9615
- uses the last written mantissa digit in the same way, so `'1.42000e1'` denotes
9616
- the same interval. For example:
9611
+ the precision of that spelling with `~~`: the right-hand side is a decimal atom,
9612
+ so trailing zeroes remain significant. `V ~~ '14.2000'` accepts a **float** in
9613
+ the closed decimal interval `14.19995` through `14.20005`; it does not accept an
9614
+ integer term, even when that integer has the same mathematical value. Exponent
9615
+ notation uses the last written mantissa digit in the same way, so `'1.42000e1'`
9616
+ denotes the same interval. For example:
9617
9617
 
9618
9618
  ```eyeprolog
9619
9619
  ?- V is 0+(3.2+11).
9620
- V ~ '14.2000'.
9621
- ```
9622
-
9623
- This `~` notation belongs only to indented quad answer descriptions. It does
9624
- not install an approximate-equality predicate or operator in ordinary Prolog
9625
- source. A numeric right-hand side such as `V ~ 14.2000` is deliberately
9626
- rejected because parsing it as a float would discard the written decimal
9627
- precision that the check is intended to retain.
9620
+ V ~~ '14.2000'.
9621
+ ```
9622
+
9623
+ `~~` is an EyeProlog normal-profile operator at priority 700 with specifier
9624
+ `xfx`, matching the priority/specifier of ISO comparison operators such as `=`;
9625
+ there is no built-in `~~/2` predicate. Quad answer descriptions interpret the
9626
+ operator specially as approximate float matching.
9627
+
9628
+ An approximation is accepted as well-formed only when its exact decimal
9629
+ interval contains at least three distinct, strictly ascending finite EyeProlog
9630
+ floats: the minimum representable float in the interval, the float denoted by
9631
+ the written midpoint, and the maximum representable float in the interval. This
9632
+ prevents a decimal spelling from claiming precision finer than the
9633
+ implementation can represent, including decimal spellings whose apparent
9634
+ precision collapses to the same implementation float, and rejects ranges that
9635
+ would require non-finite continuation values. Endpoint
9636
+ selection is directed inward, so a binary rounding of a decimal bound cannot
9637
+ admit a float that lies mathematically outside the closed decimal interval.
9638
+
9639
+ A numeric right-hand side such as `V ~~ 14.2000` is deliberately rejected
9640
+ because parsing it as a float would discard the written decimal precision that
9641
+ the check is intended to retain.
9628
9642
 
9629
9643
  EyeProlog treats native variable constraints, attributed-variable residue, and
9630
9644
  delayed goals as pending residue for this purpose. Multiple