Fix MOD to return the remainder with the sign of the divisor (HF-357) - #1752
Conversation
MOD returned the result of JavaScript's `%` operator, which is the truncated remainder and takes the sign of the dividend. Excel, Google Sheets and the OpenDocument specification all define MOD as the floored remainder, which takes the sign of the divisor, so the results differed whenever the two arguments had opposite signs: =MOD(-3, 12) returned -3 instead of 9, and =MOD(5, -3) returned 2 instead of -1. The remainder given by `%` is now shifted by one divisor when its sign disagrees with the divisor's, and left alone otherwise. Deriving the result from `%` rather than from one of the two textbook one-liners keeps it exact - both of those lose precision at large or small magnitudes, as the JSDoc on `flooredRemainder` documents. Same-sign arguments and =MOD(x, 0) are unaffected. Alongside the fix: MOD is dropped from the list of differences and from the deviations listed in DEV_DOCS.md, and its sign contract moves from the `divisor` parameter description into `shortDescription`, which is the part of the catalogue entry that the generated built-in functions guide renders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Lauy4E2iY7EcTmSp4KZMa
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
hyperformula-docs | 9f423a6 | Commit Preview URL Branch Preview URL |
Aug 28 2026, 12:28 PM |
CI blocker: one stale expectation in
|
|
Two things the jest run confirms that the karma one could not: Generated by Claude Code |
Performance comparison of head (9f423a6) vs base (b6b0331) |
DEV_DOCS.md prescribes that the internal team adds tests directly to the
hyperformula-tests repository; the public test/ directory holds only the
smoke tests. The MOD spec and the adr/detailedError helpers landed here
in the previous commit because this session did not yet have access to
that repository.
They now live in handsontable/hyperformula-tests#46, on a branch of this
same name, so fetch-tests.sh pairs the two and CI runs them from there.
That also stops describe('Function MOD') running twice once the private
suite is checked out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Lauy4E2iY7EcTmSp4KZMa
|
Blocker resolved, and I owe a correction on it. The tests now live in the private suite where The correction. I wrote earlier that the I have also filed the unrelated negative-zero array-path leak found while testing this as HF-358, so it is no longer only a note at the bottom of this description. Generated by Claude Code |
|
CI is green on One thing for whoever merges: these two need to land back-to-back, because
That's inherent to the split-repo setup rather than anything specific to this change, but the window is worth keeping short. Merging #46 first and #1752 immediately after is the smaller exposure, since the tests repo has no CI of its own. Generated by Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1752 +/- ##
========================================
Coverage 97.31% 97.31%
========================================
Files 195 195
Lines 15719 15725 +6
Branches 3384 3384
========================================
+ Hits 15297 15303 +6
Misses 422 422
🚀 New features to boost your workflow:
|
… when absent Measured in Excel via the Graph API (three independent sessions, LEN()-proven), which contradicts this task's acceptance criteria: =ADDRESS(2,3,1,FALSE) -> R2C3 (LEN 4, no "!") =ADDRESS(2,3,1,FALSE,) -> R2C3 (LEN 4, no "!") =ADDRESS(2,3,1,FALSE,"") -> !R2C3 (LEN 5, keeps "!") =ADDRESS(2,3,1,FALSE,<empty cell ref>) -> !R2C3 (LEN 5, keeps "!") =ADDRESS(1,1,4,TRUE,"") -> !A1 (LEN 3, keeps "!") Excel distinguishes an argument that is syntactically absent from one that is present with an empty value. The AC asked for the separator to be dropped in all three empty-ish cases "consistently with Excel"; that premise holds for only one of them. The previous revision of this branch implemented the AC literally and regressed three cases that develop already got right, so this replaces it. The remaining real defect is the empty argument slot: a trailing comma reached the implementation as "" and produced a stray "!". Rather than hand-inspecting AstNodeType.EMPTY in the plugin, this adds an `emptyAsAbsent` argument-validation option next to the existing `emptyAsDefault`, so the distinction lives in the argument metadata and every function can opt in. ADDRESS's sheetName uses it. `emptyAsAbsent` is part of the custom-function metadata surface, so it is documented in docs/guide/custom-functions.md and the CHANGELOG. Verified: 6185/6189 jest tests pass. The single failure (=MOD(-7,2) in unit/function-metadata-api.spec.ts) reproduces on clean develop with clean develop tests and is unrelated: it is a stale golden expectation left by the MOD sign fix in #1752.
Context
MODreturned the result of JavaScript's%operator, which is the truncated remainder and takes the sign of the dividend. Excel, Google Sheets and the OpenDocument specification defineMODas the floored remainder, which takes the sign of the divisor. The two agree whenever the arguments share a sign and differ by exactly one divisor when they do not, so every mixed-sign call was wrong:=MOD(-3, 12)9-39=MOD(5, -3)-12-1=MOD(7, 3)111=MOD(-7, -3)-1-1-1Same-sign arguments and
=MOD(x, 0)→#DIV/0!were already correct and are unchanged.Why
%plus a correction, and not a one-liner. Both textbook formulas are too lossy for a calculation engine, soModuloPluginshifts the remainder given by%(which is exact for IEEE 754 doubles) only when its sign disagrees with the divisor's. The rejected alternatives, with the concrete failures now pinned by tests:dividend - divisor * Math.floor(dividend / divisor)rounds twice and the multiplication scales the division's error back up —MOD(1e308, 3)returns0instead of2, and a dividend ofNumber.MAX_VALUEoverflows to-Infinity.((dividend % divisor) + divisor) % divisorloses a remainder that is negligible next to the divisor —MOD(1e-20, 3)returns0instead of1e-20— and overflows toNaNonce the intermediate sum exceedsNumber.MAX_VALUE.The reasoning is recorded in the JSDoc on
flooredRemainderso the next reader does not "simplify" it back.Docs and metadata.
MODis removed from the list of differences (row and root-cause bullet) and from the deviations listed inDEV_DOCS.md, which both asserted the old behaviour. The sign contract moves out of thedivisorparameter description and intoshortDescription, becausescript/renderBuiltinFunctionsTable.tsrenders only name, short description and syntax — parameter descriptions never reach the generated guide page, so the contract was invisible there before.How did you test your changes?
Tests live in the private suite, per
DEV_DOCS.md: handsontable/hyperformula-tests#46, on a branch of this same name, sofetch-tests.shpairs the two and this PR's CI runs them. That PR takesunit/interpreter/function-modulo.spec.tsfrom 4 cases to 38 (one assertion each) and corrects one stale golden value inunit/function-metadata-api.spec.ts— see it for the full breakdown.The old spec covered only same-sign arguments (
5,2,36,6,10.5,3), which is why this went unnoticed. The new cases add opposite signs in both positions, exact division in all four sign combinations, zero and fractional arguments, coercion, and the precision boundaries that separate the correct implementation from the plausible-but-wrong ones.Mutation-checked — the suite is not merely green, it discriminates:
dividend % divisordividend - divisor * Math.floor(dividend / divisor)((dividend % divisor) + divisor) % divisorRun locally in the exact CI arrangement (this branch, with the tests repo checked out at
test/hyperformula-tests):Other gates:
npx tsc --noEmitclean;eslint . --ext .js,.ts0 errors, and 0 new warnings insrc/(the file's 2 warnings for a missing class/method JSDoc are pre-existing and shared with every sibling plugin).npm run docs:generate-function-docsregenerates cleanly, and the resultingMODrow reads "Returns the remainder when one number is divided by another. The result has the same sign as divisor. | MOD(dividend, divisor)".Types of changes
Related issues:
Checklist:
Notes for the reviewer
Three judgement calls to confirm, none of them blocking:
MOD, and there is no config escape hatch (only a custom-function reimplementation). I followed the closest precedent instead: the empty-cellMATCH/VLOOKUPfix shipped in 3.4.0 as a plain### Fixedwhile also editing the list of differences. Ticking the box would mean creating a migration guide, and nomigration-from-3.xguide exists yet. Happy to reclassify.=MOD(-10, "1e400")now returns#NUM!where it used to return-10. That follows from the definition — for a positive divisor the floored result must lie in[0, divisor), so the only answer here is unrepresentable — and-10was exactly the truncated-remainder bug. Pinned by two tests rather than left to chance; say the word if you would rather special-case non-finite divisors.QUOTIENTis deliberately untouched. The identitya = b * QUOTIENT(a, b) + MOD(a, b)no longer holds for mixed signs, becauseQUOTIENTtruncates. Excel has exactly the same inconsistency, so changing it would break Excel parity.Separately, while testing this I found a pre-existing, unrelated bug, now filed as HF-358: negative zero leaks through the array/range path for
MOD,ROUND,INTand*alike (Interpreter.evaluateAstappliesfixNegativeZeroonly whenisExtendedNumber(val), which is false for aSimpleRangeValue). It predates this change and affects the same inputs before and after it.Note
Medium Risk
Mixed-sign
MODresults change for any workbook that depended on the old dividend-sign semantics; impact is limited to that function but can alter live spreadsheet calculations.Overview
MODno longer uses JavaScript’s%operator. It now returns the floored remainder whose sign matches the divisor, matching Excel, Google Sheets, and OpenDocument (e.g.=MOD(-3, 12)is 9, not -3). Same-sign inputs and#DIV/0!for a zero divisor are unchanged.ModuloPluginroutes non-zero divisors through a newflooredRemainderhelper that adjusts%only when the truncated remainder’s sign disagrees with the divisor’s, avoiding less stable one-liner formulas at extreme magnitudes. JSDoc on that helper records why.Docs follow the behaviour change: CHANGELOG entry,
MODremoved from the Excel/Sheets differences table and related prose inDEV_DOCS.md, and function catalogueshortDescriptionupdated so the divisor-sign rule appears in generated built-in function docs.Reviewed by Cursor Bugbot for commit 9f423a6. Bugbot is set up for automated code reviews on this repo. Configure here.