packages feed

shikumi-optimize-0.3.0.0: src/Shikumi/Optimize/Report.hs

-- | Versioned diagnostic metadata. No prompts, examples or executable closures.
module Shikumi.Optimize.Report
  ( Direction (..),
    Aggregation (..),
    MissingPolicy (..),
    ObjectiveSpec (..),
    ObjectivePolicy (..),
    ObjectiveValues,
    qualityPolicy,
    validateObjectives,
    aggregateObjectives,
    objectiveFrontier,
    selectObjectiveWinner,
    RunStatus (..),
    CandidateStatus (..),
    CandidateReport (..),
    EventKind (..),
    OptimizationEvent (..),
    OptimizationReport (..),
  )
where

import Control.Monad (forM, unless)
import Data.Aeson (FromJSON (..), ToJSON, withObject, (.!=), (.:), (.:?))
import Data.List (minimumBy, nub, sortOn)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Ord (comparing)
import Data.Text (Text)
import GHC.Generics (Generic)

data Direction = Maximize | Minimize
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data Aggregation = Mean | Total | Worst
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data MissingPolicy = Required | Substitute Double
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data ObjectiveSpec = ObjectiveSpec
  { objectiveId :: !Text,
    unit :: !Text,
    direction :: !Direction,
    aggregation :: !Aggregation,
    missingPolicy :: !MissingPolicy,
    lowerBound :: !(Maybe Double),
    upperBound :: !(Maybe Double)
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data ObjectivePolicy = ObjectivePolicy
  { objectives :: ![ObjectiveSpec],
    primaryObjective :: !Text,
    tieBreakObjectives :: ![Text]
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

type ObjectiveValues = Map Text Double

qualityPolicy :: ObjectivePolicy
qualityPolicy = ObjectivePolicy [ObjectiveSpec "quality" "score" Maximize Mean Required (Just 0) (Just 1)] "quality" []

finite :: Double -> Bool
finite x = not (isNaN x || isInfinite x)

validateObjectives :: ObjectivePolicy -> Either Text ()
validateObjectives p = do
  let ids = map objectiveId (objectives p)
      ordered = primaryObjective p : tieBreakObjectives p
  unless (not (null ids) && all (/= "") ids && nub ids == ids) (Left "objectives must have distinct nonempty IDs")
  unless (all (`elem` ids) ordered && nub ordered == ordered) (Left "selection objectives must be declared and distinct")
  mapM_ check (objectives p)
  where
    check s = do
      unless (all finite (mapMaybe id [lowerBound s, upperBound s]) && case missingPolicy s of Required -> True; Substitute v -> finite v) (Left "objective configuration must be finite")
      unless (case (lowerBound s, upperBound s) of (Just l, Just u) -> l <= u; _ -> True) (Left "objective bounds are reversed")

aggregateObjectives :: ObjectivePolicy -> [ObjectiveValues] -> Either Text ObjectiveValues
aggregateObjectives p rows = do
  validateObjectives p
  unless (not (null rows)) (Left "no complete evaluation rows")
  unless (all (all finite . Map.elems) rows) (Left "non-finite objective value")
  Map.fromList
    <$> forM
      (objectives p)
      ( \s -> do
          xs <- forM rows $ \row -> case Map.lookup (objectiveId s) row of
            Just v -> Right v
            Nothing -> case missingPolicy s of Required -> Left "required objective missing"; Substitute v -> Right v
          let v = case aggregation s of
                Mean -> sum xs / fromIntegral (length xs)
                Total -> sum xs
                Worst -> (if direction s == Maximize then minimum else maximum) xs
          unless (finite v) (Left "non-finite objective aggregate")
          pure (objectiveId s, v)
      )

eligible :: ObjectivePolicy -> CandidateReport -> Bool
eligible p c = candidateStatus c == CandidateCompleted && all valid (objectives p)
  where
    valid s = case Map.lookup (objectiveId s) (objectiveValues c) of
      Nothing -> False
      Just v -> finite v && maybe True (v >=) (lowerBound s) && maybe True (v <=) (upperBound s)

objectiveFrontier :: ObjectivePolicy -> [CandidateReport] -> [CandidateReport]
objectiveFrontier p cs = filter (\c -> not (any (`dominates` c) valid)) valid
  where
    valid = sortOn candidateId (filter (eligible p) cs)
    oriented s c = (if direction s == Maximize then negate else id) (Map.findWithDefault 0 (objectiveId s) (objectiveValues c))
    dominates a b =
      let pairs = [(oriented s a, oriented s b) | s <- objectives p]
       in all (uncurry (<=)) pairs && any (uncurry (<)) pairs

selectObjectiveWinner :: ObjectivePolicy -> [CandidateReport] -> Maybe CandidateReport
selectObjectiveWinner p cs = case objectiveFrontier p cs of
  [] -> Nothing
  xs -> Just (minimumBy (comparing key) xs)
  where
    key c = ([value ident c | ident <- primaryObjective p : tieBreakObjectives p], candidateId c)
    value ident c =
      let v = Map.findWithDefault 0 ident (objectiveValues c)
       in if any (\s -> objectiveId s == ident && direction s == Maximize) (objectives p) then negate v else v

data RunStatus = Completed | BudgetStopped | Failed | Cancelled
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data CandidateStatus = Unscored | CandidateCompleted | CandidateFailed | CandidateIncomplete
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data CandidateReport = CandidateReport
  { candidateId :: !Int,
    candidateStatus :: !CandidateStatus,
    requiredExamples :: !Int,
    completedExamples :: !Int,
    exampleScores :: ![(Int, Double)],
    objectiveValues :: !ObjectiveValues,
    candidateOperations :: !Int,
    candidateReason :: !(Maybe Text)
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data EventKind = RunStarted | CandidateMetadata Int (Map Text Text) | CandidateStarted Int | CandidateEnded Int CandidateStatus | BudgetStop | RunFinished RunStatus
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data OptimizationEvent = OptimizationEvent {eventId :: !Int, eventKind :: !EventKind}
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data OptimizationReport = OptimizationReport
  { reportVersion :: !Int,
    runStatus :: !RunStatus,
    admittedOperations :: !Int,
    predictedWork :: !Int,
    runControls :: !(Map Text Int),
    reportedPolicy :: !(Maybe ObjectivePolicy),
    candidates :: ![CandidateReport],
    candidateMetadata :: !(Map Int (Map Text Text)),
    unexecutedReservations :: ![Int],
    frontier :: ![Int],
    selectedCandidate :: !(Maybe Int),
    resultStatus :: !(Maybe CandidateStatus),
    selectionReason :: !Text,
    candidateDetailAvailable :: !Bool,
    validationMode :: !Text,
    observerFailures :: !Int,
    events :: ![OptimizationEvent]
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON)

instance FromJSON OptimizationReport where
  parseJSON = withObject "OptimizationReport" $ \o -> do
    version <- o .: "reportVersion"
    unless (version == (1 :: Int)) (fail "unsupported optimization report version")
    OptimizationReport version
      <$> o .: "runStatus"
      <*> o .: "admittedOperations"
      <*> o .: "predictedWork"
      <*> o .: "runControls"
      <*> o .: "reportedPolicy"
      <*> o .: "candidates"
      <*> (o .:? "candidateMetadata" .!= Map.empty)
      <*> o .: "unexecutedReservations"
      <*> o .: "frontier"
      <*> o .: "selectedCandidate"
      <*> o .: "resultStatus"
      <*> o .: "selectionReason"
      <*> o .: "candidateDetailAvailable"
      <*> o .: "validationMode"
      <*> o .: "observerFailures"
      <*> o .: "events"