feat: add unbiased integer range helpers

Expand range sampling from the original u32-only helper to all primitive
integer widths. The new gen_range_<int> methods cover 0..n sampling, while
matching gen_range_<int>_in methods accept standard RangeBounds forms such as
exclusive, inclusive, open-ended, and full-width ranges.

Use rejection sampling against the matching primitive reader for each integer
width. That keeps modulo bias out of the public helpers without introducing a
general RNG trait or depending on rand. Full-width ranges fall back directly to
the primitive reader because their span cannot be represented in the same
integer type.

Document the generated APIs with doctested examples and add a ranges example
that demonstrates unsigned, signed, usize, and full-width sampling.

Test Plan:
- cargo test
- cargo clippy
- cargo clippy --benches
- cargo clippy --tests
- cargo +nightly fmt

Refs: IDEAS.md ergonomics backlog
This commit is contained in:
2026-04-28 20:03:37 +02:00
parent 733a2508cb
commit b8f9fa9b1b
3 changed files with 263 additions and 22 deletions
+2
View File
@@ -11,6 +11,8 @@ fn main() -> std::io::Result<()> {
println!("i32 : {}", rng.get_i32()?); println!("i32 : {}", rng.get_i32()?);
println!("dice 1-6 : {}", rng.gen_range_u32(6)? + 1); println!("dice 1-6 : {}", rng.gen_range_u32(6)? + 1);
println!("offset : {}", rng.gen_range_i32_in(-10..=10)?);
println!("index : {}", rng.gen_range_usize_in(0..16)?);
let token = rng.string_from(charset::ALPHANUMERIC, 24)?; let token = rng.string_from(charset::ALPHANUMERIC, 24)?;
println!("token : {token}"); println!("token : {token}");
+21
View File
@@ -0,0 +1,21 @@
//! Run with: `cargo run --example ranges`
use ez_urandom::OsRandom;
fn main() -> std::io::Result<()> {
let mut rng = OsRandom::try_new()?;
let byte = rng.gen_range_u8(10)?;
let port = rng.gen_range_u16_in(49152..=65535)?;
let index = rng.gen_range_usize_in(0..8)?;
let offset = rng.gen_range_i32_in(-10..=10)?;
let full_width = rng.gen_range_i128_in(..)?;
println!("u8 below 10 : {byte}");
println!("ephemeral port : {port}");
println!("usize index : {index}");
println!("signed offset : {offset}");
println!("full-width i128 : {full_width}");
Ok(())
}
+240 -22
View File
@@ -19,6 +19,7 @@ use std::{
fs::File, fs::File,
io::{self, Read}, io::{self, Read},
mem::size_of, mem::size_of,
ops::{Bound, RangeBounds},
}; };
use ::paste::paste; use ::paste::paste;
@@ -104,6 +105,179 @@ macro_rules! os_random_get_integer_impls {
}; };
} }
macro_rules! os_random_unsigned_range_impls {
($(($t:ty, $method:ident, $range_method:ident, $below_method:ident, $get_method:ident)),*) => {
$(
/// Returns a uniformly distributed integer in `0..n`.
///
/// # Panics
/// Panics if `n == 0`.
///
/// # Errors
/// Returns any I/O error produced while reading from `/dev/urandom`.
///
/// # Examples
///
/// ```
/// # fn main() -> std::io::Result<()> {
/// let mut rng = ez_urandom::OsRandom::try_new()?;
#[doc = concat!("let value = rng.", stringify!($method), "(10)?;")]
/// assert!(value < 10);
/// # Ok(())
/// # }
/// ```
pub fn $method(&mut self, n: $t) -> io::Result<$t> {
assert!(n > 0, "n must be greater than zero");
self.$below_method(n)
}
/// Returns a uniformly distributed integer from `range`.
///
/// The range may use any [`RangeBounds`] form, including `a..b`,
/// `a..=b`, `..b`, `..=b`, `a..`, and `..`.
///
/// # Panics
/// Panics if the range is empty or if an excluded bound overflows
/// the integer type.
///
/// # Errors
/// Returns any I/O error produced while reading from `/dev/urandom`.
///
/// # Examples
///
/// ```
/// # fn main() -> std::io::Result<()> {
/// let mut rng = ez_urandom::OsRandom::try_new()?;
#[doc = concat!("let value = rng.", stringify!($range_method), "(4..=9)?;")]
/// assert!((4..=9).contains(&value));
/// # Ok(())
/// # }
/// ```
pub fn $range_method<R>(&mut self, range: R) -> io::Result<$t>
where
R: RangeBounds<$t>,
{
let start = match range.start_bound() {
Bound::Included(&start) => start,
Bound::Excluded(&start) => start
.checked_add(1)
.expect("excluded range start must not overflow"),
Bound::Unbounded => <$t>::MIN,
};
let end = match range.end_bound() {
Bound::Included(&end) => end,
Bound::Excluded(&end) => end
.checked_sub(1)
.expect("excluded range end must not overflow"),
Bound::Unbounded => <$t>::MAX,
};
assert!(start <= end, "range must not be empty");
let span = end.wrapping_sub(start).wrapping_add(1);
if span == 0 {
return self.$get_method();
}
Ok(start.wrapping_add(self.$below_method(span)?))
}
fn $below_method(&mut self, n: $t) -> io::Result<$t> {
let threshold = (0 as $t).wrapping_sub(n) % n;
loop {
let value = self.$get_method()?;
if value >= threshold {
return Ok(value % n);
}
}
}
)*
};
}
macro_rules! os_random_signed_range_impls {
($(($t:ty, $u:ty, $method:ident, $range_method:ident, $below_method:ident, $get_method:ident)),*) => {
$(
/// Returns a uniformly distributed integer in `0..n`.
///
/// # Panics
/// Panics if `n <= 0`.
///
/// # Errors
/// Returns any I/O error produced while reading from `/dev/urandom`.
///
/// # Examples
///
/// ```
/// # fn main() -> std::io::Result<()> {
/// let mut rng = ez_urandom::OsRandom::try_new()?;
#[doc = concat!("let value = rng.", stringify!($method), "(10)?;")]
/// assert!((0..10).contains(&value));
/// # Ok(())
/// # }
/// ```
pub fn $method(&mut self, n: $t) -> io::Result<$t> {
assert!(n > 0, "n must be greater than zero");
self.$range_method(0..n)
}
/// Returns a uniformly distributed integer from `range`.
///
/// The range may use any [`RangeBounds`] form, including `a..b`,
/// `a..=b`, `..b`, `..=b`, `a..`, and `..`.
///
/// # Panics
/// Panics if the range is empty or if an excluded bound overflows
/// the integer type.
///
/// # Errors
/// Returns any I/O error produced while reading from `/dev/urandom`.
///
/// # Examples
///
/// ```
/// # fn main() -> std::io::Result<()> {
/// let mut rng = ez_urandom::OsRandom::try_new()?;
#[doc = concat!("let value = rng.", stringify!($range_method), "(-4..=4)?;")]
/// assert!((-4..=4).contains(&value));
/// # Ok(())
/// # }
/// ```
pub fn $range_method<R>(&mut self, range: R) -> io::Result<$t>
where
R: RangeBounds<$t>,
{
const SIGN_BIT: $u = (<$u>::MAX >> 1) + 1;
let start = match range.start_bound() {
Bound::Included(&start) => start,
Bound::Excluded(&start) => start
.checked_add(1)
.expect("excluded range start must not overflow"),
Bound::Unbounded => <$t>::MIN,
};
let end = match range.end_bound() {
Bound::Included(&end) => end,
Bound::Excluded(&end) => end
.checked_sub(1)
.expect("excluded range end must not overflow"),
Bound::Unbounded => <$t>::MAX,
};
assert!(start <= end, "range must not be empty");
let start = start.cast_unsigned() ^ SIGN_BIT;
let end = end.cast_unsigned() ^ SIGN_BIT;
let span = end.wrapping_sub(start).wrapping_add(1);
if span == 0 {
return self.$get_method();
}
let value = start.wrapping_add(self.$below_method(span)?) ^ SIGN_BIT;
Ok(value.cast_signed())
}
)*
};
}
/// Constructor and primitive integer readers. /// Constructor and primitive integer readers.
/// ///
/// Each `get_<int>` method reads exactly `size_of::<T>()` bytes from /// Each `get_<int>` method reads exactly `size_of::<T>()` bytes from
@@ -178,29 +352,73 @@ impl Default for OsRandom {
/// Uniform sampling helpers. /// Uniform sampling helpers.
/// ///
/// All methods here use rejection sampling on top of [`OsRandom::get_u32`] /// Range methods use rejection sampling on top of the matching primitive
/// so the result is exactly uniform — no modulo bias. /// integer reader, so bounded results are exactly uniform — no modulo bias.
impl OsRandom { impl OsRandom {
/// Returns a uniformly distributed integer in `0..n`. os_random_unsigned_range_impls!(
/// (u8, gen_range_u8, gen_range_u8_in, gen_below_u8, get_u8),
/// # Panics (u16, gen_range_u16, gen_range_u16_in, gen_below_u16, get_u16),
/// Panics if `n == 0`. (u32, gen_range_u32, gen_range_u32_in, gen_below_u32, get_u32),
/// (u64, gen_range_u64, gen_range_u64_in, gen_below_u64, get_u64),
/// # Errors (
/// Returns any I/O error produced while reading from `/dev/urandom`. u128,
pub fn gen_range_u32(&mut self, n: u32) -> io::Result<u32> { gen_range_u128,
assert!(n > 0, "n must be greater than zero"); gen_range_u128_in,
let n64 = u64::from(n); gen_below_u128,
// Largest multiple of n that fits in 2^32. Values at or above the get_u128
// cutoff would skew the modulo, so we discard and resample. ),
let cutoff = (1u64 << 32) - ((1u64 << 32) % n64); (
loop { usize,
let v = u64::from(self.get_u32()?); gen_range_usize,
if v < cutoff { gen_range_usize_in,
return Ok(u32::try_from(v % n64).expect("v % n is bounded by n which fits in u32")); gen_below_usize,
} get_usize
} )
} );
os_random_signed_range_impls!(
(i8, u8, gen_range_i8, gen_range_i8_in, gen_below_u8, get_i8),
(
i16,
u16,
gen_range_i16,
gen_range_i16_in,
gen_below_u16,
get_i16
),
(
i32,
u32,
gen_range_i32,
gen_range_i32_in,
gen_below_u32,
get_i32
),
(
i64,
u64,
gen_range_i64,
gen_range_i64_in,
gen_below_u64,
get_i64
),
(
i128,
u128,
gen_range_i128,
gen_range_i128_in,
gen_below_u128,
get_i128
),
(
isize,
usize,
gen_range_isize,
gen_range_isize_in,
gen_below_usize,
get_isize
)
);
/// Returns a uniformly chosen byte from `set`. /// Returns a uniformly chosen byte from `set`.
/// ///