commit 347be5310855537b93d034bbd5338ce6b5afeca9
parent dcd9754da35f9ded3faeef878fbf234497ed11c2
Author: Jared Tobin <jared@jtobin.io>
Date: Wed, 3 Jun 2026 11:40:29 -0230
fold Bettor into Bounded; drop Numeric.Eproc.Bettor
The Bettor module was thin -- just a three-constructor enum and its
documentation -- and the only module that actually case-analyzes on
Bettor is Bounded (Paired delegates to Bounded internally). Moving
the type to where it is used eliminates the thin module without
duplicating across the two test families.
* 'data Bettor' now lives in Numeric.Eproc.Bounded and is exported
alongside Config / State / Verdict.
* Numeric.Eproc.Paired re-exports Bettor(..) the same way it
re-exports Verdict(..); no API change at the Paired call site.
* Numeric.Eproc.Bettor deleted; removed from cabal exposed-modules.
* Tests, benches, and the README drop the 'B' import; bettor
constructors are referenced as Bounded.Ons / Bounded.Agrapa /
Bounded.Fixed throughout (and as the bare constructors inside the
library's own haddock examples).
Tests pass.
Diffstat:
8 files changed, 79 insertions(+), 107 deletions(-)
diff --git a/README.md b/README.md
@@ -17,12 +17,11 @@ A sample GHCi session:
```
> -- import qualified
- > import qualified Numeric.Eproc.Bettor as B
> import qualified Numeric.Eproc.Bounded as Bounded
>
> -- test H_0: E[X] = 0.5 for samples in [0, 1] at alpha = 1e-3,
> -- with the ONS bettor
- > let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ > let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
>
> -- streaming interface: 'initial' then fold observations through 'update'
> let s0 = Bounded.initial cfg
@@ -113,7 +112,7 @@ to get a REPL for the main library.
## References
- Waudby-Smith & Ramdas (2024), "[Estimating means of bounded random
- variables by betting][wsr24]." JRSS-B.
+ variables by betting][wsr24]." JRSS-Bounded.
- Ramdas, Grunwald, Vovk, Shafer (2023), "[Game-theoretic statistics
and safe anytime-valid inference][rgvs23]." Statistical Science.
- Shafer (2021), "[Testing by betting][shafer21]." JRSS-A.
diff --git a/bench/Main.hs b/bench/Main.hs
@@ -4,7 +4,6 @@
module Main where
import Control.DeepSeq
-import qualified Numeric.Eproc.Bettor as B
import qualified Numeric.Eproc.Bounded as Bounded
import qualified Numeric.Eproc.Paired as P
import Criterion.Main
@@ -26,9 +25,9 @@ main = defaultMain [
update :: Benchmark
update =
- let !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (B.Fixed 0.5)
- !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa
- !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ 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.Agrapa
+ !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
!st_f = Bounded.initial cfg_f
!st_a = Bounded.initial cfg_a
!st_o = Bounded.initial cfg_o
@@ -41,7 +40,7 @@ update =
decide :: Benchmark
decide =
- let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
!st = Bounded.initial cfg
in bgroup "Bounded.decide" [
bench "initial state" $ nf (Bounded.decide cfg) st
@@ -50,9 +49,9 @@ decide =
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 (B.Fixed 0.5)
- !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa
- !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ !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.Agrapa
+ !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
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
@@ -63,9 +62,9 @@ stream =
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 (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
+ !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.Agrapa
+ !cfg_o = P.config 0.0 1.0 1.0e-3 Bounded.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
diff --git a/bench/Weight.hs b/bench/Weight.hs
@@ -4,7 +4,6 @@
module Main where
import Control.DeepSeq
-import qualified Numeric.Eproc.Bettor as B
import qualified Numeric.Eproc.Bounded as Bounded
import qualified Numeric.Eproc.Paired as P
import Weigh
@@ -23,9 +22,9 @@ main = mainWith $ do
update :: Weigh ()
update =
- let !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (B.Fixed 0.5)
- !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa
- !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ 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.Agrapa
+ !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
!st_f = Bounded.initial cfg_f
!st_a = Bounded.initial cfg_a
!st_o = Bounded.initial cfg_o
@@ -36,7 +35,7 @@ update =
decide :: Weigh ()
decide =
- let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
!st = Bounded.initial cfg
in wgroup "Bounded.decide" $ do
func "initial state" (Bounded.decide cfg) st
@@ -44,9 +43,9 @@ decide =
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 (B.Fixed 0.5)
- !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa
- !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ !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.Agrapa
+ !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
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
@@ -56,9 +55,9 @@ stream =
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 (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
+ !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.Agrapa
+ !cfg_o = P.config 0.0 1.0 1.0e-3 Bounded.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
diff --git a/lib/Numeric/Eproc/Bettor.hs b/lib/Numeric/Eproc/Bettor.hs
@@ -1,62 +0,0 @@
-{-# OPTIONS_HADDOCK prune #-}
-{-# LANGUAGE BangPatterns #-}
-
--- |
--- Module: Numeric.Eproc.Bettor
--- Copyright: (c) 2026 Jared Tobin
--- License: MIT
--- Maintainer: Jared Tobin <jared@ppad.tech>
---
--- Bettor strategies for the e-process framework.
---
--- A bettor describes how, given the history of centred observations
--- @z_t = x_t - m@ (where @x_t@ is the new observation and @m@ is the
--- null mean), the next predictable bet @lambda_t@ is chosen. The
--- wealth process is the running product of per-step factors
---
--- @W_t = prod_{s <= t} (1 + lambda_s * z_s)@
---
--- and the test rejects when @W_t@ crosses @1\/alpha@. Predictability
--- -- that is, @lambda_t@ depends only on data observed strictly
--- before step @t@ -- is what makes @W@ a nonnegative supermartingale
--- under @H_0@, so that Ville's inequality applies and the resulting
--- test is anytime-valid.
-
-module Numeric.Eproc.Bettor (
- -- * Bettor strategies
- Bettor(..)
- ) where
-
--- bettor strategies ----------------------------------------------------------
-
--- | A predictable bettor.
---
--- For 'Agrapa' and 'Ons', a per-direction safe-bet ceiling
--- @lambda_max@ is derived from the sample bounds supplied to the
--- surrounding test configuration (e.g.
--- 'Numeric.Eproc.Bounded.config') -- bets get clipped to
--- @[0, lambda_max]@ so that the wealth factor @1 + lambda * z@
--- stays nonnegative for every admissible observation.
---
--- * 'Fixed' always bets the supplied constant @lambda@. The wager
--- does not respond to observed data; this strategy is useful only
--- as a baseline.
---
--- * 'Agrapa' is the aGRAPA (approximate growth-rate adaptive
--- predictable plug-in) bettor of Waudby-Smith & Ramdas (2024).
--- It tracks the empirical mean @mu@ and variance @sigma^2@ of
--- centred observations and bets the Kelly-optimal plug-in
--- @lambda* = mu \/ (sigma^2 + mu^2)@ clipped to
--- @[0, lambda_max]@. Fast to compute and competitive in practice.
---
--- * 'Ons' is the online Newton step bettor. 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.
-data Bettor =
- Fixed {-# UNPACK #-} !Double
- | Agrapa
- | Ons
- deriving (Eq, Show)
diff --git a/lib/Numeric/Eproc/Bounded.hs b/lib/Numeric/Eproc/Bounded.hs
@@ -33,6 +33,9 @@ module Numeric.Eproc.Bounded (
, State
, Verdict(..)
+ -- * Bettor strategies
+ , Bettor(..)
+
-- * Construction
, config
, initial
@@ -47,10 +50,46 @@ module Numeric.Eproc.Bounded (
) where
import GHC.Exts (Double(D#))
-import Numeric.Eproc.Bettor
-- types ----------------------------------------------------------------------
+-- | A predictable bettor.
+--
+-- A bettor describes how, given the history of centred observations
+-- @z_t = x_t - m@, the next predictable bet @lambda_t@ is chosen.
+-- Predictability -- that is, @lambda_t@ depends only on data
+-- observed strictly before step @t@ -- is what makes the resulting
+-- wealth process a nonnegative supermartingale under @H_0@.
+--
+-- For 'Agrapa' and 'Ons', a per-direction safe-bet ceiling
+-- @lambda_max@ is derived from the sample bounds supplied to
+-- 'config' -- bets get clipped to @[0, lambda_max]@ so that the
+-- wealth factor @1 + lambda * z@ stays nonnegative for every
+-- admissible observation.
+--
+-- * 'Fixed' always bets the supplied constant @lambda@. The wager
+-- does not respond to observed data; this strategy is useful only
+-- as a baseline.
+--
+-- * 'Agrapa' is the aGRAPA (approximate growth-rate adaptive
+-- predictable plug-in) bettor of Waudby-Smith & Ramdas (2024).
+-- It tracks the empirical mean @mu@ and variance @sigma^2@ of
+-- centred observations and bets the Kelly-optimal plug-in
+-- @lambda* = mu \/ (sigma^2 + mu^2)@ clipped to
+-- @[0, lambda_max]@. Fast to compute and competitive in practice.
+--
+-- * 'Ons' is the online Newton step bettor. 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.
+data Bettor =
+ Fixed {-# UNPACK #-} !Double
+ | Agrapa
+ | Ons
+ deriving (Eq, Show)
+
-- | Test outcome at the current sample count.
--
-- 'Reject' means the wealth process has crossed the Bonferroni
@@ -197,8 +236,7 @@ step_bet b !lam_max !s !z = case b of
-- @log(2 \/ alpha)@; the 2 is the Bonferroni union-bound
-- adjustment for the two one-sided e-processes.
--
--- >>> import qualified Numeric.Eproc.Bettor as B
--- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 B.Ons
+-- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 Ons
config
:: Double -- ^ null mean @m@
-> Double -- ^ sample lower bound @lo@
diff --git a/lib/Numeric/Eproc/Paired.hs b/lib/Numeric/Eproc/Paired.hs
@@ -30,6 +30,9 @@ module Numeric.Eproc.Paired (
, State
, Verdict(..)
+ -- * Bettor strategies
+ , Bettor(..)
+
-- * Construction
, config
, initial
@@ -44,8 +47,7 @@ module Numeric.Eproc.Paired (
) where
import qualified Numeric.Eproc.Bounded as Bounded
-import Numeric.Eproc.Bounded (Verdict(..))
-import Numeric.Eproc.Bettor (Bettor)
+import Numeric.Eproc.Bounded (Verdict(..), Bettor(..))
-- types ----------------------------------------------------------------------
@@ -67,8 +69,7 @@ newtype State = State Bounded.State
-- 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
+-- >>> let cfg = config 0.0 1.0 1.0e-3 Ons
config
:: Double -- ^ sample lower bound @lo@
-> Double -- ^ sample upper bound @hi@
diff --git a/ppad-eproc.cabal b/ppad-eproc.cabal
@@ -34,7 +34,6 @@ library
if flag(llvm)
ghc-options: -fllvm -O2
exposed-modules:
- Numeric.Eproc.Bettor
Numeric.Eproc.Bounded
Numeric.Eproc.Paired
build-depends:
diff --git a/test/Main.hs b/test/Main.hs
@@ -4,7 +4,6 @@ module Main where
import Data.Bits
import Data.Word
-import qualified Numeric.Eproc.Bettor as B
import qualified Numeric.Eproc.Bounded as Bounded
import qualified Numeric.Eproc.Paired as P
import Test.Tasty
@@ -127,12 +126,12 @@ paired_avg_rate cfg pa pb budget trials seed =
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 B.Ons
+ let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Ons
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 B.Ons
+ let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Ons
Bounded.decide cfg (Bounded.initial cfg) @?= Bounded.Continue
]
@@ -144,14 +143,14 @@ sanity_tests = testGroup "sanity" [
calibration_tests :: TestTree
calibration_tests = testGroup "null calibration" [
testCase "ONS, Bernoulli(0.5), m=0.5, alpha=0.05" $ do
- let cfg = Bounded.config 0.5 0.0 1.0 0.05 B.Ons
+ let cfg = Bounded.config 0.5 0.0 1.0 0.05 Bounded.Ons
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.
assertBool ("FPR " ++ show rate ++ " exceeded slack") $
rate <= 0.10
, testCase "aGRAPA, Bernoulli(0.5), m=0.5, alpha=0.05" $ do
- let cfg = Bounded.config 0.5 0.0 1.0 0.05 B.Agrapa
+ let cfg = Bounded.config 0.5 0.0 1.0 0.05 Bounded.Agrapa
rate = rejection_rate cfg 0.5 2000 200 67890
assertBool ("FPR " ++ show rate ++ " exceeded slack") $
rate <= 0.10
@@ -163,12 +162,12 @@ calibration_tests = testGroup "null calibration" [
power_tests :: TestTree
power_tests = testGroup "power" [
testCase "ONS detects Bernoulli(0.7) vs m=0.5" $ do
- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
rate = rejection_rate cfg 0.7 5000 100 11111
assertBool ("power " ++ show rate ++ " too low") $
rate >= 0.95
, testCase "aGRAPA detects Bernoulli(0.7) vs m=0.5" $ do
- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa
+ let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Agrapa
rate = rejection_rate cfg 0.7 5000 100 22222
assertBool ("power " ++ show rate ++ " too low") $
rate >= 0.95
@@ -179,11 +178,11 @@ power_tests = testGroup "power" [
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 B.Ons
+ let cfg = P.config 0.0 1.0 1.0e-3 Bounded.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 = P.config 0.0 1.0 1.0e-3 B.Ons
+ let cfg = P.config 0.0 1.0 1.0e-3 Bounded.Ons
rate = paired_avg_rate cfg 0.3 0.7 5000 100 44444
assertBool ("power " ++ show rate) $ rate >= 0.95
]
@@ -195,17 +194,17 @@ two_sample_tests = testGroup "two-sample" [
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 (B.Fixed 0.5)
+ let cfg = 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 "ONS bettor runs without error" $ do
- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons
+ let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons
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 "aGRAPA bettor runs without error" $ do
- let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa
+ let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Agrapa
xs = take 100 (cycle [0.0, 1.0])
st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs
assertBool "samples advanced" (Bounded.samples st == 100)