packages feed

ppad-eproc-0.4.0: lib/Numeric/Eproc/Mixture.hs

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

-- |
-- Module: Numeric.Eproc.Mixture
-- Copyright: (c) 2026 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Uniform convex mixture of e-processes.
--
-- Given @K@ component e-processes @E^1_t, ..., E^K_t@ adapted to a
-- common filtration -- each testing (its facet of) a shared null
-- @H_0@ -- their arithmetic mean
--
--     @M_t = (E^1_t + ... + E^K_t) \/ K@
--
-- is itself an e-process with @M_0 = 1@: convex combinations
-- preserve the nonnegative-supermartingale property. By Ville's
-- inequality @P(sup_t M_t >= 1 \/ alpha) <= alpha@ under @H_0@, so a
-- level-@alpha@ test of the /combined/ null rejects when
-- @sup_t log(E^1_t + ... + E^K_t)@ crosses @log(K \/ alpha)@ -- no
-- Bonferroni correction, and strictly more powerful than one, since
-- the sum dominates the max. Use a mixture when the alternative has
-- several qualitatively different faces (a location shift, a shape
-- change, a rare-outlier channel, ...) and you want a single test
-- with power against their union.
--
-- This module does not own or update the components: they may be
-- heterogeneous (different test modules, different observation
-- transformations), so the caller steps each component itself and
-- feeds 'update' the vector of their current log e-values, as
-- reported by each module's @log_evalue@ accessor, one entry per
-- component in a fixed order.
--
-- Two preconditions are the caller's responsibility, and the
-- type-I guarantee depends on both:
--
--   1. Each entry must be the current log e-value of a genuine
--      e-process for @H_0@, and all components must be adapted to
--      the same filtration and stepped in lockstep -- 'update' is
--      called exactly once per underlying observation, after all
--      components have absorbed it.
--
--   2. The vector must have exactly the @K@ entries declared in
--      'config', always in the same order.
--
-- The rejection latch is kept on the supremum of the /mixture's/
-- log-wealth. Latching (or summing) per-component suprema instead
-- would combine peaks attained at different times -- a quantity
-- that can exceed anything the mixture ever reached, silently
-- inflating the effective alpha. Ville's inequality bounds the
-- mixture's own supremum; that is the only sound latch, and it is
-- the one this module maintains.
--
-- == Example
--
-- Combine a sign test and a magnitude test running against the same
-- stream of differences @d_t@ (the shape used for two-channel
-- symmetry testing):
--
-- >>> import qualified Numeric.Eproc.Bernoulli.TwoSided as Sign
-- >>> import qualified Numeric.Eproc.Bounded as Magn
-- >>> import qualified Numeric.Eproc.Mixture as Mix
-- >>> let Right sc = Sign.config 0.5 1.0e-3 Sign.Newton
-- >>> let Right mc = Magn.config 0.0 (-1.0) 1.0 1.0e-3 Magn.Newton
-- >>> let Right xc = Mix.config 2 1.0e-3
-- >>> :{
-- let step (s, m, x) d =
--       let s' = Sign.update sc s (d > 0)
--           m' = Magn.update mc m d
--       in  (s', m', Mix.update xc x
--                      [Sign.log_evalue s', Magn.log_evalue m'])
-- :}
-- >>> let ds = take 400 (cycle [0.6, 0.7, -0.2, 0.8])
-- >>> let z0 = (Sign.initial sc, Magn.initial mc, Mix.initial xc)
-- >>> let (_, _, xf) = foldl' step z0 ds
-- >>> Mix.decide xc xf
-- Reject
-- >>> Mix.p_value xc xf
-- 9.482234479673792e-34

module Numeric.Eproc.Mixture (
  -- * Mixture configuration and state
    Config
  , State
  , Verdict(..)
  , ConfigError(..)

  -- * Construction
  , config
  , initial

  -- * Streaming
  , update
  , decide

  -- * Inspection
  , log_wealth
  , log_wealth_sup
  , log_evalue
  , log_evalue_sup
  , p_value
  , samples
  ) where

import Numeric.Eproc.Common (Verdict(..), ConfigError(..), finite)

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

-- | Mixture configuration. Build with 'config'.
--
--   Carries the component count @K@, the significance level, the
--   precomputed rejection threshold @log(K \/ alpha)@, and @log K@
--   (the mixture log-wealth of a fresh state).
data Config = Config {
    -- ^ component count @K@
    cfg_k          :: {-# UNPACK #-} !Int
    -- ^ significance level @alpha@
  , cfg_alpha      :: {-# UNPACK #-} !Double
    -- ^ rejection threshold @log(K \/ alpha)@
  , cfg_log_thresh :: {-# UNPACK #-} !Double
    -- ^ @log K@
  , cfg_log_k      :: {-# UNPACK #-} !Double
  }

-- | Streaming mixture state. Construct with 'initial' and fold
--   per-step component log e-value vectors through 'update'.
--
--   Tracks the current mixture log-wealth @log(sum_i E^i_t)@ and
--   its latched supremum, which is what 'decide' tests against the
--   rejection threshold.
data State = State {
    st_n           :: {-# UNPACK #-} !Int     -- ^ update count
  , st_log_sum     :: {-# UNPACK #-} !Double  -- ^ log(sum_i E^i)
  , st_sup_log_sum :: {-# UNPACK #-} !Double  -- ^ sup of the above
  }

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

-- | Build a 'Config' for a @K@-component uniform mixture at level
--   @alpha@.
--
--   The rejection threshold is precomputed as @log(K \/ alpha)@:
--   the mixture @M_t = (sum_i E^i_t) \/ K@ crosses @1 \/ alpha@
--   exactly when the sum crosses @K \/ alpha@.
--
--   Returns 'Left' with a 'ConfigError' on inputs outside the
--   mathematical regime: @K < 1@, or @alpha@ non-finite or outside
--   @(0, 1)@.
--
--   >>> let Right cfg = config 4 1.0e-3
config
  :: Int     -- ^ component count @K@
  -> Double  -- ^ significance level @alpha@
  -> Either ConfigError Config
config !k !alpha
  | k < 1 =
      Left (InvalidComponentCount k)
  | not (finite alpha && alpha > 0 && alpha < 1) =
      Left (InvalidAlpha alpha)
  | otherwise =
      let !kd = fromIntegral k
      in  Right Config {
              cfg_k          = k
            , cfg_alpha      = alpha
            , cfg_log_thresh = log (kd / alpha)
            , cfg_log_k      = log kd
            }
{-# INLINE config #-}

-- | The initial 'State' for a fresh mixture.
--
--   Every component starts at e-value @1@, so the mixture log-sum
--   (and its supremum) starts at @log K@.
--
--   >>> let s0 = initial cfg
initial :: Config -> State
initial Config{..} = State {
    st_n           = 0
  , st_log_sum     = cfg_log_k
  , st_sup_log_sum = cfg_log_k
  }
{-# INLINE initial #-}

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

-- | Fold one step's component log e-values into the running
--   'State': computes the current mixture log-sum via a numerically
--   stable log-sum-exp and latches its supremum.
--
--   /Preconditions/ (documented in the module header, unchecked
--   here): the vector holds exactly the @K@ log e-values of
--   components adapted to a common filtration, in a fixed order,
--   with 'update' called once per underlying observation. The
--   degenerate empty vector leaves the state unchanged.
--
--   >>> let s1 = update cfg s0 [0.1, -0.2, 0.0, 0.4]
update :: Config -> State -> [Double] -> State
update _ st@State{..} les = case les of
  []       -> st
  (l : ls) ->
    let !m = foldl' max l ls
        !s = foldl' (\ !acc v -> acc + exp (v - m)) 0 les
        -- all components at e-value zero: the mixture log-sum is
        -- -Infinity, and (m +) would poison it into NaN.
        !cur | isInfinite m && m < 0 = m
             | otherwise             = m + log s
    in  State {
            st_n           = st_n + 1
          , st_log_sum     = cur
          , st_sup_log_sum = max st_sup_log_sum cur
          }
{-# INLINE update #-}

-- | Compute the current 'Verdict' from the running 'State'.
--
--   'Reject' iff the supremum-so-far of @log(sum_i E^i_t)@ has ever
--   crossed @log(K \/ alpha)@ -- equivalently, the mixture
--   e-process @M_t@ has exceeded @1 \/ alpha@ at some point in the
--   stream so far. Under the combined @H_0@, by Ville's inequality,
--   the probability of this ever happening is at most @alpha@,
--   simultaneously over all sample sizes: peek and stop freely.
--
--   >>> decide cfg s0
--   Continue
decide :: Config -> State -> Verdict
decide Config{..} State{..}
  | st_sup_log_sum >= cfg_log_thresh = Reject
  | otherwise                        = Continue
{-# INLINE decide #-}

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

-- | The current mixture log-wealth @log(sum_i E^i_t)@, before
--   normalization by @K@. Not monotone; bounded above by
--   'log_wealth_sup'. Starts at @log K@.
--
--   >>> log_wealth s0
--   1.3862943611198906
log_wealth :: State -> Double
log_wealth = st_log_sum
{-# INLINE log_wealth #-}

-- | The supremum-so-far of @log(sum_i E^i_t)@. Monotone
--   nondecreasing; 'decide' rejects exactly when it crosses
--   @log(K \/ alpha)@. Starts at @log K@.
--
--   >>> log_wealth_sup s0
--   1.3862943611198906
log_wealth_sup :: State -> Double
log_wealth_sup = st_sup_log_sum
{-# INLINE log_wealth_sup #-}

-- | The current log e-value of the mixture: the log of
--   @M_t = (sum_i E^i_t) \/ K@, i.e. 'log_wealth' minus @log K@,
--   normalized so a fresh state sits at @0@. This is itself a
--   component-shaped quantity: mixtures nest, so it can in turn be
--   fed to an outer mixture. Not monotone; bounded above by
--   'log_evalue_sup'.
--
--   >>> log_evalue s0
--   0.0
log_evalue :: Config -> State -> Double
log_evalue Config{..} State{..} = st_log_sum - cfg_log_k
{-# INLINE log_evalue #-}

-- | The supremum-so-far of the log e-value: 'log_wealth_sup' minus
--   @log K@. Monotone nondecreasing, starting at @0@; 'decide'
--   rejects exactly when it crosses @log(1 \/ alpha)@.
--
--   >>> log_evalue_sup s0
--   0.0
log_evalue_sup :: Config -> State -> Double
log_evalue_sup Config{..} State{..} = st_sup_log_sum - cfg_log_k
{-# INLINE log_evalue_sup #-}

-- | The anytime-valid p-value: the reciprocal of the largest
--   mixture e-value attained so far. Monotone nonincreasing; under
--   the combined @H_0@, @P(exists t: p_t <= alpha) <= alpha@ for
--   every @alpha@ simultaneously. 'decide' returns 'Reject' exactly
--   when this value has reached the configured @alpha@ or below.
--
--   >>> p_value cfg s0
--   1.0
p_value :: Config -> State -> Double
p_value cfg s = min 1 (exp (negate (log_evalue_sup cfg s)))
{-# INLINE p_value #-}

-- | The number of 'update' steps consumed so far.
--
--   >>> samples s0
--   0
samples :: State -> Int
samples = st_n
{-# INLINE samples #-}