packages feed

dataframe-operations 1.1.0.3 → 1.1.1.0

raw patch · 12 files changed

+1052/−304 lines, 12 filesdep +bytestringdep −unordered-containersdep ~dataframe-coredep ~dataframe-parsingdep ~textPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: bytestring

Dependencies removed: unordered-containers

Dependency ranges changed: dataframe-core, dataframe-parsing, text

API changes (from Hackage documentation)

- DataFrame.Operations.Aggregation: hashKeyUnboxed :: Unbox a => MVector s Int -> Maybe Bitmap -> (Int -> a -> Int) -> Vector a -> ST s ()
- DataFrame.Operations.Join: [ciOffsets] :: CompactIndex -> !HashMap Int (Int, Int)
- DataFrame.Operations.Typing: BoolAssumption :: ParsingAssumption
- DataFrame.Operations.Typing: DateAssumption :: ParsingAssumption
- DataFrame.Operations.Typing: DoubleAssumption :: ParsingAssumption
- DataFrame.Operations.Typing: IntAssumption :: ParsingAssumption
- DataFrame.Operations.Typing: NoAssumption :: ParsingAssumption
- DataFrame.Operations.Typing: TextAssumption :: ParsingAssumption
- DataFrame.Operations.Typing: data ParsingAssumption
- DataFrame.Operations.Typing: makeParsingAssumption :: DateFormat -> Vector (Maybe Text) -> ParsingAssumption
- DataFrame.Operations.Typing: parseTimeOpt :: DateFormat -> Text -> Maybe Day
- DataFrame.Operations.Typing: type DateFormat = String
+ DataFrame.Operations.AggregateScatter: runMomentPlan :: GroupedDataFrame -> Int -> MomentPlan -> Maybe [(Text, Column)]
+ DataFrame.Operations.AggregateScatter: runPlan :: GroupedDataFrame -> Vector Int -> Int -> AggPlan -> Column
+ DataFrame.Operations.Aggregation: interpretNamed :: GroupedDataFrame -> NamedExpr -> Column
+ DataFrame.Operations.Inference: BoolAssumption :: ParsingAssumption
+ DataFrame.Operations.Inference: DateAssumption :: ParsingAssumption
+ DataFrame.Operations.Inference: DoubleAssumption :: ParsingAssumption
+ DataFrame.Operations.Inference: IntAssumption :: ParsingAssumption
+ DataFrame.Operations.Inference: NoAssumption :: ParsingAssumption
+ DataFrame.Operations.Inference: TextAssumption :: ParsingAssumption
+ DataFrame.Operations.Inference: byteStringDateParser :: DateFormat -> ByteString -> Maybe Day
+ DataFrame.Operations.Inference: data ParsingAssumption
+ DataFrame.Operations.Inference: instance GHC.Classes.Eq DataFrame.Operations.Inference.ParsingAssumption
+ DataFrame.Operations.Inference: instance GHC.Show.Show DataFrame.Operations.Inference.ParsingAssumption
+ DataFrame.Operations.Inference: makeParsingAssumption :: DateFormat -> Vector (Maybe Text) -> ParsingAssumption
+ DataFrame.Operations.Inference: makeParsingAssumptionBytes :: DateFormat -> Vector (Maybe ByteString) -> ParsingAssumption
+ DataFrame.Operations.Inference: parseTimeOpt :: DateFormat -> Text -> Maybe Day
+ DataFrame.Operations.Inference: promoteIntColumn :: (Int -> src -> Bool) -> (src -> Maybe Int) -> (src -> Maybe Double) -> Vector src -> Maybe Column
+ DataFrame.Operations.Inference: promoteIntColumnIndexed :: Int -> (Int -> Bool) -> (Int -> Maybe Int) -> (Int -> Maybe Double) -> Maybe Column
+ DataFrame.Operations.Inference: readIntStrict :: Text -> Maybe Int
+ DataFrame.Operations.Inference: type DateFormat = String
+ DataFrame.Operations.Join: [ciKeys] :: CompactIndex -> {-# UNPACK #-} !Vector Int
+ DataFrame.Operations.Join: [ciLens] :: CompactIndex -> {-# UNPACK #-} !Vector Int
+ DataFrame.Operations.Join: [ciMask] :: CompactIndex -> {-# UNPACK #-} !Int
+ DataFrame.Operations.Join: [ciStarts] :: CompactIndex -> {-# UNPACK #-} !Vector Int
+ DataFrame.Operations.Join: ciLookup :: CompactIndex -> Int -> (Int, Int)
+ DataFrame.Operations.Join: innerKernel :: Bool -> Vector Int -> Vector Int -> (Vector Int, Vector Int)
+ DataFrame.Operations.Join: nextPow2Above :: Int -> Int
+ DataFrame.Operations.Join: parInnerKernel :: Vector Int -> Vector Int -> (Vector Int, Vector Int)
+ DataFrame.Operations.Join: parLeftKernel :: Vector Int -> Vector Int -> (Vector Int, Vector Int)
+ DataFrame.Operations.JoinPar: parBuildThreshold :: Int
+ DataFrame.Operations.JoinPar: parInnerProbe :: Vector Int -> (Int -> (Int, Int)) -> Vector Int -> IO (Vector Int, Vector Int)
+ DataFrame.Operations.JoinPar: parJoinThreshold :: Int
+ DataFrame.Operations.JoinPar: parLeftProbe :: Vector Int -> (Int -> (Int, Int)) -> Vector Int -> IO (Vector Int, Vector Int)
+ DataFrame.Operations.JoinPar: parProbeThreshold :: Int
+ DataFrame.Operations.JoinPar: shouldParallelizeJoin :: Int -> Int -> Bool
+ DataFrame.Operations.JoinPar: shouldParallelizeSmallBuildProbe :: Int -> Bool
- DataFrame.Operations.Join: CompactIndex :: {-# UNPACK #-} !Vector Int -> !HashMap Int (Int, Int) -> CompactIndex
+ DataFrame.Operations.Join: CompactIndex :: {-# UNPACK #-} !Vector Int -> {-# UNPACK #-} !Vector Int -> {-# UNPACK #-} !Vector Int -> {-# UNPACK #-} !Vector Int -> {-# UNPACK #-} !Int -> CompactIndex
- DataFrame.Typed.Join: fullOuterJoin :: forall (keys :: [Symbol]) (left :: [Type]) (right :: [Type]). AllKnownSymbol keys => TypedDataFrame left -> TypedDataFrame right -> TypedDataFrame (FullOuterJoinSchema keys left right)
+ DataFrame.Typed.Join: fullOuterJoin :: forall (keys :: [Symbol]) (left :: [Type]) (right :: [Type]). (AllKnownSymbol keys, AssertAllPresent keys left, AssertAllPresent keys right, AssertKeyTypesMatch keys left right) => TypedDataFrame left -> TypedDataFrame right -> TypedDataFrame (FullOuterJoinSchema keys left right)
- DataFrame.Typed.Join: innerJoin :: forall (keys :: [Symbol]) (left :: [Type]) (right :: [Type]). AllKnownSymbol keys => TypedDataFrame left -> TypedDataFrame right -> TypedDataFrame (InnerJoinSchema keys left right)
+ DataFrame.Typed.Join: innerJoin :: forall (keys :: [Symbol]) (left :: [Type]) (right :: [Type]). (AllKnownSymbol keys, AssertAllPresent keys left, AssertAllPresent keys right, AssertKeyTypesMatch keys left right) => TypedDataFrame left -> TypedDataFrame right -> TypedDataFrame (InnerJoinSchema keys left right)
- DataFrame.Typed.Join: leftJoin :: forall (keys :: [Symbol]) (left :: [Type]) (right :: [Type]). AllKnownSymbol keys => TypedDataFrame left -> TypedDataFrame right -> TypedDataFrame (LeftJoinSchema keys left right)
+ DataFrame.Typed.Join: leftJoin :: forall (keys :: [Symbol]) (left :: [Type]) (right :: [Type]). (AllKnownSymbol keys, AssertAllPresent keys left, AssertAllPresent keys right, AssertKeyTypesMatch keys left right) => TypedDataFrame left -> TypedDataFrame right -> TypedDataFrame (LeftJoinSchema keys left right)
- DataFrame.Typed.Join: rightJoin :: forall (keys :: [Symbol]) (left :: [Type]) (right :: [Type]). AllKnownSymbol keys => TypedDataFrame left -> TypedDataFrame right -> TypedDataFrame (RightJoinSchema keys left right)
+ DataFrame.Typed.Join: rightJoin :: forall (keys :: [Symbol]) (left :: [Type]) (right :: [Type]). (AllKnownSymbol keys, AssertAllPresent keys left, AssertAllPresent keys right, AssertKeyTypesMatch keys left right) => TypedDataFrame left -> TypedDataFrame right -> TypedDataFrame (RightJoinSchema keys left right)

Files

dataframe-operations.cabal view
@@ -1,7 +1,6 @@ cabal-version:      2.4 name:               dataframe-operations-version:            1.1.0.3-+version:            1.1.1.0 synopsis:           Column operations, expression DSL, and statistics for the dataframe ecosystem. description:     Untyped column operations (select, filter, sort, join, groupBy,@@ -34,9 +33,12 @@                         DataFrame.Functions                         DataFrame.Monad                         DataFrame.Internal.Statistics+                        DataFrame.Operations.AggregateScatter                         DataFrame.Operations.Aggregation                         DataFrame.Operations.Core+                        DataFrame.Operations.Inference                         DataFrame.Operations.Join+                        DataFrame.Operations.JoinPar                         DataFrame.Operations.Merge                         DataFrame.Operations.Permutation                         DataFrame.Operations.SetOps@@ -50,14 +52,14 @@                         DataFrame.Typed.Join                         DataFrame.Typed.Operations     build-depends:      base >= 4 && < 5,+                        bytestring >= 0.11 && < 0.13,                         containers >= 0.6.7 && < 0.9,-                        dataframe-core >= 1.0.2 && < 1.1,-                        dataframe-parsing ^>= 1.0,+                        dataframe-core >= 1.1 && < 1.2,+                        dataframe-parsing ^>= 1.0.2,                         random >= 1.2 && < 2,                         regex-tdfa >= 1.3.0 && < 2,                         text >= 2.0 && < 3,                         time >= 1.12 && < 2,-                        unordered-containers >= 0.1 && < 1,                         vector ^>= 0.13,                         vector-algorithms ^>= 0.9     hs-source-dirs:     src
+ src/DataFrame/Operations/AggregateScatter.hs view
@@ -0,0 +1,224 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE ExplicitNamespaces #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++{- | Execute a recognised aggregation plan ('AggPlan') through the vectorized+scatter kernel, producing one result column (length @nGroups@, canonical group+order). The scatter reductions live in 'DataFrame.Internal.AggKernel' (sequential)+and 'DataFrame.Internal.AggKernelPar' (parallel by disjoint group range); this+module handles the compound @max - min@ combine and the holistic grouped median.+A plan only reaches here once 'planAgg' verified the value columns are clean+unboxed Int/Double, so the @error@ branches are unreachable.++Every reduction takes the Round-5 grouping layout @(valueIndices, offsets)@ so+the parallel kernel can split the group-id range across capabilities with no+cross-worker merge. Each group's rows stay in original-row order within one+worker's range, so results are byte-identical to the sequential path at any @-N@.+-}+module DataFrame.Operations.AggregateScatter (runPlan, runMomentPlan) where++import qualified Data.Text as T+import qualified Data.Vector.Algorithms.Intro as VA+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as VUM++import Control.Concurrent (forkIO, getNumCapabilities)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (SomeException, throwIO, try)+import Data.Type.Equality (TestEquality (..), type (:~:) (Refl))+import DataFrame.Internal.AggKernel (Reduction (..), scatterColumnToDouble)+import DataFrame.Internal.AggKernelDirect (directReduce, directThreshold)+import DataFrame.Internal.AggKernelPar (momentScatterPar, scatterReducePar)+import DataFrame.Internal.AggPlan (AggPlan (..), MomentPlan (..), Moments (..))+import DataFrame.Internal.Column (Column (..), fromUnboxedVector)+import DataFrame.Internal.DataFrame (GroupedDataFrame (..), getColumn)+import System.IO.Unsafe (unsafePerformIO)+import Type.Reflection (typeRep)++runPlan :: GroupedDataFrame -> VU.Vector Int -> Int -> AggPlan -> Column+runPlan gdf rtg nGroups plan = case plan of+    PlanScatter red name -> scatterColumn red name+    PlanMaxMinusMin a b -> maxMinusMin vis offs nGroups (col a) (col b)+    PlanMedian name -> groupedMedian vis offs nGroups (col name)+  where+    vis = valueIndices gdf+    offs = offsets gdf+    {- The low-cardinality DIRECT-INDEXED fast path: for a small dense domain the+    grouping layer's @rowToGroup@ already maps row -> group, so we scatter+    straight off it (no @valueIndices@ gather). 'directReduce' only admits+    order-independent reductions (so the merged parallel result is byte-identical+    to -N1); anything it rejects keeps the order-preserving group-range kernel. -}+    scatterColumn red name =+        let c = col name+            direct+                | nGroups <= directThreshold = directReduce red rtg nGroups c+                | otherwise = Nothing+         in case direct of+                Just out -> out+                Nothing -> case scatterReducePar red vis offs nGroups c of+                    Just out -> out+                    Nothing -> error "runPlan: scatterReducePar rejected a planned column"+    col name = case getColumn name (fullDataframe gdf) of+        Just c -> c+        Nothing -> error ("runPlan: planned column missing: " ++ T.unpack name)++{- | Run a recognised moment (Q9 regression) plan as one fused scatter over the+two base columns, returning each output name bound to its moment field. The six+sufficient statistics (count, Sx, Sy, Sxx, Syy, Sxy) come out of a single pass,+replacing the three derive passes and six independent scatters of the+per-expression path. Byte-identical to the sequential kernel at any @-N@.+-}+runMomentPlan ::+    GroupedDataFrame -> Int -> MomentPlan -> Maybe [(T.Text, Column)]+runMomentPlan gdf nGroups mp = do+    ms <- momentScatterPar vis offs nGroups (col (mpColX mp)) (col (mpColY mp))+    pure+        [ (mpNName mp, mN ms)+        , (mpSxName mp, mSx ms)+        , (mpSyName mp, mSy ms)+        , (mpSxxName mp, mSxx ms)+        , (mpSyyName mp, mSyy ms)+        , (mpSxyName mp, mSxy ms)+        ]+  where+    vis = valueIndices gdf+    offs = offsets gdf+    col name = case getColumn name (fullDataframe gdf) of+        Just c -> c+        Nothing -> error ("runMomentPlan: planned column missing: " ++ T.unpack name)++{- | @max a - min b@ on the small @nGroups@ arrays. Preserves the Int element+type of the source columns (matching the interpreter), falling back to a Double+combine otherwise.+-}+maxMinusMin ::+    VU.Vector Int -> VU.Vector Int -> Int -> Column -> Column -> Column+maxMinusMin vis offs nGroups ca cb =+    case (ca, cb) of+        ( UnboxedColumn Nothing (_ :: VU.Vector x)+            , UnboxedColumn Nothing (_ :: VU.Vector y)+            )+                | Just Refl <- testEquality (typeRep @x) (typeRep @Int)+                , Just Refl <- testEquality (typeRep @y) (typeRep @Int) ->+                    let mx = scatterExtremaInt RMax vis offs nGroups ca+                        mn = scatterExtremaInt RMin vis offs nGroups cb+                     in fromUnboxedVector (VU.zipWith (-) mx mn)+        _ ->+            let mx = scatterExtremaDbl RMax vis offs nGroups ca+                mn = scatterExtremaDbl RMin vis offs nGroups cb+             in fromUnboxedVector (VU.zipWith (-) mx mn)++scatterExtremaInt ::+    Reduction -> VU.Vector Int -> VU.Vector Int -> Int -> Column -> VU.Vector Int+scatterExtremaInt red vis offs nGroups c = case scatterReducePar red vis offs nGroups c of+    Just (UnboxedColumn _ (v :: VU.Vector a))+        | Just Refl <- testEquality (typeRep @a) (typeRep @Int) -> v+    _ -> error "scatterExtremaInt"++scatterExtremaDbl ::+    Reduction -> VU.Vector Int -> VU.Vector Int -> Int -> Column -> VU.Vector Double+scatterExtremaDbl red vis offs nGroups c =+    case scatterReducePar red vis offs nGroups c of+        Just (UnboxedColumn _ (v :: VU.Vector a))+            | Just Refl <- testEquality (typeRep @a) (typeRep @Double) -> v+            | Just Refl <- testEquality (typeRep @a) (typeRep @Int) -> VU.map fromIntegral v+        _ -> error "scatterExtremaDbl"++-------------------------------------------------------------------------------+-- Parallel holistic median+-------------------------------------------------------------------------------++{- | Holistic per-group median over a single unboxed Int/Double column. The+@valueIndices@/@offsets@ layout already places each group's rows in a contiguous+run, so we copy each group's values into a scratch buffer at its own offset and+sort that slice in place — each group's slice is independent, so the per-group+sorts split across capabilities by group range with no merge. Empty groups never+occur, so the result is total.+-}+groupedMedian :: VU.Vector Int -> VU.Vector Int -> Int -> Column -> Column+groupedMedian vis offs nGroups c = case scatterColumnToDouble c of+    Nothing -> error "groupedMedian: non-numeric planned column"+    Just vals -> fromUnboxedVector (medianByGroup vis offs nGroups vals)++medianByGroup ::+    VU.Vector Int -> VU.Vector Int -> Int -> VU.Vector Double -> VU.Vector Double+medianByGroup vis offs nGroups vals = unsafePerformIO $ do+    let !n = VU.length vis+    buf <- VUM.new (max 1 n)+    out <- VUM.new (max 1 nGroups)+    caps <- getNumCapabilities+    let !bounds = groupRangeBounds offs nGroups caps+    -- Each worker fills+sorts the buffer slices of its own group range, then+    -- writes that range's medians. Disjoint ranges => safe to parallelise.+    forEachRange bounds caps $ \gs ge ->+        let grp !g+                | g >= ge = pure ()+                | otherwise = do+                    let !s = VU.unsafeIndex offs g+                        !e = VU.unsafeIndex offs (g + 1)+                        !len = e - s+                        fill !pos+                            | pos >= e = pure ()+                            | otherwise = do+                                VUM.unsafeWrite buf pos (VU.unsafeIndex vals (VU.unsafeIndex vis pos))+                                fill (pos + 1)+                    fill s+                    let slice = VUM.unsafeSlice s len buf+                    VA.sort slice+                    let mid = s + len `div` 2+                    med <-+                        if odd len+                            then VUM.unsafeRead buf mid+                            else do+                                hi <- VUM.unsafeRead buf mid+                                lo <- VUM.unsafeRead buf (mid - 1)+                                pure ((hi + lo) / 2)+                    VUM.unsafeWrite out g med+                    grp (g + 1)+         in grp gs+    VU.unsafeFreeze (VUM.unsafeSlice 0 nGroups out)+{-# NOINLINE medianByGroup #-}++-------------------------------------------------------------------------------+-- Group-range partitioning (shared with the median path)+-------------------------------------------------------------------------------++{- | Split @[0, nGroups)@ into @caps@ contiguous group ranges balanced by row+count. Identical policy to 'DataFrame.Internal.AggKernelPar.groupRangeBounds'.+-}+groupRangeBounds :: VU.Vector Int -> Int -> Int -> VU.Vector Int+groupRangeBounds offs nGroups caps = VU.create $ do+    b <- VUM.new (caps + 1)+    let !nRows = VU.unsafeIndex offs nGroups+        !per = max 1 ((nRows + caps - 1) `div` caps)+        adv !target !gg+            | gg >= nGroups = nGroups+            | VU.unsafeIndex offs gg >= target = gg+            | otherwise = adv target (gg + 1)+        go !w !prev+            | w >= caps = VUM.unsafeWrite b caps nGroups+            | otherwise = do+                let !target = min nRows (w * per)+                    !g = adv target prev+                VUM.unsafeWrite b w g+                go (w + 1) g+    VUM.unsafeWrite b 0 0+    go 1 0+    pure b++forEachRange :: VU.Vector Int -> Int -> (Int -> Int -> IO ()) -> IO ()+forEachRange bounds caps act+    | caps <= 1 = act (VU.unsafeIndex bounds 0) (VU.unsafeIndex bounds caps)+    | otherwise = do+        vars <- mapM spawn [0 .. caps - 1]+        results <- mapM takeMVar vars+        mapM_ (either (throwIO :: SomeException -> IO ()) pure) results+  where+    spawn w = do+        var <- newEmptyMVar+        let !s = VU.unsafeIndex bounds w+            !e = VU.unsafeIndex bounds (w + 1)+        _ <- forkIO (try (act s e) >>= putMVar var)+        pure var
src/DataFrame/Operations/Aggregation.hs view
@@ -1,7 +1,7 @@+{-# LANGUAGE BangPatterns #-} {-# LANGUAGE ExplicitNamespaces #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-}-{-# LANGUAGE LambdaCase #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE Strict #-}@@ -17,19 +17,14 @@ import qualified Data.Text as T import qualified Data.Vector as V import qualified Data.Vector.Unboxed as VU-import qualified Data.Vector.Unboxed.Mutable as VUM  import Control.Exception (throw)-import Control.Monad-import Control.Monad.ST (ST, runST)-import Data.Type.Equality (TestEquality (..), type (:~:) (Refl)) import DataFrame.Errors+import DataFrame.Internal.AggPlan (MomentPlan, planAgg, planMoments) import DataFrame.Internal.Column (-    Bitmap,     Column (..),     TypedColumn (..),     atIndicesStable,-    bitmapTestBit,  ) import DataFrame.Internal.DataFrame (     DataFrame (..),@@ -39,123 +34,67 @@  ) import DataFrame.Internal.Expression import DataFrame.Internal.Grouping (buildRowToGroup, changingPoints, groupBy)-import DataFrame.Internal.Hash (-    fnvOffset,-    mixDouble,-    mixInt,-    mixShow,-    mixText,-    nullSalt,- ) import DataFrame.Internal.Interpreter-import DataFrame.Internal.Types+import DataFrame.Internal.RowHash (computeRowHashesIO)+import DataFrame.Operations.AggregateScatter (runMomentPlan, runPlan) import DataFrame.Operations.Core import DataFrame.Operations.Subset-import Type.Reflection (typeRep)+import System.IO.Unsafe (unsafePerformIO) +{- | Per-row key hash over the selected key columns. Delegates to the shared+'computeRowHashesIO' kernel, which forks over contiguous row ranges for large+frames (the hashing of a wide 1e7-row text/factor join key dominates that join)+and is bit-for-bit identical to a single sequential pass at any capability count.+-} computeRowHashes :: [Int] -> DataFrame -> VU.Vector Int-computeRowHashes indices df = runST $ do+computeRowHashes indices df =     let n = fst (dimensions df)-    mv <- VUM.replicate n fnvOffset--    let selectedCols = map (columns df V.!) indices--    forM_ selectedCols $ \case-        UnboxedColumn ubm (v :: VU.Vector a) ->-            case testEquality (typeRep @a) (typeRep @Int) of-                Just Refl -> hashKeyUnboxed mv ubm mixInt v-                Nothing ->-                    case testEquality (typeRep @a) (typeRep @Double) of-                        Just Refl ->-                            hashKeyUnboxed mv ubm mixDouble v-                        Nothing ->-                            case sIntegral @a of-                                STrue ->-                                    hashKeyUnboxed mv ubm (\h d -> mixInt h (fromIntegral @a @Int d)) v-                                SFalse ->-                                    case sFloating @a of-                                        STrue ->-                                            hashKeyUnboxed mv ubm (\h d -> mixDouble h (realToFrac d :: Double)) v-                                        SFalse ->-                                            hashKeyUnboxed mv ubm mixShow v-        BoxedColumn bm (v :: V.Vector a) ->-            case testEquality (typeRep @a) (typeRep @T.Text) of-                Just Refl ->-                    V.imapM_-                        ( \i (t :: T.Text) -> do-                            h <- VUM.unsafeRead mv i-                            let h' = case bm of-                                    Just bm' | not (bitmapTestBit bm' i) -> mixInt h nullSalt-                                    _ -> mixText h t-                            VUM.unsafeWrite mv i h'-                        )-                        v-                Nothing ->-                    V.imapM_-                        ( \i d -> do-                            h <- VUM.unsafeRead mv i-                            let h' = case bm of-                                    Just bm' | not (bitmapTestBit bm' i) -> mixInt h nullSalt-                                    _ -> mixShow h d-                            VUM.unsafeWrite mv i h'-                        )-                        v--    VU.unsafeFreeze mv--{- | Fold a value-mix over an unboxed key column into the running hash vector,-respecting the null bitmap (a null slot mixes 'nullSalt' instead of its-uninitialised stored value, so @Nothing@ never matches a present @Just x@).-The non-nullable case is the original tight loop. @INLINE@d to specialise per call.--}-hashKeyUnboxed ::-    (VU.Unbox a) =>-    VUM.MVector s Int ->-    Maybe Bitmap ->-    (Int -> a -> Int) ->-    VU.Vector a ->-    ST s ()-hashKeyUnboxed mv ubm mix v = case ubm of-    Nothing ->-        VU.imapM_-            ( \i x -> do-                h <- VUM.unsafeRead mv i-                VUM.unsafeWrite mv i (mix h x)-            )-            v-    Just bm ->-        VU.imapM_-            ( \i x -> do-                h <- VUM.unsafeRead mv i-                VUM.unsafeWrite-                    mv-                    i-                    (if bitmapTestBit bm i then mix h x else mixInt h nullSalt)-            )-            v-{-# INLINE hashKeyUnboxed #-}+        selectedCols = map (columns df V.!) indices+     in unsafePerformIO (computeRowHashesIO n selectedCols)+{-# NOINLINE computeRowHashes #-}  {- | Aggregate a grouped dataframe using the expressions given. All ungrouped columns will be dropped. -} aggregate :: [NamedExpr] -> GroupedDataFrame -> DataFrame-aggregate aggs gdf@(Grouped df groupingColumns valIndices offs _rowToGroup) =+aggregate aggs gdf@(Grouped df groupingColumns valIndices offs rowToGroupV) =     let         df' =             selectIndices                 (VU.map (valIndices VU.!) (VU.init offs))                 (select groupingColumns df) -        f (name, UExpr (expr :: Expr a)) d =-            let-                value = case interpretAggregation @a gdf expr of-                    Left e -> throw e-                    Right (UnAggregated _) -> throw $ UnaggregatedException (T.pack $ show expr)-                    Right (Aggregated (TColumn col)) -> col-             in-                insertColumn name value d+        !nGroups = VU.length offs - 1++        -- Fast path: a recognised reduction scatters in one unboxed pass.+        -- Anything 'planAgg' rejects keeps the existing interpreter, so the+        -- general typed + DSL aggregate API stays correct for arbitrary+        -- expressions.+        f ne@(name, uexpr) d =+            let value = case planAgg gdf uexpr of+                    Just plan -> runPlan gdf rowToGroupV nGroups plan+                    Nothing -> interpretNamed gdf ne+             in insertColumn name value d++        -- Fused fast path: the Q9 regression family (count + five moment sums+        -- of two base columns) becomes one scatter over the base columns,+        -- dropping the derived product columns and the six separate folds.+        -- 'planMoments' returns 'Nothing' on any other set, falling back below.+        fusedMoments = do+            mp <- planMoments gdf aggs :: Maybe MomentPlan+            runMomentPlan gdf nGroups mp      in-        fold f aggs df'+        case fusedMoments of+            Just cols -> fold (uncurry insertColumn) cols df'+            Nothing -> fold f aggs df'++-- | The fall-back path: evaluate one named aggregation via the interpreter.+interpretNamed :: GroupedDataFrame -> NamedExpr -> Column+interpretNamed gdf (_, UExpr (expr :: Expr a)) =+    case interpretAggregation @a gdf expr of+        Left e -> throw e+        Right (UnAggregated _) -> throw $ UnaggregatedException (T.pack $ show expr)+        Right (Aggregated (TColumn col)) -> col  selectIndices :: VU.Vector Int -> DataFrame -> DataFrame selectIndices xs df =
src/DataFrame/Operations/Core.hs view
@@ -32,6 +32,7 @@     columnTypeString,     fromList,     fromVector,+    materializePacked,     toDoubleVector,     toFloatVector,     toIntVector,@@ -538,6 +539,7 @@                         countNulls                         columnType                         : acc+    go acc i col@(PackedText _ _) = go acc i (materializePacked col)  nulls :: Column -> Int nulls (BoxedColumn (Just bm) xs) =@@ -552,6 +554,7 @@ nulls (UnboxedColumn (Just bm) xs) =     let n = VG.length xs      in n - VU.foldl' (\acc b -> acc + popCount b) 0 bm+nulls c@(PackedText _ _) = nulls (materializePacked c) nulls _ = 0  {- | Creates a dataframe from a list of tuples with name and column.@@ -748,12 +751,7 @@         pure $             V.generate                 (fst (dataframeDimensions df))-                ( \i ->-                    foldl-                        (\acc j -> acc `VU.snoc` ((m VG.! j) VG.! i))-                        VU.empty-                        [0 .. (V.length m - 1)]-                )+                (\i -> VU.generate (V.length m) (\j -> (m VG.! j) VG.! i))  {- | Returns a dataframe as a two dimensional vector of doubles. @@ -775,12 +773,7 @@         pure $             V.generate                 (fst (dataframeDimensions df))-                ( \i ->-                    foldl-                        (\acc j -> acc `VU.snoc` ((m VG.! j) VG.! i))-                        VU.empty-                        [0 .. (V.length m - 1)]-                )+                (\i -> VU.generate (V.length m) (\j -> (m VG.! j) VG.! i))  {- | Returns a dataframe as a two dimensional vector of ints. @@ -801,12 +794,7 @@         pure $             V.generate                 (fst (dataframeDimensions df))-                ( \i ->-                    foldl-                        (\acc j -> acc `VU.snoc` ((m VG.! j) VG.! i))-                        VU.empty-                        [0 .. (V.length m - 1)]-                )+                (\i -> VU.generate (V.length m) (\j -> (m VG.! j) VG.! i))  {- | Get a specific column as a vector. @@ -951,4 +939,6 @@         Just (BoxedColumn Nothing (_ :: V.Vector a)) -> UExpr (Col @a name)         Just (UnboxedColumn (Just _) (_ :: VU.Vector a)) -> UExpr (Col @(Maybe a) name)         Just (UnboxedColumn Nothing (_ :: VU.Vector a)) -> UExpr (Col @a name)+        Just (PackedText (Just _) _) -> UExpr (Col @(Maybe T.Text) name)+        Just (PackedText Nothing _) -> UExpr (Col @T.Text name)         Nothing -> error $ "showDerivedExpressions: column not found: " ++ T.unpack name
+ src/DataFrame/Operations/Inference.hs view
@@ -0,0 +1,221 @@+{-# 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 #-}
src/DataFrame/Operations/Join.hs view
@@ -12,12 +12,9 @@  import Control.Applicative ((<|>)) import Control.Exception (throw)-import Control.Monad (forM_, when)+import Control.Monad (when) import Control.Monad.ST (ST, runST)-import qualified Data.HashMap.Strict as HM-#if !MIN_VERSION_base(4,20,0)-import Data.List (foldl')-#endif+import Data.Bits ((.&.)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.STRef (newSTRef, readSTRef, writeSTRef)@@ -33,8 +30,16 @@  ) import DataFrame.Internal.Column as D import DataFrame.Internal.DataFrame as D+import DataFrame.Internal.ParRadixSort (parSortByHash) import DataFrame.Operations.Aggregation as D import DataFrame.Operations.Core as D+import DataFrame.Operations.JoinPar (+    parInnerProbe,+    parLeftProbe,+    shouldParallelizeJoin,+    shouldParallelizeSmallBuildProbe,+ )+import System.IO.Unsafe (unsafePerformIO) import Type.Reflection  -- | Equivalent to SQL join types.@@ -58,44 +63,103 @@ join FULL_OUTER xs right = fullOuterJoin xs right  {- | Row-count threshold for the build side.-When the build side exceeds this, sort-merge join is used-instead of hash join to avoid L3 cache thrashing.+When the build side exceeds this, sort-merge join is used instead of the+single-threaded hash join. The parallel chunked-probe hash join+('parInnerKernel' \/ 'parLeftKernel') is preferred above this size when more+than one capability is available (see 'shouldParallelizeJoin'): partitioning+the probe over cores beats sort-merge for the large build sides measured here+(e.g. the 2M-row build in the join pipeline). Sort-merge remains the+single-threaded fallback. -} joinStrategyThreshold :: Int joinStrategyThreshold = 500_000  {- | A compact index mapping hash values to contiguous slices of-original row indices. All indices live in a single unboxed vector;-the HashMap stores @(offset, length)@ into that vector.+original row indices. All indices live in a single unboxed vector+(@ciSortedIndices@, sorted by hash). The lookup table is an open-addressing+linear-probe hash table held in three parallel unboxed vectors keyed by hash:+@ciKeys@ holds the hash at each slot, @ciStarts@ the run offset into+@ciSortedIndices@ (@-1@ marks an empty slot, since real offsets are @>= 0@),+and @ciLens@ the run length. @ciMask@ is @tableSize - 1@ (table size is a+power of two), used to map a hash to its home slot. -} data CompactIndex = CompactIndex     { ciSortedIndices :: {-# UNPACK #-} !(VU.Vector Int)-    , ciOffsets :: !(HM.HashMap Int (Int, Int))+    , ciKeys :: {-# UNPACK #-} !(VU.Vector Int)+    , ciStarts :: {-# UNPACK #-} !(VU.Vector Int)+    , ciLens :: {-# UNPACK #-} !(VU.Vector Int)+    , ciMask :: {-# UNPACK #-} !Int     } +{- | Look up a hash in the open-addressing table.+Returns @(start, len)@ of the matching run, or @(-1, 0)@ on a miss.+-}+ciLookup :: CompactIndex -> Int -> (Int, Int)+ciLookup ci !h = go (h .&. mask)+  where+    !mask = ciMask ci+    !keys = ciKeys ci+    !starts = ciStarts ci+    !lens = ciLens ci+    go !slot =+        let !s = starts `VU.unsafeIndex` slot+         in if s < 0+                then (-1, 0)+                else+                    if keys `VU.unsafeIndex` slot == h+                        then (s, lens `VU.unsafeIndex` slot)+                        else go ((slot + 1) .&. mask)+{-# INLINE ciLookup #-}++{- | Smallest power of two strictly greater than @n@, at least 2.+Used to size the open-addressing table so the load factor stays below ~0.5.+-}+nextPow2Above :: Int -> Int+nextPow2Above n = go 2+  where+    go !p+        | p > n = p+        | otherwise = go (p * 2)+ {- | Build a compact index from a vector of row hashes.-Sorts @(hash, originalIndex)@ pairs by hash, then scans for-contiguous runs to populate the offset map.+Sorts @(hash, originalIndex)@ pairs by hash, scans for contiguous runs, then+inserts each run into an open-addressing linear-probe table sized to keep the+load factor under ~0.5. -} buildCompactIndex :: VU.Vector Int -> CompactIndex buildCompactIndex hashes =     let n = VU.length hashes-        (sortedHashes, sortedIndices) = sortWithIndices hashes-        !offs = buildOffsets sortedHashes n 0 HM.empty-     in CompactIndex sortedIndices offs-  where-    buildOffsets ::-        VU.Vector Int ->-        Int ->-        Int ->-        HM.HashMap Int (Int, Int) ->-        HM.HashMap Int (Int, Int)-    buildOffsets !sh !n !i !acc-        | i >= n = acc-        | otherwise =-            let !h = sh `VU.unsafeIndex` i-                !end = findGroupEnd sh h (i + 1) n-             in buildOffsets sh n end (HM.insert h (i, end - i) acc)+        (sortedHashes, sortedIndices) = parSortByHash n hashes+        -- Worst case every row is a distinct group; 2*n+1 keeps the table+        -- sparse even then. Capacity is independent of the actual group count+        -- so building stays a single pass with no resize.+        !cap = nextPow2Above (2 * n)+        !mask = cap - 1+        (keys, starts, lens) = runST $ do+            mKeys <- VUM.unsafeNew cap+            mStarts <- VUM.replicate cap (-1)+            mLens <- VUM.unsafeNew cap+            let insert !i+                    | i >= n = return ()+                    | otherwise = do+                        let !h = sortedHashes `VU.unsafeIndex` i+                            !end = findGroupEnd sortedHashes h (i + 1) n+                        probe h (h .&. mask) i (end - i)+                        insert end+                probe !h !slot !start !len = do+                    s <- VUM.unsafeRead mStarts slot+                    if s < 0+                        then do+                            VUM.unsafeWrite mKeys slot h+                            VUM.unsafeWrite mStarts slot start+                            VUM.unsafeWrite mLens slot len+                        else probe h ((slot + 1) .&. mask) start len+            insert 0+            (,,)+                <$> VU.unsafeFreeze mKeys+                <*> VU.unsafeFreeze mStarts+                <*> VU.unsafeFreeze mLens+     in CompactIndex sortedIndices keys starts lens mask  -- | Find the end of a contiguous run of equal values starting at @j@. findGroupEnd :: VU.Vector Int -> Int -> Int -> Int -> Int@@ -203,19 +267,57 @@         rightHashes = D.computeRowHashes rightKeyIdxs right          buildRows = min leftRows rightRows+        -- Probe with the larger side, build on the smaller. The probe side+        -- drives parallelism (it is partitioned across cores), so probing the+        -- larger side maximises the win.+        probeRows = max leftRows rightRows+        -- Parallelize the probe either when the build is large enough to spill+        -- cache (the original sort-merge regime) or when the build is small+        -- (cache-resident, read-only) but the probe is huge: partitioning a+        -- ~1e7-row probe over cores wins even against a tiny hot build (the+        -- medium-factor lever). Both routes use the same bit-identical+        -- 'parInnerKernel'.+        useParallel =+            shouldParallelizeJoin probeRows buildRows+                || shouldParallelizeSmallBuildProbe probeRows         (leftIxs, rightIxs)-            | buildRows > joinStrategyThreshold =+            | buildRows > joinStrategyThreshold && not useParallel =                 sortMergeInnerKernel leftHashes rightHashes             | rightRows <= leftRows =                 -- Build on right (smaller or equal), probe with left-                hashInnerKernel leftHashes rightHashes+                innerKernel useParallel leftHashes rightHashes             | otherwise =                 -- Build on left (smaller), probe with right, swap result-                let (!rIxs, !lIxs) = hashInnerKernel rightHashes leftHashes+                let (!rIxs, !lIxs) = innerKernel useParallel rightHashes leftHashes                  in (lIxs, rIxs)      in         assembleInner csSet left right leftIxs rightIxs +{- | Select the inner-join kernel: parallel chunked-probe hash join when the+caller decided the probe side is large enough and multi-core, otherwise the+sequential single-threaded hash kernel.+-}+innerKernel ::+    Bool -> VU.Vector Int -> VU.Vector Int -> (VU.Vector Int, VU.Vector Int)+innerKernel True = parInnerKernel+innerKernel False = hashInnerKernel+{-# INLINE innerKernel #-}++{- | Parallel inner-join kernel: build the 'CompactIndex' on @buildHashes@ once,+then probe @probeHashes@ in parallel. Bit-for-bit identical output to+'hashInnerKernel' (probe-row order preserved). Runs the IO probe via+'unsafePerformIO'; the computation is pure (no observable effects, fixed result+for fixed inputs).+-}+parInnerKernel ::+    VU.Vector Int -> VU.Vector Int -> (VU.Vector Int, VU.Vector Int)+parInnerKernel probeHashes buildHashes =+    let !ci = buildCompactIndex buildHashes+        (pf, bf) =+            unsafePerformIO (parInnerProbe (ciSortedIndices ci) (ciLookup ci) probeHashes)+     in (pf, bf)+{-# NOINLINE parInnerKernel #-}+ -- | Compute hashes for the given key column names in a DataFrame. buildHashColumn :: [T.Text] -> DataFrame -> VU.Vector Int buildHashColumn keys df =@@ -236,7 +338,6 @@     (VU.Vector Int, VU.Vector Int) hashProbeKernel ci probeHashes =     let ciIxs = ciSortedIndices ci-        ciOff = ciOffsets ci         (pFrozen, bFrozen) = runST $ do             let !probeN = VU.length probeHashes                 initCap = max 1 (min probeN 1_000_000)@@ -265,9 +366,10 @@                     | i >= probeN = return ()                     | otherwise = do                         let !h = probeHashes `VU.unsafeIndex` i-                        case HM.lookup h ciOff of-                            Nothing -> go (i + 1)-                            Just (!start, !len) -> do+                            (!start, !len) = ciLookup ci h+                        if start < 0+                            then go (i + 1)+                            else do                                 !p <- readSTRef posRef                                 ensureCapacity (p + len)                                 pv <- readSTRef pvRef@@ -311,7 +413,6 @@     let (pFrozen, bFrozen) = runST $ do             let ci = buildCompactIndex buildHashes                 ciIxs = ciSortedIndices ci-                ciOff = ciOffsets ci                 !probeN = VU.length probeHashes                 !buildN = VU.length buildHashes                 initCap = max 1 (min (probeN + buildN) 1_000_000)@@ -340,9 +441,10 @@                     | i >= probeN = return ()                     | otherwise = do                         let !h = probeHashes `VU.unsafeIndex` i-                        case HM.lookup h ciOff of-                            Nothing -> go (i + 1)-                            Just (!start, !len) -> do+                            (!start, !len) = ciLookup ci h+                        if start < 0+                            then go (i + 1)+                            else do                                 !p <- readSTRef posRef                                 when (p + len > maxJoinOutputRows) $                                     error $@@ -566,16 +668,36 @@         leftHashes = D.computeRowHashes leftKeyIdxs left         rightHashes = D.computeRowHashes rightKeyIdxs right -        -- Right is always the build side for left join+        leftRows = fst (D.dimensions left)+        -- Right is always the build side for left join; left is the probe side+        -- (and drives parallelism). Parallelize either in the large-build regime+        -- or when the build is small but the probe (left) is huge: the+        -- read-only shared index is probed across cores with no synchronization.+        useParallel =+            shouldParallelizeJoin leftRows rightRows+                || shouldParallelizeSmallBuildProbe leftRows         (leftIxs, rightIxs)-            | rightRows > joinStrategyThreshold =+            | rightRows > joinStrategyThreshold && not useParallel =                 sortMergeLeftKernel leftHashes rightHashes+            | useParallel =+                parLeftKernel leftHashes rightHashes             | otherwise =                 hashLeftKernel leftHashes rightHashes      in         -- rightIxs uses -1 as sentinel for "no match"         assembleLeft csSet left right leftIxs rightIxs +{- | Parallel left-join kernel: build the 'CompactIndex' on @rightHashes@ once,+then probe @leftHashes@ in parallel. Bit-for-bit identical output to+'hashLeftKernel' (left-row order preserved, @-1@ sentinel for misses).+-}+parLeftKernel ::+    VU.Vector Int -> VU.Vector Int -> (VU.Vector Int, VU.Vector Int)+parLeftKernel leftHashes rightHashes =+    let !ci = buildCompactIndex rightHashes+     in unsafePerformIO (parLeftProbe (ciSortedIndices ci) (ciLookup ci) leftHashes)+{-# NOINLINE parLeftKernel #-}+ {- | Hash-based left join kernel. Returns @(leftExpandedIndices, rightExpandedIndices)@ where right indices use @-1@ as sentinel for unmatched rows.@@ -587,7 +709,6 @@ hashLeftKernel leftHashes rightHashes = runST $ do     let ci = buildCompactIndex rightHashes         ciIxs = ciSortedIndices ci-        ciOff = ciOffsets ci         !leftN = VU.length leftHashes         !rightN = VU.length rightHashes         initCap = max 1 (min (leftN + rightN) 1_000_000)@@ -616,16 +737,17 @@             | i >= leftN = return ()             | otherwise = do                 let !h = leftHashes `VU.unsafeIndex` i+                    (!start, !len) = ciLookup ci h                 !p <- readSTRef posRef-                case HM.lookup h ciOff of-                    Nothing -> do+                if start < 0+                    then do                         ensureCapacity (p + 1)                         lv <- readSTRef lvRef                         rv <- readSTRef rvRef                         VUM.unsafeWrite lv p i                         VUM.unsafeWrite rv p (-1)                         writeSTRef posRef (p + 1)-                    Just (!start, !len) -> do+                    else do                         ensureCapacity (p + len)                         lv <- readSTRef lvRef                         rv <- readSTRef rvRef@@ -864,89 +986,111 @@ hashFullOuterKernel leftHashes rightHashes = runST $ do     let leftCI = buildCompactIndex leftHashes         rightCI = buildCompactIndex rightHashes-        leftOff = ciOffsets leftCI-        rightOff = ciOffsets rightCI         leftSI = ciSortedIndices leftCI         rightSI = ciSortedIndices rightCI--    -- Count: matched + left-only + right-only-    let leftEntries = HM.toList leftOff-        rightEntries = HM.toList rightOff--        !matchedCount =-            foldl'-                ( \acc (h, (_, ll)) ->-                    case HM.lookup h rightOff of-                        Nothing -> acc-                        Just (_, rl) -> acc + ll * rl-                )-                0-                leftEntries--        !leftOnlyCount =-            foldl'-                ( \acc (h, (_, ll)) ->-                    if HM.member h rightOff then acc else acc + ll-                )-                0-                leftEntries--        !rightOnlyCount =-            foldl'-                ( \acc (h, (_, rl)) ->-                    if HM.member h leftOff then acc else acc + rl-                )-                0-                rightEntries+        leftKeys = ciKeys leftCI+        leftStarts = ciStarts leftCI+        leftLens = ciLens leftCI+        rightKeys = ciKeys rightCI+        rightStarts = ciStarts rightCI+        rightLens = ciLens rightCI+        !leftCap = VU.length leftStarts+        !rightCap = VU.length rightStarts -        !totalCount = matchedCount + leftOnlyCount + rightOnlyCount+    -- Count: matched + left-only + right-only. Iterate over occupied slots+    -- (ciStarts /= -1) in each table, cross-referencing the other via ciLookup.+    let countLeft !slot !acc+            | slot >= leftCap = acc+            | otherwise =+                let !lStart = leftStarts `VU.unsafeIndex` slot+                 in if lStart < 0+                        then countLeft (slot + 1) acc+                        else+                            let !h = leftKeys `VU.unsafeIndex` slot+                                !ll = leftLens `VU.unsafeIndex` slot+                                (!rs, !rl) = ciLookup rightCI h+                             in countLeft (slot + 1) (acc + if rs < 0 then ll else ll * rl)+        countRightOnly !slot !acc+            | slot >= rightCap = acc+            | otherwise =+                let !rStart = rightStarts `VU.unsafeIndex` slot+                 in if rStart < 0+                        then countRightOnly (slot + 1) acc+                        else+                            let !h = rightKeys `VU.unsafeIndex` slot+                                !rl = rightLens `VU.unsafeIndex` slot+                                (!ls, _) = ciLookup leftCI h+                             in countRightOnly (slot + 1) (acc + if ls < 0 then rl else 0)+        !leftPlusMatched = countLeft 0 0+        !rightOnlyCount = countRightOnly 0 0+        !totalCount = leftPlusMatched + rightOnlyCount      lv <- VUM.unsafeNew totalCount     rv <- VUM.unsafeNew totalCount     posRef <- newSTRef (0 :: Int) -    -- Fill matched + left-only (iterate left keys)-    forM_ leftEntries $ \(h, (lStart, lLen)) -> do-        !p <- readSTRef posRef-        case HM.lookup h rightOff of-            Nothing -> do-                -- Left-only rows-                let goL !j !q-                        | j >= lLen = return ()-                        | otherwise = do-                            VUM.unsafeWrite lv q (leftSI `VU.unsafeIndex` (lStart + j))-                            VUM.unsafeWrite rv q (-1)-                            goL (j + 1) (q + 1)-                goL 0 p-                writeSTRef posRef (p + lLen)-            Just (!rStart, !rLen) -> do-                -- Cross product-                fillCrossProduct-                    leftSI-                    rightSI-                    lStart-                    (lStart + lLen)-                    rStart-                    (rStart + rLen)-                    lv-                    rv-                    p-                writeSTRef posRef (p + lLen * rLen)+    -- Fill matched + left-only (iterate left slots)+    let fillLeft !slot+            | slot >= leftCap = return ()+            | otherwise = do+                let !lStart = leftStarts `VU.unsafeIndex` slot+                if lStart < 0+                    then fillLeft (slot + 1)+                    else do+                        let !h = leftKeys `VU.unsafeIndex` slot+                            !lLen = leftLens `VU.unsafeIndex` slot+                            (!rStart, !rLen) = ciLookup rightCI h+                        !p <- readSTRef posRef+                        if rStart < 0+                            then do+                                let goL !j !q+                                        | j >= lLen = return ()+                                        | otherwise = do+                                            VUM.unsafeWrite lv q (leftSI `VU.unsafeIndex` (lStart + j))+                                            VUM.unsafeWrite rv q (-1)+                                            goL (j + 1) (q + 1)+                                goL 0 p+                                writeSTRef posRef (p + lLen)+                            else do+                                fillCrossProduct+                                    leftSI+                                    rightSI+                                    lStart+                                    (lStart + lLen)+                                    rStart+                                    (rStart + rLen)+                                    lv+                                    rv+                                    p+                                writeSTRef posRef (p + lLen * rLen)+                        fillLeft (slot + 1)+    fillLeft 0 -    -- Fill right-only (iterate right keys not in left)-    forM_ rightEntries $ \(h, (rStart, rLen)) ->-        case HM.lookup h leftOff of-            Just _ -> return ()-            Nothing -> do-                !p <- readSTRef posRef-                let goR !j !q-                        | j >= rLen = return ()-                        | otherwise = do-                            VUM.unsafeWrite lv q (-1)-                            VUM.unsafeWrite rv q (rightSI `VU.unsafeIndex` (rStart + j))-                            goR (j + 1) (q + 1)-                goR 0 p-                writeSTRef posRef (p + rLen)+    -- Fill right-only (iterate right slots not in left)+    let fillRightOnly !slot+            | slot >= rightCap = return ()+            | otherwise = do+                let !rStart = rightStarts `VU.unsafeIndex` slot+                if rStart < 0+                    then fillRightOnly (slot + 1)+                    else do+                        let !h = rightKeys `VU.unsafeIndex` slot+                            !rLen = rightLens `VU.unsafeIndex` slot+                            (!ls, _) = ciLookup leftCI h+                        if ls >= 0+                            then fillRightOnly (slot + 1)+                            else do+                                !p <- readSTRef posRef+                                let goR !j !q+                                        | j >= rLen = return ()+                                        | otherwise = do+                                            VUM.unsafeWrite lv q (-1)+                                            VUM.unsafeWrite rv q (rightSI `VU.unsafeIndex` (rStart + j))+                                            goR (j + 1) (q + 1)+                                goR 0 p+                                writeSTRef posRef (p + rLen)+                                fillRightOnly (slot + 1)+    fillRightOnly 0      (,) <$> VU.unsafeFreeze lv <*> VU.unsafeFreeze rv @@ -1060,6 +1204,9 @@         -- Coalesce two nullable columns: take first non-Nothing per row,         -- producing a non-optional column.         coalesceKeyColumn :: Column -> Column -> Column+        coalesceKeyColumn l r+            | D.isPackedText l || D.isPackedText r =+                coalesceKeyColumn (D.materializePacked l) (D.materializePacked r)         coalesceKeyColumn             (BoxedColumn lBm (lCol :: VB.Vector a))             (BoxedColumn rBm (rCol :: VB.Vector b)) =
+ src/DataFrame/Operations/JoinPar.hs view
@@ -0,0 +1,215 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE ScopedTypeVariables #-}++{- |+Parallel chunked-probe join kernels. The build side is indexed once into a+shared, read-only 'CompactIndex' (open-addressing, from+"DataFrame.Operations.Join"); the probe side is split into @caps@ /contiguous/+row ranges and probed in parallel by 'forkIO' workers (no sparks). Each worker+makes two passes over its range — a count pass to size its slice, then a fill+pass — and writes into the single shared output buffers at a precomputed+prefix-sum offset. Because ranges are contiguous and laid out in range order,+the produced @(probeIxs, buildIxs)@ vectors are /bit-for-bit identical/ to the+sequential 'hashInnerKernel' \/ 'hashLeftKernel': probe rows appear in original+order and, within a probe row, build matches in @ciSortedIndices@ order.++This is the parallel==sequential correctness gate (see+@tests/Operations/ParallelJoin.hs@). A sequential fallback is used when there is+a single capability or the probe side is below 'parJoinThreshold'; the caller+('innerJoin' \/ 'leftJoin') decides via 'shouldParallelizeJoin'.+-}+module DataFrame.Operations.JoinPar (+    parInnerProbe,+    parLeftProbe,+    shouldParallelizeJoin,+    shouldParallelizeSmallBuildProbe,+    parJoinThreshold,+    parBuildThreshold,+    parProbeThreshold,+) where++import Control.Concurrent (forkIO, getNumCapabilities)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (SomeException, throwIO, try)+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as VUM+import System.IO.Unsafe (unsafePerformIO)++{- | Below this many probe rows the fork/coordination overhead is not worth it;+the caller uses its sequential 'ST' kernel instead.+-}+parJoinThreshold :: Int+parJoinThreshold = 200000++{- | Below this many build rows the shared 'CompactIndex' is small and hot, so+the sequential hash probe is already memory-bound-fast and the fork overhead+loses (measured: a 1e4-row Text-key build probed by 1e7 rows is /slower/ in+parallel). Parallelism only pays once the build index is large enough to spill+cache — exactly the regime where sort-merge used to be chosen.+-}+parBuildThreshold :: Int+parBuildThreshold = 500000++{- | Whether a join should take the parallel probe path: more than one+capability, a probe side of at least 'parJoinThreshold' rows, and a build side+of at least 'parBuildThreshold' rows (a small/hot index is faster probed+sequentially).+-}+shouldParallelizeJoin :: Int -> Int -> Bool+shouldParallelizeJoin probeRows buildRows =+    probeRows >= parJoinThreshold+        && buildRows >= parBuildThreshold+        && capabilities > 1+{-# NOINLINE shouldParallelizeJoin #-}++{- | Above this many probe rows the probe-side row hashing and table lookups+dominate the join, so partitioning the probe across cores wins even when the+build side is small and cache-resident (the regime 'shouldParallelizeJoin'+deliberately leaves sequential). Sized at 1e6: below it the per-question gain is+swamped by 'forkIO'/coordination overhead, so small/medium-inner joins stay+sequential (measured). This is the small-build large-probe lever closing the+medium-factor 1e7 join (1e7 probe x ~1e4 build).+-}+parProbeThreshold :: Int+parProbeThreshold = 1000000++{- | Whether a /small-build/ join (build below 'parBuildThreshold', so radix+partitioning / sort-merge is not used) should take the parallel probe path: a+very large probe side (at least 'parProbeThreshold') and more than one+capability. The shared build index is read-only across threads, so probing it in+parallel needs no synchronization. Independent of build size on purpose: the+build is already tiny; the cost is the 1e7-row probe hashing, which parallelizes+cleanly.+-}+shouldParallelizeSmallBuildProbe :: Int -> Bool+shouldParallelizeSmallBuildProbe probeRows =+    probeRows >= parProbeThreshold+        && capabilities > 1+{-# NOINLINE shouldParallelizeSmallBuildProbe #-}++capabilities :: Int+capabilities = unsafePerformIO getNumCapabilities+{-# NOINLINE capabilities #-}++{- | A read-only view of the build-side index needed by the probe: the lookup+returns @(start, len)@ of the matching run in @sortedIndices@, or @(-1, 0)@ on a+miss. Passed in by the caller so this module need not depend on the+'CompactIndex' record directly.+-}+data ProbeIndex = ProbeIndex+    { piSorted :: !(VU.Vector Int)+    , piLookup :: !(Int -> (Int, Int))+    }++{- | Parallel inner-join probe. @parInnerProbe sortedIdxs lookup probeHashes@+returns @(probeIxs, buildIxs)@ identical to a sequential probe of the same+index. The build index must already be constructed from the build side.+-}+parInnerProbe ::+    VU.Vector Int ->+    (Int -> (Int, Int)) ->+    VU.Vector Int ->+    IO (VU.Vector Int, VU.Vector Int)+parInnerProbe sortedIdxs lookupFn =+    runProbe False (ProbeIndex sortedIdxs lookupFn)++{- | Parallel left-join probe. Like 'parInnerProbe' but every probe row emits at+least one output row; unmatched rows carry a @-1@ sentinel in the build column.+-}+parLeftProbe ::+    VU.Vector Int ->+    (Int -> (Int, Int)) ->+    VU.Vector Int ->+    IO (VU.Vector Int, VU.Vector Int)+parLeftProbe sortedIdxs lookupFn =+    runProbe True (ProbeIndex sortedIdxs lookupFn)++{- | Shared two-pass parallel probe. @keepUnmatched@ selects left- vs+inner-join semantics. Splits @[0, probeN)@ into @caps@ contiguous ranges, counts+each range's output, prefix-sums to global offsets, then fills the single output+buffers in parallel.+-}+runProbe ::+    Bool ->+    ProbeIndex ->+    VU.Vector Int ->+    IO (VU.Vector Int, VU.Vector Int)+runProbe keepUnmatched pidx probeHashes = do+    caps <- getNumCapabilities+    let !probeN = VU.length probeHashes+        !nChunks = max 1 (min caps probeN)+        !sorted = piSorted pidx+        !lookupFn = piLookup pidx+        chunkBounds k = (lo, hi)+          where+            !lo = (probeN * k) `div` nChunks+            !hi = (probeN * (k + 1)) `div` nChunks+        -- Count pass: output rows produced by probe range [lo, hi).+        countRange !lo !hi =+            let go !i !acc+                    | i >= hi = acc+                    | otherwise =+                        let (!start, !len) = lookupFn (VU.unsafeIndex probeHashes i)+                         in if start < 0+                                then go (i + 1) (if keepUnmatched then acc + 1 else acc)+                                else go (i + 1) (acc + len)+             in go lo 0+    chunkCounts <- VUM.new (nChunks + 1)+    forkRanges nChunks $ \k ->+        let (lo, hi) = chunkBounds k+         in VUM.unsafeWrite chunkCounts k (countRange lo hi)+    -- Exclusive prefix sum -> per-chunk global start offsets; total at [nChunks].+    let scan !k !acc+            | k > nChunks = pure acc+            | otherwise = do+                c <- if k < nChunks then VUM.unsafeRead chunkCounts k else pure 0+                VUM.unsafeWrite chunkCounts k acc+                scan (k + 1) (acc + c)+    !total <- scan 0 0+    pv <- VUM.unsafeNew (max 1 total)+    bv <- VUM.unsafeNew (max 1 total)+    -- Fill pass: each chunk writes from its prefix-sum offset.+    offs <- VU.unsafeFreeze chunkCounts+    forkRanges nChunks $ \k -> do+        let (lo, hi) = chunkBounds k+            !base = VU.unsafeIndex offs k+            fill !i !p+                | i >= hi = pure ()+                | otherwise = do+                    let (!start, !len) = lookupFn (VU.unsafeIndex probeHashes i)+                    if start < 0+                        then+                            if keepUnmatched+                                then do+                                    VUM.unsafeWrite pv p i+                                    VUM.unsafeWrite bv p (-1)+                                    fill (i + 1) (p + 1)+                                else fill (i + 1) p+                        else do+                            let writeMatch !j !q+                                    | j >= len = pure ()+                                    | otherwise = do+                                        VUM.unsafeWrite pv q i+                                        VUM.unsafeWrite bv q (VU.unsafeIndex sorted (start + j))+                                        writeMatch (j + 1) (q + 1)+                            writeMatch 0 p+                            fill (i + 1) (p + len)+        fill lo base+    pf <- VU.unsafeFreeze (VUM.slice 0 total pv)+    bf <- VU.unsafeFreeze (VUM.slice 0 total bv)+    pure (pf, bf)++{- | Run @body k@ for @k@ in @[0, nChunks)@, one chunk per task, on @nChunks@+forked threads; rethrow the first failure. Chunk @k@ is owned by exactly one+thread, so concurrent writes to disjoint output regions are race-free.+-}+forkRanges :: Int -> (Int -> IO ()) -> IO ()+forkRanges nChunks body = do+    vars <- mapM spawn [0 .. nChunks - 1]+    results <- mapM takeMVar vars+    mapM_ (either (throwIO :: SomeException -> IO ()) pure) results+  where+    spawn k = do+        var <- newEmptyMVar+        _ <- forkIO (try (body k) >>= putMVar var)+        pure var
src/DataFrame/Operations/Permutation.hs view
@@ -27,6 +27,7 @@     unsafeGetColumn,  ) import DataFrame.Internal.Expression (Expr (Col), getColumns)+import DataFrame.Internal.PackedText (packedSlice, sliceCmpBytes) import DataFrame.Operations.Core (dimensions) import DataFrame.Operations.Transformations (derive) import System.Random (Random (randomR), RandomGen)@@ -117,6 +118,12 @@         UnboxedColumn _ (v :: VU.Vector b) -> case testEquality (typeRep @a) (typeRep @b) of             Just Refl -> \i j -> compare (v `VU.unsafeIndex` i) (v `VU.unsafeIndex` j)             Nothing -> \_ _ -> EQ+        PackedText _ p -> case testEquality (typeRep @a) (typeRep @T.Text) of+            Just Refl -> \i j ->+                let (ai, oi, li) = packedSlice p i+                    (aj, oj, lj) = packedSlice p j+                 in sliceCmpBytes ai oi li aj oj lj+            Nothing -> \_ _ -> EQ sortOrderComparator (Desc (Col name :: Expr a)) df =     case unsafeGetColumn name df of         BoxedColumn _ (v :: V.Vector b) -> case testEquality (typeRep @a) (typeRep @b) of@@ -124,6 +131,12 @@             Nothing -> \_ _ -> EQ         UnboxedColumn _ (v :: VU.Vector b) -> case testEquality (typeRep @a) (typeRep @b) of             Just Refl -> \i j -> compare (v `VU.unsafeIndex` j) (v `VU.unsafeIndex` i)+            Nothing -> \_ _ -> EQ+        PackedText _ p -> case testEquality (typeRep @a) (typeRep @T.Text) of+            Just Refl -> \i j ->+                let (ai, oi, li) = packedSlice p i+                    (aj, oj, lj) = packedSlice p j+                 in sliceCmpBytes aj oj lj ai oi li             Nothing -> \_ _ -> EQ sortOrderComparator _ _ = error "Sorting on compound column" 
src/DataFrame/Operations/Statistics.hs view
@@ -248,6 +248,7 @@     Just ((BoxedColumn _ (column :: V.Vector a'))) -> case testEquality (typeRep @a') (typeRep @a) of         Just Refl -> VG.sum column         Nothing -> 0+    Just (PackedText _ _) -> 0 sum expr df = case interpret df expr of     Left e -> throw e     Right (TColumn xs) -> case toVector @a @V.Vector xs of
src/DataFrame/Operations/Subset.hs view
@@ -45,6 +45,7 @@  ) import DataFrame.Internal.Expression import DataFrame.Internal.Interpreter+import DataFrame.Internal.PackedText (packedIndexText, packedLength) import DataFrame.Operations.Core () import DataFrame.Operations.Merge () import DataFrame.Operations.Transformations (apply)@@ -130,10 +131,12 @@     -- | Dataframe to filter     DataFrame ->     DataFrame-filter (Col filterColumnName) condition df = case getColumn filterColumnName df of+filter e@(Col filterColumnName) condition df = case getColumn filterColumnName df of     Nothing ->         throw $             ColumnsNotFoundException [filterColumnName] "filter" (M.keys $ columnIndices df)+    Just c@(PackedText _ _) ->+        filter e condition (insertColumn filterColumnName (materializePacked c) df)     Just _col@(BoxedColumn bm (column :: V.Vector b)) ->         -- Check direct type match first, then try Maybe b match for nullable columns         case testEquality (typeRep @a) (typeRep @b) of@@ -240,6 +243,8 @@             filter (Col @(Maybe a) colName) isJust df & apply @(Maybe a) fromJust colName         UnboxedColumn (Just _) (_col :: VU.Vector a) ->             filter (Col @(Maybe a) colName) isJust df & apply @(Maybe a) fromJust colName+        c@(PackedText (Just _) _) ->+            filterJust colName (insertColumn colName (materializePacked c) df)         _ -> df     Just _ -> df @@ -255,6 +260,8 @@     Just column | hasMissing column -> case column of         BoxedColumn (Just _) (_col :: V.Vector a) -> filter (Col @(Maybe a) colName) isNothing df         UnboxedColumn (Just _) (_col :: VU.Vector a) -> filter (Col @(Maybe a) colName) isNothing df+        c@(PackedText (Just _) _) ->+            filterNothing colName (insertColumn colName (materializePacked c) df)         _ -> df     _ -> df @@ -484,6 +491,10 @@         Just bitmap ->             V.generate (VU.length col') $ \i ->                 if bitmapTestBit bitmap i then T.pack (show (col' VU.! i)) else "null"+columnToTextVec (PackedText bm p) =+    V.generate (packedLength p) $ \i -> case bm of+        Just bitmap | not (bitmapTestBit bitmap i) -> "null"+        _ -> packedIndexText p i  -- | Build a map from stringified label to row indices. groupByIndices :: Column -> M.Map T.Text (VU.Vector Int)
src/DataFrame/Operations/Typing.hs view
@@ -6,7 +6,13 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -module DataFrame.Operations.Typing where+-- 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@@ -30,6 +36,7 @@     ensureOptional,     finalizeParseResult,     fromVector,+    materializePacked,  ) import DataFrame.Internal.DataFrame (     DataFrame (..),@@ -39,11 +46,10 @@ import DataFrame.Internal.Parsing import DataFrame.Internal.Schema import DataFrame.Operations.Core ()+import DataFrame.Operations.Inference import Text.Read import Type.Reflection -type DateFormat = String- {- | How parse failures are surfaced in the resulting column.  * 'NoSafeRead' — strict parsing: failures throw (via 'read').@@ -228,14 +234,16 @@         (parseUnboxedColumnWithPred False isNull readBool cols)         (handleTextAssumption isNull cols) +{- | Int columns: one fused pass with in-place Int -> Double promotion+('promoteIntColumn'); a cell parsing as neither demotes to Text over+the retained raw cells. 'readIntStrict' rejects overflow so a huge+integer promotes to its true 'Double' value instead of wrapping.+-} handleIntAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column handleIntAssumption isNull cols =-    case parseUnboxedColumnWithPred 0 isNull readInt cols of-        Just (mbm, vec) -> UnboxedColumn mbm vec-        Nothing ->-            unboxedOrFallback-                (parseUnboxedColumnWithPred 0 isNull readDouble cols)-                (handleTextAssumption 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 =@@ -334,14 +342,6 @@ convertOnlyEmpty :: T.Text -> Maybe T.Text convertOnlyEmpty v = if v == "" then Nothing else Just v -parseTimeOpt :: DateFormat -> T.Text -> Maybe Day-parseTimeOpt dateFormat s =-    parseTimeM {- Accept leading/trailing whitespace -}-        True-        defaultTimeLocale-        dateFormat-        (T.unpack s)- unsafeParseTime :: DateFormat -> T.Text -> Day unsafeParseTime dateFormat s =     parseTimeOrError {- Accept leading/trailing whitespace -}@@ -361,40 +361,6 @@     hasSameConstructor Nothing Nothing = True     hasSameConstructor _ _ = False -makeParsingAssumption ::-    DateFormat -> V.Vector (Maybe T.Text) -> ParsingAssumption-makeParsingAssumption dateFormat asMaybeText-    -- All the examples are "NA", "Null", "", so we can't make any shortcut-    -- assumptions and just have to go the long way.-    | V.all (== Nothing) asMaybeText = NoAssumption-    -- After accounting for nulls, parsing for Ints and Doubles results in the-    -- same corresponding positions of Justs and Nothings, so we assume-    -- that the best way to parse is Int-    | vecSameConstructor asMaybeText asMaybeBool = BoolAssumption-    | vecSameConstructor asMaybeText asMaybeInt-        && vecSameConstructor asMaybeText asMaybeDouble =-        IntAssumption-    -- After accounting for nulls, the previous condition fails, so some (or none) can be parsed as Ints-    -- and some can be parsed as Doubles, so we make the assumpotion of doubles.-    | vecSameConstructor asMaybeText asMaybeDouble = DoubleAssumption-    -- After accounting for nulls, parsing for Dates results in the same corresponding-    -- positions of Justs and Nothings, so we assume that the best way to parse is Date.-    | vecSameConstructor asMaybeText asMaybeDate = DateAssumption-    | otherwise = TextAssumption-  where-    asMaybeBool = V.map (>>= readBool) asMaybeText-    asMaybeInt = V.map (>>= readInt) asMaybeText-    asMaybeDouble = V.map (>>= readDouble) asMaybeText-    asMaybeDate = V.map (>>= parseTimeOpt dateFormat) asMaybeText--data ParsingAssumption-    = BoolAssumption-    | IntAssumption-    | DoubleAssumption-    | DateAssumption-    | NoAssumption-    | TextAssumption- {- | Re-type columns of a 'DataFrame' according to the supplied schema map. The caller provides a @resolveMode@ function that maps a column name to its 'SafeReadMode' — typically built from a global default plus an overrides map@@ -426,6 +392,9 @@         EitherRead -> fromVector (V.map ((readEitherRaw @a) . toStr) col)      asType :: SafeReadMode -> SchemaType -> Column -> Column+    -- A raw CSV string column may arrive as PackedText; decode to boxed Text+    -- so the re-parse arms below can read the cells.+    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
src/DataFrame/Typed/Join.hs view
@@ -26,7 +26,11 @@ -- | Typed inner join on one or more key columns. innerJoin ::     forall (keys :: [Symbol]) left right.-    (AllKnownSymbol keys) =>+    ( AllKnownSymbol keys+    , AssertAllPresent keys left+    , AssertAllPresent keys right+    , AssertKeyTypesMatch keys left right+    ) =>     TypedDataFrame left ->     TypedDataFrame right ->     TypedDataFrame (InnerJoinSchema keys left right)@@ -38,7 +42,11 @@ -- | Typed left join. leftJoin ::     forall (keys :: [Symbol]) left right.-    (AllKnownSymbol keys) =>+    ( AllKnownSymbol keys+    , AssertAllPresent keys left+    , AssertAllPresent keys right+    , AssertKeyTypesMatch keys left right+    ) =>     TypedDataFrame left ->     TypedDataFrame right ->     TypedDataFrame (LeftJoinSchema keys left right)@@ -50,7 +58,11 @@ -- | Typed right join. rightJoin ::     forall (keys :: [Symbol]) left right.-    (AllKnownSymbol keys) =>+    ( AllKnownSymbol keys+    , AssertAllPresent keys left+    , AssertAllPresent keys right+    , AssertKeyTypesMatch keys left right+    ) =>     TypedDataFrame left ->     TypedDataFrame right ->     TypedDataFrame (RightJoinSchema keys left right)@@ -62,7 +74,11 @@ -- | Typed full outer join. fullOuterJoin ::     forall (keys :: [Symbol]) left right.-    (AllKnownSymbol keys) =>+    ( AllKnownSymbol keys+    , AssertAllPresent keys left+    , AssertAllPresent keys right+    , AssertKeyTypesMatch keys left right+    ) =>     TypedDataFrame left ->     TypedDataFrame right ->     TypedDataFrame (FullOuterJoinSchema keys left right)