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

-- |
-- Module: Numeric.Eproc.Paired
-- Copyright: (c) 2026 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- 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]@.
--
-- 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]@.
--
-- 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
-- beyond the scope of this module.
--
-- == Example
--
-- Test @H_0: E[a] = E[b]@ for samples in @[0, 1]@ at level
-- @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 ps  = take 1000 (cycle [(1, 0), (1, 0), (0, 0), (1, 1)])
-- >>> decide cfg (foldl' (update cfg) (initial cfg) ps)
-- Reject

module Numeric.Eproc.Paired (
  -- * Test configuration and state
    Config
  , State
  , Verdict(..)

  -- * Bettor strategies
  , Bettor(..)

  -- * Construction
  , config
  , initial

  -- * Streaming
  , update
  , decide

  -- * Inspection
  , log_wealth
  , samples
  ) where

import qualified Numeric.Eproc.Bounded as Bounded
import Numeric.Eproc.Common (Bettor(..), Verdict(..))

-- types ----------------------------------------------------------------------

-- | Paired two-sample test configuration. Build with 'config'. Wraps
--   a 'Numeric.Eproc.Bounded.Config' for the underlying
--   difference test.
newtype Config = Config Bounded.Config

-- | Streaming paired two-sample test state. Construct with 'initial'
--   and fold paired observations through 'update'.
newtype State = State Bounded.State

-- construction ---------------------------------------------------------------

-- | Build a 'Config' for the paired two-sample test.
--
--   Bounds @lo@ and @hi@ are the (shared) bounds on the individual
--   @a@ and @b@ samples; the underlying mean test is then configured
--   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
config
  :: Double  -- ^ sample lower bound @lo@
  -> Double  -- ^ sample upper bound @hi@
  -> Double  -- ^ significance level @alpha@
  -> Bettor  -- ^ bettor strategy
  -> Config
config :: Double -> Double -> Double -> Bettor -> Config
config !Double
lo !Double
hi !Double
alpha Bettor
b =
  let !d :: Double
d = Double
hi Double -> Double -> Double
forall a. Num a => a -> a -> a
- Double
lo
  in  Config -> Config
Config (Double -> Double -> Double -> Double -> Bettor -> Config
Bounded.config Double
0 (Double -> Double
forall a. Num a => a -> a
negate Double
d) Double
d Double
alpha Bettor
b)
{-# INLINE config #-}

-- | The initial 'State' for a fresh streaming test.
--
--   >>> let s0 = initial cfg
initial :: Config -> State
initial :: Config -> State
initial (Config Config
c) = State -> State
State (Config -> State
Bounded.initial Config
c)
{-# INLINE initial #-}

-- streaming ------------------------------------------------------------------

-- | Fold one paired observation @(a, b)@ into the running 'State'.
--
--   Equivalent to feeding the difference @a - b@ into the underlying
--   bounded-mean test.
--
--   >>> let s1 = update cfg s0 (0.3, 0.7)
update :: Config -> State -> (Double, Double) -> State
update :: Config -> State -> (Double, Double) -> State
update (Config Config
c) (State State
s) (!Double
a, !Double
b) =
  State -> State
State (Config -> State -> Double -> State
Bounded.update Config
c State
s (Double
a Double -> Double -> Double
forall a. Num a => a -> a -> a
- Double
b))
{-# INLINE update #-}

-- | 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
--   @log(2 \/ alpha)@.
--
--   >>> decide cfg s0
--   Continue
decide :: Config -> State -> Verdict
decide :: Config -> State -> Verdict
decide (Config Config
c) (State State
s) = Config -> State -> Verdict
Bounded.decide Config
c State
s
{-# INLINE decide #-}

-- inspection -----------------------------------------------------------------

-- | The current log-wealth of the underlying bounded-mean test on
--   the differences.
--
--   >>> log_wealth s0
--   0.0
log_wealth :: State -> Double
log_wealth :: State -> Double
log_wealth (State State
s) = State -> Double
Bounded.log_wealth State
s
{-# INLINE log_wealth #-}

-- | The number of paired observations consumed so far.
--
--   >>> samples s0
--   0
samples :: State -> Int
samples :: State -> Int
samples (State State
s) = State -> Int
Bounded.samples State
s
{-# INLINE samples #-}