packages feed

synapse-cc-0.2.0: src/SynapseCC/Benchmark.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}

-- | Pipeline step benchmarking with regression detection.
--
-- After the first successful run, step timings are written to a JSON baseline
-- file inside the cache directory. Every subsequent run compares against that
-- baseline and emits a warning for any step that regresses by more than the
-- configured threshold.
module SynapseCC.Benchmark
  ( -- * Timing
    timeStep

    -- * Baseline management
  , loadBaseline
  , saveBaseline
  , baselinePath

    -- * Regression reporting
  , reportBenchmarks

    -- * Types
  , Baseline(..)
  , StepTiming(..)
  ) where

import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy as BL
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock
import GHC.Generics (Generic)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath ((</>), takeDirectory)

-- ============================================================================
-- Types
-- ============================================================================

data StepTiming = StepTiming
  { stName :: !Text
  , stMs   :: !Int
  } deriving stock (Show, Eq, Generic)
    deriving anyclass (FromJSON, ToJSON)

data Baseline = Baseline
  { blVersion    :: !Text
  , blBackend    :: !Text
  , blTarget     :: !Text
  , blRecordedAt :: !Text
  , blSteps      :: !(Map Text Int)  -- step name -> baseline ms
  , blTotalMs    :: !Int
  } deriving stock (Show, Eq, Generic)
    deriving anyclass (FromJSON, ToJSON)

-- ============================================================================
-- Timing
-- ============================================================================

-- | Run an action and return its result paired with elapsed milliseconds.
timeStep :: IO a -> IO (a, Int)
timeStep action = do
  t0 <- getCurrentTime
  result <- action
  t1 <- getCurrentTime
  let ms = round (diffUTCTime t1 t0 * 1000) :: Int
  pure (result, ms)

-- ============================================================================
-- Baseline I/O
-- ============================================================================

-- | Canonical path for the baseline file.
baselinePath :: FilePath  -- ^ cache dir (optCacheDir)
             -> Text      -- ^ target  (e.g. "typescript")
             -> Text      -- ^ backend (e.g. "substrate")
             -> FilePath
baselinePath cacheDir target backend =
  cacheDir </> "benchmarks" </> T.unpack target </> T.unpack backend <> ".json"

-- | Load a baseline from disk.  Returns Nothing if the file doesn't exist or
-- can't be parsed.
loadBaseline :: FilePath -> IO (Maybe Baseline)
loadBaseline path = do
  exists <- doesFileExist path
  if not exists
    then pure Nothing
    else do
      bytes <- BL.readFile path
      case Aeson.eitherDecode bytes of
        Left  _  -> pure Nothing   -- corrupted — treat as missing
        Right bl -> pure (Just bl)

-- | Write a baseline to disk, creating parent directories as needed.
saveBaseline :: FilePath -> Baseline -> IO ()
saveBaseline path bl = do
  createDirectoryIfMissing True (takeDirectory path)
  BL.writeFile path (Aeson.encode bl)

-- ============================================================================
-- Regression detection
-- ============================================================================

-- | Regression threshold: warn when a step is this many percent slower than
-- baseline AND at least this many milliseconds slower (avoids noise on fast
-- steps).
regressionPct :: Int
regressionPct = 25

regressionMinMs :: Int
regressionMinMs = 200

-- | Compare a run's step timings against a baseline.
-- Returns (regressions, improvements) as lists of human-readable lines.
checkRegressions :: Map Text Int  -- ^ baseline step times
                 -> Map Text Int  -- ^ actual step times
                 -> ([Text], [Text])
checkRegressions baseline actual =
  Map.foldlWithKey' classify ([], []) actual
  where
    classify (regs, imps) step ms =
      case Map.lookup step baseline of
        Nothing -> (regs, imps)  -- new step, no baseline yet
        Just baseMs ->
          let delta    = ms - baseMs
              pct      = (delta * 100) `div` max 1 baseMs
              label    = padStep step <> T.pack (show ms) <> "ms"
                       <> " (baseline " <> T.pack (show baseMs) <> "ms"
                       <> ", " <> showDelta delta <> ")"
          in if delta > regressionMinMs && pct > regressionPct
               then (label : regs, imps)
               else if delta < negate regressionMinMs
                      then (regs, label : imps)
                      else (regs, imps)

    showDelta d
      | d > 0    = "+" <> T.pack (show d) <> "ms"
      | otherwise = T.pack (show d) <> "ms"

    padStep s = s <> T.replicate (max 1 (24 - T.length s)) " "

-- ============================================================================
-- Public reporting
-- ============================================================================

-- | Called at the end of a successful pipeline run.
--
-- Behaviour:
--  * If no baseline exists: save current timings as the new baseline and
--    print a "baseline recorded" notice.
--  * If a baseline exists: compare, print a timing table, warn on regressions,
--    and update the baseline for any step that improved.
reportBenchmarks
  :: FilePath       -- ^ baseline file path
  -> Text           -- ^ backend name
  -> Text           -- ^ target name
  -> Text           -- ^ ISO-8601 timestamp
  -> Map Text Int   -- ^ step timings (ms)
  -> Int            -- ^ total pipeline ms
  -> IO ()
reportBenchmarks path backend target ts steps totalMs = do
  mbBaseline <- loadBaseline path

  case mbBaseline of
    Nothing -> do
      -- First run — record baseline
      let bl = Baseline
            { blVersion    = "1"
            , blBackend    = backend
            , blTarget     = target
            , blRecordedAt = ts
            , blSteps      = steps
            , blTotalMs    = totalMs
            }
      saveBaseline path bl
      putStrLn $ "\n[bench] Baseline recorded (" <> show totalMs <> "ms total)"
      putStrLn   "[bench] Future runs will compare against this baseline."

    Just bl -> do
      let (regs, imps) = checkRegressions (blSteps bl) steps

      -- Always print the timing table
      putStrLn "\n[bench] Step timings:"
      mapM_ (\(k, v) -> putStrLn $ "  " <> T.unpack (padStep k) <> show v <> "ms") (Map.toAscList steps)
      putStrLn $ "  " <> T.unpack (padStep "total") <> show totalMs <> "ms"
      putStrLn $ "  baseline total: " <> show (blTotalMs bl) <> "ms"

      -- Report regressions
      if null regs
        then putStrLn "[bench] No regressions."
        else do
          putStrLn "\n[bench] ⚠  REGRESSIONS DETECTED:"
          mapM_ (\r -> putStrLn $ "  " <> T.unpack r) regs

      -- Report improvements
      mapM_ (\i -> putStrLn $ "[bench] ✓ faster: " <> T.unpack i) imps

      -- Update baseline: keep the minimum (best) time per step, update total
      -- if this run was faster overall.
      let mergedSteps = Map.unionWith min steps (blSteps bl)
          newTotal    = min totalMs (blTotalMs bl)
          bl'         = bl { blSteps = mergedSteps, blTotalMs = newTotal, blRecordedAt = ts }
      saveBaseline path bl'
  where
    padStep s = s <> T.replicate (max 1 (20 - T.length s)) " "