packages feed

seihou-cli-0.16.0.0: src/Seihou/CLI/Judgement/Evaluate.hs

-- | @seihou judgement eval NAME@: run a judgement's labelled examples
-- through the same program a commit uses and report how it decides them.
--
-- Each example becomes an in-memory snapshot and goes through
-- 'runJudgement', so the transmission safeguard, data policy and limits
-- apply exactly as they do to a commit. Each case's observation is kept, so
-- the threshold sweep re-decides every case under other threshold pairs
-- without further model calls. Evaluation proposes nothing and writes
-- nothing. Offline mode runs only the corpus checks and the deterministic
-- scanner; it says so and never reports model accuracy.
module Seihou.CLI.Judgement.Evaluate
  ( EvalCase (..),
    EvalReport (..),
    CheckTally (..),
    GateTally (..),
    evaluateExamples,
    gateTally,
    evaluateOffline,
    tally,
    sweep,
    renderEvalReport,
  )
where

import Control.Monad (forM)
import Data.Foldable (toList)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Time.Clock (diffUTCTime, getCurrentTime)
import Seihou.CLI.Judgement.Evidence (Snapshot (..))
import Seihou.CLI.Judgement.Execute
import Seihou.CLI.Judgement.Runtime (JudgementBackend)
import Seihou.CLI.Judgement.Scanner (scanCaptured)
import Seihou.CLI.Judgement.Validate (exampleEvidence)
import Seihou.Core.Judgement
import Seihou.Core.JudgementExamples
import Seihou.Prelude

data EvalCase = EvalCase
  { example :: !JudgementExample,
    observation :: !JudgementObservation,
    decision :: !JudgementDecision,
    seconds :: !Double
  }
  deriving stock (Eq, Show, Generic)

data CheckTally = CheckTally
  { correct :: !Int,
    missedUnsafe :: !Int,
    falseBlocks :: !Int,
    undecided :: !Int,
    errors :: !Int,
    scored :: !Int
  }
  deriving stock (Eq, Show, Generic)

-- | How the judgment-only gate did on one check. A gate is qualified only
-- when it clears no unsafe case; the benign cases it escalates are its cost.
data GateTally = GateTally
  { unsafeCleared :: !Int,
    unsafeEscalated :: !Int,
    benignCleared :: !Int,
    benignEscalated :: !Int,
    gateFailures :: !Int
  }
  deriving stock (Eq, Show, Generic)

data EvalReport = EvalReport
  { judgement :: !JudgementDefinition,
    policy :: !JudgementPolicy,
    route :: !Text,
    cases :: ![EvalCase]
  }
  deriving stock (Eq, Show, Generic)

-- | Run every example; 'Nothing' as the backend makes every check an error,
-- which the report then shows as runtime failures.
evaluateExamples :: Maybe JudgementBackend -> Bool -> Maybe FilePath -> Text -> JudgementDefinition -> JudgementPolicy -> JudgementExamples -> IO EvalReport
evaluateExamples be remote sc routeLabel def pol ex = do
  cases <- forM (ex ^. #examples) $ \e -> do
    let snap = Snapshot StagedChangesScope ("example:" <> e ^. #id) (map unprefix (exampleEvidence e)) []
        unprefix f = f & #path .~ dropPrefix (T.unpack (e ^. #id)) (f ^. #path)
    start <- getCurrentTime
    obs <- runJudgement (JudgementExecution pol be remote sc) def snap
    end <- getCurrentTime
    pure (EvalCase e obs (decideJudgement def pol obs) (realToFrac (diffUTCTime end start)))
  pure (EvalReport def pol routeLabel cases)
  where
    dropPrefix pfx p = case splitAt (length pfx + 1) p of
      (h, rest) | h == pfx <> "/" -> rest
      _ -> p

-- | Corpus and scanner only. Returns, per example, the deterministic
-- findings the secret scan produced, when the definition declares it.
evaluateOffline :: Maybe FilePath -> JudgementDefinition -> JudgementExamples -> IO (Either Text [(Text, Int)])
evaluateOffline sc def ex
  | SecretScan `notElem` (def ^. #deterministicChecks) = pure (Right [(e ^. #id, 0) | e <- ex ^. #examples])
  | otherwise = case sc of
      Nothing -> pure (Left "this judgement declares the secret scan, and gitleaks was not found")
      Just exe -> fmap sequence . forM (ex ^. #examples) $ \e ->
        scanCaptured exe (exampleEvidence e) [] <&> fmap (\r -> (e ^. #id, length (r ^. #findings)))

-- | A deterministic finding blocks the whole judgement before any check is
-- reviewed, so it counts as a block for every check.
actualStatus :: JudgementDecision -> Text -> Maybe CheckStatus
actualStatus d name
  | not (null (d ^. #deterministic)) = Just CheckBlocked
  | otherwise = case [c ^. #status | c <- d ^. #checks, c ^. #check == name] of
      (s : _) -> Just s
      [] -> Nothing

-- | Score one check across every case that states an expectation for it.
tally :: [(JudgementExample, JudgementDecision)] -> Text -> CheckTally
tally rows name = foldr add (CheckTally 0 0 0 0 0 0) scoredRows
  where
    scoredRows = [(want, actualStatus d name) | (e, d) <- rows, Just want <- [Map.lookup name (e ^. #expect)]]
    add (want, got) t =
      let t' = t & #scored %~ (+ 1)
       in case (want, got) of
            (ExpectBlocked, Just CheckPassed) -> t' & #missedUnsafe %~ (+ 1)
            (ExpectPassed, Just CheckBlocked) -> t' & #falseBlocks %~ (+ 1)
            (ExpectPassed, Just CheckPassed) -> t' & #correct %~ (+ 1)
            (ExpectBlocked, Just CheckBlocked) -> t' & #correct %~ (+ 1)
            (ExpectNeedsReview, Just CheckNeedsReview) -> t' & #correct %~ (+ 1)
            (_, Just CheckNeedsReview) -> t' & #undecided %~ (+ 1)
            (_, Just CheckFailed) -> t' & #errors %~ (+ 1)
            (ExpectNeedsReview, Just _) -> t' & #undecided %~ (+ 1)
            _ -> t' & #errors %~ (+ 1)

-- | The gate's record on one check, or 'Nothing' when the gate never ran for
-- it. A case counts as cleared when the gate decided every chunk.
gateTally :: [EvalCase] -> Text -> Maybe GateTally
gateTally cases name
  | null gated = Nothing
  | otherwise = Just (foldr add (GateTally 0 0 0 0 0) gated)
  where
    gated =
      [ (want, cleared, failed)
      | c <- cases,
        null (c ^. #decision . #deterministic),
        Just want <- [Map.lookup name (c ^. #example . #expect)],
        d <- [x | x <- c ^. #decision . #checks, x ^. #check == name],
        o <- [x | x <- c ^. #observation . #checks, x ^. #check == name],
        let gates = [g | ch <- o ^. #chunks, Just g <- [ch ^. #gate]],
        not (null gates),
        let cleared = not (null (d ^. #chunks)) && all ((== DecidedByGate) . (^. #decidedBy)) (d ^. #chunks)
            failed = length [() | GateFailed _ <- gates]
      ]
    add (want, cleared, failed) t =
      let t' = t & #gateFailures %~ (+ failed)
       in case (want, cleared) of
            (ExpectPassed, True) -> t' & #benignCleared %~ (+ 1)
            (ExpectPassed, False) -> t' & #benignEscalated %~ (+ 1)
            (_, True) -> t' & #unsafeCleared %~ (+ 1)
            (_, False) -> t' & #unsafeEscalated %~ (+ 1)

-- | How one check's tally would change under other review thresholds,
-- re-deciding the retained observations. Only valid pairs are swept.
sweep :: EvalReport -> Text -> [(Thresholds, CheckTally)]
sweep report name =
  [ (t, tally [(c ^. #example, decideJudgement def (pol t) (c ^. #observation)) | c <- report ^. #cases] name)
  | blockAt <- [0.5, 0.6, 0.7, 0.8, 0.9],
    passAt <- [0.05, 0.1, 0.2, 0.3, 0.4, 0.5],
    passAt <= blockAt,
    let t = Thresholds blockAt passAt
  ]
  where
    def = report ^. #judgement
    pol t = (report ^. #policy) & #checks %~ Map.adjust (#review .~ t) name

renderEvalReport :: EvalReport -> Text
renderEvalReport r =
  T.unlines $
    [ "Evaluation of '" <> def ^. #name <> "' " <> fromMaybe "(unversioned)" (def ^. #version) <> " on " <> T.pack (show (length (r ^. #cases))) <> " example(s)",
      "Route: " <> r ^. #route <> ". Probabilities are model-reported and these counts apply to this route only.",
      "Mean latency per example: " <> secs (if null (r ^. #cases) then 0 else sum (map (^. #seconds) (r ^. #cases)) / fromIntegral (length (r ^. #cases)))
    ]
      <> concatMap perCase (r ^. #cases)
      <> concatMap perCheck (toList (def ^. #checks))
  where
    def = r ^. #judgement
    rows = [(c ^. #example, c ^. #decision) | c <- r ^. #cases]
    secs x = T.pack (show (fromIntegral (round (x * 10) :: Int) / (10 :: Double))) <> "s"
    perCase c
      | not (null (c ^. #decision . #deterministic)) =
          ["  " <> c ^. #example . #id <> ": " <> renderJudgementStatus (c ^. #decision . #status) <> " by the secret scan before any request (counts as blocked for every check)"]
      | otherwise = perReviewed c
    perReviewed c =
      ("  " <> c ^. #example . #id <> ": " <> renderJudgementStatus (c ^. #decision . #status))
        : [ "    "
              <> d ^. #check
              <> " "
              <> renderCheckStatus (d ^. #status)
              <> maybe "" (\p -> " at " <> renderProbability p) (d ^. #reviewConcern)
              <> maybe "" (\p -> "; gate " <> renderProbability p <> (if d ^. #reviewConcern == Nothing then ", cleared by the gate" else "")) (d ^. #gateConcern)
              <> maybe "" (\want -> " (expected " <> renderExpectedStatus want <> ")") (Map.lookup (d ^. #check) (c ^. #example . #expect))
          | d <- c ^. #decision . #checks
          ]
    perCheck chk =
      let name = chk ^. #name
          t = tally rows name
          cp = checkPolicyFor (r ^. #policy) chk
       in [ "Check " <> name <> " at pass " <> renderProbability (cp ^. #review . #passAt) <> " / block " <> renderProbability (cp ^. #review . #blockAt) <> ": " <> line t
          ]
            <> maybe [] (gateLines cp) (gateTally (r ^. #cases) name)
            <> [ "  threshold sweep (no further model calls):"
               ]
            <> [ "    pass " <> renderProbability (th ^. #passAt) <> " block " <> renderProbability (th ^. #blockAt) <> ": " <> line s
               | (th, s) <- sweep r name
               ]
    gateLines cp g =
      [ "  gate at pass "
          <> renderProbability (cp ^. #gate . #passAt)
          <> " / block "
          <> renderProbability (cp ^. #gate . #blockAt)
          <> ": cleared "
          <> T.pack (show (g ^. #unsafeCleared))
          <> " unsafe and "
          <> T.pack (show (g ^. #benignCleared))
          <> " benign; escalated "
          <> T.pack (show (g ^. #benignEscalated))
          <> " benign and "
          <> T.pack (show (g ^. #unsafeEscalated))
          <> " unsafe; "
          <> T.pack (show (g ^. #gateFailures))
          <> " gate failure(s)"
          <> (if g ^. #unsafeCleared > 0 then ". NOT QUALIFIED: the gate cleared an unsafe case" else "")
      ]
    line t =
      T.pack (show (t ^. #correct))
        <> "/"
        <> T.pack (show (t ^. #scored))
        <> " correct, "
        <> T.pack (show (t ^. #missedUnsafe))
        <> " missed unsafe, "
        <> T.pack (show (t ^. #falseBlocks))
        <> " false blocks, "
        <> T.pack (show (t ^. #undecided))
        <> " undecided, "
        <> T.pack (show (t ^. #errors))
        <> " errors"