Skip to content

Fix 32-bit Int overflow in Rational add/sub/mul/div - #10

Merged
hellerve merged 1 commit into
masterfrom
claude/fix-arithmetic-overflow
Jul 19, 2026
Merged

Fix 32-bit Int overflow in Rational add/sub/mul/div#10
hellerve merged 1 commit into
masterfrom
claude/fix-arithmetic-overflow

Conversation

@carpentry-agent

Copy link
Copy Markdown

Fix 32-bit Int overflow in core Rational arithmetic

add, sub, mul, and div formed their naive cross-products before any
reduction — reduction only happened later inside new. Because Int is 32-bit,
an intermediate product exceeding 2³¹ overflowed and produced a wrong
fraction, even when the fully-reduced answer was perfectly representable.

Concretely, on master:

(add (new 1 46341) (new 1 46341)) ; d = 46341*46341 = 2147488281 > 2^31-1
; => garbage, not (Rational 2/46341)

Fix

Cancel common factors first, using the existing private gcd:

  • add / sub reduce the two denominators to their lcm before combining:
    g = gcd(da, db), common denominator da*(db/g), numerator
    na*(db/g) ± nb*(da/g). Intermediates stay as small as the reduced
    denominators allow.
  • mul cross-cancels each numerator against the opposing denominator:
    g1 = gcd(na, db), g2 = gcd(nb, da).
  • div (multiply by the reciprocal) cross-cancels g1 = gcd(na, nb),
    g2 = gcd(da, db).

Every result still passes through new, so sign normalization and final
reduction are unchanged. Outputs are byte-identical to master for all
non-overflowing inputs
; div-by-zero behaviour (±1/0) is preserved. No public
signature changes.

Tests

Added five cases to tests/rational.carp that fail on master and pass here —
one per operation, each with intermediate products that overflow 2³¹ but a small
representable reduced result (e.g. mul/div of denominators near √(2³¹) that
cross-cancel to 4/9). All 132 tests pass; carp-fmt -c and angler are clean.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

add, sub, mul, and div formed their naive cross-products (na*db, da*db,
na*nb, ...) before any reduction, which only happened later inside new.
Since Int is 32-bit, denominators or numerators whose product exceeds
2^31 overflowed and produced wrong fractions even when the fully-reduced
result was perfectly representable, e.g. (add (new 1 46341) (new 1 46341))
corrupted the tiny 2/46341.

Cancel common factors first using the existing gcd: add/sub reduce the
denominators to their lcm before combining; mul and div cross-cancel each
numerator against the opposing denominator. Results are unchanged for all
non-overflowing inputs (still normalized through new).

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out claude/fix-arithmetic-overflow at 4d9c1d5 (== PR head SHA; merge-base = current origin/master HEAD 19b8456, so no stale-branch skew). Full CI gate locally:

  • carp -x tests/rational.carp132 / 0.
  • angler, carp-fmt --check, carp -x gendocs.carp — all clean. Matches green CI (ubuntu + macos).

Findings

I verified the fix is value-preserving algebraically, not just green:

  • add / sub: g = gcd(da,db), denominator da*qb = da*db/g = lcm(da,db). Since lcm/da = qb and lcm/db = qa, the numerator na*qb ± nb*qa is exactly the naive numerator over the true LCD. Intermediates shrink to the LCM whenever the denominators share a factor (the reported 1/46341 + 1/46341 case: denom stays 46341, not 46341²), and collapse to the old da*db only when they're coprime — which is unavoidable and still correct.
  • mul / div: the two gcds cross-cancel, and the g1*g2 factor divides out of both numerator and denominator, so the value equals na*nb/(da*db) (mul) / na*db/(da*nb) (div) exactly. Traced each / — every divisor divides its dividend evenly (each g is a gcd of one of the operands), so no truncation, and each result still flows through new for final reduction + sign normalization. Outputs are therefore byte-identical to master for non-overflowing inputs.

Beyond the suite I ran a 19-case probe (zero operands to all four ops, div-by-zero sign, negatives/sign-normalization, and exact reduced results on the overflow-avoiding paths) — all pass. Notably mul/div with a zero operand don't divide by a zero gcd: gcd(0,x)=x, so the cancelling divisions stay well-defined and div(±n, 0) still yields ±1/0 with the correct sign.

Two non-blocking notes (both pre-existing and out of this PR's scope, flagging only for a future pass):

  • modulo (rational.carp:264) still forms the naive (/ (* na db) (* nb da)) quotient, so it retains the same 32-bit overflow this PR fixes for the four core ops.
  • < (rational.carp:274) compares naive cross-products (* na db) vs (* nb da), which can likewise overflow for large denominators and silently misorder.
  • The one input that still traps is div(0/1, 0/1)0/0 integer division; I confirmed master crashes identically (its new(0,0) hits the same gcd(0,0)=0 then /0), so this is not a regression — just a latent, mathematically-undefined case.

Verdict: merge

The overflow bug is a real silent-wrong-answer defect on core arithmetic; the fix is correct, exactly value-preserving, tightly scoped, and green on every gate. It's currently a draft — leaving the un-draft/merge call to you.

@hellerve
hellerve marked this pull request as ready for review July 19, 2026 20:10
@hellerve
hellerve merged commit 3e6970f into master Jul 19, 2026
2 checks passed
@hellerve
hellerve deleted the claude/fix-arithmetic-overflow branch July 19, 2026 20:10
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.

1 participant