packages feed

dataframe-learn-2.0.0.0: src-internal/DataFrame/DecisionTree/Pool.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}

{- | Candidate-pool scoring and boolean expansion: penalized scoring, diverse
top-K selection, AND/OR saturation, and structural/truth-vector dedup. The
per-node scoring scans run in parallel chunks.
-}
module DataFrame.DecisionTree.Pool (
    evalWithPenaltyVec,
    primaryColExpr,
    primaryColCV,
    takeDiverse,
    candidateParChunk,
    bestDiscreteCandidate,
    boolExprsVec,
    DedupMode (..),
    saturateCandidates,
    roundProducts,
    admitKeys,
    admitVecs,
    dedupCVByExpr,
    nubByExpr,
) where

import DataFrame.DecisionTree.CondVec (
    CondVec (..),
    combineAndVec,
    combineOrVec,
    countErrorsByVec,
 )
import DataFrame.DecisionTree.Types (
    CarePoint,
    SynthConfig (..),
    TreeConfig (..),
 )
import DataFrame.Internal.Expression (
    Expr,
    compareExpr,
    eSize,
    eqExpr,
    getColumns,
    normalize,
 )

import Control.Parallel.Strategies (parListChunk, rdeepseq, using)
import Data.Function (on)
import Data.List (minimumBy, sortBy)
import qualified Data.Map.Strict as M
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Vector.Unboxed as VU

{- | Penalized score of a candidate: care-point errors plus a complexity
penalty, tie-broken by expression size.
-}
evalWithPenaltyVec :: TreeConfig -> [CarePoint] -> CondVec -> (Int, Int)
evalWithPenaltyVec cfg carePoints cv = (countErrorsByVec (cvVec cv) carePoints + penalty, sz)
  where
    sz = eSize (cvExpr cv)
    penalty = floor (complexityPenalty (synthConfig cfg) * fromIntegral sz)

{- | First referenced column of a condition (a sentinel for literal-only ones),
used by 'takeDiverse' to enforce per-column diversity.
-}
primaryColExpr :: Expr Bool -> T.Text
primaryColExpr e = case getColumns e of
    [] -> "<noncol>"
    (c : _) -> c

primaryColCV :: CondVec -> T.Text
primaryColCV = primaryColExpr . cvExpr

{- | Keep the first @k@ of an already-sorted list, admitting at most @quota@ per
primary column (@Nothing@ disables the per-column cap).
-}
takeDiverse :: Int -> Maybe Int -> (a -> T.Text) -> [a] -> [a]
takeDiverse k Nothing _ = take k
takeDiverse k (Just quota) primary = go M.empty 0
  where
    go !_ !_ [] = []
    go !seen !n (x : xs)
        | n >= k = []
        | M.findWithDefault 0 col seen >= quota = go seen n xs
        | otherwise = x : go (M.insertWith (+) col 1 seen) (n + 1) xs
      where
        !col = primary x

{- | Chunk size for the parallel per-node candidate scans; tuned by an -N
sweep, not correctness-affecting.
-}
candidateParChunk :: Int
candidateParChunk = 64

{- | Decorate candidates with their penalty in parallel chunks, forcing only
the @(Int, Int)@ key so the order (hence later sorts/minima) is preserved.
-}
decorate :: (CondVec -> (Int, Int)) -> [CondVec] -> [((Int, Int), CondVec)]
decorate penaltyCV xs = zip (map penaltyCV xs `using` parListChunk candidateParChunk rdeepseq) xs

-- | The diverse top-@expressionPairs@ valid candidates by penalty.
sortedTopK :: TreeConfig -> (CondVec -> (Int, Int)) -> [CondVec] -> [CondVec]
sortedTopK cfg penaltyCV validCondVecs =
    map
        snd
        ( takeDiverse
            (expressionPairs cfg)
            (perColumnQuota (synthConfig cfg))
            (primaryColCV . snd)
            sorted
        )
  where
    sorted = sortBy (compare `on` fst) (decorate penaltyCV validCondVecs)

-- | Lowest-penalty candidate after boolean saturation of the diverse top-K.
bestDiscreteCandidate ::
    TreeConfig -> (CondVec -> (Int, Int)) -> [CondVec] -> Maybe CondVec
bestDiscreteCandidate _ _ [] = Nothing
bestDiscreteCandidate cfg penaltyCV validCondVecs =
    case saturateCandidates
        Structural
        (boolExpansion (synthConfig cfg))
        (sortedTopK cfg penaltyCV validCondVecs) of
        [] -> Nothing
        xs -> Just (snd (minimumBy (compare `on` fst) (decorate penaltyCV xs)))

{- | AND/OR expansion of cached conditions to depth @maxDepth@ (each
combination is a single vector op, not an interpret).
-}
boolExprsVec :: [CondVec] -> [CondVec] -> Int -> Int -> [CondVec]
boolExprsVec baseExprs prevExprs depth maxDepth
    | depth == 0 =
        baseExprs ++ boolExprsVec baseExprs prevExprs (depth + 1) maxDepth
    | depth >= maxDepth = []
    | otherwise = combined ++ boolExprsVec baseExprs combined (depth + 1) maxDepth
  where
    combined = roundProducts prevExprs baseExprs

data DedupMode = Structural | TruthVector
    deriving (Eq, Show)

{- | Saturate the pool with AND/OR combinations, deduplicating structurally
(byte-identical, first occurrence kept) or by truth vector (opt-in).
-}
saturateCandidates :: DedupMode -> Int -> [CondVec] -> [CondVec]
saturateCandidates Structural maxDepth base = base' ++ go 1 base' seen0
  where
    (base', seen0) = admitKeys Set.empty base
    go !depth frontier seen
        | depth >= maxDepth || null frontier = []
        | otherwise =
            let (admitted, seen') = admitKeys seen (roundProducts frontier base)
             in admitted ++ go (depth + 1) admitted seen'
saturateCandidates TruthVector maxDepth base = M.elems (go 1 frontier0 reps0)
  where
    (reps0, frontier0) = admitVecs M.empty base
    go !depth frontier reps
        | depth >= maxDepth || null frontier = reps
        | otherwise =
            let (reps', admitted) = admitVecs reps (roundProducts frontier base)
             in go (depth + 1) admitted reps'

{- | One combination round: @frontier × base@ via AND then OR, skipping
self-pairs (mirrors 'boolExprsVec' for byte-identical structural output).
-}
roundProducts :: [CondVec] -> [CondVec] -> [CondVec]
roundProducts frontier base =
    [ c
    | e1 <- frontier
    , e2 <- base
    , not (eqExpr (cvExpr e1) (cvExpr e2))
    , c <- [combineAndVec e1 e2, combineOrVec e1 e2]
    ]

-- | Admit candidates with a not-yet-seen normalized form, preserving order.
admitKeys :: Set.Set String -> [CondVec] -> ([CondVec], Set.Set String)
admitKeys = go []
  where
    go acc seen [] = (reverse acc, seen)
    go acc !seen (c : cs)
        | structuralKey c `Set.member` seen = go acc seen cs
        | otherwise = go (c : acc) (Set.insert (structuralKey c) seen) cs

structuralKey :: CondVec -> String
structuralKey = show . normalize . cvExpr

{- | Admit candidates by distinct truth vector, keeping the smallest-expression
representative per vector.
-}
admitVecs ::
    M.Map (VU.Vector Bool) CondVec ->
    [CondVec] ->
    (M.Map (VU.Vector Bool) CondVec, [CondVec])
admitVecs = go []
  where
    go acc reps [] = (reps, reverse acc)
    go acc !reps (c : cs) = case M.lookup (cvVec c) reps of
        Nothing -> go (c : acc) (M.insert (cvVec c) c reps) cs
        Just r -> go acc (M.insert (cvVec c) (smaller r c) reps) cs

smaller :: CondVec -> CondVec -> CondVec
smaller a b = case compare (eSize (cvExpr a)) (eSize (cvExpr b)) of
    LT -> a
    GT -> b
    EQ -> if compareExpr (cvExpr a) (cvExpr b) /= GT then a else b

-- | Deduplicate 'CondVec's by normalized 'cvExpr', keeping the first.
dedupCVByExpr :: [CondVec] -> [CondVec]
dedupCVByExpr = go Set.empty
  where
    go _ [] = []
    go seen (cv : cvs)
        | structuralKey cv `Set.member` seen = go seen cvs
        | otherwise = cv : go (Set.insert (structuralKey cv) seen) cvs

-- | Deduplicate expressions by normalized form, keeping the first.
nubByExpr :: [Expr Bool] -> [Expr Bool]
nubByExpr = go Set.empty
  where
    go _ [] = []
    go seen (e : es)
        | k `Set.member` seen = go seen es
        | otherwise = e : go (Set.insert k seen) es
      where
        k = show (normalize e)