# Matching LibreOffice · Malte Therkildsen

> P33’s spreadsheet matches LibreOffice in every cell of its corpus to all fifteen digits, because two files compute logarithms, powers and a fused multiply-add exactly with BigInt. Switch them off in the page to see which cells differ.

Source: https://therkildsen.dev/writing/digits

[Writing](https://therkildsen.dev/writing)/Matching LibreOffice
W09NotesFrom the Lab

# Matching LibreOffice to the fifteenth digit

Cells, the spreadsheet in P33, is held against LibreOffice cell by cell, and a number matches only when all fifteen digits LibreOffice writes are the same. With plain JavaScript arithmetic in node, eight cells of the corpus differ, all of them logarithms or loan and cash flow formulas. Two files, `cr.mjs` and `fp.mjs`, compute those values exactly with BigInt and round them once, and every cell matches. Below, the whole corpus is worked out in your browser. Switch a part off to see which cells depend on it.

PostedKind**Notes**Series**From the Lab**

        Loading the corpus.

        Switch all off

        Cells·

        Match·

        Differ·

| Workbook and cell | Formula | LibreOffice | Cells |
| --- | --- | --- | --- |

    The corpus is the fourteen workbooks P33 is tested on and the eighty workbooks of its fuzzer, each worked out and saved by LibreOffice. Your browser builds every workbook with the same code as P33 and compares each formula cell with LibreOffice’s value. The marked digits in the Cells column are where the two values first differ. With a part off, the page uses your browser’s own `Math` functions, so the count can differ from the command further down, which ran in node.

## How the judge compares two numbers

      A spreadsheet number is a double, the same floating point type as a number in JavaScript. Writing a double so that it reads back as the same double takes up to seventeen significant digits. LibreOffice writes fifteen of them into the file it saves. P33’s judge (`judge.mjs`) writes its own number the same way, rounded half away from zero at the fifteenth digit, and compares the two texts.

      Two doubles that sit side by side, one unit in the last place apart (an ulp, the gap from a double to the next one), usually round to the same fifteen digits. They read differently when a rounding boundary of the fifteenth digit falls between them. A function that is one ulp off therefore passes most cells and fails a few, and which ones fail depends on the inputs.

## A logarithm rounded once

      A function is correctly rounded when it returns the double nearest to the true value. The JavaScript specification asks `Math.log` only for an approximation. The size of the error is left to the engine. , the engine in Chrome and node, returns the nearest double for most inputs and its neighbor for some. `Math.log(3)` is one of them. Pick a function and a number below to see where the true value lies.

            LN

            EXP
            POWER

          The double belowIn fifteen digits

          The true value

          The double aboveIn fifteen digits

        ·

      LibreOffice takes its logarithms from the system library of the Mac it ran on. One cell of the corpus depends on the difference: `=LOG10(LN(3))`, from the fuzzer. The two values of `LN(3)` read the same in fifteen digits. `LOG10` of a number this close to one magnifies their relative difference, so the fifteenth digit of the result changes. LibreOffice saved the logarithm of the correctly rounded `LN(3)`.

      `cr.mjs` computes each value in fixed point: a BigInt that holds the number times a power of two, with `P = 160n` bits after the point where a double has fifty-two. The logarithm splits its input into a power of two and a factor between three quarters and one and a half. It then sums the series in `ln(f) = 2 * atanh((f - 1) / (f + 1))` until a term is zero. The exponential subtracts a whole number of `ln(2)` and sums the Taylor series of what is left. The result is then rounded once to the nearest double, with ties to the even one, in `nearest()`.

## One rounding instead of two

      JavaScript computes `a * b + c` with two roundings: one after the product and one after the sum. The compiler that built LibreOffice for the Mac turned that expression, in the loan formulas, into a fused multiply-add, an instruction that rounds once. JavaScript has no such operation, so `fp.mjs` writes one. It splits each double into an integer and a power of two, and multiplies the integers exactly as BigInt. It lines the sum up on the smaller power, adds, and rounds the result once.

      RATE shows the effect most. It finds the interest rate by Newton’s method. It stops when a step is smaller than one ten millionth. A different rounding in any step changes the steps after it, and so the rate it returns. In the loan workbook, the two ways of rounding `a * b + c` give rates that first differ in the fourteenth digit.

      Whole powers get the same treatment. `fp.mjs` raises the significant bits of `x`, read as one whole number, to the power exactly as a BigInt, and rounds once. A negative power divides a power of two by that BigInt, with sixty more bits than the divisor has. A final bit records whether the division left a remainder. In node, `Math.pow(1.05, 127)` returns the other double, and a cell would show it. The figure above has it as an example, worked out by your browser.

## Held against Python and LibreOffice

      The true values come from code that shares nothing with `cr.mjs` or `fp.mjs`. `oracle.py` uses Python’s decimal module, at the precision in the first line below, for the logarithms, exponentials and real powers. It uses exact fractions for the fused multiply-add and the whole powers, and rounds each value once to the nearest double. The first table counts the values each side gets wrong. The second runs plain JavaScript against the two files on random inputs. It counts how often plain JavaScript returns another double and how often the fifteen digits change too, and it times each call.

```
$ node web/notes/digits/ulp.mjs
against oracle.json (Python's decimal at 120 digits, exact fractions), 4,000 values
function          values   cr.mjs or fp.mjs wrong   plain JavaScript wrong
ln                500      0                        5
log10             500      0                        26
exp               500      0                        55
log1p             500      0                        45
expm1             500      0                        44
a^b, b not whole  500      0                        51
a^n, n whole      500      0                        57
a * b + c         500      0                        133
cr.mjs and fp.mjs gave the true value for all 4,000

100,000 random inputs per function, plain JavaScript against cr.mjs and fp.mjs, in node 22.22.0
function          other double   other 15 digits   plain, per call   exact, per call
ln                608 (0.6%)     57 (0.06%)        109 ns            6.1 µs
log10             5,330 (5.3%)   313 (0.31%)       120 ns            6.3 µs
exp               9,579 (9.6%)   723 (0.72%)       43 ns             6.7 µs
log1p             8,024 (8.0%)   643 (0.64%)       79 ns             7.9 µs
expm1             9,250 (9.3%)   598 (0.60%)       54 ns             7.2 µs
a^b, b not whole  9,776 (9.8%)   660 (0.66%)       154 ns            12.6 µs
a^n, n whole      9,726 (9.7%)   566 (0.57%)       174 ns            19.3 µs
a * b + c         21,211 (21.2%) 2,284 (2.28%)     97 ns             888 ns
```

      Then the corpus, worked out by the copy of Cells this page runs, with each part switched off on its own and then all three. The two columns on the right are Cells with the parts off and with them on.

```
$ node web/notes/digits/held.mjs
LibreOffice 26.8.0.3, corpus made 2026-10-04: 14 workbooks and 80 from the fuzzer, 5,472 cells
every part on (as P33):  5,472 of 5,472 match
cr.mjs off:              1 differ
fp.mjs fma off:          5 differ
fp.mjs pow off:          4 differ
all three off:           8 differ

the cells that differ with all three off (LibreOffice, Cells with them off, Cells with them on):
finance H17  =NPV(0.1,-1000,A5:A7)                   -19.1243767502221     -19.1243767502222     -19.1243767502221
finance H28  =RATE(A2,-500,A3)                     0.00618341316125358   0.00618341316125379   0.00618341316125358
finance H29  =RATE(48,-200,8000)                   0.00770147248823326   0.00770147248823306   0.00770147248823326
finance H30  =RATE(12,-100,1000,0,1)                0.0350315303622831     0.035031530362283    0.0350315303622831
finance H32  =RATE(4.5,-100,400)                    0.0443334638914012    0.0443334638914013    0.0443334638914012
loan I7  =RATE(B3*B4,-B6,B1)*B4                     0.0449999999999946    0.0449999999999901    0.0449999999999946
fuzzer seed 51 H47  =B7-0+FV(0.05,182,25000,0,0)     -3592212252.33211      -3592212252.3321     -3592212252.33211
fuzzer seed 68 H35  =LOG10(LN(3-C1))                0.0408444525689207    0.0408444525689206    0.0408444525689207
in 1.3 s, in node 22.22.0
```

## What it does not cover

      The corpus was saved by one build of LibreOffice on one Mac, named in the first line of the command above. LibreOffice on Windows or Linux uses other system libraries and other compiler settings, and this note says nothing about the digits it gives there.

      `cr.mjs` works with a fixed number of bits and has no proof that they suffice for every input. A correctly rounded result needs enough bits to decide on which side of the midpoint between two doubles the true value falls. An input whose true value lies closer to a midpoint than those bits can resolve would be rounded the wrong way, and `cr.mjs` does not detect that case. The oracle above found no such input among its values, which is evidence and not a guarantee.

      Exact arithmetic costs time: a call to `cr.mjs` takes microseconds where `Math.log` takes nanoseconds, as the second table shows. Cells uses the two files only where the last digit can show: the logarithms, EXP, POWER and the power operator, ROUNDUP and ROUNDDOWN, the variance and standard deviation, and the loan and cash flow functions.

      The source of `cr.mjs` and `fp.mjs` is P33’s, under `prototypes/source/p33/` in the portfolio’s repository. The note’s copy of Cells is in `web/notes/digits/cells/`, written by `copy.mjs`, and differs only where six files import the two through `switch.mjs`. `held.mjs` and `ulp.mjs` are the commands, `wide.mjs` computes the true values in the figure, and `digits.test.mjs` holds the tests.

[Newer · W10Spark, a JavaScript engine held against Ecma’s tests](https://therkildsen.dev/writing/spark)[Older · W08Asymptote, figures drawn by the build and redrawn in the page](https://therkildsen.dev/writing/asymptote)
