shikumi-optimize-0.1.0.0: src/Shikumi/Optimize/MIPRO.hs
{-# LANGUAGE ScopedTypeVariables #-}
-- | MIPROv2 (EP-20): the joint instruction-and-demonstration optimizer. Where
-- 'Shikumi.Optimize.Instruction.instructionSearch' improves one axis (instructions)
-- one node at a time, MIPROv2 searches /both/ axes — instruction × demo set — across
-- all nodes jointly, in three phases:
--
-- 1. __bootstrap__ candidate demo sets (per node) from the teacher's passing runs
-- plus the labelled training pairs ('bootstrapDemoCandidates');
-- 2. __propose__ candidate instructions (per node) through EP-19's grounded
-- proposer ('proposeInstructionCandidates'); and
-- 3. __search__ the joint @(instruction × demoset)@ grid with cheap minibatch
-- trials gated by periodic full evaluation ('searchJoint').
--
-- The result is V1's 'Shikumi.Compile.Types.CompiledProgram', produced via
-- 'freezeProgram', invoked through V1's 'Shikumi.Optimize.optimize' and persisted
-- with V1's serialization — no parallel optimizer or persistence surface (MasterPlan
-- integration point #4).
--
-- __Search surrogate.__ DSPy drives phase 3 with Optuna's TPE sampler, which has no
-- Haskell equivalent in this workspace. We implement __greedy coordinate descent with
-- minibatch pruning__ over the joint grid: each trial screens the one-coordinate
-- neighbours of the running best on a seeded minibatch, then full-evaluates the
-- best-screened neighbour and accepts it only if it strictly improves the full score.
-- This keeps the three essential, testable behaviours — joint grid, minibatch-screen
-- + full-eval-confirm, and a hard 'Budget' — while staying fully deterministic. A
-- later EP can swap the neighbour-selection step for a TPE-lite without changing this
-- module's public surface.
module Shikumi.Optimize.MIPRO
( -- * Public surface
Miprov2Auto (..),
Miprov2Config (..),
miprov2Auto,
miprov2,
miprov2With,
-- * Phases (exported for tests)
bootstrapDemoCandidates,
proposeInstructionCandidates,
searchJoint,
)
where
import Control.Monad (forM)
import Data.Aeson (ToJSON)
import Data.Aeson.Text (encodeToLazyText)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text.Lazy qualified as TL
import Effectful (Eff, (:>))
import Effectful.Concurrent (Concurrent)
import Effectful.Error.Static (Error, catchError)
import Effectful.Prim (Prim)
import Shikumi.Effect.Time (Time)
import Shikumi.Error (ShikumiError)
import Shikumi.Eval
( Dataset,
Example (..),
Metric,
dataset,
datasetExamples,
datasetSize,
prediction,
unScore,
)
import Shikumi.LLM (LLM)
import Shikumi.Optimize.Bootstrap (recoverDemo)
import Shikumi.Optimize.Propose
( ProposeRequest (..),
ProposeResult (..),
proposeInstructions,
)
import Shikumi.Optimize.Search (freezeProgram, scoreOn)
import Shikumi.Optimize.Types (Budget (..), Optimizer (..), defaultBudget)
import Shikumi.Program
( Demo (..),
Params (..),
Program,
foldParams,
mapParamsAt,
runProgram,
)
-- ---------------------------------------------------------------------------
-- Configuration and presets
-- ---------------------------------------------------------------------------
-- | How aggressively to search. Mirrors DSPy's light/medium/heavy "auto" modes.
data Miprov2Auto = Miprov2Light | Miprov2Medium | Miprov2Heavy
deriving stock (Eq, Show)
-- | The full, explicit configuration. Presets fill it in; advanced users build one
-- directly.
data Miprov2Config = Miprov2Config
{ -- | instruction candidates proposed per node (incl. the current at index 0)
numInstructCandidates :: !Int,
-- | demo-set candidates per node (incl. the empty set at index 0)
numDemoCandidates :: !Int,
-- | minibatch trials the search performs
numTrials :: !Int,
-- | examples per minibatch screening evaluation
minibatchSize :: !Int,
-- | run a full eval of a trial's proposal every k trials (also retained for
-- config compatibility; the implementation full-evaluates each trial's single
-- proposal to gate acceptance — see the module header)
fullEvalEvery :: !Int,
-- | cap on demos in one bootstrapped set (prompt-size guard)
maxBootstrappedDemos :: !Int,
-- | min metric score for a teacher run to contribute demos
bootstrapThreshold :: !Double,
-- | hard LM-call / candidate ceiling (V1's 'Budget')
budget :: !Budget
}
deriving stock (Eq, Show)
-- | Turn a preset into a concrete config. Values are small enough for hermetic tests
-- yet keep DSPy's monotonic ordering (heavier => more candidates, more trials).
miprov2Auto :: Miprov2Auto -> Miprov2Config
miprov2Auto Miprov2Light = Miprov2Config 2 2 6 3 3 4 1.0 defaultBudget
miprov2Auto Miprov2Medium = Miprov2Config 3 3 12 5 4 4 1.0 defaultBudget
miprov2Auto Miprov2Heavy = Miprov2Config 4 4 18 8 5 4 1.0 defaultBudget
-- ---------------------------------------------------------------------------
-- Public optimizers
-- ---------------------------------------------------------------------------
-- | MIPROv2 with a preset. The teacher (whose successful runs seed bootstrapped
-- demos) defaults to the student itself, matching DSPy's default.
miprov2 :: (ToJSON i, ToJSON o) => Miprov2Auto -> Optimizer i o
miprov2 a = Optimizer $ \train metric student ->
runOptimizer (miprov2With (miprov2Auto a) student) train metric student
-- | MIPROv2 with an explicit config and an explicit teacher program.
miprov2With :: (ToJSON i, ToJSON o) => Miprov2Config -> Program i o -> Optimizer i o
miprov2With cfg teacher = Optimizer $ \train metric student -> do
demoCands <- bootstrapDemoCandidates cfg teacher train metric student
instrCands <- proposeInstructionCandidates cfg student train demoCands
best <- searchJoint cfg train metric student instrCands demoCands
pure (freezeProgram best)
-- ---------------------------------------------------------------------------
-- Phase 1 — bootstrap demo-set candidates
-- ---------------------------------------------------------------------------
-- | For each node (in @foldParams@ order), a list of candidate demo sets. Candidate
-- 0 is always the empty set (so "no demos" is always reachable). The remaining sets
-- come from the teacher's metric-passing runs (bootstrapped) and from the labelled
-- training pairs (labelled demos, DSPy's @max_labeled_demos@) — recovered at the
-- program-I/O level and attached to every node (the documented degradation while
-- per-node trace recovery via EP-16 is deferred; for single-node programs the two
-- coincide).
bootstrapDemoCandidates ::
(ToJSON i, ToJSON o, LLM :> es, Error ShikumiError :> es) =>
Miprov2Config ->
-- | teacher
Program i o ->
Dataset i o ->
Metric o ->
-- | student (for the node count)
Program i o ->
Eff es [[[Demo]]]
bootstrapDemoCandidates cfg teacher train metric student = do
let exs = datasetExamples train
keepIfPassing (Example inp expd) =
( do
out <- runProgram teacher inp
let s = unScore (metric expd (prediction out))
pure [recoverDemo inp out | s >= bootstrapThreshold cfg]
)
`catchError` \_ (_ :: ShikumiError) -> pure []
bootstrapped <- concat <$> mapM keepIfPassing exs
let cap = max 1 (maxBootstrappedDemos cfg)
labeledSet = take cap (map (\(Example i o) -> recoverDemo i o) exs)
bootSet = take cap bootstrapped
sets = take (max 1 (numDemoCandidates cfg)) ([] : filter (not . null) [labeledSet, bootSet])
nNodes = length (foldParams student)
pure (replicate nNodes sets)
-- ---------------------------------------------------------------------------
-- Phase 2 — propose instruction candidates
-- ---------------------------------------------------------------------------
-- | For each node (in @foldParams@ order), a list of candidate instructions with the
-- node's current instruction first (the grounded proposer guarantees retention).
-- Uses EP-19's 'proposeInstructions'; the per-candidate tip starts at the /creative/
-- tip (@tipIndex = 1@) so a node always gets a strong proposal even at small N.
proposeInstructionCandidates ::
(ToJSON i, ToJSON o, LLM :> es, Error ShikumiError :> es) =>
Miprov2Config ->
Program i o ->
Dataset i o ->
[[[Demo]]] ->
Eff es [[Text]]
proposeInstructionCandidates cfg student train demoCands =
forM (zip3 [0 ..] (foldParams student) demoCands) $ \(k, ps, nodeDemoSets) -> do
let curInstr = fromMaybe "" (instructionOverride ps)
demoTexts = map renderDemo (concat (take 1 (filter (not . null) nodeDemoSets)))
ProposeResult cs <-
proposeInstructions
train
ProposeRequest
{ program = student,
targetNode = k,
currentInstruction = curInstr,
history = [],
bootstrappedDemos = demoTexts,
numCandidates = max 0 (numInstructCandidates cfg - 1),
tipIndex = 1,
viewBatch = 2
}
pure cs
-- | Render a recovered 'Demo' as @<input-json> => <output-json>@ for the proposal
-- prompt's demo signal.
renderDemo :: Demo -> Text
renderDemo (Demo i o) = enc i <> " => " <> enc o
where
enc = TL.toStrict . encodeToLazyText
-- ---------------------------------------------------------------------------
-- Phase 3 — the joint minibatch search
-- ---------------------------------------------------------------------------
-- | A per-node selection: @(instructionIndex, demoSetIndex)@.
type JointVec = [(Int, Int)]
-- | Search the joint per-node @(instruction × demoset)@ grid by greedy coordinate
-- descent with minibatch screening, returning the best program found within the
-- 'Budget'. Each trial screens the one-coordinate neighbours of the running best on a
-- seeded minibatch, then full-evaluates the best-screened neighbour and accepts it
-- only if it strictly improves the full score.
searchJoint ::
(LLM :> es, Concurrent :> es, Error ShikumiError :> es, Time :> es, Prim :> es) =>
Miprov2Config ->
Dataset i o ->
Metric o ->
Program i o ->
-- | per-node instruction candidates
[[Text]] ->
-- | per-node demo-set candidates
[[[Demo]]] ->
Eff es (Program i o)
searchJoint cfg train metric student instrCands demoCands = do
let nNodes = length (foldParams student)
dsSize = datasetSize train
mbSize = max 1 (min dsSize (minibatchSize cfg))
maxCalls = maxLmCalls (budget cfg)
apply = applyVec instrCands demoCands student
baseVec = replicate nNodes (0, 0)
if dsSize > maxCalls
then pure (apply baseVec)
else do
baseFull <- scoreOn train metric (apply baseVec)
let screen _t calls acc [] = pure (reverse acc, calls)
screen t calls acc (v : vs)
| calls + mbSize > maxCalls = pure (reverse acc, calls)
| otherwise = do
s <- scoreOn (minibatchAt mbSize (t * 7 + length acc) train) metric (apply v)
screen t (calls + mbSize) ((v, s) : acc) vs
go t calls bestVec bestFull
| t >= numTrials cfg = pure bestVec
| otherwise = do
(scored, calls1) <- screen t calls [] (oneCoordNeighbors instrCands demoCands bestVec)
case bestByScore scored of
Nothing -> pure bestVec
Just propVec
| calls1 + dsSize > maxCalls -> pure bestVec
| otherwise -> do
fs <- scoreOn train metric (apply propVec)
if fs > bestFull
then go (t + 1) (calls1 + dsSize) propVec fs
else go (t + 1) (calls1 + dsSize) bestVec bestFull
best <- go 0 dsSize baseVec baseFull
pure (apply best)
-- | Apply a joint vector to the student: set each node's instruction and demos to the
-- selected candidates.
applyVec :: [[Text]] -> [[[Demo]]] -> Program i o -> JointVec -> Program i o
applyVec instrCands demoCands prog vec = foldl step prog (zip [0 ..] vec)
where
step p (k, (i, d)) =
mapParamsAt
k
( \ps ->
ps
{ instructionOverride = Just (instrCands !! k !! i),
demos = demoCands !! k !! d
}
)
p
-- | The one-coordinate neighbours of a vector: each move changes exactly one node's
-- instruction or demo index to a non-current alternative.
oneCoordNeighbors :: [[Text]] -> [[[Demo]]] -> JointVec -> [JointVec]
oneCoordNeighbors instrCands demoCands vec =
[setAt k (i, snd (vec !! k)) vec | k <- nodes, i <- idxs (instrCands !! k), i /= fst (vec !! k)]
++ [setAt k (fst (vec !! k), d) vec | k <- nodes, d <- idxs (demoCands !! k), d /= snd (vec !! k)]
where
nodes = [0 .. length vec - 1]
idxs xs = [0 .. length xs - 1]
setAt k v xs = take k xs ++ [v] ++ drop (k + 1) xs
-- | The vector with the strictly-highest score (earliest on ties); 'Nothing' if none.
bestByScore :: [(JointVec, Double)] -> Maybe JointVec
bestByScore [] = Nothing
bestByScore (x : xs) = Just (fst (foldl (\b c -> if snd c > snd b then c else b) x xs))
-- | A deterministic minibatch of @sz@ examples, rotated by @seed@ (a seeded sample is
-- as reproducible as the whole set, and far cheaper to screen on).
minibatchAt :: Int -> Int -> Dataset i o -> Dataset i o
minibatchAt sz seed ds =
let exs = datasetExamples ds
n = length exs
in if n == 0 then ds else dataset (take (max 1 sz) (drop (seed `mod` n) (cycle exs)))