packages feed

dataframe-operations-2.0.0.0: src/DataFrame/Operations/Typing.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

-- The inference lattice (sample classification, 'ParsingAssumption',
-- Int -> Double promotion) lives in "DataFrame.Operations.Inference"
-- and is re-exported here for backwards compatibility.
module DataFrame.Operations.Typing (
    module DataFrame.Operations.Typing,
    module DataFrame.Operations.Inference,
) where

import qualified Data.Map as M
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Mutable as VM
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM

import Control.Applicative (asum)
import Control.Monad (join)
import Control.Monad.ST (runST)
import Data.Maybe (fromMaybe)
import qualified Data.Proxy as P
import Data.Time
import Data.Type.Equality (TestEquality (..))
import DataFrame.Internal.Column (
    Bitmap,
    Column (..),
    Columnable,
    bitmapTestBit,
    ensureOptional,
    finalizeParseResult,
    fromVector,
    materializePacked,
 )
import DataFrame.Internal.DataFrame (
    DataFrame (..),
    insertColumn,
    unsafeGetColumn,
 )
import DataFrame.Internal.Parsing
import DataFrame.Operations.Core ()
import DataFrame.Operations.Inference
import DataFrame.Schema
import Text.Read
import Type.Reflection

{- | How parse failures are surfaced: 'NoSafeRead' throws, 'MaybeRead' yields
@Nothing@ (column wrapped @Maybe a@), 'EitherRead' yields @Left rawText@
(column wrapped @Either Text a@, preserving the original input).
-}
data SafeReadMode
    = NoSafeRead
    | MaybeRead
    | EitherRead
    deriving (Eq, Show, Read)

-- | Options controlling how text columns are parsed into typed values.
data ParseOptions = ParseOptions
    { missingValues :: [T.Text]
    -- ^ Values to treat as @Nothing@ when the effective mode is 'MaybeRead'.
    , sampleSize :: Int
    -- ^ Number of rows to inspect when inferring a column's type (0 = all rows).
    , parseSafe :: SafeReadMode
    -- ^ Default 'SafeReadMode' for columns without a 'parseSafeOverrides' entry.
    , parseSafeOverrides :: [(T.Text, SafeReadMode)]
    {- ^ Per-column overrides taking precedence over 'parseSafe' — e.g. strict
    IDs (@NoSafeRead@) alongside lenient fields (@MaybeRead@/@EitherRead@).
    -}
    , parseDateFormat :: DateFormat
    -- ^ Date format string as accepted by "Data.Time.Format" (e.g. @\"%Y-%m-%d\"@).
    }

{- | Sensible out-of-the-box parse options: infer from the first 100 rows,
  treat common nullish strings as missing, and expect ISO 8601 dates.
-}
defaultParseOptions :: ParseOptions
defaultParseOptions =
    ParseOptions
        { missingValues = []
        , sampleSize = 100
        , parseSafe = MaybeRead
        , parseSafeOverrides = []
        , parseDateFormat = "%Y-%m-%d"
        }

{- | Resolve a column's effective 'SafeReadMode': the override if present,
otherwise the default.
-}
effectiveSafeRead ::
    SafeReadMode -> [(T.Text, SafeReadMode)] -> T.Text -> SafeReadMode
effectiveSafeRead def overrides name = fromMaybe def (lookup name overrides)

parseDefaults :: ParseOptions -> DataFrame -> DataFrame
parseDefaults opts df = df{columns = V.imap forCol (columns df)}
  where
    nameAt =
        let inverted = M.fromList [(i, n) | (n, i) <- M.toList (columnIndices df)]
         in \i -> M.findWithDefault "" i inverted
    forCol i col =
        let mode =
                effectiveSafeRead
                    (parseSafe opts)
                    (parseSafeOverrides opts)
                    (nameAt i)
         in parseDefault opts{parseSafe = mode, parseSafeOverrides = []} col

parseDefault :: ParseOptions -> Column -> Column
parseDefault opts (BoxedColumn Nothing (c :: V.Vector a)) =
    case (typeRep @a) `testEquality` (typeRep @T.Text) of
        Nothing -> case (typeRep @a) `testEquality` (typeRep @String) of
            Just Refl -> parseFromExamples opts (V.map T.pack c)
            Nothing -> BoxedColumn Nothing c
        Just Refl -> parseFromExamples opts c
parseDefault opts (BoxedColumn (Just bm) (c :: V.Vector a)) =
    case (typeRep @a) `testEquality` (typeRep @T.Text) of
        Nothing -> case (typeRep @a) `testEquality` (typeRep @String) of
            Just Refl ->
                parseFromExamples
                    opts
                    (V.imap (\i x -> if bitmapTestBit bm i then T.pack x else "") c)
            Nothing -> BoxedColumn (Just bm) c
        Just Refl ->
            parseFromExamples opts (V.imap (\i x -> if bitmapTestBit bm i then x else "") c)
parseDefault _ column = column

parseFromExamples :: ParseOptions -> V.Vector T.Text -> Column
parseFromExamples opts cols =
    let isNull = case parseSafe opts of
            NoSafeRead -> T.null
            _ -> isNullishOrMissing (missingValues opts)
        examples = V.map (classify isNull) (V.take (sampleSize opts) cols)
        dfmt = parseDateFormat opts
        assumption = makeParsingAssumption dfmt examples
     in case parseSafe opts of
            EitherRead -> handleEitherAssumption dfmt assumption cols
            mode ->
                let result = case assumption of
                        BoolAssumption -> handleBoolAssumption isNull cols
                        IntAssumption -> handleIntAssumption isNull cols
                        DoubleAssumption -> handleDoubleAssumption isNull cols
                        TextAssumption -> handleTextAssumption isNull cols
                        DateAssumption -> handleDateAssumption dfmt isNull cols
                        NoAssumption -> handleNoAssumption dfmt isNull cols
                 in if mode == MaybeRead then ensureOptional result else result
  where
    classify p t = if p t then Nothing else Just t

{- | For 'EitherRead' mode: parse under the chosen assumption into an
@Either Text a@ column. Successful parses become @Right@; failures (including
null/missing cells) become @Left@ carrying the raw input verbatim.
-}
handleEitherAssumption ::
    DateFormat -> ParsingAssumption -> V.Vector T.Text -> Column
handleEitherAssumption dfmt assumption raw = case assumption of
    BoolAssumption -> fromVector (V.map (toEither readBool) raw)
    IntAssumption -> fromVector (V.map (toEither readInt) raw)
    DoubleAssumption -> fromVector (V.map (toEither readDouble) raw)
    DateAssumption -> fromVector (V.map (toEither (parseTimeOpt dfmt)) raw)
    TextAssumption -> fromVector (V.map textToEither raw)
    NoAssumption -> fromVector (V.map textToEither raw)
  where
    toEither :: (T.Text -> Maybe a) -> T.Text -> Either T.Text a
    toEither p t = maybe (Left t) Right (p t)

    textToEither :: T.Text -> Either T.Text T.Text
    textToEither t = if T.null t then Left t else Right t

parseUnboxedColumnWithPred ::
    forall src a.
    (VU.Unbox a) =>
    a ->
    (src -> Bool) ->
    (src -> Maybe a) ->
    V.Vector src ->
    Maybe (Maybe Bitmap, VU.Vector a)
parseUnboxedColumnWithPred nullValue isNull parser vec = runST $ do
    let n = V.length vec
    values <- VUM.unsafeNew n
    vmask <- VUM.unsafeNew n
    let go !i !anyNull
            | i >= n = finalizeParseResult values vmask anyNull
            | otherwise =
                let !src = V.unsafeIndex vec i
                 in if isNull src
                        then do
                            VUM.unsafeWrite vmask i 0
                            VUM.unsafeWrite values i nullValue
                            go (i + 1) True
                        else case parser src of
                            Just v -> do
                                VUM.unsafeWrite vmask i 1
                                VUM.unsafeWrite values i v
                                go (i + 1) anyNull
                            Nothing -> return Nothing
    go 0 False
{-# INLINE parseUnboxedColumnWithPred #-}

-- | Wrap a successful 'parseUnboxedColumnWithPred' result as a 'Column'.
unboxedOrFallback ::
    (Columnable a, VU.Unbox a) =>
    Maybe (Maybe Bitmap, VU.Vector a) ->
    Column ->
    Column
unboxedOrFallback (Just (mbm, vec)) _ = UnboxedColumn mbm vec
unboxedOrFallback Nothing fallback = fallback

handleBoolAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column
handleBoolAssumption isNull cols =
    unboxedOrFallback
        (parseUnboxedColumnWithPred False isNull readBool cols)
        (handleTextAssumption isNull cols)

{- | Int columns: one fused pass with in-place Int -> Double promotion; a cell
parsing as neither demotes the column to Text. 'readIntStrict' rejects overflow
so a huge integer promotes to 'Double' rather than wrapping.
-}
handleIntAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column
handleIntAssumption isNull cols =
    case promoteIntColumn (\_ t -> isNull t) readIntStrict readDouble cols of
        Just col -> col
        Nothing -> handleTextAssumption isNull cols

handleDoubleAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column
handleDoubleAssumption isNull cols =
    unboxedOrFallback
        (parseUnboxedColumnWithPred 0 isNull readDouble cols)
        (handleTextAssumption isNull cols)

{- | Text columns: no parse, just null-marking. An all-non-null column stays a
plain @V.Vector T.Text@; otherwise it becomes @V.Vector (Maybe T.Text)@.
-}
handleTextAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column
handleTextAssumption isNull cols
    | V.any isNull cols =
        fromVector
            (V.map (\t -> if isNull t then Nothing else Just t) cols)
    | otherwise = fromVector cols

{- | Date: single boxed parse pass ('Day' is not unboxable). Bails to
'handleTextAssumption' the moment a non-null cell fails to parse as a 'Day'.
A column with no nulls keeps type 'Day' rather than 'Maybe Day'.
-}
handleDateAssumption ::
    DateFormat -> (T.Text -> Bool) -> V.Vector T.Text -> Column
handleDateAssumption dateFormat isNull cols =
    case parseBoxedMaybeColumn isNull (parseTimeOpt dateFormat) cols of
        Just (anyNull, vec)
            | anyNull -> fromVector vec
            | otherwise -> fromVector (V.mapMaybe id vec)
        Nothing -> handleTextAssumption isNull cols

parseBoxedMaybeColumn ::
    (T.Text -> Bool) ->
    (T.Text -> Maybe a) ->
    V.Vector T.Text ->
    Maybe (Bool, V.Vector (Maybe a))
parseBoxedMaybeColumn isNull parser cols = runST $ do
    let n = V.length cols
    out <- VM.new n
    let loop !i !anyNull
            | i >= n = do
                frozen <- V.unsafeFreeze out
                return (Just (anyNull, frozen))
            | otherwise =
                let !t = V.unsafeIndex cols i
                 in if isNull t
                        then do
                            VM.unsafeWrite out i Nothing
                            loop (i + 1) True
                        else case parser t of
                            Just v -> do
                                VM.unsafeWrite out i (Just v)
                                loop (i + 1) anyNull
                            Nothing -> return Nothing
    loop 0 False

-- Reached only when the sample was all-null: try each concrete type in turn,
-- falling back to Text. A column with no nulls keeps type 'Day', not 'Maybe Day'.
handleNoAssumption ::
    DateFormat -> (T.Text -> Bool) -> V.Vector T.Text -> Column
handleNoAssumption dateFormat isNull cols
    | V.all isNull cols =
        fromVector (V.map (const (Nothing :: Maybe T.Text)) cols)
    | Just (mbm, vec) <- parseUnboxedColumnWithPred False isNull readBool cols =
        UnboxedColumn mbm vec
    | Just (mbm, vec) <- parseUnboxedColumnWithPred 0 isNull readInt cols =
        UnboxedColumn mbm vec
    | Just (mbm, vec) <- parseUnboxedColumnWithPred 0 isNull readDouble cols =
        UnboxedColumn mbm vec
    | otherwise = case parseBoxedMaybeColumn isNull (parseTimeOpt dateFormat) cols of
        Just (anyNull, vec)
            | anyNull -> fromVector vec
            | otherwise -> fromVector (V.mapMaybe id vec)
        Nothing -> handleTextAssumption isNull cols

{- | True for nullish or explicitly-listed missing strings. ('convertNullish'
and 'convertOnlyEmpty' below are kept only for external callers.)
-}
isNullishOrMissing :: [T.Text] -> T.Text -> Bool
isNullishOrMissing missing v = isNullish v || v `elem` missing

convertNullish :: [T.Text] -> T.Text -> Maybe T.Text
convertNullish missing v = if isNullish v || v `elem` missing then Nothing else Just v

convertOnlyEmpty :: T.Text -> Maybe T.Text
convertOnlyEmpty v = if v == "" then Nothing else Just v

unsafeParseTime :: DateFormat -> T.Text -> Day
unsafeParseTime dateFormat s =
    parseTimeOrError
        True
        defaultTimeLocale
        dateFormat
        (T.unpack s)

hasNullValues :: (Eq a) => V.Vector (Maybe a) -> Bool
hasNullValues = V.any (== Nothing)

vecSameConstructor :: V.Vector (Maybe a) -> V.Vector (Maybe b) -> Bool
vecSameConstructor xs ys = (V.length xs == V.length ys) && V.and (V.zipWith hasSameConstructor xs ys)
  where
    hasSameConstructor :: Maybe a -> Maybe b -> Bool
    hasSameConstructor (Just _) (Just _) = True
    hasSameConstructor Nothing Nothing = True
    hasSameConstructor _ _ = False

{- | Re-type columns of a 'DataFrame' according to a schema map. @resolveMode@
maps a column name to its 'SafeReadMode' (typically via 'effectiveSafeRead').
-}
parseWithTypes ::
    (T.Text -> SafeReadMode) ->
    M.Map T.Text SchemaType ->
    DataFrame ->
    DataFrame
parseWithTypes resolveMode ts df
    | M.null ts = df
    | otherwise =
        M.foldrWithKey
            (\k v d -> insertColumn k (asType (resolveMode k) v (unsafeGetColumn k d)) d)
            df
            ts
  where
    plainType ::
        forall a b.
        (Columnable a, Read a) =>
        SafeReadMode -> V.Vector b -> (b -> String) -> Column
    plainType mode col toStr = case mode of
        NoSafeRead -> fromVector (V.map ((read @a) . toStr) col)
        MaybeRead -> fromVector (V.map ((readMaybe @a) . toStr) col)
        EitherRead -> fromVector (V.map ((readEitherRaw @a) . toStr) col)

    asType :: SafeReadMode -> SchemaType -> Column -> Column
    asType mode st c@(PackedText _ _) = asType mode st (materializePacked c)
    asType mode (SType (_ :: P.Proxy a)) c@(BoxedColumn _ (col :: V.Vector b)) = case typeRep @a of
        App t1 _t2 -> case eqTypeRep t1 (typeRep @Maybe) of
            Just HRefl -> case testEquality (typeRep @a) (typeRep @b) of
                Just Refl -> c
                Nothing -> case testEquality (typeRep @T.Text) (typeRep @b) of
                    Just Refl -> fromVector (V.map (join . (readAsMaybe @a) . T.unpack) col)
                    Nothing -> fromVector (V.map (join . (readAsMaybe @a) . show) col)
            Nothing -> case t1 of
                App t1' _t2' -> case eqTypeRep t1' (typeRep @Either) of
                    Just HRefl -> case testEquality (typeRep @a) (typeRep @b) of
                        Just Refl -> c
                        Nothing -> case testEquality (typeRep @T.Text) (typeRep @b) of
                            Just Refl -> fromVector (V.map ((readAsEither @a) . T.unpack) col)
                            Nothing -> fromVector (V.map ((readAsEither @a) . show) col)
                    Nothing -> case testEquality (typeRep @a) (typeRep @b) of
                        Just Refl -> c
                        Nothing -> case testEquality (typeRep @T.Text) (typeRep @b) of
                            Just Refl -> plainType @a mode col T.unpack
                            Nothing -> plainType @a mode col show
                _ -> c
        _ -> case testEquality (typeRep @a) (typeRep @b) of
            Just Refl -> c
            Nothing -> case testEquality (typeRep @T.Text) (typeRep @b) of
                Just Refl -> plainType @a mode col T.unpack
                Nothing -> plainType @a mode col show
    asType _ _ c = c

readAsMaybe :: (Read a) => String -> Maybe a
readAsMaybe s
    | null s = Nothing
    | otherwise = readMaybe $ "Just " <> s

readAsEither :: (Read a) => String -> a
readAsEither v = case asum [readMaybe $ "Left " <> s, readMaybe $ "Right " <> s] of
    Nothing -> error $ "Couldn't read value: " <> s
    Just v' -> v'
  where
    s = if null v then "\"\"" else v

{- | Try 'readMaybe'; on failure return @Left raw@ where @raw@ is the original
input text. Used by 'parseWithTypes' under 'EitherRead'.
-}
readEitherRaw :: forall a. (Read a) => String -> Either T.Text a
readEitherRaw s = case readMaybe s of
    Just v -> Right v
    Nothing -> Left (T.pack s)