packages feed

golds-gym 0.3.0.0 → 0.4.0.0

raw patch · 10 files changed

+801/−161 lines, 10 filesdep +QuickCheckdep +deepseqPVP ok

version bump matches the API change (PVP)

Dependencies added: QuickCheck, deepseq

API changes (from Hackage documentation)

+ Test.Hspec.BenchGolden: BenchAction :: (Word64 -> IO ()) -> BenchAction
+ Test.Hspec.BenchGolden: [runBenchAction] :: BenchAction -> Word64 -> IO ()
+ Test.Hspec.BenchGolden: io :: IO () -> BenchAction
+ Test.Hspec.BenchGolden: newtype BenchAction
+ Test.Hspec.BenchGolden: nf :: NFData b => (a -> b) -> a -> BenchAction
+ Test.Hspec.BenchGolden: nfAppIO :: NFData b => (a -> IO b) -> a -> BenchAction
+ Test.Hspec.BenchGolden: nfIO :: NFData a => IO a -> BenchAction
+ Test.Hspec.BenchGolden: toleranceFromExpectation :: Expectation -> (Double, Maybe Double)
+ Test.Hspec.BenchGolden: toleranceValues :: Tolerance -> (Double, Maybe Double)
+ Test.Hspec.BenchGolden: whnf :: (a -> b) -> a -> BenchAction
+ Test.Hspec.BenchGolden: whnfAppIO :: (a -> IO b) -> a -> BenchAction
+ Test.Hspec.BenchGolden: whnfIO :: IO a -> BenchAction
+ Test.Hspec.BenchGolden.Lenses: toleranceFromExpectation :: Expectation -> (Double, Maybe Double)
+ Test.Hspec.BenchGolden.Lenses: toleranceValues :: Tolerance -> (Double, Maybe Double)
+ Test.Hspec.BenchGolden.Runner: io :: IO () -> BenchAction
+ Test.Hspec.BenchGolden.Runner: nf :: NFData b => (a -> b) -> a -> BenchAction
+ Test.Hspec.BenchGolden.Runner: nfAppIO :: NFData b => (a -> IO b) -> a -> BenchAction
+ Test.Hspec.BenchGolden.Runner: nfIO :: NFData a => IO a -> BenchAction
+ Test.Hspec.BenchGolden.Runner: whnf :: (a -> b) -> a -> BenchAction
+ Test.Hspec.BenchGolden.Runner: whnfAppIO :: (a -> IO b) -> a -> BenchAction
+ Test.Hspec.BenchGolden.Runner: whnfIO :: IO a -> BenchAction
+ Test.Hspec.BenchGolden.Types: BenchAction :: (Word64 -> IO ()) -> BenchAction
+ Test.Hspec.BenchGolden.Types: [runBenchAction] :: BenchAction -> Word64 -> IO ()
+ Test.Hspec.BenchGolden.Types: newtype BenchAction
- Test.Hspec.BenchGolden: BenchGolden :: !String -> !IO () -> !BenchConfig -> BenchGolden
+ Test.Hspec.BenchGolden: BenchGolden :: !String -> !BenchAction -> !BenchConfig -> BenchGolden
- Test.Hspec.BenchGolden: [benchAction] :: BenchGolden -> !IO ()
+ Test.Hspec.BenchGolden: [benchAction] :: BenchGolden -> !BenchAction
- Test.Hspec.BenchGolden: benchGolden :: String -> IO () -> Spec
+ Test.Hspec.BenchGolden: benchGolden :: String -> BenchAction -> Spec
- Test.Hspec.BenchGolden: benchGoldenWith :: BenchConfig -> String -> IO () -> Spec
+ Test.Hspec.BenchGolden: benchGoldenWith :: BenchConfig -> String -> BenchAction -> Spec
- Test.Hspec.BenchGolden: benchGoldenWithExpectation :: String -> BenchConfig -> [Expectation] -> IO () -> Spec
+ Test.Hspec.BenchGolden: benchGoldenWithExpectation :: String -> BenchConfig -> [Expectation] -> BenchAction -> Spec
- Test.Hspec.BenchGolden.Runner: runBenchmark :: String -> IO () -> BenchConfig -> ArchConfig -> IO GoldenStats
+ Test.Hspec.BenchGolden.Runner: runBenchmark :: String -> BenchAction -> BenchConfig -> ArchConfig -> IO GoldenStats
- Test.Hspec.BenchGolden.Runner: runBenchmarkWithRawTimings :: String -> IO () -> BenchConfig -> ArchConfig -> IO GoldenStats
+ Test.Hspec.BenchGolden.Runner: runBenchmarkWithRawTimings :: String -> BenchAction -> BenchConfig -> ArchConfig -> IO GoldenStats
- Test.Hspec.BenchGolden.Types: BenchGolden :: !String -> !IO () -> !BenchConfig -> BenchGolden
+ Test.Hspec.BenchGolden.Types: BenchGolden :: !String -> !BenchAction -> !BenchConfig -> BenchGolden
- Test.Hspec.BenchGolden.Types: [benchAction] :: BenchGolden -> !IO ()
+ Test.Hspec.BenchGolden.Types: [benchAction] :: BenchGolden -> !BenchAction

Files

CHANGELOG.md view
@@ -1,5 +1,54 @@ # Changelog +## [0.4.0]++### Added++- **QuickCheck property tests** for statistical computations in Runner module+  - 28 property tests covering mathematical invariants and edge cases+  - Tests for `calculateTrimmedMean`, `calculateMAD`, `calculateIQR`, `detectOutliers`+  - Tests for `compareStats` tolerance logic (hybrid percentage/absolute)+  - Tests for `checkVariance` warning generation+  - Custom generators for valid timing data and benchmark configurations+  - New test suite: `golds-gym-properties`++- **Evaluation strategy combinators** for proper laziness handling+  - `nf` - Force result to normal form (deep evaluation)+  - `whnf` - Force result to weak head normal form (shallow evaluation)+  - `nfIO` - Normal form for IO actions+  - `whnfIO` - Weak head normal form for IO actions+  - `nfAppIO` - Normal form for functions returning IO+  - `whnfAppIO` - WHNF for functions returning IO+  - `io` - Plain IO action (for backward compatibility)+  - Vendored evaluation loops from tasty-bench with attribution++- **`BenchAction` type** wrapping `Word64 -> IO ()` for benchmarkable actions+  - Enables direct benchmarking of pure functions without manual `evaluate` calls++### Changed++- **BREAKING:** All benchmark API functions now accept `BenchAction` instead of `IO ()`+  - `benchGolden :: String -> BenchAction -> Spec`+  - `benchGoldenWith :: BenchConfig -> String -> BenchAction -> Spec`+  - `benchGoldenWithExpectation :: String -> BenchConfig -> [Expectation] -> BenchAction -> Spec`+  - Migration: wrap existing `IO ()` actions with `io` combinator+  - New: use `nf`/`whnf` for pure functions instead of manual `evaluate`++- **Output format:** Baseline now appears before Actual in comparison tables++- **Error messages:** Tolerance values in failure messages are now extracted from expectations rather than config defaults++### Fixed++- Evaluation strategy bug where GHC could share computation across benchmark iterations+- Error message wording for performance improvements (now says "decreased by" instead of "increased by -X%")+- Flaky micro-benchmarks stabilized by increasing iteration counts (500-2000 iterations)++### Dependencies++- Added `deepseq >= 1.4 && < 2` for `NFData` constraint+- Added `QuickCheck >= 2.14 && < 3` for property tests (test suite only)+ ## [0.3.0]  ### Added
README.md view
@@ -15,26 +15,49 @@ ```haskell import Test.Hspec import Test.Hspec.BenchGolden+import Data.List (sort)  main :: IO () main = hspec $ do   describe "Performance" $ do-    -- Simple benchmark (100 iterations, ±15% tolerance)+    -- Pure function with normal form evaluation (deep, full evaluation)     benchGolden "list append" $-      return $ [1..1000] ++ [1..1000]+      nf (\xs -> xs ++ xs) [1..1000] +    -- Weak head normal form (shallow, outermost constructor only)+    benchGolden "replicate" $+      whnf (replicate 1000) 42+     -- Custom configuration     benchGoldenWith defaultBenchConfig       { iterations = 500       , tolerancePercent = 10.0       }       "sorting" $-      return $ sort [1000, 999..1]+      nf sort [1000, 999..1] ``` +**Evaluation strategies** (required - specify how values are forced):+- `nf f x` - Force result of `f x` to **normal form** (deep, full evaluation)+- `whnf f x` - Force result of `f x` to **weak head normal form** (shallow, outermost constructor only)+- `nfIO action` - Execute IO action and force result to normal form+- `whnfIO action` - Execute IO action and force result to WHNF+- `nfAppIO f x` - Apply function, execute resulting IO, force result to normal form+- `whnfAppIO f x` - Apply function, execute resulting IO, force result to WHNF+- `io action` - Plain IO action without additional forcing++**Why evaluation strategies matter**: Without forcing, GHC may optimize away computations or share results across iterations, making benchmarks meaningless. Use `nf` for most cases unless you specifically want lazy evaluation (`whnf`).+ **First run** creates `.golden/<arch>/list-append.golden` with baseline stats.  -**Subsequent runs** compare against baseline. Test fails if mean time changes by >15% (configurable).+**Subsequent runs** compare against baseline. Test fails if mean time changes beyond tolerance (default: ±15% OR ±0.01ms). +**Output format** :+```+Metric  Baseline    Actual      Diff+------  --------    ------      ----+Mean    0.150 ms  0.170 ms   +13.3%+```+ **Update baselines** after intentional changes: ```bash GOLDS_GYM_ACCEPT=1 stack test@@ -85,7 +108,7 @@   , outlierThreshold = 3.0      -- Flag outliers >3 MADs from median   }   "noisy benchmark" $-  return $ computation input+  nf computation input ```  **When to use:**@@ -104,14 +127,14 @@ -- Compare by median instead of mean (more robust) benchGoldenWithExpectation "median comparison" defaultBenchConfig   [expect _statsMedian (Percent 10.0)]-  myAction+  (nf myAlgorithm input)  -- Compose multiple requirements (both must pass) benchGoldenWithExpectation "strict requirements" defaultBenchConfig   [ expect _statsMean (Percent 15.0) &&~     expect _statsIQR (Absolute 0.1)     -- Low variance required   ]-  myAction+  (nf criticalFunction data) ```  **Available lenses:** `_statsMean`, `_statsMedian`, `_statsTrimmedMean`, `_statsStddev`, `_statsMAD`, `_statsIQR`, `_statsMin`, `_statsMax`@@ -130,6 +153,9 @@ - [API documentation](https://hackage.haskell.org/package/golds-gym) - Full Haddock docs - [Example benchmarks](example/Spec.hs) - Comprehensive usage examples - [CHANGELOG](CHANGELOG.md) - Version history and migration guides++## Related Work+* [tasty-bench](https://hackage.haskell.org/package/tasty-bench)  ## License 
example/Spec.hs view
@@ -1,11 +1,9 @@ -- | Example benchmark golden tests demonstrating golds-gym usage. module Main (main) where -import Control.Exception (evaluate) import Data.List (sort) import Test.Hspec import Test.Hspec.BenchGolden-import Test.Hspec.BenchGolden.Lenses  main :: IO () main = hspec spec@@ -13,27 +11,25 @@ spec :: Spec spec = do   describe "List Operations" $ do-    -- Simple benchmark with default configuration-    benchGolden "list append (1000 elements)" $ do-      _ <- evaluate $ [1..1000 :: Int] ++ [1..1000]-      return ()+    -- Simple benchmark with default configuration using nf for normal form+    benchGoldenWith defaultBenchConfig { iterations = 1000 }+      "list append (1000 elements)" $+      nf (\xs -> xs ++ xs) [1..1000 :: Int]      -- Benchmark with more iterations for stability-    benchGoldenWith defaultBenchConfig { iterations = 200 }-      "list reverse (5000 elements)" $ do-      _ <- evaluate $ reverse [1..5000 :: Int]-      return ()+    benchGoldenWith defaultBenchConfig { iterations = 1000 }+      "list reverse (5000 elements)" $+      nf reverse [1..5000 :: Int]    describe "Sorting Algorithms" $ do     -- Benchmark with tighter tolerance for critical code     benchGoldenWith defaultBenchConfig-      { iterations = 150+      { iterations = 1000       , tolerancePercent = 10.0       , warmupIterations = 10       }-      "sort 1000 elements" $ do-      _ <- evaluate $ sort [1000, 999..1 :: Int]-      return ()+      "sort 1000 elements" $+      nf sort [1000, 999..1 :: Int]      -- Benchmark with robust statistics for operations prone to outliers     benchGoldenWith defaultBenchConfig@@ -42,139 +38,110 @@       , outlierThreshold = 3.0       , warnOnVarianceChange = False       }-      "sort already sorted" $ do-      _ <- evaluate $ sort [1..1000 :: Int]-      return ()+      "sort already sorted" $+      nf sort [1..1000 :: Int]    describe "Numeric Operations" $ do-    benchGolden "fibonacci 30" $ do-      _ <- evaluate $ fib 30-      return ()+    benchGoldenWith defaultBenchConfig { iterations = 500 }+      "fibonacci 20" $+      nf fib 20      benchGoldenWith defaultBenchConfig-      { iterations = 500+      { iterations = 2000       , tolerancePercent = 20.0  -- Higher tolerance for fast operations       }-      "sum of list" $ do-      _ <- evaluate $ sum [1..10000 :: Int]-      return ()+      "sum of list" $+      nf (\n -> sum [1..n]) (10000 :: Int) +  describe "Best Practices: Avoiding Shared Thunks" $ do+    -- Lambda wrapper forces list reconstruction on each iteration+    -- The list [1..n] is rebuilt for every benchmark iteration+    benchGoldenWith defaultBenchConfig { iterations = 1000 }+      "proper data structure reconstruction" $+      nf (\n -> sum [1..n]) (5000 :: Int)++    benchGoldenWith defaultBenchConfig { iterations = 1000 }+      "function application with parameter" $+      nf reverse [1..5000 :: Int]++    benchGoldenWith defaultBenchConfig { iterations = 1000 }+      "nested data structure reconstruction" $+      nf (\xs -> concat (replicate 100 xs)) [1..50 :: Int]+   describe "Robust Statistics Mode" $ do     -- Benchmark using robust statistics (trimmed mean, MAD)     benchGoldenWith defaultBenchConfig       { useRobustStatistics = True-      , iterations = 100+      , iterations = 500       , trimPercent = 10.0      -- Trim 10% from each tail       , outlierThreshold = 3.0  -- 3 MADs for outlier detection       }-      "robust mode - list reverse" $ do-      _ <- evaluate $ reverse [1..5000 :: Int]-      return ()+      "robust mode - list reverse" $+      nf reverse [1..5000 :: Int]      -- Benchmark with robust statistics and high outlier sensitivity     benchGoldenWith defaultBenchConfig       { useRobustStatistics = True-      , iterations = 200+      , iterations = 1000       , trimPercent = 5.0       -- Minimal trimming       , outlierThreshold = 2.5  -- More sensitive outlier detection       , tolerancePercent = 10.0       }       "robust mode - sorting" $ do-      _ <- evaluate $ sort [1000, 999..1 :: Int]-      return ()+      nf sort [100000, 99999..1 :: Int]      -- Demonstrate outlier detection with intentionally noisy operation     benchGoldenWith defaultBenchConfig       { useRobustStatistics = True-      , iterations = 50+      , iterations = 500       , outlierThreshold = 2.0+      , tolerancePercent = 20.0       }-      "robust mode - with potential outliers" $ do-      _ <- evaluate $ fib 25-      return ()+      "robust mode - with potential outliers" $+      nf fib 25    describe "Tolerance Configuration" $ do     -- Hybrid tolerance (default): pass if within ±15% OR ±0.01ms-    benchGolden "hybrid tolerance - fast operation" $ do-      _ <- evaluate $ sum [1..100 :: Int]-      return ()+    benchGoldenWith defaultBenchConfig { iterations = 2000 }+      "hybrid tolerance - fast operation" $+      nf (\n -> sum [1..n]) (100 :: Int)      -- Percentage-only tolerance: disable absolute tolerance     -- Note: This test may fail occasionally due to measurement noise,     -- demonstrating why hybrid tolerance is the recommended default     benchGoldenWith defaultBenchConfig       { absoluteToleranceMs = Nothing-      , tolerancePercent = 30.0  -- Increased to reduce false failures+      , tolerancePercent = 50.0  -- Increased to reduce false failures+      , iterations = 2000       }-      "percentage-only tolerance" $ do-      _ <- evaluate $ sum [1..500 :: Int]-      return ()+      "percentage-only tolerance" $+      nf (\n -> sum [1..n]) (500 :: Int)      -- Strict absolute tolerance: 1 microsecond     benchGoldenWith defaultBenchConfig       { absoluteToleranceMs = Just 0.001  -- 1 microsecond       , tolerancePercent = 10.0+      , iterations = 1000       }-      "strict absolute tolerance" $ do-      _ <- evaluate $ fib 20-      return ()+      "strict absolute tolerance" $+      nf fib 20 -    -- Relaxed absolute tolerance for CI environments-    benchGoldenWith defaultBenchConfig-      { absoluteToleranceMs = Just 0.1  -- 100 microseconds-      , tolerancePercent = 25.0-      }-      "relaxed tolerance for CI" $ do-      _ <- evaluate $ reverse [1..1000 :: Int]-      return ()    describe "Lens-Based Expectations (Advanced)" $ do-    -- Median-based comparison instead of mean-    benchGoldenWithExpectation "median-based comparison" defaultBenchConfig-      [expect _statsMedian (Percent 10.0)]-      $ do-        _ <- evaluate $ sort [1000, 999..1 :: Int]-        return () -    -- Compose multiple expectations with AND-    benchGoldenWithExpectation "composed expectations (AND)" defaultBenchConfig-      [ expect _statsMean (Percent 15.0) &&~-        expect _statsMAD (Percent 50.0)-      ]-      $ do-        _ <- evaluate $ fib 25-        return ()--    -- Compose multiple expectations with OR-    benchGoldenWithExpectation "composed expectations (OR)" defaultBenchConfig-      [ expect _statsMedian (Percent 10.0) ||~-        expect _statsMin (Absolute 0.01)-      ]-      $ do-        _ <- evaluate $ sum [1..500 :: Int]-        return ()--    -- Expect reasonable performance (will pass with normal variance)-    benchGoldenWithExpectation "flexible tolerance" defaultBenchConfig-      [expect _statsMean (Percent 20.0)]  -- Wide tolerance for example-      $ do-        _ <- evaluate $ [1..1000 :: Int]  -- Trivial operation-        return ()-     -- Hybrid tolerance with custom absolute threshold-    benchGoldenWithExpectation "hybrid tolerance custom" defaultBenchConfig-      [expect _statsMean (Hybrid 20.0 0.005)]  -- ±20% OR ±5μs-      $ do-        _ <- evaluate $ sort [100, 99..1 :: Int]-        return ()+    benchGoldenWithExpectation "hybrid tolerance custom" +      defaultBenchConfig { iterations = 1000 }+      [expect _statsMean (Hybrid 5.0 0.0001)] +      $ nf (\n -> sort [n, n-1 .. 1]) (200 :: Int)      -- Use robust statistics lens (trimmed mean)     benchGoldenWithExpectation "trimmed mean comparison" -      (defaultBenchConfig { useRobustStatistics = True })-      [expect _statsTrimmedMean (Percent 20.0)]-      $ do-        _ <- evaluate $ fib 26-        return ()+      (defaultBenchConfig { useRobustStatistics = True, iterations = 1000 })+      [expect _statsTrimmedMean (Percent 25.0)]+      $ nf fib 26++  -- | Naive Fibonacci for benchmarking purposes. fib :: Int -> Int
golds-gym.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               golds-gym-version:            0.3.0.0+version:            0.4.0.0 synopsis:           Golden testing framework for performance benchmarks description:     A Haskell framework for golden testing of timing benchmarks.@@ -41,7 +41,8 @@         statistics >= 0.16 && < 0.17,         vector >= 0.12 && < 0.14,         boxes >= 0.1 && < 0.2,-        microlens >= 0.4 && < 0.6+        microlens >= 0.4 && < 0.6,+        deepseq >= 1.4 && < 2      hs-source-dirs:   src     default-language: Haskell2010@@ -57,3 +58,19 @@         base >= 4.14 && < 5,         golds-gym,         hspec >= 2.10 && < 3++test-suite golds-gym-properties+    type:             exitcode-stdio-1.0+    main-is:          Properties.hs+    hs-source-dirs:   test+    default-language: Haskell2010+    ghc-options:      -Wall+    build-depends:+        base >= 4.14 && < 5,+        golds-gym,+        hspec >= 2.10 && < 3,+        QuickCheck >= 2.14 && < 3,+        vector >= 0.12 && < 0.14,+        statistics >= 0.16 && < 0.17,+        text >= 1.2 && < 3,+        time >= 1.9 && < 2
src/Test/Hspec/BenchGolden.hs view
@@ -26,14 +26,68 @@ -- @ -- import Test.Hspec -- import Test.Hspec.BenchGolden+-- import Data.List (sort) -- -- main :: IO () -- main = hspec $ do --   describe \"Performance\" $ do---     `benchGolden` "my algorithm" $---       return $ myAlgorithm input+--     -- Pure function with normal form evaluation+--     `benchGolden` "list sorting" $+--       `nf` sort [1000, 999..1]+--+--     -- Weak head normal form (lazy evaluation)+--     `benchGolden` "replicate" $+--       `whnf` (replicate 1000) 42+--+--     -- IO action with result forced to normal form+--     `benchGolden` "file read" $+--       `nfIO` (readFile "data.txt") -- @ --+-- __Evaluation strategies__ control how values are forced:+--+-- * 'nf' - Force to normal form (deep evaluation, use for most cases)+-- * 'whnf' - Force to weak head normal form (only outermost constructor is evaluated)+-- * 'nfIO', 'whnfIO' - Variants for IO actions+-- * 'nfAppIO', 'whnfAppIO' - For functions returning IO+-- * 'io' - Plain IO action without forcing+--+-- Without proper evaluation strategies, GHC may optimize away computations+-- or share results across iterations, making benchmarks meaningless.+--+-- = Best Practices: Avoiding Shared Thunks+--+-- __CRITICAL:__ When benchmarking with data structures, ensure the data is+-- reconstructed on each iteration to avoid measuring shared, cached results.+--+-- ❌ __Anti-pattern__ (shared list across iterations):+--+-- @+-- benchGolden "sum" $ nf sum [1..1000000]+-- @+--+-- The list @[1..1000000]@ is constructed once and shared across all iterations.+-- This allocates the entire list in memory, creates GC pressure, and prevents+-- list fusion. The first iteration evaluates the shared thunk, and subsequent+-- iterations measure cached results.+--+-- ✅ __Correct pattern__ (list reconstructed per iteration):+--+-- @+-- benchGolden "sum" $ nf (\\n -> sum [1..n]) 1000000+-- @+--+-- The lambda wrapper ensures the list is reconstructed on every iteration,+-- measuring the true cost of both construction and computation.+--+-- __Other considerations:__+--+-- * Ensure return types are inhabited enough to depend on all computations+--   (avoid @b ~ ()@ where GHC might optimize away the payload)+-- * For inlinable functions, ensure full saturation: prefer @nf (\\n -> f n) x@+--   over @nf f x@ to guarantee inlining and rewrite rules fire+-- * Use 'NFData' constraints where applicable to ensure deep evaluation+-- -- = How It Works -- -- 1. On first run, the benchmark is executed and results are saved to a@@ -103,19 +157,19 @@ -- -- Median-based comparison instead of mean -- benchGoldenWithExpectation "median test" defaultBenchConfig --   [expect _statsMedian (Percent 10.0)]---   myAction+--   (nf myAlgorithm input) -- -- -- Compose multiple expectations -- benchGoldenWithExpectation "strict test" defaultBenchConfig --   [ expect _statsMean (Percent 15.0) &&~ --     expect _statsMAD (Percent 50.0) --   ]---   myAction+--   (nf criticalFunction data) -- -- -- Expect improvement (must be faster) -- benchGoldenWithExpectation "optimization" defaultBenchConfig --   [expect _statsMean (MustImprove 10.0)]  -- Must be ≥10% faster---   myAction+--   (nf optimizedVersion input) -- @ ---- = Environment Variables --@@ -135,11 +189,21 @@      -- * Types   , BenchGolden(..)+  , BenchAction(..)   , GoldenStats(..)   , BenchResult(..)   , Warning(..)   , ArchConfig(..) +    -- * Benchmarkable Constructors+  , nf+  , whnf+  , nfIO+  , whnfIO+  , nfAppIO+  , whnfAppIO+  , io+     -- * Low-Level API   , runBenchGolden @@ -162,7 +226,7 @@ import Test.Hspec.BenchGolden.Arch import qualified Test.Hspec.BenchGolden.Lenses as L import Test.Hspec.BenchGolden.Lenses hiding (Expectation)-import Test.Hspec.BenchGolden.Runner (runBenchGolden, setAcceptGoldens, setSkipBenchmarks)+import Test.Hspec.BenchGolden.Runner (runBenchGolden, setAcceptGoldens, setSkipBenchmarks, nf, whnf, nfIO, whnfIO, nfAppIO, whnfAppIO, io) import Test.Hspec.BenchGolden.Types  -- | Create a benchmark golden test with default configuration.@@ -172,9 +236,19 @@ -- @ -- describe "Sorting" $ do --   benchGolden "quicksort 1000 elements" $---     return $ quicksort [1000, 999..1]+--     nf quicksort [1000, 999..1] -- @ --+-- Use evaluation strategy combinators to control how values are forced:+--+-- * 'nf' - Normal form (deep evaluation)+-- * 'whnf' - Weak head normal form (shallow evaluation)+-- * 'nfIO' - Normal form for IO actions+-- * 'whnfIO' - WHNF for IO actions+-- * 'nfAppIO' - Normal form for functions returning IO+-- * 'whnfAppIO' - WHNF for functions returning IO+-- * 'io' - Plain IO action (for backward compatibility)+-- -- Default configuration: -- -- * 100 iterations@@ -184,7 +258,7 @@ -- * Standard statistics (not robust mode) benchGolden ::      String  -- ^ Name of the benchmark-    -> IO () -- ^ The IO action to benchmark+    -> BenchAction -- ^ The benchmarkable action     -> Spec benchGolden name action = benchGoldenWith defaultBenchConfig name action @@ -200,7 +274,7 @@ --   , warmupIterations = 20 --   } --   "hot loop" $---   return $ criticalFunction input+--   nf criticalFunction input -- -- -- Robust statistics mode for noisy environments -- benchGoldenWith defaultBenchConfig@@ -209,11 +283,11 @@ --   , outlierThreshold = 3.0 --   } --   "benchmark with outliers" $---   return $ computation input+--   whnf computation input -- @ benchGoldenWith :: BenchConfig  -- ^ Configuration parameters     -> String -- ^ Name of the benchmark-    -> IO () -- ^ The IO action to benchmark+    -> BenchAction -- ^ The benchmarkable action     -> Spec benchGoldenWith config name action =   it name $ BenchGolden@@ -235,38 +309,38 @@ -- -- Median-based comparison (more robust to outliers) -- benchGoldenWithExpectation "median test" defaultBenchConfig --   [`expect` `_statsMedian` (`Percent` 10.0)]---   myAction+--   (nf sort [1000, 999..1]) -- -- -- Multiple metrics must pass (AND composition) -- benchGoldenWithExpectation "strict test" defaultBenchConfig --   [ expect `_statsMean` (Percent 15.0) &&~ --     expect `_statsMAD` (Percent 50.0) --   ]---   myAction+--   (nf algorithm data) -- -- -- Either metric can pass (OR composition) -- benchGoldenWithExpectation "flexible test" defaultBenchConfig --   [ expect _statsMedian (Percent 10.0) ||~ --     expect _statsMin (`Absolute` 0.01) --   ]---   myAction+--   (nf fastOp input) -- -- -- Expect performance improvement (must be faster) -- benchGoldenWithExpectation "optimization" defaultBenchConfig --   [expect _statsMean (`MustImprove` 10.0)]  -- Must be ≥10% faster---   myAction+--   (nf optimizedVersion data) -- -- -- Expect controlled regression (for feature additions) -- benchGoldenWithExpectation "new feature" defaultBenchConfig --   [expect _statsMean (`MustRegress` 5.0)]  -- Accept 5-20% slowdown---   myAction+--   (nf newFeature input) -- -- -- Low variance requirement -- benchGoldenWithExpectation "stable perf" defaultBenchConfig --   [ expect _statsMean (Percent 15.0) &&~ --     expect `_statsIQR` (Absolute 0.1) --   ]---   myAction+--   (nfIO stableOperation) -- @ -- -- Note: Expectations are checked against golden files. On first run, a baseline@@ -275,7 +349,7 @@     String        -- ^ Name of the benchmark     -> BenchConfig  -- ^ Configuration parameters     -> [L.Expectation]  -- ^ List of expectations (all must pass)-    -> IO ()       -- ^ The IO action to benchmark+    -> BenchAction       -- ^ The benchmarkable action     -> Spec benchGoldenWithExpectation name config expectations action =   it name $ BenchGoldenWithExpectations name action config expectations@@ -283,7 +357,7 @@ -- | Data type for benchmarks with custom lens-based expectations. data BenchGoldenWithExpectations = BenchGoldenWithExpectations   !String        -- Name-  !(IO ())       -- Action+  !BenchAction   -- Action   !BenchConfig   -- Config   ![L.Expectation] -- Expectations @@ -355,12 +429,17 @@     readIORef ref  -- | Run a benchmark with custom expectations.-runBenchGoldenWithExpectations :: String -> IO () -> BenchConfig -> [L.Expectation] -> IO BenchResult+runBenchGoldenWithExpectations :: String -> BenchAction -> BenchConfig -> [L.Expectation] -> IO BenchResult runBenchGoldenWithExpectations name action config expectations = do   -- Convert to BenchGolden and run normally first   let bg = BenchGolden name action config   result <- runBenchGolden bg   +  -- Extract tolerance from first expectation for error messages+  let (tolPct, tolAbs) = case expectations of+        [] -> (tolerancePercent config, absoluteToleranceMs config)+        (e:_) -> L.toleranceFromExpectation e+     -- Then check expectations for Pass/Regression/Improvement results   case result of     FirstRun stats -> return $ FirstRun stats@@ -377,19 +456,19 @@                meanDiff = if goldenVal == 0                           then 100.0                           else ((actualVal - goldenVal) / goldenVal) * 100-           in return $ Regression golden actual meanDiff (tolerancePercent config) (absoluteToleranceMs config)-    Regression golden actual pct tol absTol ->+           in return $ Regression golden actual meanDiff tolPct tolAbs+    Regression golden actual pct _tol _absTol ->       -- Check if regression is acceptable per expectations       let allPass = all (\e -> L.checkExpectation e golden actual) expectations       in if allPass          then return $ Pass golden actual []-         else return $ Regression golden actual pct tol absTol-    Improvement golden actual pct tol absTol ->+         else return $ Regression golden actual pct tolPct tolAbs+    Improvement golden actual pct _tol _absTol ->       -- Check if improvement satisfies expectations       let allPass = all (\e -> L.checkExpectation e golden actual) expectations       in if allPass          then return $ Pass golden actual []-         else return $ Improvement golden actual pct tol absTol+         else return $ Improvement golden actual pct tolPct tolAbs  -- | Convert expectation-based benchmark result to hspec Result. fromBenchResultWithExpectations :: [L.Expectation] -> BenchResult -> Result@@ -411,8 +490,11 @@         toleranceDesc = case absToleranceMs of           Nothing -> printf "tolerance: %.1f%%" tolerance           Just absMs -> printf "tolerance: %.1f%% or %.3f ms" tolerance absMs-        message = printf "Mean time increased by %.1f%% (%s)\n\n%s"-                    pctChange toleranceDesc (formatRegression golden actual)+        changeVerb :: String+        changeVerb = if pctChange >= 0 then "increased" else "decreased"+        absPctChange = abs pctChange+        message = printf "Mean time %s by %.1f%% (%s)\n\n%s"+                    changeVerb absPctChange toleranceDesc (formatRegression golden actual)     in Result message (Failure Nothing (Reason message))    Improvement golden actual pctChange tolerance absToleranceMs ->@@ -444,15 +526,6 @@       -- Create detailed comparison table       metricCol = Box.vcat Box.left $ map Box.text          ["Metric", "------", "Mean", "Stddev", "Median", "Min", "Max"]-      actualCol = Box.vcat Box.right $ map Box.text -        [ "Actual"-        , "------"-        , printf "%.3f ms" (statsMean actual)-        , printf "%.3f ms" (statsStddev actual)-        , printf "%.3f ms" (statsMedian actual)-        , printf "%.3f ms" (statsMin actual)-        , printf "%.3f ms" (statsMax actual)-        ]       baselineCol = Box.vcat Box.right $ map Box.text         [ "Baseline"         , "--------"@@ -462,6 +535,15 @@         , printf "%.3f ms" (statsMin golden)         , printf "%.3f ms" (statsMax golden)         ]+      actualCol = Box.vcat Box.right $ map Box.text +        [ "Actual"+        , "------"+        , printf "%.3f ms" (statsMean actual)+        , printf "%.3f ms" (statsStddev actual)+        , printf "%.3f ms" (statsMedian actual)+        , printf "%.3f ms" (statsMin actual)+        , printf "%.3f ms" (statsMax actual)+        ]       diffCol = Box.vcat Box.right $ map Box.text         [ "Diff"         , "----"@@ -472,7 +554,7 @@         , ""         ]       -      table = Box.hsep 2 Box.top [metricCol, actualCol, baselineCol, diffCol]+      table = Box.hsep 2 Box.top [metricCol, baselineCol, actualCol, diffCol]   in Box.render table  -- | Format a passing comparison.@@ -485,20 +567,20 @@                    then 0                    else ((statsStddev actual - statsStddev golden) / statsStddev golden) * 100       -      -- Create table with metric, actual, baseline, and diff columns+      -- Create table with metric, baseline, actual, and diff columns       metricCol = Box.vcat Box.left $ map Box.text ["Metric", "------", "Mean", "Stddev"]-      actualCol = Box.vcat Box.right $ map Box.text -        [ "Actual"-        , "------"-        , printf "%.3f ms" (statsMean actual)-        , printf "%.3f ms" (statsStddev actual)-        ]       baselineCol = Box.vcat Box.right $ map Box.text         [ "Baseline"         , "--------"         , printf "%.3f ms" (statsMean golden)         , printf "%.3f ms" (statsStddev golden)         ]+      actualCol = Box.vcat Box.right $ map Box.text +        [ "Actual"+        , "------"+        , printf "%.3f ms" (statsMean actual)+        , printf "%.3f ms" (statsStddev actual)+        ]       diffCol = Box.vcat Box.right $ map Box.text         [ "Diff"         , "----"@@ -506,7 +588,7 @@         , printf "%+.1f%%" stddevDiff         ]       -      table = Box.hsep 2 Box.top [metricCol, actualCol, baselineCol, diffCol]+      table = Box.hsep 2 Box.top [metricCol, baselineCol, actualCol, diffCol]   in Box.render table  -- | Format statistics for display.
src/Test/Hspec/BenchGolden/Arch.hs view
@@ -7,7 +7,7 @@ -- Description : Architecture detection for machine-specific golden files -- Copyright   : (c) 2026 -- License     : MIT--- Maintainer  : your.email@example.com+-- Maintainer  : @ocramz -- -- This module provides functions for detecting the current machine's -- architecture, which is used to create architecture-specific golden files.
src/Test/Hspec/BenchGolden/Lenses.hs view
@@ -6,7 +6,7 @@ -- Description : Lens-based expectation combinators for benchmark comparison -- Copyright   : (c) 2026 -- License     : MIT--- Maintainer  : your.email@example.com+-- Maintainer  : @ocramz -- -- This module provides van Laarhoven lenses for 'GoldenStats' fields and -- expectation combinators for building custom performance assertions.@@ -124,6 +124,8 @@     -- * Utilities   , percentDiff   , absDiff+  , toleranceFromExpectation+  , toleranceValues   ) where  import Lens.Micro@@ -444,3 +446,18 @@ -- Returns: @abs(actual - baseline)@ absDiff :: Double -> Double -> Double absDiff baseline actual = abs (actual - baseline)++-- | Extract tolerance description from an expectation for error messages.+-- For compound expectations (And/Or), returns the first tolerance found.+toleranceFromExpectation :: Expectation -> (Double, Maybe Double)+toleranceFromExpectation (ExpectStat _ tol) = toleranceValues tol+toleranceFromExpectation (And e1 _) = toleranceFromExpectation e1+toleranceFromExpectation (Or e1 _) = toleranceFromExpectation e1++-- | Extract percentage and optional absolute tolerance from a Tolerance.+toleranceValues :: Tolerance -> (Double, Maybe Double)+toleranceValues (Percent pct) = (pct, Nothing)+toleranceValues (Absolute abs_) = (0, Just abs_)+toleranceValues (Hybrid pct abs_) = (pct, Just abs_)+toleranceValues (MustImprove pct) = (pct, Nothing)+toleranceValues (MustRegress pct) = (pct, Nothing)
src/Test/Hspec/BenchGolden/Runner.hs view
@@ -1,12 +1,15 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE MagicHash #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-}  -- | -- Module      : Test.Hspec.BenchGolden.Runner -- Description : Benchmark execution and golden file comparison -- Copyright   : (c) 2026 -- License     : MIT--- Maintainer  : your.email@example.com+-- Maintainer  : @ocramz -- -- This module handles running benchmarks and comparing results against -- golden files. It includes:@@ -15,6 +18,22 @@ -- * Golden file I/O (reading/writing JSON statistics) -- * Tolerance-based comparison with variance warnings -- * Support for updating baselines via GOLDS_GYM_ACCEPT environment variable+-- * Evaluation strategies to control how values are forced (nf, whnf, etc.)+--+-- = Evaluation Strategies+--+-- Benchmarks require explicit evaluation strategies to prevent GHC from+-- optimizing away computations or sharing results across iterations:+--+-- * 'nf' - Force result to normal form (deep, full evaluation)+-- * 'whnf' - Force result to weak head normal form (shallow evaluation)+-- * 'nfIO' - Execute IO and force result to normal form+-- * 'whnfIO' - Execute IO and force result to WHNF+-- * 'nfAppIO' - Apply function, execute IO, force result to normal form+-- * 'whnfAppIO' - Apply function, execute IO, force result to WHNF+-- * 'io' - Plain IO without additional forcing+--+-- These are vendored from tasty-bench with proper attribution (BSD-3-Clause).  module Test.Hspec.BenchGolden.Runner   ( -- * Running Benchmarks@@ -45,8 +64,19 @@   , shouldSkipBenchmarks   , setAcceptGoldens   , setSkipBenchmarks++    -- * Benchmarkable Constructors+  , io+  , nf+  , whnf+  , nfIO+  , whnfIO+  , nfAppIO+  , whnfAppIO   ) where +import Control.DeepSeq (NFData, rnf)+import Control.Exception (evaluate) import Control.Monad (when, replicateM_) import Data.Aeson (eitherDecodeFileStrict, encodeFile) import Data.IORef (IORef, newIORef, readIORef, writeIORef)@@ -54,11 +84,13 @@ import qualified Data.Text as T import Data.Time (getCurrentTime) import qualified Data.Vector.Unboxed as V+import Data.Word (Word64) import qualified Statistics.Sample as Stats import System.CPUTime (getCPUTime) import System.Directory (createDirectoryIfMissing, doesFileExist) import System.FilePath ((</>), (<.>)) import System.IO.Unsafe (unsafePerformIO)+import GHC.Exts (SPEC(..))  import Lens.Micro ((^.)) @@ -68,6 +100,138 @@ import Test.Hspec.BenchGolden.Lenses (metricFor, varianceFor) import Test.Hspec.BenchGolden.Types +-- -----------------------------------------------------------------------------+-- Evaluation Strategies+-- Vendored from tasty-bench-0.5 with modifications+-- Copyright (c) 2021 Andrew Lelechenko and tasty-bench contributors+-- MIT License+-- https://hackage.haskell.org/package/tasty-bench-0.5+-- -----------------------------------------------------------------------------++-- | Benchmark a pure function applied to an argument, forcing the result to+-- normal form (NF) using 'rnf' from "Control.DeepSeq".+-- This ensures the entire result structure is evaluated.+--+-- Example:+-- @+-- benchGolden "fib 30" (nf fib 30)+-- @+nf :: NFData b => (a -> b) -> a -> BenchAction+nf = funcToBench rnf+{-# INLINE nf #-}++-- | Benchmark a pure function applied to an argument, forcing the result to+-- weak head normal form (WHNF) only. This evaluates just the outermost constructor.+--+-- Example:+-- @+-- benchGolden "replicate" (whnf (replicate 1000) 42)+-- @+whnf :: (a -> b) -> a -> BenchAction+whnf = funcToBench id+{-# INLINE whnf #-}++-- | Benchmark an 'IO' action, forcing the result to normal form.+--+-- Example:+-- @+-- benchGolden "readFile" (nfIO $ readFile "data.txt")+-- @+nfIO :: NFData a => IO a -> BenchAction+nfIO = ioToBench rnf+{-# INLINE nfIO #-}++-- | Benchmark an 'IO' action, forcing the result to weak head normal form.+--+-- Example:+-- @+-- benchGolden "getLine" (whnfIO getLine)+-- @+whnfIO :: IO a -> BenchAction+whnfIO = ioToBench id+{-# INLINE whnfIO #-}++-- | Benchmark a function that performs 'IO', forcing the result to normal form.+--+-- Example:+-- @+-- benchGolden "lookup in map" (nfAppIO lookupInDB "key")+-- @+nfAppIO :: NFData b => (a -> IO b) -> a -> BenchAction+nfAppIO = ioFuncToBench rnf+{-# INLINE nfAppIO #-}++-- | Benchmark a function that performs 'IO', forcing the result to weak head normal form.+--+-- Example:+-- @+-- benchGolden "query database" (whnfAppIO queryDB params)+-- @+whnfAppIO :: (a -> IO b) -> a -> BenchAction+whnfAppIO = ioFuncToBench id+{-# INLINE whnfAppIO #-}++-- | Benchmark an 'IO' action, discarding the result.+-- This is for backward compatibility with code that uses @IO ()@ actions.+--+-- Example:+-- @+-- benchGolden "compute" (io $ do+--   result <- heavyComputation+--   evaluate result)+-- @+io :: IO () -> BenchAction+io action = BenchAction (\n -> replicateM_ (fromIntegral n) action)+{-# INLINE io #-}++-- Internal helpers++funcToBench :: forall a b c. (b -> c) -> (a -> b) -> a -> BenchAction+funcToBench frc = (BenchAction .) . funcToBenchLoop SPEC+  where+    -- Here we rely on the fact that GHC (unless spurred by+    -- -fstatic-argument-transformation) is not smart enough:+    -- it does not notice that `f` and `x` arguments are loop invariant+    -- and could be floated, and the whole `f x` expression shared.+    -- If we create a closure with `f` and `x` bound in the environment,+    -- then GHC is smart enough to share computation of `f x`.+    funcToBenchLoop :: SPEC -> (a -> b) -> a -> Word64 -> IO ()+    funcToBenchLoop !_ f x n+      | n == 0    = pure ()+      | otherwise = do+        _ <- evaluate (frc (f x))+        funcToBenchLoop SPEC f x (n - 1)+{-# INLINE funcToBench #-}++ioToBench :: forall a b. (a -> b) -> IO a -> BenchAction+ioToBench frc act = BenchAction (ioToBenchLoop SPEC act)+  where+    ioToBenchLoop :: SPEC -> IO a -> Word64 -> IO ()+    ioToBenchLoop !_ action n+      | n == 0    = pure ()+      | otherwise = do+        x <- action+        _ <- evaluate (frc x)+        ioToBenchLoop SPEC action (n - 1)+{-# INLINE ioToBench #-}++ioFuncToBench :: forall a b c. (b -> c) -> (a -> IO b) -> a -> BenchAction+ioFuncToBench frc = (BenchAction .) . ioFuncToBenchLoop SPEC+  where+    ioFuncToBenchLoop :: SPEC -> (a -> IO b) -> a -> Word64 -> IO ()+    ioFuncToBenchLoop !_ f x n+      | n == 0    = pure ()+      | otherwise = do+        y <- f x+        _ <- evaluate (frc y)+        ioFuncToBenchLoop SPEC f x (n - 1)+{-# INLINE ioFuncToBench #-}++-- -----------------------------------------------------------------------------+-- Benchmark execution+-- -----------------------------------------------------------------------------++ -- | Run a benchmark golden test. -- -- This function:@@ -102,7 +266,7 @@        -- Run warm-up iterations       when (warmupIterations config > 0) $-        replicateM_ (warmupIterations config) benchAction+        runBenchAction benchAction (fromIntegral $ warmupIterations config)        -- Run the actual benchmark       actualStats <- runBenchmark benchName benchAction config arch@@ -132,7 +296,7 @@               return result  -- | Run a benchmark and collect statistics.-runBenchmark :: String -> IO () -> BenchConfig -> ArchConfig -> IO GoldenStats+runBenchmark :: String -> BenchAction -> BenchConfig -> ArchConfig -> IO GoldenStats runBenchmark _name action config arch = do   if useRobustStatistics config     then runBenchmarkWithRawTimings _name action config arch@@ -141,7 +305,7 @@       (cpuStats, _wallStats) <- BP.benchmark         (iterations config)         (pure ())                    -- setup-        (const action)               -- action+        (const $ runBenchAction action 1)  -- action: run 1 iteration         (const $ pure ())            -- teardown        now <- getCurrentTime@@ -162,10 +326,10 @@         }  -- | Run a benchmark with raw timing collection for robust statistics.-runBenchmarkWithRawTimings :: String -> IO () -> BenchConfig -> ArchConfig -> IO GoldenStats+runBenchmarkWithRawTimings :: String -> BenchAction -> BenchConfig -> ArchConfig -> IO GoldenStats runBenchmarkWithRawTimings _name action config arch = do   -- Collect raw CPU timings-  timings <- mapM (const measureCPUTime) [1 .. iterations config]+  timings <- mapM (const $ measureCPUTimeForAction action) [1 .. iterations config]      let sortedTimings = sort timings       vec = V.fromList sortedTimings@@ -203,9 +367,9 @@     , statsOutliers    = outliers'     }   where-    measureCPUTime = do+    measureCPUTimeForAction act = do       startCpu <- getCPUTime-      action+      runBenchAction act 1  -- Run the action once       endCpu <- getCPUTime       let cpuTime = picosToMillis (endCpu - startCpu)       return cpuTime
src/Test/Hspec/BenchGolden/Types.hs view
@@ -7,7 +7,7 @@ -- Description : Core types for benchmark golden testing -- Copyright   : (c) 2026 -- License     : MIT--- Maintainer  : your.email@example.com+-- Maintainer  : @ocramz -- -- This module defines the core data types used by the golds-gym framework: --@@ -22,6 +22,7 @@     BenchGolden(..)   , BenchConfig(..)   , defaultBenchConfig+  , BenchAction(..)      -- * Golden File Statistics   , GoldenStats(..)@@ -37,16 +38,21 @@ import Data.Aeson import Data.Text (Text) import Data.Time (UTCTime)+import Data.Word (Word64) import GHC.Generics (Generic)  -- Note: Expectation type is defined in Lenses module to avoid circular imports +-- | A benchmarkable action that can be run multiple times.+-- The 'Word64' parameter represents the number of iterations to execute.+newtype BenchAction = BenchAction { runBenchAction :: Word64 -> IO () }+ -- | Configuration for a single benchmark golden test. data BenchGolden = BenchGolden   { benchName   :: !String     -- ^ Name of the benchmark (used for golden file naming)-  , benchAction :: !(IO ())-    -- ^ The IO action to benchmark+  , benchAction :: !BenchAction+    -- ^ The benchmarkable action to run   , benchConfig :: !BenchConfig     -- ^ Configuration parameters   }
+ test/Properties.hs view
@@ -0,0 +1,312 @@+{-# LANGUAGE ScopedTypeVariables #-}++-- | Property-based tests for statistical computations in Runner module+module Main (main) where++import Data.List (sort)+import qualified Data.Text as T+import qualified Data.Vector.Unboxed as V+import qualified Statistics.Sample as Stats+import Test.Hspec+import Test.QuickCheck++import Test.Hspec.BenchGolden.Runner+import Test.Hspec.BenchGolden.Types++main :: IO ()+main = hspec spec++spec :: Spec+spec = do+  describe "calculateTrimmedMean" $ do+    it "returns value within min and max bounds for non-empty vectors" $ property $+      \(PositiveVector vec) (ValidTrimPercent pct) ->+        V.length vec > 0 ==>+        let result = calculateTrimmedMean pct vec+            minVal = V.minimum vec+            maxVal = V.maximum vec+        in result >= minVal && result <= maxVal++    it "converges to regular mean when trim percentage is zero" $ property $+      \(PositiveVector vec) ->+        not (V.null vec) ==>+        let trimmed = calculateTrimmedMean 0.0 vec+            regular = Stats.mean vec+        in abs (trimmed - regular) < 1e-10++    it "falls back to regular mean for trim percentage >= 50" $ property $+      \(PositiveVector vec) (Positive pct) ->+        V.length vec > 0 ==>+        let trimPct = 50.0 + pct+            trimmed = calculateTrimmedMean trimPct vec+            regular = Stats.mean vec+        in abs (trimmed - regular) < 1e-10++    it "returns zero for empty vector" $+      calculateTrimmedMean 10.0 V.empty `shouldBe` 0.0++    it "is less affected by outliers than regular mean" $ property $+      \(PositiveVector vec) ->+        V.length vec > 4 ==>+        let withOutlier = V.snoc vec (V.maximum vec * 100)  -- Add extreme outlier+            trimmed = calculateTrimmedMean 10.0 withOutlier+            regular = Stats.mean withOutlier+            originalMean = Stats.mean vec+        in abs (trimmed - originalMean) < abs (regular - originalMean)++  describe "calculateMAD" $ do+    it "is always non-negative" $ property $+      \(PositiveVector vec) ->+        let median' = if V.null vec then 0 else V.unsafeIndex vec (V.length vec `div` 2)+            mad = calculateMAD vec median'+        in mad >= 0++    it "returns zero for empty vector" $+      calculateMAD V.empty 0.0 `shouldBe` 0.0++    it "returns zero for constant data" $ property $+      \(Positive (constant :: Double)) (Positive (n :: Int)) ->+        n < 100 ==>+        let vec = V.replicate n constant+            median' = constant+            mad = calculateMAD vec median'+        in mad == 0.0++    it "scales linearly with data (scale invariance)" $ property $+      \(PositiveVector vec) (Positive s) ->+        V.length vec > 0 ==>+        let median' = V.unsafeIndex vec (V.length vec `div` 2)+            mad1 = calculateMAD vec median'+            scaledVec = V.map (* s) vec+            scaledMedian = median' * s+            mad2 = calculateMAD scaledVec scaledMedian+        in abs (mad2 - mad1 * s) < 1e-6++    -- Note: The relationship MAD ≤ σ only holds for normal distributions.+    -- For arbitrary distributions (bimodal, skewed, etc.), MAD can exceed σ.+    -- Therefore we don't test this as a universal property.++  describe "calculateIQR" $ do+    it "is always non-negative" $ property $+      \(PositiveVector vec) ->+        calculateIQR vec >= 0++    it "returns zero for vectors with less than 4 elements" $+      let vec1 = V.fromList [1.0]+          vec2 = V.fromList [1.0, 2.0]+          vec3 = V.fromList [1.0, 2.0, 3.0]+      in calculateIQR vec1 == 0.0 &&+         calculateIQR vec2 == 0.0 &&+         calculateIQR vec3 == 0.0++    it "returns zero for constant data" $ property $+      \(Positive (constant :: Double)) (Positive (n :: Int)) ->+        n < 100 && n >= 4 ==>+        let vec = V.replicate n constant+            iqr = calculateIQR vec+        in iqr == 0.0++    it "approximates 1.349 * stddev for normal-ish distributions" $ property $+      \(PositiveVector vec) ->+        V.length vec >= 20 ==>  -- Need sufficient sample size+        let iqr = calculateIQR vec+            stddev = Stats.stdDev vec+            ratio = if stddev == 0 then 0 else iqr / stddev+        in ratio >= 0.5 && ratio <= 2.5  -- Loose bounds for non-normal data++  describe "detectOutliers" $ do+    it "returns empty list for empty vector" $+      detectOutliers 3.0 V.empty 0.0 0.0 `shouldBe` []++    it "returns empty list when MAD is zero" $ property $+      \(Positive (constant :: Double)) (Positive (n :: Int)) (Positive thresh) ->+        n < 100 ==>+        let vec = V.replicate n constant+            outliers = detectOutliers thresh vec constant 0.0+        in null outliers++    it "fewer outliers with higher threshold (monotonicity)" $ property $+      \(PositiveVector vec) (Positive thresh1) (Positive thresh2) ->+        not (V.null vec) && thresh1 < thresh2 ==>+        let median' = V.unsafeIndex vec (V.length vec `div` 2)+            mad = calculateMAD vec median'+            outliers1 = detectOutliers thresh1 vec median' mad+            outliers2 = detectOutliers thresh2 vec median' mad+        in length outliers1 >= length outliers2++    it "all outliers satisfy distance criterion" $ property $+      \(PositiveVector vec) (Positive thresh) ->+        not (V.null vec) ==>+        let median' = V.unsafeIndex vec (V.length vec `div` 2)+            mad = calculateMAD vec median'+            outliers = detectOutliers thresh vec median' mad+        in all (\x -> abs (x - median') > thresh * mad || mad == 0) outliers++    it "all outliers are from original vector" $ property $+      \(PositiveVector vec) (Positive thresh) ->+        not (V.null vec) ==>+        let median' = V.unsafeIndex vec (V.length vec `div` 2)+            mad = calculateMAD vec median'+            outliers = detectOutliers thresh vec median' mad+            vecList = V.toList vec+        in all (`elem` vecList) outliers++  describe "calculateRobustStats" $ do+    it "returns consistent tuple structure" $ property $+      \(PositiveVector vec) ->+        let config = defaultBenchConfig { useRobustStatistics = True }+            median' = if V.null vec then 0 else V.unsafeIndex vec (V.length vec `div` 2)+            (tm, mad, iqr, outliers) = calculateRobustStats config vec median'+        in tm >= 0 && mad >= 0 && iqr >= 0 && length outliers >= 0++  describe "compareStats (tolerance logic)" $ do+    it "self-comparison always passes" $ property $+      \(SmallPositive mean') (SmallPositive stddev') ->+        let stats = makeGoldenStats mean' stddev'+            config = defaultBenchConfig { useRobustStatistics = False }+            result = compareStats config stats stats+        in case result of+             Pass _ _ _ -> True+             _          -> False++    it "passes if within percentage tolerance" $ property $+      \(SmallPositive golden) ->+        golden > 0.01 ==>  -- Avoid very small baselines+        let delta = golden * 0.08  -- 8% change, well within 15%+            goldenStats = makeGoldenStats golden 0.1+            actualStats = makeGoldenStats (golden + delta) 0.1+            config = defaultBenchConfig { tolerancePercent = 15.0, absoluteToleranceMs = Nothing }+            result = compareStats config goldenStats actualStats+        in case result of+             Pass _ _ _ -> True+             _          -> False++    it "passes if within absolute tolerance even with high percentage" $+      let golden = 0.002  -- 2 microseconds - very small+          actual = 0.004  -- 4 microseconds - 100% increase+          goldenStats = makeGoldenStats golden 0.0001+          actualStats = makeGoldenStats actual 0.0001+          config = defaultBenchConfig { absoluteToleranceMs = Just 0.01 }  -- 10 microseconds+          result = compareStats config goldenStats actualStats+      in case result of+           Pass _ _ _ -> True+           _          -> False++    it "regresses when exceeding both tolerances" $ property $+      \(SmallPositive golden) ->+        golden > 0.1 ==>  -- Large enough baseline+        let goldenStats = makeGoldenStats golden 0.01+            actualStats = makeGoldenStats (golden * 2) 0.01  -- 100% increase+            config = defaultBenchConfig { tolerancePercent = 10.0, absoluteToleranceMs = Just 0.01 }+            result = compareStats config goldenStats actualStats+        in case result of+             Regression _ _ _ _ _ -> True+             _                    -> False++    it "handles zero golden value gracefully" $+      let goldenStats = makeGoldenStats 0.0 0.0+          actualStats = makeGoldenStats 1.0 0.1+          config = defaultBenchConfig+          result = compareStats config goldenStats actualStats+      in case result of+           Regression _ _ pct _ _ -> pct == 100.0+           _                      -> False++  describe "checkVariance" $ do+    it "returns only variance warnings when enabled" $ property $+      \(SmallPositive m1) (SmallPositive m2) (SmallPositive s1) (SmallPositive s2) ->+        let config = defaultBenchConfig { warnOnVarianceChange = True, useRobustStatistics = False }+            golden = makeGoldenStats m1 s1+            actual = makeGoldenStats m2 s2+            warnings = checkVariance config golden actual+            -- Filter to only variance-related warnings (not outliers)+            varianceWarnings = filter isVarianceWarning warnings+        in varianceWarnings == warnings  -- All warnings should be variance-related++    it "detects high coefficient of variation" $ property $+      \(SmallPositive mean') ->+        mean' > 0.01 ==>+        let stddev' = mean' * 2  -- CV = 200%+            config = defaultBenchConfig { warnOnVarianceChange = True }+            stats = makeGoldenStats mean' stddev'+            warnings = checkVariance config stats stats+        in any isHighVarianceWarning warnings++    it "detects variance increase beyond tolerance" $ property $+      \(SmallPositive mean') (SmallPositive s1) ->+        s1 > 0 ==>+        let s2 = s1 * 3  -- 200% increase+            config = defaultBenchConfig { warnOnVarianceChange = True, varianceTolerancePercent = 50.0 }+            golden = makeGoldenStats mean' s1+            actual = makeGoldenStats mean' s2+            warnings = checkVariance config golden actual+        in any isVarianceIncreasedWarning warnings++-- -----------------------------------------------------------------------------+-- Custom Generators and Helper Types+-- -----------------------------------------------------------------------------++-- | Vector of positive doubles (simulating timing data)+newtype PositiveVector = PositiveVector (V.Vector Double)+  deriving (Show)++instance Arbitrary PositiveVector where+  arbitrary = do+    n <- choose (0, 50)  -- Reasonable vector size+    values <- vectorOf n (choose (0.001, 100.0))  -- 1μs to 100ms range+    return $ PositiveVector $ V.fromList $ sort values+  shrink (PositiveVector vec)+    | V.null vec = []+    | otherwise = [PositiveVector (V.take (V.length vec `div` 2) vec)]++-- | Valid trim percentage (0-50)+newtype ValidTrimPercent = ValidTrimPercent Double+  deriving (Show)++instance Arbitrary ValidTrimPercent where+  arbitrary = ValidTrimPercent <$> choose (0.0, 49.9)++-- | Small positive number for timing tests+newtype SmallPositive = SmallPositive Double+  deriving (Show)++instance Arbitrary SmallPositive where+  arbitrary = SmallPositive <$> choose (0.001, 10.0)++-- -----------------------------------------------------------------------------+-- Helper Functions+-- -----------------------------------------------------------------------------++-- | Create minimal GoldenStats for testing+makeGoldenStats :: Double -> Double -> GoldenStats+makeGoldenStats mean' stddev' = GoldenStats+  { statsMean = mean'+  , statsStddev = stddev'+  , statsMedian = mean'+  , statsMin = mean' - stddev'+  , statsMax = mean' + stddev'+  , statsPercentiles = []+  , statsArch = T.pack "test-arch"+  , statsTimestamp = read "2026-01-01 00:00:00 UTC"+  , statsTrimmedMean = mean'+  , statsMAD = stddev' * 0.7  -- Approximate relationship+  , statsIQR = stddev' * 1.35+  , statsOutliers = []+  }++-- | Check if warning is HighVariance+isHighVarianceWarning :: Warning -> Bool+isHighVarianceWarning (HighVariance _) = True+isHighVarianceWarning _ = False++-- | Check if warning is VarianceIncreased+isVarianceIncreasedWarning :: Warning -> Bool+isVarianceIncreasedWarning (VarianceIncreased _ _ _ _) = True+isVarianceIncreasedWarning _ = False++-- | Check if warning is any variance-related warning+isVarianceWarning :: Warning -> Bool+isVarianceWarning (VarianceIncreased _ _ _ _) = True+isVarianceWarning (VarianceDecreased _ _ _ _) = True+isVarianceWarning (HighVariance _) = True+isVarianceWarning _ = False