golds-gym 0.2.0.0 → 0.3.0.0
raw patch · 8 files changed
+770/−434 lines, 8 filesdep +microlensPVP ok
version bump matches the API change (PVP)
Dependencies added: microlens
API changes (from Hackage documentation)
- Test.Hspec.BenchGolden: benchGoldenIO :: String -> IO () -> Spec
- Test.Hspec.BenchGolden: benchGoldenIOWith :: BenchConfig -> String -> IO () -> Spec
+ Test.Hspec.BenchGolden: (&&~) :: Expectation -> Expectation -> Expectation
+ Test.Hspec.BenchGolden: (@<) :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden: (@<<) :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden: (@>>) :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden: (@~) :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden: (||~) :: Expectation -> Expectation -> Expectation
+ Test.Hspec.BenchGolden: Absolute :: !Double -> Tolerance
+ Test.Hspec.BenchGolden: Hybrid :: !Double -> !Double -> Tolerance
+ Test.Hspec.BenchGolden: MustImprove :: !Double -> Tolerance
+ Test.Hspec.BenchGolden: MustRegress :: !Double -> Tolerance
+ Test.Hspec.BenchGolden: Percent :: !Double -> Tolerance
+ Test.Hspec.BenchGolden: _statsIQR :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: _statsMAD :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: _statsMax :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: _statsMean :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: _statsMedian :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: _statsMin :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: _statsStddev :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: _statsTrimmedMean :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: absDiff :: Double -> Double -> Double
+ Test.Hspec.BenchGolden: benchGoldenWithExpectation :: String -> BenchConfig -> [Expectation] -> IO () -> Spec
+ Test.Hspec.BenchGolden: checkExpectation :: Expectation -> GoldenStats -> GoldenStats -> Bool
+ Test.Hspec.BenchGolden: data Tolerance
+ Test.Hspec.BenchGolden: expect :: Lens' GoldenStats Double -> Tolerance -> Expectation
+ Test.Hspec.BenchGolden: expectStat :: Lens' GoldenStats Double -> Tolerance -> Expectation
+ Test.Hspec.BenchGolden: infixl 4 @>>
+ Test.Hspec.BenchGolden: infixr 2 ||~
+ Test.Hspec.BenchGolden: infixr 3 &&~
+ Test.Hspec.BenchGolden: instance Test.Hspec.Core.Example.Example Test.Hspec.BenchGolden.BenchGoldenWithExpectations
+ Test.Hspec.BenchGolden: metricFor :: BenchConfig -> Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: mustImprove :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden: mustRegress :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden: pattern Or :: () => !Expectation -> !Expectation -> Expectation
+ Test.Hspec.BenchGolden: pattern ExpectStat :: () => !Lens' GoldenStats Double -> !Tolerance -> Expectation
+ Test.Hspec.BenchGolden: percentDiff :: Double -> Double -> Double
+ Test.Hspec.BenchGolden: varianceFor :: BenchConfig -> Lens' GoldenStats Double
+ Test.Hspec.BenchGolden: withinAbsolute :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden: withinHybrid :: Double -> Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden: withinPercent :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: (&&~) :: Expectation -> Expectation -> Expectation
+ Test.Hspec.BenchGolden.Lenses: (@<) :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: (@<<) :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: (@>>) :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: (@~) :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: (||~) :: Expectation -> Expectation -> Expectation
+ Test.Hspec.BenchGolden.Lenses: Absolute :: !Double -> Tolerance
+ Test.Hspec.BenchGolden.Lenses: And :: !Expectation -> !Expectation -> Expectation
+ Test.Hspec.BenchGolden.Lenses: ExpectStat :: !Lens' GoldenStats Double -> !Tolerance -> Expectation
+ Test.Hspec.BenchGolden.Lenses: Hybrid :: !Double -> !Double -> Tolerance
+ Test.Hspec.BenchGolden.Lenses: MustImprove :: !Double -> Tolerance
+ Test.Hspec.BenchGolden.Lenses: MustRegress :: !Double -> Tolerance
+ Test.Hspec.BenchGolden.Lenses: Or :: !Expectation -> !Expectation -> Expectation
+ Test.Hspec.BenchGolden.Lenses: Percent :: !Double -> Tolerance
+ Test.Hspec.BenchGolden.Lenses: _statsIQR :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: _statsMAD :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: _statsMax :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: _statsMean :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: _statsMedian :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: _statsMin :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: _statsStddev :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: _statsTrimmedMean :: Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: absDiff :: Double -> Double -> Double
+ Test.Hspec.BenchGolden.Lenses: checkExpectation :: Expectation -> GoldenStats -> GoldenStats -> Bool
+ Test.Hspec.BenchGolden.Lenses: data Expectation
+ Test.Hspec.BenchGolden.Lenses: data Tolerance
+ Test.Hspec.BenchGolden.Lenses: expect :: Lens' GoldenStats Double -> Tolerance -> Expectation
+ Test.Hspec.BenchGolden.Lenses: expectStat :: Lens' GoldenStats Double -> Tolerance -> Expectation
+ Test.Hspec.BenchGolden.Lenses: infixl 4 @>>
+ Test.Hspec.BenchGolden.Lenses: infixr 2 ||~
+ Test.Hspec.BenchGolden.Lenses: infixr 3 &&~
+ Test.Hspec.BenchGolden.Lenses: instance GHC.Classes.Eq Test.Hspec.BenchGolden.Lenses.Expectation
+ Test.Hspec.BenchGolden.Lenses: instance GHC.Classes.Eq Test.Hspec.BenchGolden.Lenses.Tolerance
+ Test.Hspec.BenchGolden.Lenses: instance GHC.Show.Show Test.Hspec.BenchGolden.Lenses.Expectation
+ Test.Hspec.BenchGolden.Lenses: instance GHC.Show.Show Test.Hspec.BenchGolden.Lenses.Tolerance
+ Test.Hspec.BenchGolden.Lenses: metricFor :: BenchConfig -> Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: mustImprove :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: mustRegress :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: percentDiff :: Double -> Double -> Double
+ Test.Hspec.BenchGolden.Lenses: varianceFor :: BenchConfig -> Lens' GoldenStats Double
+ Test.Hspec.BenchGolden.Lenses: withinAbsolute :: Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: withinHybrid :: Double -> Double -> Double -> Double -> Bool
+ Test.Hspec.BenchGolden.Lenses: withinPercent :: Double -> Double -> Double -> Bool
Files
- CHANGELOG.md +30/−0
- README.md +63/−382
- example/Spec.hs +49/−6
- golds-gym.cabal +4/−2
- src/Test/Hspec/BenchGolden.hs +165/−34
- src/Test/Hspec/BenchGolden/Lenses.hs +446/−0
- src/Test/Hspec/BenchGolden/Runner.hs +11/−10
- src/Test/Hspec/BenchGolden/Types.hs +2/−0
CHANGELOG.md view
@@ -1,5 +1,35 @@ # Changelog +## [0.3.0]++### Added++- **Lens-based expectation combinators** for custom performance assertions+ - New `Test.Hspec.BenchGolden.Lenses` module with van Laarhoven lenses for `GoldenStats` fields+ - Lenses: `_statsMean`, `_statsMedian`, `_statsTrimmedMean`, `_statsStddev`, `_statsMAD`, `_statsIQR`, `_statsMin`, `_statsMax`+ - Smart metric selectors: `metricFor` and `varianceFor` automatically choose appropriate lens based on `BenchConfig`+ - `Expectation` type for composable performance assertions+ - `Tolerance` variants: `Percent`, `Absolute`, `Hybrid`, `MustImprove`, `MustRegress`+ - Boolean composition operators: `(&&~)` for AND, `(||~)` for OR+ - Infix operators: `(@~)` for percentage, `(@<)` for absolute, `(@<<)` for must-improve, `(@>>)` for must-regress+ - `benchGoldenWithExpectation` combinator for custom lens-based expectations+ - Enables assertions like "must be 10% faster", "median within 10%", "IQR < 0.1ms"++- **Tolerance helper functions** for manual comparison+ - `withinPercent`, `withinAbsolute`, `withinHybrid` for tolerance checking+ - `mustImprove`, `mustRegress` for directional performance expectations+ - `percentDiff`, `absDiff` utilities++### Changed++- **Refactored comparison logic to use lenses internally** (non-breaking)+ - `compareStats` now uses `metricFor` instead of if/else branching+ - `checkVariance` now uses `varianceFor` for cleaner metric selection++### Dependencies++- Added `microlens >= 0.4 && < 0.6`+ ## [0.2.0] - 2026-01-30 ### Added
README.md view
@@ -1,24 +1,16 @@ # golds-gym 🏋️ -[](https://github.com/ocramz/golds-gym/actions/workflows/ci.yml)--A Haskell golden testing framework for performance benchmarks.--## Overview+[](https://github.com/ocramz/golds-gym/actions/workflows/ci.yml) [](https://hackage.haskell.org/package/golds-gym) -`golds-gym` allows you to define timing benchmarks that are saved to golden files the first time they run. On subsequent runs, new benchmark results are compared against the golden baselines using configurable tolerance thresholds.+Golden testing for performance benchmarks. Save timing baselines on first run, compare against them on subsequent runs. **Key Features:**-- Architecture-specific golden files (different baselines per CPU/OS)-- Configurable tolerance for mean time comparison-- **Robust statistics** mode (trimmed mean, MAD, outlier detection)-- Optional variance (stddev) warnings-- Configurable warm-up iterations-- JSON-based golden files for easy inspection-- Seamless integration with hspec-+- Architecture-specific baselines (different hardware = different golden files)+- Hybrid tolerance (handles both fast <1ms and slow operations)+- Robust statistics mode (outlier detection, trimmed mean)+- Lens-based custom expectations (assert "must be faster", compare by median, etc.) -## Usage+## Quick Start ```haskell import Test.Hspec@@ -27,428 +19,117 @@ main :: IO () main = hspec $ do describe "Performance" $ do- -- Simple benchmark with defaults (100 iterations, 15% tolerance)+ -- Simple benchmark (100 iterations, ±15% tolerance) benchGolden "list append" $ return $ [1..1000] ++ [1..1000] - -- Benchmark with custom configuration+ -- Custom configuration benchGoldenWith defaultBenchConfig { iterations = 500 , tolerancePercent = 10.0- , warmupIterations = 10- , warnOnVarianceChange = True } "sorting" $ return $ sort [1000, 999..1]-- -- Robust statistics mode (outlier detection, trimmed mean)- benchGoldenWith defaultBenchConfig- { useRobustStatistics = True- , trimPercent = 10.0- , outlierThreshold = 3.0- , tolerancePercent = 10.0- }- "robust benchmark" $- return $ expensiveComputation input-- -- IO benchmark- benchGolden "file operations" $ do- writeFile "/tmp/test" "hello"- readFile "/tmp/test" ``` -## Golden Files--Golden files are stored in `.golden/<architecture>/` with the following structure:+**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). -```-.golden/-├── aarch64-darwin-Apple_M1/-│ ├── list-append.golden-│ ├── list-append.actual-│ └── sorting.golden-└── x86_64-linux-Intel_Core_i7/- └── list-append.golden+**Update baselines** after intentional changes:+```bash+GOLDS_GYM_ACCEPT=1 stack test ``` -Each `.golden` file contains JSON with timing statistics:+## How It Works +Golden files store timing statistics per architecture (e.g., `.golden/aarch64-darwin-Apple_M1/`):+ ```json { "mean": 1.234, "stddev": 0.056, "median": 1.201,- "min": 1.100,- "max": 1.456,- "percentiles": [[50, 1.201], [90, 1.350], [99, 1.440]], "architecture": "aarch64-darwin-Apple_M1",- "timestamp": "2026-01-30T12:00:00Z",- "trimmedMean": 1.220,- "mad": 0.042,- "iqr": 0.085,- "outliers": [1.456]+ "timestamp": "2026-01-30T12:00:00Z" } ``` -## Updating Baselines--To regenerate golden files (after intentional performance changes):--```bash-GOLDS_GYM_ACCEPT=1 cabal test-# Or with stack:-GOLDS_GYM_ACCEPT=1 stack test-```+**Hybrid tolerance** (default) prevents false failures: benchmarks pass if within **±15% OR ±0.01ms**. This handles measurement noise for fast operations (<1ms) while catching real regressions for slower code. ## Configuration -### BenchConfig Options+Key `BenchConfig` options: | Field | Default | Description | |-------|---------|-------------| | `iterations` | 100 | Number of benchmark iterations |-| `warmupIterations` | 5 | Warm-up runs (discarded) | | `tolerancePercent` | 15.0 | Allowed mean time deviation (%) |-| `absoluteToleranceMs` | Just 0.01 | Minimum absolute tolerance in milliseconds (hybrid tolerance) |-| `warnOnVarianceChange` | True | Warn if stddev changes significantly |-| `varianceTolerancePercent` | 50.0 | Allowed stddev deviation (%) |-| `outputDir` | ".golden" | Directory for golden files |-| `failOnFirstRun` | False | Fail if no baseline exists |-| `useRobustStatistics` | False | Use robust statistics (trimmed mean, MAD) |-| `trimPercent` | 10.0 | Percentage to trim from each tail (%) |-| `outlierThreshold` | 3.0 | MAD multiplier for outlier detection |--### Hybrid Tolerance Strategy--**New in v0.2.0**: Hybrid tolerance prevents false failures from measurement noise.--The framework uses BOTH percentage and absolute tolerance by default:--```-Benchmark passes if:- (mean_change <= ±15%) OR (abs_time_diff <= 0.01ms)-```--#### Why Hybrid Tolerance?--For extremely fast operations (< 1ms), tiny measurement noise causes huge percentage variations:+| `absoluteToleranceMs` | Just 0.01 | Absolute tolerance (ms) - enables hybrid mode |+| `useRobustStatistics` | False | Use trimmed mean/MAD instead of mean/stddev |+| `warmupIterations` | 5 | Warm-up runs before measurement | -- **Baseline**: 0.001 ms-- **Actual**: 0.0015 ms -- **Percentage difference**: +50% ❌ (fails with 15% tolerance)-- **Absolute difference**: +0.0005 ms ✅ (negligible, within 0.01ms tolerance)+See `BenchConfig` type for all options. -The hybrid approach automatically handles this:+**Environment variables:**+- `GOLDS_GYM_ACCEPT=1` - Regenerate all golden files+- `GOLDS_GYM_SKIP=1` - Skip benchmarks entirely (useful in CI) -- **Fast operations (< 1ms)**: Absolute tolerance dominates → noise ignored-- **Slow operations (> 1ms)**: Percentage tolerance dominates → regressions caught+## Advanced: Robust Statistics -#### Configuration Examples+Standard mean/stddev are sensitive to outliers (GC pauses, OS scheduling). Robust statistics provide outlier-resistant comparisons: -**Default (hybrid tolerance)**: ```haskell-benchGolden "fast operation" $ do- return $ sum [1..100]-```-Passes if within ±15% **or** ±0.01ms (10 microseconds).--**Percentage-only (disable absolute tolerance)**:-```haskell benchGoldenWith defaultBenchConfig- { absoluteToleranceMs = Nothing- , tolerancePercent = 20.0- }- "long operation" $ do- return $ expensiveComputation input-```-Traditional percentage-only comparison.--**Strict absolute tolerance**:-```haskell-benchGoldenWith defaultBenchConfig- { absoluteToleranceMs = Just 0.001 -- 1 microsecond- , tolerancePercent = 10.0- }- "performance-critical" $ do- return $ criticalPath data-```-Very strict for performance-critical code.--**Relaxed tolerance for noisy CI**:-```haskell-benchGoldenWith defaultBenchConfig- { absoluteToleranceMs = Just 0.1 -- 100 microseconds- , tolerancePercent = 25.0+ { useRobustStatistics = True -- Use trimmed mean + MAD+ , trimPercent = 10.0 -- Remove top/bottom 10%+ , outlierThreshold = 3.0 -- Flag outliers >3 MADs from median }- "ci benchmark" $ do+ "noisy benchmark" $ return $ computation input ```-More forgiving for shared CI runners. -## Architecture Detection--The framework automatically detects:-- CPU architecture (x86_64, aarch64)-- Operating system (darwin, linux, windows)-- CPU model (Apple M1, Intel Core i7, etc.)--This ensures benchmarks are only compared against baselines from equivalent hardware.--## Robust Statistics--**New in 0.1.0**: Robust statistical methods for more reliable benchmark comparisons.--### Why Use Robust Statistics?--Standard mean and standard deviation are sensitive to outliers. A single anomalous timing (e.g., from GC, OS scheduling) can skew results. Robust statistics provide:+**When to use:**+- Benchmarking in noisy environments (shared CI, development machines)+- Operations with occasional GC pauses or system interruptions+- Fast operations (<1ms) with high variance+- You see outliers in test output warnings -- **Trimmed Mean**: Removes extreme values before averaging-- **MAD (Median Absolute Deviation)**: Outlier-resistant measure of variance-- **Outlier Detection**: Identifies and reports anomalous timings-- **IQR (Interquartile Range)**: Spread of the middle 50% of data+## Advanced: Lens-Based Expectations -### Enabling Robust Mode+For fine-grained control, use lens-based expectations to assert custom performance requirements: ```haskell-benchGoldenWith defaultBenchConfig- { useRobustStatistics = True -- Enable robust statistics- , trimPercent = 10.0 -- Trim 10% from each tail- , outlierThreshold = 3.0 -- Outliers are 3+ MADs from median- , tolerancePercent = 10.0 -- Compare trimmed means- }- "my benchmark" $ do- -- your code here-```--### How It Works--1. **Trimmed Mean**: Sorts all timing measurements, removes the top and bottom `trimPercent`, then computes the mean of remaining values.--2. **MAD Calculation**: Computes `median(|x - median(x)|)` - more robust than standard deviation.--3. **Outlier Detection**: Any measurement where `|x - median| > outlierThreshold * MAD` is flagged as an outlier.--4. **Comparison**: When enabled, uses trimmed mean instead of mean for regression detection, and MAD instead of stddev for variance checks.--### Outlier Warnings--When outliers are detected, you'll see warnings in test output:--```-Warnings:- ⚠ 3 outlier(s) detected: 2.1ms 2.3ms 2.5ms-```--Outliers are reported but **not removed** - they're preserved in golden files for analysis.--### When to Use Robust Statistics--✅ **Use robust statistics when:**-- Benchmarking in noisy environments (shared CI runners)-- Operations subject to GC pauses or OS scheduling variability-- Fast operations (< 1ms) with high relative variance (CV > 50%)-- Sorting already-sorted data or other operations with occasional slowdowns-- You see large max/stddev values with small mean times-- You need more stable baselines across runs--❌ **Standard statistics may be better when:**-- Benchmarking isolated, long-running operations-- You have dedicated benchmark hardware-- Outliers are legitimate and should be tracked--## Integration with CI--In CI environments, you may want to:--1. **Skip benchmarks** (if CI is too noisy):- ```bash- GOLDS_GYM_SKIP=1 cabal test- ```--2. **Use relaxed tolerance** (for shared CI runners):- ```haskell- benchGoldenWith defaultBenchConfig- { tolerancePercent = 25.0- , absoluteToleranceMs = Just 0.1 -- 100 microseconds- }- "benchmark" $ ...- ```--3. **Enable robust statistics** (outlier detection):- ```haskell- benchGoldenWith defaultBenchConfig- { useRobustStatistics = True- , tolerancePercent = 20.0- }- "benchmark" $ ...- ```--## Troubleshooting--### Random Test Failures Due to Measurement Noise--**Symptom**: Tests fail intermittently with small percentage increases despite negligible absolute time differences:--```-Mean time increased by 35.5% (tolerance: 15.0%)--Metric Actual Baseline Diff------- ------ -------- -----Mean 0.001 ms 0.000 ms +35.5%-```--**Root Cause**: Operations taking < 1ms have high relative measurement noise. A 0.0005ms difference is negligible but represents 50% variation.--**Solutions**:--1. **Use hybrid tolerance (default since v0.2.0)**:- ```haskell- benchGolden "fast operation" $ ...- ```- The default `absoluteToleranceMs = Just 0.01` prevents these failures.--2. **Adjust absolute tolerance threshold**:- ```haskell- benchGoldenWith defaultBenchConfig- { absoluteToleranceMs = Just 0.001 -- Stricter: 1 microsecond- }- "very fast operation" $ ...- ```--3. **Increase iterations for stability**:- ```haskell- benchGoldenWith defaultBenchConfig- { iterations = 500 -- More samples reduce noise- }- "noisy operation" $ ...- ```--4. **Use robust statistics**:- ```haskell- benchGoldenWith defaultBenchConfig- { useRobustStatistics = True -- Outlier-resistant- , trimPercent = 10.0- }- "operation with outliers" $ ...- ```--### High Variance Warnings--**Symptom**: Warnings about variance changes despite passing benchmarks:--```-Warnings:- ⚠ Variance increased by 65.2% (0.001 ms -> 0.002 ms, tolerance: 50.0%)-```--**Solutions**:--1. **Disable variance warnings** (if not critical):- ```haskell- benchGoldenWith defaultBenchConfig- { warnOnVarianceChange = False- }- "benchmark" $ ...- ```--2. **Increase variance tolerance**:- ```haskell- benchGoldenWith defaultBenchConfig- { varianceTolerancePercent = 100.0 -- Allow ±100% stddev change- }- "benchmark" $ ...- ```--3. **Use robust statistics** (MAD instead of stddev):- ```haskell- benchGoldenWith defaultBenchConfig- { useRobustStatistics = True -- Uses MAD, more stable- }- "benchmark" $ ...- ```--### Outlier Warnings+import Test.Hspec.BenchGolden.Lenses -**Symptom**: Outliers detected in benchmark runs:+-- Compare by median instead of mean (more robust)+benchGoldenWithExpectation "median comparison" defaultBenchConfig+ [expect _statsMedian (Percent 10.0)]+ myAction -```-Warnings:- ⚠ 3 outlier(s) detected: 2.1ms 2.3ms 2.5ms+-- Compose multiple requirements (both must pass)+benchGoldenWithExpectation "strict requirements" defaultBenchConfig+ [ expect _statsMean (Percent 15.0) &&~+ expect _statsIQR (Absolute 0.1) -- Low variance required+ ]+ myAction ``` -**Causes**:-- Garbage collection pauses-- OS scheduling interruptions-- CPU thermal throttling-- Background processes--**Solutions**:--1. **Increase outlier threshold** (less sensitive):- ```haskell- benchGoldenWith defaultBenchConfig- { useRobustStatistics = True- , outlierThreshold = 5.0 -- More forgiving (default: 3.0)- }- "benchmark" $ ...- ```--2. **Increase warm-up iterations**:- ```haskell- benchGoldenWith defaultBenchConfig- { warmupIterations = 20 -- Stabilize before measurement- }- "benchmark" $ ...- ```--3. **Minimize system load**:- - Close background applications- - Disable system services during benchmarking- - Use dedicated benchmark hardware--### Benchmarks Pass Locally But Fail in CI--**Cause**: Different architecture or noisier environment.--**Solutions**:--1. **Architecture-specific baselines**: Golden files are already per-architecture. Check that your CI architecture ID matches:- ```bash- GOLDS_GYM_ARCH=custom-ci-id cabal test- ```--2. **Relaxed CI configuration**:- ```haskell- #ifdef CI_BUILD- ciConfig :: BenchConfig- ciConfig = defaultBenchConfig- { tolerancePercent = 30.0- , absoluteToleranceMs = Just 0.2- , useRobustStatistics = True- }- #endif- ```--3. **Skip benchmarks in CI**:- ```yaml- # .github/workflows/ci.yml- - name: Run tests- run: GOLDS_GYM_SKIP=1 stack test- ```+**Available lenses:** `_statsMean`, `_statsMedian`, `_statsTrimmedMean`, `_statsStddev`, `_statsMAD`, `_statsIQR`, `_statsMin`, `_statsMax` -### Regenerating Golden Files+**Tolerance types:**+- `Percent 15.0` - Within ±15%+- `Absolute 0.01` - Within ±0.01ms+- `Hybrid 15.0 0.01` - Within ±15% OR ±0.01ms+- `MustImprove 10.0` - Must be ≥10% faster (for testing optimizations)+- `MustRegress 5.0` - Must be ≥5% slower (for accepting controlled regressions) -**When to regenerate**:-- Intentional performance improvements/changes-- Compiler upgrades affecting code generation-- Architecture changes+**Composition:** `(&&~)` for AND, `(||~)` for OR -**How**:-```bash-GOLDS_GYM_ACCEPT=1 stack test-```+## Documentation -**Warning**: Only regenerate when you've verified the performance change is expected!+- [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 ## License
example/Spec.hs view
@@ -5,6 +5,7 @@ import Data.List (sort) import Test.Hspec import Test.Hspec.BenchGolden+import Test.Hspec.BenchGolden.Lenses main :: IO () main = hspec spec@@ -58,12 +59,6 @@ _ <- evaluate $ sum [1..10000 :: Int] return () - describe "IO Operations" $ do- benchGoldenIO "evaluate lazy thunk" $ do- let bigList = [1..100000 :: Int]- _ <- evaluate (length bigList)- return ()- describe "Robust Statistics Mode" $ do -- Benchmark using robust statistics (trimmed mean, MAD) benchGoldenWith defaultBenchConfig@@ -132,6 +127,54 @@ "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 ()++ -- Use robust statistics lens (trimmed mean)+ benchGoldenWithExpectation "trimmed mean comparison" + (defaultBenchConfig { useRobustStatistics = True })+ [expect _statsTrimmedMean (Percent 20.0)]+ $ do+ _ <- evaluate $ fib 26+ return () -- | 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.2.0.0+version: 0.3.0.0 synopsis: Golden testing framework for performance benchmarks description: A Haskell framework for golden testing of timing benchmarks.@@ -25,6 +25,7 @@ Test.Hspec.BenchGolden.Types Test.Hspec.BenchGolden.Arch Test.Hspec.BenchGolden.Runner+ Test.Hspec.BenchGolden.Lenses build-depends: base >= 4.14 && < 5,@@ -39,7 +40,8 @@ bytestring >= 0.10 && < 0.13, statistics >= 0.16 && < 0.17, vector >= 0.12 && < 0.14,- boxes >= 0.1 && < 0.2+ boxes >= 0.1 && < 0.2,+ microlens >= 0.4 && < 0.6 hs-source-dirs: src default-language: Haskell2010
src/Test/Hspec/BenchGolden.hs view
@@ -9,15 +9,18 @@ -- Description : Golden testing for performance benchmarks -- Copyright : (c) 2026 -- License : MIT--- Maintainer : your.email@example.com+-- Maintainer : @ocramz -- -- = Overview -- -- @golds-gym@ is a framework for golden testing of performance benchmarks. -- It integrates with hspec and uses benchpress for lightweight timing measurements. ----- Optionally, benchmarks can use robust statistics to mitigate the impact of outliers.+-- Benchmarks can use robust statistics to mitigate the impact of outliers. --+-- The library can be used both to assert that performance does not regress, and to set expectations+-- for improvements across project versions (see `benchGoldenWithExpectation`).+-- -- = Quick Start -- -- @@@ -37,10 +40,7 @@ -- golden file as the baseline. -- -- 2. On subsequent runs, the benchmark is executed and compared against--- the baseline using a configurable tolerance (default ±15%).------ 3. If the mean time exceeds the tolerance, the test fails with a--- regression or improvement notification.+-- the baseline using a configurable tolerance or expectation combinators. -- -- = Architecture-Specific Baselines --@@ -50,14 +50,7 @@ -- -- = Configuration ----- Use 'benchGoldenWith' with a custom 'BenchConfig' to adjust:------ * Number of iterations--- * Warm-up iterations--- * Tolerance percentage--- * Absolute tolerance (hybrid tolerance strategy)--- * Variance warnings--- * Robust statistics mode (trimmed mean, MAD, outlier detection)+-- Use 'benchGoldenWith' or 'benchGoldenWithExpectation' with a custom 'BenchConfig': -- -- == Tolerance Configuration --@@ -100,9 +93,32 @@ -- } -- \"benchmark\" $ ... -- @+---- = Lens-Based Expectations (Advanced) ----- = Environment Variables+-- For custom performance expectations, use lens-based combinators: --+-- @+-- import Test.Hspec.BenchGolden.Lenses+--+-- -- Median-based comparison instead of mean+-- benchGoldenWithExpectation "median test" defaultBenchConfig+-- [expect _statsMedian (Percent 10.0)]+-- myAction+--+-- -- Compose multiple expectations+-- benchGoldenWithExpectation "strict test" defaultBenchConfig+-- [ expect _statsMean (Percent 15.0) &&~+-- expect _statsMAD (Percent 50.0)+-- ]+-- myAction+--+-- -- Expect improvement (must be faster)+-- benchGoldenWithExpectation "optimization" defaultBenchConfig+-- [expect _statsMean (MustImprove 10.0)] -- Must be ≥10% faster+-- myAction+-- @+---- = Environment Variables+-- -- * @GOLDS_GYM_ACCEPT=1@ - Regenerate all golden files -- * @GOLDS_GYM_SKIP=1@ - Skip all benchmark tests -- * @GOLDS_GYM_ARCH=custom-id@ - Override architecture detection@@ -111,8 +127,7 @@ ( -- * Spec Combinators benchGolden , benchGoldenWith- , benchGoldenIO- , benchGoldenIOWith+ , benchGoldenWithExpectation -- * Configuration , BenchConfig(..)@@ -128,12 +143,16 @@ -- * Low-Level API , runBenchGolden + -- * Lens-Based Expectations+ , module Test.Hspec.BenchGolden.Lenses+ -- * Re-exports , module Test.Hspec.BenchGolden.Arch ) where import Data.IORef import qualified Data.Text as T+import Lens.Micro ((^.)) import System.Environment (lookupEnv) import Text.Printf (printf) import qualified Text.PrettyPrint.Boxes as Box@@ -141,6 +160,8 @@ import Test.Hspec.Core.Spec 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.Types @@ -201,30 +222,70 @@ , benchConfig = config } --- | Create a benchmark golden test for an IO action.++-- | Create a benchmark golden test with custom lens-based expectations. ----- This is an alias for 'benchGolden' that makes it clear the action--- involves IO (e.g., file operations, network calls).+-- This combinator allows you to specify custom performance expectations using+-- lenses and tolerance combinators. Expectations can be composed using boolean+-- operators ('&&~', '||~'). --+-- Examples:+-- -- @--- benchGoldenIO "file read" $ do--- contents <- readFile "large-file.txt"--- evaluate (length contents)+-- -- Median-based comparison (more robust to outliers)+-- benchGoldenWithExpectation "median test" defaultBenchConfig+-- [`expect` `_statsMedian` (`Percent` 10.0)]+-- myAction+--+-- -- Multiple metrics must pass (AND composition)+-- benchGoldenWithExpectation "strict test" defaultBenchConfig+-- [ expect `_statsMean` (Percent 15.0) &&~+-- expect `_statsMAD` (Percent 50.0)+-- ]+-- myAction+--+-- -- Either metric can pass (OR composition)+-- benchGoldenWithExpectation "flexible test" defaultBenchConfig+-- [ expect _statsMedian (Percent 10.0) ||~+-- expect _statsMin (`Absolute` 0.01)+-- ]+-- myAction+--+-- -- Expect performance improvement (must be faster)+-- benchGoldenWithExpectation "optimization" defaultBenchConfig+-- [expect _statsMean (`MustImprove` 10.0)] -- Must be ≥10% faster+-- myAction+--+-- -- Expect controlled regression (for feature additions)+-- benchGoldenWithExpectation "new feature" defaultBenchConfig+-- [expect _statsMean (`MustRegress` 5.0)] -- Accept 5-20% slowdown+-- myAction+--+-- -- Low variance requirement+-- benchGoldenWithExpectation "stable perf" defaultBenchConfig+-- [ expect _statsMean (Percent 15.0) &&~+-- expect `_statsIQR` (Absolute 0.1)+-- ]+-- myAction -- @ ----- Note: For IO actions in noisy environments (CI, shared systems),--- consider using 'benchGoldenIOWith' with @useRobustStatistics = True@.-benchGoldenIO :: String -- ^ Name of the benchmark- -> IO () -- ^ The IO action to benchmark+-- Note: Expectations are checked against golden files. On first run, a baseline+-- is created. Use @GOLDS_GYM_ACCEPT=1@ to regenerate baselines.+benchGoldenWithExpectation ::+ String -- ^ Name of the benchmark+ -> BenchConfig -- ^ Configuration parameters+ -> [L.Expectation] -- ^ List of expectations (all must pass)+ -> IO () -- ^ The IO action to benchmark -> Spec-benchGoldenIO = benchGolden+benchGoldenWithExpectation name config expectations action =+ it name $ BenchGoldenWithExpectations name action config expectations --- | Create an IO benchmark golden test with custom configuration.-benchGoldenIOWith :: BenchConfig -- ^ Configuration parameters- -> String -- ^ Name of the benchmark- -> IO () -- ^ The IO action to benchmark- -> Spec-benchGoldenIOWith = benchGoldenWith+-- | Data type for benchmarks with custom lens-based expectations.+data BenchGoldenWithExpectations = BenchGoldenWithExpectations+ !String -- Name+ !(IO ()) -- Action+ !BenchConfig -- Config+ ![L.Expectation] -- Expectations -- | Instance for BenchGolden without arguments. instance Example BenchGolden where@@ -263,6 +324,76 @@ result <- runBenchGolden bg writeIORef ref (fromBenchResult result) readIORef ref++-- | Instance for BenchGoldenWithExpectations (custom expectations).+instance Example BenchGoldenWithExpectations where+ type Arg BenchGoldenWithExpectations = ()+ evaluateExample (BenchGoldenWithExpectations name action config expectations) _params hook _progress = do+ -- Read environment variables to determine accept/skip flags+ acceptEnv <- lookupEnv "GOLDS_GYM_ACCEPT"+ skipEnv <- lookupEnv "GOLDS_GYM_SKIP"+ + let shouldAccept = case acceptEnv of+ Just "1" -> True+ Just "true" -> True+ Just "yes" -> True+ _ -> False+ shouldSkip = case skipEnv of+ Just "1" -> True+ Just "true" -> True+ Just "yes" -> True+ _ -> False+ + -- Store the flags so Runner can access them+ setAcceptGoldens shouldAccept+ setSkipBenchmarks shouldSkip+ + ref <- newIORef (Result "" Success)+ hook $ \() -> do+ result <- runBenchGoldenWithExpectations name action config expectations+ writeIORef ref (fromBenchResultWithExpectations expectations result)+ readIORef ref++-- | Run a benchmark with custom expectations.+runBenchGoldenWithExpectations :: String -> IO () -> 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+ + -- Then check expectations for Pass/Regression/Improvement results+ case result of+ FirstRun stats -> return $ FirstRun stats+ Pass golden actual warnings ->+ -- Check all expectations+ let allPass = all (\e -> L.checkExpectation e golden actual) expectations+ in if allPass+ then return $ Pass golden actual warnings+ else + -- Expectations failed - calculate actual percentage diff for error message+ let lens = L.metricFor config+ goldenVal = golden ^. lens+ actualVal = actual ^. lens+ 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 ->+ -- 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 ->+ -- 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++-- | Convert expectation-based benchmark result to hspec Result.+fromBenchResultWithExpectations :: [L.Expectation] -> BenchResult -> Result+fromBenchResultWithExpectations _expectations = fromBenchResult -- | Convert a benchmark result to an hspec Result. fromBenchResult :: BenchResult -> Result
+ src/Test/Hspec/BenchGolden/Lenses.hs view
@@ -0,0 +1,446 @@+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module : Test.Hspec.BenchGolden.Lenses+-- Description : Lens-based expectation combinators for benchmark comparison+-- Copyright : (c) 2026+-- License : MIT+-- Maintainer : your.email@example.com+--+-- 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+ ) 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)
src/Test/Hspec/BenchGolden/Runner.hs view
@@ -60,9 +60,12 @@ import System.FilePath ((</>), (<.>)) import System.IO.Unsafe (unsafePerformIO) +import Lens.Micro ((^.))+ import qualified Test.BenchPress as BP import Test.Hspec.BenchGolden.Arch (detectArchitecture, sanitizeForFilename)+import Test.Hspec.BenchGolden.Lenses (metricFor, varianceFor) import Test.Hspec.BenchGolden.Types -- | Run a benchmark golden test.@@ -295,11 +298,10 @@ -- noise creates large percentage variations despite negligible absolute differences. compareStats :: BenchConfig -> GoldenStats -> GoldenStats -> BenchResult compareStats config golden actual =- let -- Choose comparison metric based on config- (goldenValue, actualValue) =- if useRobustStatistics config- then (statsTrimmedMean golden, statsTrimmedMean actual)- else (statsMean golden, statsMean actual)+ let -- Use lens-based metric selection+ metric = metricFor config+ goldenValue = golden ^. metric+ actualValue = actual ^. metric -- Calculate percentage difference meanDiff = if goldenValue == 0@@ -338,11 +340,10 @@ -- | Check for variance changes and generate warnings. checkVariance :: BenchConfig -> GoldenStats -> GoldenStats -> [Warning] checkVariance config golden actual =- let -- Use MAD for robust statistics, stddev otherwise- (goldenVar, actualVar) =- if useRobustStatistics config- then (statsMAD golden, statsMAD actual)- else (statsStddev golden, statsStddev actual)+ let -- Use lens-based variance metric selection+ vLens = varianceFor config+ goldenVar = golden ^. vLens+ actualVar = actual ^. vLens varDiff = if goldenVar == 0 then if actualVar == 0 then 0 else 100
src/Test/Hspec/BenchGolden/Types.hs view
@@ -39,6 +39,8 @@ import Data.Time (UTCTime) import GHC.Generics (Generic) +-- Note: Expectation type is defined in Lenses module to avoid circular imports+ -- | Configuration for a single benchmark golden test. data BenchGolden = BenchGolden { benchName :: !String