packages feed

shikumi-optimize-0.3.0.0: src/Shikumi/Optimize/GEPA.hs

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Reflective evolution with failure-aware, node-grounded evidence. Legacy
-- callbacks produce explicitly program-scoped critiques, never node attribution.
module Shikumi.Optimize.GEPA
  ( FeedbackMetric,
    ReflectIn (..),
    ReflectOut (..),
    reflectiveProposer,
    captureFeedback,
    mutateNode,
    gepa,
    gepaWith,
    GEPAConfig (..),
    ObjectiveCallback (..),
    defaultGEPAConfig,
    gepaWithFeedback,
    FeedbackCallback (..),
    mutateFromEvidence,
  )
where

import Control.Monad (forM, forM_, when)
import Data.Either (isRight)
import Data.List (find, findIndex, sortOn)
import Data.Maybe (catMaybes)
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, (:>))
import Effectful.Concurrent (Concurrent)
import Effectful.Error.Static (Error, throwError)
import Effectful.Prim (Prim)
import GHC.Generics (Generic)
import Shikumi.Adapter (ToPrompt)
import Shikumi.Effect.Time (Time)
import Shikumi.Error (ShikumiError (..))
import Shikumi.Eval
  ( Dataset,
    dataset,
    datasetExamples,
    datasetSize,
    unScore,
  )
import Shikumi.Eval.Evaluate (tryShikumi)
import Shikumi.LLM (LLM)
import Shikumi.Module (predict)
import Shikumi.Optimize.Execution qualified as X
import Shikumi.Optimize.Feedback
import Shikumi.Optimize.Pareto (Candidate (..), paretoFrontier, sampleParent)
import Shikumi.Optimize.Report qualified as R
import Shikumi.Optimize.Search (effectiveInstructionAt, freezeProgram, newBudgetMeter, scoringCost, setNodeInstrIfNew, tryCharge)
import Shikumi.Optimize.Types (Budget (..), ConfiguredOptimizer (..), Optimizer (..))
import Shikumi.Program
  ( NodeFields (..),
    Program,
    foldParams,
    nodeFieldsIndexed,
    runProgram,
    setProgramParams,
  )
import Shikumi.Schema (FromModel, ToSchema, Validatable)
import Shikumi.Signature (mkSignature)
import Shikumi.Trace.Feedback (FeedbackLog, attachFeedback, feedbackFor, runFeedback)
import Shikumi.Trace.Node (NodePath (..), programNodePaths)
import Shikumi.Trace.Observation (NodeObservation (..), runProgramObserved)

-- ---------------------------------------------------------------------------
-- The reflective proposer
-- ---------------------------------------------------------------------------

-- | The reflective proposer's input: the node's current instruction, its accumulated
-- critiques, program/dataset summaries, and the node's field names.
data ReflectIn = ReflectIn
  { currentInstruction :: !Text,
    feedback :: !Text,
    programSummary :: !Text,
    datasetSummary :: !Text,
    fieldSummary :: !Text
  }
  deriving stock (Generic, Show)

instance FromModel ReflectIn

instance ToPrompt ReflectIn

newtype ReflectOut = ReflectOut {proposedInstruction :: Text}
  deriving stock (Generic, Show)

instance ToSchema ReflectOut

instance FromModel ReflectOut

instance ToPrompt ReflectOut

instance Validatable ReflectOut

-- | The default reflective proposer: a single predict node that addresses the
-- feedback specifically.
reflectiveProposer :: Program ReflectIn ReflectOut
reflectiveProposer =
  predict
    ( mkSignature
        "You are improving the instruction for one node of a language-model pipeline. You are \
        \given the node's current instruction, textual feedback describing how it failed on \
        \several examples, a summary of the whole program, a summary of the dataset, and the \
        \node's input/output field names. Write a single improved instruction that addresses the \
        \feedback specifically, in the `proposedInstruction` field."
    )

-- ---------------------------------------------------------------------------
-- M1 — feedback capture
-- ---------------------------------------------------------------------------

-- | Compatibility projection. Program critique is stored once at the root key,
-- labeled with its provenance; use 'captureEvidence' to retain full attribution.
captureFeedback ::
  (LLM :> es, Error ShikumiError :> es, Prim :> es) =>
  Dataset i o -> FeedbackMetric o -> Program i o -> Eff es (FeedbackLog, [Double])
captureFeedback ds fm prog = do
  captured <- captureEvidence defaultFeedbackConfig ds (legacyFeedback fm) prog
  (_, logbook) <- runFeedback $ forM_ captured $ \(_, fb, _) ->
    forM_ (programCritique fb) $ \(source, t) ->
      when (not (T.null t)) (attachFeedback (NodePath []) ("program (" <> tshow source <> "): " <> t))
  pure (logbook, [unScore (overallScore fb) | (_, fb, _) <- captured])

-- ---------------------------------------------------------------------------
-- M2 — reflective mutation
-- ---------------------------------------------------------------------------

-- | Reflect on node @idx@'s accumulated feedback and overwrite its instruction with
-- the proposal. A node with no feedback is left unchanged (nothing to reflect on).
mutateNode ::
  (LLM :> es, Error ShikumiError :> es) =>
  Program ReflectIn ReflectOut ->
  -- | program summary
  Text ->
  -- | dataset summary
  Text ->
  [NodeFields] ->
  FeedbackLog ->
  [NodePath] ->
  Int ->
  Program i o ->
  Eff es (Program i o)
mutateNode proposer progSummary dataSummary fields fblog paths idx prog =
  case drop idx paths of
    [] -> pure prog
    (path : _) ->
      let crits = feedbackFor path fblog
       in if null crits
            then pure prog
            else do
              let cur = effectiveInstructionAt idx prog
                  fldSummary = renderFields (drop idx fields)
                  fb = T.intercalate "\n" crits
              ReflectOut newInstr <-
                runProgram proposer (ReflectIn cur fb progSummary dataSummary fldSummary)
              pure (setNodeInstrIfNew idx newInstr prog)

-- | Render a node's field names for the proposer prompt.
renderFields :: [NodeFields] -> Text
renderFields [] = "inputs: ?; outputs: ?"
renderFields (NodeFields ins outs : _) =
  "inputs: " <> commas ins <> "; outputs: " <> commas outs
  where
    commas xs = if null xs then "?" else T.intercalate ", " xs

-- ---------------------------------------------------------------------------
-- M3 — the evolution loop
-- ---------------------------------------------------------------------------

-- | The reflective evolutionary optimizer. Takes its reflective proposer and feedback
-- metric explicitly (so it is testable under a stub LM) and returns V1's
-- 'Optimizer'. GEPA gates its seed evaluation before any LM call; if the budget is
-- too small to score the student once, it returns the student unscored. Each
-- evolution step reserves a conservative full-step cost before capture, reflection,
-- and child scoring.
gepa ::
  Program ReflectIn ReflectOut ->
  FeedbackMetric o ->
  Budget ->
  Optimizer i o
gepa proposer fbMetric budget = Optimizer $ \train metric student -> do
  -- Preserve the legacy all-or-nothing predicted seed gate.
  if datasetSize train == 0 || maxLmCalls budget < scoringCost train student || maxCandidates budget <= 0
    then pure (freezeProgram student)
    else do
      let controls = X.defaultRunConfig {X.runLimits = X.RunLimits (max 0 (maxLmCalls budget)) (max 0 (maxCandidates budget)) 1 1}
          cfg =
            (defaultGEPAConfig (FeedbackCallback (legacyFeedback fbMetric)))
              { feedbackConfig = defaultFeedbackConfig {includeProgramCritique = True},
                minibatchSize = datasetSize train
              }
      (result, _) <- X.runSearchSession controls $ \session -> runConfiguredOptimizer (gepaWith cfg proposer) session train metric student
      either throwError pure result

-- | An effectful callback portable across the optimizer's existing effect row.
newtype FeedbackCallback o = FeedbackCallback
  { runFeedbackCallback ::
      forall es.
      (LLM :> es, Concurrent :> es, Error ShikumiError :> es, Time :> es, Prim :> es) =>
      EvidenceMetric es o
  }

gepaWithFeedback :: FeedbackConfig -> Program ReflectIn ReflectOut -> FeedbackCallback o -> Budget -> Optimizer i o
gepaWithFeedback cfg proposer callback budget = Optimizer $ \train metric student -> do
  either throwError pure (validateFeedbackConfig cfg)
  meter <- newBudgetMeter budget
  let paths = programNodePaths student
      progSummary = fallbackProgramSummary (length paths)
      dataSummary = fallbackDatasetSummary (datasetSize train)
      maxCands = maxCandidates budget
      rebuild cand = either (const student) id (setProgramParams (params cand) student)
      seedCost = scoringCost train student

  seedFits <- tryCharge meter seedCost
  if not seedFits
    then pure (freezeProgram student)
    else do
      seedRows <- captureEvidence cfg train (legacyFeedback (\e p -> (metric e p, ""))) student
      let candidateFrom prog rows =
            let scores = [unScore (overallScore fb) | (_, fb, _) <- rows]
             in Candidate (foldParams prog) scores (if null scores then 0 else sum scores / fromIntegral (length scores))
          seedCand = candidateFrom student seedRows

          -- A full step costs: capture + child evaluation over the whole dataset,
          -- plus one reflective proposer call.
          stepCost = 2 * seedCost + 1
          stepCap = maxCands + 4

          loop step cands seed frontier
            | step >= stepCap = pure (bestOf seedCand frontier)
            | length cands >= maxCands = pure (bestOf seedCand frontier)
            | otherwise = do
                fitsStep <- tryCharge meter stepCost
                if not fitsStep
                  then pure (bestOf seedCand frontier)
                  else case sampleParent seed (paretoFrontier frontier) of
                    Nothing -> pure (bestOf seedCand frontier)
                    Just (parent, seed') -> do
                      let parentProg = rebuild parent
                      captured <- captureEvidence cfg train (runFeedbackCallback callback) parentProg
                      child <- mutateFromEvidence cfg proposer progSummary dataSummary captured step parentProg
                      if foldParams child == foldParams parentProg
                        then loop (step + 1) cands seed' frontier
                        else do
                          rows <- captureEvidence cfg train (legacyFeedback (\e p -> (metric e p, ""))) child
                          let childCand = candidateFrom child rows
                              frontier' = paretoFrontier (childCand : frontier)
                          loop (step + 1) (childCand : cands) seed' frontier'

      best <- loop 0 [seedCand] 1 [seedCand]
      pure (freezeProgram (rebuild best))

-- | Reflect only on executed nodes with attributed critiques (or explicitly
-- enabled program fallback). Redaction covers all evidence, errors and critiques
-- before it reaches the proposer. Rejected retries remain labeled evidence.
mutateFromEvidence ::
  (LLM :> es, Error ShikumiError :> es) =>
  FeedbackConfig ->
  Program ReflectIn ReflectOut ->
  Text ->
  Text ->
  [(EvaluationEvidence o, FeedbackResult, a)] ->
  Int ->
  Program i o ->
  Eff es (Program i o)
mutateFromEvidence cfg proposer progSummary dataSummary rows step prog = do
  either throwError pure (validateFeedbackConfig cfg)
  validated <- mapM (\(ev, fb, _) -> (ev,) <$> either throwError pure (validateFeedback cfg paths ev fb)) rows
  let relevant path ev fb =
        [ obs
        | obs <- observations ev,
          observationPath obs == path,
          not (observationOpaque obs),
          any (\f -> feedbackPath f == path && not (T.null (critique f)) && maybe True (== observationInvocation obs) (feedbackInvocation f)) (nodeCritiques fb)
            || (includeProgramCritique cfg && maybe False (not . T.null . snd) (programCritique fb))
        ]
      evidence path = [(ev, fb, obs) | (ev, fb) <- validated, obs <- relevant path ev fb]
      eligible = [p | p <- paths, not (null (evidence p))]
  case eligible of
    [] -> pure prog
    _ | reflectionExamples cfg == 0 || reflectionCharacters cfg == 0 -> pure prog
    _ -> do
      let path = eligible !! (max 0 step `mod` length eligible)
          local = sortOn (\(_, _, obs) -> (isRight (observationStatus obs), null (observationRejectedBy obs))) (evidence path)
          chosen = take (reflectionExamples cfg) local
          render (ev, fb, obs) =
            "example "
              <> tshow (exampleIndex ev)
              <> "; invocation "
              <> tshow (observationInvocation obs)
              <> "; status: "
              <> tshow (observationStatus obs)
              <> "; rejected scopes: "
              <> tshow (observationRejectedBy obs)
              <> "\nnode critiques: "
              <> T.intercalate
                "\n"
                [ tshow (provenance f) <> ": " <> critique f
                | f <- nodeCritiques fb,
                  feedbackPath f == path,
                  maybe True (== observationInvocation obs) (feedbackInvocation f)
                ]
              <> (if includeProgramCritique cfg then "\nprogram critique: " <> maybe "" tshow (programCritique fb) else "")
              <> "\ninput: "
              <> maybe (tshow (observationInputFields obs)) tshow (observationInput obs)
              <> "\noutput: "
              <> maybe (tshow (observationOutputFields obs)) tshow (observationOutput obs)
          payload =
            T.intercalate "\n\n" (map render chosen)
              <> if length chosen < length local then "\n[examples truncated]" else ""
          clean = boundText (reflectionCharacters cfg) . redactEvidence cfg
      case findIndex (== path) paths of
        Nothing -> pure prog
        Just idx -> do
          ReflectOut newInstruction <-
            runProgram
              proposer
              ( ReflectIn
                  (clean (effectiveInstructionAt idx prog))
                  (clean payload)
                  (clean progSummary)
                  (clean dataSummary)
                  (clean (renderFields (drop idx (nodeFieldsIndexed prog))))
              )
          pure (setNodeInstrIfNew idx newInstruction prog)
  where
    paths = programNodePaths prog

-- | The frontier candidate with the highest aggregate (earliest on ties); falls back
-- to the seed if the frontier is somehow empty.
bestOf :: Candidate -> [Candidate] -> Candidate
bestOf seedCand = foldl' (\b c -> if aggregate c > aggregate b then c else b) seedCand

-- | A minimal program summary (EP-19's program describer is the richer source).
fallbackProgramSummary :: Int -> Text
fallbackProgramSummary k = "A language-model program with " <> tshow k <> " predict node(s)."

-- | A minimal dataset summary (EP-19's dataset summarizer is the richer source).
fallbackDatasetSummary :: Int -> Text
fallbackDatasetSummary k = "A dataset of " <> tshow k <> " example(s)."

tshow :: (Show a) => a -> Text
tshow = T.pack . show

-- | Callbacks are trusted code. The framework sends only training evidence to
-- reflection; it is not a security sandbox around caller closures.
data GEPAConfig i o = GEPAConfig
  { validationDataset :: !(Maybe (Dataset i o)),
    feedbackConfig :: !FeedbackConfig,
    feedbackCallback :: !(FeedbackCallback o),
    objectivePolicy :: !R.ObjectivePolicy,
    objectiveCallback :: !(Maybe (ObjectiveCallback o)),
    minibatchSize :: !Int,
    childrenPerGeneration :: !Int
  }

newtype ObjectiveCallback o = ObjectiveCallback
  { runObjectiveCallback ::
      forall es.
      (LLM :> es, Concurrent :> es, Error ShikumiError :> es, Time :> es, Prim :> es) =>
      X.ObjectiveMetric es o
  }

defaultGEPAConfig :: FeedbackCallback o -> GEPAConfig i o
defaultGEPAConfig callback = GEPAConfig Nothing defaultFeedbackConfig callback R.qualityPolicy Nothing 4 1

-- | Configured reflective evolution, with full validation before frontier entry.
-- Children are proposed serially from a generation snapshot, then scored in
-- bounded batches. Width one retains adaptive single-child evolution.
gepaWith :: GEPAConfig i o -> Program ReflectIn ReflectOut -> ConfiguredOptimizer i o
gepaWith cfg proposer = ConfiguredOptimizer $ \session train metric student -> do
  either throwError pure (validateFeedbackConfig (feedbackConfig cfg))
  either (throwError . ValidationFailure) pure (R.validateObjectives (objectivePolicy cfg))
  when (datasetSize train == 0 || maybe False ((== 0) . datasetSize) (validationDataset cfg)) $
    throwError (ValidationFailure "GEPA training and explicit validation datasets must be nonempty")
  when (minibatchSize cfg <= 0 || childrenPerGeneration cfg <= 0) $
    throwError (ValidationFailure "GEPA minibatch and generation sizes must be positive")
  let validation = maybe train id (validationDataset cfg)
      mode = maybe "training-as-validation compatibility" (const "explicit validation") (validationDataset cfg)
      policy = objectivePolicy cfg
      minibatch = dataset (take (minibatchSize cfg) (datasetExamples train))
      objectivesFor expected measured = case objectiveCallback cfg of
        Nothing -> X.scalarObjectives metric expected measured
        Just callback -> runObjectiveCallback callback expected measured
      evaluate ident prog = do
        X.addPredictedWork session (scoringCost validation prog)
        report <-
          X.evaluateCandidate
            session
            ident
            validation
            (runProgramObserved prog)
            (failureClassification (feedbackConfig cfg))
            metric
            policy
            objectivesFor
        pure (report, prog)
      best completed = case R.selectObjectiveWinner policy (map fst completed) of
        Nothing -> student
        Just winner -> maybe student snd (find (\(r, _) -> R.candidateId r == R.candidateId winner) completed)
      propose step parent = do
        X.addPredictedWork session (2 * scoringCost minibatch parent + 1)
        captured <- captureEvidence (feedbackConfig cfg) minibatch (runFeedbackCallback (feedbackCallback cfg)) parent
        child <-
          mutateFromEvidence
            (feedbackConfig cfg)
            proposer
            (fallbackProgramSummary (length (programNodePaths student)))
            (fallbackDatasetSummary (datasetSize train))
            captured
            step
            parent
        -- The screen verifies training execution/feedback before expensive full
        -- validation, without rejecting a child solely for lower training quality.
        screened <- captureEvidence (feedbackConfig cfg) minibatch (legacyFeedback (\e p -> (metric e p, ""))) child
        when (all (\(ev, _, _) -> not (isRight (executionResult ev))) screened) $
          throwError (ValidationFailure "GEPA training screen has no successful executions")
        pure child
      loop step completed = do
        halted <- X.sessionStopped session
        remaining <- X.remainingCandidates session
        if halted || remaining == 0
          then do
            when (remaining == 0) (X.reserveCandidate session >> pure ())
            pure completed
          else do
            let front = R.objectiveFrontier policy (map fst completed)
                parents = [p | (r, p) <- completed, R.candidateId r `elem` map R.candidateId front]
                parent = if null parents then best completed else parents !! ((X.deterministicSeed (X.sessionLimits session) + step) `mod` length parents)
            proposals <- tryShikumi $ forM [0 .. min remaining (childrenPerGeneration cfg) - 1] $ \offset -> do
              ident <- X.reserveCandidate session
              case ident of
                Nothing -> pure Nothing
                Just ix -> do
                  result <- tryShikumi (propose (step + offset) parent)
                  case result of
                    Right child -> pure (Just (ix, child))
                    Left e -> do
                      -- Close a reserved proposal as an incomplete/failed candidate
                      -- through the same generic lifecycle boundary.
                      _ <-
                        X.evaluateCandidate
                          session
                          ix
                          validation
                          (\_ -> throwError e)
                          (failureClassification (feedbackConfig cfg))
                          metric
                          policy
                          objectivesFor
                      pure Nothing
            case proposals of
              Left e -> do
                haltedNow <- X.sessionStopped session
                if haltedNow && e == BudgetExceeded "optimizer operation admission exhausted" then pure completed else throwError e
              Right pending -> do
                results <- X.evaluateCandidates session (uncurry evaluate) (catMaybes pending)
                let completed' = completed ++ results
                X.setSelection session mode policy
                loop (step + childrenPerGeneration cfg) completed'
  X.setSelection session mode policy
  seedId <- X.reserveCandidate session
  completed <- case seedId of
    Nothing -> pure []
    Just ident -> do
      seed <- evaluate ident student
      loop 0 [seed]
  X.setSelection session mode policy
  pure (freezeProgram (best completed))