packages feed

heni-0.1.0.0: src/Test/Heni/Result.hs

-- | Pure evidence classification and exactly-once campaign accounting.
module Test.Heni.Result
  ( MutationEvidence (..),
    BaselineEvidence (..),
    CaseEvidence (..),
    Verdict (..),
    DetectionKind (..),
    CaseResult (..),
    RunFailure (..),
    RunStatus (..),
    SemanticScore (..),
    RunResult (..),
    classifyCase,
    summarizeRun,
    validateStageEvidence,
  )
where

import Data.List (find, nub)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Test.Heni.Case (validateCase)
import Test.Heni.Internal.Model

-- | Classify observations without running IO. Validity gates precede all witnesses.
classifyCase :: CaseDefinition -> CaseEvidence -> CaseResult
classifyCase definition@CaseDefinition {caseId, expectation, evidenceMode} evidence@CaseEvidence {baseline, mutation, stages, infrastructureErrors, cleanupErrors, stopped}
  | Left err <- validateCase definition = result InfrastructureError Nothing (Text.pack (show err))
  | not (null infrastructureErrors && null cleanupErrors) = result InfrastructureError Nothing "infrastructure or cleanup failed"
  | any stageInfrastructure stages = result InfrastructureError Nothing "launch, setup, output, or evidence protocol failed"
  | length stages /= Set.size (Set.fromList [s | StageEvidence {stage = s} <- stages]) = result InfrastructureError Nothing "duplicate stage evidence"
  | Just why <- stopped = result NotRun Nothing why
  | baseline /= BaselinePassed = result NotRun Nothing "unchanged baseline did not pass"
  | EditRejected why <- mutation = result InvalidMutation Nothing why
  | expectation /= Control && mutation /= Changed = result InvalidMutation Nothing "mutation did not change bytes"
  | expectation == Control && mutation /= Unchanged = result InvalidMutation Nothing "control changed bytes"
  | any (\StageEvidence {stage, outcome} -> outcome == Timeout && (stage /= Replay || not requiresReplay)) stages = result TimedOut Nothing "stage exceeded wall-clock budget"
  | any (\StageEvidence {outcome} -> outcome == Cancelled) stages = result NotRun Nothing "stage cancelled"
  | otherwise = classifyExpectation expectation
  where
    requiresReplay = case expectation of NamedTests _ RequireReplay -> True; _ -> False
    semanticCase = case expectation of
      NamedTests {} -> True
      Diagnostic Test _ _ -> True
      _ -> False
    result v d r = CaseResult caseId v d semanticCase (expectation == Control) r evidence
    at s = find (\StageEvidence {stage} -> stage == s) stages
    passed s = case at s of Just StageEvidence {outcome = Exited 0} -> True; _ -> False
    buildGate next
      | at Build == Nothing = result InfrastructureError Nothing "missing build evidence"
      | not (passed Build) = result UnexpectedBuildFailure Nothing "semantic candidate did not build"
      | otherwise = next
    missing = result InfrastructureError Nothing "missing required stage evidence"
    selectionGate next = case at Test of
      Just StageEvidence {selection = SelectionEvidence {selected, executed}} | evidenceMode == StructuredTestsV1 && (maybe True (<= 0) selected || maybe True (<= 0) executed) -> result Uncovered Nothing "structured test selection did not execute"
      _ -> next
    wrong = result WrongFailure Nothing "observed failure did not match the declared expectation"
    survived = result Survived Nothing "candidate passed without the required detection"
    caught kind = result Caught (Just kind) "declared failure and witness matched"
    diagnostic s code witness kind = case at s of
      Nothing -> missing
      Just StageEvidence {outcome, transcript}
        | outcome == Exited code && witness `Text.isInfixOf` transcript -> caught kind
        | outcome == Exited 0 -> survived
        | otherwise -> wrong
    classifyExpectation (Diagnostic Build code witness) = diagnostic Build code witness ExpectedBuildRejection
    classifyExpectation (Diagnostic s code witness) = buildGate (selectionGate (diagnostic s code witness GenericDiagnostic))
    classifyExpectation (BuildRejection code witness) = diagnostic Build code witness ExpectedBuildRejection
    classifyExpectation (CrashProbe sig probe) = buildGate $ case at Test of
      Nothing -> missing
      Just StageEvidence {outcome, transcript}
        | outcome == Signaled sig && probe `Text.isInfixOf` transcript -> caught ExplicitCrash
        | outcome == Exited 0 -> survived
        | otherwise -> wrong
    classifyExpectation Control = buildGate $ selectionGate $ case at Test of
      Nothing -> missing
      Just StageEvidence {outcome = Exited 0} -> result Caught (Just ControlPassed) "unchanged control passed"
      _ -> wrong
    classifyExpectation (NamedTests expected policy) = buildGate $ case at Test of
      Nothing -> missing
      Just original@StageEvidence {outcome, selection = SelectionEvidence {selected, executed, executedIds, failedIds, skippedIds}}
        | maybe True (<= 0) selected || maybe True (<= 0) executed || not (expected `Set.isSubsetOf` executedIds) || not (Set.null (Set.intersection expected skippedIds)) -> result Uncovered Nothing "intended tests did not execute"
        | outcome == Exited 0 && Set.null failedIds -> survived
        | outcome == Exited 0 || outcome /= Exited 1 || failedIds /= expected -> wrong
        | policy == RequireReplay && not (reproduced expected original) -> result WrongFailure Nothing "required replay did not reproduce identical failure and generator identity"
        | otherwise -> caught SemanticNamed
    reproduced expected StageEvidence {replay = Just ReplayEvidence {token = t, generator = g, failures = fs}} =
      not (Text.null t || Text.null g) && fs == expected && case at Replay of
        Just StageEvidence {outcome = Exited 1, selection = SelectionEvidence {executed, executedIds, failedIds, skippedIds}, replay = Just ReplayEvidence {token = rt, generator = rg, failures = rfs}} ->
          maybe False (> 0) executed && expected `Set.isSubsetOf` executedIds && Set.null (Set.intersection expected skippedIds) && failedIds == expected && rfs == expected && rt == t && rg == g
        _ -> False
    reproduced _ _ = False

stageInfrastructure :: StageEvidence -> Bool
stageInfrastructure StageEvidence {stage, outcome, truncated, selection = SelectionEvidence {discovered, selected, executed, executedIds, failedIds, skippedIds, setupErrors}} =
  launchFailure || hookFailure || truncated || not (null setupErrors) || invalidCounts || invalidIds
  where
    launchFailure = case outcome of LaunchFailed {} -> True; _ -> False
    hookFailure = stage `elem` [Setup, Teardown] && outcome `notElem` [Exited 0, Timeout, Cancelled]
    invalidCounts =
      any (maybe False (< 0)) [discovered, selected, executed]
        || exceeds selected discovered
        || exceeds executed selected
        || maybe False (< Set.size executedIds) executed
    exceeds (Just a) (Just b) = a > b
    exceeds _ _ = False
    invalidIds = not (failedIds `Set.isSubsetOf` executedIds) || not (Set.null (Set.intersection executedIds skippedIds))

-- | Shared protocol/validity check for unchanged baselines and candidates.
validateStageEvidence :: StageEvidence -> Either Text.Text ()
validateStageEvidence evidence
  | stageInfrastructure evidence = Left "launch, setup, output, or selection evidence is invalid"
  | otherwise = Right ()

-- | Reject duplicate/foreign results, fill missing results with 'NotRun', and
-- recompute totals from the selected ledger. Input ordering cannot inflate scores.
summarizeRun :: SelectedCases -> [CaseResult] -> [RunFailure] -> Either ConfigError RunResult
summarizeRun (SelectedCases ids) supplied suppliedFailures
  | length supplied /= Map.size byId = bad "duplicate case result"
  | any (`notElem` ids) (Map.keys byId) = bad "result for unselected case"
  | any (`notElem` ids) affected = bad "run failure refers to unselected case"
  | otherwise = Right (RunResult ids ordered allFailures runStatus semanticScore)
  where
    bad = Left . ConfigError "results"
    byId = Map.fromList [(i, r) | r@CaseResult {caseId = i} <- supplied]
    missing i = CaseResult i NotRun Nothing False False "selected case did not complete" (CaseEvidence BaselineUnavailable Unchanged [] [] [] (Just "not executed"))
    ordered = [Map.findWithDefault (missing i) i byId | i <- ids]
    controlFailures = [ControlFailure i why | CaseResult {caseId = i, verdict, reason = why, control = True} <- ordered, verdict /= Caught]
    baselineFailures = [BaselineFailure [i] why | CaseResult {caseId = i, evidence = CaseEvidence {baseline = BaselineFailed why}} <- ordered]
    allFailures = nub (suppliedFailures ++ controlFailures ++ baselineFailures)
    affected = concatMap (\case BaselineFailure xs _ -> xs; ControlFailure i _ -> [i]; _ -> []) suppliedFailures
    hasInfra = any (\CaseResult {verdict} -> verdict == InfrastructureError) ordered || any (\case RunInfrastructure {} -> True; _ -> False) allFailures
    interrupted = any (\case Interrupted {} -> True; _ -> False) allFailures
    runStatus
      | hasInfra = RunUntrustworthy
      | interrupted = RunInterrupted
      | not (null allFailures) || any (\CaseResult {verdict} -> verdict /= Caught) ordered = RunFailed
      | otherwise = RunPassed
    numerator = length [() | CaseResult {semantic = True, verdict = Caught} <- ordered]
    denominator = numerator + length [() | CaseResult {semantic = True, verdict = Survived} <- ordered]
    isComplete = null allFailures && all (\CaseResult {verdict} -> verdict `elem` [Caught, Survived]) ordered
    semanticScore = if denominator == 0 then Nothing else Just (SemanticScore numerator denominator isComplete)