Skip to content

Random.int uses its range modulo 2^32: wide ranges silently narrow, and 2^32 + 1 returns the low end of its range forever #147

Description

@gilramir

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions