shikumi-0.2.0.0: test/CombinatorSpec.hs
-- | EP-5 acceptance: every combinator demonstrated against a deterministic mock
-- LM, asserting observable aggregate behaviour. Order-sensitive cases run under
-- the sequential 'runProgram'; 'runProgramConc' is exercised with
-- order-independent (identical) replies so the concurrent pop order cannot affect
-- the assertion. The final group proves integration point #4's cross-cutting
-- properties (the parameter traversal reaches every nested leaf; the structural
-- shape and parameter vector round-trip).
module CombinatorSpec (tests) where
import Control.Lens ((&), (.~))
import Data.Generics.Labels ()
import Data.IORef (newIORef, readIORef)
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (runEff)
import Effectful.Concurrent (runConcurrent)
import Effectful.Error.Static (runErrorNoCallStack)
import ProgramFixtures
( Cell (..),
Draft (..),
Topic (..),
cellSig,
draftResponse,
markerBody,
mkResponse,
outlineResponse,
outlineToDraft,
runRecordingLLM,
runScriptedLLM,
topicToOutline,
)
import Shikumi.Combinator
( TempSchedule (..),
chain,
ensemble,
majorityVote,
majorityVoteBy,
mapP,
mapSeqP,
parallel2,
parallelN,
retry,
retryWhen,
validate,
validateRetry,
(>>>),
)
import Shikumi.Error (ShikumiError (..), isTransient)
import Shikumi.LLM (Response)
import Shikumi.LLM.Mock (MockReply (..), runMockLLM, runMockLLMCounting)
import Shikumi.Module (predict)
import Shikumi.Program
( Program,
ProgramShape (..),
ProgramShapeError (..),
emptyParams,
foldParams,
mapParams,
programParams,
programShape,
runProgram,
runProgramConc,
setProgramParams,
)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))
-- ---------------------------------------------------------------------------
-- Run harnesses
-- ---------------------------------------------------------------------------
-- | Run a program sequentially against a list of canned success 'Response's.
runSeq :: Program i o -> [Response] -> i -> IO (Either ShikumiError o)
runSeq prog rs i = do
ref <- newIORef rs
runEff . runErrorNoCallStack @ShikumiError . runScriptedLLM ref $ runProgram prog i
-- | Run a program sequentially against scripted replies (successes /and/
-- failures).
runMockSeq :: Program i o -> [MockReply] -> i -> IO (Either ShikumiError o)
runMockSeq prog rs i =
runEff . runErrorNoCallStack @ShikumiError . runMockLLM rs $ runProgram prog i
-- | Run a program and report both the result and the number of completions
-- issued. The counter is read after the run resolves, so it survives a failure
-- short-circuit.
runCounted :: Program i o -> [MockReply] -> i -> IO (Either ShikumiError o, Int)
runCounted prog rs i = do
cnt <- newIORef 0
res <- runEff . runErrorNoCallStack @ShikumiError . runMockLLMCounting cnt rs $ runProgram prog i
n <- readIORef cnt
pure (res, n)
-- | Run a program through the concurrent executor. Replies are popped from a
-- shared queue, so callers pass identical replies when the assertion is
-- order-sensitive.
runConc ::
Program i o -> [Response] -> i -> IO (Either ShikumiError o)
runConc prog rs i = do
ref <- newIORef rs
runEff . runErrorNoCallStack @ShikumiError . runConcurrent . runScriptedLLM ref $
runProgramConc prog i
-- | Run sequentially while recording each request's rendered system prompt.
runRec :: Program i o -> [Response] -> i -> IO [Text]
runRec prog rs i = do
cap <- newIORef []
ref <- newIORef rs
_ <- runEff . runErrorNoCallStack @ShikumiError . runRecordingLLM cap ref $ runProgram prog i
readIORef cap
-- ---------------------------------------------------------------------------
-- Fixtures
-- ---------------------------------------------------------------------------
-- | A @Cell -> Cell@ predictor — the simplest typed leaf to compose.
cellP :: Program Cell Cell
cellP = predict cellSig
-- | A canned response decoding to @Cell {cell = v}@.
cellResp :: Text -> Response
cellResp v = mkResponse (markerBody [("cell", v)])
-- | A transient (retryable) failure.
transientFail :: MockReply
transientFail = MockFail (ProviderFailure "stub transient")
-- | Concatenate the cells (a reducer for outputs that are not usefully modal).
concatCells :: [Cell] -> Cell
concatCells cs = Cell (T.concat (map cell cs))
-- | A fixed single-temperature schedule (the schedule is inert on the wire; see
-- 'Shikumi.Program.TempSchedule').
sched1 :: TempSchedule
sched1 = TempFixed [0.0]
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
tests :: TestTree
tests =
testGroup
"CombinatorSpec"
[ smokeTests,
pipelineTests,
mapTests,
parallelTests,
retryTests,
validateTests,
majorityVoteTests,
ensembleTests,
crossCuttingTests
]
smokeTests :: TestTree
smokeTests =
testGroup
"smoke"
[ testCase "predict through the mock LLM returns the decoded value" $ do
r <- runSeq cellP [cellResp "echoed"] (Cell "in")
r @?= Right (Cell "echoed")
]
pipelineTests :: TestTree
pipelineTests =
testGroup
"Pipeline"
[ testCase "(>>>) threads an intermediate type absent from the composite signature" $ do
let pipe = predict topicToOutline >>> predict outlineToDraft :: Program Topic Draft
r <- runSeq pipe [outlineResponse, draftResponse] (Topic "haskell")
r @?= Right (Draft "A fine essay about the outline."),
testCase "chain composes n same-type stages, returning the last stage's output" $ do
let pipe = chain [cellP, cellP, cellP]
r <- runSeq pipe [cellResp "a", cellResp "b", cellResp "c"] (Cell "in")
r @?= Right (Cell "c")
]
mapTests :: TestTree
mapTests =
testGroup
"Map"
[ testCase "mapSeqP runs once per element, preserving input order" $ do
let prog = mapSeqP cellP
r <- runSeq prog [cellResp "1", cellResp "2", cellResp "3", cellResp "4", cellResp "5"] (replicate 5 (Cell "in"))
r @?= Right [Cell "1", Cell "2", Cell "3", Cell "4", Cell "5"],
testCase "mapP runs under the concurrent executor and yields one output per input" $ do
let prog = mapP 2 cellP
r <- runConc prog (replicate 3 (cellResp "x")) (replicate 3 (Cell "in"))
r @?= Right [Cell "x", Cell "x", Cell "x"]
]
parallelTests :: TestTree
parallelTests =
testGroup
"Parallel"
[ testCase "parallel2 runs both programs on the same input and pairs the outputs" $ do
let prog = parallel2 cellP cellP
r <- runSeq prog [cellResp "L", cellResp "R"] (Cell "in")
r @?= Right (Cell "L", Cell "R"),
testCase "parallelN collects every program's output in order" $ do
let prog = parallelN [cellP, cellP, cellP]
r <- runSeq prog [cellResp "1", cellResp "2", cellResp "3"] (Cell "in")
r @?= Right [Cell "1", Cell "2", Cell "3"],
testCase "parallel2 runs concurrently under runProgramConc" $ do
let prog = parallel2 cellP cellP
r <- runConc prog [cellResp "z", cellResp "z"] (Cell "in")
r @?= Right (Cell "z", Cell "z")
]
retryTests :: TestTree
retryTests =
testGroup
"Retry"
[ testCase "fail-then-succeed returns the success after exactly two attempts" $ do
(r, n) <- runCounted (retry 3 cellP) [transientFail, MockOk (cellResp "ok")] (Cell "in")
r @?= Right (Cell "ok")
n @?= 2,
testCase "all-fail surfaces the last error after exactly n attempts" $ do
(r, n) <- runCounted (retry 3 cellP) [transientFail, transientFail, transientFail] (Cell "in")
r @?= Left (ProviderFailure "stub transient")
n @?= 3,
testCase "retryWhen skips a non-matching error after a single attempt" $ do
(r, n) <-
runCounted
(retryWhen isTransient 3 cellP)
[MockFail (SchemaMismatch "nope"), MockOk (cellResp "ok")]
(Cell "in")
r @?= Left (SchemaMismatch "nope")
n @?= 1
]
validateTests :: TestTree
validateTests =
testGroup
"Validate"
[ testCase "a rejected output surfaces ValidationFailure with the reason" $ do
let prog = validate (\c -> cell c == "good") "cell must be good" cellP
r <- runMockSeq prog [MockOk (cellResp "bad")] (Cell "in")
r @?= Left (ValidationFailure "cell must be good"),
testCase "an accepted output passes through unchanged" $ do
let prog = validate (\c -> cell c == "good") "cell must be good" cellP
r <- runMockSeq prog [MockOk (cellResp "good")] (Cell "in")
r @?= Right (Cell "good"),
testCase "validateRetry recovers when a later attempt passes" $ do
let prog = validateRetry 2 (\c -> cell c == "good") "cell must be good" cellP
(r, n) <- runCounted prog [MockOk (cellResp "bad"), MockOk (cellResp "good")] (Cell "in")
r @?= Right (Cell "good")
n @?= 2
]
majorityVoteTests :: TestTree
majorityVoteTests =
testGroup
"MajorityVote"
[ testCase "five samples A,B,A,A,B return the modal A" $ do
let prog = majorityVote 5 sched1 cellP
r <-
runSeq
prog
[cellResp "A", cellResp "B", cellResp "A", cellResp "A", cellResp "B"]
(Cell "in")
r @?= Right (Cell "A"),
testCase "a tie resolves to the first-seen value" $ do
let prog = majorityVote 2 sched1 cellP
r <- runSeq prog [cellResp "A", cellResp "B"] (Cell "in")
r @?= Right (Cell "A"),
testCase "majorityVoteBy applies the custom reducer" $ do
let prog = majorityVoteBy 3 sched1 concatCells cellP
r <- runSeq prog [cellResp "x", cellResp "y", cellResp "z"] (Cell "in")
r @?= Right (Cell "xyz")
]
ensembleTests :: TestTree
ensembleTests =
testGroup
"Ensemble"
[ testCase "ensemble folds every member's output with the reducer" $ do
let prog = ensemble [cellP, cellP, cellP] concatCells
r <- runSeq prog [cellResp "1", cellResp "2", cellResp "3"] (Cell "in")
r @?= Right (Cell "123"),
testCase "the parameter traversal reaches a leaf inside every member" $
length (foldParams (ensemble [cellP, cellP, cellP] concatCells)) @?= 3
]
-- A deliberately deep program: a Pipeline of (a Retry of a MajorityVote) then a
-- Validate — every combinator wrapper nests a single Predict leaf, so the
-- traversal must reach two leaves total.
deepProg :: Program Cell Cell
deepProg =
chain
[ retry 2 (majorityVote 3 sched1 cellP),
validate (const True) "always ok" cellP
]
crossCuttingTests :: TestTree
crossCuttingTests =
testGroup
"cross-cutting (traversal + serialization)"
[ testCase "the traversal reaches every nested leaf" $
length (foldParams deepProg) @?= 2,
testCase "an instruction written through the traversal reaches the deepest leaf at run time" $ do
let sentinel = "SENTINEL-INSTRUCTION"
rewritten = mapParams (\p -> p & #instructionOverride .~ Just sentinel) deepProg
-- stage 1 samples the MajorityVote leaf 3×, stage 2 the Validate leaf 1×.
prompts <-
runRec
rewritten
(replicate 4 (cellResp "v"))
(Cell "in")
assertBool
("expected the sentinel in every captured prompt; got: " <> show prompts)
(length prompts == 4 && all (T.isInfixOf sentinel) prompts),
testCase "the structural shape is parameter-independent and round-trips the parameter vector" $ do
let p1 = emptyParams & #instructionOverride .~ Just "one"
p2 = emptyParams & #instructionOverride .~ Just "two"
programParams deepProg @?= [emptyParams, emptyParams]
case setProgramParams [p1, p2] deepProg of
Left e -> assertBool ("unexpected setProgramParams failure: " <> show e) False
Right prog' -> do
programParams prog' @?= [p1, p2]
programShape prog' @?= programShape deepProg,
testCase "a parameter-vector length mismatch is a typed error, not a crash" $
case setProgramParams [emptyParams] deepProg of
Left (ParamCountMismatch (expected, got)) -> (expected, got) @?= (2, 1)
Right _ -> assertBool "expected a ParamCountMismatch" False,
testCase "a function-carrying node serializes its structure (closure omitted) without crashing" $
case programShape (ensemble [cellP] concatCells) of
ShapeEnsemble [ShapePredict _] -> pure ()
other -> assertBool ("expected ShapeEnsemble [ShapePredict _]; got: " <> show other) False
]