packages feed

shikumi-0.5.0.0: test/DecisionDeriveSpec.hs

module DecisionDeriveSpec (tests) where

import Data.Aeson (eitherDecode, encode, object, toJSON, (.=))
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Shikumi.Decision
import Shikumi.Decision.Setting
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
import Test.Tasty.QuickCheck (choose, forAll, testProperty)

noul :: Judgment
noul = Judgment "urgent" Nothing (JudgeBoolean Nothing Nothing)

score :: Judgment
score = Judgment "severity" Nothing (JudgeOrdered [toJSON ("Minor" :: Text), toJSON ("Disruptive" :: Text), toJSON ("Blocking" :: Text)])

choice :: Judgment
choice = Judgment "category" Nothing (JudgeChoice [("z", Nothing), ("a", Nothing)])

tests :: TestTree
tests =
  testGroup
    "Decision derivation"
    [ testCase "Noul threshold 0.7 on p=0.8 gives True, confidence 0.142857" $ do
        let (answer, c) = deriveNoul 0.7 (NoulEvidence 0.8)
        answer @?= True
        assertBool "confidence" (abs (c - 1 / 7) < 1e-12),
      testCase "Noul endpoints and equality use an inclusive threshold" $ do
        deriveNoul 0 (NoulEvidence 0) @?= (True, 0)
        deriveNoul 1 (NoulEvidence 1) @?= (True, 0)
        deriveNoul 0.7 (NoulEvidence 0.7) @?= (True, 0),
      testCase "Score cuts change the level while preserving value" $ do
        let e = ScoreEvidence (V.fromList [0.1, 0.3, 0.6]) (Just 0.55)
        deriveScore [0.5, 1.6] e @?= Right (1.5, 1)
        deriveScore [0.5, 1.5] e @?= Right (1.5, 2),
      testCase "Choice raw tie follows declaration order" $
        deriveChoice ["z", "a"] Map.empty (ChoiceEvidence (Map.fromList [("z", 0.5), ("a", 0.5)]) Nothing) @?= Right "z",
      testCase "Choice weighted tie prefers the raw winner" $
        deriveChoice ["z", "a"] (Map.singleton "z" 4) (ChoiceEvidence (Map.fromList [("z", 0.2), ("a", 0.8)]) (Just 0.65)) @?= Right "a",
      testCase "zero-weight label is never selected" $
        deriveChoice ["z", "a"] (Map.singleton "z" 0) (ChoiceEvidence (Map.fromList [("z", 0.9), ("a", 0.1)]) Nothing) @?= Right "a",
      testCase "weights greater than one can change the winner" $
        deriveChoice ["z", "a"] (Map.singleton "z" 5) (ChoiceEvidence (Map.fromList [("z", 0.2), ("a", 0.8)]) Nothing) @?= Right "z",
      testCase "all-zero probability or weighted mass is rejected" $ do
        deriveChoice ["z", "a"] Map.empty (ChoiceEvidence (Map.fromList [("z", 0), ("a", 0)]) Nothing) @?= Left "no remaining probability mass"
        deriveChoice ["z", "a"] (Map.fromList [("z", 0), ("a", 0)]) (ChoiceEvidence (Map.fromList [("z", 0.5), ("a", 0.5)]) Nothing) @?= Left "no remaining probability mass"
        deriveScore [0.5] (ScoreEvidence (V.fromList [0, 0]) Nothing) @?= Left "no remaining probability mass",
      testCase "defaults match the declared kind and level count" $ do
        defaultSetting noul @?= NoulSetting 0.5
        defaultSetting score @?= ScoreSetting [0.5, 1.5]
        defaultSetting choice @?= ChoiceSetting Map.empty,
      testCase "settings have stable tagged JSON and round-trip" $ do
        let settings = [NoulSetting 0.7, ScoreSetting [0.5, 1.6], ChoiceSetting (Map.singleton "z" 2)]
        map toJSON settings
          @?= [object ["kind" .= ("noul" :: Text), "threshold" .= (0.7 :: Double)], object ["kind" .= ("score" :: Text), "cuts" .= ([0.5, 1.6] :: [Double])], object ["kind" .= ("choice" :: Text), "weights" .= Map.singleton ("z" :: Text) (2 :: Double)]]
        eitherDecode (encode settings) @?= Right settings,
      testCase "valid settings preserve sparse weights and endpoint thresholds" $ do
        mapM_ (\t -> validateSetting noul (NoulSetting t) @?= Right (NoulSetting t)) [0, 0.5, 1]
        validateSetting choice (ChoiceSetting (Map.singleton "z" 0)) @?= Right (ChoiceSetting (Map.singleton "z" 0)),
      testCase "validation names the field and rejects each broken rule" $ do
        let invalid =
              [ (noul, ScoreSetting [], "kind"),
                (noul, NoulSetting (-0.1), "threshold"),
                (noul, NoulSetting 1.1, "threshold"),
                (noul, NoulSetting (0 / 0), "finite"),
                (score, ScoreSetting [0.5], "N-1"),
                (score, ScoreSetting [0.5, 0.5], "increasing"),
                (score, ScoreSetting [1.5, 0.5], "increasing"),
                (score, ScoreSetting [0, 1], "(0,N-1)"),
                (score, ScoreSetting [1, 2], "(0,N-1)"),
                (score, ScoreSetting [0.5, 1 / 0], "finite"),
                (choice, ChoiceSetting (Map.singleton "unknown" 1), "declared"),
                (choice, ChoiceSetting (Map.singleton "z" (-1)), "non-negative"),
                (choice, ChoiceSetting (Map.singleton "a" (1 / 0)), "finite")
              ]
        mapM_
          ( \(j, setting, rule) -> case validateSetting j setting of
              Left msg -> do assertBool "field name" (judgmentField j `T.isInfixOf` msg); assertBool (T.unpack msg) (rule `T.isInfixOf` msg)
              Right s -> assertFailure ("accepted invalid setting " <> show s)
          )
          invalid,
      testProperty "score value is independent of valid cuts and level is monotone in value" $
        forAll (choose (0.01, 0.99)) $ \c ->
          forAll (choose (0, 1)) $ \p ->
            forAll (choose (p, 1)) $ \q ->
              let lo = ScoreEvidence (V.fromList [1 - p, p]) Nothing
                  hi = ScoreEvidence (V.fromList [1 - q, q]) Nothing
               in case (deriveScore [c] lo, deriveScore [0.5] lo, deriveScore [c] hi) of
                    (Right (v, l), Right (v', _), Right (w, m)) -> v == v' && v <= w && l <= m
                    _ -> False
    ]