ppad-eproc 0.1.0 → 0.2.0
raw patch · 9 files changed
+697/−355 lines, 9 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
+ Numeric.Eproc.Bernoulli: InvalidAlpha :: {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Bernoulli: InvalidBaselineRate :: {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Bernoulli: InvalidBounds :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Bernoulli: InvalidNullMean :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Bernoulli: data ConfigError
+ Numeric.Eproc.Bounded: InvalidAlpha :: {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Bounded: InvalidBaselineRate :: {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Bounded: InvalidBounds :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Bounded: InvalidNullMean :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Bounded: data ConfigError
+ Numeric.Eproc.Common: InvalidAlpha :: {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Common: InvalidBaselineRate :: {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Common: InvalidBounds :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Common: InvalidNullMean :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Common: SAdaptive :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> {-# UNPACK #-} !Int -> BetState
+ Numeric.Eproc.Common: SFixed :: BetState
+ Numeric.Eproc.Common: SNewton :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> BetState
+ Numeric.Eproc.Common: bet_lambda :: Bettor -> Double -> BetState -> Double
+ Numeric.Eproc.Common: data BetState
+ Numeric.Eproc.Common: data ConfigError
+ Numeric.Eproc.Common: finite :: Double -> Bool
+ Numeric.Eproc.Common: init_bet :: Bettor -> BetState
+ Numeric.Eproc.Common: instance GHC.Classes.Eq Numeric.Eproc.Common.ConfigError
+ Numeric.Eproc.Common: instance GHC.Show.Show Numeric.Eproc.Common.ConfigError
+ Numeric.Eproc.Common: step_bet :: Bettor -> Double -> BetState -> Double -> BetState
+ Numeric.Eproc.Paired: InvalidAlpha :: {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Paired: InvalidBaselineRate :: {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Paired: InvalidBounds :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Paired: InvalidNullMean :: {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> {-# UNPACK #-} !Double -> ConfigError
+ Numeric.Eproc.Paired: data ConfigError
- Numeric.Eproc.Bernoulli: config :: Double -> Double -> Bettor -> Config
+ Numeric.Eproc.Bernoulli: config :: Double -> Double -> Bettor -> Either ConfigError Config
- Numeric.Eproc.Bounded: config :: Double -> Double -> Double -> Double -> Bettor -> Config
+ Numeric.Eproc.Bounded: config :: Double -> Double -> Double -> Double -> Bettor -> Either ConfigError Config
- Numeric.Eproc.Paired: config :: Double -> Double -> Double -> Bettor -> Config
+ Numeric.Eproc.Paired: config :: Double -> Double -> Double -> Bettor -> Either ConfigError Config
Files
- CHANGELOG +12/−0
- bench/Main.hs +54/−17
- bench/Weight.hs +51/−17
- lib/Numeric/Eproc/Bernoulli.hs +80/−116
- lib/Numeric/Eproc/Bounded.hs +104/−142
- lib/Numeric/Eproc/Common.hs +143/−19
- lib/Numeric/Eproc/Paired.hs +32/−15
- ppad-eproc.cabal +1/−1
- test/Main.hs +220/−28
CHANGELOG view
@@ -1,5 +1,17 @@ # Changelog +- 0.2.0 (2026-06-28)+ * Introduces several breaking API changes, along with various internal+ refinements:++ * config functions now return 'Either ConfigError Config' and reject invalid+ parameters.++ * the 'Bernoulli.config' argument order changed from (a, p, b) to (p, a, b),+ for consistency with other modules.++ * log_wealth now returns the supremum, not the current value.+ - 0.1.0 (2026-06-03) * Initial release, supporting anytime-valid sequential testing via e-processes: bounded-mean, Bernoulli, and paired two-sample tests,
bench/Main.hs view
@@ -4,6 +4,7 @@ module Main where import Control.DeepSeq+import qualified Numeric.Eproc.Bernoulli as Bern import qualified Numeric.Eproc.Bounded as Bounded import qualified Numeric.Eproc.Paired as P import Criterion.Main@@ -12,35 +13,44 @@ -- WHNF == NF for these types. orphan instances keep the library API -- untouched. instance NFData Bounded.State where rnf !_ = ()-instance NFData P.State where rnf !_ = ()+instance NFData P.State where rnf !_ = ()+instance NFData Bern.State where rnf !_ = () instance NFData Bounded.Verdict where rnf !_ = () +-- partial helper for benches: configs here are hardcoded valid, so a+-- 'Left' would be a bench-suite bug.+ok :: Either e a -> a+ok (Right x) = x+ok (Left _) = error "bench: invalid config"+ main :: IO () main = defaultMain [ update , decide , stream , twosample+ , bern_update+ , bern_stream ] update :: Benchmark update =- let !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5)- !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive- !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton+ let !cfg_f = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5))+ !cfg_a = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive)+ !cfg_o = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton) !st_f = Bounded.initial cfg_f !st_a = Bounded.initial cfg_a !st_o = Bounded.initial cfg_o !x = 0.7 in bgroup "Bounded.update (one step)" [- bench "fixed" $ nf (Bounded.update cfg_f st_f) x+ bench "fixed" $ nf (Bounded.update cfg_f st_f) x , bench "adaptive" $ nf (Bounded.update cfg_a st_a) x- , bench "newton" $ nf (Bounded.update cfg_o st_o) x+ , bench "newton" $ nf (Bounded.update cfg_o st_o) x ] decide :: Benchmark decide =- let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton+ let !cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton) !st = Bounded.initial cfg in bgroup "Bounded.decide" [ bench "initial state" $ nf (Bounded.decide cfg) st@@ -49,25 +59,52 @@ stream :: Benchmark stream = let !xs = force (take 1000 (cycle [0.3, 0.7]))- !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5)- !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive- !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton+ !cfg_f = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5))+ !cfg_a = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive)+ !cfg_o = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton) run_m cfg = foldl' (Bounded.update cfg) (Bounded.initial cfg) in bgroup "Bounded.update (1000-sample fold)" [- bench "fixed" $ nf (run_m cfg_f) xs+ bench "fixed" $ nf (run_m cfg_f) xs , bench "adaptive" $ nf (run_m cfg_a) xs- , bench "newton" $ nf (run_m cfg_o) xs+ , bench "newton" $ nf (run_m cfg_o) xs ] twosample :: Benchmark twosample = let !ps = force (take 1000 (cycle [(0.3, 0.7), (0.7, 0.3)]))- !cfg_f = P.config 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5)- !cfg_a = P.config 0.0 1.0 1.0e-3 Bounded.Adaptive- !cfg_o = P.config 0.0 1.0 1.0e-3 Bounded.Newton+ !cfg_f = ok (P.config 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5))+ !cfg_a = ok (P.config 0.0 1.0 1.0e-3 Bounded.Adaptive)+ !cfg_o = ok (P.config 0.0 1.0 1.0e-3 Bounded.Newton) run_t cfg = foldl' (P.update cfg) (P.initial cfg) in bgroup "Paired.update (1000-sample fold)" [- bench "fixed" $ nf (run_t cfg_f) ps+ bench "fixed" $ nf (run_t cfg_f) ps , bench "adaptive" $ nf (run_t cfg_a) ps- , bench "newton" $ nf (run_t cfg_o) ps+ , bench "newton" $ nf (run_t cfg_o) ps+ ]++bern_update :: Benchmark+bern_update =+ let !cfg_f = ok (Bern.config 0.05 1.0e-3 (Bern.Fixed 5.0))+ !cfg_a = ok (Bern.config 0.05 1.0e-3 Bern.Adaptive)+ !cfg_o = ok (Bern.config 0.05 1.0e-3 Bern.Newton)+ !st_f = Bern.initial cfg_f+ !st_a = Bern.initial cfg_a+ !st_o = Bern.initial cfg_o+ in bgroup "Bernoulli.update (one step)" [+ bench "fixed" $ nf (Bern.update cfg_f st_f) True+ , bench "adaptive" $ nf (Bern.update cfg_a st_a) True+ , bench "newton" $ nf (Bern.update cfg_o st_o) True+ ]++bern_stream :: Benchmark+bern_stream =+ let !xs = force (take 1000 (cycle [True, False]))+ !cfg_f = ok (Bern.config 0.05 1.0e-3 (Bern.Fixed 5.0))+ !cfg_a = ok (Bern.config 0.05 1.0e-3 Bern.Adaptive)+ !cfg_o = ok (Bern.config 0.05 1.0e-3 Bern.Newton)+ run_b cfg = foldl' (Bern.update cfg) (Bern.initial cfg)+ in bgroup "Bernoulli.update (1000-sample fold)" [+ bench "fixed" $ nf (run_b cfg_f) xs+ , bench "adaptive" $ nf (run_b cfg_a) xs+ , bench "newton" $ nf (run_b cfg_o) xs ]
bench/Weight.hs view
@@ -4,14 +4,21 @@ module Main where import Control.DeepSeq+import qualified Numeric.Eproc.Bernoulli as Bern import qualified Numeric.Eproc.Bounded as Bounded import qualified Numeric.Eproc.Paired as P import Weigh instance NFData Bounded.State where rnf !_ = ()-instance NFData P.State where rnf !_ = ()+instance NFData P.State where rnf !_ = ()+instance NFData Bern.State where rnf !_ = () instance NFData Bounded.Verdict where rnf !_ = () +-- partial helper for benches: configs here are hardcoded valid.+ok :: Either e a -> a+ok (Right x) = x+ok (Left _) = error "weigh: invalid config"+ -- note that 'weigh' doesn't work properly in a repl main :: IO () main = mainWith $ do@@ -19,23 +26,25 @@ decide stream twosample+ bern_update+ bern_stream update :: Weigh () update =- let !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5)- !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive- !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton+ let !cfg_f = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5))+ !cfg_a = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive)+ !cfg_o = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton) !st_f = Bounded.initial cfg_f !st_a = Bounded.initial cfg_a !st_o = Bounded.initial cfg_o in wgroup "Bounded.update (one step)" $ do- func "fixed" (Bounded.update cfg_f st_f) 0.7+ func "fixed" (Bounded.update cfg_f st_f) 0.7 func "adaptive" (Bounded.update cfg_a st_a) 0.7- func "newton" (Bounded.update cfg_o st_o) 0.7+ func "newton" (Bounded.update cfg_o st_o) 0.7 decide :: Weigh () decide =- let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton+ let !cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton) !st = Bounded.initial cfg in wgroup "Bounded.decide" $ do func "initial state" (Bounded.decide cfg) st@@ -43,23 +52,48 @@ stream :: Weigh () stream = let !xs = force (take 1000 (cycle [0.3, 0.7]))- !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5)- !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive- !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton+ !cfg_f = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5))+ !cfg_a = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive)+ !cfg_o = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton) run_m cfg = foldl' (Bounded.update cfg) (Bounded.initial cfg) in wgroup "Bounded.update (1000-sample fold)" $ do- func "fixed" (run_m cfg_f) xs+ func "fixed" (run_m cfg_f) xs func "adaptive" (run_m cfg_a) xs- func "newton" (run_m cfg_o) xs+ func "newton" (run_m cfg_o) xs twosample :: Weigh () twosample = let !ps = force (take 1000 (cycle [(0.3, 0.7), (0.7, 0.3)]))- !cfg_f = P.config 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5)- !cfg_a = P.config 0.0 1.0 1.0e-3 Bounded.Adaptive- !cfg_o = P.config 0.0 1.0 1.0e-3 Bounded.Newton+ !cfg_f = ok (P.config 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5))+ !cfg_a = ok (P.config 0.0 1.0 1.0e-3 Bounded.Adaptive)+ !cfg_o = ok (P.config 0.0 1.0 1.0e-3 Bounded.Newton) run_t cfg = foldl' (P.update cfg) (P.initial cfg) in wgroup "Paired.update (1000-sample fold)" $ do- func "fixed" (run_t cfg_f) ps+ func "fixed" (run_t cfg_f) ps func "adaptive" (run_t cfg_a) ps- func "newton" (run_t cfg_o) ps+ func "newton" (run_t cfg_o) ps++bern_update :: Weigh ()+bern_update =+ let !cfg_f = ok (Bern.config 0.05 1.0e-3 (Bern.Fixed 5.0))+ !cfg_a = ok (Bern.config 0.05 1.0e-3 Bern.Adaptive)+ !cfg_o = ok (Bern.config 0.05 1.0e-3 Bern.Newton)+ !st_f = Bern.initial cfg_f+ !st_a = Bern.initial cfg_a+ !st_o = Bern.initial cfg_o+ in wgroup "Bernoulli.update (one step)" $ do+ func "fixed" (Bern.update cfg_f st_f) True+ func "adaptive" (Bern.update cfg_a st_a) True+ func "newton" (Bern.update cfg_o st_o) True++bern_stream :: Weigh ()+bern_stream =+ let !xs = force (take 1000 (cycle [True, False]))+ !cfg_f = ok (Bern.config 0.05 1.0e-3 (Bern.Fixed 5.0))+ !cfg_a = ok (Bern.config 0.05 1.0e-3 Bern.Adaptive)+ !cfg_o = ok (Bern.config 0.05 1.0e-3 Bern.Newton)+ run_b cfg = foldl' (Bern.update cfg) (Bern.initial cfg)+ in wgroup "Bernoulli.update (1000-sample fold)" $ do+ func "fixed" (run_b cfg_f) xs+ func "adaptive" (run_b cfg_a) xs+ func "newton" (run_b cfg_o) xs
lib/Numeric/Eproc/Bernoulli.hs view
@@ -10,9 +10,16 @@ -- -- One-sided Bernoulli rate anytime-valid test. ----- For samples @x_t@ in @{0, 1}@, tests @H_0: E[x] <= p_0@ against--- @H_1: E[x] > p_0@.+-- For samples @x_t@ in @{0, 1}@, tests --+-- @H_0: E[x_t | F_{t-1}] <= p_0 for all t@+--+-- against @H_1: E[x_t | F_{t-1}] > p_0@ (at some @t@). Here+-- @F_{t-1}@ is the filtration generated by everything observed+-- strictly before time @t@; the conditional form is what anytime+-- validity actually requires. For i.i.d. samples this reduces to+-- the usual marginal statement @E[x] <= p_0@.+-- -- A single wealth process is run: -- -- @W_n = prod_{i=1..n} (1 + lambda_i * (x_i - p_0))@@@ -24,7 +31,10 @@ -- a nonnegative supermartingale, so by Ville's inequality the -- probability of @W_n@ ever crossing @1 \/ alpha@ is at most -- @alpha@, regardless of when the user decides to stop streaming--- samples.+-- samples. Rejection is /latched/ in the running state: once the+-- wealth has crossed threshold, 'decide' continues to return+-- 'Reject' even if subsequent observations drive the current+-- wealth back below threshold. -- -- Unlike "Numeric.Eproc.Bounded", the alternative here is one-sided, -- so a single wealth process suffices and no Bonferroni adjustment@@ -35,7 +45,7 @@ -- Test @H_0: E[x] <= 0.05@ at level @alpha = 1e-3@ against a stream -- with empirical rate @~0.5@: ----- >>> let cfg = config 1.0e-3 0.05 Newton+-- >>> let Right cfg = config 0.05 1.0e-3 Newton -- >>> let xs = take 200 (cycle [True, False]) -- >>> decide cfg (foldl' (update cfg) (initial cfg) xs) -- Reject@@ -45,6 +55,7 @@ Config , State , Verdict(..)+ , ConfigError(..) -- * Bettor strategies , Bettor(..)@@ -62,7 +73,11 @@ , samples ) where -import Numeric.Eproc.Common (Bettor(..), Verdict(..))+import Numeric.Eproc.Common (+ Bettor(..), Verdict(..), ConfigError(..)+ , BetState, init_bet, bet_lambda, step_bet+ , finite+ ) -- types ---------------------------------------------------------------------- @@ -70,19 +85,6 @@ -- "Numeric.Eproc.Common" is @x_t - p_0@; the safe-bet ceiling -- @lambda_max@ is derived from @p_0@ (see 'config'). --- bettor state. one constructor per 'Bettor' alternative; the--- constructor used in a given 'State' matches the 'Bettor' chosen in--- the enclosing 'Config'.-data BetState =- SFixed- | SAdaptive- {-# UNPACK #-} !Double -- sum of z (centred observation)- {-# UNPACK #-} !Double -- sum of z^2 (for online variance)- {-# UNPACK #-} !Int -- count- | SNewton- {-# UNPACK #-} !Double -- current bet lambda- {-# UNPACK #-} !Double -- running sum of per-step squared gradients- -- | Bernoulli rate test configuration. Build with 'config'. -- -- Carries the bettor strategy, the baseline rate, the significance@@ -104,69 +106,18 @@ -- | Streaming test state. Construct with 'initial' and fold -- observations through 'update'. ----- Carries the sample count, running log-wealth, and whatever--- per-step state the chosen 'Bettor' needs.+-- Carries the sample count, current and supremum-so-far running+-- log-wealth, and whatever per-step state the chosen 'Bettor'+-- needs. The supremum field is what 'decide' tests against the+-- rejection threshold; this is the supremum-style event Ville's+-- inequality actually bounds. data State = State {- st_n :: {-# UNPACK #-} !Int -- ^ sample count- , st_log_w :: {-# UNPACK #-} !Double -- ^ running log-wealth- , st_bet :: !BetState -- ^ bettor state+ st_n :: {-# UNPACK #-} !Int -- ^ sample count+ , st_log_w :: {-# UNPACK #-} !Double -- ^ running log-wealth+ , st_max_log_w :: {-# UNPACK #-} !Double -- ^ sup log-wealth so far+ , st_bet :: !BetState -- ^ bettor state } --- internal ----------------------------------------------------------------------- per-bettor initial state.-init_bet :: Bettor -> BetState-init_bet b = case b of- Fixed _ -> SFixed- Adaptive -> SAdaptive 0 0 0- Newton -> SNewton 0 1.0e-6 -- small acc seed avoids div-by-zero-{-# INLINE init_bet #-}---- compute the next bet 'lambda' from the bettor and its current--- state. for Adaptive we form a Kelly-style plug-in from the running--- sample mean and variance; for Newton the bet is just the last--- lambda chosen by the Newton step (updated during 'step_bet').-bet_lambda :: Bettor -> Double -> BetState -> Double-bet_lambda b !lam_max !s = case b of- Fixed lam -> lam- Adaptive -> case s of- SAdaptive !sm !sm2 !n- | n == 0 -> 0- | otherwise ->- let !nd = fromIntegral n- !mu = sm / nd- !mu2 = mu * mu- !var = max 0 (sm2 / nd - mu2)- !den = var + mu2- !raw = if den == 0 then 0 else mu / den- in max 0 (min lam_max raw)- _ -> 0- Newton -> case s of- SNewton !lam _ -> lam- _ -> 0-{-# INLINE bet_lambda #-}---- update bettor state with newly observed centred value 'z'. for--- Adaptive this is just accumulating sums; for Newton we take one--- Newton step on the per-step log-wealth loss '-log(1 + lambda * z)',--- accumulating squared gradients for adaptive scaling.-step_bet :: Bettor -> Double -> BetState -> Double -> BetState-step_bet b !lam_max !s !z = case b of- Fixed _ -> SFixed- Adaptive -> case s of- SAdaptive !sm !sm2 !n -> SAdaptive (sm + z) (sm2 + z * z) (n + 1)- _ -> SAdaptive z (z * z) 1- Newton -> case s of- SNewton !lam !acc ->- let !denom = 1 + lam * z- !g = if denom == 0 then 0 else negate z / denom- !acc' = acc + g * g- !lam' = lam - g / acc'- !clp = max 0 (min lam_max lam')- in SNewton clp acc'- _ -> SNewton 0 1.0e-6-{-# INLINE step_bet #-}- -- construction --------------------------------------------------------------- -- | Build a 'Config' for the Bernoulli rate test.@@ -177,39 +128,46 @@ -- requires @lambda <= 1 \/ p_0@; the ceiling stored is half this -- to leave numerical margin -- the WSR safety recommendation. ----- @p_0@ must lie strictly in @(0, 1)@ and @alpha@ strictly in--- @(0, 1)@. The degenerate case @p_0 = 0@ would make @lambda_max@--- infinite (any divergence would reject immediately and the test--- becomes uninteresting); the caller is expected to pass a small--- positive baseline.+-- Returns 'Left' with a 'ConfigError' on inputs that would leave+-- the mathematical regime: either of @p_0@ or @alpha@ non-finite+-- (NaN or infinite); @p_0@ outside @(0, 1)@ (the degenerate case+-- @p_0 = 0@ would make @lambda_max@ infinite, and @p_0 = 1@+-- leaves no room for an alternative); or @alpha@ outside+-- @(0, 1)@. ----- >>> let cfg = config 1.0e-3 0.05 Newton+-- >>> let Right cfg = config 0.05 1.0e-3 Newton config- :: Double -- ^ significance level @alpha@, in @(0, 1)@- -> Double -- ^ baseline rate @p_0@, in @(0, 1)@+ :: Double -- ^ baseline rate @p_0@, in @(0, 1)@+ -> Double -- ^ significance level @alpha@, in @(0, 1)@ -> Bettor -- ^ bettor strategy- -> Config-config !alpha !p0 !b = Config {- cfg_bettor = b- , cfg_lam_max = 0.5 / p0- , cfg_p0 = p0- , cfg_alpha = alpha- , cfg_log_thresh = log (1 / alpha)- }+ -> Either ConfigError Config+config !p0 !alpha !b+ | not (finite p0 && p0 > 0 && p0 < 1) =+ Left (InvalidBaselineRate p0)+ | not (finite alpha && alpha > 0 && alpha < 1) =+ Left (InvalidAlpha alpha)+ | otherwise = Right Config {+ cfg_bettor = b+ , cfg_lam_max = 0.5 / p0+ , cfg_p0 = p0+ , cfg_alpha = alpha+ , cfg_log_thresh = log (1 / alpha)+ } {-# INLINE config #-} -- | The initial 'State' for a fresh streaming test. ----- Log-wealth starts at @0@ (i.e., wealth @1@) and the bettor--- starts in the per-strategy initial state appropriate for the--- 'Bettor' chosen in the 'Config'.+-- Both log-wealth fields start at @0@ (i.e., wealth @1@) and the+-- bettor starts in the per-strategy initial state appropriate+-- for the 'Bettor' chosen in the 'Config'. -- -- >>> let s0 = initial cfg initial :: Config -> State initial Config{..} = State {- st_n = 0- , st_log_w = 0- , st_bet = init_bet cfg_bettor+ st_n = 0+ , st_log_w = 0+ , st_max_log_w = 0+ , st_bet = init_bet cfg_bettor } {-# INLINE initial #-} @@ -227,8 +185,13 @@ -- -- @log_w' = log_w + log (1 + lambda * z)@ ----- and then steps the bettor state given the newly observed @z@.+-- updates the running supremum log-wealth, then steps the bettor+-- state given the newly observed @z@. --+-- /Precondition/: @True@ and @False@ both /must/ be admissible+-- under the test (this holds vacuously for the @{0, 1}@ support).+-- The function is total.+-- -- >>> let s1 = update cfg s0 True update :: Config -> State -> Bool -> State update Config{..} State{..} !x =@@ -237,41 +200,42 @@ !lam = bet_lambda cfg_bettor cfg_lam_max st_bet !fac = 1 + lam * z !logw' = st_log_w + log fac+ !maxw' = max st_max_log_w logw' !s' = step_bet cfg_bettor cfg_lam_max st_bet z- in State (st_n + 1) logw' s'+ in State (st_n + 1) logw' maxw' s' {-# INLINE update #-} -- | Compute the current 'Verdict' from the running 'State'. ----- 'Reject' iff log-wealth has crossed the threshold+-- 'Reject' iff log-wealth has /ever/ crossed the threshold -- @log(1 \/ alpha)@; equivalently, wealth has exceeded--- @1 \/ alpha@. Under @H_0@, by Ville's inequality, the--- probability of this ever happening is at most @alpha@ -- and--- crucially this bound holds at /every/ sample size--- simultaneously, so the user is free to peek at the verdict as--- often as they like and stop on the first 'Reject'.+-- @1 \/ alpha@ at some point in the stream so far. Under @H_0@,+-- by Ville's inequality, the probability of this ever happening+-- is at most @alpha@ -- and crucially this bound holds at /every/+-- sample size simultaneously, so the user is free to peek at the+-- verdict as often as they like and stop on the first 'Reject'. -- -- >>> decide cfg s0 -- Continue decide :: Config -> State -> Verdict decide Config{..} State{..}- | st_log_w >= cfg_log_thresh = Reject- | otherwise = Continue+ | st_max_log_w >= cfg_log_thresh = Reject+ | otherwise = Continue {-# INLINE decide #-} -- inspection ----------------------------------------------------------------- --- | The current log-wealth.+-- | The supremum-so-far log-wealth, across all sample counts up to+-- the current one. ----- This is the natural \"test statistic\": it is monotone (in--- expectation under @H_1@) in the evidence against @H_0@--- accumulated so far, and the test rejects exactly when it crosses--- @log(1 \/ alpha)@.+-- This is the natural \"test statistic\": it is monotone+-- nondecreasing in the sample count, and 'decide' rejects exactly+-- when it crosses @log(1 \/ alpha)@. -- -- >>> log_wealth s0 -- 0.0 log_wealth :: State -> Double-log_wealth = st_log_w+log_wealth = st_max_log_w {-# INLINE log_wealth #-} -- | The number of samples consumed so far.
lib/Numeric/Eproc/Bounded.hs view
@@ -1,6 +1,5 @@ {-# OPTIONS_HADDOCK prune #-} {-# LANGUAGE BangPatterns #-}-{-# LANGUAGE MagicHash #-} {-# LANGUAGE RecordWildCards #-} -- |@@ -11,28 +10,41 @@ -- -- Two-sided bounded-mean anytime-valid test. ----- For samples @x_t@ in @[lo, hi]@, tests @H_0: E[x] = m@ against--- @H_1: E[x] /= m@.+-- For samples @x_t@ in @[lo, hi]@, tests --+-- @H_0: E[x_t | F_{t-1}] = m for all t@+--+-- against the negation. Here @F_{t-1}@ is the filtration generated+-- by everything observed strictly before time @t@; the conditional+-- form is what anytime validity actually requires. For i.i.d.+-- samples this reduces to the usual marginal statement+-- @E[x] = m@; for adaptively-collected or otherwise non-i.i.d.+-- streams the conditional statement is the right thing to think+-- about.+-- -- Internally two one-sided e-processes are run in parallel: a -- /positive-direction/ process betting against the alternative--- @E[x] > m@ (using centred observations @z = x - m@), and a--- /negative-direction/ process betting against @E[x] < m@ (using--- @-z@). Each maintains its own log-wealth and bettor state. The--- test rejects when either side's wealth crosses @2 \/ alpha@; the--- factor of 2 is the Bonferroni adjustment for the two-sided union.+-- @E[x_t | F_{t-1}] > m@ (using centred observations @z = x - m@),+-- and a /negative-direction/ process betting against+-- @E[x_t | F_{t-1}] < m@ (using @-z@). Each maintains its own+-- log-wealth and bettor state. The test rejects when /either/+-- side's wealth has /ever/ crossed @2 \/ alpha@; the factor of 2+-- is the Bonferroni adjustment for the two-sided union. -- -- The test is /anytime-valid/: under @H_0@ the wealth process is a -- nonnegative supermartingale, so by Ville's inequality the--- probability of ever crossing the threshold is at most @alpha@,+-- probability of /ever/ crossing the threshold is at most @alpha@, -- regardless of when the user decides to stop streaming samples.+-- Rejection is /latched/ in the running state -- once a side has+-- crossed threshold, 'decide' continues to return 'Reject' even if+-- the current log-wealth has since dropped back below threshold. -- -- == Example -- -- Test @H_0: E[x] = 0.5@ for @x@ in @[0, 1]@ at level @alpha = 1e-3@ -- against a stream with empirical mean @0.8@: ----- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 Newton+-- >>> let Right cfg = config 0.5 0.0 1.0 1.0e-3 Newton -- >>> let xs = concat (replicate 30 [1, 1, 0, 1, 1, 0, 1, 1, 1, 1]) -- >>> decide cfg (foldl' (update cfg) (initial cfg) xs) -- Reject@@ -42,6 +54,7 @@ Config , State , Verdict(..)+ , ConfigError(..) -- * Bettor strategies , Bettor(..)@@ -59,8 +72,11 @@ , samples ) where -import GHC.Exts (Double(D#))-import Numeric.Eproc.Common (Bettor(..), Verdict(..))+import Numeric.Eproc.Common (+ Bettor(..), Verdict(..), ConfigError(..)+ , BetState, init_bet, bet_lambda, step_bet+ , finite+ ) -- types ---------------------------------------------------------------------- @@ -69,19 +85,6 @@ -- ceilings @lambda_max@ are derived from the sample bounds (see -- 'config'). --- per-direction bettor state. one constructor per 'Bettor' alternative;--- the constructor used in a given 'State' matches the 'Bettor' chosen--- in the enclosing 'Config'.-data BetState =- SFixed- | SAdaptive- {-# UNPACK #-} !Double -- sum of z (centred observation)- {-# UNPACK #-} !Double -- sum of z^2 (for online variance)- {-# UNPACK #-} !Int -- count- | SNewton- {-# UNPACK #-} !Double -- current bet lambda- {-# UNPACK #-} !Double -- running sum of per-step squared gradients- -- | Bounded-mean test configuration. Build with 'config'. -- -- Carries the bettor strategy, the null mean, the significance@@ -107,85 +110,22 @@ -- observations through 'update'. -- -- The two log-wealth fields track the running log-wealth of the--- positive- and negative-direction e-processes separately;--- 'decide' compares each to the threshold and 'log_wealth' returns--- the larger of the two. The per-direction bettor states carry--- whatever the chosen 'Bettor' needs (running sums, current bet,--- etc.).+-- positive- and negative-direction e-processes separately; the+-- two /maximum/ log-wealth fields latch the supremum so far on+-- each side, so 'decide' tests the supremum-style event Ville's+-- inequality actually bounds. The per-direction bettor states+-- carry whatever the chosen 'Bettor' needs (running sums, current+-- bet, etc.). data State = State {- st_n :: {-# UNPACK #-} !Int -- ^ sample count- , st_log_w_pos :: {-# UNPACK #-} !Double -- ^ log-wealth, pos-dir process- , st_log_w_neg :: {-# UNPACK #-} !Double -- ^ log-wealth, neg-dir process- , st_bet_pos :: !BetState -- ^ bettor state, pos-direction- , st_bet_neg :: !BetState -- ^ bettor state, neg-direction+ st_n :: {-# UNPACK #-} !Int -- ^ sample count+ , st_log_w_pos :: {-# UNPACK #-} !Double -- ^ log-wealth, pos+ , st_log_w_neg :: {-# UNPACK #-} !Double -- ^ log-wealth, neg+ , st_max_log_w_pos :: {-# UNPACK #-} !Double -- ^ sup log-wealth, pos+ , st_max_log_w_neg :: {-# UNPACK #-} !Double -- ^ sup log-wealth, neg+ , st_bet_pos :: !BetState -- ^ bettor state, pos+ , st_bet_neg :: !BetState -- ^ bettor state, neg } --- internal ----------------------------------------------------------------------- floor for the wealth factor before taking a log; keeps the running--- log-wealth finite when a step pushes the factor to (or below) zero.--- NB. written via MagicHash because the fractional literal '1.0e-300'--- compiles as 'fromRational (1.0e-300 :: Rational)', and GHC does--- not constant-fold the conversion -- leaving a per-step--- '$wrationalToDouble' call in the worker.-tiny :: Double-tiny = D# 1.0e-300##-{-# INLINE tiny #-}---- per-bettor initial state.-init_bet :: Bettor -> BetState-init_bet b = case b of- Fixed _ -> SFixed- Adaptive -> SAdaptive 0 0 0- Newton -> SNewton 0 1.0e-6 -- small acc seed avoids div-by-zero-{-# INLINE init_bet #-}---- compute the next bet 'lambda' from the bettor and its current--- state; 'lam_max' is the direction-specific safety bound. for--- Adaptive we form a Kelly-style plug-in from the running sample--- mean and variance; for Newton the bet is just the last lambda--- chosen by the Newton step (updated during 'step_bet').-bet_lambda :: Bettor -> Double -> BetState -> Double-bet_lambda b !lam_max !s = case b of- Fixed lam -> lam- Adaptive -> case s of- SAdaptive !sm !sm2 !n- | n == 0 -> 0- | otherwise ->- let !nd = fromIntegral n- !mu = sm / nd- !mu2 = mu * mu- !var = max 0 (sm2 / nd - mu2)- !den = var + mu2- !raw = if den == 0 then 0 else mu / den- in max 0 (min lam_max raw)- _ -> 0- Newton -> case s of- SNewton !lam _ -> lam- _ -> 0-{-# INLINE bet_lambda #-}---- update bettor state with newly observed centred value 'z'. for--- Adaptive this is just accumulating sums; for Newton we take one--- Newton step on the per-step log-wealth loss '-log(1 + lambda * z)',--- accumulating squared gradients for adaptive scaling.-step_bet :: Bettor -> Double -> BetState -> Double -> BetState-step_bet b !lam_max !s !z = case b of- Fixed _ -> SFixed- Adaptive -> case s of- SAdaptive !sm !sm2 !n -> SAdaptive (sm + z) (sm2 + z * z) (n + 1)- _ -> SAdaptive z (z * z) 1- Newton -> case s of- SNewton !lam !acc ->- let !denom = 1 + lam * z- !g = if denom == 0 then 0 else negate z / denom- !acc' = acc + g * g- !lam' = lam - g / acc'- !clp = max 0 (min lam_max lam')- in SNewton clp acc'- _ -> SNewton 0 1.0e-6-{-# INLINE step_bet #-}- -- construction --------------------------------------------------------------- -- | Build a 'Config' for the bounded-mean test.@@ -209,27 +149,40 @@ -- @log(2 \/ alpha)@; the 2 is the Bonferroni union-bound -- adjustment for the two one-sided e-processes. ----- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 Newton+-- Returns 'Left' with a 'ConfigError' on inputs that would leave+-- the mathematical regime: any of @m@, @lo@, @hi@, @alpha@+-- non-finite (NaN or infinite); @alpha@ outside @(0, 1)@;+-- @lo >= hi@; or @m@ outside the open interval @(lo, hi)@+-- (strict, to avoid the safe-bet ceilings dividing by zero).+--+-- >>> let Right cfg = config 0.5 0.0 1.0 1.0e-3 Newton config :: Double -- ^ null mean @m@ -> Double -- ^ sample lower bound @lo@ -> Double -- ^ sample upper bound @hi@ -> Double -- ^ significance level @alpha@ -> Bettor -- ^ bettor strategy- -> Config-config !m !lo !hi !alpha !b = Config {- cfg_bettor = b- , cfg_lam_max_pos = 0.5 / (m - lo)- , cfg_lam_max_neg = 0.5 / (hi - m)- , cfg_null_mean = m- , cfg_alpha = alpha- , cfg_log_thresh = log (2 / alpha)- }+ -> Either ConfigError Config+config !m !lo !hi !alpha !b+ | not (finite alpha && alpha > 0 && alpha < 1) =+ Left (InvalidAlpha alpha)+ | not (finite lo && finite hi && lo < hi) =+ Left (InvalidBounds lo hi)+ | not (finite m && lo < m && m < hi) =+ Left (InvalidNullMean m lo hi)+ | otherwise = Right Config {+ cfg_bettor = b+ , cfg_lam_max_pos = 0.5 / (m - lo)+ , cfg_lam_max_neg = 0.5 / (hi - m)+ , cfg_null_mean = m+ , cfg_alpha = alpha+ , cfg_log_thresh = log (2 / alpha)+ } {-# INLINE config #-} -- | The initial 'State' for a fresh streaming test. ----- Both directional log-wealths start at @0@ (i.e., wealth @1@) and+-- All four log-wealth fields start at @0@ (i.e., wealth @1@), and -- both bettors start in the per-strategy initial state appropriate -- for the 'Bettor' chosen in the 'Config'. --@@ -238,11 +191,13 @@ initial Config{..} = let !s0 = init_bet cfg_bettor in State {- st_n = 0- , st_log_w_pos = 0- , st_log_w_neg = 0- , st_bet_pos = s0- , st_bet_neg = s0+ st_n = 0+ , st_log_w_pos = 0+ , st_log_w_neg = 0+ , st_max_log_w_pos = 0+ , st_max_log_w_neg = 0+ , st_bet_pos = s0+ , st_bet_neg = s0 } {-# INLINE initial #-} @@ -256,12 +211,16 @@ -- -- @log_w' = log_w + log (1 + lambda * z)@ ----- (with the symmetric @-lambda@ for the negative direction), and--- then steps the bettor states given the newly observed @z@. The--- per-step wealth factor is floored at a tiny positive value to--- keep the log finite when a marginal bet drives the factor to (or--- below) zero.+-- (with the symmetric @-lambda@ for the negative direction), then+-- updates the running supremum of log-wealth on each side and+-- steps the bettor states given the newly observed @z@. --+-- /Precondition/: @x@ must lie in the @[lo, hi]@ interval given+-- to 'config'. The type-I error guarantee of the test depends on+-- this. Out-of-range observations can drive the wealth factor+-- negative, taking the construction out of the supermartingale+-- regime entirely; the function does not check for this.+-- -- >>> let s1 = update cfg s0 0.7 update :: Config -> State -> Double -> State update Config{..} State{..} !x =@@ -270,46 +229,49 @@ !lam_n = bet_lambda cfg_bettor cfg_lam_max_neg st_bet_neg !fac_p = 1 + lam_p * z !fac_n = 1 - lam_n * z- !logw_p = st_log_w_pos + log (max tiny fac_p)- !logw_n = st_log_w_neg + log (max tiny fac_n)+ !logw_p = st_log_w_pos + log fac_p+ !logw_n = st_log_w_neg + log fac_n+ !maxp = max st_max_log_w_pos logw_p+ !maxn = max st_max_log_w_neg logw_n !sp = step_bet cfg_bettor cfg_lam_max_pos st_bet_pos z !sn = step_bet cfg_bettor cfg_lam_max_neg st_bet_neg (negate z)- in State (st_n + 1) logw_p logw_n sp sn+ in State (st_n + 1) logw_p logw_n maxp maxn sp sn {-# INLINE update #-} -- | Compute the current 'Verdict' from the running 'State'. ----- 'Reject' iff either directional log-wealth has crossed the--- Bonferroni-adjusted threshold @log(2 \/ alpha)@; equivalently,--- the wealth process on either side has exceeded @2 \/ alpha@.--- Under @H_0@, by Ville's inequality, the probability of this ever--- happening is at most @alpha@ -- and crucially this bound holds--- at /every/ sample size simultaneously, so the user is free to--- peek at the verdict as often as they like and stop on the first--- 'Reject'.+-- 'Reject' iff either directional log-wealth has /ever/ crossed+-- the Bonferroni-adjusted threshold @log(2 \/ alpha)@;+-- equivalently, the wealth process on either side has exceeded+-- @2 \/ alpha@ at some point in the stream so far. Under @H_0@,+-- by Ville's inequality, the probability of this ever happening+-- is at most @alpha@ -- and crucially this bound holds at /every/+-- sample size simultaneously, so the user is free to peek at the+-- verdict as often as they like and stop on the first 'Reject'. -- -- >>> decide cfg s0 -- Continue decide :: Config -> State -> Verdict decide Config{..} State{..}- | st_log_w_pos >= cfg_log_thresh = Reject- | st_log_w_neg >= cfg_log_thresh = Reject- | otherwise = Continue+ | st_max_log_w_pos >= cfg_log_thresh = Reject+ | st_max_log_w_neg >= cfg_log_thresh = Reject+ | otherwise = Continue {-# INLINE decide #-} -- inspection ----------------------------------------------------------------- --- | The current log-wealth, taken as the maximum of the two--- directional processes.+-- | The supremum-so-far log-wealth, taken as the maximum across the+-- two directional processes and across all sample counts up to+-- the current one. ----- This is the natural \"test statistic\": it is monotone in the--- evidence against @H_0@ accumulated so far, and the test rejects--- exactly when it crosses @log(2 \/ alpha)@.+-- This is the natural \"test statistic\": it is monotone+-- nondecreasing in the sample count, and 'decide' rejects exactly+-- when it crosses @log(2 \/ alpha)@. -- -- >>> log_wealth s0 -- 0.0 log_wealth :: State -> Double-log_wealth State{..} = max st_log_w_pos st_log_w_neg+log_wealth State{..} = max st_max_log_w_pos st_max_log_w_neg {-# INLINE log_wealth #-} -- | The number of samples consumed so far.
lib/Numeric/Eproc/Common.hs view
@@ -1,4 +1,5 @@ {-# OPTIONS_HADDOCK prune #-}+{-# LANGUAGE BangPatterns #-} -- | -- Module: Numeric.Eproc.Common@@ -7,14 +8,30 @@ -- Maintainer: Jared Tobin <jared@ppad.tech> -- -- Shared vocabulary for the eproc tests: the predictable bettor--- strategies and the test verdict type. Re-exported from each test--- module ("Numeric.Eproc.Bounded", "Numeric.Eproc.Paired",+-- strategies, the test verdict type, and the configuration-error+-- type. Re-exported from each test module+-- ("Numeric.Eproc.Bounded", "Numeric.Eproc.Paired", -- "Numeric.Eproc.Bernoulli"); import this module directly only if -- you need the types without picking a particular test.+--+-- The 'BetState' type and its helpers are internal to the library:+-- they are exposed here so that 'Numeric.Eproc.Bounded' and+-- 'Numeric.Eproc.Bernoulli' can share one implementation, not for+-- direct use. module Numeric.Eproc.Common ( Bettor(..) , Verdict(..)+ , ConfigError(..)++ -- * Internal: shared bettor state+ , BetState(..)+ , init_bet+ , bet_lambda+ , step_bet++ -- * Internal: helpers+ , finite ) where -- | A predictable bettor.@@ -27,14 +44,14 @@ -- what makes the resulting wealth process a nonnegative -- supermartingale under @H_0@. ----- For 'Adaptive' and 'Newton', a safe-bet ceiling @lambda_max@--- derived from the test's admissible-observation range is enforced--- by clipping @lambda@ to @[0, lambda_max]@, so the wealth factor--- stays nonnegative.+-- All three bettors enforce a safe-bet ceiling @lambda_max@+-- derived from the test's admissible-observation range by clipping+-- @lambda@ to @[0, lambda_max]@; this keeps the per-step wealth+-- factor nonnegative. ----- * 'Fixed' always bets the supplied constant @lambda@. The wager--- does not respond to observed data; this strategy is useful--- only as a baseline.+-- * 'Fixed' bets the supplied constant @lambda@ (clipped to+-- @[0, lambda_max]@). The wager does not respond to observed+-- data; this strategy is useful only as a baseline. -- -- * 'Adaptive' is the aGRAPA (approximate growth-rate adaptive -- predictable plug-in) bettor of Waudby-Smith & Ramdas (2024).@@ -44,13 +61,14 @@ -- @[0, lambda_max]@. Fast to compute and competitive in -- practice. ----- * 'Newton' is the online Newton step (ONS) bettor. The per-step+-- * 'Newton' is the online Newton step (ONS) bettor of+-- Waudby-Smith & Ramdas (2024, Algorithm 2). The per-step -- log-wealth loss @-log(1 + lambda * z)@ is convex in @lambda@; -- ONS performs one Newton step per observation, accumulating--- squared gradients to scale the update. Achieves logarithmic--- regret against the best constant bet in hindsight and is in--- practice the strongest of the three bettors under most signal--- regimes.+-- squared gradients to scale the update by a fixed learning+-- rate @2 \/ (2 - log 3)@. Achieves logarithmic regret against+-- the best constant bet in hindsight and is in practice the+-- strongest of the three bettors under most signal regimes. data Bettor = Fixed {-# UNPACK #-} !Double | Adaptive@@ -59,12 +77,118 @@ -- | Test outcome at the current sample count. ----- 'Reject' means the wealth process has crossed the rejection--- threshold, so @H_0@ is rejected at level @alpha@. 'Continue'--- means there is not yet enough evidence; collect more samples--- (or stop and report no rejection -- the type-I error guarantee--- holds for /any/ stopping rule).+-- 'Reject' means the wealth process has /ever/ crossed the+-- rejection threshold, so @H_0@ is rejected at level @alpha@.+-- Once a state has rejected it stays rejected, even if subsequent+-- observations drive the current wealth back below threshold;+-- this is the supremum-style guarantee that Ville's inequality+-- actually delivers. 'Continue' means there is not yet enough+-- evidence; collect more samples (or stop and report no+-- rejection -- the type-I error guarantee holds for /any/+-- stopping rule). data Verdict = Reject | Continue deriving (Eq, Show)++-- | Reasons that a test-configuration smart constructor can reject+-- its inputs. Returned by 'Numeric.Eproc.Bounded.config',+-- 'Numeric.Eproc.Bernoulli.config', and+-- 'Numeric.Eproc.Paired.config'.+data ConfigError =+ -- | significance level outside @(0, 1)@+ InvalidAlpha {-# UNPACK #-} !Double+ -- | sample bounds violate @lo < hi@+ | InvalidBounds {-# UNPACK #-} !Double {-# UNPACK #-} !Double+ -- | null mean outside @(lo, hi)@ (strict, to avoid div-by-zero+ -- in the safe-bet ceilings)+ | InvalidNullMean+ {-# UNPACK #-} !Double -- m+ {-# UNPACK #-} !Double -- lo+ {-# UNPACK #-} !Double -- hi+ -- | baseline rate outside @(0, 1)@+ | InvalidBaselineRate {-# UNPACK #-} !Double+ deriving (Eq, Show)++-- | True iff the argument is a finite IEEE-754 double (not NaN, not+-- @+\/-Infinity@). Used by the @config@ smart constructors to keep+-- the bounded-random-variable promise honest.+finite :: Double -> Bool+finite x = not (isNaN x) && not (isInfinite x)+{-# INLINE finite #-}++-- | Per-bettor state. One constructor per 'Bettor' alternative; the+-- constructor used in any given state matches the 'Bettor' chosen+-- in the enclosing 'Config'.+--+-- Internal: exposed only so that the per-test 'State' types in+-- "Numeric.Eproc.Bounded" and "Numeric.Eproc.Bernoulli" can share+-- one implementation.+data BetState =+ SFixed+ | SAdaptive+ {-# UNPACK #-} !Double -- sum of z (centred observation)+ {-# UNPACK #-} !Double -- sum of z^2 (for online variance)+ {-# UNPACK #-} !Int -- count+ | SNewton+ {-# UNPACK #-} !Double -- current bet lambda+ {-# UNPACK #-} !Double -- running sum of per-step squared gradients++-- | Per-bettor initial state.+init_bet :: Bettor -> BetState+init_bet b = case b of+ Fixed _ -> SFixed+ Adaptive -> SAdaptive 0 0 0+ Newton -> SNewton 0 1.0e-6 -- small acc seed avoids div-by-zero+{-# INLINE init_bet #-}++-- | WSR (2024) Algorithm 2 ONS learning rate, @2 \/ (2 - log 3)@.+ons_lr :: Double+ons_lr = 2 / (2 - log 3)+{-# INLINE ons_lr #-}++-- | Compute the next bet 'lambda' from the bettor and its current+-- state; 'lam_max' is the direction-specific safety bound. All+-- strategies clip the result to @[0, lam_max]@ so the wealth+-- factor stays nonnegative.+bet_lambda :: Bettor -> Double -> BetState -> Double+bet_lambda b !lam_max !s = case b of+ Fixed lam -> max 0 (min lam_max lam)+ Adaptive -> case s of+ SAdaptive !sm !sm2 !n+ | n == 0 -> 0+ | otherwise ->+ let !nd = fromIntegral n+ !mu = sm / nd+ !mu2 = mu * mu+ !var = max 0 (sm2 / nd - mu2)+ !den = var + mu2+ !raw = if den == 0 then 0 else mu / den+ in max 0 (min lam_max raw)+ _ -> 0+ Newton -> case s of+ SNewton !lam _ -> lam+ _ -> 0+{-# INLINE bet_lambda #-}++-- | Update bettor state with newly observed centred value 'z'. For+-- 'Adaptive' this is just accumulating sums; for 'Newton' we take+-- one online Newton step (with the WSR learning rate) on the+-- per-step log-wealth loss @-log(1 + lambda * z)@, accumulating+-- squared gradients for adaptive scaling.+step_bet :: Bettor -> Double -> BetState -> Double -> BetState+step_bet b !lam_max !s !z = case b of+ Fixed _ -> SFixed+ Adaptive -> case s of+ SAdaptive !sm !sm2 !n -> SAdaptive (sm + z) (sm2 + z * z) (n + 1)+ _ -> SAdaptive z (z * z) 1+ Newton -> case s of+ SNewton !lam !acc ->+ let !denom = 1 + lam * z+ !g = if denom == 0 then 0 else negate z / denom+ !acc' = acc + g * g+ !lam' = lam - ons_lr * g / acc'+ !clp = max 0 (min lam_max lam')+ in SNewton clp acc'+ _ -> SNewton 0 1.0e-6+{-# INLINE step_bet #-}
lib/Numeric/Eproc/Paired.hs view
@@ -10,15 +10,23 @@ -- Paired two-sample anytime-valid mean-equality test. -- -- For paired observations @(a_t, b_t)@ where both samples lie in--- @[lo, hi]@, tests @H_0: E[a] = E[b]@ against--- @H_1: E[a] /= E[b]@.+-- @[lo, hi]@, tests ----- The reduction is straightforward: under the null, the differences--- @d_t = a_t - b_t@ have mean zero, and differences of @[lo, hi]@--- values lie in @[lo - hi, hi - lo]@. So the paired test is just--- the bounded-mean test ("Numeric.Eproc.Bounded") on @d_t@ with--- null mean @0@ and sample bounds @[lo - hi, hi - lo]@.+-- @H_0: E[a_t - b_t | F_{t-1}] = 0 for all t@ --+-- against the negation. Here @F_{t-1}@ is the filtration generated+-- by everything observed strictly before time @t@; the conditional+-- form is what anytime validity actually requires. For i.i.d. pairs+-- this reduces to the usual marginal statement @E[a] = E[b]@; for+-- adaptively-collected or otherwise non-i.i.d. streams the+-- conditional statement is the right thing to think about.+--+-- The reduction is straightforward: under @H_0@, the differences+-- @d_t = a_t - b_t@ have (conditional) mean zero, and differences+-- of @[lo, hi]@ values lie in @[lo - hi, hi - lo]@. So the paired+-- test is just the bounded-mean test ("Numeric.Eproc.Bounded") on+-- @d_t@ with null mean @0@ and sample bounds @[lo - hi, hi - lo]@.+-- -- Pairing is required: independent two-sample testing without -- alignment would need to bet against a richer alternative (the -- joint distribution rather than the marginal difference) and is@@ -30,7 +38,7 @@ -- @alpha = 1e-3@ against a stream of paired observations where @a@ -- runs systematically higher than @b@: ----- >>> let cfg = config 0.0 1.0 1.0e-3 Newton+-- >>> let Right cfg = config 0.0 1.0 1.0e-3 Newton -- >>> let ps = take 1000 (cycle [(1, 0), (1, 0), (0, 0), (1, 1)]) -- >>> decide cfg (foldl' (update cfg) (initial cfg) ps) -- Reject@@ -40,6 +48,7 @@ Config , State , Verdict(..)+ , ConfigError(..) -- * Bettor strategies , Bettor(..)@@ -58,7 +67,7 @@ ) where import qualified Numeric.Eproc.Bounded as Bounded-import Numeric.Eproc.Common (Bettor(..), Verdict(..))+import Numeric.Eproc.Common (Bettor(..), Verdict(..), ConfigError(..)) -- types ---------------------------------------------------------------------- @@ -80,16 +89,20 @@ -- on the differences, which lie in @[lo - hi, hi - lo]@ with null -- mean @0@. ----- >>> let cfg = config 0.0 1.0 1.0e-3 Newton+-- Returns 'Left' with a 'ConfigError' on inputs that would leave+-- the mathematical regime: any of @lo@, @hi@, @alpha@ non-finite+-- (NaN or infinite); @lo >= hi@; or @alpha@ outside @(0, 1)@.+--+-- >>> let Right cfg = config 0.0 1.0 1.0e-3 Newton config :: Double -- ^ sample lower bound @lo@ -> Double -- ^ sample upper bound @hi@ -> Double -- ^ significance level @alpha@ -> Bettor -- ^ bettor strategy- -> Config+ -> Either ConfigError Config config !lo !hi !alpha b = let !d = hi - lo- in Config (Bounded.config 0 (negate d) d alpha b)+ in fmap Config (Bounded.config 0 (negate d) d alpha b) {-# INLINE config #-} -- | The initial 'State' for a fresh streaming test.@@ -106,6 +119,10 @@ -- Equivalent to feeding the difference @a - b@ into the underlying -- bounded-mean test. --+-- /Precondition/: both @a@ and @b@ must lie in the @[lo, hi]@+-- interval given to 'config'. The type-I error guarantee of the+-- test depends on this; the function does not check.+-- -- >>> let s1 = update cfg s0 (0.3, 0.7) update :: Config -> State -> (Double, Double) -> State update (Config c) (State s) (!a, !b) =@@ -115,7 +132,7 @@ -- | Compute the current 'Verdict' from the running 'State'. -- -- 'Reject' iff either directional log-wealth of the underlying--- bounded-mean test on the differences has crossed+-- bounded-mean test on the differences has /ever/ crossed -- @log(2 \/ alpha)@. -- -- >>> decide cfg s0@@ -126,8 +143,8 @@ -- inspection ----------------------------------------------------------------- --- | The current log-wealth of the underlying bounded-mean test on--- the differences.+-- | The supremum-so-far log-wealth of the underlying bounded-mean+-- test on the differences. -- -- >>> log_wealth s0 -- 0.0
ppad-eproc.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: ppad-eproc-version: 0.1.0+version: 0.2.0 synopsis: Anytime-valid sequential testing via e-processes. license: MIT license-file: LICENSE
test/Main.hs view
@@ -6,9 +6,11 @@ import Data.Word import qualified Numeric.Eproc.Bernoulli as Bern import qualified Numeric.Eproc.Bounded as Bounded+import qualified Numeric.Eproc.Common as C import qualified Numeric.Eproc.Paired as P import Test.Tasty import Test.Tasty.HUnit+import qualified Test.Tasty.QuickCheck as QC main :: IO () main = defaultMain $ testGroup "ppad-eproc" [@@ -18,8 +20,16 @@ , two_sample_tests , bernoulli_tests , bettor_smoke_tests+ , latched_rejection_tests+ , config_validation_tests+ , safety_property_tests ] +-- partial helper: tests below hardcode valid configs.+ok :: Either e a -> a+ok (Right x) = x+ok (Left _) = error "test: invalid config"+ -- prng ----------------------------------------------------------------------- -- inline PCG-style PRNG, no external deps.@@ -110,8 +120,8 @@ go !n !g !st | n >= budget = (P.decide cfg st, n) | otherwise = case P.decide cfg st of- Bounded.Reject -> (Bounded.Reject, n)- Bounded.Continue ->+ P.Reject -> (P.Reject, n)+ P.Continue -> let (a, g1) = bernoulli pa g (b, g2) = bernoulli pb g1 st' = P.update cfg st (a, b)@@ -130,7 +140,7 @@ rejects = length [ () | g <- gens , let (v, _) = run_paired cfg pa pb budget g- , v == Bounded.Reject ]+ , v == P.Reject ] in fromIntegral rejects / fromIntegral trials -- sanity ---------------------------------------------------------------------@@ -139,12 +149,12 @@ sanity_tests :: TestTree sanity_tests = testGroup "sanity" [ testCase "degenerate input never rejects" $ do- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Newton+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Newton) xs = replicate 5000 0.5 st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs Bounded.decide cfg st @?= Bounded.Continue , testCase "two-sided thresholds applied symmetrically" $ do- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Newton+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Newton) Bounded.decide cfg (Bounded.initial cfg) @?= Bounded.Continue ] @@ -156,17 +166,17 @@ calibration_tests :: TestTree calibration_tests = testGroup "null calibration" [ testCase "Newton, Bernoulli(0.5), m=0.5, alpha=0.05" $ do- let cfg = Bounded.config 0.5 0.0 1.0 0.05 Bounded.Newton+ let cfg = ok (Bounded.config 0.5 0.0 1.0 0.05 Bounded.Newton) rate = rejection_rate cfg 0.5 2000 200 12345- -- expected rate <= 0.05; allow up to 0.10 slack for sampling- -- variability over 200 trials.+ -- expected rate <= 0.05; allow up to ~0.08 slack for sampling+ -- variability over 200 trials (sigma ~ 0.015). assertBool ("FPR " ++ show rate ++ " exceeded slack") $- rate <= 0.10+ rate <= 0.08 , testCase "Adaptive, Bernoulli(0.5), m=0.5, alpha=0.05" $ do- let cfg = Bounded.config 0.5 0.0 1.0 0.05 Bounded.Adaptive+ let cfg = ok (Bounded.config 0.5 0.0 1.0 0.05 Bounded.Adaptive) rate = rejection_rate cfg 0.5 2000 200 67890 assertBool ("FPR " ++ show rate ++ " exceeded slack") $- rate <= 0.10+ rate <= 0.08 ] -- power ----------------------------------------------------------------------@@ -175,12 +185,12 @@ power_tests :: TestTree power_tests = testGroup "power" [ testCase "Newton detects Bernoulli(0.7) vs m=0.5" $ do- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton) rate = rejection_rate cfg 0.7 5000 100 11111 assertBool ("power " ++ show rate ++ " too low") $ rate >= 0.95 , testCase "Adaptive detects Bernoulli(0.7) vs m=0.5" $ do- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive) rate = rejection_rate cfg 0.7 5000 100 22222 assertBool ("power " ++ show rate ++ " too low") $ rate >= 0.95@@ -191,11 +201,11 @@ two_sample_tests :: TestTree two_sample_tests = testGroup "two-sample" [ testCase "identical distributions don't reject" $ do- let cfg = P.config 0.0 1.0 1.0e-3 Bounded.Newton+ let cfg = ok (P.config 0.0 1.0 1.0e-3 Bounded.Newton) rate = paired_avg_rate cfg 0.5 0.5 2000 100 33333 assertBool ("FPR " ++ show rate) $ rate <= 0.05 , testCase "different distributions reject" $ do- let cfg = P.config 0.0 1.0 1.0e-3 Bounded.Newton+ let cfg = ok (P.config 0.0 1.0 1.0e-3 Bounded.Newton) rate = paired_avg_rate cfg 0.3 0.7 5000 100 44444 assertBool ("power " ++ show rate) $ rate >= 0.95 ]@@ -238,27 +248,27 @@ bernoulli_tests :: TestTree bernoulli_tests = testGroup "bernoulli" [ testCase "all-zero stream never rejects" $ do- let cfg = Bern.config 1.0e-6 0.05 Bern.Newton+ let cfg = ok (Bern.config 0.05 1.0e-6 Bern.Newton) xs = replicate 5000 False st = foldl' (Bern.update cfg) (Bern.initial cfg) xs Bern.decide cfg st @?= Bern.Continue , testCase "Newton FPR under H_0 (p = p_0 = 0.05)" $ do- let cfg = Bern.config 0.05 0.05 Bern.Newton+ let cfg = ok (Bern.config 0.05 0.05 Bern.Newton) rate = bernoulli_rate cfg 0.05 2000 200 55555 assertBool ("FPR " ++ show rate ++ " exceeded slack") $- rate <= 0.10+ rate <= 0.08 , testCase "Adaptive FPR under H_0 (p = p_0 = 0.05)" $ do- let cfg = Bern.config 0.05 0.05 Bern.Adaptive+ let cfg = ok (Bern.config 0.05 0.05 Bern.Adaptive) rate = bernoulli_rate cfg 0.05 2000 200 66666 assertBool ("FPR " ++ show rate ++ " exceeded slack") $- rate <= 0.10+ rate <= 0.08 , testCase "Newton detects p = 0.3 vs p_0 = 0.05" $ do- let cfg = Bern.config 1.0e-3 0.05 Bern.Newton+ let cfg = ok (Bern.config 0.05 1.0e-3 Bern.Newton) rate = bernoulli_rate cfg 0.3 5000 100 77777 assertBool ("power " ++ show rate ++ " too low") $ rate >= 0.95 , testCase "Adaptive detects p = 0.3 vs p_0 = 0.05" $ do- let cfg = Bern.config 1.0e-3 0.05 Bern.Adaptive+ let cfg = ok (Bern.config 0.05 1.0e-3 Bern.Adaptive) rate = bernoulli_rate cfg 0.3 5000 100 88888 assertBool ("power " ++ show rate ++ " too low") $ rate >= 0.95@@ -270,19 +280,201 @@ -- deterministic stream. bettor_smoke_tests :: TestTree bettor_smoke_tests = testGroup "bettor smoke" [- testCase "fixed bettor runs without error" $ do- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5)+ testCase "fixed bettor runs without error (bounded)" $ do+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5)) xs = take 100 (cycle [0.0, 1.0]) st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs assertBool "samples advanced" (Bounded.samples st == 100)- , testCase "Newton bettor runs without error" $ do- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton+ , testCase "Newton bettor runs without error (bounded)" $ do+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton) xs = take 100 (cycle [0.0, 1.0]) st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs assertBool "samples advanced" (Bounded.samples st == 100)- , testCase "Adaptive bettor runs without error" $ do- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive+ , testCase "Adaptive bettor runs without error (bounded)" $ do+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive) xs = take 100 (cycle [0.0, 1.0]) st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs assertBool "samples advanced" (Bounded.samples st == 100)+ , testCase "fixed bettor runs without error (bernoulli)" $ do+ let cfg = ok (Bern.config 0.5 1.0e-3 (Bern.Fixed 0.5))+ xs = take 100 (cycle [True, False])+ st = foldl' (Bern.update cfg) (Bern.initial cfg) xs+ assertBool "samples advanced" (Bern.samples st == 100)+ , testCase "Newton bettor runs without error (bernoulli)" $ do+ let cfg = ok (Bern.config 0.5 1.0e-3 Bern.Newton)+ xs = take 100 (cycle [True, False])+ st = foldl' (Bern.update cfg) (Bern.initial cfg) xs+ assertBool "samples advanced" (Bern.samples st == 100)+ , testCase "Adaptive bettor runs without error (bernoulli)" $ do+ let cfg = ok (Bern.config 0.5 1.0e-3 Bern.Adaptive)+ xs = take 100 (cycle [True, False])+ st = foldl' (Bern.update cfg) (Bern.initial cfg) xs+ assertBool "samples advanced" (Bern.samples st == 100)+ ]++-- latched rejection ----------------------------------------------------------++-- once the wealth crosses threshold, subsequent observations driving the+-- current wealth back below threshold must not unrejection the test.+latched_rejection_tests :: TestTree+latched_rejection_tests = testGroup "latched rejection" [+ testCase "bounded: cross then drown stays rejected" $ do+ -- alpha = 0.5 => threshold log(2/0.5) = log 4 ~ 1.386.+ -- Fixed 1.0 with x=1 grows log_w_pos by log 1.5 ~ 0.405/step;+ -- five 1s push it past threshold. Then forty 0s drop it well+ -- below.+ let cfg = ok (Bounded.config 0.5 0.0 1.0 0.5 (Bounded.Fixed 1.0))+ xs1 = replicate 5 1.0+ xs2 = replicate 40 0.0+ st1 = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs1+ st2 = foldl' (Bounded.update cfg) st1 xs2+ Bounded.decide cfg st1 @?= Bounded.Reject+ Bounded.decide cfg st2 @?= Bounded.Reject+ , testCase "bernoulli: cross then drown stays rejected" $ do+ let cfg = ok (Bern.config 0.05 0.5 (Bern.Fixed 1.0))+ xs1 = replicate 5 True+ xs2 = replicate 200 False+ st1 = foldl' (Bern.update cfg) (Bern.initial cfg) xs1+ st2 = foldl' (Bern.update cfg) st1 xs2+ Bern.decide cfg st1 @?= Bern.Reject+ Bern.decide cfg st2 @?= Bern.Reject+ ]++-- config validation ----------------------------------------------------------++config_validation_tests :: TestTree+config_validation_tests = testGroup "config validation" [+ testCase "Bounded: alpha <= 0 rejected" $+ assertLeft (Bounded.config 0.5 0.0 1.0 0.0 Bounded.Newton)+ , testCase "Bounded: alpha >= 1 rejected" $+ assertLeft (Bounded.config 0.5 0.0 1.0 1.5 Bounded.Newton)+ , testCase "Bounded: lo >= hi rejected" $+ assertLeft (Bounded.config 0.5 1.0 0.0 0.01 Bounded.Newton)+ , testCase "Bounded: m == lo rejected" $+ assertLeft (Bounded.config 0.0 0.0 1.0 0.01 Bounded.Newton)+ , testCase "Bounded: m == hi rejected" $+ assertLeft (Bounded.config 1.0 0.0 1.0 0.01 Bounded.Newton)+ , testCase "Bounded: m outside [lo, hi] rejected" $+ assertLeft (Bounded.config 2.0 0.0 1.0 0.01 Bounded.Newton)+ , testCase "Bernoulli: alpha <= 0 rejected" $+ assertLeft (Bern.config 0.5 0.0 Bern.Newton)+ , testCase "Bernoulli: alpha >= 1 rejected" $+ assertLeft (Bern.config 0.5 1.0 Bern.Newton)+ , testCase "Bernoulli: p0 == 0 rejected" $+ assertLeft (Bern.config 0.0 0.05 Bern.Newton)+ , testCase "Bernoulli: p0 == 1 rejected" $+ assertLeft (Bern.config 1.0 0.05 Bern.Newton)+ , testCase "Paired: alpha out of range rejected" $+ assertLeft (P.config 0.0 1.0 0.0 Bounded.Newton)+ , testCase "Paired: lo >= hi rejected" $+ assertLeft (P.config 1.0 0.0 0.01 Bounded.Newton)+ , testCase "Bounded: infinite bounds rejected" $+ assertLeft (Bounded.config 0.0 nInf pInf 0.01 Bounded.Newton)+ , testCase "Bounded: NaN m rejected" $+ assertLeft (Bounded.config nan 0.0 1.0 0.01 Bounded.Newton)+ , testCase "Bounded: NaN alpha rejected" $+ assertLeft (Bounded.config 0.5 0.0 1.0 nan Bounded.Newton)+ , testCase "Bernoulli: NaN p0 rejected" $+ assertLeft (Bern.config nan 0.01 Bern.Newton)+ , testCase "Bernoulli: infinite alpha rejected" $+ assertLeft (Bern.config 0.05 pInf Bern.Newton)+ , testCase "Paired: infinite hi rejected" $+ assertLeft (P.config 0.0 pInf 0.01 Bounded.Newton)+ ]+ where+ nan, pInf, nInf :: Double+ nan = 0 / 0+ pInf = 1 / 0+ nInf = negate (1 / 0)+ assertLeft :: Either C.ConfigError a -> Assertion+ assertLeft e = case e of+ Left _ -> pure ()+ Right _ -> assertFailure "expected Left"++-- safety properties ----------------------------------------------------------++unit_double :: QC.Gen Double+unit_double = QC.choose (0, 1)++arb_bettor :: QC.Gen C.Bettor+arb_bettor = QC.oneof [+ pure C.Adaptive+ , pure C.Newton+ , C.Fixed <$> QC.choose (-10, 10) -- intentionally include unsafe values+ ]++finite :: Double -> Bool+finite x = not (isNaN x) && not (isInfinite x)++monotone_reject_bounded :: [Bounded.Verdict] -> Bool+monotone_reject_bounded [] = True+monotone_reject_bounded (Bounded.Continue : rest) = monotone_reject_bounded rest+monotone_reject_bounded (Bounded.Reject : rest) = all (== Bounded.Reject) rest++monotone_reject_bern :: [Bern.Verdict] -> Bool+monotone_reject_bern [] = True+monotone_reject_bern (Bern.Continue : rest) = monotone_reject_bern rest+monotone_reject_bern (Bern.Reject : rest) = all (== Bern.Reject) rest++safety_property_tests :: TestTree+safety_property_tests = testGroup "safety properties" [+ QC.testProperty "Bounded: log_wealth finite after any admissible stream" $+ QC.forAll arb_bettor $ \b ->+ QC.forAll (QC.listOf unit_double) $ \xs ->+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 b)+ st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs+ in finite (Bounded.log_wealth st)++ , QC.testProperty "Bernoulli: log_wealth finite after any admissible stream" $+ QC.forAll arb_bettor $ \b ->+ QC.forAll QC.arbitrary $ \xs ->+ let cfg = ok (Bern.config 0.05 1.0e-3 b)+ st = foldl' (Bern.update cfg) (Bern.initial cfg) (xs :: [Bool])+ in finite (Bern.log_wealth st)++ , QC.testProperty "Bounded: Fixed with arbitrary lambda is safe" $+ QC.forAll (QC.choose (-1000, 1000)) $ \lam ->+ QC.forAll (QC.listOf unit_double) $ \xs ->+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 (C.Fixed lam))+ st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs+ in finite (Bounded.log_wealth st)++ , QC.testProperty "Bernoulli: Fixed with arbitrary lambda is safe" $+ QC.forAll (QC.choose (-1000, 1000)) $ \lam ->+ QC.forAll QC.arbitrary $ \xs ->+ let cfg = ok (Bern.config 0.05 1.0e-3 (C.Fixed lam))+ st = foldl' (Bern.update cfg) (Bern.initial cfg) (xs :: [Bool])+ in finite (Bern.log_wealth st)++ , QC.testProperty "Bounded: log_wealth is monotone nondecreasing" $+ QC.forAll arb_bettor $ \b ->+ QC.forAll (QC.listOf unit_double) $ \xs ->+ let cfg = ok (Bounded.config 0.5 0.0 1.0 1.0e-3 b)+ sts = scanl (Bounded.update cfg) (Bounded.initial cfg) xs+ lws = map Bounded.log_wealth sts+ in and (zipWith (<=) lws (drop 1 lws))++ , QC.testProperty "Bernoulli: log_wealth is monotone nondecreasing" $+ QC.forAll arb_bettor $ \b ->+ QC.forAll QC.arbitrary $ \xs ->+ let cfg = ok (Bern.config 0.05 1.0e-3 b)+ sts = scanl (Bern.update cfg) (Bern.initial cfg) (xs :: [Bool])+ lws = map Bern.log_wealth sts+ in and (zipWith (<=) lws (drop 1 lws))++ , QC.testProperty "Bounded: rejection is latched" $+ QC.forAll arb_bettor $ \b ->+ QC.forAll (QC.listOf unit_double) $ \xs ->+ let cfg = ok (Bounded.config 0.5 0.0 1.0 0.5 b)+ sts = scanl (Bounded.update cfg) (Bounded.initial cfg) xs+ vs = map (Bounded.decide cfg) sts+ in monotone_reject_bounded vs++ , QC.testProperty "Bernoulli: rejection is latched" $+ QC.forAll arb_bettor $ \b ->+ QC.forAll QC.arbitrary $ \xs ->+ let cfg = ok (Bern.config 0.5 0.5 b)+ sts = scanl (Bern.update cfg) (Bern.initial cfg) (xs :: [Bool])+ vs = map (Bern.decide cfg) sts+ in monotone_reject_bern vs ]