gren-lang / gren-lang/core

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

Open
#147 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.