Skip to content

Add the random builtin: one uniform integer, typed, replacing $RANDOM - #418

Merged
tobert merged 5 commits into
mainfrom
feat/random-builtin
Aug 27, 2026
Merged

Add the random builtin: one uniform integer, typed, replacing $RANDOM#418
tobert merged 5 commits into
mainfrom
feat/random-builtin

Conversation

@tobert

@tobert tobert commented Aug 27, 2026

Copy link
Copy Markdown
Owner

kaish had no $RANDOM, and the arithmetic rewrite that follows refuses $((RANDOM % 100)) with an error that has to name a real replacement. This is that replacement.

random                    # 0 to 32767, bash's $RANDOM range
random --max 100          # 0 to 100, inclusive
random --min -5 --max 5
x=$(random --max 6); echo $((x + 1))   # typed: typeof $(random) is number
random --json --min 7 --max 7          # 7

The draw is uniform: 8 bytes from the OS CSPRNG mapped onto the range by Lemire's method with rejection, so draw % width bias never appears, and the width is carried in 128 bits so the full 64-bit span works. An entropy failure is an error, never a fixed value.

Errors exit 2 and name the fix:

random --min 10 --max 5   # random: --min 10 is greater than --max 5; swap them or widen the range
random 100                # random: takes no positional argument; write `--max 100`

The mapper's unit tests pin exact accepted, rejected, and full-span boundary values, checked by mutating the threshold comparison and watching them fail.

🤖 Generated with Claude Code

tobert and others added 5 commits August 27, 2026 08:49
kaish has no $RANDOM, and the arithmetic rewrite that follows this
change refuses $((RANDOM % 100)) with an error naming a builtin as the
fix. That builtin has to exist first, so this adds it: random [--min
N] [--max N] prints one integer chosen from min..=max, both inclusive
(defaults 0 and 32767, matching bash's $RANDOM range).

The output is typed, not stringified. The schema declares
with_typed_substitution() and the result carries Value::Int, so
$(random --max 6) binds a number and x=$(random --max 6); echo $((x +
1)) works without a cast. --json emits the bare number, not a
string-wrapped envelope.

Sampling reads 8 bytes from getrandom and maps them onto the range
with Lemire's method (widen the multiply into 128 bits, reject the
low slice that would otherwise skew one bucket) instead of draw %
width, which is measurably biased whenever the range doesn't evenly
divide 2^64. The width itself is carried in u128 so the full i64 span
(--min i64::MIN --max i64::MAX) doesn't overflow computing it. A
getrandom failure is a hard error, not a fallback value - a script
that trusted random for a coin flip must never get a predictable
substitute silently.

Bounds and shape are enforced before any draw happens, each a curated
exit-2 error naming the value and the fix: --min greater than --max,
a non-integer bound (left to clap's own parse error), and a
positional argument (random takes none - the fix always spells out
the equivalent --max flag). random registers alphabetically in the
builtin table between pwd and read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add random to the README's System category table (alphabetical,
between push and read) and a CHANGELOG Unreleased/Added bullet
summarizing the contract and the unbiased-sampling method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sweep_covers_every_registered_builtin failed with random missing from
CASES. Add it between pwd and read, using a fixed --min 3 --max 3
range so the draw is deterministic and the case only pins the shape:
--json on a typed Int emits a bare JSON number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
random.rs's non-test comments ran 45 lines against 113 lines of code -
narrative that belongs in the commit history, which already carries it
(the previous commit explains why Lemire's method, why the u128
width, why entropy failure is fatal). Trimmed the module doc, the
map_draw_to_range and draw_random doc comments, and the inline //
notes down to what a reader needs at the point of the code - 20
comment lines against 112 lines of code. The published /// on --min,
--max, and the about text are untouched.

Also trims the CHANGELOG bullet: drop the algorithm name and the
modulo-bias parenthetical, since that detail lives in the code and
the commit, not the release notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
map_draw_is_deterministic called map_draw_to_range twice with the same
draw and asserted the two results were equal - trivially true for any
pure function, and the chosen draw landed on an accepted value, so no
test pinned an actual mapped result or a rejection. An off-by-one or a
flipped threshold comparison would have passed unnoticed.

Replaced it with map_draw_accepts_and_maps_a_known_draw (draw
0x1234_5678_9abc_def0 over -100..=100 must map to exactly -86) and
map_draw_rejects_a_known_biased_draw (draw 0 over 0..=6 must reject,
since it falls under the width-7 threshold of 2). Both values were
computed independently of the implementation before writing the
assertions. Also pinned the full i64 span's boundary outputs
(map_draw_full_i64_span_pins_boundary_values): draw 0 must map to
i64::MIN and draw u64::MAX to i64::MAX, not just "some value in
range" as the existing never_panics test already checked.

Verified the new tests actually discriminate: flipping the threshold
comparison from < to > failed both map_draw_accepts_and_maps_a_known_draw
and map_draw_rejects_a_known_biased_draw before the fix was reverted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tobert
tobert merged commit 38ae16c into main Aug 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant