Skip to content

Compute xs:decimal arithmetic exactly - #688

Open
dbartholomae wants to merge 1 commit into
FontoXML:masterfrom
dbartholomae:exact-decimal-arithmetic
Open

dbartholomae wants to merge 1 commit into
FontoXML:masterfrom
dbartholomae:exact-decimal-arithmetic

Conversation

@dbartholomae

Copy link
Copy Markdown

Part of #686

What changes

  • src/expressions/operators/arithmetic/decimalArithmetic.ts (new): exact +, -, *, div, idiv and mod for xs:decimal operands. Each operand is read as the shortest decimal its number prints as (String(0.1) is "0.1"), the operation runs on a BigInt coefficient with a decimal exponent, and only the result is rounded back to a number. 1.1 + 2.2 eq 3.3 is now true, and 10.05 + 33.33 is 43.38 rather than 43.379999999999995. A quotient that does not terminate is carried to 20 digits plus a sticky digit before that final rounding, so it still rounds correctly.
  • BinaryOperator.ts: when determineReturnType gives xs:decimal (two decimals, or a decimal and an integer), the numeric branch takes the exact operation instead of the one in the operation map. The choice is made once per pair of operand types, where the map lookup already happens, so nothing is added per evaluation for other types.
  • tsconfig.json, build.js: es2020.bigint added to lib. target stays es2017, so the code calls BigInt(10) rather than using 10n literals.
  • test/assets/unrunnableTestCases.csv: the four QT3 tests that now pass are removed.

What does not change

  • Values: xs:decimal values are still JavaScript numbers. What callers pass in and get back, and fontoxpath.d.ts, are byte-for-byte the same.
  • Other types: xs:double, xs:float, untyped and all-integer arithmetic run the same code as before. @a + 0.2 on an untyped attribute is still a double addition, as the spec requires.
  • Division by zero: 1.0 div 0.0 still gives Infinity (the spec wants FOAR0001, which is a separate change).
  • Long operands: an operand of more than 15 significant digits falls back to number arithmetic. Such a number prints as up to 17 digits, like 0.3333333333333333 for 1.0 div 3, and computing exactly on those digits is no more accurate than the float operation and sometimes one ulp further off. op-divide-dayTimeDuration-by-dTD-11 regresses without this limit.
  • Integer operands: when both operands are whole numbers, as in 2.0 * 3, number arithmetic is used, because the float operation is already exact for them.
  • Dependencies: none added or bumped.

Tests

  • Unit tests: BinaryOperator.tests.ts gains an xs:decimal arithmetic block with 13 tests. They cover each operator, negative operands, non-terminating quotients, values cast from strings, a decimal meeting an integer, the string form of a result and the result type. Guards check that double, untyped and long operands keep their current results. On master, the 8 that test exact results fail; the 5 that pin behaviour which must not change pass.

  • QT3: npm run qt3tests -- --regenerate

    • master: 21,829 passing, 4,259 expected failures.
    • This branch: 21,833 passing. Four tests newly pass: K-NumericAdd-4, K-NumericMod-19, XMark-Q18 and XMark-All (the last two convert currencies with 2.20371 * $v). None newly fail.
  • CI suites: all pass on Node 24 with the bundle built by npm run build:

    • npm run lint
    • ci-test
    • test-jscodegen, with and without --dist
    • integrationtests -- --dist
    • qt3tests -- --dist
    • qt3testsxqueryx, xqutstests, xqutstestsxqueryx
    • fuzzertest
  • Bundle size: fontoxpath.js grows by 1,556 bytes (+0.5 %); gzipped by 595 bytes. Operators are looked up in a switch rather than an object literal, because Closure's advanced mode renames the literal's keys and the lookup then misses in the bundle only.

  • Benchmark: built bundles, benchmark suite, median of five alternating runs each, Node 24, Apple M1 Max:

    Case master (ops/s) branch (ops/s) change
    integer: 1000 × ($i * 2 + 1) 725 723 -0.2 %
    double: 1000 × ($i * 0.5e0 + 1e0) 712 701 -1.5 %
    untyped: 1000 × (@A * $i + 1) 406 402 -1.0 %
    decimal whole: 1000 × ($i * 2.0 + 1.0) 784 771 -1.6 %
    decimal: 1000 × ($i * 1.1 + 0.01) 781 466 -40.3 %
    decimal: 1000 × ($i div 1.5) 1,233 649 -47.3 %
    EN 16931 rule BR-S-08 expression (single eval) 100,271 83,276 -16.9 %
    1.1 + 2.2 (single eval) 883,575 591,476 -33.1 %

    Integer, double, untyped and whole-number decimal arithmetic are within 2 %. Decimal arithmetic on fractional operands pays for the conversion: about 0.4 µs per operation here, which is 40–47 % in a loop that does nothing else and 17 % on a realistic expression. If that is too much, +, - and * can stay in plain numbers whenever both scaled coefficients are below 2^53, with one division by a power of ten at the end, which is also correctly rounded; I kept this version simpler to review first.

This builds on the idea in @NeosiaNexus's fontoxpath-exact-decimal fork, credited as co-author. Maintainer edits are enabled.


Disclosure: this pull request was written with an AI coding assistant (Claude Code). The tests, the QT3 runs, the benchmark and the bundle checks described above were run, not only written.

Two xs:decimals, or an xs:decimal and an xs:integer, are now added,
subtracted, multiplied and divided on BigInt coefficients and rounded
back to a number once, so 1.1 + 2.2 eq 3.3 holds. Values stay
JavaScript numbers; double, float, untyped and integer arithmetic are
unchanged. Operands of more than 15 significant digits keep number
arithmetic.

Makes K-NumericAdd-4, K-NumericMod-19, XMark-Q18 and XMark-All pass.

Part of FontoXML#686

Co-authored-by: NeosiaNexus <63867369+NeosiaNexus@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@DrRataplan

Copy link
Copy Markdown
Contributor

Thanks for this. While the -47% slowdown for some functions scare me a bit, correctness is a whole lot more important to me than speed. And using FontoXPath for fast arithmetic is a bit of a stretch.

@bwrrp would you have some time to check this out?

Too bad https://github.com/tc39/proposal-decimal does not seem to be usable here (yet).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants