packages feed

shikumi-optimize-0.3.0.0: test/NodeBootstrapSpec.hs

module NodeBootstrapSpec (tests) where

import Baikai (AssistantContent (..), emptyResponse, emptyTextContent)
import Control.Lens ((&), (.~), (^.))
import Data.Aeson (ToJSON, object, toJSON, (.=))
import Data.Generics.Labels ()
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Effectful.Concurrent (Concurrent, runConcurrent)
import Effectful.Dispatch.Dynamic (interpret)
import Effectful.Error.Static (Error, runErrorNoCallStack)
import Effectful.Prim (Prim, runPrim)
import GHC.Generics (Generic)
import Shikumi.Adapter (ToPrompt)
import Shikumi.Compile.Serialize (decodeCompiledOnto, encodeCompiled)
import Shikumi.Compile.Types (compiledProgram)
import Shikumi.Effect.Time (Time, runTime)
import Shikumi.Error (ShikumiError (..))
import Shikumi.Eval (Dataset, dataset, exactMatch, example)
import Shikumi.LLM (LLM (..))
import Shikumi.Module (predict, predictCaptured)
import Shikumi.Optimize
import Shikumi.Program (CaptureCodec (..), Demo (..), Params (..), Program (..), emptyParams, mapParamsAt, programParams, runProgram)
import Shikumi.Schema (FromModel, ToSchema, Validatable, deriveSchema)
import Shikumi.Signature (Signature, mkSignature)
import Shikumi.Trace.Node (NodePath (..), NodeStep (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

newtype Question = Question {question :: Text} deriving stock (Eq, Show, Generic)

instance ToJSON Question

instance ToSchema Question

instance FromModel Question

instance ToPrompt Question

instance Validatable Question

newtype City = City {city :: Text} deriving stock (Eq, Show, Generic)

instance ToJSON City

instance ToSchema City

instance FromModel City

instance ToPrompt City

instance Validatable City

newtype Country = Country {country :: Text} deriving stock (Eq, Show, Generic)

instance ToJSON Country

instance ToSchema Country

instance FromModel Country

instance ToPrompt Country

instance Validatable Country

citySig :: Signature Question City
citySig = mkSignature "Extract city"

countrySig :: Signature City Country
countrySig = mkSignature "Resolve country"

program :: Program Question Country
program = Compose (predictCaptured citySig) (predictCaptured countrySig)

train :: Dataset Question Country
train = dataset [example (Question "Where is Paris?") (Country "France")]

expected :: [[Demo]]
expected = [[Demo (toJSON (Question "Where is Paris?")) (toJSON (City "Paris"))], [Demo (toJSON (City "Paris")) (toJSON (Country "France"))]]

runCity :: IORef Int -> Eff '[LLM, Error ShikumiError, Concurrent, Time, Prim, IOE] a -> IO (Either ShikumiError a)
runCity calls =
  runEff
    . runPrim
    . runTime
    . runConcurrent
    . runErrorNoCallStack @ShikumiError
    . interpret
      ( \_ -> \case
          Complete _ ctx _ -> do
            liftIO (modifyIORef' calls (+ 1))
            callNumber <- liftIO (readIORef calls)
            let prompt = fromMaybe "" (ctx ^. #systemPrompt)
                canAnswer = not ("Student" `T.isInfixOf` prompt) || V.length (ctx ^. #messages) > 1
                body
                  | "Retry country" `T.isInfixOf` prompt && callNumber == 2 = "{\"country\":\"Spain\"}"
                  | "Extract city" `T.isInfixOf` prompt = if canAnswer then "{\"city\":\"Paris\"}" else "{\"city\":\"Lyon\"}"
                  | otherwise = if canAnswer then "{\"country\":\"France\"}" else "{\"country\":\"Spain\"}"
            pure (emptyResponse & #message . #content .~ V.singleton (AssistantText (emptyTextContent & #text .~ body)))
          Stream {} -> pure []
      )

recover :: (LLM :> es, Error ShikumiError :> es, Prim :> es) => NodeBootstrapConfig -> Program Question Country -> Program i o -> Eff es (Map.Map NodePath [Demo], BootstrapReport)
recover cfg teacher student = do
  meter <- newBudgetMeter defaultBudget
  bootstrapNodeDemos cfg meter teacher train exactMatch student

tests :: TestTree
tests =
  testGroup
    "node-local bootstrap"
    [ testCase "bootstrap excludes successful leaves from a rejected retry attempt" $ do
        calls <- newIORef 0
        let retryTeacher =
              Retry
                2
                ( Validate
                    (\out -> if out == Country "France" then Right out else Left "retry")
                    (mapParamsAt 1 (\ps -> ps {instructionOverride = Just "Retry country"}) program)
                )
            cfg =
              defaultNodeBootstrapConfig
                { nodeMapping =
                    Just
                      [ (NodePath [StepRetry, StepValidate, StepComposeL], NodePath [StepComposeL]),
                        (NodePath [StepRetry, StepValidate, StepComposeR], NodePath [StepComposeR])
                      ]
                }
        result <- runCity calls (recover cfg retryTeacher program)
        case result of Right (pools, _) -> Map.elems pools @?= expected; Left e -> assertFailure (show e)
        readIORef calls >>= (@?= 4),
      testCase "explicit mapping handles a differently wrapped teacher" $ do
        calls <- newIORef 0
        let wrapped = Validate Right program
            pairs = [(NodePath [StepValidate, StepComposeL], NodePath [StepComposeL]), (NodePath [StepValidate, StepComposeR], NodePath [StepComposeR])]
        automatic <- runCity calls (recover defaultNodeBootstrapConfig wrapped program)
        case automatic of Left (ValidationFailure _) -> pure (); _ -> assertFailure "expected explicit mapping requirement"
        readIORef calls >>= (@?= 0)
        mapped <- runCity calls (recover (defaultNodeBootstrapConfig {nodeMapping = Just pairs}) wrapped program)
        case mapped of Right (pools, _) -> Map.elems pools @?= expected; Left e -> assertFailure (show e),
      testCase "compatible predictors use independent seeded subsets" $ do
        calls <- newIORef 0
        let parallel = Parallel (predictCaptured citySig) (predictCaptured citySig)
            examples = dataset [example (Question (T.pack (show n))) (City "Paris", City "Paris") | n <- [1 .. 12 :: Int]]
            cfg = defaultNodeBootstrapConfig {nodeSeed = Just 17, nodeBootstrapConfig = BootstrapConfig 1 3}
            collect = do
              meter <- newBudgetMeter defaultBudget
              bootstrapNodeDemos cfg meter parallel examples exactMatch parallel
        first <- runCity calls collect
        again <- runCity calls collect
        first @?= again
        case first of
          Right (pools, _) -> case Map.elems pools of
            [a, b] -> do
              map length [a, b] @?= [3, 3]
              assertBool "independent subsets" (a /= b)
            _ -> assertFailure "expected two nodes"
          Left e -> assertFailure (show e),
      testCase "duplicate targets require explicit merge" $ do
        calls <- newIORef 0
        let parallel = Parallel (predictCaptured citySig) (predictCaptured citySig)
            examples = dataset [example (Question "Paris?") (City "Paris", City "Paris")]
            cfg = defaultNodeBootstrapConfig {nodeMapping = Just [(NodePath [StepParallelL], NodePath []), (NodePath [StepParallelR], NodePath [])]}
            collect c = do
              meter <- newBudgetMeter defaultBudget
              bootstrapNodeDemos c meter parallel examples exactMatch (predictCaptured citySig)
        rejected <- runCity calls (collect cfg)
        case rejected of Left (ValidationFailure _) -> pure (); _ -> assertFailure "expected duplicate rejection"
        readIORef calls >>= (@?= 0)
        merged <- runCity calls (collect (cfg {mergeTargetMappings = True}))
        case merged of Right (pools, _) -> map length (Map.elems pools) @?= [2]; Left e -> assertFailure (show e),
      testCase "heterogeneous demos run and round-trip onto the capture template" $ do
        calls <- newIORef 0
        result <- runCity calls $ do
          cp <- optimize (bootstrapFewShot program defaultBudget) train exactMatch program
          out <- runProgram (compiledProgram cp) (Question "Where is Paris?")
          pure (cp, out)
        case result of
          Left e -> assertFailure (show e)
          Right (cp, out) -> do
            out @?= Country "France"
            map demos (programParams (compiledProgram cp)) @?= expected
            case decodeCompiledOnto program (encodeCompiled cp) of
              Left e -> assertFailure e
              Right restored -> do
                map demos (programParams (compiledProgram restored)) @?= expected
                replay <- runCity calls (runProgram (compiledProgram restored) (Question "Where is Paris?"))
                replay @?= Right (Country "France"),
      testCase "incompatible mappings fail before calls" $ do
        calls <- newIORef 0
        let cfg = defaultNodeBootstrapConfig {nodeMapping = Just [(NodePath [StepComposeL], NodePath [StepComposeR])]}
        result <- runCity calls (recover cfg program program)
        case result of Left (ValidationFailure _) -> pure (); _ -> assertFailure "expected preflight rejection"
        readIORef calls >>= (@?= 0),
      testCase "uncaptured composite fails before calls" $ do
        calls <- newIORef 0
        result <- runCity calls (recover defaultNodeBootstrapConfig (Compose (predict citySig) (predict countrySig)) program)
        case result of Left (ValidationFailure msg) -> assertBool "actionable" ("predictCaptured" `T.isInfixOf` msg); _ -> assertFailure "expected missing codec"
        readIORef calls >>= (@?= 0),
      testCase "failed root contributes no demos" $ do
        calls <- newIORef 0
        let failed = Validate (const (Left "rejected")) program
            cfg = defaultNodeBootstrapConfig {nodeMapping = Just [(NodePath [StepValidate, StepComposeL], NodePath [StepComposeL]), (NodePath [StepValidate, StepComposeR], NodePath [StepComposeR])]}
        result <- runCity calls (recover cfg failed program)
        case result of Right (pools, _) -> Map.elems pools @?= [[], []]; Left e -> assertFailure (show e),
      testCase "a dishonest codec is rejected by target decoding" $ do
        calls <- newIORef 0
        let codec = CaptureCodec toJSON (const (object ["wrong" .= ("Paris" :: Text)])) (deriveSchema @Question) (deriveSchema @City)
            bad = Compose (PredictCaptured codec citySig emptyParams) (predictCaptured countrySig)
        result <- runCity calls (recover defaultNodeBootstrapConfig bad program)
        case result of
          Right (pools, BootstrapReport notes) -> do
            Map.lookup (NodePath [StepComposeL]) pools @?= Just []
            assertBool "diagnostic" (not (null notes))
          Left e -> assertFailure (show e),
      testCase "seeded pools reproduce and obey per-node caps" $ do
        calls <- newIORef 0
        let cfg = defaultNodeBootstrapConfig {nodeSeed = Just 17, nodeBootstrapConfig = BootstrapConfig 1 1}
        a <- runCity calls (recover cfg program program)
        b <- runCity calls (recover cfg program program)
        a @?= b
        case a of Right (pools, _) -> map length (Map.elems pools) @?= [1, 1]; Left e -> assertFailure (show e),
      testCase "random search accepts a heterogeneous capture program" $ do
        calls <- newIORef 0
        let student =
              mapParamsAt 0 (\ps -> ps {instructionOverride = Just "Student Extract city"}) $
                mapParamsAt 1 (\ps -> ps {instructionOverride = Just "Student Resolve country"}) program
        result <- runCity calls (optimize (bootstrapRandomSearch program 2 defaultBudget) train exactMatch student)
        case result of
          Right cp -> do
            map demos (programParams (compiledProgram cp)) @?= expected
            runCity calls (runProgram (compiledProgram cp) (Question "Paris?")) >>= (@?= Right (Country "France"))
          Left e -> assertFailure (show e),
      testCase "MIPRO builds separate valid node candidate pools" $ do
        calls <- newIORef 0
        result <- runCity calls (bootstrapDemoCandidates ((miprov2Auto Miprov2Light) {numDemoCandidates = 4}) program train exactMatch program)
        case result of
          Right sets -> map (filter (not . null)) sets @?= map (: []) expected
          Left e -> assertFailure (show e)
    ]