Found against: gren 0.6.6, gren-lang/core 7.4.2, node 22
Reproduction: 2026-09-10-random-int-wide-range in https://github.com/gilramir/gren-bug-reports
./run.sh prints everyfigure below
Another math bug.
Summary
Random.int chooses between two algorithms with a power-of-two test, and masks
with range - 1 when it takes the fast path:
range =
hi - lo + 1
in
-- fast path for power of 2
if (Bitwise.and (range - 1) range) == 0 then
{ value = (Bitwise.shiftRightZfBy 0 (Bitwise.and (range - 1) (peel seed0))) + lo, seed = next seed0 }
Bitwise.and is JavaScript &, which coerces each operand to a signed
32-bit integer on its own. An Int is a float64 and holds far more than that, so
for a range of 2^32 or wider neither the test nor the mask is about range at
all. Both are about range modulo 2^32.
So a caller who asks for a range wider than 2^32 gets a a narrower range than what they asked for.
What that produces
Each row below is one call's own requested interval, from 0 on the left to hi
on the right, cut into 48 equal buckets — 48 because it divides by both 2 and 3,
so every boundary here lands on a bucket edge. 20000 draws from
Random.initialSeed 7 are dropped in. A bucket that ever received a draw is
#; one that never did is ..
(I added apostrophes to make the hex numbers easier to read)
int 0 0xFFFF'FFFF ################################################ all of it
int 0 0x1'FFFF'FFFF ########################........................ the low half
int 0 0x7FFF'FFFF ################################################ all of it
int 0 0x1'7FFF'FFFF ################................................ the low third
int 0 0x1'0000'0000 #............................................... only 0
int 0 0x1'0000'0001 #............................................... only 0 and 1
The draws are uniform over a prefix of the interval and absent from the
rest. Nothing is out of range and nothing is biased within what it reaches; the
interval is simply smaller than the one that was asked for.
We can see the problem from another viewpoint by generating 6 random numbers, between
different ranges, but all starting from the same seed.
Here, each pair of rows belongs together; the high member of their range differs by 0x100000000
#1 #2 #3 #4 #5 #6
ok int 0 0xFFFF'FFFF 2412620543 962486949 4105077394 2526171418 1015690994 2793901850
BAD int 0 0x1'FFFF'FFFF 2412620543 962486949 4105077394 2526171418 1015690994 2793901850
ok int 0 0x7FFF'FFFF 265136895 962486949 1957593746 378687770 1015690994 646418202
BAD int 0 0x1'7FFF'FFFF 265136895 962486949 1957593746 378687770 1015690994 646418202
BAD int 0 0x1'0000'0000 0 0 0 0 0 0
BAD int 0 0x1'0000'0001 1 1 0 0 0 0
Rows 1 and 3 are correct.
Rows 2 and 4 are wrong; they should not be identical to their partners.
Rows 5 and 6 are obviously wrong.
What int decided in each case, which is the rule above:
call range range mod 2^32 (range-1) & range mask
ok int 0 0xFFFF'FFFF 2^32 0 0 4294967295
BAD int 0 0x1'FFFF'FFFF 2^33 0 0 4294967295
ok int 0 0x7FFF'FFFF 2^31 2147483648 0 2147483647
BAD int 0 0x1'7FFF'FFFF 1.5 * 2^32 2147483648 0 2147483647
BAD int 0 0x1'0000'0000 2^32 + 1 1 0 0
BAD int 0 0x1'0000'0001 2^32 + 2 2 0 1
The first row works only by coincidence,
The one row that works by coincidence
int 0 0xFFFFFFFF is not a hypothetical caller. It is the only Random.int
call in the whole of core, and independentSeed makes it three times for
every seed it hands out:
independentSeed : Generator Seed
independentSeed =
Generator <|
\seed0 ->
let
gen =
int 0 0xFFFFFFFF
makeIndependentSeed state b c =
next <| Seed { state = state, increment = Bitwise.shiftRightZfBy 0 (Bitwise.or 1 (Bitwise.xor b c)) }
in
step (map3 makeIndependentSeed gen gen gen) seed0
That is the m = 0 case of the rule, and it is worth following both truncations
through, because neither one is an answer about range:
the actual range from 0 to 0xFFFFFFFF
range = 0xFFFFFFFF - 0 + 1 = 4294967296 -- 2^32, exact in a float64
range - 1 = 4294967295
the test Bitwise.and (range - 1) range (it becomes int32 due to `and`)
range - 1 as int32 = -1 -- all 32 bits set
range as int32 = 0 -- its one set bit is bit 32, out of range
-1 & 0 = 0 -- reads as "power of two": fast path
the mask Bitwise.and (range - 1) (peel seed0)
range - 1 as int32 = -1
-1 & x = x -- the identity; the enclosing
-- shiftRightZfBy 0 puts the sign back
range - 1 is 4294967295, which already fits in 32 bits — it is all ones — so
reading it as -1 changes how the bits are interpreted, not what they are. That
happens twice, once in the test and once in the mask, and both times it is
harmless: all ones is exactly the mask a 32-bit range calls for.
range is the one that loses. Its only set bit is bit 32, the coercion discards
it, and what the test actually sees is 0.
That loss is harmless here and nowhere else above 2^32. The test asks whether
(range - 1) & range is zero, and in exact arithmetic it is, because 4294967295
and 4294967296 share no bits — so the branch taken is the right branch. It is
reached by a different question, though. The test that was meant to run passes
because 2^32 is a power of two; the test that did run passes because 0 has no
bits at all.
So this is not two errors cancelling. It is one coercion that happens to be
lossless, and one that throws away the only bit that mattered and still lands on
the correct branch. The defect is fully present in this row — at this one width
it has nothing left to damage.
That is what makes the repair delicate rather than obvious, and it is why
"Suggested fix" below opens with a guard that does not work. Whatever replaces
the test has to keep 2^32 on the fast path, because 2^32 is where core itself
lives.
Cause
Bitwise.and, .or, .xor, .complement and the shifts are the JavaScript
operators, which are defined on int32. Random.int is the only place in core
that hands one a value derived from user-supplied bounds rather than from a
32-bit quantity it produced itself, so it is the only place where the coercion is
reachable from the outside.
Underneath that is a missing contract. Random.peel yields 32 bits, so at most 2^32
distinct values are reachable however they are post-processed, and no range wider
than that is satisfiable by this generator at all. int never checks; the
coercion then reinterprets the excess instead of refusing it.
Reproduction repository
gren-bug-reports/2026-09-10-random-int-wide-range — ./run.sh prints the
decision table and the draws above.
A sibling report, in the same module and independent of this one
core#146 is report was found while reading Random.
This bug and that one share nothing mechanically: fixing either leaves the other one still broken.
Found against:
gren0.6.6,gren-lang/core7.4.2, node 22Reproduction:
2026-09-10-random-int-wide-rangein https://github.com/gilramir/gren-bug-reports./run.shprints everyfigure belowAnother math bug.
Summary
Random.intchooses between two algorithms with a power-of-two test, and maskswith
range - 1when it takes the fast path:Bitwise.andis JavaScript&, which coerces each operand to a signed32-bit integer on its own. An
Intis a float64 and holds far more than that, sofor a range of 2^32 or wider neither the test nor the mask is about
rangeatall. Both are about
rangemodulo 2^32.So a caller who asks for a range wider than 2^32 gets a a narrower range than what they asked for.
What that produces
Each row below is one call's own requested interval, from 0 on the left to
hion the right, cut into 48 equal buckets — 48 because it divides by both 2 and 3,
so every boundary here lands on a bucket edge. 20000 draws from
Random.initialSeed 7are dropped in. A bucket that ever received a draw is#; one that never did is..(I added apostrophes to make the hex numbers easier to read)
The draws are uniform over a prefix of the interval and absent from the
rest. Nothing is out of range and nothing is biased within what it reaches; the
interval is simply smaller than the one that was asked for.
We can see the problem from another viewpoint by generating 6 random numbers, between
different ranges, but all starting from the same seed.
Here, each pair of rows belongs together; the high member of their range differs by 0x100000000
Rows 1 and 3 are correct.
Rows 2 and 4 are wrong; they should not be identical to their partners.
Rows 5 and 6 are obviously wrong.
What
intdecided in each case, which is the rule above:The first row works only by coincidence,
The one row that works by coincidence
int 0 0xFFFFFFFFis not a hypothetical caller. It is the onlyRandom.intcall in the whole of
core, andindependentSeedmakes it three times forevery seed it hands out:
That is the
m = 0case of the rule, and it is worth following both truncationsthrough, because neither one is an answer about
range:range - 1is 4294967295, which already fits in 32 bits — it is all ones — soreading it as
-1changes how the bits are interpreted, not what they are. Thathappens twice, once in the test and once in the mask, and both times it is
harmless: all ones is exactly the mask a 32-bit range calls for.
rangeis the one that loses. Its only set bit is bit 32, the coercion discardsit, and what the test actually sees is
0.That loss is harmless here and nowhere else above 2^32. The test asks whether
(range - 1) & rangeis zero, and in exact arithmetic it is, because 4294967295and 4294967296 share no bits — so the branch taken is the right branch. It is
reached by a different question, though. The test that was meant to run passes
because 2^32 is a power of two; the test that did run passes because
0has nobits at all.
So this is not two errors cancelling. It is one coercion that happens to be
lossless, and one that throws away the only bit that mattered and still lands on
the correct branch. The defect is fully present in this row — at this one width
it has nothing left to damage.
That is what makes the repair delicate rather than obvious, and it is why
"Suggested fix" below opens with a guard that does not work. Whatever replaces
the test has to keep 2^32 on the fast path, because 2^32 is where
coreitselflives.
Cause
Bitwise.and,.or,.xor,.complementand the shifts are the JavaScriptoperators, which are defined on int32.
Random.intis the only place incorethat hands one a value derived from user-supplied bounds rather than from a
32-bit quantity it produced itself, so it is the only place where the coercion is
reachable from the outside.
Underneath that is a missing contract.
Random.peelyields 32 bits, so at most 2^32distinct values are reachable however they are post-processed, and no range wider
than that is satisfiable by this generator at all.
intnever checks; thecoercion then reinterprets the excess instead of refusing it.
Reproduction repository
gren-bug-reports/2026-09-10-random-int-wide-range—./run.shprints thedecision table and the draws above.
A sibling report, in the same module and independent of this one
core#146 is report was found while reading
Random.This bug and that one share nothing mechanically: fixing either leaves the other one still broken.