packages feed

golds-gym-0.4.0.0: src/Test/Hspec/BenchGolden/Types.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- |
-- Module      : Test.Hspec.BenchGolden.Types
-- Description : Core types for benchmark golden testing
-- Copyright   : (c) 2026
-- License     : MIT
-- Maintainer  : @ocramz
--
-- This module defines the core data types used by the golds-gym framework:
--
-- * 'BenchGolden' - Configuration for a benchmark golden test
-- * 'BenchConfig' - Configurable benchmark parameters
-- * 'GoldenStats' - Statistics stored in golden files
-- * 'ArchConfig' - Machine architecture identification
-- * 'BenchResult' - Result of comparing benchmark against golden

module Test.Hspec.BenchGolden.Types
  ( -- * Benchmark Configuration
    BenchGolden(..)
  , BenchConfig(..)
  , defaultBenchConfig
  , BenchAction(..)

    -- * Golden File Statistics
  , GoldenStats(..)

    -- * Architecture Configuration
  , ArchConfig(..)

    -- * Benchmark Results
  , BenchResult(..)
  , Warning(..)
  ) where

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 :: !BenchAction
    -- ^ The benchmarkable action to run
  , benchConfig :: !BenchConfig
    -- ^ Configuration parameters
  }

-- | Configurable parameters for benchmark execution and comparison.
data BenchConfig = BenchConfig
  { iterations            :: !Int
    -- ^ Number of benchmark iterations to run
  , warmupIterations      :: !Int
    -- ^ Number of warm-up iterations (discarded before measurement)
  , tolerancePercent      :: !Double
    -- ^ Allowed deviation in mean time (as percentage, e.g., 15.0 = ±15%)
  , absoluteToleranceMs   :: !(Maybe Double)
    -- ^ Minimum absolute tolerance in milliseconds (e.g., 0.01 = 10 microseconds).
    --   When set, benchmarks pass if EITHER the percentage difference is within
    --   'tolerancePercent' OR the absolute time difference is within this threshold.
    --   This prevents false failures for extremely fast operations (< 1ms) where
    --   measurement noise causes large percentage variations despite negligible
    --   absolute time differences. Set to 'Nothing' to disable (percentage-only).
  , warnOnVarianceChange  :: !Bool
    -- ^ Whether to emit warnings when stddev changes significantly
  , varianceTolerancePercent :: !Double
    -- ^ Allowed deviation in stddev (as percentage)
  , outputDir             :: !FilePath
    -- ^ Directory for storing golden files
  , failOnFirstRun        :: !Bool
    -- ^ Whether to fail if no golden file exists yet
  , useRobustStatistics   :: !Bool
    -- ^ Use robust statistics (trimmed mean, MAD) instead of mean/stddev
  , trimPercent           :: !Double
    -- ^ Percentage to trim from each tail for trimmed mean (e.g., 10.0 = 10%)
  , outlierThreshold      :: !Double
    -- ^ MAD multiplier for outlier detection (e.g., 3.0 = 3 MADs from median)
  } deriving (Show, Eq, Generic)

-- | Default benchmark configuration with sensible defaults.
--
-- * 100 iterations
-- * 5 warm-up iterations
-- * 15% tolerance on mean time
-- * 0.01 ms (10 microseconds) absolute tolerance - prevents false failures for fast operations
-- * Variance warnings enabled at 50% tolerance
-- * Output to @.golden/@ directory
-- * Success on first run (creates baseline)
--
-- = Hybrid Tolerance Strategy
--
-- The default configuration uses BOTH percentage and absolute tolerance:
--
-- * Benchmarks pass if mean time is within ±15% OR within ±0.01ms
-- * This prevents measurement noise from failing fast operations (< 1ms)
-- * For slower operations (> 1ms), percentage tolerance dominates
--
-- Set @absoluteToleranceMs = Nothing@ for percentage-only comparison.
defaultBenchConfig :: BenchConfig
defaultBenchConfig = BenchConfig
  { iterations            = 100
  , warmupIterations      = 5
  , tolerancePercent      = 15.0
  , absoluteToleranceMs   = Just 0.01  -- 10 microseconds
  , warnOnVarianceChange  = True
  , varianceTolerancePercent = 50.0
  , outputDir             = ".golden"
  , failOnFirstRun        = False
  , useRobustStatistics   = False
  , trimPercent           = 10.0
  , outlierThreshold      = 3.0
  }

-- | Statistics stored in golden files.
--
-- These represent the baseline performance characteristics of a benchmark
-- on a specific architecture.
data GoldenStats = GoldenStats
  { statsMean        :: !Double
    -- ^ Mean execution time in milliseconds
  , statsStddev      :: !Double
    -- ^ Standard deviation in milliseconds
  , statsMedian      :: !Double
    -- ^ Median execution time in milliseconds
  , statsMin         :: !Double
    -- ^ Minimum execution time in milliseconds
  , statsMax         :: !Double
    -- ^ Maximum execution time in milliseconds
  , statsPercentiles :: ![(Int, Double)]
    -- ^ Percentile values (e.g., [(50, 1.2), (90, 1.5), (99, 1.8)])
  , statsArch        :: !Text
    -- ^ Architecture identifier
  , statsTimestamp   :: !UTCTime
    -- ^ When this baseline was recorded
  , statsTrimmedMean :: !Double
    -- ^ Trimmed mean (with tails removed) in milliseconds
  , statsMAD         :: !Double
    -- ^ Median absolute deviation in milliseconds
  , statsIQR         :: !Double
    -- ^ Interquartile range (Q3 - Q1) in milliseconds
  , statsOutliers    :: ![Double]
    -- ^ List of detected outlier timings in milliseconds
  } deriving (Show, Eq, Generic)

-- | Machine architecture configuration.
--
-- Used to generate unique identifiers for golden file directories,
-- ensuring benchmarks are only compared against equivalent hardware.
data ArchConfig = ArchConfig
  { archId    :: !Text
    -- ^ Unique identifier (e.g., "aarch64-darwin-Apple_M1")
  , archOS    :: !Text
    -- ^ Operating system (e.g., "darwin", "linux")
  , archCPU   :: !Text
    -- ^ CPU architecture (e.g., "aarch64", "x86_64")
  , archModel :: !(Maybe Text)
    -- ^ CPU model if available (e.g., "Apple M1", "Intel Core i7")
  } deriving (Show, Eq, Generic)

-- | Result of running a benchmark and comparing against golden.
data BenchResult
  = FirstRun !GoldenStats
    -- ^ No golden file existed; baseline created
  | Pass !GoldenStats !GoldenStats ![Warning]
    -- ^ Benchmark passed (golden stats, actual stats, warnings)
  | Regression !GoldenStats !GoldenStats !Double !Double !(Maybe Double)
    -- ^ Performance regression (golden, actual, percent change, tolerance, absolute tolerance)
  | Improvement !GoldenStats !GoldenStats !Double !Double !(Maybe Double)
    -- ^ Performance improvement (golden, actual, percent change, tolerance, absolute tolerance)
  deriving (Show, Eq)

-- | Warnings that may be emitted during benchmark comparison.
data Warning
  = VarianceIncreased !Double !Double !Double !Double
    -- ^ Stddev increased (golden, actual, percent change, tolerance)
  | VarianceDecreased !Double !Double !Double !Double
    -- ^ Stddev decreased significantly (golden, actual, percent change, tolerance)
  | HighVariance !Double
    -- ^ Current run has unusually high variance
  | OutliersDetected !Int ![Double]
    -- ^ Outliers detected (count, list of outlier timings)
  deriving (Show, Eq)

-- JSON instances for GoldenStats (stored in golden files)

instance ToJSON GoldenStats where
  toJSON GoldenStats{..} = object
    [ "mean"        .= statsMean
    , "stddev"      .= statsStddev
    , "median"      .= statsMedian
    , "min"         .= statsMin
    , "max"         .= statsMax
    , "percentiles" .= statsPercentiles
    , "architecture" .= statsArch
    , "timestamp"   .= statsTimestamp
    , "trimmedMean" .= statsTrimmedMean
    , "mad"         .= statsMAD
    , "iqr"         .= statsIQR
    , "outliers"    .= statsOutliers
    ]

instance FromJSON GoldenStats where
  parseJSON = withObject "GoldenStats" $ \v -> GoldenStats
    <$> v .: "mean"
    <*> v .: "stddev"
    <*> v .: "median"
    <*> v .: "min"
    <*> v .: "max"
    <*> v .: "percentiles"
    <*> v .: "architecture"
    <*> v .: "timestamp"
    <*> v .: "trimmedMean"
    <*> v .: "mad"
    <*> v .: "iqr"
    <*> v .: "outliers"

instance ToJSON ArchConfig where
  toJSON ArchConfig{..} = object
    [ "id"    .= archId
    , "os"    .= archOS
    , "cpu"   .= archCPU
    , "model" .= archModel
    ]

instance FromJSON ArchConfig where
  parseJSON = withObject "ArchConfig" $ \v -> ArchConfig
    <$> v .: "id"
    <*> v .: "os"
    <*> v .: "cpu"
    <*> v .:? "model"