eproc

Anytime-valid sequential testing and confidence sequences.
git clone git://git.ppad.tech/eproc.git
Log | Files | Refs | README | LICENSE

TwoSided.hs (5850B)


      1 {-# OPTIONS_HADDOCK prune #-}
      2 {-# LANGUAGE BangPatterns #-}
      3 
      4 -- |
      5 -- Module: Numeric.Eproc.Bernoulli.TwoSided
      6 -- Copyright: (c) 2026 Jared Tobin
      7 -- License: MIT
      8 -- Maintainer: Jared Tobin <jared@ppad.tech>
      9 --
     10 -- Two-sided Bernoulli rate anytime-valid test. Companion to
     11 -- "Numeric.Eproc.Bernoulli", which handles the one-sided case;
     12 -- reach for this module when you want to test
     13 --
     14 --     @H_0: E[x_t | F_{t-1}] = p_0   for all t@
     15 --
     16 -- against the negation. The canonical case is the sign test at
     17 -- @p_0 = 1\/2@.
     18 --
     19 -- This is exactly the two-sided bounded-mean test on @[0, 1]@ with
     20 -- null mean @p_0@, so the module is a thin newtype wrapper over
     21 -- "Numeric.Eproc.Bounded" (much as "Numeric.Eproc.Paired" is a
     22 -- wrapper for the paired difference case). See the Bounded module
     23 -- for the mathematical detail: convex-hedge combination of two
     24 -- per-direction e-processes, threshold @log(2 \/ alpha)@, latched
     25 -- rejection, etc.
     26 --
     27 -- == Example
     28 --
     29 -- Sign test at @p_0 = 1\/2@ with a downward shift:
     30 --
     31 -- >>> let Right cfg = config 0.5 1.0e-3 Newton
     32 -- >>> let s0 = initial cfg
     33 -- >>> let xs = take 500 (cycle [False, False, False, True])
     34 -- >>> decide cfg (foldl' (update cfg) s0 xs)
     35 -- Reject
     36 
     37 module Numeric.Eproc.Bernoulli.TwoSided (
     38   -- * Test configuration and state
     39     Config
     40   , State
     41   , Verdict(..)
     42   , ConfigError(..)
     43 
     44   -- * Bettor strategies
     45   , Bettor(..)
     46 
     47   -- * Construction
     48   , config
     49   , initial
     50 
     51   -- * Streaming
     52   , update
     53   , decide
     54 
     55   -- * Inspection
     56   , log_wealth
     57   , log_wealth_sup
     58   , log_evalue
     59   , log_evalue_sup
     60   , p_value
     61   , samples
     62   ) where
     63 
     64 import qualified Numeric.Eproc.Bounded as Bounded
     65 import Numeric.Eproc.Common (Bettor(..), Verdict(..), ConfigError(..))
     66 
     67 -- types ----------------------------------------------------------------------
     68 
     69 -- | Two-sided Bernoulli rate test configuration. Build with 'config'.
     70 --   Wraps a 'Numeric.Eproc.Bounded.Config' on @[0, 1]@ with null
     71 --   mean @p_0@.
     72 newtype Config = Config Bounded.Config
     73 
     74 -- | Streaming test state. Construct with 'initial' and fold
     75 --   observations through 'update'.
     76 newtype State = State Bounded.State
     77 
     78 -- construction ---------------------------------------------------------------
     79 
     80 -- | Build a 'Config' for the two-sided Bernoulli rate test.
     81 --
     82 --   Returns 'Left' with a 'ConfigError' on inputs that would leave
     83 --   the mathematical regime: @p_0@ outside @(0, 1)@ (or non-finite),
     84 --   or @alpha@ outside @(0, 1)@ (or non-finite).
     85 --
     86 --   >>> let Right cfg = config 0.5 1.0e-3 Newton
     87 config
     88   :: Double  -- ^ baseline rate @p_0@, in @(0, 1)@
     89   -> Double  -- ^ significance level @alpha@, in @(0, 1)@
     90   -> Bettor  -- ^ bettor strategy
     91   -> Either ConfigError Config
     92 config !p0 !alpha b
     93   -- NaN comparisons return False and (-Inf, +Inf) fail the range
     94   -- check, so this catches non-finite p_0 without a separate guard.
     95   | not (p0 > 0 && p0 < 1) = Left (InvalidBaselineRate p0)
     96   | otherwise              = fmap Config (Bounded.config p0 0 1 alpha b)
     97 {-# INLINE config #-}
     98 
     99 -- | The initial 'State' for a fresh streaming test.
    100 --
    101 --   >>> let s0 = initial cfg
    102 initial :: Config -> State
    103 initial (Config c) = State (Bounded.initial c)
    104 {-# INLINE initial #-}
    105 
    106 -- streaming ------------------------------------------------------------------
    107 
    108 -- | Fold one observation into the running 'State'. Equivalent to
    109 --   feeding the numeric @1@\/@0@ encoding of the observation into
    110 --   the underlying bounded-mean test.
    111 --
    112 --   >>> let s1 = update cfg s0 True
    113 update :: Config -> State -> Bool -> State
    114 update (Config c) (State s) !x =
    115   State (Bounded.update c s (if x then 1 else 0))
    116 {-# INLINE update #-}
    117 
    118 -- | Compute the current 'Verdict' from the running 'State'.
    119 --
    120 --   >>> decide cfg s0
    121 --   Continue
    122 decide :: Config -> State -> Verdict
    123 decide (Config c) (State s) = Bounded.decide c s
    124 {-# INLINE decide #-}
    125 
    126 -- inspection -----------------------------------------------------------------
    127 
    128 -- | The current @log(K^+_t + K^-_t)@ of the underlying bounded-mean
    129 --   test. Not monotone; bounded above by 'log_wealth_sup'. Starts
    130 --   at @log 2@.
    131 --
    132 --   >>> log_wealth s0
    133 --   0.6931471805599453
    134 log_wealth :: State -> Double
    135 log_wealth (State s) = Bounded.log_wealth s
    136 {-# INLINE log_wealth #-}
    137 
    138 -- | The supremum-so-far of @log(K^+_t + K^-_t)@ from the underlying
    139 --   bounded-mean test. Monotone nondecreasing; 'decide' rejects
    140 --   exactly when it crosses @log(2 \/ alpha)@. Starts at @log 2@.
    141 --
    142 --   >>> log_wealth_sup s0
    143 --   0.6931471805599453
    144 log_wealth_sup :: State -> Double
    145 log_wealth_sup (State s) = Bounded.log_wealth_sup s
    146 {-# INLINE log_wealth_sup #-}
    147 
    148 -- | The current log e-value of the underlying bounded-mean test:
    149 --   'log_wealth' minus @log 2@, normalized so a fresh state sits at
    150 --   @0@. Not monotone; bounded above by 'log_evalue_sup'.
    151 --
    152 --   >>> log_evalue s0
    153 --   0.0
    154 log_evalue :: State -> Double
    155 log_evalue (State s) = Bounded.log_evalue s
    156 {-# INLINE log_evalue #-}
    157 
    158 -- | The supremum-so-far of the log e-value: 'log_wealth_sup' minus
    159 --   @log 2@. Monotone nondecreasing, starting at @0@; 'decide'
    160 --   rejects exactly when it crosses @log(1 \/ alpha)@.
    161 --
    162 --   >>> log_evalue_sup s0
    163 --   0.0
    164 log_evalue_sup :: State -> Double
    165 log_evalue_sup (State s) = Bounded.log_evalue_sup s
    166 {-# INLINE log_evalue_sup #-}
    167 
    168 -- | The anytime-valid p-value: the reciprocal of the largest
    169 --   e-value attained so far. Monotone nonincreasing; under @H_0@,
    170 --   @P(exists t: p_t <= alpha) <= alpha@ for every @alpha@
    171 --   simultaneously. 'decide' returns 'Reject' exactly when this
    172 --   value has reached the configured @alpha@ or below.
    173 --
    174 --   >>> p_value s0
    175 --   1.0
    176 p_value :: State -> Double
    177 p_value (State s) = Bounded.p_value s
    178 {-# INLINE p_value #-}
    179 
    180 -- | The number of samples consumed so far.
    181 --
    182 --   >>> samples s0
    183 --   0
    184 samples :: State -> Int
    185 samples (State s) = Bounded.samples s
    186 {-# INLINE samples #-}