Make Rational's ordering exact and overflow-free - #11
Conversation
Rational.< multiplied each numerator by the opposing denominator and compared the two products, so it started returning wrong answers as soon as a cross product exceeded 2^31. The boundary was crisp at 46341 = ceil(sqrt(2^31)): (< 46341/1 1/46341) was true, and because < is the < implementation it dragged >, <=, >=, min, max, clamp and Array.sorted down with it. The relation was not even antisymmetric, so sorting a mix of tiny and huge fractions scrambled the array rather than merely misplacing one element. Comparison now walks the two continued-fraction expansions: take the floored integer parts, and if they agree recurse on the reciprocals of the remainders with the result negated. That is O(log n), uses only division and subtraction, and cannot overflow for any representable input, because the answer is a bool and is therefore always representable. Floor division is used throughout so negatives, Int.MIN and zero fall out of the same code path without a sign prologue. modulo had the same defect through (/ (* na db) (* nb da)), and routing it through the reduced div path is not enough: 100000/1 mod 1/100000 has the quotient 10^10, which does not fit in an Int even in lowest terms while the remainder 0/1 obviously does. It now computes the remainder without ever materializing the quotient, using a mod b = b * ((P rem Q) / Q) with P/Q the fully reduced a/b, and evaluating P rem Q by doubling so no intermediate leaves Int range. add/sub/mul/div can still overflow when the result in lowest terms is genuinely not representable; that needs an API decision, so their docstrings now carry the same caveat pow already had. Verified against Python's arbitrary-precision fractions on 300 random large pairs (master gets 66 wrong, this gets 0) and on 40000 random large pairs for antisymmetry and crash-freedom.
There was a problem hiding this comment.
Build & Tests
carp -x tests/rational.carp on 9f16ae3: 165 assertions, 0 failures (132 → 165, as claimed). CI green on ubuntu + macOS with head_sha 9f16ae35. Merge-base is the current origin/master tip (3e6970f), so the branch is a clean fast-forward. No CHANGELOG in this repo and none added, which is right.
The 14-fail-on-master claim is exact. I put origin/master:rational.carp under this branch's tests: 151 passed, 14 failed, and the 14 are precisely the ordering and modulo overflow cases (46341/1 is not below 1/46341, sorted orders a mix of tiny and huge fractions ascending, min/max/clamp at overflow magnitude, both modulo-overflow tests). The new tests are load-bearing, not decoration.
The comparison fix is correct, and the bug it fixes is as bad as advertised. I ran my own oracle sweep rather than reusing the PR's: 1000 pairs against Python's Fraction, built from adversarial specials (Int.MIN, Int.MAX, 1/Int.MAX, Int.MIN/Int.MAX, the 46339–46342 boundary, near-ties like 46341/46342) crossed with each other, plus 600 random pairs across four magnitude regimes.
branch: 0 / 1000 wrong
master: 168 / 1000 wrong
Same inputs, same harness — so the harness is demonstrably capable of reporting mismatches, and the branch simply has none. No crashes, including at Int.MIN.
I also checked the parts of cmp that the tests do not reach:
- Termination and overflow-freedom are structural. The recursion
(cmp da ra db rb)passes the previous remainders as the new denominators, andra < da,rb < dbwith both strictly positive on the recursive path, so it strictly decreases — Euclid-style, ~46 levels worst case forInt. Only/,mod,dec,+and sign comparisons appear;mod-floor's(+ r d)cannot overflow becauserlies in(-d, 0)there. <really is total at a zero denominator.(reciprocal zero)gives1/0, andinf < 1/1is false,1/1 < infis true,inf < infis false. No SIGFPE.- Denormalized values are not a regression.
Rational.set-denominatoris public (onlyinitisprivate), so a negative denominator is reachable. Fuzzing 300 such pairs: neither binary crashes or hangs, they differ on 56, and against the oracle the branch is more correct than master (95/115 vs 66/115, zero denominators excluded). Strictly better, so no concern. - The disclosed residuals are accurate.
1073741825/6 mod 715827883/4overflows on both (branch-2147483647/4, master-536870912/3, true1/12).1/65536 mod 65536/1SIGFPEs on both — confirmed pre-existing, as stated. Modulo by a zero rational also SIGFPEs on both; likewise pre-existing.
modulo matches the oracle on 900 further cases (all sign combinations, the two overflow examples from the description, and 700 random pairs), restricted to inputs whose exact answer and reduced divisor product fit in an Int.
Findings
1. modulo regresses at an Int.MIN numerator — master gets these right and this branch does not (rational.carp:281-296). mul-mod requires 0 <= a, but it is fed (Int.abs pn), and Int.abs Int.MIN is Int.MIN — still negative. (Int.mod Int.MIN m) is then negative, add-mod's 0 <= a,b < m precondition breaks, and the (if (Int.< pn 0) (Int.neg r) r) sign correction negates an already-wrong value:
| input | true value | master | this branch |
|---|---|---|---|
Int.MIN/1 mod 3/1 |
-2/1 |
-2/1 |
2/1 |
Int.MIN/3 mod 5/1 |
-8/3 |
-8/3 |
8/3 |
Int.MIN/1 mod -3/1 |
-2/1 |
-2/1 |
2/1 |
Int.MIN/1 mod 2147483647/1 |
-1/1 |
-1/1 |
-2147483648/1 |
Four cases where master is correct and this branch is not, so it is a regression rather than an untouched corner. It is a sign flip, which is the worst shape for a silent wrong answer — modulo is documented as carrying the sign of a, and here it returns the opposite.
The trigger is narrow but not exotic: the reduced numerator of a must be exactly Int.MIN (so na = Int.MIN and gcd(na, nb) = 1, i.e. an odd nb) and the true remainder must be non-zero. -2147483647/1 mod 3/1 is correct, so the boundary is exactly Int.MIN — the same input class the PR description names as the reason cmp avoids Int.abs. That reasoning was applied to the comparison path, which is clean, but modulo still calls Int.abs twice.
The existing suite passes with the bug present, so this is also a coverage gap: tests/rational.carp contains no Int.MIN case at all (grepped).
Replacing the Int.abs pn magnitude step with a floored reduction fixes it — I prototyped this to confirm the diagnosis rather than to prescribe the patch:
m (Int.abs q)
r (mul-mod (mod-floor pn m) (/ db g2) m)]
(mul b &(new (if (and (Int.< pn 0) (Int./= r 0)) (Int.- r m) r) q))))mod-floor already returns a non-negative representative for a negative pn without ever taking a magnitude, so mul-mod's precondition holds at Int.MIN. With it: all four rows above match the oracle, the suite stays 165/0, and all 900 of my modulo oracle cases still match. It also fixes two cases that are wrong on master and on this branch today — Int.MIN/1 mod 7/2 (-2/1) and Int.MIN/7 mod 3/7 (-2/7) — so it is a strict improvement over both.
The mirror case, Int.MIN in the divisor, SIGFPEs identically on master and this branch. That one is pre-existing and I would leave it out of scope here.
Verdict: revise
The headline change is excellent and I could not break it: the continued-fraction comparison is exact on 1000 adversarial pairs where master gets 168 wrong, it is overflow-free and terminating for structural reasons rather than by testing, it is total at a zero denominator, and it is even more correct than master on denormalized values. The modulo rewrite is right for every input I could construct except one — an Int.MIN numerator, where Int.abs overflows, mul-mod's precondition breaks and the result comes back with the wrong sign on inputs master handles correctly. That is a narrow but real regression in a published numeric library, it is invisible to the current suite, and the two-line floored-reduction fix above resolves it while keeping every other check green. Fix that and add an Int.MIN case to the modulo tests, and I would take this.
The magnitude step `(Int.abs pn)` breaks at a reduced numerator of exactly Int.MIN: two's complement has no positive twin for -2^31, so `Int.abs` is the identity there and `mul-mod` is handed a negative `a`, violating its `0 <= a` precondition. `Int.mod` then returns a negative representative, `add-mod`'s `0 <= a,b < m` invariant fails, and the trailing sign correction negates an already-wrong value — a silent sign flip in a function documented to carry the sign of the dividend. Taking the floored representative instead removes the magnitude step entirely. `mod-floor` lands in [0, m) for every input including Int.MIN, because it only ever adds `m` to a remainder already in (-m, 0), so `mul-mod`'s precondition holds by construction rather than by assuming the dividend can be negated. The truncated remainder is then recovered at the end: `r - m` when the dividend is negative and `r` is non-zero. Four rows that master gets right and the previous commit did not, now correct: Int.MIN/1 mod 3/1, Int.MIN/3 mod 5/1, Int.MIN/1 mod -3/1 and Int.MIN/1 mod Int.MAX/1. Two more that were wrong on master as well are also fixed: Int.MIN/1 mod 7/2 and Int.MIN/7 mod 3/7. Against Python's fractions.Fraction on 7456 pairs whose exact answer and reduced divisor product are representable: 0 wrong, against 329 on the previous commit and 1212 on master. The 383 of those with a reduced numerator of Int.MIN go from 329 wrong to 0. Comparison is untouched. The mirror case, an Int.MIN divisor numerator, still SIGFPEs exactly as it does on master, and is left alone.
|
Fixed the Reproduced firstAll four rows from the review reproduce exactly on
The two cases the reviewer said were wrong on both check out too, and the fix corrects them: Why the floored reduction works
So the result is in Same shape as the prototype in the review — I derived it independently and then checked it matched. Oracle
Split by trigger class:
The harness demonstrably reports mismatches — it finds 329 and 1212 on the other two revisions from the same case list. The set is 6000 adversarial specials ( Nothing else moved
Left out of scope
TestsEight Seven of the eight fail on
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet. |
There was a problem hiding this comment.
Build & Tests
carp -x tests/rational.carp on 44d6265: 173 assertions, 0 failures (165 → 173, as claimed). CI green on ubuntu + macOS at this head. Merge-base is still the current origin/master tip (3e6970f), so the branch is a clean fast-forward. No CHANGELOG in this repo and none added, which is right.
git diff 9f16ae3..44d6265 -- rational.carp touches three lines inside modulo and nothing else — the continued-fraction comparison really is untouched, as stated.
Prior feedback
The Int.MIN regression is fixed. I rebuilt my oracle harness and re-ran all six rows from the last review against binaries built from master, 9f16ae3 and 44d6265:
| input | true | master | 9f16ae3 |
44d6265 |
|---|---|---|---|---|
Int.MIN/1 mod 3/1 |
-2/1 |
-2/1 |
2/1 |
-2/1 |
Int.MIN/3 mod 5/1 |
-8/3 |
-8/3 |
8/3 |
-8/3 |
Int.MIN/1 mod -3/1 |
-2/1 |
-2/1 |
2/1 |
-2/1 |
Int.MIN/1 mod Int.MAX/1 |
-1/1 |
-1/1 |
-2147483648/1 |
-1/1 |
Int.MIN/1 mod 7/2 |
-2/1 |
-2147483648/1 |
2/1 |
-2/1 |
Int.MIN/7 mod 3/7 |
-2/7 |
-1840700270/7 |
2/7 |
-2/7 |
The reasoning holds structurally too, not just empirically: mod-floor pn m lands in [0, m) for every pn when m > 0 — Int.mod only traps on a zero divisor or the INT_MIN / -1 pair, and neither is reachable with a positive m — so mul-mod's 0 <= a < m precondition is now satisfied by construction rather than by assuming pn can be negated. The r /= 0 guard is load-bearing exactly as described. Nothing to add.
Leaving the Int.MIN-in-the-divisor mirror out of scope is the right call, and the correction about 5/1 mod Int.MIN/1 not crashing is accurate.
Findings
1. modulo is wrong on an ordinary input class where master is right — and it is the class the new docstring specifically promises (rational.carp:290-301).
Whenever the reduced product q = (nb/g1) * (da/g2) overflows Int, m = (Int.abs q) is garbage and every modular step below it is garbage. The boundary is the same 46341 = ceil(sqrt(2^31)) this PR uses for the comparison story, and master handles the whole class correctly:
a mod b |
true | master | 44d6265 |
|---|---|---|---|
1/46340 mod 46340/1 |
1/46340 |
1/46340 |
1/46340 |
1/46341 mod 46341/1 |
1/46341 |
1/46341 |
-46341/2147479015 |
1/50000 mod 50000/1 |
1/50000 |
1/50000 |
-3125/112185456 |
1/2 mod 2147483647/1 |
1/2 |
1/2 |
-2147483647/2 |
3/70000 mod 70001/1 |
3/70000 |
3/70000 |
210003/605102704 |
(Rational.modulo &(Rational.new 1 2) &(Rational.new 2147483647 1)) returning -2147483647/2 instead of 1/2 is the shape of the whole class: a is smaller than b, the true quotient is 0, and the answer is literally a.
That last point is what makes this a docstring violation rather than a corner: the new doc says "The quotient a/b is never materialized, so a remainder that is representable is returned exactly even when the quotient itself would overflow." Here the quotient does not overflow — it is zero — and the remainder is a itself. It is the easiest case the promise covers, and it comes back wrong.
The comment disclosing this understates it. It reports 724/800 wrong on the branch against 237/800 on master, over an adversarially-built set — a 3× gap. Measured on inputs with no adversarial construction, it is not 3×, it is total. Two sweeps, each against Python Fraction, counting only pairs whose true remainder is representable:
ordinary: small numerator / 5-digit denominator mod 5-digit numerator (400 pairs)
q fits in Int n=221 master: 0 wrong branch: 0 wrong
q OVERFLOWS n=179 master: 0 wrong branch: 179 wrong
|a| < |b|, so the answer is exactly `a` (400 pairs)
q fits in Int n=206 master: 0 wrong branch: 0 wrong
q OVERFLOWS n=194 master: 0 wrong branch: 194 wrong
Master is not "also bad here" — it is perfect here, because when da*nb wraps to a large magnitude the truncated quotient still comes out 0 and a - 0*b is a.
On an unbiased sweep the rewrite is a net regression. 900 random pairs, each component drawn from one of four magnitude regimes, symmetric between a and b (so neither overflow direction is favoured), 744 with a representable true remainder:
master 189 wrong (25%)
branch 252 wrong (34%)
fixed by the branch (master wrong, branch right): 136
broken by the branch (master right, branch wrong): 199
The two implementations fail on different products — master when na*db overflows, this branch when nb*da overflows — and the branch trades a class it degrades gracefully on for one it fails completely on.
This is cheaply fixable, and this PR already ships the tool. All 199 newly-broken cases have |a| < |b|, and the new exact < cannot overflow, so a shortcut is safe. I prototyped it to confirm the diagnosis rather than to prescribe the patch — it needs cmp moved above modulo, since Carp has no forward references, and it declines the shortcut at Int.MIN where Int.abs is the identity:
(defn modulo [a b]
(if (and (Int./= @(numerator a) Int.MIN)
(Int./= @(numerator b) Int.MIN)
(Int.< (cmp (Int.abs @(numerator a)) @(denominator a)
(Int.abs @(numerator b)) @(denominator b)) 0))
@a
(let [na @(numerator a)
...With it, on the same 744 unbiased pairs:
master 189 wrong (25%)
branch 252 wrong (34%)
guarded 3 wrong ( 0%)
the existing suite stays 173/0, and all six Int.MIN rows above stay correct. The 3 residuals are the genuine hard case — q overflows and the quotient is non-zero, e.g. 2059044299/195 mod 1285988199/265 — where master is wrong too. Those are worth a docstring caveat rather than a fix.
I should have caught this last round. My previous modulo oracle was explicitly restricted to inputs whose reduced divisor product fits in an Int, which excluded exactly this class by construction. The regression is inherited from 9f16ae3 and not introduced by this commit — but master is the merge target, so it is this PR's to resolve.
Verdict: revise
The Int.MIN fix asked for last round is correct, verified against an independent oracle on every row, and sound for structural reasons. The comparison rewrite remains the strongest part of this PR and I have not been able to fault it in two rounds. But modulo still loses to master overall: whenever (nb/g1) * (da/g2) overflows it returns a wrong answer, including for 1/2 mod Int.MAX/1 where the correct result is just 1/2, and on an unbiased sweep it breaks 199 cases master gets right against 136 it fixes. Add the |a| < |b| shortcut above — every newly-broken case falls in it, the suite stays green, and the result is more accurate than either master or this branch — and this is ready.
…divisor When |a| < |b| the truncated quotient is zero and the remainder is a itself, so no modular arithmetic is needed. The general path first forms the reduced divisor product (nb/g1)*(da/g2); whenever that leaves Int range the modulus is garbage and so is everything below it, which made the branch wrong on the whole class -- including 1/2 mod Int.MAX/1, where the answer is just 1/2. master got that class right by accident: when nb*da wraps, its truncated quotient still comes out 0 and a - 0*b is a. The shortcut uses the new exact comparison, which cannot overflow. Rather than comparing |a| against |b|, it brackets a between -|b| and |b|, so a dividend numerator of Int.MIN needs no Int.abs and is covered too. It declines when the divisor numerator is Int.MIN or a denominator is not positive, leaving those to the general path unchanged. cmp moves above modulo because Carp has no forward references; its body is byte-identical. Unbiased differential sweep against Python's fractions.Fraction, drawing a and b from the same distribution so neither overflow direction is favoured, over 8390 pairs with a representable true remainder: master 1946 wrong, 44d6265 2701 wrong, this 32 wrong and nothing wrong here that either of the other two gets right. The 32 residuals are the hard case the docstring now discloses: the divisor product overflows and the quotient is non-zero, where master is wrong too.
|
Added the Reproduced first, on
|
a mod b |
true | master | 44d6265 |
9616ab0 |
|---|---|---|---|---|
1/46340 mod 46340/1 |
1/46340 |
1/46340 |
1/46340 |
1/46340 |
1/46341 mod 46341/1 |
1/46341 |
1/46341 |
-46341/2147479015 |
1/46341 |
1/50000 mod 50000/1 |
1/50000 |
1/50000 |
-3125/112185456 |
1/50000 |
1/2 mod Int.MAX/1 |
1/2 |
1/2 |
-2147483647/2 |
1/2 |
3/70000 mod 70001/1 |
3/70000 |
3/70000 |
210003/605102704 |
3/70000 |
My own oracle
I built the sweep from scratch rather than reusing the review's: python3 + fractions.Fraction, whole-Rational comparison, the same case list run against binaries built from master, 44d6265 and this push. a and b are drawn independently from the same distribution — a magnitude regime picked per component from tiny / small / medium / Int.MAX-scale / the 46330–46350 boundary, sign on the numerator — so neither overflow direction is favoured. Only pairs whose true remainder is representable are counted.
900 pairs drawn, 749 kept:
master 153 wrong (20%)
44d6265 241 wrong (32%)
9616ab0 3 wrong ( 0%)
fixed by 44d6265 (master wrong, branch right): 109
broken by 44d6265 (master right, branch wrong): 197 <- all 197 have |a| < |b|
So the regression reproduces on an independently built sweep, and the shape is the one the review described: every newly-broken case falls inside the shortcut.
Five seeds, 8390 kept cases:
master 1946 wrong (23%)
44d6265 2701 wrong (32%)
9616ab0 32 wrong (0.4%)
9616ab0 wrong where master is right: 0
9616ab0 wrong where 44d6265 is right: 0
Split by whether the reduced divisor product q = (nb/g1)*(da/g2) leaves Int range, on the 749-case sweep:
| cases | master | 44d6265 |
9616ab0 |
|
|---|---|---|---|---|
q fits |
508 | 109 | 0 | 0 |
q overflows |
241 | 44 | 241 | 3 |
The harness demonstrably reports mismatches — it finds 153 and 241 on the other two revisions from the same case list.
The patch
The reviewer's prototype, with two changes.
It brackets a rather than taking its magnitude. |a| < |b| is the same as -|b| < a < |b|, and written that way the dividend never needs Int.abs, so an Int.MIN dividend numerator is covered rather than declined:
(defn abs-below? [a b]
(let [na @(numerator a)
da @(denominator a)
nb @(numerator b)
db @(denominator b)]
(and (Int./= nb Int.MIN)
(Int.< 0 da)
(Int.< 0 db)
(let [mag (Int.abs nb)]
(and (Int.< (cmp na da mag db) 0)
(Int.< (cmp (Int.neg mag) db na da) 0))))))Two extra rows that the numerator-abs form still gets wrong:
a mod b |
true | master | 44d6265 |
numerator-abs | 9616ab0 |
|---|---|---|---|---|---|
Int.MIN/3 mod Int.MAX/1 |
-2147483648/3 |
-1/1 |
-1/1 |
-1/1 |
-2147483648/3 |
Int.MIN/46341 mod 46341/1 |
-2147483648/46341 |
-4633/46341 |
214697853/2147479015 |
214697853/2147479015 |
-2147483648/46341 |
It also declines on a non-positive denominator. set-denominator is public, so da <= 0 is reachable, and with a negative denominator cmp on magnitudes measures the wrong thing. On 1893 denormalized pairs (at least one negative denominator, drawn the same way):
master 546 wrong
44d6265 1467 wrong
9616ab0 1467 wrong <- bit-identical to 44d6265, 0 outputs differ
numerator-abs only, no denominator guard: 1742 wrong
Without the guard the shortcut fires on denormalized input and hands back the unnormalized @a — e.g. 1612985175/-46346 mod -46345/787 returns 1612985175/-46346. With it, this push is byte-identical to 44d6265 on every denormalized case, so modulo on those inputs is exactly as it was; that 44d6265 is worse than master there is inherited from 9f16ae3 and I have not touched it.
Int.abs is x > 0 ? x : -x, so it is deferred behind its own nb /= Int.MIN guard rather than bound in the outer let — and short-circuits, so -INT_MIN is never evaluated.
cmp moves above modulo since Carp has no forward references. git diff confirms the body is byte-identical; nothing else in the comparison rewrite is touched.
Adversarial specials
The unbiased sweep never draws Int.MIN, so separately: the full cross product of {Int.MIN, Int.MIN+1, ±Int.MAX, ±46340..46342, ±65536, ±7, ±3, ±1} × {1,2,3,5,7,46341,65536,715827883,Int.MAX} against itself, 16360 pairs with a representable remainder, excluding the two shapes that SIGFPE identically on all three revisions (Int.MIN divisor numerator, divisor product wrapping to exactly zero):
master 4179 wrong
44d6265 3824 wrong
9616ab0 56 wrong 0 wrong where master or 44d6265 is right
Docstring
Rewritten so the promise matches what ships. It now states the shortcut, and states the residual as a condition rather than an unconditional guarantee: inexact when a is at least as large as b in magnitude and the cancelled numerator(b) * denominator(a) leaves Int range, with 2059044299/195 mod 1285988199/265 named as the example. That is exactly the 3-residual class above — q overflows and the quotient is non-zero — and master is wrong on all three of them too.
Tests
Suite goes 173 → 181, 0 failures. Both exhaustive small-pair sweeps still report 0 (lt-mismatches, modulo-mismatches).
Eight new modulo assertions: the flagship 1/2 mod Int.MAX/1, its negative twin, both sides of the 46341 boundary, a negative divisor at overflow magnitude, 3/70000 mod 70001/1, and the two Int.MIN-dividend rows above. Seven fail on 44d6265 — verified by running the new tests against the previous rational.carp: 174 passed, 7 failed. The eighth is 1/46340 mod 46340/1, the paired control just below the boundary, which passes on both by design.
All six Int.MIN rows from the previous round still match the oracle.
carp-fmt -c and angler are clean on both changed files.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.
There was a problem hiding this comment.
Build & Tests
carp -x tests/rational.carp on 9616ab0: 181 assertions, 0 failures (173 → 181, as claimed). CI green on ubuntu + macOS, and the run really is against this head (9616ab0, 12:31Z). Merge-base is the current origin/master tip (3e6970f), so this is still a clean fast-forward. No CHANGELOG in this repo and none added, which is right.
I rebuilt the oracle harness from scratch: three binaries from 3e6970f, 44d6265 and 9616ab0, each reading fixtures at runtime, all compared against Python Fraction with truncated division.
Prior feedback
The overflow regression is fixed, and abs-below? is a better shortcut than the one I sketched. On an unbiased sweep of 900 random pairs across four magnitude regimes, 673 with a representable true remainder:
master 195 wrong (29%)
44d6265 351 wrong (52%)
9616ab0 8 wrong ( 1%) fixed 187, broke 0
9616ab0's wrong set is a strict subset of master's — 195 − 187 = 8. On the two targeted classes from last round it is now perfect where 44d6265 was not:
ordinary (small numerator / 5-digit denominator mod 5-digit numerator) master 0 44d6265 119 9616ab0 0
|a| < |b|, so the answer is exactly `a` master 0 44d6265 94 9616ab0 0
And on an exhaustive sweep of every na ∈ [-8,8], da ∈ [1,8], nb ∈ [-8,8]\{0}, db ∈ [1,8] — 17408 pairs — 9616ab0 is wrong 0 times with 0 regressions against master.
Bracketing a between ±|b| instead of comparing magnitudes is strictly stronger than the Int.abs-on-both-numerators form I prototyped, because it never needs |na|. Your two extra rows reproduce, and master is wrong on both:
a mod b |
true | master | 9616ab0 |
|---|---|---|---|
Int.MIN/3 mod Int.MAX/1 |
-2147483648/3 |
-1/1 |
-2147483648/3 |
Int.MIN/46341 mod 46341/1 |
-2147483648/46341 |
-4633/46341 |
-2147483648/46341 |
The docstring's claims check out too: 100000/1 mod 1/100000 gives 0/1 (master gives 268435456/3125), and 2059044299/195 mod 1285988199/265 is genuinely in the residual class.
Findings
1. modulo is a sign flip on an ordinary input class where master is exact: a divisor whose reduced numerator is Int.MIN (rational.carp:288-300).
abs-below? declines whenever nb = Int.MIN, so those inputs fall through to the general path, where m = (Int.abs q) is negative and every modular step below it is garbage. The result comes back with the wrong sign, and usually the wrong magnitude as well:
a mod b |
true | master | 9616ab0 |
|---|---|---|---|
5/1 mod Int.MIN/1 |
5/1 |
5/1 |
-5/1 |
7/3 mod Int.MIN/1 |
7/3 |
7/3 |
-7/1 |
1/46341 mod Int.MIN/1 |
1/46341 |
1/46341 |
-1/1 |
Int.MAX/1 mod Int.MIN/1 |
2147483647/1 |
2147483647/1 |
-2147483647/1 |
-3/7 mod Int.MIN/1 |
-3/7 |
-3/7 |
3/1 |
Every one of these has |a| < |b|, so the true quotient is 0 and the answer is literally a — the same shape as the 1/2 mod Int.MAX/1 case from last round, in the other operand.
This is the exclusion in your own sweep, and it is my fault it is there. The PR comment excludes "the two shapes that SIGFPE identically on all three revisions (Int.MIN divisor numerator, divisor product wrapping to exactly zero)". The second is correct. The first is not: only the sub-case where the product wraps to exactly zero divides by zero. That happens when da/g2 is even; when it is odd, q is merely negative, m is negative with it, and the computation runs on to a wrong answer instead of trapping. 1/2 mod Int.MIN/1 does SIGFPE on all three revisions, but 1/1 mod Int.MIN/1 returns -1/1 where master returns 1/1. I wrote "the mirror case SIGFPEs identically on master and this branch" last round, which is what seeded the exclusion; it is true only of that even-denominator subset.
Re-running your adversarial cross product ({Int.MIN, Int.MIN+1, ±Int.MAX, ±46340..46342, ±65536, ±7, ±3, ±1} × {1,2,3,5,7,46341,65536,715827883,Int.MAX} against itself, 26244 pairs), excluding only the shapes that genuinely SIGFPE and remainders that are not representable — 22856 pairs:
master 5315 wrong
44d6265 5236 wrong
9616ab0 718 wrong
head wrong where master is right: 586
...reduced divisor numerator == Int.MIN: 586 of 586
...any other shape: 0
So the class is both real and exactly one class: outside a reduced Int.MIN divisor numerator, this push does not lose to master anywhere in 22856 adversarial pairs.
The fix is small, and it is the same trick abs-below? already uses, applied to the other operand. |b| is not representable at Int.MIN, but -a is, so bracket by negating a instead — |a| < |b| iff b < a and b < -a when b < 0. I prototyped it to confirm the diagnosis, not to prescribe the patch:
(and (Int.< 0 da)
(Int.< 0 db)
(if (Int./= nb Int.MIN)
(let [mag (Int.abs nb)]
(and (Int.< (cmp na da mag db) 0)
(Int.< (cmp (Int.neg mag) db na da) 0)))
; |b| is not representable, so bracket by negating `a` instead
(if (Int.= na Int.MIN)
(Int.< db da)
(and (Int.< (cmp nb db na da) 0)
(Int.< (cmp nb db (Int.neg na) da) 0)))))With it, on the same 22856 pairs:
9616ab0 718 wrong
prototype 132 wrong 0 wrong where master, 44d6265 or 9616ab0 is right
The residual 132 are a strict subset of master's wrong set — the genuine hard case the docstring already names. The suite stays 181/0.
Verdict: revise
The overflow fix is right, and I verified it three ways — an unbiased sweep where the wrong set is a strict subset of master's, both targeted classes at zero, and 17408 exhaustive small pairs with no regression. abs-below? is a better predicate than the one I proposed, the docstring now matches what ships, and the comparison rewrite has survived three rounds without a scratch. What is left is the mirror of the bug you already fixed once: a divisor whose reduced numerator is Int.MIN returns a sign-flipped answer on 586 pairs where master is exact, and it is in the PR only because I told you last round that class crashed. Bracket by negating a when nb = Int.MIN, add a x mod Int.MIN/1 row to the tests, and I have nothing else.
abs-below? took |numerator(b)| to bracket the dividend, so it declined whenever that numerator was Int.MIN, whose magnitude is not representable. Those inputs fell through to the general path, where m = (Int.abs q) is negative and every modular step below it is garbage: the result came back sign-flipped, and usually with the wrong magnitude too. -|n| is representable for every Int, including Int.MIN, and |a| < |b| is the same as -|b| < -|a|, so comparing the negated magnitudes needs no special case in either operand and drops the Int.MIN guard entirely. The docstring's residual condition now says the *magnitude* of the cancelled product must leave Int range: a product of exactly Int.MIN is inside Int range but still has no representable absolute value.
|
Fixed the Reproduced first, on
|
a mod b |
true | master | 9616ab0 |
60c5c97 |
|---|---|---|---|---|
5/1 mod Int.MIN/1 |
5/1 |
5/1 |
-5/1 |
5/1 |
7/3 mod Int.MIN/1 |
7/3 |
7/3 |
-7/1 |
7/3 |
1/46341 mod Int.MIN/1 |
1/46341 |
1/46341 |
-1/1 |
1/46341 |
Int.MAX/1 mod Int.MIN/1 |
2147483647/1 |
2147483647/1 |
-2147483647/1 |
2147483647/1 |
-3/7 mod Int.MIN/1 |
-3/7 |
-3/7 |
3/1 |
-3/7 |
Int.MIN/1 mod 3/1 still gives -2/1, so round one's fix is intact.
The patch
Different shape from the prototype, and simpler than what it replaces.
The prototype brackets a by ±|b| normally and switches to negating a when nb = Int.MIN — two branches, plus a third for na = Int.MIN inside the second. But -|n| is representable for every Int, including Int.MIN, where |n| is not. And |a| < |b| is just -|b| < -|a|. So negating both magnitudes needs no case split in either operand, and the nb /= Int.MIN guard disappears rather than growing a sibling:
(defn neg-abs [n] (if (Int.< 0 n) (Int.neg n) n))
(defn abs-below? [a b]
(let [da @(denominator a)
db @(denominator b)]
(and (Int.< 0 da)
(Int.< 0 db)
(Int.<
(cmp (neg-abs @(numerator b)) db (neg-abs @(numerator a)) da)
0))))Net −1 line. The non-positive denominator guards stay, for the reason from last round: set-denominator is public, and with a negative denominator (neg-abs n)/d does not denote -|a|.
My own oracle
Built from scratch, not reused. python3 + fractions.Fraction; the harness prints the constructed operands alongside the result, so new's own normalization is never part of the oracle's assumption. Three binaries — 3e6970f, 9616ab0, 60c5c97 — over identical fixtures.
Re-derived the exclusion list rather than reusing the PR's, exactly as you asked. The general path divides by zero iff the cancelled q wraps to 0 (or both numerators are 0); I modelled that in Python and validated the model by observing that all three binaries run every "safe" row to completion — no crash, so no false negatives.
Your adversarial cross product, {Int.MIN, Int.MIN+1, ±Int.MAX, 0, ±1, ±3, ±7, ±46340..46342, ±65536} × {1,2,3,5,7,46341,65536,715827883,Int.MAX} against itself, deduplicated after new and restricted to representable true remainders — 17251 pairs:
master 4283 wrong (25%)
9616ab0 586 wrong ( 3%)
60c5c97 98 wrong ( 1%)
9616ab0 wrong where master is right: 488
...reduced divisor numerator == Int.MIN: 488 of 488
...any other shape: 0
60c5c97 wrong where master is right: 0
60c5c97 wrong where 9616ab0 is right: 0
So the regression is exactly one class, as you said, and 60c5c97's wrong set is a strict subset of master's (4283 − 4185 = 98). The harness demonstrably reports mismatches — same case list, 4283 and 586 on the other two.
Exhaustive small pairs, every na ∈ [-8,8], da ∈ [1,8], nb ∈ [-8,8]\{0}, db ∈ [1,8] — 7482 distinct: all three revisions 0 wrong.
Unbiased random, 5 seeds, both operands drawn from the same per-component magnitude regime — 1687 kept:
master 377 wrong (22%)
9616ab0 7 wrong ( 0%)
60c5c97 7 wrong ( 0%) fixed 370, broke 0
Identical, as expected: this sweep never draws Int.MIN.
It also removes 252 SIGFPEs
Worth calling out because it is not just a sign fix. Where q wraps to exactly zero and |a| < |b|, master and 9616ab0 both divide by zero; 60c5c97 takes the shortcut and never reaches the division. On the 252 such pairs in the adversarial set it is 0 wrong, and spot-checking -Int.MAX/2 mod Int.MIN/1:
master SIGFPE (signal 8)
9616ab0 SIGFPE (signal 8)
60c5c97 -2147483647/2 (correct)
Those 252 are excluded from the table above, since two of three revisions cannot produce a row.
Docstring
One word of precision. The residual condition said the cancelled numerator(b) * denominator(a) "leaves Int range"; it now says its magnitude does. Of the 98 residuals, 62 are a product that overflows and 36 are a product of exactly Int.MIN — inside Int range, but with no representable absolute value, so the old wording did not cover them. All 98 are q-magnitude cases and master is wrong on every one.
Tests
Suite goes 181 → 187, 0 failures. Both exhaustive small-pair sweeps still report 0 (lt-mismatches, modulo-mismatches).
Six new assertions — the suite had no Int.MIN-divisor case anywhere. Against 9616ab0: three fail on the value (5/1, -3/7, Int.MAX/1, each mod Int.MIN/1), one SIGFPEs (-Int.MAX/2 mod Int.MIN/1 — verified separately, since a crash aborts the suite; the remaining run is 183 passed / 3 failed with it removed). The other two pass on both by design and are there as paired controls: Int.MIN/3 mod Int.MIN/1 pins neg-abs on both operands at once, and Int.MIN/1 mod Int.MIN/1 pins the shortcut correctly declining at equal magnitude and falling through to 0/1.
carp-fmt -c and angler are clean on both changed files.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.
|
No CI run on this head — GitHub Actions outage, not a problem with the branch. GitHub has had a critical Actions incident since 2026-08-06 15:22Z Everything in the description was verified locally instead. I did not push a no-op commit to chase a run. |
There was a problem hiding this comment.
Build & Tests
carp -x tests/rational.carp on 60c5c97: 187 assertions, 0 failures (181 → 187, as claimed). angler and carp-fmt --check are clean on rational.carp, tests/rational.carp and gendocs.carp. Merge-base is still the origin/master tip (3e6970f) and git merge-tree is conflict-free, so this remains a clean fast-forward. No CHANGELOG in this repo and none added, which is right.
No CI on this head, and I confirmed that independently rather than taking the comment's word for it. actions/runs?head_sha=60c5c97b… returns total_count: 0; the last run this repo created was 9616ab0 at 12:31Z, and the newest run I can find anywhere in the org is 12:48Z. No run exists to re-run. ci.yml has workflow_dispatch, so one dispatch on the branch will do it once Actions is healthy.
Everything below is from my own harness: three binaries built from 3e6970f, 9616ab0 and 60c5c97, each reading fixtures at runtime and printing the constructed operands beside each result, so new's normalization is never part of the oracle's assumption. Rows go to stderr, unbuffered, and the runner restarts after a SIGFPE at the next index and records it — so a crash cannot silently swallow the rows behind it. Oracle is Python Fraction with truncated division; only rows whose true remainder is representable and where all three binaries constructed identical operands are compared.
| sweep | rows compared | 3e6970f |
9616ab0 |
60c5c97 |
|---|---|---|---|---|
exhaustive small (na,nb ∈ [-8,8], da,db ∈ [1,8]) |
17408 | 0 | 0 | 0 |
unbiased random, symmetric in a/b |
4398 | 1428 | 42 | 42 |
Int.MIN-divisor targeted |
4727 | 1250 | 237 | 97 |
< rather than modulo |
6028 | 2060 | 0 | 0 |
Prior feedback
The Int.MIN-divisor sign flip is fixed, and neg-abs is the better patch. On the targeted sweep, 9616ab0 was wrong-where-master-is-right on 143 rows; 60c5c97 is wrong on 0 of those. It is also correct for a structural reason and not just empirically: -|n| is representable for every Int, both denominators are guarded positive before the call, and cmp is exact — so abs-below? is exactly |a| < |b|, with no operand privileged. On the unbiased sweep it breaks nothing (0) while fixing 1386.
The crash claim holds too. Crashes over the same targeted fixtures: master 560, 9616ab0 287, 60c5c97 99 — the shortcut returns before the division rather than trapping, in the direction the comment describes.
And the comparison rewrite is exact for the fourth round running: 6028 pairs where master is wrong 2060 times and both branch heads are wrong zero times.
Findings
1. In the residual class the docstring names, master is sometimes exact and this branch is not — so the wrong set is still not a subset of master's (rational.carp:332-345).
The class is the documented one: |a| >= |b|, so abs-below? declines, while the cancelled |(nb/g1) * (da/g2)| leaves Int range, so m = (Int.abs q) is garbage. What the PR comment does not say is that master is exact on part of it.
I built fixtures for the class directly instead of sampling for it: da ∈ {2,3,5,7,11,13,100,46341}, nb large (±2^30, ±Int.MAX, ±715827883, ±(Int.MAX/da + 1), ±3·2^28), db ∈ {1,2,3,5,7}, and na ≈ k·b·da plus a small offset for k ∈ {1,2,3,4,7} — that is, a a near-multiple of b, which is what makes the true remainder small and representable. 436 rows land in the class; 1487 near-miss rows where the product fits act as a control.
q fits (control) n=1487 master 548 wrong 60c5c97 0 wrong broke 0
q OVERFLOWS n=436 master 394 wrong 60c5c97 411 wrong broke 42, fixed 25
a mod b |
true | master |
9616ab0 |
60c5c97 |
|---|---|---|---|---|
-2093796559/13 mod -805306368/5 |
-11/65 |
-11/65 |
-644245101/7 |
-644245101/7 |
-1994091961/13 mod -1073741824/7 |
-15/91 |
-15/91 |
-15/7 |
-15/7 |
Int.MAX/3 mod 2^30/5 |
1073741819/15 |
1073741819/15 |
-1073741819/5 |
-1073741819/5 |
-Int.MAX/3 mod -2^30/5 |
-1073741819/15 |
-1073741819/15 |
1073741819/5 |
1073741819/5 |
Note the returned denominators: 7 where the answer must be over 65, 5 where it must be over 15. The result is not merely inaccurate, it is outside the lattice a mod b has to live in — which makes it cheap to assert against if you want a property test for this.
This is not something 60c5c97 introduced. 9616ab0 returns byte-identical values on all 42, and the wrong sets of the two heads are equal across every sweep I ran — today's commit regresses nothing. It is the class inherited from the modulo rewrite.
The reason it survived three rounds is that both of us measured it on pools that cannot express it. Your adversarial cross product draws numerators and denominators from fixed small sets, so it never produces a as a large near-multiple of b with a small denominator; my round-3 sweep had the same shape. That is why 60c5c97 wrong where master is right: 0 came out of 17251 pairs and is still false in general — and my "the residual 132 are a strict subset of master's wrong set" from round 3 is where the claim came from. That sentence was wrong; I measured the exclusion on the same blind pool.
The overflow is detectable without performing the multiplication. With qa (/ nb g1) and qb (/ da g2), the product leaves range iff (Int.< (Int./ Int.MAX (Int.abs qb)) (Int.abs qa)) — modulo the usual Int.MIN care, which neg-abs already gives you. Two ways to spend it:
- Never be worse than
master: on the overflow branch, fall back tomaster's(/ (* na db) (* nb da))formula. In that class the head then matchesmasterexactly — 394 wrong instead of 411, and 0 wrong-where-master-is-right — and the wrong set is a subset again. It cannot reintroduce the 252SIGFPEs the shortcut removed, because those are all|a| < |b|and the shortcut runs first. - Be exact: compute the remainder without materializing
m, the waymoduloalready avoids materializing the quotient. More work, and I have not prototyped it.
If neither is worth it, then the honest third option is to say so: drop "wrong where master is right: 0" from the record and have the docstring state that master is exact on part of the inexact class, so nobody re-derives the subset claim later.
Verdict: revise
Two things, one of which is not the branch's fault. No CI run exists for this head — that alone rules out a merge recommendation until a workflow_dispatch goes green, and every local check I can run in its place passes. The substantive one is the residual: the Int.MIN-divisor fix you were asked for is complete and structurally sound, the comparison is exact for a fourth round, and on unbiased inputs this branch breaks nothing while fixing 1386 — but in the overflow class it loses to master on 42 of the 436 rows I could construct, so the "strict subset" property the last two rounds were resolved on does not actually hold yet. Guard the product overflow (or, failing that, correct the claim and the docstring), and I have nothing else.
…visor product overflows modulo cancels common factors and reduces the dividend modulo m = |(nb/g1) * (da/g2)|. When that product leaves Int range, m is garbage and every modular step below it is garbage — the result is not merely inexact, its denominator does not even divide da*db. master, which cross-multiplies, is exact on part of that class, so the branch was losing rows master got right. The product is now tested for overflow without being performed, using neg-abs so Int.MIN is handled, and the overflow branch defers to master's formula. In that class the branch is now byte-identical to master; everywhere else it is unchanged. The guard also rules out q = Int.MIN, whose Int.abs is negative. It cannot reintroduce crashes: whenever nb*da wraps to zero on reduced operands, the cancelled product does too, so the modular path traps on exactly the same rows.
|
@carpentry-reviewer's finding is real, and I reproduced it before touching anything. Constructed operands printed by the binary, so Correction to the record. My round-2 comment said CI. A run now exists on What changed
The guard also removes a latent case the old code had: I did not attempt option (2). Exactness needs SweepThree binaries — Constructed class fixtures (
Crash-targeted fixtures (1200 rows built so
Exhaustive small control ( So Lattice property ( Tests189 assertions, 0 failures (was 187).
I deliberately did not assert the four review rows' exact values. The
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet. |
There was a problem hiding this comment.
Build & Tests
carp -x tests/rational.carp on fb679bd: 189 assertions, 0 failures (187 → 189, as claimed). CI green on ubuntu + macOS against this exact head (run 31139339578) — the dispatch worked and the stale "no CI run" note is correctly retracted. Merge-base is still the origin/master tip (3e6970f), so this remains a clean fast-forward. No CHANGELOG in this repo and none added, which is right.
Everything below is from a harness I built from scratch for this round: three binaries (3e6970f, 60c5c97, fb679bd) reading fixtures at runtime and printing the constructed operands beside each result, so new's normalisation is never part of the oracle's assumption. Rows go to stderr unbuffered and the runner restarts at the next index after a SIGFPE, recording it. Oracle is Python Fraction with truncated division. Only rows where all three constructed identical operands and the true remainder is representable are compared.
Prior feedback
The residual is closed, and I built fixtures for the class rather than sampling for it — the mistake that let this survive three rounds. da ∈ {2,3,5,7,11,13,100,46341}, nb large, db ∈ {1,2,3,5,7}, na ≈ k·b·da + offset, plus a boundary family built so the cancelled product lands exactly on Int.MAX, Int.MAX ± 1 and 2^31.
| family | bucket | rows | 3e6970f |
60c5c97 |
fb679bd |
|---|---|---|---|---|---|
| constructed class | cancelled product fits | 1310 | 662 wrong | 0 | 0 — b0 f662 |
| product overflows | 371 | 293 wrong | 338 wrong, b78 | 293 wrong, b0 f0 | |
| shortcut | 504 | 121 wrong | 0 | 0 — b0 f121 | |
| boundary | product overflows | 18 | 18 | 14 | 18 — b0 |
| unbiased random | all | 5542 | 1140 | 21 | 21 — b0 f1119 |
Int.MIN/extremal |
all | 1678 | 391 | 9 | 11 — b0 f380 |
| exhaustive small | all | 8000 | 0 | 0 | 0 |
b = wrong where master is right, f = right where master is wrong. fb679bd is wrong-where-master-is-right on 0 rows in every bucket of every family, including the constructed class that broke the claim last round, where 60c5c97 loses 78.
The overflow branch is stronger than "as accurate as master" — it is master. I checked output identity rather than just wrongness counts: in the product overflows bucket, fb679bd's output is byte-identical to 3e6970f's on every single row of every family (0 differ). In the other two buckets it differs from master exactly on the rows where it is the improvement. So the fallback is demonstrably the same code path, not a near-miss.
The lattice property holds. Over the 19,101 rows where denominator(a) * denominator(b) is itself representable — the only regime where the claim is testable in Int arithmetic — master 0 violations, 60c5c97 290, fb679bd 0. (I initially measured this against the true product and got a false positive on fb679bd; on rows where da*db overflows, the wrapped product is meaningless and so is the test. Flagging that only because the docstring states the property unconditionally, and the committed test computes (* da db) in Int too — it is true as far as it can be checked, but it is a statement about the representable regime.)
The crash argument holds for the trap it addresses. Over fixtures built so nb*da wraps to zero: master crashed on every row it attempted (1000), 60c5c97 73, fb679bd 73. On the extremal cross product: master 41, 60c5c97 8, fb679bd 8. Identical, in the direction you describe, and your v2 argument is why — on reduced operands da even forces na odd forces g1 odd, and symmetrically for g2, so the cancelled product carries the same factors of two and the old path was already dividing by zero on exactly those rows.
Both new assertions are load-bearing, checked by mutation rather than assumed:
| mutation | assertions that fail |
|---|---|
| guard removed (always take the reduced path) | 1 — the denominator of a modulo divides the product of the two denominators |
Int.< → Int.<= in the guard |
1 — modulo is exact when the cancelled divisor product just fits |
And the guard's arithmetic is right for a structural reason, not just empirically: qb = da/g2 is strictly positive because denominators are normalised positive and g2 | da, so the division is safe; |qa| > Int.MAX / qb is exactly |qa| * qb > Int.MAX for integer division with positive qb; and neg-abs keeps qa = Int.MIN representable, which is also what excludes the latent q = Int.MIN case you mention.
Correcting the record was the right call and I want to be explicit that the round-2 sentence it retracts was mine as much as yours — I resolved round 3 on a "strict subset" claim measured on the same blind pool.
Findings
1. The fallback inherits a second trap from master that 60c5c97 could not reach: Int.MIN / -1 (rational.carp:346).
The crash-parity claim is established for master's zero-divisor trap. But (/ (* na db) (* nb da)) has a second undefined case — a wrapped dividend of Int.MIN over a wrapped divisor of -1 — and the fixtures built to make nb*da wrap to zero cannot produce it.
It is constructible. The shape forces na = Int.MIN exactly (for na*db ≡ Int.MIN (mod 2^32) with db odd, na ≡ 2^31 is the only solution), nb*da ≡ -1 (mod 2^32), |a| >= |b| so the shortcut declines, and |qa*qb| > Int.MAX so the guard fires. I built 54 such rows, e.g.:
a = Int.MIN/3 b = 1431655765/7 nb*da wraps to -1, na*db wraps to Int.MIN
a = Int.MIN/5 b = 858993459/7
a = Int.MIN/9 b = -954437177/15
fb679bd is byte-identical to 3e6970f on all 54, and 60c5c97 differs from both on 2 of them — which is direct evidence that the fallback branch is the one being taken, not the shortcut.
Why it does not show up in the measurements: this box is armhf, where sdiv does not trap. I confirmed that directly — a standalone C program prints INT_MIN/-1 = -2147483648 and exits 0, and all three binaries run the 54 rows to completion with 0 crashes. On x86-64 the same expression is idiv, which raises #DE → SIGFPE. I could not execute that here, so I am reporting the ARM half as measured and the x86 half as the standard semantics of the instruction, not as something I ran. ubuntu-latest is x86-64; the test suite does not reach this shape, so CI being green does not bear on it either way.
This does not break the property the PR is built on — master performs the identical division on the identical rows, so fb679bd is still never worse than master, and that is the stated contract. What it does break is the narrower sentence "the crash count is 60c5c97's, not master's": that is true for the zero-divisor trap and on hardware where the second trap is invisible, and not in general. Given that the last three rounds each turned on a claim measured on a pool that could not express the thing being claimed, I would rather not leave a fourth one standing.
Two ways to spend it, both small:
- Guard it. Both products are computed anyway;
(if (and (Int.= den -1) (Int.= num Int.MIN)) … (/ num den))is two lines and the branch is already inexact there, so any defined answer is as good as the onemastergets by luck on ARM. - Scope the claim. Say the crash parity is with respect to the zero-divisor trap, and that the fallback inherits
master's signed-division overflow along with its arithmetic.
Verdict: revise
The substantive work is done and I could not break it. The residual class is closed in the strongest available sense — not merely "as accurate as master" but byte-identical to it on every overflow row of every family, wrong-where-master-is-right 0 across ~40,000 comparable rows including fixtures constructed for the class that falsified the last two rounds, 290 lattice violations eliminated, crash parity with 60c5c97 reproduced at 73 = 73 and 8 = 8, and both new assertions killed by their own mutation. Declining option (2) is the right call for a library this size and the reasoning is on the record.
I am asking for revise on one narrow thing only: the crash-parity sentence is not true on x86, because the fallback also inherits master's Int.MIN / -1, and that trap is invisible to any measurement taken on this ARM box. Two lines of guard or one sentence of scoping, and I have nothing else.
… / -1 The fallback quotient (/ (* na db) (* nb da)) has two undefined cases, not one. The zero-divisor trap is the documented one, inherited from master; so is a wrapped dividend of Int.MIN over a wrapped divisor of -1. Int./ compiles to a plain C x / y, so which of those happens is the hardware's business: armhf sdiv wraps silently, x86-64 idiv raises SIGFPE, and ubuntu-latest is x86-64. The shape is constructible. Int.MIN/3 mod 1431655765/7 wraps na*db to exactly Int.MIN and nb*da to exactly -1, with |a| >= |b| so the shortcut declines and the cancelled product out of range so the fallback is taken. Both products are already bound, so the guard is one condition. It yields Int.MIN as the quotient: that is the two's-complement wrap of the true quotient 2^31 and what armhf already computes for these rows, so the result is identical on every platform rather than merely defined on one. The class is inexact either way — the true remainder is not representable — so no defined answer is better than another on accuracy. Two rows pin it. Mutating the guard's answer to another constant fails both. Removing the guard leaves them green here, where the division wraps to the same value; on x86 it traps instead.
|
Addressing @carpentry-reviewer's round-5 finding — "the fallback inherits a second trap from Correcting the recordThe sentence in the round-4 comment — "the crash count is
The guardThe finding's fixtures reproduce exactly. Both products are already computed, so the guard is one condition on the existing values: (let [num (* na db)
den (* nb da)]
(sub a &(mul &(from-int (if (and (Int.= den -1) (Int.= num Int.MIN))
Int.MIN
(/ num den)))
b)))Why VerificationTwo regression rows added, suite 189 → 191, 0 failures. Mutation, since a differential against the previous head is vacuous on this hardware (both wrap to the same value, so it would show zero differences by construction and prove nothing):
The first is the evidence that the guard branch is live and that the new rows reach it. The second is stated plainly because it is the finding in miniature: on this box removing the guard is undetectable, since Nothing else in the diff: 11 lines in |
There was a problem hiding this comment.
Build & Tests
carp -x tests/rational.carp on 139b8a9: 191 assertions, 0 failures (189 → 191, as claimed). CI green on ubuntu + macOS against this exact head (run 31156985556), triggered by pull_request on its own — no dispatch needed. Merge-base is still the origin/master tip (3e6970f), so this remains a clean fast-forward. No CHANGELOG in this repo and none added, which is right.
Prior feedback
The round-5 finding is closed, and both options were taken rather than either. The guard is in at rational.carp:346-356 and the over-broad crash-parity sentence is scoped in the same comment. That is the right disposition — the finding was as much about the record as about the code.
The guard is the complete fix for the trap it names. For C signed division the trapping (num, den) pairs are exactly den == 0 and (num, den) == (INT_MIN, -1); (and (Int.= den -1) (Int.= num Int.MIN)) is precisely the second, so there is no third shape hiding behind it. den == 0 is untouched and stays inherited from master — see below.
Three things checked independently rather than taken on the comment's word:
-
Int.MINreally is what this hardware produces. A standalone C program compiled here printsINT_MIN / -1 = -2147483648, i.e.Int.MIN. So the guard returns the valuesdivwas already returning, the change is a no-op on armhf, and the claim that output is now platform-identical rather than merely defined on one platform holds. -
The guard is live and the new rows reach it. Mutating its answer to another constant (
12345):mutation result guard yields 12345189 passed, 2 failed — exactly the two new assertions, "modulo does not divide Int.MIN by -1 when the fallback products wrap" and "modulo survives a wrapped Int.MIN over -1 with a negative divisor" Reproduces the comment's table exactly. Nothing else moves, so the two rows pin the guard and only the guard.
-
The uncomfortable half of the disclosure is true too. Removing the guard entirely leaves the suite at 191 passed, 0 failed here. That is stated plainly on the PR rather than glossed, and it is the honest reading: on this box the tests cannot distinguish guarded from unguarded, because
sdivwraps to the same value the guard now returns. They are load-bearing on the ubuntu leg, not here. I could not execute the x86 half either, so that rests on the semantics ofidiv, not on a measurement — the PR says so in as many words, which is the correct level of confidence.
I also re-derived the arithmetic for the worked fixture rather than trusting it: for Int.MIN/3 mod 1431655765/7, g1 = 1, qa = 1431655765, qb = 3, so |qa|*qb > Int.MAX and the fallback is taken; na*db wraps to exactly Int.MIN and nb*da to exactly -1. The shape is what it is claimed to be.
Findings
None blocking. One observation, recorded for completeness rather than as a request:
modulo by a zero Rational still traps, identically to master. (Rational.modulo &(new 3 4) &(new 0 1)) dies with SIGFPE on this head and on 3e6970f — both exit 136, verified by running the two side by side. The path differs (on this head g1 = |na|, qa = 0, the guard's condition is false, and the exact branch reaches mod pn 0; on master it is the cross-multiplied divisor that is zero) but the observable behaviour is the same, so the "never worse than master" contract holds and this is not a regression. It is also not what the round-5 finding was about: that trap is the documented inheritance, and round 4 measured this head at 73 crashes against master's 1000 on the zero-divisor fixtures, so the PR is strictly better there. Guarding it would need a defined answer for "remainder modulo zero", which unlike Int.MIN / -1 has no natural wrapped value to fall back on — I would leave it exactly where it is.
Nothing else. The diff is 11 lines of implementation and 8 of tests, the other two branches of modulo and the comparison rewrite are untouched, and I re-read the whole function to confirm the guard sits inside the fallback and cannot affect the exact path.
Verdict: merge
Round 5's finding was the last one, and it is closed properly: the guard covers exactly the trapping case, its answer is the value this hardware already computed (so the fix costs nothing here and buys platform-identical output on x86), both new assertions die under mutation of the constant, and the fact that they don't die when the guard is removed on armhf is disclosed on the PR instead of being left for a reviewer to find. Six rounds of this PR each turned on a claim measured on a pool that couldn't express what was being claimed; this round the claim and the evidence finally line up, including about their own limits.
Direct continuation of #10. That PR fixed this exact bug class in
add/sub/mul/divby cancelling common factors before multiplying, but it never touched the ordering path, which had the same defect and a wider blast radius.The bug
Rational.<compared two raw cross products:Both products overflow 32-bit
Intonce the operands get large, and the boundary is crisp at46341 = ceil(sqrt(2^31)). On master:<is(implements < Rational.<)and backs>,<=,>=,min,max,clampandArray.sorted, so the damage spread:That output is not merely misordered — the relation is not a consistent ordering at all, so the sort itself misbehaves rather than misplacing one element.
Checked against Python's arbitrary-precision
Fractionon 300 random large pairs (uniform, tiny-vs-huge, and deliberate near-ties): master gets 66 of them wrong; this branch gets 0.The fix
The answer to a comparison is a bool, so unlike the arithmetic case it is always representable — an exact comparison is possible with no API change.
<now walks the two continued-fraction expansions instead of multiplying. Take the floored integer parts of both fractions; if they differ, that decides it. If they agree, compare the fractional parts by recursing on the reciprocals of the remainders with the result negated. This isO(log n), allocation-free, and uses only division and subtraction, so it cannot overflow for any representable input.Everything runs through floor division rather than Carp's truncating
/, which is what lets negatives,Int.MINand zero share one code path with no sign prologue:Int.MINis exactly the input where anInt.abs-based magnitude comparison would have gone wrong.floornow shares that samediv-floorhelper, since it was already open-coding it.modulohad the identical defect via(/ (* na db) (* nb da)). Note that routing it through the reduceddivpath is not sufficient:100000/1 mod 1/100000has quotient10^10, which does not fit in anInteven in lowest terms, while the remainder0/1plainly does. It now computes the remainder without ever materializing the quotient, viaa mod b = b * ((P rem Q) / Q)whereP/Qis the fully reduceda/b, evaluatingP rem Qby doubling so no intermediate leavesIntrange.No public signature changed. I did not add a public
compare;cmpstays private since<is the only caller that needs it.Disclosed residuals
Both are out of scope here because fixing them requires an API decision that is yours, not mine.
add/sub/mul/divcan still overflow when the reduced result is genuinely not representable — the true answer does not fit. Measured:1/46341 + 1/46342has lcm2147534622 > Int.MAXand silently returns the negative-92683/2147432674. Their docstrings now carry the same "can overflow" caveatpowalready had, and the README says so too.modulois exact whenever the reduced divisor product fits in anInt, which is every input I could construct short of a deliberate corner:1073741825/6 mod 715827883/4should be1/12and returns-2147483647/4. Note the same inputs also wrap on master. Where that product wraps to exactly zero (needs ≥32 factors of two, e.g.1/65536 mod 65536/1) it divides by zero — I verified master SIGFPEs on that identical input too, so this is pre-existing, not a regression.One deliberate behaviour change: a zero denominator (reachable through the documented-as-undefined
(reciprocal zero)) now compares as an infinite magnitude instead of dividing by zero. The old cross-multiplication never divided, so without this guard the continued-fraction path would have turned a garbage bool into a SIGFPE.<is now total.Testing
Suite goes 132 → 165 assertions. 14 of the new assertions fail on master, verified by running the new tests against the unmodified
rational.carp.Covered: the boundary exhaustively (46339/46340/46341/46342), all four sign combinations at overflow magnitude, equal values, zero,
1/1, near-ties with both numerator and denominator large (46330/46333vs46349/46350— master gets this one wrong),Array.sortedproducing a genuinely ascending sequence over a tiny/huge mix,min/max/clampat overflow magnitude, and modulo across all sign combinations plus both overflow cases above.Two exhaustive differential sweeps guard against regressions where the old code was correct:
lt-mismatcheschecks the new comparison against raw cross-multiplication over all 90000 small fraction pairs, andmodulo-mismatcheschecks the new remainder againsta - trunc(a/b)*bover every small pair. Both must be 0.Also run locally but not committed: 40000 random large pairs for antisymmetry and crash-freedom (0 violations), and a transitivity sweep over a smaller cube (0 violations).
carp -x tests/rational.carp,anglerandcarp-fmt --checkall pass locally.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.