packages feed

ppad-censor-0.5.1: lib/Censor/Rng.hs

{-# OPTIONS_HADDOCK prune #-}
{-# LANGUAGE BangPatterns #-}

-- |
-- Module: Censor.Rng
-- Copyright: (c) 2026 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- A minimal splitmix64 PRNG for input samplers.
--
-- Samplers feeding 'Censor.runCT' want a pseudorandom source that is
-- deterministic under a fixed seed, cheap, and light on allocation,
-- so that sampling work does not itself perturb the measurement (see
-- the measurement-hygiene notes in "Censor"). This module provides
-- exactly that and nothing more. It is /not/ a cryptographic
-- generator; the sampled inputs only need to be independent of the
-- target's timing distribution, not unpredictable.

module Censor.Rng (
    -- * Stateful generator
    Rng
  , mkRng
  , nextWord
  , reseed

    -- * Byte fills
  , randomBytes
  , fillRandom

    -- * Pure step
  , splitMix
  ) where

import qualified Data.Bits as B
import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BI
import Data.IORef
import Data.Word (Word8, Word64)
import Foreign.Ptr (Ptr)
import Foreign.Storable (pokeByteOff)

-- | splitmix64 state threaded through an 'IORef'.
newtype Rng = Rng (IORef Word64)

-- | Create a generator from a seed. Equal seeds yield equal streams.
mkRng :: Word64 -> IO Rng
mkRng s = Rng <$> newIORef s
{-# INLINE mkRng #-}

-- | Draw the next 'Word64' from the generator.
nextWord :: Rng -> IO Word64
nextWord (Rng ref) = atomicModifyIORef' ref splitMix
{-# INLINE nextWord #-}

-- | Reset a generator to a seed in place: after @reseed g s@ the
--   generator draws exactly the stream of a fresh @'mkRng' s@.
--
--   Lets one long-lived generator replay a pinned stream, or adopt
--   a fresh one, without allocating per use — so the two classes
--   of a fix-vs-random sampler can share a single generator object
--   and differ only in the seed value written into it.
reseed :: Rng -> Word64 -> IO ()
reseed (Rng ref) s = atomicWriteIORef ref s
{-# INLINE reseed #-}

-- | The pure splitmix64 step: from a state, produce the successor
--   state and an output word.
splitMix :: Word64 -> (Word64, Word64)
splitMix !s =
  let !s' = s + 0x9E3779B97F4A7C15
      !z0 = (s' `B.xor` (s' `B.shiftR` 30)) * 0xBF58476D1CE4E5B9
      !z1 = (z0 `B.xor` (z0 `B.shiftR` 27)) * 0x94D049BB133111EB
      !z2 =  z1 `B.xor` (z1 `B.shiftR` 31)
  in  (s', z2)
{-# INLINE splitMix #-}

-- | Draw @n@ pseudorandom bytes as a strict 'BS.ByteString'.
--
--   The result is a fresh, fully-materialised heap value -- no
--   thunks for the meter to pay inside the timed region -- so a
--   sampler can return it directly.
randomBytes :: Rng -> Int -> IO BS.ByteString
randomBytes !g !n
  | n <= 0    = pure BS.empty
  | otherwise = BI.create n $ \p -> fillRandom g p n

-- | Fill @n@ bytes at the pointer with pseudorandom bytes drawn
--   from the generator.
fillRandom :: Rng -> Ptr Word8 -> Int -> IO ()
fillRandom !g !dst !n = go 0
  where
    go !i
      | i >= n    = pure ()
      | otherwise = do
          !w <- nextWord g
          let !left  = n - i
              !chunk = if left > 8 then 8 else left
          writeWord dst i w chunk
          go (i + chunk)

-- write the low @lim@ bytes of a 'Word64', little-endian, at the
-- given offset.
writeWord :: Ptr Word8 -> Int -> Word64 -> Int -> IO ()
writeWord !dst !off !w !lim = go 0
  where
    go !j
      | j >= lim  = pure ()
      | otherwise = do
          let !byte = fromIntegral (w `B.shiftR` (8 * j)) :: Word8
          pokeByteOff dst (off + j) byte
          go (j + 1)