`Random.int` uses its range modulo 2^32: wide ranges silently narrow, and 2^32 + 1 returns the low end of its range forever
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 48
- Forks
- 14
- Avg merge
- 4h 14m
- Merged PRs (30d)
- 1
Description
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the Random.int and Random.peel entry points described in the report, then run 2026-09-10-random-int-wide-range/run.sh to reproduce the decision table. Trace the power-of-two check and bitwise operations, and use the listed ranges as acceptance checks so wide requests no longer silently narrow while the 2^32 case remains valid.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100