packages feed

freckle-app-1.25.0.1: library/Freckle/App/Random.hs

{-# LANGUAGE NamedFieldPuns #-}

module Freckle.App.Random
  ( smallRandomSubsetOfLargeIntegerRange
  , Range (..)
  , NonEmptyRange (..)
  , inclusiveRange
  ) where

import Freckle.App.Prelude

import Control.Monad.Random (MonadRandom (..), Random)
import Control.Monad.ST (runST)
import Control.Monad.State.Strict (execStateT, get, put)
import Data.Functor ((<&>))
import Data.STRef (newSTRef, readSTRef, writeSTRef)

import Data.Set qualified as Set

-- | A possibly-empty contiguous range of integers
data Range i
  = RangeEmpty
  | RangeNonEmpty (NonEmptyRange i)

-- | A nonempty contiguous range of integers
data NonEmptyRange i = NonEmptyRange
  { inclusiveMinBound :: i
  , offset :: Natural
  -- ^ The size of the range minus one
  }

inclusiveRange
  :: Integral i
  => i
  -- ^ Lower bound, inclusive
  -> i
  -- ^ Upper bound, inclusive
  -> Range i
inclusiveRange a b =
  if a <= b
    then RangeNonEmpty (NonEmptyRange a (fromIntegral (b - a)))
    else RangeEmpty

-- | Select a fixed number of items uniformly at random
--   from a contiguous range of integers
--
-- This process accommodates selecting from a large range, but only has
-- reasonable performance when the number of items being selected is small
-- (it is quadratic in the number of items).
--
-- If the requested size is greater than or equal to the range, the entire
-- range is returned.
--
-- e.g. @smallRandomSubsetOfLargeIntegerRange 10 (inclusiveRange 30 70)@
-- may produce something like @fromList [32,34,45,54,56,58,62,63,64,65]@.
smallRandomSubsetOfLargeIntegerRange
  :: (MonadRandom m, Random i, Integral i)
  => Natural
  -- ^ How many items are wanted
  -> Range i
  -> m (Set i)
smallRandomSubsetOfLargeIntegerRange n = \case
  RangeEmpty -> pure Set.empty
  RangeNonEmpty r ->
    fmap gaps $
      flip execStateT (RangeWithGaps r Set.empty) $
        for_ [1 .. n] $ \_ -> do
          get >>= (lift . randomlyRemove) >>= put

data RangeWithGaps i = RangeWithGaps
  { rangeWithoutGaps :: NonEmptyRange i
  , gaps :: Set i
  -- ^ The set of items that has been removed from the larger range
  }

-- | Randomly remove an item from a 'RangeWithGaps'.
--
-- This selects uniformly at random an item from a 'RangeWithGaps' and
-- removes it (adds it to the 'gaps' set).
--
-- If every item in the range has already been removed, this does nothing.
randomlyRemove
  :: (MonadRandom m, Random i, Integral i) => RangeWithGaps i -> m (RangeWithGaps i)
randomlyRemove rg =
  randomFromRangeWithGaps rg <&> \case
    Nothing -> rg
    Just i -> rg {gaps = Set.insert i (gaps rg)}

-- | Randomly select an item from a 'RangeWithGaps'
--
-- This selects uniformly at random an item from the range that is not
-- present in the 'gaps' set.
randomFromRangeWithGaps
  :: (MonadRandom m, Random i, Integral i)
  => RangeWithGaps i
  -> m (Maybe i)
randomFromRangeWithGaps rg =
  let
    RangeWithGaps {rangeWithoutGaps, gaps} = rg
    NonEmptyRange {inclusiveMinBound, offset} = rangeWithoutGaps
  in
    if fromIntegral (Set.size gaps) == offset + 1
      then pure Nothing
      else
        Just
          <$> do
            r <-
              (inclusiveMinBound +)
                <$> getRandomR (0, fromIntegral offset - fromIntegral (Set.size gaps))
            pure $ runST $ do
              xRef <- newSTRef r
              gapQueue <- newSTRef $ Set.toAscList gaps
              let go = do
                    x <- readSTRef xRef
                    readSTRef gapQueue >>= \case
                      g : gs | g <= x -> do
                        writeSTRef xRef (x + 1)
                        writeSTRef gapQueue gs
                        go
                      _ -> pure x
              go