golds-gym-0.4.0.0: src/Test/Hspec/BenchGolden/Lenses.hs
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Test.Hspec.BenchGolden.Lenses
-- Description : Lens-based expectation combinators for benchmark comparison
-- Copyright : (c) 2026
-- License : MIT
-- Maintainer : @ocramz
--
-- This module provides van Laarhoven lenses for 'GoldenStats' fields and
-- expectation combinators for building custom performance assertions.
--
-- = Quick Start
--
-- @
-- import Test.Hspec
-- import Test.Hspec.BenchGolden
-- import Test.Hspec.BenchGolden.Lenses
--
-- main :: IO ()
-- main = hspec $ do
-- describe \"Custom Expectations\" $ do
-- -- Expect median within 10% tolerance
-- benchGoldenWithExpectation \"median-based\" defaultBenchConfig
-- [expect _statsMedian (Percent 10.0)]
-- myAction
--
-- -- Expect IQR within absolute 0.5ms
-- benchGoldenWithExpectation \"low variance\" defaultBenchConfig
-- [expect _statsIQR (Absolute 0.5)]
-- myAction
--
-- -- Compose multiple expectations (both must pass)
-- benchGoldenWithExpectation \"composed\" defaultBenchConfig
-- [ expect _statsMean (Percent 15.0) &&~
-- expect _statsMAD (Percent 50.0)
-- ]
-- myAction
-- @
--
-- = Lenses
--
-- Simple van Laarhoven lenses provide access to 'GoldenStats' fields:
--
-- * '_statsMean', '_statsMedian', '_statsTrimmedMean' - Central tendency metrics
-- * '_statsStddev', '_statsMAD', '_statsIQR' - Dispersion metrics
-- * '_statsMin', '_statsMax' - Range metrics
--
-- = Smart Selectors
--
-- 'metricFor' and 'varianceFor' automatically select the appropriate lens
-- based on 'BenchConfig' settings:
--
-- @
-- let lens = metricFor config -- Returns _statsTrimmedMean if useRobustStatistics
-- baseline = golden ^. lens
-- current = actual ^. lens
-- @
--
-- = Expectation Combinators
--
-- Build expectations with 'expect' and compose them:
--
-- * 'Percent' tolerance - e.g., @Percent 15.0@ for ±15%
-- * 'Absolute' tolerance - e.g., @Absolute 0.01@ for ±0.01ms
-- * 'Hybrid' tolerance - e.g., @Hybrid 15.0 0.01@ (pass if either satisfied)
--
-- Boolean composition operators:
--
-- * '(&&~)' - AND (both expectations must pass)
-- * '(||~)' - OR (either expectation can pass)
--
-- = Infix Operators
--
-- For concise tolerance checking:
--
-- * '(@~)' - Within percentage: @baseline \@~ 15.0 $ actual@
-- * '(@<)' - Within absolute: @baseline \@< 0.01 $ actual@
-- * '(@<<)' - Must be faster (negative tolerance): @baseline \@<< 5.0 $ actual@
-- * '(@>>)' - Must be slower (positive tolerance): @baseline \@>> 5.0 $ actual@
module Test.Hspec.BenchGolden.Lenses
( -- * Lenses for GoldenStats
_statsMean
, _statsStddev
, _statsMedian
, _statsMin
, _statsMax
, _statsTrimmedMean
, _statsMAD
, _statsIQR
-- * Smart Metric Selectors
, metricFor
, varianceFor
-- * Expectation Types
, Expectation(..)
, Tolerance(..)
-- * Expectation Combinators
, expect
, expectStat
, checkExpectation
-- * Tolerance Checking Functions
, withinPercent
, withinAbsolute
, withinHybrid
, mustImprove
, mustRegress
-- * Infix Operators
, (@~)
, (@<)
, (@<<)
, (@>>)
-- * Boolean Composition
, (&&~)
, (||~)
-- * Utilities
, percentDiff
, absDiff
, toleranceFromExpectation
, toleranceValues
) where
import Lens.Micro
import Test.Hspec.BenchGolden.Types
-- -----------------------------------------------------------------------------
-- Lenses for GoldenStats fields
-- -----------------------------------------------------------------------------
-- | Lens for mean execution time in milliseconds.
_statsMean :: Lens' GoldenStats Double
_statsMean f s = fmap (\x -> s { statsMean = x }) (f (statsMean s))
-- | Lens for standard deviation in milliseconds.
_statsStddev :: Lens' GoldenStats Double
_statsStddev f s = fmap (\x -> s { statsStddev = x }) (f (statsStddev s))
-- | Lens for median execution time in milliseconds.
_statsMedian :: Lens' GoldenStats Double
_statsMedian f s = fmap (\x -> s { statsMedian = x }) (f (statsMedian s))
-- | Lens for minimum execution time in milliseconds.
_statsMin :: Lens' GoldenStats Double
_statsMin f s = fmap (\x -> s { statsMin = x }) (f (statsMin s))
-- | Lens for maximum execution time in milliseconds.
_statsMax :: Lens' GoldenStats Double
_statsMax f s = fmap (\x -> s { statsMax = x }) (f (statsMax s))
-- | Lens for trimmed mean (with tails removed) in milliseconds.
_statsTrimmedMean :: Lens' GoldenStats Double
_statsTrimmedMean f s = fmap (\x -> s { statsTrimmedMean = x }) (f (statsTrimmedMean s))
-- | Lens for median absolute deviation (MAD) in milliseconds.
_statsMAD :: Lens' GoldenStats Double
_statsMAD f s = fmap (\x -> s { statsMAD = x }) (f (statsMAD s))
-- | Lens for interquartile range (IQR = Q3 - Q1) in milliseconds.
_statsIQR :: Lens' GoldenStats Double
_statsIQR f s = fmap (\x -> s { statsIQR = x }) (f (statsIQR s))
-- -----------------------------------------------------------------------------
-- Smart Metric Selectors
-- -----------------------------------------------------------------------------
-- | Select the appropriate central tendency metric based on configuration.
--
-- Returns:
--
-- * '_statsTrimmedMean' if 'useRobustStatistics' is 'True'
-- * '_statsMean' otherwise
--
-- Example:
--
-- @
-- let lens = metricFor config
-- baseline = golden ^. lens
-- current = actual ^. lens
-- @
metricFor :: BenchConfig -> Lens' GoldenStats Double
metricFor cfg = if useRobustStatistics cfg
then _statsTrimmedMean
else _statsMean
-- | Select the appropriate dispersion metric based on configuration.
--
-- Returns:
--
-- * '_statsMAD' if 'useRobustStatistics' is 'True'
-- * '_statsStddev' otherwise
--
-- Example:
--
-- @
-- let vLens = varianceFor config
-- goldenVar = golden ^. vLens
-- actualVar = actual ^. vLens
-- @
varianceFor :: BenchConfig -> Lens' GoldenStats Double
varianceFor cfg = if useRobustStatistics cfg
then _statsMAD
else _statsStddev
-- -----------------------------------------------------------------------------
-- Expectation Types
-- -----------------------------------------------------------------------------
-- | Tolerance specification for performance comparison.
data Tolerance
= Percent !Double
-- ^ Percentage tolerance (e.g., @Percent 15.0@ = ±15%)
| Absolute !Double
-- ^ Absolute tolerance in milliseconds (e.g., @Absolute 0.01@ = ±0.01ms)
| Hybrid !Double !Double
-- ^ Hybrid tolerance: pass if EITHER percentage OR absolute is satisfied
-- (e.g., @Hybrid 15.0 0.01@ = pass if within ±15% OR ±0.01ms)
| MustImprove !Double
-- ^ Must be faster by at least this percentage (e.g., @MustImprove 10.0@ = must be ≥10% faster)
| MustRegress !Double
-- ^ Must be slower by at least this percentage (e.g., @MustRegress 5.0@ = must be ≥5% slower)
deriving (Show, Eq)
-- | An expectation for comparing golden and actual statistics.
--
-- Expectations can be composed using boolean operators:
--
-- @
-- expect _statsMean (Percent 15.0) &&~ expect _statsMAD (Percent 50.0)
-- @
data Expectation
= ExpectStat !(Lens' GoldenStats Double) !Tolerance
-- ^ Expect a specific field to be within tolerance
| And !Expectation !Expectation
-- ^ Both expectations must pass
| Or !Expectation !Expectation
-- ^ Either expectation can pass
-- Manual Eq instance (lenses can't be compared, so we only compare structure)
instance Eq Expectation where
ExpectStat _ tol1 == ExpectStat _ tol2 = tol1 == tol2
And e1 e2 == And e3 e4 = e1 == e3 && e2 == e4
Or e1 e2 == Or e3 e4 = e1 == e3 && e2 == e4
_ == _ = False
instance Show Expectation where
show (ExpectStat _ tol) = "expect <field> " ++ show tol
show (And e1 e2) = "(" ++ show e1 ++ " &&~ " ++ show e2 ++ ")"
show (Or e1 e2) = "(" ++ show e1 ++ " ||~ " ++ show e2 ++ ")"
-- -----------------------------------------------------------------------------
-- Expectation Combinators
-- -----------------------------------------------------------------------------
-- | Create an expectation for a specific statistic field.
--
-- Example:
--
-- @
-- expect _statsMedian (Percent 10.0)
-- expect _statsIQR (Absolute 0.5)
-- expect _statsMean (Hybrid 15.0 0.01)
-- expect _statsMean (MustImprove 10.0)
-- @
expect :: Lens' GoldenStats Double -> Tolerance -> Expectation
expect = ExpectStat
-- | Create an expectation using a custom lens.
--
-- This is an alias for 'expect' for compatibility.
expectStat :: Lens' GoldenStats Double -> Tolerance -> Expectation
expectStat = expect
-- | Check if an expectation is satisfied for the given golden and actual stats.
--
-- Returns 'True' if the expectation passes, 'False' otherwise.
checkExpectation :: Expectation -> GoldenStats -> GoldenStats -> Bool
checkExpectation (ExpectStat lns tol) golden actual =
let baseline = golden ^. lns
current = actual ^. lns
in checkTolerance tol baseline current
checkExpectation (And e1 e2) golden actual =
checkExpectation e1 golden actual && checkExpectation e2 golden actual
checkExpectation (Or e1 e2) golden actual =
checkExpectation e1 golden actual || checkExpectation e2 golden actual
-- | Check tolerance between baseline and current values.
checkTolerance :: Tolerance -> Double -> Double -> Bool
checkTolerance (Percent pct) baseline current =
withinPercent pct baseline current
checkTolerance (Absolute absThreshold) baseline current =
withinAbsolute absThreshold baseline current
checkTolerance (Hybrid pct absThreshold) baseline current =
withinHybrid pct absThreshold baseline current
checkTolerance (MustImprove minPct) baseline current =
mustImprove minPct baseline current
checkTolerance (MustRegress minPct) baseline current =
mustRegress minPct baseline current
-- -----------------------------------------------------------------------------
-- Tolerance Checking Functions
-- -----------------------------------------------------------------------------
-- | Check if value is within percentage tolerance.
--
-- @
-- withinPercent 15.0 baseline actual -- within ±15%
-- @
withinPercent :: Double -> Double -> Double -> Bool
withinPercent tolerance baseline actual =
let pct = percentDiff baseline actual
in abs pct <= tolerance
-- | Check if value is within absolute tolerance (milliseconds).
--
-- @
-- withinAbsolute 0.01 baseline actual -- within ±0.01ms
-- @
withinAbsolute :: Double -> Double -> Double -> Bool
withinAbsolute threshold baseline actual =
absDiff baseline actual <= threshold
-- | Check if value satisfies hybrid tolerance (percentage OR absolute).
--
-- @
-- withinHybrid 15.0 0.01 baseline actual -- within ±15% OR ±0.01ms
-- @
withinHybrid :: Double -> Double -> Double -> Double -> Bool
withinHybrid pctTolerance absThreshold baseline actual =
withinPercent pctTolerance baseline actual ||
withinAbsolute absThreshold baseline actual
-- | Check if actual is faster than baseline by at least the given percentage.
--
-- @
-- mustImprove 10.0 baseline actual -- must be ≥10% faster
-- @
mustImprove :: Double -> Double -> Double -> Bool
mustImprove minPercent baseline actual =
let pct = percentDiff baseline actual
in pct <= negate minPercent -- Negative percentage = improvement
-- | Check if actual is slower than baseline by at least the given percentage.
--
-- @
-- mustRegress 5.0 baseline actual -- must be ≥5% slower
-- @
mustRegress :: Double -> Double -> Double -> Bool
mustRegress minPercent baseline actual =
let pct = percentDiff baseline actual
in pct >= minPercent -- Positive percentage = regression
-- -----------------------------------------------------------------------------
-- Infix Operators
-- -----------------------------------------------------------------------------
-- | Infix operator for percentage tolerance check.
--
-- @
-- baseline \@~ 15.0 $ actual -- within ±15%
-- @
(@~) :: Double -> Double -> Double -> Bool
(@~) baseline tolerance actual = withinPercent tolerance baseline actual
infixl 4 @~
-- | Infix operator for absolute tolerance check.
--
-- @
-- baseline \@< 0.01 $ actual -- within ±0.01ms
-- @
(@<) :: Double -> Double -> Double -> Bool
(@<) baseline threshold actual = withinAbsolute threshold baseline actual
infixl 4 @<
-- | Infix operator for "must improve" check.
--
-- @
-- baseline \@<< 10.0 $ actual -- must be ≥10% faster
-- @
(@<<) :: Double -> Double -> Double -> Bool
(@<<) baseline minPercent actual = mustImprove minPercent baseline actual
infixl 4 @<<
-- | Infix operator for "must regress" check.
--
-- @
-- baseline \@>> 5.0 $ actual -- must be ≥5% slower
-- @
(@>>) :: Double -> Double -> Double -> Bool
(@>>) baseline minPercent actual = mustRegress minPercent baseline actual
infixl 4 @>>
-- -----------------------------------------------------------------------------
-- Boolean Composition
-- -----------------------------------------------------------------------------
-- | AND composition of expectations (both must pass).
--
-- @
-- expect _statsMean (Percent 15.0) &&~ expect _statsMAD (Percent 50.0)
-- @
(&&~) :: Expectation -> Expectation -> Expectation
(&&~) = And
infixr 3 &&~
-- | OR composition of expectations (either can pass).
--
-- @
-- expect _statsMedian (Percent 10.0) ||~ expect _statsMin (Absolute 0.01)
-- @
(||~) :: Expectation -> Expectation -> Expectation
(||~) = Or
infixr 2 ||~
-- -----------------------------------------------------------------------------
-- Utilities
-- -----------------------------------------------------------------------------
-- | Calculate percentage difference between baseline and actual.
--
-- Returns: @((actual - baseline) / baseline) * 100@
--
-- * Positive = regression (slower)
-- * Negative = improvement (faster)
-- * Zero = no change
percentDiff :: Double -> Double -> Double
percentDiff baseline actual
| baseline == 0 = if actual == 0 then 0 else 100
| otherwise = ((actual - baseline) / baseline) * 100
-- | Calculate absolute difference between baseline and actual.
--
-- 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)