shikumi-optimize-0.2.1.0: src/Shikumi/Optimize/KNN.hs
-- | KNN few-shot demo selection (EP-23): pick, for each input, the training
-- examples whose inputs are most /semantically similar/ to it, and show those as the
-- demos. Two forms:
--
-- * 'knnFewShot' — the faithful run-time form: a single 'Embed' node that, per
-- input, embeds the input, ranks the training examples by cosine similarity, and
-- runs the student under the @k@ nearest as demos. Demos depend on the input.
-- * 'knnFewShotCentroid' — a compile-time fallback that bakes the @k@ examples
-- nearest the training centroid as a fixed demo set (a plain @Params@ artifact),
-- for callers who cannot run an embedder at execution time.
--
-- The embedder is injected as a /pure/ closure @Text -> Vector Double@ (the shape of
-- EP-15's pure @runEmbedding@ argument), not the @Embedding@ effect: an 'Embed'
-- body's row is fixed to @(LLM, Error ShikumiError)@, so it cannot call @embedText@;
-- all embedding-effect work happens at the caller, outside the node. The run-time
-- form carries no @Params@ (it serializes as an @Embed@ shape with an empty vector,
-- exactly like @react@).
module Shikumi.Optimize.KNN
( -- * Run-time form (the faithful analog)
knnFewShot,
knnDemos,
-- * Compile-time fallback
knnFewShotCentroid,
-- * Pure helpers (exposed so callers/tests can reproduce the ranking)
cosine,
nearestDemos,
centroid,
)
where
import Data.Aeson (ToJSON, toJSON)
import Data.List (sortBy)
import Data.Ord (Down (..), comparing)
import Data.Text (Text)
import Data.Vector (Vector)
import Data.Vector qualified as V
import Shikumi.Adapter (ToPrompt, toPrompt)
import Shikumi.Eval (Dataset, Example (..), datasetExamples)
import Shikumi.Optimize.LabeledFewShot (withDemos)
import Shikumi.Optimize.Search (freezeProgram)
import Shikumi.Optimize.Types (Optimizer (..))
import Shikumi.Program (Demo (..), Program, embed, runProgram)
-- | Cosine similarity of two vectors: @dot a b / (‖a‖·‖b‖)@, in @[-1, 1]@. A
-- zero-norm vector yields @0@ (no signal). Ranking by cosine puts the most similar
-- examples first.
cosine :: Vector Double -> Vector Double -> Double
cosine a b
| na == 0 || nb == 0 = 0
| otherwise = dot a b / (na * nb)
where
dot x y = V.sum (V.zipWith (*) x y)
na = sqrt (dot a a)
nb = sqrt (dot b b)
-- | The @k@ training examples whose input embeddings are nearest a query vector,
-- nearest first, as JSON demos. Ties break by original order (a stable sort).
nearestDemosByVec ::
(ToJSON i, ToJSON o, ToPrompt i) =>
(Text -> Vector Double) ->
Int ->
[Example i o] ->
Vector Double ->
[Demo]
nearestDemosByVec embedder k exs qv =
[Demo (toJSON i) (toJSON o) | (_, _, Example i o) <- take (max 0 k) sorted]
where
scored = [(cosine (embedder (toPrompt i)) qv, idx, ex) | (idx, ex@(Example i _)) <- zip [0 :: Int ..] exs]
sorted = sortBy (comparing (\(s, idx, _) -> (Down s, idx))) scored
-- | The @k@ training examples nearest a rendered query input, nearest first.
nearestDemos ::
(ToJSON i, ToJSON o, ToPrompt i) =>
(Text -> Vector Double) ->
Int ->
[Example i o] ->
Text ->
[Demo]
nearestDemos embedder k exs query = nearestDemosByVec embedder k exs (embedder query)
-- | The component-wise mean of a list of equal-length vectors (empty list → empty
-- vector).
centroid :: [Vector Double] -> Vector Double
centroid [] = V.empty
centroid vs = V.map (/ fromIntegral (length vs)) (foldl1 (V.zipWith (+)) vs)
-- | The run-time KNN node: for each input, attach the @k@ nearest training examples
-- as demos and run the student under them. A plain @Program i o@ (an 'Embed' node)
-- usable anywhere a Program is; it carries no @Params@ (like @react@).
knnDemos ::
(ToJSON i, ToJSON o, ToPrompt i) =>
(Text -> Vector Double) ->
Int ->
Dataset i o ->
Program i o ->
Program i o
knnDemos embedder k train student =
let exs = datasetExamples train
in embed $ \i -> runProgram (withDemos (nearestDemos embedder k exs (toPrompt i)) student) i
-- | Run-time KNN as an 'Optimizer': selection is by embedding geometry, not by
-- score, so it consults neither the metric nor the LM at optimize time and spends
-- zero optimizer LM calls. The result is a structure-changing @Embed@ wrapper
-- around the student. Its run-time selector closure is not persisted by
-- 'Shikumi.Compile.Serialize.encodeCompiled', so the saved state must be decoded
-- onto the same @knnDemos@ template; decode onto the plain student fails with a
-- shape mismatch. Use 'knnFewShotCentroid' when the desired artifact is only
-- serializable node parameters.
knnFewShot ::
(ToJSON i, ToJSON o, ToPrompt i) =>
(Text -> Vector Double) ->
Int ->
Optimizer i o
knnFewShot embedder k = Optimizer $ \train _metric student ->
pure (freezeProgram (knnDemos embedder k train student))
-- | Compile-time fallback: bake the @k@ training examples nearest the training
-- centroid as a fixed demo set on every node, and freeze. A plain @Params@ artifact,
-- no run-time embedder needed, and zero optimizer LM calls are spent.
knnFewShotCentroid ::
(ToJSON i, ToJSON o, ToPrompt i) =>
(Text -> Vector Double) ->
Int ->
Optimizer i o
knnFewShotCentroid embedder k = Optimizer $ \train _metric student ->
let exs = datasetExamples train
c = centroid [embedder (toPrompt i) | Example i _ <- exs]
ds = nearestDemosByVec embedder k exs c
in pure (freezeProgram (withDemos ds student))