From b8f9fa9b1bf59de2c7351f6c913adeda37ef9e5d Mon Sep 17 00:00:00 2001 From: ddidderr Date: Tue, 28 Apr 2026 20:03:37 +0200 Subject: [PATCH] feat: add unbiased integer range helpers Expand range sampling from the original u32-only helper to all primitive integer widths. The new gen_range_ methods cover 0..n sampling, while matching gen_range__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 --- examples/demo.rs | 2 + examples/ranges.rs | 21 ++++ src/lib.rs | 262 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 examples/ranges.rs diff --git a/examples/demo.rs b/examples/demo.rs index 69c0cd9..f493ff3 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -11,6 +11,8 @@ fn main() -> std::io::Result<()> { println!("i32 : {}", rng.get_i32()?); 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)?; println!("token : {token}"); diff --git a/examples/ranges.rs b/examples/ranges.rs new file mode 100644 index 0000000..a57547f --- /dev/null +++ b/examples/ranges.rs @@ -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(()) +} diff --git a/src/lib.rs b/src/lib.rs index e7d6d00..4e5848f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ use std::{ fs::File, io::{self, Read}, mem::size_of, + ops::{Bound, RangeBounds}, }; 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(&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(&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. /// /// Each `get_` method reads exactly `size_of::()` bytes from @@ -178,29 +352,73 @@ impl Default for OsRandom { /// Uniform sampling helpers. /// -/// All methods here use rejection sampling on top of [`OsRandom::get_u32`] -/// so the result is exactly uniform — no modulo bias. +/// Range methods use rejection sampling on top of the matching primitive +/// integer reader, so bounded results are exactly uniform — no modulo bias. impl OsRandom { - /// 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`. - pub fn gen_range_u32(&mut self, n: u32) -> io::Result { - assert!(n > 0, "n must be greater than zero"); - let n64 = u64::from(n); - // Largest multiple of n that fits in 2^32. Values at or above the - // cutoff would skew the modulo, so we discard and resample. - let cutoff = (1u64 << 32) - ((1u64 << 32) % n64); - loop { - let v = u64::from(self.get_u32()?); - if v < cutoff { - return Ok(u32::try_from(v % n64).expect("v % n is bounded by n which fits in u32")); - } - } - } + os_random_unsigned_range_impls!( + (u8, gen_range_u8, gen_range_u8_in, gen_below_u8, get_u8), + (u16, gen_range_u16, gen_range_u16_in, gen_below_u16, get_u16), + (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), + ( + u128, + gen_range_u128, + gen_range_u128_in, + gen_below_u128, + get_u128 + ), + ( + usize, + gen_range_usize, + gen_range_usize_in, + 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`. ///