Skip to content

[deps] upgrade Math.js to 15.2.0 and preserve expression contracts - #36

Draft
mwasiluk wants to merge 6 commits into
SilverDecisions:modernizefrom
mwasiluk:deps/mathjs-upgrade
Draft

mwasiluk wants to merge 6 commits into
SilverDecisions:modernizefrom
mwasiluk:deps/mathjs-upgrade

Conversation

@mwasiluk

@mwasiluk mwasiluk commented Nov 4, 2026

Copy link
Copy Markdown
Member

Draft: depends on consumer changes and on decisions of the owner (below).

Base: modernize after the squashes of A (2e70d48) and B (d00e0e9). Own commits (rebased, same tree as
before): 1f24b29, e1171b6, 0c0bd47 (replaces the rounding of the first two; the net change is what is
described here), b70e2eb, d1e3558, f6d361b (docs). Squash on merge.

Version: Math.js 15.2.0

Latest stable release; the only version after 9.x without known parser vulnerabilities:
GHSA-jvff-x2qm-6286, GHSA-29qv-4j9f-fjw5 (CVE-2026-40897), GHSA-5v89-rwgr-qj6g (CVE-2026-41139), all High,
affect 13.1.0 - 15.1.x, fixed in 15.2.0 (9.5.0 not affected).

Changes

  • mathjs 9.5.0 → 15.2.0 (exact). Runtime tree: fraction.js 5.3.4 (bigint), typed-function 4.2.2,
    complex.js 2.4.3, decimal.js 10.6.0, @babel/runtime 7.29.10; sd-utils, sd-random, lodash, loglevel unchanged.
  • Engine (src/expression-engine.js), public API unchanged:
    • Fraction arithmetic (add, subtract, multiply, divide) is exact, as Math.js 15 computes it. The
      earlier rounding policies of this PR are removed: an absolute bound made small values 0, a relative one
      changed x + 0 (x = 2^80/3: compare(x, add(x, 0)) 1, subtract -1/3). Regression tests for both.
    • toFloat of a Fraction returns the double nearest to it, ties to even, including subnormals and overflow
      (fraction.js 5 valueOf() = Number(n) / Number(d) rounds twice and gives Infinity/NaN for large parts).
      Contract: correct rounding. Reference: 188 cases (both sides of midpoints, ties, subnormals, the overflow
      boundary, random Fractions up to 1200 bits) generated with Python fractions.Fraction
      (test/contract/fixtures/tofloat_cases.py).
    • toNumber rejects a Unit (as 0.1.17; Math.js 14+ converts the value of a unit).
  • Long sums: exact Fractions grow without limit (the mean of the probabilistic analysis: hundreds of digits
    after 100 runs, the browser blocks at 1000). Bounded where they are formed, in sd-computations
    (fix/psa-exact-mean), not in the engine.
  • Tests: SD_EXPRESSION_ENGINE_BASELINE=0.1.17 / npm run test:baseline for the committed 0.1.17 dist,
    test/contract/mathjs-version.test.js asserts both versions for every difference: exactness against bigint
    rational arithmetic (1000 random operand pairs of 1e-30 - 1e30), small values, toFloat cases, and the
    table of reproduced Math.js 10-15 changes of user expressions.
  • docs/MATHJS_UPGRADE.md: sources of the differences and a proposal for old diagrams (for decision, not
    implemented).
  • Build check: bundles parse as ES2020, package sources compiled by a consumer stay ES5.

Silent changes of user expressions (reproduced, Math.js HISTORY.md 10.0 - 15.2)

0.1.17 C
100 + 3% + 1, 100 * 3% + 1, x% + 1 (x = 2) (15.0) 104, 4, 1.02 100, 0, 0
10 / 4 mod 3 (14.0, mod precedence) 10 2.5
-1 / 2 x, +1 / 2 x (x = 2), -1 / 2 pi (11.0) -0.25, 0.25, -0.159 -1, 1, -1.571
x > 0 (x = 1e-15), abs(0.1 + 0.2 - 0.3) < 1e-15, isZero(1e-15) (13.0 tolerances) true, true, false false, false, true
isInteger(2.00000000000001) ? 100 : 0, isInteger(0.1 * 3 * 10) (15.1) 0, false 100, true
0.3 mod 0.1 (11.11.1) 0.0999… -5.6e-17
print("$1 $2", [10, 20]) (11.11.1), typeOf(f) (11.0), combinations(60, 30) (10.2) 20 $2, Function, …420 10 20, function, …460
sqrt([4, 9]), x ? : 1, size(), m[1, :], US gallons error, error, array, vector, more precise

absTol: Number.EPSILON in the Math.js configuration restores all comparison cases of 13.0 (not
isInteger, mod, floor/ceil) - an option, not applied. The expressions of the 16 saved diagrams of the
test data give the same values in both versions.

Consumer changes (separate branches)

  • sd-computations build/minifier-bigint: merged (faddc5f). uglify-js 3.4.9 → 3.19.3 (lockfile, dev): the
    standalone build failed on 0n.
  • sd-computations fix/psa-exact-mean: merged (296c764). Expected values of the
    probabilistic analysis from the rows: exact fixed-point sum of the doubles (no BigInt) and one rounding of sum /
    runs. Independent of C; ready for review.
  • sd-computations fix/job-resume-semantics (172e22e, 52a7ab5): an expression semantics profile (profile
    version, full Math.js version, whole Math.js configuration) in every job execution; a stopped job is continued
    only under the same profile, otherwise archived and restarted; a worker with another profile refuses, and the
    refused execution is ended (FAILED, endTime) and can be run again. Draft: not to be merged before the
    decision on expressions that change the shared Math.js state (decision 6); its refusal of diagrams with
    config(...) applies with 0.1.17 too.
  • SilverDecisions fix/babel-bigint (95114ae, patch; the fork cannot be pushed from here): Math.js and
    fraction.js excluded from the global babelify transform, which turned ** on bigint into Math.pow.

Results (final set: C d0a97e3, code unchanged in f6d361b, + the four consumer branches; PSA at 1e0526b, 79066f3 changes only the sign of zero and comments)

  • npm run test:build, Node 24.22.2 / npm 11.19.1 and Node 22.22.0 / npm 10.9.4: all checks passed; contract
    tests standalone(.min) 72 / 3 skipped, module(.min) 74 / 1, source 71 / 4, consumer bundle 71 / 4, installed
    dist 72 / 74; Jasmine 11/11 (engine code unchanged since; not rerun for the documentation commits).
  • npm run test:baseline (committed 0.1.17 dist): standalone 70 / 5 skipped, module 72 / 3.
  • sd-computations (minifier 41a87b8, PSA 1e0526b, resume 52a7ab5) with C: gulp build incl. standalone,
    Karma 222/222 (196 + 11 exact sum / mean + 15 semantics profile, incl. real Web Workers).
  • SilverDecisions (Babel fix, C, the sd-computations branches): gulp build, Karma 37/37; a worker with another
    Math.js configuration is refused from the sensitivity analysis dialog (alert, loading ended, stored FAILED with
    endTime), the analysis completes after a reload with the normal worker. Earlier browser scenarios (worker):
    identical to B except two values where 0.1.17 rounded (expected-value-minimization of
    differentrulesandflip_pass.json exact instead of 0.5816063329128321; PSA 51.71343227838675 → …676).
  • Job records of SilverDecisions 1.2.1 (IndexedDB, worker): the tornado result is read back identically; a
    probabilistic analysis stopped after 1110 rows is not continued under Math.js 15: a new instance computes all
    600 runs (expected values within 0.26 ulp of the exact row mean), the old one stays STOPPED with its rows under
    …|archived|<id>. Control: the new sd-computations with 0.1.17 continues the same instance.
  • PSA, 1000 runs (Karma, Chromium; worker / main thread): new sd-computations + 0.1.17 13.1 / 14.7 s; + C 35.6 /
    43.8 s (31-40 s in earlier runs); old sd-computations + C blocks.

Decisions needed

  1. Runtime requirement ES2020 + BigInt (Math.js ≥ 13).
  2. The silent expression changes above: handling of old diagrams (docs/MATHJS_UPGRADE.md), and whether the
    0.1.17 comparison tolerances are kept (absTol: Number.EPSILON).
  3. Performance: the probabilistic analysis about 2.5 times slower with Math.js 15.
  4. JSON form of Fractions with string n / d in the job repository (records of 0.1.17 are read).
  5. Stored job results of 0.1.17: a stopped job is restarted under Math.js 15; completed results are shown as
    computed (marking them in the UI, cleaning up archived instances: open).
  6. Expressions that change the shared Math.js instance (config(...), import, createUnit): with the semantics
    profile, a diagram whose code calls config(...) cannot run restartable analyses in the SilverDecisions worker
    (page and worker profiles differ). Allow (refused as now), reject, or isolate per diagram; import /
    createUnit are not covered by the profile.

Squash merge

[deps] upgrade Math.js to 15.2.0 and preserve expression contracts

Math.js 9.5.0 -> 15.2.0 (fixes the parser vulnerabilities of 13.1-15.1). Fraction arithmetic stays exact,
toFloat is correctly rounded for Fractions of any size, eval with asNumber rejects units; the committed
0.1.17 dist is tested against its own contract; behaviour changes of Math.js for user expressions are
listed in docs/MATHJS_UPGRADE.md. Requires ES2020 and BigInt.

Math.js 9.5.0 -> 15.2.0 (fraction.js 5 with bigint, typed-function 4);
15.2.0 fixes the parser vulnerabilities of 13.1-15.1 (CVE-2026-40897,
CVE-2026-41139, GHSA-jvff-x2qm-6286).

Engine adaptations to keep the 0.1.17 contract: add/subtract/multiply/
divide round results with a denominator above 2^53 - 1 to the closest
fraction with a smaller one (long sums such as the probabilistic
sensitivity analysis mean stayed bounded with fraction.js 4; with bigint
they grew to thousands of digits and toFloat returned NaN); toFloat
handles Fractions beyond the double range; eval with asNumber rejects
Unit results as before.

Tests: the contract of built artifacts of a release is selected
explicitly (SD_EXPRESSION_ENGINE_BASELINE=0.1.17, npm run test:baseline
for the committed dist/), version-dependent expectations assert both
Math.js versions. The bundles parse as ES2020 (Math.js 13+ requires
ES2020 and BigInt); the package sources still compile to ES5.
The denominator bound of the arithmetic helpers was absolute: results
below about 1e-16 became 0 (divide(1, 1e20), multiply(divide(1, 1e20),
1e20)), and toFloat lost Fractions beyond the double range (Infinity for
(10^310 + 1) / 1009, 0 for (2^1200 + 1) / (2^2200 + 1)).

Results stay exact while numerator and denominator have at most 64 bits
and are otherwise replaced by the first continued-fraction convergent
within a relative error of 2^-64; toFloat divides with bigint to 64
significant bits and scales by a power of two. Tests against exact
rational arithmetic: the reported cases, random operands from 1e-30 to
1e30, a sum of terms between 1e-25 and 1e25, random large Fractions in
toFloat.
…Math.js changes

Revert the rounding of add/subtract/multiply/divide: it changed values
(x + 0 != x for x = 2^80 / 3) and decisions on ties; the arithmetic is
exact again as Math.js 15 computes it. Bounding the long sums of the
probabilistic sensitivity analysis belongs to the aggregation in
sd-computations.

toFloat of a Fraction returns the double nearest to it, ties to even,
including subnormal results and overflow to Infinity (bigint division with
a sticky bit). Reference cases (midpoints, ties, subnormals, overflow,
random large Fractions) generated with Python's fractions.Fraction.

Version-difference tests for the reproduced Math.js 10-15 changes of user
expressions (percent and mod precedence, implicit multiplication,
comparison tolerances, isInteger, mod round-off, matrix functions, print
indices, ...); docs/MATHJS_UPGRADE.md lists their sources and proposes the
handling of old diagrams for decision.
sd-computations (fix/job-resume-semantics) records the expression semantics in every job execution and continues a stopped job only under the semantics it was started with; the proposal for old diagrams refers to it instead of an unimplemented marker.
sd-computations (fix/job-resume-semantics, 52a7ab5) now records a profile of the Math.js version and configuration instead of the Math.js major; expressions that change the shared configuration are listed as a decision.
fix/job-resume-semantics is not ready to be merged on its own: it refuses analyses of diagrams that call config(...) also with 0.1.17, and its profile does not cover createUnit / import.
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