eproc

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 8b73a8dc0a86c40d9e7845ce275580f9e87d083a
parent c8c70f93df09bbe173951220453ef661036d33e3
Author: Jared Tobin <jared@jtobin.io>
Date:   Wed,  3 Jun 2026 10:29:16 -0230

rename Test to Paired

'Test' was overly generic given that Mean is itself a test (the
two-sided bounded-mean test). 'Paired' more precisely names what the
module provides: the paired two-sample mean-equality test.

Module Numeric.Eproc.Test becomes Numeric.Eproc.Paired; import alias
'T' becomes 'P'; benchmark bgroup labels ('Test.update ...') updated
to 'Paired.update ...'.

No semantic / API changes; tests pass.

Diffstat:
MREADME.md | 2+-
Mbench/Main.hs | 14+++++++-------
Mbench/Weight.hs | 14+++++++-------
Alib/Numeric/Eproc/Paired.hs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dlib/Numeric/Eproc/Test.hs | 132-------------------------------------------------------------------------------
Mppad-eproc.cabal | 2+-
Mtest/Main.hs | 20++++++++++----------
7 files changed, 158 insertions(+), 158 deletions(-)

diff --git a/README.md b/README.md @@ -46,7 +46,7 @@ A sample GHCi session: ``` For the paired two-sample mean-equality test, see -`Numeric.Eproc.Test`. +`Numeric.Eproc.Paired`. ## Documentation diff --git a/bench/Main.hs b/bench/Main.hs @@ -6,14 +6,14 @@ module Main where import Control.DeepSeq import qualified Numeric.Eproc.Bettor as B import qualified Numeric.Eproc.Mean as M -import qualified Numeric.Eproc.Test as T +import qualified Numeric.Eproc.Paired as P import Criterion.Main -- all relevant fields are strict (and UNPACK'd for the doubles), so -- WHNF == NF for these types. orphan instances keep the library API -- untouched. instance NFData M.State where rnf !_ = () -instance NFData T.State where rnf !_ = () +instance NFData P.State where rnf !_ = () instance NFData M.Verdict where rnf !_ = () main :: IO () @@ -63,11 +63,11 @@ stream = twosample :: Benchmark twosample = let !ps = force (take 1000 (cycle [(0.3, 0.7), (0.7, 0.3)])) - !cfg_f = T.config 0.0 1.0 1.0e-3 (B.Fixed 0.5) - !cfg_a = T.config 0.0 1.0 1.0e-3 B.Agrapa - !cfg_o = T.config 0.0 1.0 1.0e-3 B.Ons - run_t cfg = foldl' (T.update cfg) (T.initial cfg) - in bgroup "Test.update (1000-sample fold)" [ + !cfg_f = P.config 0.0 1.0 1.0e-3 (B.Fixed 0.5) + !cfg_a = P.config 0.0 1.0 1.0e-3 B.Agrapa + !cfg_o = P.config 0.0 1.0 1.0e-3 B.Ons + 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 "agrapa" $ nf (run_t cfg_a) ps , bench "ons" $ nf (run_t cfg_o) ps diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -6,11 +6,11 @@ module Main where import Control.DeepSeq import qualified Numeric.Eproc.Bettor as B import qualified Numeric.Eproc.Mean as M -import qualified Numeric.Eproc.Test as T +import qualified Numeric.Eproc.Paired as P import Weigh instance NFData M.State where rnf !_ = () -instance NFData T.State where rnf !_ = () +instance NFData P.State where rnf !_ = () instance NFData M.Verdict where rnf !_ = () -- note that 'weigh' doesn't work properly in a repl @@ -56,11 +56,11 @@ stream = twosample :: Weigh () twosample = let !ps = force (take 1000 (cycle [(0.3, 0.7), (0.7, 0.3)])) - !cfg_f = T.config 0.0 1.0 1.0e-3 (B.Fixed 0.5) - !cfg_a = T.config 0.0 1.0 1.0e-3 B.Agrapa - !cfg_o = T.config 0.0 1.0 1.0e-3 B.Ons - run_t cfg = foldl' (T.update cfg) (T.initial cfg) - in wgroup "Test.update (1000-sample fold)" $ do + !cfg_f = P.config 0.0 1.0 1.0e-3 (B.Fixed 0.5) + !cfg_a = P.config 0.0 1.0 1.0e-3 B.Agrapa + !cfg_o = P.config 0.0 1.0 1.0e-3 B.Ons + 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 "agrapa" (run_t cfg_a) ps func "ons" (run_t cfg_o) ps diff --git a/lib/Numeric/Eproc/Paired.hs b/lib/Numeric/Eproc/Paired.hs @@ -0,0 +1,132 @@ +{-# 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.Mean") 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. + +module Numeric.Eproc.Paired ( + -- * Test configuration and state + Config + , State + , Verdict(..) + + -- * Construction + , config + , initial + + -- * Streaming + , update + , decide + + -- * Inspection + , log_wealth + , samples + ) where + +import qualified Numeric.Eproc.Mean as M +import Numeric.Eproc.Mean (Verdict(..)) +import Numeric.Eproc.Bettor (Bettor) + +-- types ---------------------------------------------------------------------- + +-- | Paired two-sample test configuration. Build with 'config'. Wraps +-- a 'Numeric.Eproc.Mean.Config' for the underlying +-- difference test. +newtype Config = Config M.Config + +-- | Streaming paired two-sample test state. Construct with 'initial' +-- and fold paired observations through 'update'. +newtype State = State M.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@. +-- +-- >>> import qualified Numeric.Eproc.Bettor as B +-- >>> let cfg = config 0.0 1.0 1.0e-3 B.Ons +config + :: Double -- ^ sample lower bound @lo@ + -> Double -- ^ sample upper bound @hi@ + -> Double -- ^ significance level @alpha@ + -> Bettor -- ^ bettor strategy + -> Config +config !lo !hi !alpha b = + let !d = hi - lo + in Config (M.config 0 (negate d) d alpha b) +{-# INLINE config #-} + +-- | The initial 'State' for a fresh streaming test. +-- +-- >>> let s0 = initial cfg +initial :: Config -> State +initial (Config c) = State (M.initial 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 c) (State s) (!a, !b) = + State (M.update c s (a - 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 c) (State s) = M.decide c 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 s) = M.log_wealth s +{-# INLINE log_wealth #-} + +-- | The number of paired observations consumed so far. +-- +-- >>> samples s0 +-- 0 +samples :: State -> Int +samples (State s) = M.samples s +{-# INLINE samples #-} diff --git a/lib/Numeric/Eproc/Test.hs b/lib/Numeric/Eproc/Test.hs @@ -1,132 +0,0 @@ -{-# OPTIONS_HADDOCK prune #-} -{-# LANGUAGE BangPatterns #-} - --- | --- Module: Numeric.Eproc.Test --- 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.Mean") 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. - -module Numeric.Eproc.Test ( - -- * Test configuration and state - Config - , State - , Verdict(..) - - -- * Construction - , config - , initial - - -- * Streaming - , update - , decide - - -- * Inspection - , log_wealth - , samples - ) where - -import qualified Numeric.Eproc.Mean as M -import Numeric.Eproc.Mean (Verdict(..)) -import Numeric.Eproc.Bettor (Bettor) - --- types ---------------------------------------------------------------------- - --- | Paired two-sample test configuration. Build with 'config'. Wraps --- a 'Numeric.Eproc.Mean.Config' for the underlying --- difference test. -newtype Config = Config M.Config - --- | Streaming paired two-sample test state. Construct with 'initial' --- and fold paired observations through 'update'. -newtype State = State M.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@. --- --- >>> import qualified Numeric.Eproc.Bettor as B --- >>> let cfg = config 0.0 1.0 1.0e-3 B.Ons -config - :: Double -- ^ sample lower bound @lo@ - -> Double -- ^ sample upper bound @hi@ - -> Double -- ^ significance level @alpha@ - -> Bettor -- ^ bettor strategy - -> Config -config !lo !hi !alpha b = - let !d = hi - lo - in Config (M.config 0 (negate d) d alpha b) -{-# INLINE config #-} - --- | The initial 'State' for a fresh streaming test. --- --- >>> let s0 = initial cfg -initial :: Config -> State -initial (Config c) = State (M.initial 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 c) (State s) (!a, !b) = - State (M.update c s (a - 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 c) (State s) = M.decide c 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 s) = M.log_wealth s -{-# INLINE log_wealth #-} - --- | The number of paired observations consumed so far. --- --- >>> samples s0 --- 0 -samples :: State -> Int -samples (State s) = M.samples s -{-# INLINE samples #-} diff --git a/ppad-eproc.cabal b/ppad-eproc.cabal @@ -36,7 +36,7 @@ library exposed-modules: Numeric.Eproc.Bettor Numeric.Eproc.Mean - Numeric.Eproc.Test + Numeric.Eproc.Paired build-depends: base >= 4.9 && < 5 diff --git a/test/Main.hs b/test/Main.hs @@ -6,7 +6,7 @@ import Data.Bits import Data.Word import qualified Numeric.Eproc.Bettor as B import qualified Numeric.Eproc.Mean as M -import qualified Numeric.Eproc.Test as T +import qualified Numeric.Eproc.Paired as P import Test.Tasty import Test.Tasty.HUnit @@ -87,26 +87,26 @@ rejection_rate cfg p budget trials seed = in fromIntegral rejects / fromIntegral trials run_paired - :: T.Config + :: P.Config -> Double -> Double -- ^ p for A and B -> Int -> Gen - -> (T.Verdict, Int) -run_paired cfg pa pb budget g0 = go 0 g0 (T.initial cfg) + -> (P.Verdict, Int) +run_paired cfg pa pb budget g0 = go 0 g0 (P.initial cfg) where go !n !g !st - | n >= budget = (T.decide cfg st, n) - | otherwise = case T.decide cfg st of + | n >= budget = (P.decide cfg st, n) + | otherwise = case P.decide cfg st of M.Reject -> (M.Reject, n) M.Continue -> let (a, g1) = bernoulli pa g (b, g2) = bernoulli pb g1 - st' = T.update cfg st (a, b) + st' = P.update cfg st (a, b) in go (n + 1) g2 st' paired_avg_rate - :: T.Config + :: P.Config -> Double -> Double -> Int @@ -179,11 +179,11 @@ power_tests = testGroup "power" [ two_sample_tests :: TestTree two_sample_tests = testGroup "two-sample" [ testCase "identical distributions don't reject" $ do - let cfg = T.config 0.0 1.0 1.0e-3 B.Ons + let cfg = P.config 0.0 1.0 1.0e-3 B.Ons 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 = T.config 0.0 1.0 1.0e-3 B.Ons + let cfg = P.config 0.0 1.0 1.0e-3 B.Ons rate = paired_avg_rate cfg 0.3 0.7 5000 100 44444 assertBool ("power " ++ show rate) $ rate >= 0.95 ]