packages feed

scientist-0.0.0.0: library/Scientist/Experiment.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

module Scientist.Experiment
  ( Experiment

  -- * Construction
  , newExperiment

  -- * Modifiying values
  , setExperimentTry
  , setExperimentTryNamed
  , setExperimentEnabled
  , setExperimentOnException
  , setExperimentCompare
  , setExperimentContext
  , setExperimentIgnore
  , setExperimentRunIf
  , setExperimentPublish

  -- * Common modifying values
  , experimentCompareEq
  , experimentCompareOn
  , experimentCompareBy
  , experimentEnabledPercent

  -- * Accessing values
  , getExperimentName
  , getExperimentUse
  , getExperimentTries
  , getExperimentEnabled
  , getExperimentOnException
  , getExperimentCompare
  , getExperimentContext
  , getExperimentIgnore
  , getExperimentRunIf
  , getExperimentPublish
  ) where

import Prelude

import Control.Monad.IO.Class (MonadIO(..))
import Control.Monad.Random (evalRandIO, getRandomR)
import Data.Function (on)
import Data.List.NonEmpty
import Data.Maybe (fromMaybe)
import Data.Text (Text, pack)
import Scientist.Candidate
import Scientist.Control
import Scientist.NamedCandidate
import Scientist.Result
import UnliftIO.Exception (SomeException, throwIO)

data Experiment m c a b = Experiment
  { experimentName :: Text
  , experimentUse :: m (Control a)
  , experimentTries :: Maybe (NonEmpty (NamedCandidate m b))
  , experimentEnabled :: Maybe (m Bool)
  , experimentOnException :: Maybe (SomeException -> m ())
  , experimentCompare
      :: Maybe (Control a -> Either SomeException (Candidate b) -> Bool)
  , experimentContext :: Maybe c
  , experimentIgnore
      :: Maybe (Control a -> Either SomeException (Candidate b) -> Bool)
  , experimentRunIf :: Maybe Bool
  , experimentPublish :: Maybe (Result c a b -> m ())
  , experimentCandidateCount :: Int
  }

newExperiment :: Functor m => Text -> m a -> Experiment m c a b
newExperiment name f = Experiment
  { experimentName = name
  , experimentUse = Control <$> f
  , experimentTries = Nothing
  , experimentEnabled = Nothing
  , experimentOnException = Nothing
  , experimentCompare = Nothing
  , experimentContext = Nothing
  , experimentIgnore = Nothing
  , experimentRunIf = Nothing
  , experimentPublish = Nothing
  , experimentCandidateCount = 0
  }

-- | A new, candidate code path
--
-- If called multiple times, adds multiple candidate paths.
--
-- By default, there are no candidate paths and running the experiment will
-- return 'ResultSkipped'.
--
setExperimentTry
  :: Functor m => m b -> Experiment m c a b -> Experiment m c a b
setExperimentTry = setExperimentTryInternal Nothing

setExperimentTryNamed
  :: Functor m => Text -> m b -> Experiment m c a b -> Experiment m c a b
setExperimentTryNamed = setExperimentTryInternal . Just

setExperimentTryInternal
  :: Functor m => Maybe Text -> m b -> Experiment m c a b -> Experiment m c a b
setExperimentTryInternal mName f ex = ex
  { experimentTries = Just updated
  , experimentCandidateCount = updatedCount
  }
 where
  thisTry = pure $ namedCandidate thisName $ Candidate <$> f
  thisName = fromMaybe inferName mName
  inferName = case currentCount of
    0 -> "candidate"
    n -> pack $ "candidate-" <> show n

  current = experimentTries ex
  updated = maybe thisTry (<> thisTry) current

  currentCount = experimentCandidateCount ex
  updatedCount = currentCount + 1

-- | If the candidate paths should be executed
--
-- See 'experimentEnabledPercent' for an example.
--
-- By default, candidate paths are always run.
--
setExperimentEnabled :: m Bool -> Experiment m c a b -> Experiment m c a b
setExperimentEnabled f ex = ex { experimentEnabled = Just f }

-- | How to handle an exception evaluating or publishing
--
-- By default, the exception is re-thrown.
--
setExperimentOnException
  :: (SomeException -> m ()) -> Experiment m c a b -> Experiment m c a b
setExperimentOnException f ex = ex { experimentOnException = Just f }

-- | Decide if a given result is a match.
--
-- See 'experimentCompareEq' and 'experimentCompareBy'.
--
-- By default, all comparisons fail.
--
setExperimentCompare
  :: (Control a -> Either SomeException (Candidate b) -> Bool)
  -> Experiment m c a b
  -> Experiment m c a b
setExperimentCompare f ex = ex { experimentCompare = Just f }

-- | Decide if a given result should be ignored
--
-- By default, no results are ignored.
--
setExperimentIgnore
  :: (Control a -> Either SomeException (Candidate b) -> Bool)
  -> Experiment m c a b
  -> Experiment m c a b
setExperimentIgnore f ex = ex { experimentIgnore = Just f }

-- | Decide if the experiment should run at all
--
-- By default, experiments are run.
--
setExperimentRunIf :: Bool -> Experiment m c a b -> Experiment m c a b
setExperimentRunIf b ex = ex { experimentRunIf = Just b }

-- | How to publish results
--
-- By default, results are not published.
--
setExperimentPublish
  :: (Result c a b -> m ()) -> Experiment m c a b -> Experiment m c a b
setExperimentPublish f ex = ex { experimentPublish = Just f }

getExperimentName :: Experiment m c a b -> Text
getExperimentName = experimentName

getExperimentUse :: Experiment m c a b -> m (Control a)
getExperimentUse = experimentUse

getExperimentTries
  :: Experiment m c a b -> Maybe (NonEmpty (NamedCandidate m b))
getExperimentTries = experimentTries

getExperimentEnabled :: Applicative m => Experiment m c a b -> m Bool
getExperimentEnabled = fromMaybe (pure True) . experimentEnabled

getExperimentOnException
  :: MonadIO m => Experiment m c a b -> SomeException -> m ()
getExperimentOnException = fromMaybe throwIO . experimentOnException

getExperimentCompare
  :: Experiment m c a b
  -> (Control a -> Either SomeException (Candidate b) -> Bool)
getExperimentCompare = fromMaybe (\_ _ -> False) . experimentCompare

setExperimentContext :: c -> Experiment m c a b -> Experiment m c a b
setExperimentContext x ex = ex { experimentContext = Just x }

getExperimentContext :: Experiment m c a b -> Maybe c
getExperimentContext = experimentContext

getExperimentIgnore
  :: Experiment m c a b
  -> (Control a -> Either SomeException (Candidate b) -> Bool)
getExperimentIgnore = fromMaybe (\_ _ -> False) . experimentIgnore

getExperimentRunIf :: Experiment m c a b -> Bool
getExperimentRunIf = fromMaybe True . experimentRunIf

getExperimentPublish
  :: Applicative m => Experiment m c a b -> (Result c a b -> m ())
getExperimentPublish = fromMaybe (const $ pure ()) . experimentPublish

-- | Compare non-exception candidates with the control by '(==)'
--
-- Exception candidates fail comparison.
--
experimentCompareEq
  :: Eq a => Control a -> Either SomeException (Candidate a) -> Bool
experimentCompareEq = experimentCompareBy (==)

-- | Compare by equality on some function
--
-- Exception candidates fail comparison.
--
experimentCompareOn
  :: Eq b => (a -> b) -> Control a -> Either SomeException (Candidate a) -> Bool
experimentCompareOn f = experimentCompareBy ((==) `on` f)

-- | Compare by some function
--
-- Exception candidates fail comparison.
--
experimentCompareBy
  :: (a -> b -> Bool) -> Control a -> Either SomeException (Candidate b) -> Bool
experimentCompareBy f (Control a) = \case
  Left _ -> False
  Right (Candidate b) -> f a b

-- | Enable the experiment in the given percentage of runs
experimentEnabledPercent :: MonadIO m => Int -> m Bool
experimentEnabledPercent n
  | n <= 0 = pure False
  | n >= 100 = pure True
  | otherwise = liftIO $ evalRandIO $ (<= n) <$> getRandomR (0, 100)