packages feed

dataframe-operations-1.1.1.0: src/DataFrame/Operations/Inference.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ScopedTypeVariables #-}

{- | Single-pass sampled inference lattice (Round-2 S2). One walk over
the sampled cells maintains a candidate mask {Bool, Int, Double, Date}
cleared by attempting the WS-B byte parsers; the assumption is the
highest-priority surviving candidate. Shared by every reader (audit T1),
together with the Int -> Double prefix promotion that replaces the
full-column retry chain (audit T2).
-}
module DataFrame.Operations.Inference (
    DateFormat,
    ParsingAssumption (..),
    makeParsingAssumption,
    makeParsingAssumptionBytes,
    byteStringDateParser,
    parseTimeOpt,
    promoteIntColumn,
    promoteIntColumnIndexed,
    readIntStrict,
) where

import qualified Data.ByteString as BS
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed.Mutable as VUM

import Control.Monad.ST (runST)
import Data.Maybe (isJust)
import Data.Time (Day, defaultTimeLocale, parseTimeM)
import DataFrame.Internal.Column (Column (..), finalizeParseResult)
import DataFrame.Internal.Parsing (readByteStringDate, readInt)
import DataFrame.Internal.Parsing.Fast (
    parseBoolField,
    parseDateField,
    parseDoubleField,
    parseIntField,
 )

type DateFormat = String

data ParsingAssumption
    = BoolAssumption
    | IntAssumption
    | DoubleAssumption
    | DateAssumption
    | NoAssumption
    | TextAssumption
    deriving (Eq, Show)

parseTimeOpt :: DateFormat -> T.Text -> Maybe Day
parseTimeOpt dateFormat s =
    parseTimeM {- Accept leading/trailing whitespace -}
        True
        defaultTimeLocale
        dateFormat
        (T.unpack s)

{- | The default @%Y-%m-%d@ format takes the WS-B byte-level fast path;
custom formats keep the reference 'readByteStringDate' parser.
-}
byteStringDateParser :: DateFormat -> BS.ByteString -> Maybe Day
byteStringDateParser "%Y-%m-%d" = parseDateField
byteStringDateParser fmt = readByteStringDate fmt
{-# INLINE byteStringDateParser #-}

{- | 'DataFrame.Internal.Parsing.readInt' that rejects overflow instead of
wrapping. Fields of <= 18 chars cannot overflow and keep the Text-level
parse; longer (rare) fields take the exact byte-level parser, so an
overflowing cell demotes\/promotes instead of silently wrapping.
-}
readIntStrict :: T.Text -> Maybe Int
readIntStrict t
    | T.length t <= 18 = readInt t
    | otherwise = parseIntField (TE.encodeUtf8 t)
{-# INLINE readIntStrict #-}

{- | Candidate-mask priority, reproducing the documented fallback order:
an all-null sample makes no assumption; Int wins only when the Double
mask agrees (so mixed Int\/Double samples classify as Double).
-}
pickAssumption ::
    Bool -> Bool -> Bool -> Bool -> Bool -> ParsingAssumption
pickAssumption seen b i d dt
    | not seen = NoAssumption
    | b = BoolAssumption
    | i && d = IntAssumption
    | d = DoubleAssumption
    | dt = DateAssumption
    | otherwise = TextAssumption

{- | Classify a sample of decoded 'T.Text' cells ('Nothing' = null).
Bool\/Int\/Double candidates are tested with the WS-B byte parsers on
the UTF-8 bytes (strip-tolerant, overflow-rejecting); the Date
candidate keeps 'parseTimeOpt' so custom formats behave exactly as
before. The walk exits early once every candidate is cleared.
-}
makeParsingAssumption ::
    DateFormat -> V.Vector (Maybe T.Text) -> ParsingAssumption
makeParsingAssumption dfmt cells = go 0 False True True True True
  where
    n = V.length cells
    go !i !seen !b !int !d !dt
        | i >= n || (seen && not (b || int || d || dt)) =
            pickAssumption seen b int d dt
        | otherwise = case V.unsafeIndex cells i of
            Nothing -> go (i + 1) seen b int d dt
            Just t ->
                let bs = TE.encodeUtf8 t
                 in go
                        (i + 1)
                        True
                        (b && isJust (parseBoolField bs))
                        (int && isJust (parseIntField bs))
                        (d && isJust (parseDoubleField bs))
                        (dt && isJust (parseTimeOpt dfmt t))

-- | Byte-level twin of 'makeParsingAssumption' for raw field bytes.
makeParsingAssumptionBytes ::
    DateFormat -> V.Vector (Maybe BS.ByteString) -> ParsingAssumption
makeParsingAssumptionBytes dfmt cells = go 0 False True True True True
  where
    n = V.length cells
    dateP = byteStringDateParser dfmt
    go !i !seen !b !int !d !dt
        | i >= n || (seen && not (b || int || d || dt)) =
            pickAssumption seen b int d dt
        | otherwise = case V.unsafeIndex cells i of
            Nothing -> go (i + 1) seen b int d dt
            Just bs ->
                go
                    (i + 1)
                    True
                    (b && isJust (parseBoolField bs))
                    (int && isJust (parseIntField bs))
                    (d && isJust (parseDoubleField bs))
                    (dt && isJust (dateP bs))

{- | Fused Int pass with in-place promotion (audit T2): on the first
non-null cell that fails Int but parses as Double, the built Int prefix
is converted by a vector map and the pass continues as Double over the
retained raw cells. @Nothing@ = some cell parses as neither (the caller
demotes the column to Text). Null slots hold sentinels (0 \/ 0.0)
guarded by the bitmap, exactly like the unpromoted passes.
-}
promoteIntColumn ::
    forall src.
    (Int -> src -> Bool) ->
    (src -> Maybe Int) ->
    (src -> Maybe Double) ->
    V.Vector src ->
    Maybe Column
promoteIntColumn isNullAt parseI parseD vec =
    promoteIntColumnIndexed
        (V.length vec)
        (\i -> isNullAt i (V.unsafeIndex vec i))
        (parseI . V.unsafeIndex vec)
        (parseD . V.unsafeIndex vec)
{-# INLINE promoteIntColumn #-}

{- | 'promoteIntColumn' over row indices instead of a materialized cell
vector (used by readers that keep cells as buffer offsets).
-}
promoteIntColumnIndexed ::
    Int ->
    (Int -> Bool) ->
    (Int -> Maybe Int) ->
    (Int -> Maybe Double) ->
    Maybe Column
promoteIntColumnIndexed n isNullAt parseI parseD = runST $ do
    values <- VUM.unsafeNew n
    vmask <- VUM.unsafeNew n
    let intLoop !i !anyNull
            | i >= n =
                fmap (uncurry UnboxedColumn)
                    <$> finalizeParseResult values vmask anyNull
            | isNullAt i = do
                VUM.unsafeWrite vmask i 0
                VUM.unsafeWrite values i 0
                intLoop (i + 1) True
            | otherwise = case parseI i of
                Just v -> do
                    VUM.unsafeWrite vmask i 1
                    VUM.unsafeWrite values i v
                    intLoop (i + 1) anyNull
                Nothing -> case parseD i of
                    Just dv -> promote i anyNull dv
                    Nothing -> return Nothing

        -- Switch to Double at row i: convert the Int prefix, then
        -- continue parsing the remaining cells as Double.
        promote !i !anyNull !dv = do
            dvalues <- VUM.unsafeNew n
            let copy !j
                    | j >= i = return ()
                    | otherwise = do
                        x <- VUM.unsafeRead values j
                        VUM.unsafeWrite dvalues j (fromIntegral x)
                        copy (j + 1)
            copy 0
            VUM.unsafeWrite vmask i 1
            VUM.unsafeWrite dvalues i dv
            dblLoop dvalues (i + 1) anyNull

        dblLoop dvalues !i !anyNull
            | i >= n =
                fmap (uncurry UnboxedColumn)
                    <$> finalizeParseResult dvalues vmask anyNull
            | isNullAt i = do
                VUM.unsafeWrite vmask i 0
                VUM.unsafeWrite dvalues i 0
                dblLoop dvalues (i + 1) True
            | otherwise = case parseD i of
                Just dv -> do
                    VUM.unsafeWrite vmask i 1
                    VUM.unsafeWrite dvalues i dv
                    dblLoop dvalues (i + 1) anyNull
                Nothing -> return Nothing
    intLoop 0 False
{-# INLINE promoteIntColumnIndexed #-}