packages feed

shikumi-optimize-0.1.0.0: src/Shikumi/Optimize/COPRO.hs

-- | COPRO (EP-21): coordinate-ascent prompt optimization. Where
-- 'Shikumi.Optimize.Instruction.instructionSearch' is one-shot per node, COPRO
-- improves each node's instruction over several /rounds/ (depth), proposing several
-- candidates per round (breadth) and feeding the /scored attempt history/ forward so
-- later rounds learn from what scored well. It is the principled generalization of
-- @instructionSearch@ (depth-1, no-history COPRO ≈ @instructionSearch@), kept
-- alongside it rather than replacing it.
--
-- COPRO consumes EP-19's grounded proposer ('Shikumi.Optimize.Propose.proposeInstructions')
-- directly: each round's call passes the node's current instruction and its scored
-- 'PastInstruction' history, and the proposer returns ranked candidates with the
-- current instruction always retained (the safety property — a node never degrades).
--
-- Output is V1's 'Shikumi.Compile.Types.CompiledProgram' via 'freezeProgram', invoked
-- through 'Shikumi.Optimize.optimize' and serialized unchanged (integration point #4).
module Shikumi.Optimize.COPRO
  ( CoproConfig (..),
    defaultCoproConfig,
    copro,
  )
where

import Control.Monad (foldM)
import Data.Aeson (ToJSON)
import Data.Maybe (fromMaybe)
import Effectful (Eff, (:>))
import Effectful.Concurrent (Concurrent)
import Effectful.Error.Static (Error)
import Effectful.Prim (Prim)
import Shikumi.Effect.Time (Time)
import Shikumi.Error (ShikumiError)
import Shikumi.Eval (Dataset, Metric, datasetSize)
import Shikumi.LLM (LLM)
import Shikumi.Optimize.Propose
  ( PastInstruction (..),
    ProposeRequest (..),
    ProposeResult (..),
    proposeInstructions,
  )
import Shikumi.Optimize.Search (freezeProgram, instructionAt, scoreOn, setNodeInstr)
import Shikumi.Optimize.Types (Budget (..), Optimizer (..), defaultBudget)
import Shikumi.Program (Program, foldParams)

-- | COPRO's two knobs plus the shared 'Budget'.
data CoproConfig = CoproConfig
  { -- | candidate instructions generated per node per round (clamped to @>= 2@)
    breadth :: !Int,
    -- | number of coordinate-ascent rounds (clamped to @>= 1@)
    depth :: !Int,
    -- | LM-call / candidate ceilings
    budget :: !Budget
  }
  deriving stock (Eq, Show)

-- | Breadth 4, depth 3, the default budget.
defaultCoproConfig :: CoproConfig
defaultCoproConfig = CoproConfig {breadth = 4, depth = 3, budget = defaultBudget}

-- | Coordinate-ascent instruction optimization. Visits each node in @foldParams@
-- order, optimizing it over @depth@ rounds against the already-improved earlier
-- nodes, threading one running LM-call count so the 'Budget' bounds the whole search.
copro :: (ToJSON i, ToJSON o) => CoproConfig -> Optimizer i o
copro cfg = Optimizer $ \train metric student -> do
  let nNodes = length (foldParams student)
  (final, _calls) <-
    foldM
      (\acc idx -> optimizeNode cfg train metric idx acc)
      (student, 0)
      [0 .. nNodes - 1]
  pure (freezeProgram final)

-- | Optimize node @idx@ over @depth@ rounds. Returns the program with node @idx@ set
-- to its best-found instruction, plus the updated LM-call count. Each round proposes
-- @breadth - 1@ fresh candidates (plus the retained current instruction) via the
-- grounded proposer fed the scored attempt history, scores the not-yet-seen ones on
-- the whole training set, records @(instruction, best-score)@, and sets the node to
-- the best so far. Every spend is gated against the 'Budget'.
optimizeNode ::
  (ToJSON i, ToJSON o, LLM :> es, Concurrent :> es, Error ShikumiError :> es, Time :> es, Prim :> es) =>
  CoproConfig ->
  Dataset i o ->
  Metric o ->
  Int ->
  (Program i o, Int) ->
  Eff es (Program i o, Int)
optimizeNode cfg train metric idx (prog0, calls0) = goRound 1 prog0 calls0 []
  where
    dsSize = datasetSize train
    bdth = max 2 (breadth cfg)
    dpth = max 1 (depth cfg)
    proposerCost = 4 + (bdth - 1)
    maxCalls = maxLmCalls (budget cfg)
    maxCands = maxCandidates (budget cfg)

    goRound r prog calls evald
      | r > dpth = pure (setBest prog evald, calls)
      | otherwise = do
          let cur = fromMaybe "" (instructionAt idx prog)
              hist = [PastInstruction i s | (i, s) <- evald]
          (cands, calls1) <-
            if calls + proposerCost <= maxCalls
              then do
                ProposeResult cs <-
                  proposeInstructions
                    train
                    ProposeRequest
                      { program = prog,
                        targetNode = idx,
                        currentInstruction = cur,
                        history = hist,
                        bootstrappedDemos = [],
                        numCandidates = bdth - 1,
                        tipIndex = 1,
                        viewBatch = 2
                      }
                pure (cs, calls + proposerCost)
              else pure ([cur], calls)
          (evald', calls2) <- scoreNew prog calls1 evald (dedupNew evald cands)
          -- set the node to the best instruction found so far, then continue
          let prog' = setBest prog evald'
          goRound (r + 1) prog' calls2 evald'

    -- Score the not-yet-evaluated candidates, threading the call count and stopping
    -- before either Budget ceiling would be exceeded.
    scoreNew _ calls evald [] = pure (evald, calls)
    scoreNew prog calls evald (c : cs)
      | calls + dsSize > maxCalls = pure (evald, calls)
      | length evald >= maxCands = pure (evald, calls)
      | otherwise = do
          s <- scoreOn train metric (setNodeInstr idx c prog)
          scoreNew prog (calls + dsSize) (evald ++ [(c, s)]) cs

    -- Candidates not already scored, de-duplicated against each other (order-preserving).
    dedupNew evald = go (map fst evald)
      where
        go _ [] = []
        go seen (x : xs)
          | x `elem` seen = go seen xs
          | otherwise = x : go (x : seen) xs

    -- Set node idx to the highest-scoring instruction recorded (earliest on ties).
    setBest prog evald = case evald of
      [] -> prog
      (e : es) -> setNodeInstr idx (fst (foldl' pick e es)) prog
      where
        pick best c = if snd c > snd best then c else best