packages feed

keiro-dsl-0.7.0.0: src/Keiro/Dsl/BehaviorCoverage.hs

{-# OPTIONS_GHC -Werror=incomplete-patterns #-}

-- | Static behavioral obligations derivable from a validated aggregate graph.
--
-- This module deliberately knows nothing about consumer Haskell witnesses.  It
-- inventories the finite obligations the source graph requires; the generated
-- consumer contract reconciles and executes witness values in a later layer.
module Keiro.Dsl.BehaviorCoverage
  ( BehaviorKey (..),
    ObligationKind (..),
    EvidenceLevel (..),
    GuardCoverage (..),
    OutputEvidence (..),
    BehaviorRequirement (..),
    BehaviorRecordRow (..),
    BehaviorDerivationError (..),
    BehaviorObligationsReport (..),
    deriveAggregateBehaviorRequirements,
    deriveBehaviorRequirements,
    behaviorRecordRows,
    attributeBehaviorOwner,
    behaviorObligationsReport,
    renderBehaviorObligationsText,
    encodeBehaviorObligationsJson,
  )
where

import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as BL
import Data.List (find, groupBy, sortOn)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as Text
import Keiro.Dsl.EventOutput
import Keiro.Dsl.Grammar
import Keiro.Dsl.PrettyPrint (renderExpr)
import Keiro.Dsl.ReadModelShape (fnv1a64)

newtype BehaviorKey = BehaviorKey {unBehaviorKey :: Text}
  deriving stock (Eq, Ord, Show)

data ObligationKind
  = LiveTransition
  | RequiredRejection
  | ReplayTransition
  deriving stock (Eq, Ord, Show)

data EvidenceLevel
  = GeneratedAuthoritative
  | HoleWitnessed
  | LegacyRuntimeWitness
  deriving stock (Eq, Ord, Show)

data GuardCoverage
  = GuardTotal
  | GuardPartial
  | GuardUnknown
  | GuardNotApplicable
  deriving stock (Eq, Ord, Show)

data OutputEvidence
  = GeneratedOutput !Name
  | HandOwnedOutput !OutputObligationKey
  deriving stock (Eq, Ord, Show)

data BehaviorRequirement = BehaviorRequirement
  { requirementKey :: !BehaviorKey,
    requirementKind :: !ObligationKind,
    requirementEvidence :: !EvidenceLevel,
    requirementGuardCoverage :: !GuardCoverage,
    requirementContext :: !Name,
    requirementAggregate :: !Name,
    requirementSource :: !Name,
    requirementCommand :: !Name,
    requirementTarget :: !(Maybe Name),
    requirementMode :: !(Maybe TransitionMode),
    requirementEvents :: ![Name],
    requirementOutputs :: ![OutputEvidence],
    requirementLocation :: !Loc,
    requirementOwner :: !(Maybe FilePath),
    requirementCanonical :: !Text
  }
  deriving stock (Eq, Show)

-- | Forward-compatible record representation of one requirement.  Records
-- retain enough human-readable identity to report additions and removals
-- without re-reading or attempting to understand consumer Haskell.
data BehaviorRecordRow = BehaviorRecordRow
  { behaviorRecordKey :: !BehaviorKey,
    behaviorRecordKind :: !ObligationKind,
    behaviorRecordEvidence :: !EvidenceLevel,
    behaviorRecordAggregate :: !Name,
    behaviorRecordSource :: !Name,
    behaviorRecordCommand :: !Name,
    behaviorRecordOwner :: !(Maybe FilePath),
    behaviorRecordOutputs :: ![OutputEvidence]
  }
  deriving stock (Eq, Ord, Show)

data BehaviorDerivationError
  = InvalidEventOutput !Name !EventOutputError
  | EventlessStateChange !Name !Name !Name
  | DuplicateBehaviorIdentity !Text ![Loc]
  | BehaviorKeyCollision !BehaviorKey ![Text]
  deriving stock (Eq, Show)

data BehaviorObligationsReport = BehaviorObligationsReport
  { behaviorSubject :: !FilePath,
    behaviorWorkspaceService :: !(Maybe Text),
    behaviorRequirements :: ![BehaviorRequirement]
  }
  deriving stock (Eq, Show)

instance ToJSON BehaviorKey where
  toJSON = toJSON . unBehaviorKey

instance ToJSON ObligationKind where
  toJSON = toJSON . obligationKindText

instance ToJSON EvidenceLevel where
  toJSON = toJSON . evidenceLevelText

instance ToJSON GuardCoverage where
  toJSON = toJSON . guardCoverageText

instance ToJSON OutputEvidence where
  toJSON evidence = case evidence of
    GeneratedOutput command -> object ["ownership" .= ("generated-command-identity" :: Text), "command" .= command]
    HandOwnedOutput key -> object ["ownership" .= ("hand-owned" :: Text), "obligation" .= unOutputObligationKey key]

instance FromJSON BehaviorKey where
  parseJSON value = BehaviorKey <$> parseJSON value

instance FromJSON ObligationKind where
  parseJSON value = parseJSON value >>= parseLabel
    where
      parseLabel ("live-transition" :: Text) = pure LiveTransition
      parseLabel "required-rejection" = pure RequiredRejection
      parseLabel "replay-transition" = pure ReplayTransition
      parseLabel other = fail ("unknown behavior obligation kind: " <> T.unpack other)

instance FromJSON EvidenceLevel where
  parseJSON value = parseJSON value >>= parseLabel
    where
      parseLabel ("generated-authoritative" :: Text) = pure GeneratedAuthoritative
      parseLabel "hole-witnessed" = pure HoleWitnessed
      parseLabel "legacy-runtime-witness" = pure LegacyRuntimeWitness
      parseLabel other = fail ("unknown behavior evidence level: " <> T.unpack other)

instance FromJSON OutputEvidence where
  parseJSON = withObject "OutputEvidence" $ \fields -> do
    ownership <- fields .: "ownership"
    case (ownership :: Text) of
      "generated-command-identity" -> GeneratedOutput <$> fields .: "command"
      "hand-owned" -> HandOwnedOutput . OutputObligationKey <$> fields .: "obligation"
      other -> fail ("unknown event-output ownership: " <> T.unpack other)

instance ToJSON BehaviorRecordRow where
  toJSON row =
    object
      ( [ "key" .= behaviorRecordKey row,
          "kind" .= behaviorRecordKind row,
          "evidence" .= behaviorRecordEvidence row,
          "aggregate" .= behaviorRecordAggregate row,
          "source" .= behaviorRecordSource row,
          "command" .= behaviorRecordCommand row,
          "outputs" .= behaviorRecordOutputs row
        ]
          <> ["owner" .= owner | Just owner <- [behaviorRecordOwner row]]
      )

instance FromJSON BehaviorRecordRow where
  parseJSON = withObject "BehaviorRecordRow" $ \fields ->
    BehaviorRecordRow
      <$> fields .: "key"
      <*> fields .: "kind"
      <*> fields .: "evidence"
      <*> fields .: "aggregate"
      <*> fields .: "source"
      <*> fields .: "command"
      <*> fields .:? "owner"
      <*> fields .: "outputs"

instance ToJSON BehaviorRequirement where
  toJSON requirement =
    object
      [ "key" .= requirementKey requirement,
        "kind" .= requirementKind requirement,
        "evidence" .= requirementEvidence requirement,
        "guardCoverage" .= requirementGuardCoverage requirement,
        "context" .= requirementContext requirement,
        "aggregate" .= requirementAggregate requirement,
        "source" .= requirementSource requirement,
        "command" .= requirementCommand requirement,
        "target" .= requirementTarget requirement,
        "mode" .= fmap transitionModeText (requirementMode requirement),
        "events" .= requirementEvents requirement,
        "outputs" .= requirementOutputs requirement,
        "location" .= object (["line" .= unLoc (requirementLocation requirement)] <> ["member" .= owner | Just owner <- [requirementOwner requirement]])
      ]

instance ToJSON BehaviorObligationsReport where
  toJSON report =
    object
      ( [ "schema" .= ("keiro-dsl/behavior-obligations/1" :: Text),
          "subject" .= behaviorSubject report,
          "requirements" .= behaviorRequirements report
        ]
          <> ["workspace" .= object ["service" .= service] | Just service <- [behaviorWorkspaceService report]]
      )

deriveBehaviorRequirements :: Spec -> Either [BehaviorDerivationError] [BehaviorRequirement]
deriveBehaviorRequirements spec = case fmap concat (traverse (deriveAggregateBehaviorRequirements spec) aggregates) of
  Left derivationError -> Left [derivationError]
  Right raw -> do
    rejectIdentityDefects raw
    pure (sortOn requirementKey raw)
  where
    aggregates = [aggregate | NAggregate aggregate <- specNodes spec]

deriveAggregateBehaviorRequirements :: Spec -> Aggregate -> Either BehaviorDerivationError [BehaviorRequirement]
deriveAggregateBehaviorRequirements spec aggregate = do
  let reachable = liveReachableStates aggregate
      liveTransitions =
        [ transition
        | transition <- aggTransitions aggregate,
          tMode transition == TmLive,
          tSource transition `Set.member` reachable
        ]
      replayTransitions = [transition | transition <- aggTransitions aggregate, tMode transition == TmReplayOnly]
      commands = map cmdName (aggCommands aggregate)
      cells = [(state, command) | state <- Set.toAscList reachable, command <- commands]
      cellTransitions state command =
        [ transition
        | transition <- liveTransitions,
          tSource transition == state,
          tCommand transition == command
        ]
      transitionRows =
        [ transitionRequirement spec aggregate (cellGuardCoverage siblings) transition
        | (state, command) <- cells,
          let siblings = cellTransitions state command,
          transition <- siblings
        ]
      rejectionRows =
        [ pure (rejectionRequirement spec aggregate state command)
        | (state, command) <- cells,
          null (cellTransitions state command)
        ]
      replayRows = [transitionRequirement spec aggregate (replayGuardCoverage transition) transition | transition <- replayTransitions]
  sequence (transitionRows <> rejectionRows <> replayRows)

behaviorRecordRows :: [BehaviorRequirement] -> [BehaviorRecordRow]
behaviorRecordRows = map toRow
  where
    toRow requirement =
      BehaviorRecordRow
        { behaviorRecordKey = requirementKey requirement,
          behaviorRecordKind = requirementKind requirement,
          behaviorRecordEvidence = requirementEvidence requirement,
          behaviorRecordAggregate = requirementAggregate requirement,
          behaviorRecordSource = requirementSource requirement,
          behaviorRecordCommand = requirementCommand requirement,
          behaviorRecordOwner = requirementOwner requirement,
          behaviorRecordOutputs = requirementOutputs requirement
        }

attributeBehaviorOwner :: (Name -> Maybe FilePath) -> BehaviorRequirement -> BehaviorRequirement
attributeBehaviorOwner ownerForAggregate requirement =
  requirement {requirementOwner = ownerForAggregate (requirementAggregate requirement)}

behaviorObligationsReport :: FilePath -> Spec -> Either [BehaviorDerivationError] BehaviorObligationsReport
behaviorObligationsReport subject spec =
  BehaviorObligationsReport subject Nothing <$> deriveBehaviorRequirements spec

transitionRequirement :: Spec -> Aggregate -> GuardCoverage -> Transition -> Either BehaviorDerivationError BehaviorRequirement
transitionRequirement spec aggregate guardCoverage transition = do
  if null (tEmits transition) && (tSource transition /= tGoto transition || not (null (tWrites transition)))
    then Left (EventlessStateChange (aggName aggregate) (tSource transition) (tCommand transition))
    else pure ()
  mappings <-
    traverse
      (\(emitIndex, eventName) -> either (Left . InvalidEventOutput eventName) Right (eventOutputMapping spec aggregate transition emitIndex eventName))
      (zip [1 ..] (tEmits transition))
  let kind = if tMode transition == TmLive then LiveTransition else ReplayTransition
      outputs = map outputEvidence mappings
      canonical = transitionCanonical spec aggregate kind transition mappings
  pure
    BehaviorRequirement
      { requirementKey = canonicalKey canonical,
        requirementKind = kind,
        requirementEvidence = transitionEvidence transition,
        requirementGuardCoverage = guardCoverage,
        requirementContext = specContext spec,
        requirementAggregate = aggName aggregate,
        requirementSource = tSource transition,
        requirementCommand = tCommand transition,
        requirementTarget = Just (tGoto transition),
        requirementMode = Just (tMode transition),
        requirementEvents = tEmits transition,
        requirementOutputs = outputs,
        requirementLocation = tLoc transition,
        requirementOwner = Nothing,
        requirementCanonical = canonical
      }

rejectionRequirement :: Spec -> Aggregate -> Name -> Name -> BehaviorRequirement
rejectionRequirement spec aggregate state command =
  BehaviorRequirement
    { requirementKey = canonicalKey canonical,
      requirementKind = RequiredRejection,
      requirementEvidence = aggregateEvidence aggregate,
      requirementGuardCoverage = GuardNotApplicable,
      requirementContext = specContext spec,
      requirementAggregate = aggName aggregate,
      requirementSource = state,
      requirementCommand = command,
      requirementTarget = Nothing,
      requirementMode = Nothing,
      requirementEvents = [],
      requirementOutputs = [],
      requirementLocation = maybe (aggLoc aggregate) stLoc (find ((== state) . stName) (aggStates aggregate)),
      requirementOwner = Nothing,
      requirementCanonical = canonical
    }
  where
    canonical =
      T.intercalate
        "|"
        [ "behavior-v1",
          "kind=rejection",
          "context=" <> specContext spec,
          "aggregate=" <> aggName aggregate,
          "source=" <> state,
          "command=" <> command
        ]

transitionCanonical :: Spec -> Aggregate -> ObligationKind -> Transition -> [EventOutputMapping] -> Text
transitionCanonical spec aggregate kind transition mappings =
  T.intercalate
    "|"
    [ "behavior-v1",
      "kind=" <> obligationKindText kind,
      "context=" <> specContext spec,
      "aggregate=" <> aggName aggregate,
      "mode=" <> transitionModeText (tMode transition),
      "source=" <> tSource transition,
      "command=" <> tCommand transition,
      "implementation=" <> implementationText (tImplementation transition),
      "guard=" <> maybe "" renderExpr (tGuard transition),
      "writes=" <> T.intercalate ";" [name <> ":=" <> renderExpr expression | (name, expression) <- tWrites transition],
      "events=" <> T.intercalate "," (tEmits transition),
      "outputs=" <> T.intercalate "," (map eventOutputCanonical mappings),
      "target=" <> tGoto transition
    ]

canonicalKey :: Text -> BehaviorKey
canonicalKey canonical = BehaviorKey ("behavior-v1-" <> fnv1a64 canonical)

outputEvidence :: EventOutputMapping -> OutputEvidence
outputEvidence mapping = case mapping of
  GeneratedCommandIdentity command _ -> GeneratedOutput command
  HandOwnedEventOutput obligation -> HandOwnedOutput obligation

transitionEvidence :: Transition -> EvidenceLevel
transitionEvidence transition = case tImplementation transition of
  LegacyHoleImplementation -> LegacyRuntimeWitness
  GeneratedImplementation -> GeneratedAuthoritative
  HoleImplementation -> HoleWitnessed

aggregateEvidence :: Aggregate -> EvidenceLevel
aggregateEvidence aggregate
  | any ((/= LegacyHoleImplementation) . tImplementation) (aggTransitions aggregate) = GeneratedAuthoritative
  | otherwise = LegacyRuntimeWitness

cellGuardCoverage :: [Transition] -> GuardCoverage
cellGuardCoverage transitions
  | any ((== Nothing) . tGuard) transitions = GuardTotal
  | any crossesOneWayProjection guards = GuardUnknown
  | complementary = GuardTotal
  | all isLiteralFalse guards = GuardPartial
  | otherwise = GuardUnknown
  where
    guards = [guard | transition <- transitions, Just guard <- [tGuard transition]]
    complementary = or [left == complementExpr right | left <- guards, right <- guards, left /= right]
    isLiteralFalse (EAtom (ABool False)) = True
    isLiteralFalse _ = False

crossesOneWayProjection :: Expr -> Bool
crossesOneWayProjection expression = case expression of
  EOr left right -> crossesOneWayProjection left || crossesOneWayProjection right
  EAnd left right -> crossesOneWayProjection left || crossesOneWayProjection right
  ECmp _ left right -> crossesOneWayProjection left || crossesOneWayProjection right
  EAdd _ left right -> crossesOneWayProjection left || crossesOneWayProjection right
  ESubtract _ left right -> crossesOneWayProjection left || crossesOneWayProjection right
  EMultiply _ left right -> crossesOneWayProjection left || crossesOneWayProjection right
  EPath _ _ (_ : _ : _) -> True
  EPath {} -> False
  ELiteral {} -> False
  EAtom {} -> False

replayGuardCoverage :: Transition -> GuardCoverage
replayGuardCoverage transition
  | maybe False crossesOneWayProjection (tGuard transition) = GuardUnknown
  | otherwise = GuardNotApplicable

liveReachableStates :: Aggregate -> Set Name
liveReachableStates aggregate = case map stName (aggStates aggregate) of
  [] -> Set.empty
  initial : _ -> go (Set.singleton initial) [initial]
  where
    go seen [] = seen
    go seen (source : remaining) =
      let next =
            [ tGoto transition
            | transition <- aggTransitions aggregate,
              tMode transition == TmLive,
              tSource transition == source,
              tGoto transition `Set.notMember` seen
            ]
       in go (foldr Set.insert seen next) (remaining <> next)

rejectIdentityDefects :: [BehaviorRequirement] -> Either [BehaviorDerivationError] ()
rejectIdentityDefects requirements = case duplicateErrors <> collisionErrors of
  [] -> Right ()
  errors -> Left errors
  where
    byCanonical = groupsOn requirementCanonical requirements
    duplicateErrors =
      [ DuplicateBehaviorIdentity canonical (map requirementLocation duplicates)
      | duplicates@(first : _ : _) <- byCanonical,
        let canonical = requirementCanonical first
      ]
    byKey = groupsOn requirementKey requirements
    collisionErrors =
      [ BehaviorKeyCollision key canonicals
      | collisions@(first : _ : _) <- byKey,
        let key = requirementKey first,
        let canonicals = Set.toAscList (Set.fromList (map requirementCanonical collisions)),
        length canonicals > 1
      ]

groupsOn :: (Ord key) => (value -> key) -> [value] -> [[value]]
groupsOn key = groupBy (\left right -> key left == key right) . sortOn key

renderBehaviorObligationsText :: BehaviorObligationsReport -> Text
renderBehaviorObligationsText report =
  T.unlines
    ( [ "behavior obligations: " <> T.pack (behaviorSubject report),
        "schema: keiro-dsl/behavior-obligations/1",
        "required: " <> tshow (length (behaviorRequirements report))
      ]
        <> map renderRequirement (behaviorRequirements report)
    )
  where
    renderRequirement requirement =
      unBehaviorKey (requirementKey requirement)
        <> " "
        <> obligationKindText (requirementKind requirement)
        <> " "
        <> requirementAggregate requirement
        <> ":"
        <> requirementSource requirement
        <> " -- "
        <> requirementCommand requirement
        <> " ["
        <> evidenceLevelText (requirementEvidence requirement)
        <> ", guard="
        <> guardCoverageText (requirementGuardCoverage requirement)
        <> "]"

encodeBehaviorObligationsJson :: BehaviorObligationsReport -> Text
encodeBehaviorObligationsJson = Text.decodeUtf8 . BL.toStrict . Aeson.encode

obligationKindText :: ObligationKind -> Text
obligationKindText kind = case kind of
  LiveTransition -> "live-transition"
  RequiredRejection -> "required-rejection"
  ReplayTransition -> "replay-transition"

evidenceLevelText :: EvidenceLevel -> Text
evidenceLevelText evidence = case evidence of
  GeneratedAuthoritative -> "generated-authoritative"
  HoleWitnessed -> "hole-witnessed"
  LegacyRuntimeWitness -> "legacy-runtime-witness"

guardCoverageText :: GuardCoverage -> Text
guardCoverageText coverage = case coverage of
  GuardTotal -> "proved-total"
  GuardPartial -> "provably-partial"
  GuardUnknown -> "unknown"
  GuardNotApplicable -> "not-applicable"

transitionModeText :: TransitionMode -> Text
transitionModeText mode = case mode of
  TmLive -> "live"
  TmReplayOnly -> "replay-only"

implementationText :: TransitionImplementation -> Text
implementationText implementation = case implementation of
  LegacyHoleImplementation -> "legacy-hole"
  GeneratedImplementation -> "generated"
  HoleImplementation -> "hole"

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