Skip to the content
W09NotesFrom the Lab

Matching LibreOffice to the fifteenth digit

Cells, the spread­sheet in P33, is held against LibreOffice cell by cell, and a number matches only when all fif­teen digits LibreOffice writes are the same. With plain JavaScript arith­metic in node, eight cells of the corpus differ, all of them log­a­rithms or loan and cash flow for­mu­las. Two files, cr.mjs and fp.mjs, com­pute those values ex­actly 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.

PostedKindNotesSeriesFrom the Lab

Loading the corpus.

Cells·
Match·
Differ·
Workbook and cellFormulaLibreOfficeCells

The corpus is the four­teen work­books P33 is tested on and the eighty work­books of its fuzzer, each worked out and saved by LibreOffice. Your browser builds every work­book with the same code as P33 and com­pares each for­mula 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 func­tions, so the count can differ from the com­mand fur­ther down, which ran in node.

How the judge compares two numbers

A spread­sheet number is a double, the same float­ing point type as a number in JavaScript. Writing a double so that it reads back as the same double takes up to sev­en­teen sig­nif­i­cant digits. LibreOffice writes fif­teen 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 fif­teenth digit, and com­pares the two texts.

Two dou­bles that sit side by side, one unit in the last place apart (an ulp, the gap from a double to the next one), usu­ally round to the same fif­teen digits. They read dif­fer­ently when a round­ing bound­ary of the fif­teenth digit falls be­tween them. A func­tion that is one ulp off there­fore passes most cells and fails a few, and which ones fail de­pends on the inputs.

A logarithm rounded once

A func­tion is cor­rectly rounded when it re­turns the double near­est to the true value. The JavaScript spec­i­fi­ca­tion asks Math.log only for an ap­prox­i­ma­tion. The size of the error is left to the engine. , the engine in Chrome and node, re­turns the near­est double for most inputs and its neigh­bor for some. Math.log(3) is one of them. Pick a func­tion and a number below to see where the true value lies.

The double belowIn fifteen digits
The true value
The double aboveIn fifteen digits
·

LibreOffice takes its log­a­rithms from the system li­brary of the Mac it ran on. One cell of the corpus de­pends on the dif­fer­ence: =LOG10(LN(3)), from the fuzzer. The two values of LN(3) read the same in fif­teen digits. LOG10 of a number this close to one mag­ni­fies their rel­a­tive dif­fer­ence, so the fif­teenth digit of the result changes. LibreOffice saved the log­a­rithm of the cor­rectly rounded LN(3).

cr.mjs com­putes 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 log­a­rithm splits its input into a power of two and a factor be­tween three quar­ters 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 ex­po­nen­tial sub­tracts a whole number of ln(2) and sums the Taylor series of what is left. The result is then rounded once to the near­est double, with ties to the even one, in nearest().

One rounding instead of two

JavaScript com­putes a * b + c with two round­ings: one after the prod­uct and one after the sum. The com­piler that built LibreOffice for the Mac turned that ex­pres­sion, in the loan for­mu­las, into a fused mul­ti­ply-add, an in­struc­tion that rounds once. JavaScript has no such op­er­a­tion, so fp.mjs writes one. It splits each double into an in­te­ger and a power of two, and mul­ti­plies the in­te­gers ex­actly 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 in­ter­est rate by Newton’s method. It stops when a step is smaller than one ten mil­lionth. A dif­fer­ent round­ing in any step changes the steps after it, and so the rate it re­turns. In the loan work­book, the two ways of round­ing a * b + c give rates that first differ in the four­teenth digit.

Whole powers get the same treat­ment. fp.mjs raises the sig­nif­i­cant bits of x, read as one whole number, to the power ex­actly as a BigInt, and rounds once. A neg­a­tive power di­vides a power of two by that BigInt, with sixty more bits than the di­vi­sor has. A final bit records whether the di­vi­sion left a re­main­der. In node, Math.pow(1.05, 127) re­turns the other double, and a cell would show it. The figure above has it as an ex­am­ple, worked out by your browser.

Held against Python and LibreOffice

The true values come from code that shares noth­ing with cr.mjs or fp.mjs. oracle.py uses Python’s dec­i­mal module, at the pre­ci­sion in the first line below, for the log­a­rithms, ex­po­nen­tials and real powers. It uses exact frac­tions for the fused mul­ti­ply-add and the whole powers, and rounds each value once to the near­est 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 re­turns an­other double and how often the fif­teen 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 com­mand above. LibreOffice on Windows or Linux uses other system li­braries and other com­piler set­tings, and this note says noth­ing about the digits it gives there.

cr.mjs works with a fixed number of bits and has no proof that they suf­fice for every input. A cor­rectly rounded result needs enough bits to decide on which side of the mid­point be­tween two dou­bles the true value falls. An input whose true value lies closer to a mid­point than those bits can re­solve 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 ev­i­dence and not a guar­an­tee.

Exact arith­metic costs time: a call to cr.mjs takes mi­crosec­onds where Math.log takes nanosec­onds, as the second table shows. Cells uses the two files only where the last digit can show: the log­a­rithms, EXP, POWER and the power op­er­a­tor, ROUNDUP and ROUNDDOWN, the vari­ance and stan­dard de­vi­a­tion, and the loan and cash flow func­tions.

The source of cr.mjs and fp.mjs is P33’s, under prototypes/source/p33/ in the port­fo­lio’s repos­i­tory. The note’s copy of Cells is in web/notes/digits/cells/, writ­ten by copy.mjs, and dif­fers only where six files import the two through switch.mjs. held.mjs and ulp.mjs are the com­mands, wide.mjs com­putes the true values in the figure, and digits.test.mjs holds the tests.