shikumi-optimize-0.3.1.0: src/Shikumi/Optimize/ReAnchor.hs
{-# LANGUAGE ScopedTypeVariables #-}
-- | Fit local thresholds, cuts and weights. All trials share run-local response
-- replay inside SearchSession admission; only new requests consume operations.
module Shikumi.Optimize.ReAnchor
( ReAnchorConfig (..),
defaultReAnchorConfig,
reAnchor,
reAnchorWith,
ReAnchorReport (..),
FieldCalibration (..),
ObservationSummary (..),
)
where
import Baikai (Response)
import Control.Monad (foldM, when)
import Data.Aeson (ToJSON, Value (..), object, (.=))
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.List (nub)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
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.IORef (Prim, newIORef, readIORef)
import GHC.Generics (Generic)
import Shikumi.Adapter (evidenceSchema, responseValue)
import Shikumi.Compile.Types (CompiledProgram (..))
import Shikumi.Decision
import Shikumi.Decision.Runtime (decodeDecisionEvidence, evidenceOf)
import Shikumi.Decision.Setting
import Shikumi.Effect.Time (Time)
import Shikumi.Error (ShikumiError (..))
import Shikumi.Eval (Dataset, FailurePolicy (..), Metric, datasetSize)
import Shikumi.LLM (LLM)
import Shikumi.Optimize.Execution
import Shikumi.Optimize.ReAnchor.Fit
import Shikumi.Optimize.ReAnchor.Replay
import Shikumi.Optimize.Report qualified as R
import Shikumi.Optimize.Types (ConfiguredOptimizer (..))
import Shikumi.Program
import Shikumi.Trace.Node (NodePath, programNodePaths)
-- | Validation is report-only. For a full search, use RunLimits with
-- candidateLimit = 2000; replayed candidates consume no operation budget.
data ReAnchorConfig i o = ReAnchorConfig
{validation :: !(Maybe (Dataset i o)), maxCandidatesPerStep :: !Int, foldCount :: !Int}
defaultReAnchorConfig :: ReAnchorConfig i o
defaultReAnchorConfig = ReAnchorConfig Nothing 40 5
data ObservationSummary = ObservationSummary
{calls :: !Int, distinctValues :: !Int, minimumValue :: !(Maybe Double), maximumValue :: !(Maybe Double), candidatesTried :: !Int}
deriving stock (Eq, Show, Generic)
deriving anyclass (ToJSON)
data FieldCalibration = FieldCalibration
{ nodeIndex :: !Int,
nodePath :: !Text,
field :: !Text,
parameter :: !Text,
fittedValue :: !(Maybe DecisionSetting),
skippedReason :: !(Maybe Text),
trainScoreOriginal :: !Double,
trainScoreAtStart :: !Double,
trainScoreRetained :: !Double,
observations :: !ObservationSummary,
foldChecksPassed :: !Int,
foldChecksFailed :: !Int
}
deriving stock (Eq, Show, Generic)
deriving anyclass (ToJSON)
data ReAnchorReport = ReAnchorReport
{trainScoreBefore :: !Double, trainScore :: !Double, valScoreBefore :: !(Maybe Double), valScore :: !(Maybe Double), fitted :: ![FieldCalibration]}
deriving stock (Eq, Show, Generic)
deriving anyclass (ToJSON)
reAnchor :: ReAnchorConfig i o -> ConfiguredOptimizer i o
reAnchor cfg = ConfiguredOptimizer $ \session ds metric program -> fst <$> reAnchorWith cfg session ds metric program
mean :: [Double] -> Double
mean [] = 0
mean xs = sum xs / fromIntegral (length xs)
reAnchorWith ::
forall es i o.
(LLM :> es, Concurrent :> es, Error ShikumiError :> es, Time :> es, Prim :> es) =>
ReAnchorConfig i o -> SearchSession es -> Dataset i o -> Metric o -> Program i o -> Eff es (CompiledProgram i o, ReAnchorReport)
reAnchorWith cfg session train metric original = do
when (datasetSize train == 0) (throwError (ValidationFailure "ReAnchor: empty training set"))
when (maxCandidatesPerStep cfg < 2 || foldCount cfg < 2) (throwError (ValidationFailure "ReAnchor: candidate and fold counts must be at least two"))
let targets = [(ix, path, j) | (ix, (path, js)) <- zip [0 ..] (zip (programNodePaths original) (nodeDecisionFieldsIndexed original)), j <- js]
when (null targets) (throwError (ValidationFailure "ReAnchor: program has no rich decision outputs"))
store <- newReplayStore
let score ds metadata program = do
reservation <- reserveCandidate session
case reservation of
Nothing -> pure Nothing
Just ident -> do
annotateCandidate session ident metadata
seen <- newIORef []
report <-
evaluateCandidate
session
ident
ds
( \input -> do
result <- runResponses store seen program input
pure (result, [])
)
(const FailAbort)
metric
R.qualityPolicy
(scalarObjectives metric)
responses <- readIORef seen
pure $
if R.candidateStatus report == R.CandidateCompleted
then Just (ident, map snd (R.exampleScores report), responses)
else Nothing
scoreTrain = score train
optionalValidation program = case validation cfg of
Nothing -> pure Nothing
Just ds
| datasetSize ds == 0 -> pure Nothing
| otherwise -> fmap (\(_, scores, _) -> mean scores) <$> score ds (Map.singleton "role" "validation-report-only") program
initial <- scoreTrain (Map.singleton "role" "baseline") original
valBefore <- optionalValidation original
case initial of
Nothing -> do
setSelectedCandidate session Nothing "ReAnchor: unscored baseline; budget exhausted"
pure (CompiledProgram original, ReAnchorReport 0 0 valBefore Nothing [])
Just (initialId, initialScores, _) -> do
let step (program, retainedId, retainedScores, rows) (ix, path, j) = do
before <- scoreTrain (metadata ix path j "original" Nothing) program
case before of
Nothing -> pure (program, retainedId, retainedScores, rows)
Just (beforeId, beforeScores, _) -> do
let saved = decisionSettingFor (judgmentField j) (foldParams program !! ix)
start = fromMaybe (defaultSetting j) saved
set setting = mapParamsAt ix (setDecisionSetting (judgmentField j) setting)
base <- scoreTrain (metadata ix path j "start" (Just start)) (set start program)
case base of
Nothing -> pure (program, retainedId, retainedScores, rows)
Just (baseId, baseScores, responses) -> do
evidence <- either throwError pure (traverse (readEvidence j . snd) (filter ((== path) . fst) responses))
let trials current scores ident candidates = do
tried <-
traverse
( \(setting, tie) -> do
measured <- scoreTrain (metadata ix path j "trial" (Just setting)) (set setting program)
pure (fmap (\(cid, sc, _) -> (sc, tie, (setting, cid))) measured)
)
candidates
let available = [x | Just x <- tried]
(kept, refused) = selectKept (foldCount cfg) scores available
pure $ case kept of
Just (sc, _, (setting, cid)) -> (setting, sc, cid, 1, 0, length available)
Nothing -> (current, scores, ident, 0, if refused then 1 else 0, length available)
fitNoul = case start of
NoulSetting t -> trials start baseScores baseId [(NoulSetting c, tie) | (c, tie) <- thresholdCandidates (maxCandidatesPerStep cfg) [p | FieldNoul (NoulEvidence p) <- evidence] t]
_ -> pure (start, baseScores, baseId, 0, 0, 0)
fitCuts = case start of
ScoreSetting cs ->
foldM
( \(current, sc, cid, passed, failed, count) k -> case current of
ScoreSetting values -> do
let lo = if k == 0 then 0 else values !! (k - 1)
hi = if k + 1 == length values then fromIntegral (length values) else values !! (k + 1)
means = [v | FieldScore e <- evidence, Right (v, _) <- [deriveScore values e]]
candidates = [(ScoreSetting (take k values ++ [c] ++ drop (k + 1) values), tie) | (c, tie) <- cutCandidates (maxCandidatesPerStep cfg) means lo hi (values !! k)]
(next, sc', cid', p, f, n) <- trials current sc cid candidates
pure (next, sc', cid', passed + p, failed + f, count + n)
_ -> pure (current, sc, cid, passed, failed, count)
)
(start, baseScores, baseId, 0, 0, 0)
[0 .. length cs - 1]
_ -> pure (start, baseScores, baseId, 0, 0, 0)
fitWeights labels =
foldM
( \(current, sc, cid, passed, failed, count) label -> case current of
ChoiceSetting ws -> do
let weight l = Map.findWithDefault 1 l ws
flips = [rival / p | FieldChoice (ChoiceEvidence ps _) <- evidence, let p = Map.findWithDefault 0 label ps, let rival = maximum (0 : [weight l * Map.findWithDefault 0 l ps | l <- labels, l /= label]), p > 0, rival > 0]
candidates = [(ChoiceSetting (Map.insert label c ws), tie) | (c, tie) <- weightCandidates (maxCandidatesPerStep cfg) flips (weight label)]
(next, sc', cid', p, f, n) <- trials current sc cid candidates
pure (next, sc', cid', passed + p, failed + f, count + n)
_ -> pure (current, sc, cid, passed, failed, count)
)
(start, baseScores, baseId, 0, 0, 0)
labels
(setting, scores, ident, passed, failed, count) <- case judgmentKind j of
JudgeBoolean {} -> fitNoul
JudgeOrdered {} -> fitCuts
JudgeChoice labels -> fitWeights (map fst labels)
let (accepted, refused) = selectKept (foldCount cfg) beforeScores [(scores, [], ())]
keep = case accepted of Just _ -> True; _ -> False
chosen = if keep then set setting program else program
values = concatMap observedValues evidence
summary = ObservationSummary (length evidence) (length (nub values)) (safeMinimum values) (safeMaximum values) count
row = FieldCalibration ix (T.pack (show path)) (judgmentField j) (kindName j) (if keep then Just setting else Nothing) (if keep then Nothing else Just "fitted behaviour did not beat the original") (mean beforeScores) (mean baseScores) (mean (if keep then scores else beforeScores)) summary (passed + if keep then 1 else 0) (failed + if refused then 1 else 0)
pure (chosen, if keep then ident else beforeId, if keep then scores else beforeScores, rows ++ [row])
(final, selected, scores, rows) <- foldM step (original, initialId, initialScores, []) targets
valAfter <- optionalValidation final
setSelection session "ReAnchor: strict improvement with held-out fold check" R.qualityPolicy
setSelectedCandidate session (Just selected) "ReAnchor: retained settings passed strict improvement and held-out folds"
pure (CompiledProgram final, ReAnchorReport (mean initialScores) (mean scores) valBefore valAfter rows)
where
metadata :: Int -> NodePath -> Judgment -> Text -> Maybe DecisionSetting -> Map.Map Text Text
metadata ix path j role setting = Map.fromList [("node", T.pack (show ix)), ("path", T.pack (show path)), ("field", judgmentField j), ("parameter", kindName j), ("role", role), ("value", T.pack (show setting))]
kindName j = case judgmentKind j of JudgeBoolean {} -> "threshold"; JudgeOrdered {} -> "cuts"; JudgeChoice {} -> "weights"
observedValues = \case FieldNoul (NoulEvidence p) -> [p]; FieldChoice (ChoiceEvidence ps _) -> Map.elems ps; FieldScore e@(ScoreEvidence _ _) -> case deriveScore [] e of Right (v, _) -> [v]; _ -> []
safeMinimum [] = Nothing
safeMinimum xs = Just (minimum xs)
safeMaximum [] = Nothing
safeMaximum xs = Just (maximum xs)
readEvidence :: Judgment -> Response -> Either ShikumiError FieldEvidence
readEvidence j response = case evidenceOf [j] response of
Just result -> result >>= \fields -> maybe (Left (MissingField (judgmentField j))) Right (Map.lookup (judgmentField j) fields)
Nothing -> do
value <- responseValue (evidenceSchema (object ["properties" .= object []]) [j]) response
raw <- case value of
Object fields -> maybe (Left (MissingField (judgmentField j))) Right (KM.lookup (K.fromText (judgmentField j)) fields)
_ -> Left (SchemaMismatch "ReAnchor: expected evidence object")
decodeDecisionEvidence j raw