Skip to content

fix(random)!: replace rejection sampling with fixed-count Lemire draw - #281

Open
coderdan wants to merge 4 commits into
mainfrom
fix/198-lemire-bounded-draw
Open

fix(random)!: replace rejection sampling with fixed-count Lemire draw#281
coderdan wants to merge 4 commits into
mainfrom
fix/198-lemire-bounded-draw

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Closes #198.

What

Replaces the rejection-sampling bounded draw in vitaminc-random with a single fixed-count Lemire multiply-high reduction, returning a half-open [0, range) value. One implementation now serves both SafeRand::next_bounded_u32 and BoundedRng::next_bounded (the duplicate in bounded.rs is folded in).

Fixes all three problems from #198:

  1. Timing side-channel — the rejection loop's iteration count depended on RNG output, so PermutationKey derivation time was weakly dependent on secret seed material. Now exactly one 64-bit draw per call, branch-free.
  2. Power-of-two bias — the power-of-two branch returned [0, max) while the contract (and rejection branch) was inclusive [0, max], so Fisher–Yates steps at power-of-two indices silently drew from the wrong range, biasing the permutation. Half-open semantics + i + 1 at the call site fix this by construction.
  3. Password OOB panic — the inclusive bound let next_bounded_u32(94) return 94, indexing past STANDARD_CHARS (p ≈ 1/95 per character). Half-open draws stay in-bounds; the sub-alphabet skew for alphanumeric/alpha passwords is gone too.

Also fixes STANDARD_CHARS containing a duplicate 'e' and no 'd' (spotted during review of the call sites).

Breaking change

  • next_bounded_u32(range) / next_bounded(range) are now half-open and consume one u64 draw instead of one-or-more u32 draws.
  • Seeded derivation changes: anything deriving deterministic output from a seeded SafeRand through bounded draws (PermutationKey::from_seed, from_controlled_seed) produces different results than previous releases. The raw next_u32/next_u64/fill_bytes stream is unchanged. This is unavoidable — preserving the old sequence means preserving the bias and the panic. Note Constant-time oblivious permutation generation: sort-by-random-key over a Batcher network #280 (stacked next) changes key derivation again; both should land before the next release so seed-derived keys break once.

Tests

  • Deterministic boundary draws (0, midpoint, max draw; range 1; zero-range panic)
  • Half-open contract across ranges {1, 2, 4, 5, 52, 62, 64, 94, u32::MAX}
  • Chi-squared uniformity of the draw (fixed seed, deterministic)
  • Fisher–Yates position-matrix chi-squared test (would have caught the power-of-two bias)
  • Password generation regression (panics with overwhelming probability under old inclusive bound)
  • README doctest expectations updated for the new seeded outputs

https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm

next_bounded_u32 drew bounded integers with a rejection loop whose
iteration count depended on RNG output. With a secret-seeded SafeRand
(PermutationKey key derivation), that made derivation time weakly
dependent on secret material — the same timing channel removed from the
ore.rs PRP. The power-of-two branch also broke the documented inclusive
contract, silently biasing Fisher–Yates permutations, and the inclusive
upper bound made password generation index one past the character table
(reachable panic, p ≈ 1/95 per character).

A single multiply-high (Lemire) reduction over one 64-bit draw fixes all
three: exactly one draw per call (timing independent of values drawn),
branch-free, and half-open [0, range) semantics so callers can no longer
be off by one. The duplicate rejection implementation in bounded.rs is
folded into the same helper. Call sites updated: Fisher–Yates passes
i + 1; password generators now stay in-bounds by construction. Also
fixes STANDARD_CHARS containing a duplicate 'e' and no 'd'.

Tests: deterministic boundary draws, half-open contract across ranges,
chi-squared uniformity for the draw itself, a Fisher–Yates position-
matrix chi-squared test that catches the power-of-two bias, and a
password-generation regression that panics with overwhelming
probability under the old inclusive bound.

BREAKING CHANGE: next_bounded_u32 and BoundedRng::next_bounded now
return values in the half-open range [0, range) instead of the
inclusive [0, max], and consume one u64 draw instead of one-or-more u32
draws. Deterministic outputs derived from a seeded SafeRand through
bounded draws (e.g. PermutationKey::from_seed) differ from previous
releases; the raw next_u32/next_u64/fill_bytes stream is unchanged.

Closes #198

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

🧬 Mutation testing (cargo-mutants, --in-diff)

caught missed unviable timeout
19 0 4 0

✅ Every mutant in the changed lines was caught by a test.

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

✅ No CRAP threshold violations

395 function(s) analyzed · threshold 30

The mutants gate flagged the Protected<u32> impl as untested: replacing
its body with a constant survived every test. Compare it draw-for-draw
against the plain u32 impl from an identically-seeded RNG.

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
Code review of the Lemire change surfaced three loose ends:

- The crate README (compiled into docs.rs via include_str!) still
  documented the old inclusive contract with `assert!(value <= 10)` —
  and because <= is weaker than <, the doctest kept passing. Document
  the half-open contract and tighten the assertions.
- BoundedRng<usize> was left unimplemented!(): the most natural call
  for indexing (`rng.next_bounded(items.len())`) type-checked and then
  panicked at runtime. Implement it via a shared u64-width Lemire
  reduction (usize is at most 64 bits on all supported targets), with
  tests pinning it to the u32 impl and the half-open contract.
- The migration note (inclusive -> half-open, new panic on zero range)
  lived only on SafeRand::next_bounded_u32; consumers migrating through
  the public BoundedRng trait never saw it. Document the contract,
  migration, and panic on the trait method itself.

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
The statistical-distance term (<= range / 2^64) was documented only on
the pub(crate) helper. Protocol authors reading docs.rs need it to
decide whether the reduction's deviation from uniform matters at their
range, so state it on SafeRand::next_bounded_u32 and the BoundedRng
trait method.

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
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.

next_bounded_u32: replace rejection sampling with fixed-count Lemire draw (timing channel + biased permutation + password OOB panic)

1 participant