packages feed

shikumi-eval-0.1.1.0: src/Shikumi/Eval/Report.hs

-- | The evaluation report and its aggregation. A 'Report' summarises a run over
-- a dataset: the mean per-example score, pass/fail counts, summed token usage and
-- cost, total latency, and a per-example breakdown ('ExampleResult', retained in
-- dataset order for failure analysis). 'mkReport' is the pure aggregator;
-- 'renderReportText' is a deterministic human-readable rendering reused by the
-- CLI (@docs/plans/12-cli-and-developer-experience.md@) and by golden tests.
--
-- 'EvalConfig' carries the run knobs (bounded 'concurrency', the 'FailurePolicy'
-- for per-example errors, and 'numSamples' per example for multi-sample metrics);
-- 'defaultEvalConfig' scores failures @0@ and keeps going.
module Shikumi.Eval.Report
  ( -- * Per-example outcome
    ExampleResult (..),
    FailureReason (..),
    FailurePolicy (..),

    -- * Run configuration
    EvalConfig (..),
    defaultEvalConfig,

    -- * Usage totals
    UsageTotals (..),
    emptyUsageTotals,

    -- * The report
    Report (..),
    mkReport,
    renderReportText,
  )
where

import Data.Maybe (isJust, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Numeric (showFFloat)
import Numeric.Natural (Natural)
import Shikumi.Eval.Types (Score, scoreZero, unScore)

-- | The reason an example did not complete normally.
data FailureReason
  = -- | @runProgram@ threw a 'Shikumi.Error.ShikumiError' (rendered to text)
    ProgramError !Text
  | -- | the metric itself failed (an effectful metric raised an error)
    MetricError !Text
  | -- | the example exceeded its time budget
    TimedOut
  deriving stock (Eq, Show)

-- | What to do when an example fails.
data FailurePolicy
  = -- | record the failure, assign this score, continue
    FailScore !Score
  | -- | re-throw, ending the whole run
    FailAbort
  deriving stock (Eq, Show)

-- | Per-example outcome retained in the report for failure analysis.
data ExampleResult = ExampleResult
  { -- | position in the dataset (0-based)
    index :: !Int,
    score :: !Score,
    failure :: !(Maybe FailureReason),
    latencyMs :: !Integer
  }
  deriving stock (Eq, Show)

-- | Summed token/cost usage across the run.
data UsageTotals = UsageTotals
  { totalInputTokens :: !Natural,
    totalOutputTokens :: !Natural,
    totalTokens :: !Natural,
    totalCostUsd :: !Rational
  }
  deriving stock (Eq, Show)

-- | The zero of 'UsageTotals'.
emptyUsageTotals :: UsageTotals
emptyUsageTotals = UsageTotals 0 0 0 0

instance Semigroup UsageTotals where
  a <> b =
    UsageTotals
      (totalInputTokens a + totalInputTokens b)
      (totalOutputTokens a + totalOutputTokens b)
      (totalTokens a + totalTokens b)
      (totalCostUsd a + totalCostUsd b)

instance Monoid UsageTotals where
  mempty = emptyUsageTotals

-- | Knobs for an evaluation run.
data EvalConfig = EvalConfig
  { -- | max examples evaluated at once (forced to @>= 1@ by 'evaluate')
    concurrency :: !Int,
    failurePolicy :: !FailurePolicy,
    -- | samples per example for multi-sample metrics (forced to @>= 1@)
    numSamples :: !Int
  }
  deriving stock (Eq, Show, Generic)

-- | The default: four-way concurrency, score failures @0@ and keep going, one
-- sample per example.
defaultEvalConfig :: EvalConfig
defaultEvalConfig =
  EvalConfig
    { concurrency = 4,
      failurePolicy = FailScore scoreZero,
      numSamples = 1
    }

-- | The aggregate result of an evaluation run.
data Report = Report
  { -- | mean of the per-example scores, in @[0, 1]@ (0 when there are no examples)
    aggregateScore :: !Double,
    -- | examples whose score is exactly @1.0@
    passCount :: !Int,
    -- | examples carrying a 'FailureReason'
    failCount :: !Int,
    total :: !Int,
    -- | per-example outcomes, retained in dataset order
    results :: ![ExampleResult],
    usage :: !UsageTotals,
    totalLatencyMs :: !Integer
  }
  deriving stock (Eq, Show)

-- | Build a report from per-example results and the run's usage totals.
-- 'aggregateScore' is the arithmetic mean of the per-example scores (0 if there
-- are no examples). 'passCount' counts perfect scores; 'failCount' counts
-- examples with a 'FailureReason'.
mkReport :: [ExampleResult] -> UsageTotals -> Report
mkReport rs u =
  Report
    { aggregateScore =
        if null rs
          then 0
          else sum (map (unScore . score) rs) / fromIntegral (length rs),
      passCount = length (filter ((== 1.0) . unScore . score) rs),
      failCount = length (filter (isJust . failure) rs),
      total = length rs,
      results = rs,
      usage = u,
      totalLatencyMs = sum (map latencyMs rs)
    }

-- | A deterministic, human-readable multi-line summary of a report. The format
-- is stable (fixed 4-decimal score and cost, examples in index order) so the CLI
-- and golden tests can rely on it. The exact shape, for a 3-example run with one
-- failure:
--
-- > score=0.6667  pass=2/3  fail=1
-- > tokens: in=120 out=45 total=165
-- > cost: $0.0023
-- > latency: 1234 ms
-- > failures:
-- >   [2] ProgramError: decode failed
--
-- When there are no failures the trailing @failures:@ block is omitted.
renderReportText :: Report -> Text
renderReportText r =
  T.intercalate "\n" (header ++ failureLines)
  where
    header =
      [ "score="
          <> fixed4 (aggregateScore r)
          <> "  pass="
          <> tshow (passCount r)
          <> "/"
          <> tshow (total r)
          <> "  fail="
          <> tshow (failCount r),
        "tokens: in="
          <> tshowNat (totalInputTokens (usage r))
          <> " out="
          <> tshowNat (totalOutputTokens (usage r))
          <> " total="
          <> tshowNat (totalTokens (usage r)),
        "cost: $" <> fixed4 (fromRational (totalCostUsd (usage r))),
        "latency: " <> tshow (totalLatencyMs r) <> " ms"
      ]
    failingResults = mapMaybe asFailure (results r)
    asFailure er = (\fr -> (index er, fr)) <$> failure er
    failureLines
      | null failingResults = []
      | otherwise = "failures:" : map renderFailure failingResults
    renderFailure (i, fr) = "  [" <> tshow i <> "] " <> renderReason fr

-- | Render a failure reason for the report.
renderReason :: FailureReason -> Text
renderReason = \case
  ProgramError t -> "ProgramError: " <> t
  MetricError t -> "MetricError: " <> t
  TimedOut -> "TimedOut"

-- | A 'Double' shown with exactly four decimal places.
fixed4 :: Double -> Text
fixed4 d = T.pack (showFFloat (Just 4) d "")

tshow :: (Show a) => a -> Text
tshow = T.pack . show

tshowNat :: Natural -> Text
tshowNat = T.pack . show