seihou-core-0.16.0.0: src/Seihou/Core/Judgement.hs
-- | The domain of judgements: author-defined yes/no checks over captured
-- evidence, and the deterministic project policy that turns model-reported
-- evidence into a decision.
--
-- A judgement is a list of checks. Each check is one question about the
-- evidence, with a declared concern direction (whether a yes or a no answer is
-- the concern), its own enforcement, and its own threshold band for the
-- generative review and, separately, for an optional judgment-only gate.
--
-- Every probability in this module is the probability of the /concern/,
-- already converted from the model's probability of yes by
-- 'concernProbability'. A higher number is always worse, whichever answer the
-- concern is, so the policy functions never consult the concern direction.
--
-- Nothing in this module refers to secrets or security: the secret scan is one
-- member of the closed set of deterministic checks, and scanner results reach
-- the policy only as deterministic findings.
--
-- This module is pure and knows nothing about Shikumi, provider transports or
-- Git. The runtime adapter in seihou-cli converts provider replies into the
-- observation types below; 'decideCheck' and 'decideJudgement' then decide.
module Seihou.Core.Judgement
( -- * Definitions
JudgementDefinition (..),
JudgementRef (..),
JudgementCheck (..),
Answer (..),
Enforcement (..),
Thresholds (..),
Severity (..),
FindingCategory (..),
EvidenceDeclaration (..),
EvidenceScopeKind (..),
CoverageLimits (..),
DeterministicCheck (..),
defaultCoverageLimits,
defaultEvidenceDeclaration,
renderAnswer,
renderEnforcement,
renderSeverity,
renderDeterministicCheck,
renderScopeKind,
-- * Validation
JudgementError (..),
judgementError,
validateDefinition,
validateThresholds,
-- * Policy
DataPolicy (..),
renderDataPolicy,
JudgementPolicy (..),
CheckPolicy (..),
defaultPolicy,
checkPolicyFor,
-- * Probabilities
concernProbability,
validProbability,
renderProbability,
-- * Observations
Location (..),
Finding (..),
DeterministicFinding (..),
ReviewObservation (..),
GateObservation (..),
ChunkObservation (..),
CheckObservation (..),
Coverage (..),
JudgementObservation (..),
-- * Output validation
EvidenceIndex (..),
RawFinding (..),
RawReview (..),
validateReview,
findingBlocks,
-- * Decisions
CheckStatus (..),
renderCheckStatus,
DecidedBy (..),
DecisionReason (..),
renderDecisionReason,
ChunkDecision (..),
CheckDecision (..),
JudgementStatus (..),
renderJudgementStatus,
JudgementDecision (..),
decideChunk,
decideCheck,
decideJudgement,
statusSeverity,
)
where
import Control.Monad (forM_, unless, when)
import Data.Foldable (toList)
import Data.Generics.Labels ()
import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Ord (Down (..))
import Data.Text qualified as T
import Seihou.Prelude
-- ---------------------------------------------------------------------------
-- Definitions
-- | Which answer to a check's question is the concern.
data Answer = Yes | No
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderAnswer :: Answer -> Text
renderAnswer Yes = "yes"
renderAnswer No = "no"
-- | Whether a check's outcome can prevent the gated action.
data Enforcement = Enforced | Advisory
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderEnforcement :: Enforcement -> Text
renderEnforcement Enforced = "enforced"
renderEnforcement Advisory = "advisory"
-- | A threshold band over the probability of the concern. At or above
-- 'blockAt' the check blocks; at or below 'passAt' it may pass; in between it
-- needs human review. Equal values remove the needs-review band.
data Thresholds = Thresholds
{ blockAt :: !Double,
passAt :: !Double
}
deriving stock (Eq, Show, Generic)
-- | Declared severity of a finding category, least severe first.
data Severity = Info | Low | Medium | High | Critical
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderSeverity :: Severity -> Text
renderSeverity = \case
Info -> "info"
Low -> "low"
Medium -> "medium"
High -> "high"
Critical -> "critical"
-- | A category a check's findings may use. The model names the category; the
-- severity is the author's, never the model's.
data FindingCategory = FindingCategory
{ name :: !Text,
severity :: !Severity,
description :: !(Maybe Text)
}
deriving stock (Eq, Show, Generic)
-- | The closed, versioned set of evidence scopes in this delivery.
data EvidenceScopeKind = StagedChangesScope | CommitRangeScope
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderScopeKind :: EvidenceScopeKind -> Text
renderScopeKind StagedChangesScope = "staged-changes"
renderScopeKind CommitRangeScope = "commit-range"
-- | Which evidence a judgement may inspect. Path patterns are Git-style
-- globs relative to the project root; an empty include list means every path.
data EvidenceDeclaration = EvidenceDeclaration
{ scopes :: ![EvidenceScopeKind],
include :: ![Text],
exclude :: ![Text]
}
deriving stock (Eq, Show, Generic)
defaultEvidenceDeclaration :: EvidenceDeclaration
defaultEvidenceDeclaration = EvidenceDeclaration [StagedChangesScope, CommitRangeScope] [] []
-- | Declared bounds on one review. Exceeding a bound never truncates
-- silently: the uncovered files are reported and the check cannot pass.
data CoverageLimits = CoverageLimits
{ maxFileBytes :: !Int,
maxChunkBytes :: !Int,
maxChunks :: !Int,
maxAttempts :: !Int,
timeoutSeconds :: !Int,
maxFindings :: !Int
}
deriving stock (Eq, Show, Generic)
defaultCoverageLimits :: CoverageLimits
defaultCoverageLimits =
CoverageLimits
{ maxFileBytes = 262144,
maxChunkBytes = 49152,
maxChunks = 16,
maxAttempts = 3,
timeoutSeconds = 120,
maxFindings = 20
}
-- | The closed, versioned set of deterministic checks.
data DeterministicCheck = SecretScan
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderDeterministicCheck :: DeterministicCheck -> Text
renderDeterministicCheck SecretScan = "secret-scan"
-- | One author-written yes/no question.
data JudgementCheck = JudgementCheck
{ name :: !Text,
question :: !Text,
concernWhen :: !Answer,
yesMeans :: !(Maybe Text),
noMeans :: !(Maybe Text),
categories :: ![FindingCategory],
enforcement :: !Enforcement,
-- | Findings whose category severity is at or above this block.
blockingSeverity :: !Severity,
review :: !Thresholds,
gate :: !Thresholds,
useGate :: !Bool
}
deriving stock (Eq, Show, Generic)
data JudgementDefinition = JudgementDefinition
{ name :: !Text,
version :: !(Maybe Text),
description :: !(Maybe Text),
instructions :: !Text,
evidence :: !EvidenceDeclaration,
limits :: !CoverageLimits,
deterministicChecks :: ![DeterministicCheck],
checks :: !(NonEmpty JudgementCheck)
}
deriving stock (Eq, Show, Generic)
-- | A module's or recipe's reference to a judgement it activates. The
-- condition is the raw @when@ text, parsed and evaluated by the caller
-- against the referencing module's variables.
data JudgementRef = JudgementRef
{ name :: !Text,
source :: !(Maybe Text),
when :: !(Maybe Text)
}
deriving stock (Eq, Show, Generic)
-- ---------------------------------------------------------------------------
-- Validation
-- | A stable machine code and an actionable explanation naming the artifact
-- part as an author writes it.
data JudgementError = JudgementError {code :: !Text, message :: !Text}
deriving stock (Eq, Show, Generic)
judgementError :: Text -> Text -> JudgementError
judgementError = JudgementError
validProbability :: Double -> Bool
validProbability p = not (isNaN p || isInfinite p) && p >= 0 && p <= 1
validateThresholds :: Text -> Thresholds -> Either JudgementError ()
validateThresholds what t = do
forM_ [("blockAt", t ^. #blockAt), ("passAt", t ^. #passAt)] $ \(label, p) ->
unless (validProbability p) $
Left (judgementError "judgement_threshold_out_of_range" (what <> " " <> label <> " must be between 0 and 1, got " <> T.pack (show p)))
when (t ^. #passAt > t ^. #blockAt) $
Left
( judgementError
"judgement_threshold_inverted"
(what <> " passAt (" <> T.pack (show (t ^. #passAt)) <> ") must not exceed blockAt (" <> T.pack (show (t ^. #blockAt)) <> ")")
)
-- | Structural validation of a definition. Free of discovery, configuration
-- and prompts, so it can run on a descriptor alone.
validateDefinition :: JudgementDefinition -> Either JudgementError ()
validateDefinition def = do
let jname = def ^. #name
validName "judgement" jname
when (T.null (T.strip (def ^. #instructions)) && all (T.null . T.strip . (^. #question)) (def ^. #checks)) $
Left (judgementError "judgement_empty" ("judgement '" <> jname <> "' has no instructions and no check questions"))
when (null (def ^. #evidence . #scopes)) $
Left (judgementError "judgement_no_scope" ("judgement '" <> jname <> "' declares no evidence scope"))
forM_ ((def ^. #evidence . #include) <> (def ^. #evidence . #exclude)) $ \pat ->
when (T.null pat || "/" `T.isPrefixOf` pat || ".." `elem` T.splitOn "/" pat) $
Left (judgementError "judgement_bad_path_pattern" ("judgement '" <> jname <> "' path pattern '" <> pat <> "' must be a non-empty project-relative pattern without '..'"))
validateLimits jname (def ^. #limits)
let names = map (^. #name) (toList (def ^. #checks))
forM_ (duplicates names) $ \dup ->
Left (judgementError "judgement_duplicate_check" ("judgement '" <> jname <> "' declares check '" <> dup <> "' more than once"))
forM_ (def ^. #checks) $ \check -> do
let cname = check ^. #name
what = "check '" <> cname <> "' of judgement '" <> jname <> "'"
validName "check" cname
when (T.null (T.strip (check ^. #question))) $
Left (judgementError "judgement_empty_question" (what <> " has an empty question"))
validateThresholds (what <> " review") (check ^. #review)
validateThresholds (what <> " gate") (check ^. #gate)
let cats = map (^. #name) (check ^. #categories)
forM_ cats (validName "finding category")
forM_ (duplicates cats) $ \dup ->
Left (judgementError "judgement_duplicate_category" (what <> " declares finding category '" <> dup <> "' more than once"))
where
validName kind n =
unless (not (T.null n) && T.all (\c -> c `elem` ['a' .. 'z'] || c `elem` ['0' .. '9'] || c == '-') n && T.head n /= '-') $
Left (judgementError "judgement_bad_name" (kind <> " name '" <> n <> "' must be lowercase letters, digits and hyphens, starting with a letter or digit"))
validateLimits :: Text -> CoverageLimits -> Either JudgementError ()
validateLimits jname l =
forM_
[ ("maxFileBytes", l ^. #maxFileBytes),
("maxChunkBytes", l ^. #maxChunkBytes),
("maxChunks", l ^. #maxChunks),
("maxAttempts", l ^. #maxAttempts),
("timeoutSeconds", l ^. #timeoutSeconds),
("maxFindings", l ^. #maxFindings)
]
$ \(label, v) ->
when (v <= 0) $
Left (judgementError "judgement_bad_limit" ("judgement '" <> jname <> "' limit " <> label <> " must be positive, got " <> T.pack (show v)))
duplicates :: [Text] -> [Text]
duplicates xs = Map.keys (Map.filter (> (1 :: Int)) (Map.fromListWith (+) [(x, 1) | x <- xs]))
-- ---------------------------------------------------------------------------
-- Policy
-- | Where review material may be sent. 'LocalOnly' never permits a hosted
-- destination, even through a locally running executable.
data DataPolicy = LocalOnly | ApprovedRemote
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderDataPolicy :: DataPolicy -> Text
renderDataPolicy LocalOnly = "local-only"
renderDataPolicy ApprovedRemote = "approved-remote"
-- | The effective, project-owned policy for one check.
data CheckPolicy = CheckPolicy
{ enforcement :: !Enforcement,
blockingSeverity :: !Severity,
review :: !Thresholds,
gate :: !Thresholds,
useGate :: !Bool
}
deriving stock (Eq, Show, Generic)
data JudgementPolicy = JudgementPolicy
{ dataPolicy :: !DataPolicy,
gatePermitted :: !Bool,
checks :: !(Map Text CheckPolicy)
}
deriving stock (Eq, Show, Generic)
-- | The policy a definition's authored defaults imply.
defaultPolicy :: DataPolicy -> Bool -> JudgementDefinition -> JudgementPolicy
defaultPolicy dp gatePerm def =
JudgementPolicy
{ dataPolicy = dp,
gatePermitted = gatePerm,
checks = Map.fromList [(c ^. #name, fromCheck c) | c <- toList (def ^. #checks)]
}
where
fromCheck c =
CheckPolicy
{ enforcement = c ^. #enforcement,
blockingSeverity = c ^. #blockingSeverity,
review = c ^. #review,
gate = c ^. #gate,
useGate = c ^. #useGate
}
-- | The recorded policy for a check, falling back to its authored defaults.
checkPolicyFor :: JudgementPolicy -> JudgementCheck -> CheckPolicy
checkPolicyFor policy c =
fromMaybe
(CheckPolicy (c ^. #enforcement) (c ^. #blockingSeverity) (c ^. #review) (c ^. #gate) (c ^. #useGate))
(Map.lookup (c ^. #name) (policy ^. #checks))
-- ---------------------------------------------------------------------------
-- Probabilities
-- | Convert a model's probability of yes into the probability of the
-- concern. This is the only place the concern direction is consulted.
concernProbability :: Answer -> Double -> Double
concernProbability Yes p = p
concernProbability No p = 1 - p
-- ---------------------------------------------------------------------------
-- Observations
-- | A validated location inside captured evidence. Lines are 1-based and
-- refer to the post-change content.
data Location = Location
{ path :: !FilePath,
line :: !(Maybe Int)
}
deriving stock (Eq, Ord, Show, Generic)
data Finding = Finding
{ category :: !Text,
severity :: !Severity,
location :: !Location,
explanation :: !Text
}
deriving stock (Eq, Show, Generic)
-- | A finding from a deterministic check. It always blocks.
data DeterministicFinding = DeterministicFinding
{ check :: !DeterministicCheck,
rule :: !Text,
location :: !Location
}
deriving stock (Eq, Show, Generic)
-- | A validated generative review reply for one chunk.
data ReviewObservation = ReviewObservation
{ concern :: !Double,
findings :: ![Finding],
requestedHumanReview :: !Bool
}
deriving stock (Eq, Show, Generic)
-- | What the judgment-only gate reported for one chunk.
data GateObservation
= GateAnswered !Double
| GateFailed !Text
deriving stock (Eq, Show, Generic)
-- | Everything observed for one check over one evidence chunk.
data ChunkObservation = ChunkObservation
{ chunk :: !Int,
gate :: !(Maybe GateObservation),
-- | 'Nothing' when no review ran (cleared by the gate, or never sent).
review :: !(Maybe (Either Text ReviewObservation))
}
deriving stock (Eq, Show, Generic)
-- | Seihou's own coverage measurement, never the model's.
data Coverage = Coverage
{ reviewedFiles :: ![FilePath],
excludedFiles :: ![FilePath],
uncoveredFiles :: ![(FilePath, Text)]
}
deriving stock (Eq, Show, Generic)
data CheckObservation = CheckObservation
{ check :: !Text,
chunks :: ![ChunkObservation],
-- | Errors that prevented review of this check, such as material the
-- transmission safeguard refused to send.
errors :: ![Text]
}
deriving stock (Eq, Show, Generic)
data JudgementObservation = JudgementObservation
{ coverage :: !Coverage,
deterministic :: ![DeterministicFinding],
deterministicErrors :: ![Text],
checks :: ![CheckObservation],
noChanges :: !Bool
}
deriving stock (Eq, Show, Generic)
-- ---------------------------------------------------------------------------
-- Output validation
-- | The files a reply may cite, with their post-change line counts.
newtype EvidenceIndex = EvidenceIndex {files :: Map FilePath Int}
deriving stock (Eq, Show, Generic)
-- | A decoded but not yet validated finding from the runtime adapter.
data RawFinding = RawFinding
{ category :: !Text,
path :: !Text,
line :: !(Maybe Int),
explanation :: !Text
}
deriving stock (Eq, Show, Generic)
-- | A decoded but not yet validated review, with the probability already
-- converted to the probability of the concern.
data RawReview = RawReview
{ concern :: !Double,
findings :: ![RawFinding],
requestedHumanReview :: !Bool
}
deriving stock (Eq, Show, Generic)
-- | Enforce the domain after decoding: declared categories, locations inside
-- the captured evidence, bounded output and a probability in range. The
-- model reports evidence; it never chooses a category's severity.
validateReview :: CoverageLimits -> JudgementCheck -> EvidenceIndex -> RawReview -> Either Text ReviewObservation
validateReview limits check index raw = do
unless (validProbability (raw ^. #concern)) $
Left ("the reply's probability " <> renderProbability (raw ^. #concern) <> " is outside 0 to 1")
when (length (raw ^. #findings) > limits ^. #maxFindings) $
Left ("the reply has " <> T.pack (show (length (raw ^. #findings))) <> " findings, more than the limit of " <> T.pack (show (limits ^. #maxFindings)))
let declared = Map.fromList [(c ^. #name, c ^. #severity) | c <- check ^. #categories]
found <- traverse (validateFinding declared) (raw ^. #findings)
pure (ReviewObservation (raw ^. #concern) found (raw ^. #requestedHumanReview))
where
validateFinding declared f = do
sev <- case Map.lookup (f ^. #category) declared of
Just s -> Right s
Nothing -> Left ("the reply uses finding category '" <> bounded 64 (f ^. #category) <> "', which check '" <> check ^. #name <> "' does not declare")
let p = T.unpack (f ^. #path)
lineCount <- case Map.lookup p (index ^. #files) of
Just n -> Right n
Nothing -> Left ("the reply cites '" <> bounded 200 (f ^. #path) <> "', which is not in the reviewed evidence")
forM_ (f ^. #line) $ \l ->
when (l < 1 || l > max 1 lineCount) $
Left ("the reply cites line " <> T.pack (show l) <> " of '" <> T.pack p <> "', which has " <> T.pack (show lineCount) <> " lines")
pure (Finding (f ^. #category) sev (Location p (f ^. #line)) (bounded 500 (f ^. #explanation)))
bounded :: Int -> Text -> Text
bounded n t
| T.length t <= n = t
| otherwise = T.take n t <> "…"
findingBlocks :: CheckPolicy -> Finding -> Bool
findingBlocks policy f = f ^. #severity >= policy ^. #blockingSeverity
-- ---------------------------------------------------------------------------
-- Decisions
data CheckStatus = CheckPassed | CheckBlocked | CheckNeedsReview | CheckFailed | CheckNoChanges
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderCheckStatus :: CheckStatus -> Text
renderCheckStatus = \case
CheckPassed -> "passed"
CheckBlocked -> "blocked"
CheckNeedsReview -> "needs review"
CheckFailed -> "error"
CheckNoChanges -> "no changes"
-- | Ranking used to combine outcomes: block above needs-review above error
-- above pass above no-changes.
statusSeverity :: CheckStatus -> Int
statusSeverity = \case
CheckBlocked -> 4
CheckNeedsReview -> 3
CheckFailed -> 2
CheckPassed -> 1
CheckNoChanges -> 0
-- | Which route decided a chunk.
data DecidedBy = DecidedByGate | DecidedByReview | DecidedByNone
deriving stock (Eq, Ord, Show, Generic)
-- | Why a check reached its status. Rendered for humans; the constructors
-- are stable for JSON reports.
data DecisionReason
= BlockingFinding !Finding
| ConcernAtOrAboveBlock !Double !Double
| ConcernInBand !Double !Thresholds
| ConcernAtOrBelowPass !Double !Double
| ModelRequestedReview
| GateCleared !Double !Double
| GateEscalated !Double !Double
| GateDisagreed !Double !Double
| GateUnavailable !Text
| ReviewMissing
| ReviewError !Text
| CheckError !Text
deriving stock (Eq, Show, Generic)
renderDecisionReason :: DecisionReason -> Text
renderDecisionReason = \case
BlockingFinding f -> "blocking " <> renderSeverity (f ^. #severity) <> " finding '" <> f ^. #category <> "' at " <> renderLoc (f ^. #location)
ConcernAtOrAboveBlock p t -> "model-reported probability " <> fmt p <> " is at or above block threshold " <> fmt t <> "; the review located no blocking finding"
ConcernInBand p t -> "model-reported probability " <> fmt p <> " is between pass at " <> fmt (t ^. #passAt) <> " and block at " <> fmt (t ^. #blockAt)
ConcernAtOrBelowPass p t -> "model-reported probability " <> fmt p <> " is at or below pass threshold " <> fmt t
ModelRequestedReview -> "the model requested human review"
GateCleared p t -> "cleared by gate, probability " <> fmt p <> " at or below " <> fmt t
GateEscalated p t -> "gate probability " <> fmt p <> " above gate pass threshold " <> fmt t <> "; escalated to review"
GateDisagreed p t -> "gate probability " <> fmt p <> " reached gate block threshold " <> fmt t <> "; the review cannot pass this check"
GateUnavailable why -> "gate unavailable (" <> why <> "); escalated to review"
ReviewMissing -> "escalated, but no review result was produced"
ReviewError why -> "review failed: " <> why
CheckError why -> why
where
renderLoc l = T.pack (l ^. #path) <> maybe "" (\n -> ":" <> T.pack (show n)) (l ^. #line)
fmt :: Double -> Text
fmt = renderProbability
-- | A probability as a reader expects it, with two decimals: @0.02@, not
-- @2.0e-2@. Values outside 0 to 1 are rendered the same way so a diagnostic
-- can name them.
renderProbability :: Double -> Text
renderProbability p
| isNaN p || isInfinite p = T.pack (show p)
| otherwise =
let hundredths = round (abs p * 100) :: Integer
(whole, frac) = hundredths `divMod` 100
in (if p < 0 && hundredths /= 0 then "-" else "") <> T.pack (show whole) <> "." <> (if frac < 10 then "0" else "") <> T.pack (show frac)
data ChunkDecision = ChunkDecision
{ chunk :: !Int,
status :: !CheckStatus,
decidedBy :: !DecidedBy,
gateConcern :: !(Maybe Double),
reviewConcern :: !(Maybe Double),
findings :: ![Finding],
reasons :: ![DecisionReason]
}
deriving stock (Eq, Show, Generic)
data CheckDecision = CheckDecision
{ check :: !Text,
status :: !CheckStatus,
enforcement :: !Enforcement,
policy :: !CheckPolicy,
chunks :: ![ChunkDecision],
-- | The governing (highest) concern probabilities over all chunks.
gateConcern :: !(Maybe Double),
reviewConcern :: !(Maybe Double),
findings :: ![Finding],
reasons :: ![DecisionReason]
}
deriving stock (Eq, Show, Generic)
-- | Apply one check's policy to one chunk's observation.
--
-- * The gate clears a chunk at or below its pass threshold; no review is
-- needed. A higher gate probability escalates, and a gate probability at or
-- above its block threshold forbids the review from passing the chunk.
-- * A gate failure escalates visibly; it is neither an error nor a clearance.
-- * In the review, a blocking finding blocks; a probability at or above the
-- block threshold blocks even without a location; a probability above the
-- pass threshold, or a request for human review, needs review; only a
-- probability at or below the pass threshold passes.
decideChunk :: Bool -> CheckPolicy -> ChunkObservation -> ChunkDecision
decideChunk gateActive policy obs =
case (if gateActive && policy ^. #useGate then obs ^. #gate else Nothing) of
Just (GateAnswered g)
| g <= gatePass && validProbability g ->
ChunkDecision (obs ^. #chunk) CheckPassed DecidedByGate (Just g) Nothing [] [GateCleared g gatePass]
| validProbability g ->
let escalation = GateEscalated g gatePass
disagree = g >= gateBlock
d = fromReview (Just g) [escalation]
in if disagree && d ^. #status == CheckPassed
then d & #status .~ CheckNeedsReview & #reasons %~ (<> [GateDisagreed g gateBlock])
else d
| otherwise ->
fromReview Nothing [GateUnavailable ("probability " <> renderProbability g <> " is outside 0 to 1")]
Just (GateFailed why) -> fromReview Nothing [GateUnavailable why]
Nothing -> fromReview Nothing []
where
gatePass = policy ^. #gate . #passAt
gateBlock = policy ^. #gate . #blockAt
reviewT = policy ^. #review
fromReview gateP prior = case obs ^. #review of
Nothing -> ChunkDecision (obs ^. #chunk) CheckFailed DecidedByNone gateP Nothing [] (prior <> [ReviewMissing])
Just (Left why) -> ChunkDecision (obs ^. #chunk) CheckFailed DecidedByReview gateP Nothing [] (prior <> [ReviewError why])
Just (Right r) ->
let p = r ^. #concern
blocking = filter (findingBlocks policy) (r ^. #findings)
(status, reasons)
| not (validProbability p) = (CheckFailed, [ReviewError ("probability " <> renderProbability p <> " is outside 0 to 1")])
| not (null blocking) = (CheckBlocked, map BlockingFinding blocking)
| p >= reviewT ^. #blockAt = (CheckBlocked, [ConcernAtOrAboveBlock p (reviewT ^. #blockAt)])
| p > reviewT ^. #passAt = (CheckNeedsReview, [ConcernInBand p reviewT] <> [ModelRequestedReview | r ^. #requestedHumanReview])
| r ^. #requestedHumanReview = (CheckNeedsReview, [ModelRequestedReview])
| otherwise = (CheckPassed, [ConcernAtOrBelowPass p (reviewT ^. #passAt)])
in ChunkDecision (obs ^. #chunk) status DecidedByReview gateP (Just p) (r ^. #findings) (prior <> reasons)
-- | Combine a check's chunks. The most severe chunk governs, and the highest
-- probability over all chunks is reported. A check with no chunks and no
-- errors had nothing in scope.
decideCheck :: Bool -> Coverage -> CheckPolicy -> CheckObservation -> CheckDecision
decideCheck gateActive coverage policy obs =
CheckDecision
{ check = obs ^. #check,
status = finalStatus,
enforcement = policy ^. #enforcement,
policy = policy,
chunks = decided,
gateConcern = maximumMaybe (mapMaybe (^. #gateConcern) decided),
reviewConcern = maximumMaybe (mapMaybe (^. #reviewConcern) decided),
findings = concatMap (^. #findings) decided,
reasons = map CheckError (obs ^. #errors) <> uncoveredReasons <> concatMap (^. #reasons) decided
}
where
decided = map (decideChunk gateActive policy) (obs ^. #chunks)
uncovered = coverage ^. #uncoveredFiles
uncoveredReasons = [CheckError ("'" <> T.pack p <> "' was not reviewed: " <> why) | (p, why) <- uncovered]
chunkStatus = case sortOn (Down . statusSeverity) (map (^. #status) decided) of
(s : _) -> s
[] -> CheckNoChanges
finalStatus
| not (null (obs ^. #errors)) = worst [chunkStatus, CheckFailed]
| not (null uncovered) = worst [chunkStatus, CheckFailed]
| otherwise = chunkStatus
worst = NE.head . NE.sortWith (Down . statusSeverity) . NE.fromList
maximumMaybe :: [Double] -> Maybe Double
maximumMaybe [] = Nothing
maximumMaybe xs = Just (maximum xs)
data JudgementStatus = Passed | Blocked | NeedsReview | Failed | NoChanges
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderJudgementStatus :: JudgementStatus -> Text
renderJudgementStatus = \case
Passed -> "PASSED"
Blocked -> "BLOCKED"
NeedsReview -> "NEEDS REVIEW"
Failed -> "ERROR"
NoChanges -> "NO CHANGES"
data JudgementDecision = JudgementDecision
{ judgement :: !Text,
status :: !JudgementStatus,
deterministic :: ![DeterministicFinding],
deterministicErrors :: ![Text],
checks :: ![CheckDecision],
coverage :: !Coverage
}
deriving stock (Eq, Show, Generic)
-- | Combine a judgement's checks under its policy. A deterministic finding
-- blocks regardless of any probability. Among enforced checks the most
-- severe outcome governs: block, then needs-review, then error. Advisory
-- checks are reported with their real status and never affect the result.
-- Empty evidence is 'NoChanges', an explicit no-op rather than a review.
decideJudgement :: JudgementDefinition -> JudgementPolicy -> JudgementObservation -> JudgementDecision
decideJudgement def policy obs =
JudgementDecision
{ judgement = def ^. #name,
status = overall,
deterministic = obs ^. #deterministic,
deterministicErrors = obs ^. #deterministicErrors,
checks = decisions,
coverage = obs ^. #coverage
}
where
gateActive = policy ^. #gatePermitted
observed = Map.fromList [(o ^. #check, o) | o <- obs ^. #checks]
decisions =
[ decideCheck gateActive (obs ^. #coverage) (checkPolicyFor policy c) (Map.findWithDefault (missing c) (c ^. #name) observed)
| c <- toList (def ^. #checks)
]
missing c
| obs ^. #noChanges = CheckObservation (c ^. #name) [] []
| otherwise = CheckObservation (c ^. #name) [] ["check '" <> c ^. #name <> "' produced no observation"]
enforced = [d ^. #status | d <- decisions, d ^. #enforcement == Enforced]
overall
| not (null (obs ^. #deterministic)) = Blocked
| CheckBlocked `elem` enforced = Blocked
| CheckNeedsReview `elem` enforced = NeedsReview
| CheckFailed `elem` enforced || not (null (obs ^. #deterministicErrors)) = Failed
| obs ^. #noChanges = NoChanges
| all (== CheckNoChanges) enforced && not (null enforced) = NoChanges
| otherwise = Passed