Skip to content

Fix MOD to return the remainder with the sign of the divisor (HF-357) - #1752

Merged
sequba merged 3 commits into
developfrom
fix/hf-357-mod-sign-of-divisor
Aug 28, 2026
Merged

sequba merged 3 commits into
developfrom
fix/hf-357-mod-sign-of-divisor

Conversation

@sequba

@sequba sequba commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Context

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 define MOD as 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:

Formula Excel / Google Sheets Before After
=MOD(-3, 12) 9 -3 9
=MOD(5, -3) -1 2 -1
=MOD(7, 3) 1 1 1
=MOD(-7, -3) -1 -1 -1

Same-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, so ModuloPlugin shifts 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) returns 0 instead of 2, and a dividend of Number.MAX_VALUE overflows to -Infinity.
  • ((dividend % divisor) + divisor) % divisor loses a remainder that is negligible next to the divisor — MOD(1e-20, 3) returns 0 instead of 1e-20 — and overflows to NaN once the intermediate sum exceeds Number.MAX_VALUE.

The reasoning is recorded in the JSDoc on flooredRemainder so the next reader does not "simplify" it back.

Docs and metadata. MOD is removed from the list of differences (row and root-cause bullet) and from the deviations listed in DEV_DOCS.md, which both asserted the old behaviour. The sign contract moves out of the divisor parameter description and into shortDescription, because script/renderBuiltinFunctionsTable.ts renders 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, so fetch-tests.sh pairs the two and this PR's CI runs them. That PR takes unit/interpreter/function-modulo.spec.ts from 4 cases to 38 (one assertion each) and corrects one stale golden value in unit/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:

Implementation Failures out of 38
The fix 0
Old dividend % divisor 13
dividend - divisor * Math.floor(dividend / divisor) 5
((dividend % divisor) + divisor) % divisor 3
The fix without its exact-division guard 4

Run locally in the exact CI arrangement (this branch, with the tests repo checked out at test/hyperformula-tests):

Test Suites: 502 passed, 502 total
Tests:       3 skipped, 6214 passed, 6217 total

Other gates: npx tsc --noEmit clean; eslint . --ext .js,.ts 0 errors, and 0 new warnings in src/ (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-docs regenerates cleanly, and the resulting MOD row 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

  • Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore)
  • New feature or improvement (a non-breaking change that adds functionality)
  • Bug fix (a non-breaking change that fixes an issue)
  • Additional language file, or a change to an existing language file (translations)
  • Change to the documentation

Related issues:

  1. Fixes [Bug]: MOD returns the dividend's sign (should be the divisors) #1747
  2. HF-357
  3. Tests: handsontable/hyperformula-tests#46

Checklist:

  • I have reviewed the guidelines about Contributing to HyperFormula and I confirm that my code follows the code style of this project.
  • I have signed the Contributor License Agreement.
  • My change is compliant with the OpenDocument standard.
  • My change is compatible with Microsoft Excel.
  • My change is compatible with Google Sheets.
  • I described my changes in the CHANGELOG.md file.
  • My changes require a documentation update.
  • My changes require a migration guide.

Notes for the reviewer

Three judgement calls to confirm, none of them blocking:

  1. "Breaking change" left unticked. Returned values do change for anyone relying on mixed-sign MOD, and there is no config escape hatch (only a custom-function reimplementation). I followed the closest precedent instead: the empty-cell MATCH/VLOOKUP fix shipped in 3.4.0 as a plain ### Fixed while also editing the list of differences. Ticking the box would mean creating a migration guide, and no migration-from-3.x guide exists yet. Happy to reclassify.
  2. A knock-on change at an unrepresentable divisor. A divisor that overflows to infinity is reachable through string coercion, and =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 -10 was 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.
  3. QUOTIENT is deliberately untouched. The identity a = b * QUOTIENT(a, b) + MOD(a, b) no longer holds for mixed signs, because QUOTIENT truncates. 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, INT and * alike (Interpreter.evaluateAst applies fixNegativeZero only when isExtendedNumber(val), which is false for a SimpleRangeValue). It predates this change and affects the same inputs before and after it.


Note

Medium Risk
Mixed-sign MOD results 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
MOD no 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.

ModuloPlugin routes non-zero divisors through a new flooredRemainder helper 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, MOD removed from the Excel/Sheets differences table and related prose in DEV_DOCS.md, and function catalogue shortDescription updated 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.

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
@qunabu

qunabu commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

sequba commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

CI blocker: one stale expectation in hyperformula-tests

browser-tests (22, ubuntu-latest) failed on e4bffae (job) with a single assertion, in both browsers:

HF-300 function-metadata enrichment coverage
  representative example formulas compute their expected (Excel-cross-checked) values FAILED
	Expected $.length = 1 to equal 0.
	Unexpected $[0] = '=MOD(-7, 2) != -1' in array.

That is the only failure — 12434 other assertions passed, and the private suite's own function-modulo.spec.ts passed (its cases are all same-sign, which this PR does not change). unit-tests was cancelled by my merge push before it reported; the same private spec runs under jest, so I expect it to fail identically on the current head.

Why the test, not the fix, is wrong

The expectation encodes the very bug this PR fixes. =MOD(-7, 2) is one of the two examples on the MOD catalogue entry, and the private table expects it to evaluate to -1 — the truncated remainder. The floored definition that Excel, Google Sheets and OpenFormula all use gives 1:

MOD(n, d) = n - d * FLOOR(n / d)
MOD(-7, 2) = -7 - 2 * FLOOR(-3.5) = -7 - 2 * (-4) = 1

So -1 was cross-checked against HyperFormula's own output rather than against Excel. The label on the test makes that easy to miss, which is presumably how the original bug survived.

The fix I cannot push

In handsontable/hyperformula-tests, grep for MOD(-7, 2) in the HF-300 metadata-enrichment spec and change the expected value from -1 to 1. One line, no other MOD expectation is affected — the failure array had exactly one entry.

Pushing it to a branch named exactly fix/hf-357-mod-sign-of-divisor is enough: test/fetch-tests.sh checks out the same-named branch when it exists and otherwise creates it from develop, so this PR's CI picks the fix up with no further change here.

This session has access only to handsontable/hyperformula — attaching the tests repo was denied, and npm run test:setup-private cannot authenticate (could not read Username for 'https://github.com') — so I can't make that change or open that PR myself. Not re-running the job: the failure is deterministic and correctly attributed, so a re-run would only reproduce it.

What I deliberately did not do

Replacing or dropping the =MOD(-7, 2) example would turn this check green without fixing anything, and would leave the wrong expectation in the table to mislead the next person. It would also cost the better documentation: under this PR that example is exactly the one that demonstrates the new sign rule on the generated built-in functions page. The example stays as it is.


Generated by Claude Code

sequba commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

unit-tests has now reported on e4bffae and fails on the same single assertion, which pins the location exactly: test/hyperformula-tests/unit/function-metadata-api.spec.ts:1512 — expect(offenders).toEqual([]), where offenders is built two lines above from {formula, expected} pairs. The expected for =MOD(-7, 2) is -1 and needs to become 1.

Two things the jest run confirms that the karma one could not: Tests: 1 failed, 3 skipped, 6217 passed and Test Suites: 1 failed, 502 passed — so this is the only expectation in the whole private suite that this PR disturbs, and the new test/unit/interpreter/function-modulo.spec.ts passes in CI as well as locally.


Generated by Claude Code

@github-actions

github-actions Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Performance comparison of head (9f423a6) vs base (b6b0331)

                                     testName |    base |    head |  change
---------------------------------------------------------------------------
                                      Sheet A |  510.09 |  508.55 |  -0.30%
                                      Sheet B |  170.26 |  165.68 |  -2.69%
                                      Sheet T |  146.21 |  143.77 |  -1.67%
                                Column ranges |  544.65 |  538.44 |  -1.14%
                                Sorted lookup | 16633.6 | 15900.9 |  -4.40%
Sheet A:  change value, add/remove row/column |   19.19 |   16.62 | -13.39%
 Sheet B: change value, add/remove row/column |  164.87 |  152.67 |  -7.40%
                   Column ranges - add column |  175.46 |  170.89 |  -2.60%
                Column ranges - without batch |  528.96 |  525.32 |  -0.69%
                        Column ranges - batch |  134.26 |  134.57 |  +0.23%

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
@qunabu

qunabu commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

sequba commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Blocker resolved, and I owe a correction on it.

The tests now live in the private suite where DEV_DOCS.md says they belong: handsontable/hyperformula-tests#46, on a branch of this same name, so fetch-tests.sh pairs the two. The duplicate copy has been removed from the public test/ tree here, so describe('Function MOD') no longer runs twice. Verified locally in the exact CI arrangement — 502 passed, 502 total suites, 6214 passed, 3 skipped — where the same combination previously reported 1 failed.

The correction. I wrote earlier that the =MOD(-7, 2) → -1 expectation had been "cross-checked against HyperFormula's own output rather than against Excel", and that this was "presumably how the original bug survived". That was unfair and wrong. Having now read the file, the entry was a deliberately recorded divergence: the comment above the block called out two entries that "diverge from Excel by design", and the inline note on that very line read // HF dividend-sign; Excel = 1. Whoever wrote it knew Excel's answer and recorded HyperFormula's on purpose. It was an accurate description of the behaviour at the time, not an oversight — this PR is what makes it stale. The companion PR updates the entry to 1 and leaves INT as the only remaining documented divergence.

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

@sequba
sequba marked this pull request as ready for review August 28, 2026 12:36

sequba commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

CI is green on 9f423a6 — unit-tests and browser-tests both pass, along with the full build matrix, build-docs, CodeQL, and both codecov gates (ModuloPlugin.ts at 100%, every modified line covered). I've marked the companion handsontable/hyperformula-tests#46 ready for review too.

One thing for whoever merges: these two need to land back-to-back, because fetch-tests.sh pairs the repos by branch name and falls back to develop. Either PR merged alone leaves develop red:

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

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.31%. Comparing base (b6b0331) to head (9f423a6).

Additional details and impacted files

Impacted file tree graph

@@           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           
Files with missing lines Coverage Δ
...nctionMetadata/categories/math-and-trigonometry.ts 100.00% <ø> (ø)
src/interpreter/plugin/ModuloPlugin.ts 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sequba
sequba merged commit 3195e00 into develop Aug 28, 2026
36 checks passed
@sequba
sequba deleted the fix/hf-357-mod-sign-of-divisor branch August 28, 2026 12:45
marcin-kordas-hoc added a commit that referenced this pull request Aug 31, 2026
… 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.
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.

3 participants