packages feed

shikumi-optimize-0.2.0.0: src/Shikumi/Optimize/Pareto.hs

-- | The Pareto-frontier bookkeeping for GEPA (EP-22): a pure, effect-free module so
-- the frontier logic is trivially testable and reproducible. A 'Candidate' is a
-- program identified by its node-parameter vector, carrying its per-example score
-- vector and aggregate. Keeping the /frontier/ (rather than one global best)
-- preserves candidates that win on some examples even if not best on average — the
-- room reflective evolution needs to escape local optima.
module Shikumi.Optimize.Pareto
  ( Candidate (..),
    dominates,
    paretoFrontier,
    sampleParent,
  )
where

import Shikumi.Program (Params)

-- | A candidate program, identified by its @foldParams@-order node parameters, with
-- its per-example scores (dataset order) and their mean.
data Candidate = Candidate
  { params :: ![Params],
    perExample :: ![Double],
    aggregate :: !Double
  }
  deriving stock (Eq, Show)

-- | @a@ dominates @b@ when @a >= b@ on every example and @a > b@ on at least one.
-- Equal vectors are NOT domination, so equal candidates coexist on the frontier.
dominates :: Candidate -> Candidate -> Bool
dominates a b =
  let pa = perExample a
      pb = perExample b
   in length pa == length pb && and (zipWith (>=) pa pb) && or (zipWith (>) pa pb)

-- | The non-dominated subset, preserving input order (so the frontier is
-- reproducible). A candidate is kept iff no other candidate dominates it.
paretoFrontier :: [Candidate] -> [Candidate]
paretoFrontier cs = [c | c <- cs, not (any (`dominates` c) cs)]

-- | Deterministically sample a parent from the frontier, biased toward candidates
-- that achieve the best score on more examples (the "pareto" selection strategy).
-- Pure: takes an explicit 'Int' seed and returns the chosen candidate plus the next
-- seed, so the search threads RNG state with no IO. A simple LCG drives the choice.
sampleParent :: Int -> [Candidate] -> Maybe (Candidate, Int)
sampleParent _ [] = Nothing
sampleParent seed cands@(c0 : _) = Just (go r (zip cands weights), seed')
  where
    cols = length (perExample c0)
    colMax j = maximum [perExample c !! j | c <- cands]
    weightOf c = max 1 (length [() | j <- [0 .. cols - 1], (perExample c !! j) >= colMax j])
    weights = map weightOf cands
    total = max 1 (sum weights)
    seed' = lcg seed
    r = seed' `mod` total
    go _ [] = c0
    go k ((c, w) : rest) = if k < w then c else go (k - w) rest

-- | A small linear congruential generator (glibc constants), kept in-house so runs
-- are reproducible with no IO entropy. Returns a non-negative pseudo-random 'Int'.
lcg :: Int -> Int
lcg x = (1103515245 * abs x + 12345) `mod` 2147483648