packages feed

dataframe-fastcsv 1.1.0.1 → 1.1.1.0

raw patch · 18 files changed

+2495/−562 lines, 18 filesdep +timedep −arraydep −paralleldep ~dataframe-coredep ~dataframe-csvdep ~dataframe-operationsPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: time

Dependencies removed: array, parallel

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

API changes (from Hackage documentation)

- DataFrame.IO.CSV.Fast: instance GHC.Classes.Eq DataFrame.IO.CSV.Fast.CsvParseError
- DataFrame.IO.CSV.Fast: instance GHC.Enum.Enum DataFrame.IO.CSV.Fast.State
- DataFrame.IO.CSV.Fast: instance GHC.Exception.Type.Exception DataFrame.IO.CSV.Fast.CsvParseError
- DataFrame.IO.CSV.Fast: instance GHC.Show.Show DataFrame.IO.CSV.Fast.CsvParseError
+ DataFrame.IO.CSV.Fast: readSeparatedFromBytesChunks :: Int -> Word8 -> ReadOptions -> ByteString -> IO DataFrame

Files

cbits/process_csv.c view
@@ -119,8 +119,12 @@ // I owe a debt to https://github.com/geofflangdale/simdcsv // Let's go ahead and assume `in` will only ever get 64 bytes // initial_quoted will be either all_ones ~0ULL or all_zeros 0ULL+// Also reports the unquoted-newline bits through `newline_bits` so the+// chunked scan can classify row ends without re-touching file bytes. #ifdef HAS_SIMD_CSV-static uint64_t parse_chunk(uint8_t *in, uint8_t separator, uint64_t *initial_quoted) {+static uint64_t parse_chunk_nl(uint8_t *in, uint8_t separator,+                               uint64_t *initial_quoted,+                               uint64_t *newline_bits) {   uint64_t quotebits = find_character_in_chunk(in, QUOTE_CHAR);   // See https://wunkolo.github.io/post/2020/05/pclmulqdq-tricks/   // Also, section 3.1.1 of Parsing Gigabytes of JSON per Second,@@ -144,9 +148,16 @@   uint64_t commabits = find_character_in_chunk(in, separator);   uint64_t newlinebits = find_character_in_chunk(in, NEWLINE_CHAR); -  uint64_t delimiter_bits = (commabits | newlinebits) & ~quotemask;+  uint64_t not_quoted = ~quotemask;+  *newline_bits = newlinebits & not_quoted;+  uint64_t delimiter_bits = (commabits | newlinebits) & not_quoted;   return delimiter_bits; }++static uint64_t parse_chunk(uint8_t *in, uint8_t separator, uint64_t *initial_quoted) {+  uint64_t newline_bits;+  return parse_chunk_nl(in, separator, initial_quoted, &newline_bits);+} #endif  #ifdef HAS_SIMD_CSV@@ -201,6 +212,74 @@   // SIMD not available or carryless multiplication not supported.   // Signal fallback to Haskell implementation.   (void)buf; (void)len; (void)separator; (void)indices;+  return GDI_SIMD_UNAVAILABLE;+#endif+}++// Chunked entry points for the parallel scan (Round-2 WS-E2).  A worker+// owns a 64-byte-aligned byte range [base, base+len) of the file; the+// caller supplies the quote parity at `base` (from a prefix-xor of+// per-range quote counts, which is exact because every quote byte+// toggles the PCLMUL parity chain).++// Pass 1: exact delimiter / row-end counts for one range, so the flat+// output arrays can be right-sized and each worker gets an exact write+// offset.  `len` must be a multiple of 64.+size_t count_delimiters_chunk(const uint8_t *buf, size_t base, size_t len,+                              uint8_t separator, int start_in_quote,+                              size_t *newline_count) {+#ifdef HAS_SIMD_CSV+  uint64_t initial_quoted = start_in_quote ? ALL_ONES_MASK : ALL_ZEROS_MASK;+  size_t count = 0;+  size_t newlines = 0;+  for (size_t i = 0; i + 64 <= len; i += 64) {+    uint64_t newline_bits;+    uint64_t bits = parse_chunk_nl((uint8_t *)buf + base + i, separator,+                                   &initial_quoted, &newline_bits);+    count += (size_t)__builtin_popcountll(bits);+    newlines += (size_t)__builtin_popcountll(newline_bits);+  }+  *newline_count = newlines;+  return count;+#else+  (void)buf; (void)base; (void)len; (void)separator; (void)start_in_quote;+  (void)newline_count;+  return GDI_SIMD_UNAVAILABLE;+#endif+}++// Pass 2: emit absolute delimiter byte positions into `indices` and, for+// every unquoted newline, its delimiter ordinal (`ordinal_base` + local+// position) into `rowends` -- the exact shape Haskell's classifyRowEnds+// used to recompute by re-reading one file byte per delimiter.+size_t emit_delimiter_indices_chunk(const uint8_t *buf, size_t base,+                                    size_t len, uint8_t separator,+                                    int start_in_quote, size_t ordinal_base,+                                    size_t *indices, size_t *rowends,+                                    size_t *rowend_count) {+#ifdef HAS_SIMD_CSV+  uint64_t initial_quoted = start_in_quote ? ALL_ONES_MASK : ALL_ZEROS_MASK;+  size_t pos = 0;+  size_t rc = 0;+  for (size_t i = 0; i + 64 <= len; i += 64) {+    uint64_t newline_bits;+    uint64_t bits = parse_chunk_nl((uint8_t *)buf + base + i, separator,+                                   &initial_quoted, &newline_bits);+    while (bits != 0) {+      size_t r = (size_t)__builtin_ctzll(bits);+      indices[pos] = base + i + r;+      if ((newline_bits >> r) & 1ULL) {+        rowends[rc++] = ordinal_base + pos;+      }+      pos++;+      bits &= bits - 1;+    }+  }+  *rowend_count = rc;+  return pos;+#else+  (void)buf; (void)base; (void)len; (void)separator; (void)start_in_quote;+  (void)ordinal_base; (void)indices; (void)rowends; (void)rowend_count;   return GDI_SIMD_UNAVAILABLE; #endif }
cbits/process_csv.h view
@@ -42,4 +42,16 @@ size_t get_delimiter_indices(uint8_t *buf, size_t len, uint8_t separator,                              size_t *indices, int *status); +// Chunked parallel-scan entry points (see process_csv.c).  Both return+// GDI_SIMD_UNAVAILABLE when the build/CPU lacks SIMD support.+size_t count_delimiters_chunk(const uint8_t *buf, size_t base, size_t len,+                              uint8_t separator, int start_in_quote,+                              size_t *newline_count);++size_t emit_delimiter_indices_chunk(const uint8_t *buf, size_t base,+                                    size_t len, uint8_t separator,+                                    int start_in_quote, size_t ordinal_base,+                                    size_t *indices, size_t *rowends,+                                    size_t *rowend_count);+ #endif
dataframe-fastcsv.cabal view
@@ -1,7 +1,6 @@ cabal-version:      2.4 name:               dataframe-fastcsv-version:            1.1.0.1-+version:            1.1.1.0 synopsis: SIMD-accelerated CSV reader for the dataframe library.  description: A fast, SIMD-accelerated CSV/TSV reader using memory-mapped I/O@@ -46,17 +45,25 @@     import: warnings     default-extensions: Strict     exposed-modules: DataFrame.IO.CSV.Fast+    other-modules:   DataFrame.IO.CSV.Fast.Columns+                     DataFrame.IO.CSV.Fast.Core+                     DataFrame.IO.CSV.Fast.Index+                     DataFrame.IO.CSV.Fast.IndexPar+                     DataFrame.IO.CSV.Fast.Parallel+                     DataFrame.IO.CSV.Fast.Passes+                     DataFrame.IO.CSV.Fast.Slice+                     DataFrame.IO.CSV.Fast.TextMerge+                     DataFrame.IO.CSV.Fast.Workers     build-depends:    base >= 4 && < 5,-                      array >= 0.5.4.0 && < 0.6,                       bytestring >= 0.11 && < 0.13,                       containers >= 0.6.7 && < 0.9,-                      dataframe-core ^>= 1.0,-                      dataframe-csv ^>= 1.0,-                      dataframe-operations ^>= 1.1,-                      dataframe-parsing ^>= 1.0,+                      dataframe-core ^>= 1.1,+                      dataframe-csv ^>= 1.0.2,+                      dataframe-operations ^>= 1.1.1,+                      dataframe-parsing ^>= 1.0.2,                       mmap >= 0.5.8 && < 0.6,-                      parallel >= 3.2.2.0 && < 5,                       text >= 2.0 && < 3,+                      time >= 1.12 && < 2,                       vector ^>= 0.13     hs-source-dirs:   src     c-sources:        cbits/process_csv.c@@ -73,19 +80,26 @@     import: warnings     type: exitcode-stdio-1.0     main-is: Main.hs-    other-modules: Operations.ReadCsv+    -- -threaded so the chunk-parallel path actually runs on multiple+    -- capabilities (the library itself does not choose the RTS).+    ghc-options: -threaded -rtsopts "-with-rtsopts=-N"+    other-modules: Operations.ChunkParallel+                   Operations.ReadCsv+                   Operations.TypedExtraction                    Properties.Csv     build-depends:  base >= 4 && < 5,                     containers >= 0.6.7 && < 0.9,                     dataframe >= 1 && < 3,-                    dataframe-core ^>= 1.0,-                    dataframe-csv ^>= 1.0,+                    dataframe-core ^>= 1.1,+                    dataframe-csv ^>= 1.0.2,                     dataframe-fastcsv,-                    dataframe-parsing ^>= 1.0,+                    dataframe-operations ^>= 1.1.1,+                    dataframe-parsing ^>= 1.0.2,                     directory >= 1.3.0.0 && < 2,                     HUnit ^>= 1.6,                     QuickCheck >= 2 && < 3,                     text >= 2.0 && < 3,+                    time >= 1.12 && < 2,                     vector ^>= 0.13     hs-source-dirs: tests     default-language: Haskell2010
src/DataFrame/IO/CSV/Fast.hs view
@@ -1,7 +1,14 @@-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE CApiFFI #-}-{-# LANGUAGE ForeignFunctionInterface #-}+{- | SIMD-accelerated CSV reader: a C scanner finds delimiter positions,+then each column is parsed directly from the mmap'd byte slices into+unboxed column builders (Round-2 typed extraction). Reads are strict: the+returned 'DataFrame' is fully built, with no deferred parse work. +Extraction is chunk-parallel: inputs of 4 MB and up are split into one+row chunk per capability and parsed by 'Control.Concurrent.forkIO'+workers. The library never chooses RTS settings — to actually get+parallel reads, link the executable with @-threaded@ and run with+@+RTS -N@ (otherwise the reader falls back to the sequential path).+-} module DataFrame.IO.CSV.Fast (     fastReadCsv,     readCsvFast,@@ -15,68 +22,35 @@     fastReadCsvFromBytesWithSchema,     readSeparated,     readSeparatedFromBytes,+    readSeparatedFromBytesChunks,     getDelimiterIndices,     CsvParseError (..), ) where -import qualified Data.Vector as Vector-import qualified Data.Vector.Storable as VS-import Data.Vector.Storable.Mutable (-    grow,-    unsafeFromForeignPtr,- )-import qualified Data.Vector.Storable.Mutable as VSM-import System.IO.MMap (-    Mode (WriteCopy),-    mmapFileForeignPtr,- )--import Control.Exception (Exception, throwIO)-import Control.Monad (when)-import Foreign (-    Ptr,-    castForeignPtr,-    castPtr,- )-import qualified Foreign-import Foreign.C.Types-import Foreign.Marshal.Alloc (alloca)-import Foreign.Marshal.Utils (copyBytes)-import Foreign.Storable (peek, poke)- import qualified Data.ByteString as BS-import Data.ByteString.Internal (ByteString (PS)) import qualified Data.ByteString.Internal as BSI import qualified Data.Map as M+import qualified Data.Vector as Vector+import qualified Data.Vector.Storable as VS+ import Data.Text (Text)-import qualified Data.Text as Text-import qualified Data.Text.Encoding as TextEncoding import Data.Word (Word8)--import Control.Parallel.Strategies (parList, rpar, using)-import Data.Array.IArray (array, (!))-import Data.Array.Unboxed (UArray)-import Data.Ix (range)+import System.IO.MMap (Mode (WriteCopy), mmapFileForeignPtr)  import DataFrame.IO.CSV (-    HeaderSpec (..),-    RaggedRowPolicy (..),     ReadOptions (..),     TypeSpec (..),-    UnclosedQuotePolicy (..),     defaultReadOptions,-    schemaTypeMap,-    shouldInferFromSample,-    typeInferenceSampleSize,  )+import DataFrame.IO.CSV.Fast.Core (readSeparatedCore)+import DataFrame.IO.CSV.Fast.Index (+    CsvParseError (..),+    comma,+    getDelimiterIndices,+    tab,+ ) import DataFrame.Internal.DataFrame (DataFrame (..)) import DataFrame.Internal.Schema (Schema (..))-import DataFrame.Operations.Typing (-    ParseOptions (..),-    effectiveSafeRead,-    parseFromExamples,-    parseWithTypes,- )  readSeparatedDefault :: Word8 -> FilePath -> IO DataFrame readSeparatedDefault separator =@@ -106,9 +80,8 @@ fastReadTsvWithOpts = readSeparated tab  {- | Read a CSV and coerce each column to the type declared in the-supplied 'Schema'.  Columns mentioned in the schema bypass inference;-columns absent from the schema fall back to the default inference-path.  Use when the schema is known (DDL, prior runs) — it's both+supplied 'Schema'.  Schema columns bypass inference entirely: they are+parsed straight from the file bytes as the declared type, which is both faster than inference and guards against the row-1 \"looks like Int\" misclassification trap. -}@@ -121,13 +94,6 @@                 SpecifyTypes (M.toList (elements schema)) (typeSpec defaultReadOptions)             } -{- | Read a CSV and return only the named columns, in the order given.-The SIMD scan and delimiter classification still run over the whole-file, but 'extractField' and 'parseFromExamples' are skipped for-unreferenced columns, so the end-to-end cost scales with the size of-the projection rather than the row width.--}- -- | In-memory version of 'fastReadCsv'. fastReadCsvFromBytes :: BS.ByteString -> IO DataFrame fastReadCsvFromBytes = readSeparatedFromBytes comma defaultReadOptions@@ -141,6 +107,10 @@                 SpecifyTypes (M.toList (elements schema)) (typeSpec defaultReadOptions)             } +{- | Read a CSV and return only the named columns, in the order given.+The SIMD scan and delimiter classification still run over the whole+file, but unreferenced columns are dropped from the result.+-} fastReadCsvProj :: [Text] -> FilePath -> IO DataFrame fastReadCsvProj projection path = do     df <- fastReadCsv path@@ -165,20 +135,22 @@     FilePath ->     IO DataFrame readSeparated separator opts filePath = do-    -- We use write copy mode so that we can append-    -- padding to the end of the memory space+    -- WriteCopy keeps the mapping private; the scanner never needs to+    -- grow or pad the buffer, so the file is no longer copied (audit F6).     (bufferPtr, offset, len) <-         mmapFileForeignPtr             filePath             WriteCopy             Nothing-    let mutableFile = unsafeFromForeignPtr bufferPtr offset len-    readSeparatedFromMVec separator opts mutableFile len+    readSeparatedCore+        Nothing+        separator+        opts+        (VS.unsafeFromForeignPtr bufferPtr offset len) -{- | Identical to 'readSeparated' but takes an already-loaded byte buffer-(e.g. a slice of a memory-mapped file).  The bytes are copied once into-a fresh mutable storable vector so the SIMD code can grow it by 64-bytes of trailing padding without disturbing the caller's input.+{- | Identical to 'readSeparated' but reads from an already-loaded byte+buffer (e.g. a slice of a memory-mapped file). Zero-copy: the bytes are+only read, never modified or grown. -} readSeparatedFromBytes ::     Word8 ->@@ -186,487 +158,23 @@     BS.ByteString ->     IO DataFrame readSeparatedFromBytes separator opts bs = do-    let len = BS.length bs-    mvec <- VSM.unsafeNew len-    -- Copy ByteString → mutable vector (one pass, ~1 ns/byte).-    let (fp, off, _) = BSI.toForeignPtr bs-    Foreign.withForeignPtr fp $ \srcPtr ->-        VSM.unsafeWith mvec $ \dstPtr ->-            copyBytes (castPtr dstPtr) (srcPtr `Foreign.plusPtr` off) len-    readSeparatedFromMVec separator opts mvec len+    let (fp, off, len) = BSI.toForeignPtr bs+    readSeparatedCore Nothing separator opts (VS.unsafeFromForeignPtr fp off len) -{- | Shared core: takes a mutable storable vector containing exactly @len@-bytes of CSV (no trailing padding required by the caller — it is added-here) and produces a 'DataFrame'.+{- | 'readSeparatedFromBytes' with a forced chunk count, bypassing the+capability/size gate. Intended for tests and benchmarks that need to pin+the parallel split; @1@ forces the sequential path. -}-readSeparatedFromMVec ::+readSeparatedFromBytesChunks ::+    Int ->     Word8 ->     ReadOptions ->-    VSM.IOVector Word8 ->-    Int ->+    BS.ByteString ->     IO DataFrame-readSeparatedFromMVec separator opts mutableFile len = do-    paddedMutableFile <- grow mutableFile 64-    paddedCSVFileRaw <- VS.unsafeFreeze paddedMutableFile-    let (paddedCSVFile, bomLen) = stripBom paddedCSVFileRaw-        contentLen = len - bomLen-    indices <--        getDelimiterIndicesPolicy-            (fastCsvOnUnclosedQuote opts)-            separator-            contentLen-            paddedCSVFile-    let rowEnds = classifyRowEnds paddedCSVFile contentLen indices-        totalRows = VS.length rowEnds-    if totalRows == 0-        then return emptyDataFrame-        else do-            let headerNumCol = fieldsInRow rowEnds 0-                trim = fastCsvTrimUnquoted opts-                extractAt =-                    extractField trim paddedCSVFile indices rowEnds contentLen-                (columnNames, dataStartRow, numCol) = case headerSpec opts of-                    NoHeader ->-                        ( Vector.fromList $-                            map (Text.pack . show) [0 .. headerNumCol - 1]-                        , 0-                        , headerNumCol-                        )-                    UseFirstRow ->-                        ( Vector.fromList $-                            map (extractAt 0) [0 .. headerNumCol - 1]-                        , 1-                        , headerNumCol-                        )-                    ProvideNames ns ->-                        (Vector.fromList ns, 0, length ns)-            if numCol == 0-                then return emptyDataFrame-                else do-                    let dataRows =-                            collectDataRows-                                paddedCSVFile-                                indices-                                rowEnds-                                contentLen-                                dataStartRow-                                totalRows-                        numRow = VS.length dataRows-                    when (fastCsvOnRaggedRow opts == RaiseOnRagged) $-                        checkNoRaggedRows rowEnds dataRows numCol-                    let parseTypes name col =-                            let n =-                                    if shouldInferFromSample (typeSpec opts)-                                        then typeInferenceSampleSize (typeSpec opts)-                                        else 0-                                mode =-                                    effectiveSafeRead-                                        (safeRead opts)-                                        (safeReadOverrides opts)-                                        name-                                parseOpts =-                                    ParseOptions-                                        { missingValues = missingIndicators opts-                                        , sampleSize = n-                                        , parseSafe = mode-                                        , parseSafeOverrides = []-                                        , parseDateFormat = dateFormat opts-                                        }-                             in parseFromExamples parseOpts col-                        generateColumn col =-                            parseTypes (columnNames Vector.! col) $-                                Vector.generate numRow $ \i ->-                                    extractAt (dataRows VS.! i) col-                        cols =-                            Vector.fromListN-                                numCol-                                ( map generateColumn [0 .. numCol - 1]-                                    `using` parList rpar-                                )-                        colIndices =-                            M.fromList $-                                zip (Vector.toList columnNames) [0 ..]-                        dfDims = (numRow, numCol)-                    let rawDf =-                            DataFrame-                                cols-                                colIndices-                                dfDims-                                M.empty-                        schemaMap = schemaTypeMap (typeSpec opts)-                        resolveMode =-                            effectiveSafeRead-                                (safeRead opts)-                                (safeReadOverrides opts)-                    return $!-                        if M.null schemaMap-                            then rawDf-                            else parseWithTypes resolveMode schemaMap rawDf--{- | An empty 'DataFrame' — returned when the input has no delimiters-(empty file or single line with no separator and no newline). Guards-against a divide-by-zero in the row-stride math when 'numCol == 0'.--}-emptyDataFrame :: DataFrame-emptyDataFrame = DataFrame Vector.empty M.empty (0, 0) M.empty--{- | Strip a leading UTF-8 BOM (EF BB BF) if present. Returns the trimmed-vector and the number of bytes removed (0 or 3).--}-{-# INLINE stripBom #-}-stripBom :: VS.Vector Word8 -> (VS.Vector Word8, Int)-stripBom v-    | VS.length v >= 3-    , VS.unsafeIndex v 0 == 0xEF-    , VS.unsafeIndex v 1 == 0xBB-    , VS.unsafeIndex v 2 == 0xBF =-        (VS.drop 3 v, 3)-    | otherwise = (v, 0)--{- | Classify each entry in the flat delimiter vector as either a row-terminator or a field terminator, and return the indices-into-the-vector-of every row terminator.  A position counts as a row break if either--  * the byte at that position is @\\n@, or-  * the position is beyond the original content length, which only-    happens for the synthetic end-of-file delimiter written when a file-    does not end in a newline.--Field terminators (commas / tabs / the configured separator) are left-implicit: anything between consecutive row terminators is a field.--}-{-# INLINE classifyRowEnds #-}-classifyRowEnds :: VS.Vector Word8 -> Int -> VS.Vector CSize -> VS.Vector Int-classifyRowEnds file contentLen =-    VS.findIndices isRowBreak-  where-    isRowBreak pos =-        let p = fromIntegral pos :: Int-         in p >= contentLen || VS.unsafeIndex file p == lf--{- | Number of fields that row @r@ contains, derived directly from the-gap between consecutive entries in @rowEnds@.--}-{-# INLINE fieldsInRow #-}-fieldsInRow :: VS.Vector Int -> Int -> Int-fieldsInRow rowEnds r =-    let endIdx = VS.unsafeIndex rowEnds r-        startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1-     in endIdx - startIdx + 1--{- | Does row @r@ contain exactly one, empty field?  That is the case for-a bare @\\n@ or a @\\r\\n@ \u2014 blank lines, which we skip by default to-match pandas / polars @skip_blank_lines@ semantics.  We also treat a-row whose only byte is @\\r@ as blank, so CRLF-terminated blank lines-don't leak through when only the @\\n@ counts as a row break.--}-{-# INLINE isBlankRow #-}-isBlankRow ::-    VS.Vector Word8 ->-    VS.Vector CSize ->-    VS.Vector Int ->-    Int ->-    Int ->-    Bool-isBlankRow file delimiters rowEnds contentLen r =-    let endIdx = VS.unsafeIndex rowEnds r-        startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1-        numFields = endIdx - startIdx + 1-     in numFields == 1-            && let fieldEndRaw =-                    fromIntegral (VS.unsafeIndex delimiters startIdx) :: Int-                   fieldEnd = min fieldEndRaw contentLen-                   fieldStart =-                    if startIdx == 0-                        then 0-                        else-                            fromIntegral-                                (VS.unsafeIndex delimiters (startIdx - 1))-                                + 1-                   fieldLen = fieldEnd - fieldStart-                in fieldLen == 0-                    || ( fieldLen == 1-                            && VS.unsafeIndex file fieldStart == cr-                       )--{- | Select the row indices that contain actual data: skip @[0 .. skip - 1]@-and drop any blank rows from the remainder.--}-{-# INLINE collectDataRows #-}-collectDataRows ::-    VS.Vector Word8 ->-    VS.Vector CSize ->-    VS.Vector Int ->-    Int ->-    Int ->-    Int ->-    VS.Vector Int-collectDataRows file delimiters rowEnds contentLen skip total =-    VS.filter-        (not . isBlankRow file delimiters rowEnds contentLen)-        (VS.generate (total - skip) (+ skip))--{- | Extract field @col@ of row @r@ as a 'Text'.--Semantics follow RFC 4180:--  * If the raw field is wrapped in @\"..\"@, the outer quotes are-    stripped and any embedded @\"\"@ is unescaped to a single @\"@.-    Whitespace inside the quotes is preserved verbatim.--  * If the raw field is unquoted, it is returned as-is; whitespace-    is preserved by default (matching pandas / polars).  Callers that-    want legacy trim-everything behaviour pass @trimUnquoted = True@.--  * A trailing @\\r@ is dropped before decoding so CRLF files produce-    clean Text on every column, not only when @Text.strip@ happens to-    sweep it up.--If @col@ is out of range (a ragged short row), returns the empty text;-callers convert that into a null via the missing-indicator list that-'parseFromExamples' honours.--}-{-# INLINE extractField #-}-extractField ::-    Bool ->-    VS.Vector Word8 ->-    VS.Vector CSize ->-    VS.Vector Int ->-    Int ->-    Int ->-    Int ->-    Text-extractField trimUnquoted file delimiters rowEnds contentLen r col-    | col >= numFields = Text.empty-    | fieldEnd - fieldStart >= 2-    , VS.unsafeIndex file fieldStart == quote-    , VS.unsafeIndex file (fieldEnd - 1) == quote =-        unescapeDoubledQuotes-            . TextEncoding.decodeUtf8Lenient-            . unsafeToByteString-            $ VS.slice (fieldStart + 1) (fieldEnd - fieldStart - 2) file-    | otherwise =-        (if trimUnquoted then Text.strip else id)-            . TextEncoding.decodeUtf8Lenient-            . unsafeToByteString-            $ VS.slice fieldStart (fieldEnd - fieldStart) file-  where-    endIdx = VS.unsafeIndex rowEnds r-    startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1-    numFields = endIdx - startIdx + 1-    boundaryIdx = startIdx + col-    fieldEndRaw =-        fromIntegral (VS.unsafeIndex delimiters boundaryIdx) :: Int-    fieldEndClamped = min fieldEndRaw contentLen-    fieldStart =-        if boundaryIdx == 0-            then 0-            else-                fromIntegral-                    (VS.unsafeIndex delimiters (boundaryIdx - 1))-                    + 1-    -- Strip a trailing \r (CRLF line endings) from the last field of a row-    -- when the SIMD scanner, which only tracks \n, leaves it behind.-    fieldEnd =-        if fieldEndClamped > fieldStart-            && VS.unsafeIndex file (fieldEndClamped - 1) == cr-            then fieldEndClamped - 1-            else fieldEndClamped-    unsafeToByteString :: VS.Vector Word8 -> BS.ByteString-    unsafeToByteString v = PS (castForeignPtr ptr) 0 n-      where-        (ptr, n) = VS.unsafeToForeignPtr0 v--{- | Walk every data row and throw 'CsvRaggedRow' on the first one whose-field count differs from the header.  Only called when the user opts-into 'RaiseOnRagged' via 'fastCsvOnRaggedRow'.--}-{-# INLINE checkNoRaggedRows #-}-checkNoRaggedRows ::-    VS.Vector Int ->-    VS.Vector Int ->-    Int ->-    IO ()-checkNoRaggedRows rowEnds dataRows numCol =-    VS.mapM_-        ( \r ->-            let actual = fieldsInRow rowEnds r-             in when (actual /= numCol) $-                    throwIO (CsvRaggedRow r numCol actual)-        )-        dataRows--{- | RFC 4180 inner-quote unescape: @\"\"@ → @\"@.  'Text.replace' on a-two-char needle does a single linear pass and is allocation-free when-no doubled quote is present (short-circuits at the first miss).--}-{-# INLINE unescapeDoubledQuotes #-}-unescapeDoubledQuotes :: Text -> Text-unescapeDoubledQuotes t-    | Text.isInfixOf doubledQuote t = Text.replace doubledQuote singleQuote t-    | otherwise = t-  where-    doubledQuote = Text.pack "\"\""-    singleQuote = Text.singleton '"'--{- | Exceptions raised by the fast CSV parser.  Catchable with-'Control.Exception.catch' or 'Control.Exception.try'.--}-data CsvParseError-    = {- | The input ends with a quoted field that was never closed.  The-      PCLMUL quote-parity chain in the SIMD scanner treats an unmatched-      @\"@ as if the rest of the file were inside quotes, so we refuse-      to return a silently corrupted 'DataFrame' and raise instead.-      -}-      CsvUnclosedQuote-    | {- | A row has a different number of fields from the header.  Only-      raised when 'fastCsvOnRaggedRow' is set to 'RaiseOnRagged'.-      Carries the 0-based row index, the expected field count, and the-      actual field count.-      -}-      CsvRaggedRow !Int !Int !Int-    deriving (Eq, Show)--instance Exception CsvParseError---- Status codes reported via the out-parameter of 'get_delimiter_indices'.--- Must stay in sync with @GDI_*@ in @cbits/process_csv.h@.-gdiOk, gdiUnclosedQuote :: CInt-gdiOk = 0-gdiUnclosedQuote = 1--{- | Return value that the C helper uses to tell Haskell \"SIMD isn't-available on this build / CPU, run the pure-Haskell state machine.\"-Mirrors @GDI_SIMD_UNAVAILABLE@ in @cbits/process_csv.h@.  'CSize' is-unsigned so this has the same bit pattern as 'maxBound'.--}-simdUnavailable :: CSize-simdUnavailable = maxBound--foreign import capi "process_csv.h get_delimiter_indices"-    get_delimiter_indices ::-        Ptr CUChar -> -- input-        CSize -> -- input size-        CUChar -> -- separator character-        Ptr CSize -> -- result array-        Ptr CInt -> -- status out-parameter-        IO CSize -- occupancy of result array--{- | Locate delimiter byte positions in @csvFile@.  Treats an unclosed-quoted field at EOF as a hard error; callers that want to suppress the-exception can use 'getDelimiterIndicesPolicy' with 'BestEffort'.--}-{-# INLINE getDelimiterIndices #-}-getDelimiterIndices ::-    Word8 ->-    Int ->-    VS.Vector Word8 ->-    IO (VS.Vector CSize)-getDelimiterIndices = getDelimiterIndicesPolicy RaiseOnUnclosedQuote--{-# INLINE getDelimiterIndicesPolicy #-}-getDelimiterIndicesPolicy ::-    UnclosedQuotePolicy ->-    Word8 ->-    Int ->-    VS.Vector Word8 ->-    IO (VS.Vector CSize)-getDelimiterIndicesPolicy policy separator originalLen csvFile =-    VS.unsafeWith csvFile $ \buffer -> do-        let paddedLen = VS.length csvFile-        -- GC-managed pinned memory: freed automatically, no leak in streaming use.-        resultMV <- VSM.unsafeNew paddedLen-        (num_fields, status) <- alloca $ \statusPtr -> do-            poke statusPtr gdiOk-            n <--                VSM.unsafeWith resultMV $ \indicesPtr ->-                    get_delimiter_indices-                        (castPtr buffer)-                        (fromIntegral paddedLen)-                        (fromIntegral separator)-                        (castPtr indicesPtr)-                        statusPtr-            s <- peek statusPtr-            return (n, s)-        if num_fields == simdUnavailable-            then do-                -- Haskell state-machine fallback, writing directly into resultMV.-                let trans = stateTransitionTable separator-                    processChar (!state, !idx) i byte =-                        case state of-                            UnEscaped ->-                                if byte == lf || byte == separator-                                    then do-                                        VSM.unsafeWrite resultMV idx (fromIntegral i)-                                        return (toEnum (trans ! (fromEnum state, byte)), idx + 1)-                                    else return (toEnum (trans ! (fromEnum state, byte)), idx)-                            Escaped ->-                                return (toEnum (trans ! (fromEnum state, byte)), idx)-                (finalState, finalIdx) <--                    VS.ifoldM' processChar (UnEscaped, 0 :: Int) csvFile-                case finalState of-                    Escaped-                        | policy == RaiseOnUnclosedQuote ->-                            throwIO CsvUnclosedQuote-                    _ -> return ()-                finalLen <--                    if originalLen > 0 && csvFile VS.! (originalLen - 1) /= lf-                        then do-                            VSM.unsafeWrite resultMV finalIdx (fromIntegral originalLen)-                            return (finalIdx + 1)-                        else return finalIdx-                VS.unsafeFreeze (VSM.slice 0 finalLen resultMV)-            else do-                when-                    ( status == gdiUnclosedQuote-                        && policy == RaiseOnUnclosedQuote-                    )-                    (throwIO CsvUnclosedQuote)-                let n = fromIntegral num_fields-                finalLen <--                    if originalLen > 0 && csvFile VS.! (originalLen - 1) /= lf-                        then do-                            VSM.write resultMV n (fromIntegral originalLen)-                            return (n + 1)-                        else return n-                VS.unsafeFreeze (VSM.slice 0 finalLen resultMV)---- We have a Native version in case the C version--- cannot be used. For example if neither ARM_NEON--- nor AVX2 are available--lf, cr, comma, tab, quote :: Word8-lf = 0x0A-cr = 0x0D-comma = 0x2C-tab = 0x09-quote = 0x22---- We parse using a state machine-data State-    = UnEscaped -- non quoted-    | Escaped -- quoted-    deriving (Enum)--{-# INLINE stateTransitionTable #-}-stateTransitionTable :: Word8 -> UArray (Int, Word8) Int-stateTransitionTable separator = array ((0, 0), (1, 255)) [(i, f i) | i <- range ((0, 0), (1, 255))]-  where-    f (0, character)-        -- Unescaped newline-        | character == 0x0A = fromEnum UnEscaped-        -- Unescaped separator-        | character == separator = fromEnum UnEscaped-        -- Unescaped quote-        | character == 0x22 = fromEnum Escaped-        | otherwise = fromEnum UnEscaped-    -- Escaped quote-    -- escaped quote in fields are dealt as-    -- consecutive quoted sections of a field-    -- example: If we have-    -- field1, "abc""def""ghi, field3-    -- we end up processing abc, def, and ghi-    -- as consecutive quoted strings.-    f (1, 0x22) = fromEnum UnEscaped-    -- Everything else-    f (state, _) = state+readSeparatedFromBytesChunks nChunks separator opts bs = do+    let (fp, off, len) = BSI.toForeignPtr bs+    readSeparatedCore+        (Just nChunks)+        separator+        opts+        (VS.unsafeFromForeignPtr fp off len)
+ src/DataFrame/IO/CSV/Fast/Columns.hs view
@@ -0,0 +1,254 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE TypeApplications #-}++{- | Column-level dispatch for the fast CSV reader: resolve each column's+target type up front into a 'ColumnPlan' (schema hit -> typed pass directly;+miss -> sample classification + typed fallback chain; EitherRead \/ exotic+schema types -> the legacy Text pipeline). Plans are chunk-runnable so the+parallel reader (WS-E2) can execute them over row sub-ranges.+-}+module DataFrame.IO.CSV.Fast.Columns (+    ColumnEnv (..),+    ColumnPlan (..),+    Step,+    TargetType,+    ChunkOutcome (..),+    buildColumn,+    planColumn,+    runChainChunk,+    runSchemaChunk,+    runStep,+    legacyColumn,+    allNullColumn,+    finishMode,+    schemaError,+) where++import qualified Data.Map as M+import qualified Data.Proxy as P+import qualified Data.Text as T+import qualified Data.Vector as V++import Control.Exception (ErrorCall (..), throwIO)+import Data.Maybe (isJust)+import Data.Time (Day)+import Data.Type.Equality (testEquality)+import Type.Reflection (typeRep)++import DataFrame.IO.CSV (+    ReadOptions (..),+    schemaTypeMap,+    shouldInferFromSample,+    typeInferenceSampleSize,+ )+import DataFrame.IO.CSV.Fast.Passes+import DataFrame.IO.CSV.Fast.Slice (extractField)+import DataFrame.Internal.Column (Column, ensureOptional, fromVector)+import DataFrame.Internal.Schema (SchemaType (..))+import DataFrame.Operations.Typing (+    ParseOptions (..),+    ParsingAssumption (..),+    SafeReadMode (..),+    effectiveSafeRead,+    isNullishOrMissing,+    makeParsingAssumption,+    parseFromExamples,+ )++-- | One step of a type-fallback chain (always ends in 'StepText').+data Step = StepBool | StepInt | StepDouble | StepDate | StepText++-- | Run one chain step over the env's row range as a single typed pass.+runStep :: ColumnEnv -> NullSpec -> Int -> Bool -> Step -> IO PassResult+runStep env nspec col nullOnFail step = case step of+    StepBool -> boolPass env nspec col nullOnFail+    StepInt -> intPass env nspec col nullOnFail+    StepDouble -> doublePass env nspec col nullOnFail+    StepDate -> datePass env nspec col nullOnFail+    StepText -> Right . PassText <$> textPass env nspec col++-- | The documented fallback chain for each inference assumption.+chainFor :: ParsingAssumption -> [Step]+chainFor BoolAssumption = [StepBool, StepText]+chainFor IntAssumption = [StepInt, StepDouble, StepText]+chainFor DoubleAssumption = [StepDouble, StepText]+chainFor DateAssumption = [StepDate, StepText]+chainFor TextAssumption = [StepText]+chainFor NoAssumption = [StepBool, StepInt, StepDouble, StepDate, StepText]++-- | Schema target types served by the direct typed passes.+data TargetType = TInt | TDouble | TText | TBool | TDate++targetName :: TargetType -> String+targetName TInt = "Int"+targetName TDouble = "Double"+targetName TText = "Text"+targetName TBool = "Bool"+targetName TDate = "Day"++stepForTarget :: TargetType -> Step+stepForTarget TInt = StepInt+stepForTarget TDouble = StepDouble+stepForTarget TText = StepText+stepForTarget TBool = StepBool+stepForTarget TDate = StepDate++supportedType :: SchemaType -> Maybe TargetType+supportedType (SType (_ :: P.Proxy a))+    | isJust (testEquality (typeRep @a) (typeRep @Int)) = Just TInt+    | isJust (testEquality (typeRep @a) (typeRep @Double)) = Just TDouble+    | isJust (testEquality (typeRep @a) (typeRep @T.Text)) = Just TText+    | isJust (testEquality (typeRep @a) (typeRep @Bool)) = Just TBool+    | isJust (testEquality (typeRep @a) (typeRep @Day)) = Just TDate+    | otherwise = Nothing++{- | How to build one column, resolved once per read (before any chunk+fan-out). 'PlanLegacy' carries the \"re-apply 'parseWithTypes'\" flag.+-}+data ColumnPlan+    = {- | Inference: fallback chain; the 'Bool' asks for the all-null+      pre-check ('NoAssumption' only).+      -}+      PlanChain !SafeReadMode !NullSpec ![Step] !Bool+    | PlanSchema !SafeReadMode !NullSpec !TargetType+    | PlanLegacy !SafeReadMode !Bool++{- | Resolve a column's plan. For inferred columns the sample is always+classified against the global (full-range) env, so chunked and sequential+reads share one assumption.+-}+planColumn :: ColumnEnv -> T.Text -> Int -> ColumnPlan+planColumn env name col = case mode of+    EitherRead -> PlanLegacy mode (isJust schemaEntry)+    _ -> case schemaEntry of+        Nothing -> chainPlan env mode col+        Just st -> case supportedType st of+            Just t -> PlanSchema mode (nullSpecFor opts mode) t+            Nothing -> PlanLegacy mode True+  where+    opts = ceOpts env+    mode = effectiveSafeRead (safeRead opts) (safeReadOverrides opts) name+    schemaEntry = M.lookup name (schemaTypeMap (typeSpec opts))++-- | Classify a row sample with the existing assumption logic.+chainPlan :: ColumnEnv -> SafeReadMode -> Int -> ColumnPlan+chainPlan env mode col =+    PlanChain mode (nullSpecFor opts mode) (chainFor assumption) noAssumption+  where+    opts = ceOpts env+    ts = typeSpec opts+    sampleN =+        if shouldInferFromSample ts then typeInferenceSampleSize ts else 0+    isNullT = case mode of+        NoSafeRead -> T.null+        _ -> isNullishOrMissing (missingIndicators opts)+    sample =+        V.generate+            (min sampleN (ceNumRow env))+            (\i -> extractField (ceCtx env) (rowAt env i) col)+    classified =+        V.map (\t -> if isNullT t then Nothing else Just t) sample+    assumption = makeParsingAssumption (dateFormat opts) classified+    noAssumption = case assumption of+        NoAssumption -> True+        _ -> False++{- | What a chain plan produced for one row range: every cell null (only+reported when the all-null pre-check is on), or the index of the first+chain step whose pass succeeded plus its column.+-}+data ChunkOutcome = AllNullChunk | Resolved !Int !PassCol++-- | Run a fallback chain over the env's row range.+runChainChunk ::+    ColumnEnv -> NullSpec -> [Step] -> Bool -> Int -> IO ChunkOutcome+runChainChunk env nspec steps checkAllNull col = do+    allNull <- if checkAllNull then allNullPass env nspec col else pure False+    if allNull then pure AllNullChunk else go 0 steps+  where+    go i (s : rest) = do+        r <- runStep env nspec col False s+        case r of+            Right c -> pure (Resolved i c)+            Left _ -> go (i + 1) rest+    go _ [] = error "fastcsv: type-fallback chain exhausted (StepText must succeed)"++{- | Run a schema-typed pass over the env's row range. @rowOff@ maps a+chunk-local failure index to the global data-row index.+-}+runSchemaChunk ::+    ColumnEnv ->+    NullSpec ->+    SafeReadMode ->+    TargetType ->+    Int ->+    Int ->+    IO (Either Int PassCol)+runSchemaChunk env nspec mode t col rowOff = do+    r <- runStep env nspec col (mode == MaybeRead) (stepForTarget t)+    pure (either (Left . (+ rowOff)) Right r)++-- | The error a schema column raises when a cell fails under 'NoSafeRead'.+schemaError :: T.Text -> TargetType -> Int -> ErrorCall+schemaError name t i =+    ErrorCall $+        "fastcsv: schema column "+            <> show name+            <> ": row "+            <> show i+            <> " does not parse as "+            <> targetName t++-- | The all-null column an all-null range resolves to (Maybe Text).+allNullColumn :: Int -> Column+allNullColumn n = fromVector (V.replicate n (Nothing :: Maybe T.Text))++-- | 'MaybeRead' columns keep their Maybe-ness even when fully valid.+finishMode :: SafeReadMode -> Column -> Column+finishMode MaybeRead = ensureOptional+finishMode _ = id++{- | Build one column sequentially (single chunk over the full row range).+Returns the column plus a flag telling the caller to re-apply the legacy+schema conversion ('parseWithTypes') afterwards — only set for schema'd+columns the typed passes cannot serve.+-}+buildColumn :: ColumnEnv -> T.Text -> Int -> IO (Column, Bool)+buildColumn env name col = case planColumn env name col of+    PlanLegacy mode pwt -> (,pwt) <$> legacyColumn env mode col+    PlanSchema mode nspec t -> do+        r <- runSchemaChunk env nspec mode t col 0+        c <- either (throwIO . schemaError name t) (pure . passColumn) r+        pure (finishMode mode c, False)+    PlanChain mode nspec steps checkAllNull -> do+        oc <- runChainChunk env nspec steps checkAllNull col+        let c = case oc of+                AllNullChunk -> allNullColumn (ceNumRow env)+                Resolved _ c' -> passColumn c'+        pure (finishMode mode c, False)++{- | The original Text-materializing pipeline, kept for 'EitherRead' and+exotic schema types (cold paths).+-}+legacyColumn :: ColumnEnv -> SafeReadMode -> Int -> IO Column+legacyColumn env mode col = do+    let opts = ceOpts env+        ts = typeSpec opts+        parseOpts =+            ParseOptions+                { missingValues = missingIndicators opts+                , sampleSize =+                    if shouldInferFromSample ts+                        then typeInferenceSampleSize ts+                        else 0+                , parseSafe = mode+                , parseSafeOverrides = []+                , parseDateFormat = dateFormat opts+                }+        cells =+            V.generate+                (ceNumRow env)+                (\i -> extractField (ceCtx env) (rowAt env i) col)+    pure (parseFromExamples parseOpts cells)
+ src/DataFrame/IO/CSV/Fast/Core.hs view
@@ -0,0 +1,187 @@+{- | The shared read pipeline behind every "DataFrame.IO.CSV.Fast" entry+point: scan for delimiters, classify rows, then build every column with+the typed extraction passes — chunk-parallel when the input is large and+capabilities allow ('autoChunkCount'), unless the caller pins a chunk+count. The result is fully forced before it is returned.+-}+module DataFrame.IO.CSV.Fast.Core (+    readSeparatedCore,+) where++import qualified Data.Map as M+import qualified Data.Set as S+import qualified Data.Text as Text+import qualified Data.Vector as Vector+import qualified Data.Vector.Storable as VS++import Control.Exception (throwIO)+import Control.Monad (when)+import Data.Word (Word8)+import Debug.Trace (traceMarkerIO)++import DataFrame.IO.CSV (+    HeaderSpec (..),+    RaggedRowPolicy (..),+    ReadOptions (..),+    UnclosedQuotePolicy (..),+    schemaTypeMap,+ )+import DataFrame.IO.CSV.Fast.Index (+    CsvParseError (..),+    byteStringView,+    checkNoRaggedRows,+    classifyRowEnds,+    collectDataRows,+    fieldsInRow,+    getDelimiterIndicesPolicy,+ )+import DataFrame.IO.CSV.Fast.IndexPar (+    ParIndex (..),+    getDelimiterIndicesPar,+ )+import DataFrame.IO.CSV.Fast.Parallel (autoChunkCount, buildAllColumns)+import DataFrame.IO.CSV.Fast.Passes (ColumnEnv (..))+import DataFrame.IO.CSV.Fast.Slice (FieldCtx (..), extractField, stripBom)+import DataFrame.Internal.DataFrame (DataFrame (..), forceDataFrame)+import DataFrame.Operations.Typing (effectiveSafeRead, parseWithTypes)++{- | @readSeparatedCore chunkSpec separator opts buffer@; @chunkSpec@+pins the chunk count ('Nothing' = capability\/size-gated automatic).+-}+readSeparatedCore ::+    Maybe Int ->+    Word8 ->+    ReadOptions ->+    VS.Vector Word8 ->+    IO DataFrame+readSeparatedCore chunkSpec separator opts fileRaw = do+    let (file, _bomLen) = stripBom fileRaw+        contentLen = VS.length file+    nChunks <- maybe (autoChunkCount contentLen) pure chunkSpec+    -- Parallel reads scan in parallel too; sequential reads (or builds+    -- without SIMD) keep the original scan + row classification.+    mPar <-+        if nChunks > 1+            then getDelimiterIndicesPar nChunks separator contentLen file+            else pure Nothing+    (indices, rowEnds) <- case mPar of+        Just par -> do+            when+                ( piEndsInQuote par+                    && fastCsvOnUnclosedQuote opts == RaiseOnUnclosedQuote+                )+                (throwIO CsvUnclosedQuote)+            pure (piDelims par, piRowEnds par)+        Nothing -> do+            is <-+                getDelimiterIndicesPolicy+                    (fastCsvOnUnclosedQuote opts)+                    separator+                    contentLen+                    file+            pure (is, classifyRowEnds file contentLen is)+    -- Eventlog phase markers (visible under +RTS -l; no-ops otherwise).+    traceMarkerIO "fastcsv:scan-done"+    let totalRows = VS.length rowEnds+    if totalRows == 0+        then return emptyDataFrame+        else do+            let ctx =+                    FieldCtx+                        { fcFile = file+                        , fcBS = byteStringView contentLen file+                        , fcDelims = indices+                        , fcRowEnds = rowEnds+                        , fcContentLen = contentLen+                        , fcTrim = fastCsvTrimUnquoted opts+                        }+                headerNumCol = fieldsInRow rowEnds 0+                (columnNames, dataStartRow, numCol) = case headerSpec opts of+                    NoHeader ->+                        ( Vector.fromList $+                            map (Text.pack . show) [0 .. headerNumCol - 1]+                        , 0+                        , headerNumCol+                        )+                    UseFirstRow ->+                        ( Vector.fromList $+                            map (extractField ctx 0) [0 .. headerNumCol - 1]+                        , 1+                        , headerNumCol+                        )+                    ProvideNames ns ->+                        (Vector.fromList ns, 0, length ns)+            if numCol == 0+                then return emptyDataFrame+                else do+                    let dataRows =+                            collectDataRows+                                file+                                indices+                                rowEnds+                                contentLen+                                dataStartRow+                                totalRows+                        numRow = VS.length dataRows+                    when (fastCsvOnRaggedRow opts == RaiseOnRagged) $+                        checkNoRaggedRows rowEnds dataRows numCol+                    traceMarkerIO "fastcsv:rows-done"+                    VS.unsafeWith file $ \filePtr -> do+                        let env =+                                ColumnEnv+                                    { ceCtx = ctx+                                    , ceRows = dataRows+                                    , ceNumRow = numRow+                                    , cePtr = filePtr+                                    , ceTextHint =+                                        max 64 (contentLen `div` numCol)+                                    , ceOpts = opts+                                    }+                        results <- buildAllColumns nChunks env columnNames numCol+                        traceMarkerIO "fastcsv:build-done"+                        let cols = Vector.fromListN numCol (map fst results)+                            legacySchema =+                                S.fromList+                                    [ columnNames Vector.! c+                                    | (c, (_, True)) <- zip [0 ..] results+                                    ]+                            colIndices =+                                M.fromList $+                                    zip (Vector.toList columnNames) [0 ..]+                            df =+                                DataFrame+                                    cols+                                    colIndices+                                    (numRow, numCol)+                                    M.empty+                            resolveMode =+                                effectiveSafeRead+                                    (safeRead opts)+                                    (safeReadOverrides opts)+                            df' =+                                if S.null legacySchema+                                    then df+                                    else+                                        parseWithTypes+                                            resolveMode+                                            ( M.restrictKeys+                                                (schemaTypeMap (typeSpec opts))+                                                legacySchema+                                            )+                                            df+                        -- The parallel merge already forced every column+                        -- payload, so only the column thunks themselves+                        -- need forcing; the deep walk would re-chase 6M++                        -- pointers per boxed column for nothing.+                        if nChunks > 1 && S.null legacySchema+                            then do+                                Vector.foldl' (\() c -> c `seq` ()) () cols `seq`+                                    return df+                            else return $! forceDataFrame df'++{- | An empty 'DataFrame' — returned when the input has no delimiters+(empty file or single line with no separator and no newline). Guards+against a divide-by-zero in the row-stride math when 'numCol == 0'.+-}+emptyDataFrame :: DataFrame+emptyDataFrame = DataFrame Vector.empty M.empty (0, 0) M.empty
+ src/DataFrame/IO/CSV/Fast/Index.hs view
@@ -0,0 +1,298 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE CApiFFI #-}+{-# LANGUAGE ForeignFunctionInterface #-}++{- | Delimiter-index construction for the fast CSV reader. The index buffer+is sized from an exact byte count (not 8 bytes per input byte), the SIMD+scan runs over the 64-byte-aligned prefix of the unpadded mmap, and a+scalar Haskell tail finishes the final partial chunk — so the input is+never copied or grown.+-}+module DataFrame.IO.CSV.Fast.Index (+    CsvParseError (..),+    getDelimiterIndices,+    getDelimiterIndicesPolicy,+    scalarScan,+    classifyRowEnds,+    fieldsInRow,+    isBlankRow,+    collectDataRows,+    checkNoRaggedRows,+    byteStringView,+    lf,+    cr,+    comma,+    tab,+    quote,+) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Internal as BSI+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Storable.Mutable as VSM++import Control.Exception (Exception, throwIO)+import Control.Monad (when)+import Data.Word (Word8)+import Foreign (Ptr, castPtr)+import Foreign.C.Types (CInt (..), CSize (..), CUChar (..))+import Foreign.Marshal.Alloc (alloca)+import Foreign.Storable (peek, poke)++import DataFrame.IO.CSV (UnclosedQuotePolicy (..))++{- | Exceptions raised by the fast CSV parser.  Catchable with+'Control.Exception.catch' or 'Control.Exception.try'.+-}+data CsvParseError+    = {- | The input ends with a quoted field that was never closed.  The+      PCLMUL quote-parity chain in the SIMD scanner treats an unmatched+      @\"@ as if the rest of the file were inside quotes, so we refuse+      to return a silently corrupted 'DataFrame.Internal.DataFrame.DataFrame'+      and raise instead.+      -}+      CsvUnclosedQuote+    | {- | A row has a different number of fields from the header.  Only+      raised when 'DataFrame.IO.CSV.fastCsvOnRaggedRow' is set to+      'RaiseOnRagged'.  Carries the 0-based row index, the expected field+      count, and the actual field count.+      -}+      CsvRaggedRow !Int !Int !Int+    deriving (Eq, Show)++instance Exception CsvParseError++lf, cr, comma, tab, quote :: Word8+lf = 0x0A+cr = 0x0D+comma = 0x2C+tab = 0x09+quote = 0x22++-- Status codes reported via the out-parameter of 'get_delimiter_indices'.+-- Must stay in sync with @GDI_*@ in @cbits/process_csv.h@.+gdiOk, gdiUnclosedQuote :: CInt+gdiOk = 0+gdiUnclosedQuote = 1++{- | Return value that the C helper uses to tell Haskell \"SIMD isn't+available on this build / CPU, run the pure-Haskell state machine.\"+Mirrors @GDI_SIMD_UNAVAILABLE@ in @cbits/process_csv.h@.  'CSize' is+unsigned so this has the same bit pattern as 'maxBound'.+-}+simdUnavailable :: CSize+simdUnavailable = maxBound++foreign import capi "process_csv.h get_delimiter_indices"+    get_delimiter_indices ::+        Ptr CUChar -> -- input+        CSize -> -- input size+        CUChar -> -- separator character+        Ptr CSize -> -- result array+        Ptr CInt -> -- status out-parameter+        IO CSize -- occupancy of result array++-- | Zero-copy 'BS.ByteString' view of the first @n@ bytes of a vector.+byteStringView :: Int -> VS.Vector Word8 -> BS.ByteString+byteStringView n v = BSI.fromForeignPtr (fst (VS.unsafeToForeignPtr0 v)) 0 n+{-# INLINE byteStringView #-}++{- | Locate delimiter byte positions in the first @originalLen@ bytes of+@csvFile@.  Treats an unclosed quoted field at EOF as a hard error; callers+that want to suppress the exception can use 'getDelimiterIndicesPolicy'+with 'BestEffort'.+-}+{-# INLINE getDelimiterIndices #-}+getDelimiterIndices ::+    Word8 ->+    Int ->+    VS.Vector Word8 ->+    IO (VS.Vector CSize)+getDelimiterIndices = getDelimiterIndicesPolicy RaiseOnUnclosedQuote++getDelimiterIndicesPolicy ::+    UnclosedQuotePolicy ->+    Word8 ->+    Int ->+    VS.Vector Word8 ->+    IO (VS.Vector CSize)+getDelimiterIndicesPolicy policy separator contentLen csvFile = do+    -- Exact upper bound on the index count: every output index is an+    -- unquoted separator or newline byte, so two memchr-speed counts+    -- (plus one synthetic EOF slot) right-size the buffer (audit F4).+    let contentBS = byteStringView contentLen csvFile+        capacity = BS.count separator contentBS + BS.count lf contentBS + 1+    resultMV <- VSM.unsafeNew capacity+    -- The C scanner reads whole 64-byte chunks; cap it at the largest+    -- chunk boundary inside the content so no padding is needed and the+    -- input never has to be grown or copied (audit F6). The loop in C+    -- runs while i < len - 64, hence the + 64.+    let simdLen = (contentLen `div` 64) * 64+    (numFound, status) <- VS.unsafeWith csvFile $ \buffer ->+        alloca $ \statusPtr -> do+            poke statusPtr gdiOk+            n <- VSM.unsafeWith resultMV $ \indicesPtr ->+                get_delimiter_indices+                    (castPtr buffer)+                    (fromIntegral (simdLen + 64))+                    (fromIntegral separator)+                    (castPtr indicesPtr)+                    statusPtr+            s <- peek statusPtr+            pure (n, s)+    -- The status reports the quote parity at the end of the SIMD region,+    -- which is exactly the starting state for the scalar tail.+    (found, inQuote) <-+        if numFound == simdUnavailable+            then scalarScan separator csvFile resultMV 0 0 contentLen False+            else+                scalarScan+                    separator+                    csvFile+                    resultMV+                    (fromIntegral numFound)+                    simdLen+                    contentLen+                    (status == gdiUnclosedQuote)+    when (inQuote && policy == RaiseOnUnclosedQuote) (throwIO CsvUnclosedQuote)+    -- Synthetic EOF delimiter when the file does not end in a newline.+    finalLen <-+        if contentLen > 0 && VS.unsafeIndex csvFile (contentLen - 1) /= lf+            then do+                VSM.unsafeWrite resultMV found (fromIntegral contentLen)+                pure (found + 1)+            else pure found+    VS.unsafeFreeze (VSM.slice 0 finalLen resultMV)++{- | RFC 4180 quote-parity state machine over bytes @[from, to)@, appending+delimiter indices at @writeIdx@.  Returns the next write index and whether+the scan ended inside a quoted region. Serves as both the SIMD tail and the+full fallback when SIMD is unavailable.+-}+scalarScan ::+    Word8 ->+    VS.Vector Word8 ->+    VSM.IOVector CSize ->+    Int ->+    Int ->+    Int ->+    Bool ->+    IO (Int, Bool)+scalarScan separator file out writeIdx from to = go writeIdx from+  where+    go !idx !i !inQ+        | i >= to = pure (idx, inQ)+        | otherwise =+            let b = VS.unsafeIndex file i+             in if inQ+                    then go idx (i + 1) (b /= quote)+                    else+                        if b == quote+                            then go idx (i + 1) True+                            else+                                if b == lf || b == separator+                                    then do+                                        VSM.unsafeWrite out idx (fromIntegral i)+                                        go (idx + 1) (i + 1) False+                                    else go idx (i + 1) False++{- | Walk every data row and throw 'CsvRaggedRow' on the first one whose+field count differs from the header.  Only called when the user opts+into 'RaiseOnRagged' via 'DataFrame.IO.CSV.fastCsvOnRaggedRow'.+-}+checkNoRaggedRows ::+    VS.Vector Int ->+    VS.Vector Int ->+    Int ->+    IO ()+checkNoRaggedRows rowEnds dataRows numCol =+    VS.mapM_+        ( \r ->+            let actual = fieldsInRow rowEnds r+             in when (actual /= numCol) $+                    throwIO (CsvRaggedRow r numCol actual)+        )+        dataRows++{- | Classify each entry in the flat delimiter vector as either a row+terminator or a field terminator, and return the indices-into-the-vector+of every row terminator.  A position counts as a row break if either++  * the byte at that position is @\\n@, or+  * the position is beyond the original content length, which only+    happens for the synthetic end-of-file delimiter written when a file+    does not end in a newline.++Field terminators (commas / tabs / the configured separator) are left+implicit: anything between consecutive row terminators is a field.+-}+{-# INLINE classifyRowEnds #-}+classifyRowEnds :: VS.Vector Word8 -> Int -> VS.Vector CSize -> VS.Vector Int+classifyRowEnds file contentLen =+    VS.findIndices isRowBreak+  where+    isRowBreak pos =+        let p = fromIntegral pos :: Int+         in p >= contentLen || VS.unsafeIndex file p == lf++{- | Number of fields that row @r@ contains, derived directly from the+gap between consecutive entries in @rowEnds@.+-}+{-# INLINE fieldsInRow #-}+fieldsInRow :: VS.Vector Int -> Int -> Int+fieldsInRow rowEnds r =+    let endIdx = VS.unsafeIndex rowEnds r+        startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1+     in endIdx - startIdx + 1++{- | Does row @r@ contain exactly one, empty field?  That is the case for+a bare @\\n@ or a @\\r\\n@ \x2014 blank lines, which we skip by default to+match pandas / polars @skip_blank_lines@ semantics.  We also treat a+row whose only byte is @\\r@ as blank, so CRLF-terminated blank lines+don't leak through when only the @\\n@ counts as a row break.+-}+{-# INLINE isBlankRow #-}+isBlankRow ::+    VS.Vector Word8 ->+    VS.Vector CSize ->+    VS.Vector Int ->+    Int ->+    Int ->+    Bool+isBlankRow file delimiters rowEnds contentLen r =+    let endIdx = VS.unsafeIndex rowEnds r+        startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1+        numFields = endIdx - startIdx + 1+     in numFields == 1+            && let fieldEndRaw =+                    fromIntegral (VS.unsafeIndex delimiters startIdx) :: Int+                   fieldEnd = min fieldEndRaw contentLen+                   fieldStart =+                    if startIdx == 0+                        then 0+                        else+                            fromIntegral+                                (VS.unsafeIndex delimiters (startIdx - 1))+                                + 1+                   fieldLen = fieldEnd - fieldStart+                in fieldLen == 0+                    || ( fieldLen == 1+                            && VS.unsafeIndex file fieldStart == cr+                       )++{- | Select the row indices that contain actual data: skip @[0 .. skip - 1]@+and drop any blank rows from the remainder.+-}+{-# INLINE collectDataRows #-}+collectDataRows ::+    VS.Vector Word8 ->+    VS.Vector CSize ->+    VS.Vector Int ->+    Int ->+    Int ->+    Int ->+    VS.Vector Int+collectDataRows file delimiters rowEnds contentLen skip total =+    VS.filter+        (not . isBlankRow file delimiters rowEnds contentLen)+        (VS.generate (total - skip) (+ skip))
+ src/DataFrame/IO/CSV/Fast/IndexPar.hs view
@@ -0,0 +1,187 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE CApiFFI #-}++{- | Parallel delimiter-index construction (Round-2 WS-E2): the SIMD region+is split into 64-byte-aligned ranges, one per worker. Quote parity at each+range start comes from a prefix-xor of per-range quote counts (exact: every+quote byte toggles the scanner's PCLMUL parity chain), a parallel counting+pass right-sizes the flat output arrays and gives every worker an exact+write offset, and a parallel emit pass writes delimiter positions plus+row-end ordinals directly (no per-delimiter file-byte re-read). The scalar+tail and the unclosed-quote policy are unchanged from the sequential path.+-}+module DataFrame.IO.CSV.Fast.IndexPar (+    ParIndex (..),+    getDelimiterIndicesPar,+) where++import qualified Data.Bits as Bits+import qualified Data.ByteString as BS+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Storable.Mutable as VSM++import Data.Word (Word8)+import Foreign (Ptr, castPtr)+import Foreign.C.Types (CInt (..), CSize (..), CUChar (..))+import Foreign.Marshal.Alloc (alloca)+import Foreign.Marshal.Array (advancePtr)+import Foreign.Storable (peek)++import Control.Concurrent (getNumCapabilities)+import DataFrame.IO.CSV.Fast.Index (byteStringView, lf, quote, scalarScan)+import DataFrame.IO.CSV.Fast.Workers (pooledRun)++foreign import capi "process_csv.h count_delimiters_chunk"+    count_delimiters_chunk ::+        Ptr CUChar -> CSize -> CSize -> CUChar -> CInt -> Ptr CSize -> IO CSize++foreign import capi "process_csv.h emit_delimiter_indices_chunk"+    emit_delimiter_indices_chunk ::+        Ptr CUChar ->+        CSize ->+        CSize ->+        CUChar ->+        CInt ->+        CSize ->+        Ptr CSize ->+        Ptr CSize ->+        Ptr CSize ->+        IO CSize++-- | Mirrors @GDI_SIMD_UNAVAILABLE@ in @cbits/process_csv.h@.+simdUnavailable :: CSize+simdUnavailable = maxBound++{- | Result of the parallel scan: the same @(delimiters, rowEnds)@ pair the+sequential 'DataFrame.IO.CSV.Fast.Index.classifyRowEnds' path produces.+-}+data ParIndex = ParIndex+    { piDelims :: !(VS.Vector CSize)+    , piRowEnds :: !(VS.Vector Int)+    , piEndsInQuote :: !Bool+    }++{- | Scan with @nWorkers@ parallel range scans. Returns 'Nothing' when the+build\/CPU lacks SIMD support (callers fall back to the sequential scan).+-}+getDelimiterIndicesPar ::+    Int -> Word8 -> Int -> VS.Vector Word8 -> IO (Maybe ParIndex)+getDelimiterIndicesPar nWorkers separator contentLen file =+    VS.unsafeWith file $ \filePtr -> do+        probe <-+            alloca $ \nlPtr ->+                count_delimiters_chunk (castPtr filePtr) 0 0 sep 0 nlPtr+        if probe == simdUnavailable+            then pure Nothing+            else Just <$> scanParallel nWorkers separator contentLen file filePtr+  where+    sep = fromIntegral separator++scanParallel ::+    Int -> Word8 -> Int -> VS.Vector Word8 -> Ptr Word8 -> IO ParIndex+scanParallel nWorkers separator contentLen file filePtr = do+    width <- getNumCapabilities+    let sep = fromIntegral separator :: CUChar+        simdLen = (contentLen `div` 64) * 64+        tailLen = contentLen - simdLen+        ranges = alignedRanges nWorkers simdLen+        contentBS = byteStringView contentLen file+        cBool b = if b then 1 else 0 :: CInt+    -- Quote parity at each range start (prefix-xor of quote counts).+    quoteCounts <-+        pooledRun+            width+            [ pure $! BS.count quote (BS.take len (BS.drop off contentBS))+            | (off, len) <- ranges+            ]+    let parities = scanl Bits.xor False (map odd quoteCounts)+        starts = zip ranges parities+    -- Pass 1 (parallel): exact per-range delimiter / row-end counts.+    counts <-+        pooledRun+            width+            [ alloca $ \nlPtr -> do+                d <-+                    count_delimiters_chunk+                        (castPtr filePtr)+                        (fromIntegral off)+                        (fromIntegral len)+                        sep+                        (cBool p)+                        nlPtr+                nl <- peek nlPtr+                pure (fromIntegral d :: Int, fromIntegral nl :: Int)+            | ((off, len), p) <- starts+            ]+    let dOffs = scanl (+) 0 (map fst counts)+        nlOffs = scanl (+) 0 (map snd counts)+        dTot = last dOffs+        nlTot = last nlOffs+    -- Right-sized flat outputs (+ scalar-tail upper bound + synthetic EOF).+    delimsMV <- VSM.unsafeNew (dTot + tailLen + 1)+    rowEndsMV <- VSM.unsafeNew (nlTot + tailLen + 1) :: IO (VSM.IOVector Int)+    -- Pass 2 (parallel): emit positions + row-end ordinals at exact offsets.+    VSM.unsafeWith delimsMV $ \dPtr ->+        VSM.unsafeWith rowEndsMV $ \rePtr -> do+            _ <-+                pooledRun+                    width+                    [ alloca $ \rcPtr ->+                        emit_delimiter_indices_chunk+                            (castPtr filePtr)+                            (fromIntegral off)+                            (fromIntegral len)+                            sep+                            (cBool p)+                            (fromIntegral dOff)+                            (dPtr `advancePtr` dOff)+                            (castPtr (rePtr `advancePtr` nlOff))+                            rcPtr+                    | (((off, len), p), dOff, nlOff) <- zip3 starts dOffs nlOffs+                    ]+            pure ()+    -- Scalar tail (final partial 64-byte chunk), parity carried in.+    (found, inQuote) <-+        scalarScan separator file delimsMV dTot simdLen contentLen (last parities)+    nlAfterTail <- appendTailRowEnds file rowEndsMV delimsMV nlTot dTot found+    -- Synthetic EOF delimiter when the file does not end in a newline.+    (found', nlFinal) <-+        if contentLen > 0 && VS.unsafeIndex file (contentLen - 1) /= lf+            then do+                VSM.unsafeWrite delimsMV found (fromIntegral contentLen)+                VSM.unsafeWrite rowEndsMV nlAfterTail found+                pure (found + 1, nlAfterTail + 1)+            else pure (found, nlAfterTail)+    delims <- VS.unsafeFreeze (VSM.slice 0 found' delimsMV)+    rowEnds <- VS.unsafeFreeze (VSM.slice 0 nlFinal rowEndsMV)+    pure (ParIndex delims rowEnds inQuote)++{- | Classify the scalar tail's delimiters: append the ordinal of every+newline entry in @[from, to)@ of the delimiter vector.+-}+appendTailRowEnds ::+    VS.Vector Word8 ->+    VSM.IOVector Int ->+    VSM.IOVector CSize ->+    Int ->+    Int ->+    Int ->+    IO Int+appendTailRowEnds file rowEndsMV delimsMV = go+  where+    go !w !i !to+        | i >= to = pure w+        | otherwise = do+            p <- VSM.unsafeRead delimsMV i+            if VS.unsafeIndex file (fromIntegral p) == lf+                then VSM.unsafeWrite rowEndsMV w i >> go (w + 1) (i + 1) to+                else go w (i + 1) to++-- | Split @[0, n)@ (a multiple of 64) into 64-byte-aligned ranges.+alignedRanges :: Int -> Int -> [(Int, Int)]+alignedRanges k n = filter ((> 0) . snd) (zip offs sizes)+  where+    blocks = n `div` 64+    (q, r) = blocks `divMod` max 1 k+    sizes = [64 * (q + if i < r then 1 else 0) | i <- [0 .. max 1 k - 1]]+    offs = scanl (+) 0 sizes
+ src/DataFrame/IO/CSV/Fast/Parallel.hs view
@@ -0,0 +1,197 @@+{-# LANGUAGE TupleSections #-}++{- | Chunk-parallel extraction (Round-2 WS-E2): the data-row range is split+into contiguous, newline-aligned chunks (alignment is by construction —+chunks are row ranges over the quote-resolved delimiter index), each+worker runs every column's 'ColumnPlan' over its chunk with right-sized+builders, and the merger splices the results ('mergeColumns' /+'mergeTextChunksPar'). Inference classifies its sample once, globally,+before fan-out; when chunks resolve different types the merger re-parses+only the narrower-typed chunks.++Parallelism requires the executable to be linked with @-threaded@ and run+with @+RTS -N@; otherwise ('getNumCapabilities' == 1) the sequential path+runs. Inputs under 4 MB are always read sequentially.+-}+module DataFrame.IO.CSV.Fast.Parallel (+    buildAllColumns,+    autoChunkCount,+) where++import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Vector.Storable as VS++import Control.Concurrent (getNumCapabilities)+import Control.Exception (throwIO)+import Control.Monad (zipWithM)+import Data.Either (partitionEithers)+import Debug.Trace (traceMarkerIO)+import System.Mem (performMajorGC)++import DataFrame.IO.CSV.Fast.Columns+import DataFrame.IO.CSV.Fast.Passes (NullSpec, PassCol (..))+import DataFrame.IO.CSV.Fast.TextMerge (mergeTextChunksPar)+import DataFrame.IO.CSV.Fast.Workers (pooledRun)+import DataFrame.Internal.Column (Column, forceColumn)+import DataFrame.Internal.ColumnBuilder (mergeColumns)++-- | Below this input size the fan-out overhead outweighs the parallelism.+parallelThresholdBytes :: Int+parallelThresholdBytes = 4 * 1024 * 1024++{- | Chunk count for an automatic read: four chunks per capability (the+worker pool bounds concurrency at the capability count; the extra chunks+absorb straggler imbalance), or the sequential path (1) on a single+capability or a small input.+-}+autoChunkCount :: Int -> IO Int+autoChunkCount contentLen = do+    caps <- getNumCapabilities+    pure $+        if caps <= 1 || contentLen < parallelThresholdBytes+            then 1+            else caps * 4++-- | What one worker produced for one column over its chunk.+data ChunkCol+    = CCChain !ChunkOutcome+    | -- | @Left@ carries the global data-row index of the first failure.+      CCSchema !(Either Int PassCol)+    | -- | Legacy (cold-path) columns are built sequentially by the merger.+      CCSkip++-- | Splice homogeneous chunk passes (text chunks merge at the byte level).+mergePassCols :: Int -> [PassCol] -> IO Column+mergePassCols width ps@(PassText{} : _) =+    mergeTextChunksPar width [tc | PassText tc <- ps]+mergePassCols _ ps = pure $! mergeColumns [c | PassFull c <- ps]++{- | Build every column, fanning the row range out over @nChunks@ chunks+on a capability-wide worker pool (sequentially when @nChunks <= 1@). Same+result as the sequential path by construction; see the chunk-determinism+tests and property.+-}+buildAllColumns ::+    Int -> ColumnEnv -> V.Vector T.Text -> Int -> IO [(Column, Bool)]+buildAllColumns nChunks env names numCol+    | chunks <= 1 =+        mapM (\c -> buildColumn env (names V.! c) c) [0 .. numCol - 1]+    | otherwise = do+        width <- getNumCapabilities+        let plans = [planColumn env (names V.! c) c | c <- [0 .. numCol - 1]]+            ranges = chunkRanges chunks (ceNumRow env)+        traceMarkerIO "fastcsv:fanout"+        perChunk <- pooledRun width [runChunk env plans r | r <- ranges]+        traceMarkerIO "fastcsv:join-done"+        -- Reset the major-GC trigger here, where the copyable live set is+        -- small (chunk payloads are large objects). Otherwise the heap+        -- doubling crosses its threshold mid-merge and a ~0.5s gen-1+        -- collection lands at the worst point of the read.+        performMajorGC+        -- Merge in parallel too (chunk-level inside each column, pooled+        -- across columns), forcing each spliced column here so no memcpy+        -- is deferred to the final forceDataFrame walk.+        merged <-+            pooledRun+                width+                [ do+                    r@(col, _) <-+                        mergeColumn+                            width+                            env+                            (names V.! c)+                            (plans !! c)+                            c+                            [(rng, cols !! c) | (rng, cols) <- zip ranges perChunk]+                    forceColumn col `seq` pure r+                | c <- [0 .. numCol - 1]+                ]+        traceMarkerIO "fastcsv:merge-done"+        pure merged+  where+    chunks = min nChunks (ceNumRow env)++-- | Even split of @n@ rows into @k@ @(offset, length)@ ranges.+chunkRanges :: Int -> Int -> [(Int, Int)]+chunkRanges k n =+    [ (q * i + min i r, q + (if i < r then 1 else 0))+    | i <- [0 .. k - 1]+    ]+  where+    (q, r) = n `divMod` k++-- | Restrict an env to a row sub-range (builder hints scaled to match).+chunkEnv :: ColumnEnv -> (Int, Int) -> ColumnEnv+chunkEnv env (off, len) =+    env+        { ceRows = VS.slice off len (ceRows env)+        , ceNumRow = len+        , ceTextHint =+            max 64 ((ceTextHint env * len) `div` max 1 (ceNumRow env))+        }++-- | One worker: run every column's plan over one chunk of rows.+runChunk :: ColumnEnv -> [ColumnPlan] -> (Int, Int) -> IO [ChunkCol]+runChunk env plans range@(off, _) = zipWithM build [0 ..] plans+  where+    cenv = chunkEnv env range+    build col plan = case plan of+        PlanLegacy{} -> pure CCSkip+        PlanSchema mode nspec t ->+            CCSchema <$> runSchemaChunk cenv nspec mode t col off+        PlanChain _ nspec steps checkAllNull ->+            CCChain <$> runChainChunk cenv nspec steps checkAllNull col++{- | Splice one column's chunk results back together, resolving chunk-level+type promotion. Mirrors 'buildColumn' for the single-chunk case.+-}+mergeColumn ::+    Int ->+    ColumnEnv ->+    T.Text ->+    ColumnPlan ->+    Int ->+    [((Int, Int), ChunkCol)] ->+    IO (Column, Bool)+mergeColumn width env name plan col parts = case plan of+    PlanLegacy mode pwt -> (,pwt) <$> legacyColumn env mode col+    PlanSchema mode _ t ->+        case partitionEithers [r | (_, CCSchema r) <- parts] of+            ([], cs) -> do+                c <- mergePassCols width cs+                pure (finishMode mode c, False)+            (failed, _) -> throwIO (schemaError name t (minimum failed))+    PlanChain mode nspec steps _ -> do+        c <- mergeChain width env nspec steps col [(r, oc) | (r, CCChain oc) <- parts]+        pure (finishMode mode c, False)++{- | Merge chain-plan chunks. The candidate type starts at the widest chunk+resolution; any chunk that resolved narrower is re-parsed at the candidate+step, and a re-parse failure escalates the candidate further down the+chain (exactly the rows the sequential pass would have failed on). The+final 'StepText' never fails, so this terminates.+-}+mergeChain ::+    Int ->+    ColumnEnv ->+    NullSpec ->+    [Step] ->+    Int ->+    [((Int, Int), ChunkOutcome)] ->+    IO Column+mergeChain width env nspec steps col parts+    | null resolved = pure (allNullColumn (ceNumRow env))+    | otherwise = settle (maximum resolved)+  where+    resolved = [i | (_, Resolved i _) <- parts]+    settle cand = do+        results <- mapM (reparse cand) parts+        case sequence results of+            Just cs -> mergePassCols width cs+            Nothing -> settle (cand + 1)+    reparse cand (range, oc) = case oc of+        Resolved i c | i == cand -> pure (Just c)+        _ -> do+            r <- runStep (chunkEnv env range) nspec col False (steps !! cand)+            pure (either (const Nothing) Just r)
+ src/DataFrame/IO/CSV/Fast/Passes.hs view
@@ -0,0 +1,274 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE ScopedTypeVariables #-}++{- | The typed column passes (Round-2 WS-E1): each pass walks one column's+delimiter slices and appends parsed values straight into a WS-C builder —+no per-field 'Data.Text.Text' and no deferred re-parse. Text bytes are+memcpy'd into a shared buffer, only for Text-typed columns.+-}+module DataFrame.IO.CSV.Fast.Passes (+    ColumnEnv (..),+    NullSpec (..),+    PassCol (..),+    PassResult,+    passColumn,+    nullSpecFor,+    isNullSlice,+    isNullText,+    rowAt,+    intPass,+    doublePass,+    boolPass,+    textPass,+    datePass,+    allNullPass,+) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Internal as BSI+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Vector as V+import qualified Data.Vector.Mutable as VM+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Unboxed as VU++import Control.Monad.ST (stToIO)+import Data.Word (Word8)+import Foreign.Ptr (Ptr, nullPtr, plusPtr)+import GHC.Exts (Int (..), Int#, isTrue#, (+#), (>=#))++import DataFrame.IO.CSV (ReadOptions (..))+import DataFrame.IO.CSV.Fast.Index (quote)+import DataFrame.IO.CSV.Fast.Slice+import DataFrame.Internal.Column (Column, Columnable, fromVector)+import DataFrame.Internal.ColumnBuilder (+    ColumnBuilder (..),+    TextChunk,+    appendNum,+    appendText,+    appendTextSliceFromPtr,+    freezeTextChunk,+    mergeTextChunks,+    newNumBuilder,+    newTextBuilder,+ )+import DataFrame.Internal.Parsing.Fast (+    isMissingField,+    isMissingFieldIn,+    isMissingFieldSlice,+    parseBoolFieldSlice,+    parseDateFieldSlice,+    parseDoubleFieldSlice,+    parseIntFieldSlice,+ )+import DataFrame.Operations.Typing (+    SafeReadMode (..),+    isNullishOrMissing,+    parseTimeOpt,+ )++-- | Per-read context shared by every column pass.+data ColumnEnv = ColumnEnv+    { ceCtx :: !FieldCtx+    , ceRows :: !(VS.Vector Int)+    -- ^ Row indices (into row-ends) of the data rows, blanks skipped.+    , ceNumRow :: !Int+    , cePtr :: !(Ptr Word8)+    -- ^ Base pointer of the file content (valid for the whole read).+    , ceTextHint :: !Int+    -- ^ Initial byte capacity for a Text column builder.+    , ceOpts :: !ReadOptions+    }++-- | Byte-level null test: mode-dependent (audit T3, behaviors list #1).+data NullSpec+    = -- | 'NoSafeRead': only the empty field is null.+      NullEmpty+    | -- | 'MaybeRead': canonical missing set plus custom extras.+      NullMissing ![BS.ByteString] ![T.Text]++nullSpecFor :: ReadOptions -> SafeReadMode -> NullSpec+nullSpecFor _ NoSafeRead = NullEmpty+nullSpecFor opts _ =+    NullMissing+        (filter (not . isMissingField) (map TE.encodeUtf8 toks))+        toks+  where+    toks = missingIndicators opts++{-# INLINE isNullSlice #-}+isNullSlice :: NullSpec -> BS.ByteString -> Int -> Int -> Bool+isNullSlice NullEmpty _ s e = e <= s+isNullSlice (NullMissing extras _) bs s e =+    isMissingFieldSlice bs s e+        || (not (null extras) && isMissingFieldIn extras (sliceBS bs s e))++-- | Text-level twin of 'isNullSlice' for the decode-and-strip trim path.+isNullText :: NullSpec -> T.Text -> Bool+isNullText NullEmpty t = T.null t+isNullText (NullMissing _ toks) t = isNullishOrMissing toks t++{- | What a pass produced: a finished 'Column', or a raw text chunk that the+merger splices at the byte level (no per-chunk 'T.Text' materialization).+-}+data PassCol = PassFull !Column | PassText !TextChunk++-- | Finish one 'PassCol' on its own (the single-chunk \/ sequential case).+passColumn :: PassCol -> Column+passColumn (PassFull c) = c+passColumn (PassText tc) = mergeTextChunks [tc]++-- | Result of a fallible pass: @Left rowIndex@ on the first parse failure.+type PassResult = Either Int PassCol++rowAt :: ColumnEnv -> Int -> Int+rowAt env = VS.unsafeIndex (ceRows env)+{-# INLINE rowAt #-}++{- | One pass appending unboxed values; @nullOnFail@ turns parse failures+into nulls (schema + 'MaybeRead') instead of aborting (inference demotion).+-}+{-# INLINE unboxedPass #-}+unboxedPass ::+    (Columnable a, VU.Unbox a) =>+    ColumnEnv ->+    NullSpec ->+    Int ->+    Bool ->+    a ->+    (BS.ByteString -> Int -> Int -> Maybe a) ->+    IO PassResult+unboxedPass env nspec col nullOnFail nullVal parser = do+    let ctx = ceCtx env+        bs = fcBS ctx+        !(I# n) = ceNumRow env+    b <- stToIO (newNumBuilder nullVal (ceNumRow env))+    -- Loop counter is Int#: join points are not worker/wrapper'd, so a+    -- boxed counter would allocate on every iteration.+    let go :: Int# -> IO PassResult+        go i+            | isTrue# (i >=# n) =+                Right . PassFull <$> stToIO (freezeBuilder b)+            | otherwise =+                withParseSlice ctx (rowAt env (I# i)) col $ \s e ->+                    if isNullSlice nspec bs s e+                        then stToIO (appendNull b) >> go (i +# 1#)+                        else case parser bs s e of+                            Just v -> stToIO (appendNum b v) >> go (i +# 1#)+                            Nothing+                                | nullOnFail ->+                                    stToIO (appendNull b) >> go (i +# 1#)+                                | otherwise -> pure (Left (I# i))+    go 0#++intPass :: ColumnEnv -> NullSpec -> Int -> Bool -> IO PassResult+intPass env nspec col nullOnFail =+    unboxedPass env nspec col nullOnFail (0 :: Int) parseIntFieldSlice++doublePass :: ColumnEnv -> NullSpec -> Int -> Bool -> IO PassResult+doublePass env nspec col nullOnFail =+    unboxedPass env nspec col nullOnFail (0 :: Double) parseDoubleFieldSlice++boolPass :: ColumnEnv -> NullSpec -> Int -> Bool -> IO PassResult+boolPass env nspec col nullOnFail =+    unboxedPass env nspec col nullOnFail False parseBoolFieldSlice++{- | Text pass (never fails): raw bytes are memcpy'd into the shared+builder buffer; only quoted fields containing a doubled quote, and the+legacy trim knob, take the decode path. Returns the raw chunk; 'T.Text'+values materialize once, when the merger splices the chunks.+-}+textPass :: ColumnEnv -> NullSpec -> Int -> IO TextChunk+textPass env nspec col = do+    let ctx = ceCtx env+        bs = fcBS ctx+        ptr = cePtr env+        !(I# n) = ceNumRow env+    b <- stToIO (newTextBuilder (ceNumRow env) (ceTextHint env))+    let go :: Int# -> IO TextChunk+        go i+            | isTrue# (i >=# n) = stToIO (freezeTextChunk b)+            | otherwise =+                withFieldSlice ctx (rowAt env (I# i)) col $ \s e q ->+                    if fcTrim ctx && not q+                        then do+                            let t = T.strip (decodeSlice bs s e)+                            if isNullText nspec t+                                then stToIO (appendNull b)+                                else stToIO (appendText b t)+                            go (i +# 1#)+                        else+                            if isNullSlice nspec bs s e+                                then stToIO (appendNull b) >> go (i +# 1#)+                                else do+                                    hasQ <-+                                        if q+                                            then containsQuote ptr s e+                                            else pure False+                                    if hasQ+                                        then+                                            stToIO . appendText b $+                                                unescapeDoubledQuotes+                                                    (decodeSlice bs s e)+                                        else+                                            stToIO $+                                                appendTextSliceFromPtr+                                                    b+                                                    (ptr `plusPtr` s)+                                                    (e - s)+                                    go (i +# 1#)+    go 0#++-- | memchr for a doubled-quote candidate inside a quoted field's content.+containsQuote :: Ptr Word8 -> Int -> Int -> IO Bool+containsQuote ptr s e = do+    p <- BSI.memchr (ptr `plusPtr` s) quote (fromIntegral (e - s))+    pure (p /= nullPtr)+{-# INLINE containsQuote #-}++-- | Date pass: boxed (Day is not unboxable), mirrors @handleDateAssumption@.+datePass :: ColumnEnv -> NullSpec -> Int -> Bool -> IO PassResult+datePass env nspec col nullOnFail = do+    let ctx = ceCtx env+        bs = fcBS ctx+        n = ceNumRow env+        dfmt = dateFormat (ceOpts env)+        parseAt s e+            | dfmt == "%Y-%m-%d" = parseDateFieldSlice bs s e+            | otherwise = parseTimeOpt dfmt (decodeSlice bs s e)+    out <- VM.new n+    let go !i !anyNull+            | i >= n = do+                vec <- V.unsafeFreeze out+                pure . Right . PassFull $+                    if anyNull+                        then fromVector vec+                        else fromVector (V.mapMaybe id vec)+            | otherwise =+                withParseSlice ctx (rowAt env i) col $ \s e ->+                    if isNullSlice nspec bs s e+                        then VM.unsafeWrite out i Nothing >> go (i + 1) True+                        else case parseAt s e of+                            Just d ->+                                VM.unsafeWrite out i (Just d) >> go (i + 1) anyNull+                            Nothing+                                | nullOnFail ->+                                    VM.unsafeWrite out i Nothing >> go (i + 1) True+                                | otherwise -> pure (Left i)+    go (0 :: Int) False++-- | Is every data cell of the column null? (@handleNoAssumption@ head case.)+allNullPass :: ColumnEnv -> NullSpec -> Int -> IO Bool+allNullPass env nspec col = go 0+  where+    ctx = ceCtx env+    bs = fcBS ctx+    n = ceNumRow env+    go !i+        | i >= n = pure True+        | otherwise =+            withParseSlice ctx (rowAt env i) col $ \s e ->+                if isNullSlice nspec bs s e then go (i + 1) else pure False
+ src/DataFrame/IO/CSV/Fast/Slice.hs view
@@ -0,0 +1,171 @@+{-# LANGUAGE BangPatterns #-}++{- | Field-slice resolution for the fast CSV reader: maps @(row, col)@ to a+byte range of the input (quote-stripped, CR-stripped, optionally trimmed)+without materializing any intermediate value. 'extractField' is the legacy+Text materialization used for headers, inference samples and cold paths.+-}+module DataFrame.IO.CSV.Fast.Slice (+    FieldCtx (..),+    withFieldSlice,+    withParseSlice,+    sliceBS,+    decodeSlice,+    extractField,+    unescapeDoubledQuotes,+    stripBom,+) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU+import qualified Data.Text as Text+import qualified Data.Text.Encoding as TextEncoding+import qualified Data.Vector.Storable as VS++import Data.Text (Text)+import Data.Word (Word8)+import Foreign.C.Types (CSize)++import DataFrame.IO.CSV.Fast.Index (cr, quote)++-- | Everything needed to resolve a field slice, shared by all columns.+data FieldCtx = FieldCtx+    { fcFile :: !(VS.Vector Word8)+    -- ^ File content (BOM already stripped), no padding.+    , fcBS :: !BS.ByteString+    -- ^ Zero-copy 'BS.ByteString' view of 'fcFile'.+    , fcDelims :: !(VS.Vector CSize)+    -- ^ Delimiter byte positions (field and row terminators, flat).+    , fcRowEnds :: !(VS.Vector Int)+    -- ^ Indices into 'fcDelims' that terminate a row.+    , fcContentLen :: !Int+    , fcTrim :: !Bool+    -- ^ 'DataFrame.IO.CSV.fastCsvTrimUnquoted'.+    }++{- | Resolve field @col@ of row @r@ and continue with @k start end quoted@.+Quoted fields yield the bytes between the outer quotes (embedded @\"\"@ is+NOT unescaped here); a trailing @\\r@ is stripped first; a column beyond the+row's field count (ragged short row) yields the empty slice. CPS so the hot+loops never box the bounds.+-}+{-# INLINE withFieldSlice #-}+withFieldSlice :: FieldCtx -> Int -> Int -> (Int -> Int -> Bool -> r) -> r+withFieldSlice ctx r col k =+    let rowEnds = fcRowEnds ctx+        endIdx = VS.unsafeIndex rowEnds r+        startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1+        numFields = endIdx - startIdx + 1+     in if col >= numFields+            then k 0 0 False+            else+                let boundaryIdx = startIdx + col+                    fieldEndRaw =+                        fromIntegral (VS.unsafeIndex (fcDelims ctx) boundaryIdx) :: Int+                    fieldEndClamped = min fieldEndRaw (fcContentLen ctx)+                    fieldStart =+                        if boundaryIdx == 0+                            then 0+                            else+                                fromIntegral+                                    (VS.unsafeIndex (fcDelims ctx) (boundaryIdx - 1))+                                    + 1+                    file = fcFile ctx+                    fieldEnd =+                        if fieldEndClamped > fieldStart+                            && VS.unsafeIndex file (fieldEndClamped - 1) == cr+                            then fieldEndClamped - 1+                            else fieldEndClamped+                 in if fieldEnd - fieldStart >= 2+                        && VS.unsafeIndex file fieldStart == quote+                        && VS.unsafeIndex file (fieldEnd - 1) == quote+                        then k (fieldStart + 1) (fieldEnd - 1) True+                        else k fieldStart fieldEnd False++{- | 'withFieldSlice' with the trim knob applied: when 'fcTrim' is set,+unquoted slices have ASCII whitespace stripped from both ends before the+continuation runs. This is the slice the typed parsers and null tests see.+-}+{-# INLINE withParseSlice #-}+withParseSlice :: FieldCtx -> Int -> Int -> (Int -> Int -> r) -> r+withParseSlice ctx r col k =+    withFieldSlice ctx r col $ \s e q ->+        if fcTrim ctx && not q+            then+                let file = fcFile ctx+                    s' = skipWsForward file s e+                    e' = skipWsBackward file s' e+                 in k s' e'+            else k s e++{-# INLINE isAsciiWs #-}+isAsciiWs :: Word8 -> Bool+isAsciiWs w = w == 0x20 || (w - 0x09) <= 4++{-# INLINE skipWsForward #-}+skipWsForward :: VS.Vector Word8 -> Int -> Int -> Int+skipWsForward file = go+  where+    go !i !e+        | i < e && isAsciiWs (VS.unsafeIndex file i) = go (i + 1) e+        | otherwise = i++{-# INLINE skipWsBackward #-}+skipWsBackward :: VS.Vector Word8 -> Int -> Int -> Int+skipWsBackward file = go+  where+    go !s !e+        | e > s && isAsciiWs (VS.unsafeIndex file (e - 1)) = go s (e - 1)+        | otherwise = e++-- | O(1) sub-'BS.ByteString' of the file view (no copy).+{-# INLINE sliceBS #-}+sliceBS :: BS.ByteString -> Int -> Int -> BS.ByteString+sliceBS bs s e = BSU.unsafeTake (e - s) (BSU.unsafeDrop s bs)++-- | Lenient UTF-8 decode of a slice (allocates; cold paths only).+{-# INLINE decodeSlice #-}+decodeSlice :: BS.ByteString -> Int -> Int -> Text+decodeSlice bs s e = TextEncoding.decodeUtf8Lenient (sliceBS bs s e)++{- | Extract field @col@ of row @r@ as a 'Text' with the original fastcsv+semantics: quotes stripped and @\"\"@ unescaped for quoted fields, optional+'Text.strip' (full Unicode) for unquoted fields when 'fcTrim' is set.+Used for header names, inference samples and the legacy (EitherRead /+unsupported-schema) column paths.+-}+{-# INLINE extractField #-}+extractField :: FieldCtx -> Int -> Int -> Text+extractField ctx r col =+    withFieldSlice ctx r col $ \s e q ->+        if q+            then unescapeDoubledQuotes (decodeSlice (fcBS ctx) s e)+            else+                (if fcTrim ctx then Text.strip else id)+                    (decodeSlice (fcBS ctx) s e)++{- | RFC 4180 inner-quote unescape: @\"\"@ → @\"@.  'Text.replace' on a+two-char needle does a single linear pass and is allocation-free when+no doubled quote is present (short-circuits at the first miss).+-}+{-# INLINE unescapeDoubledQuotes #-}+unescapeDoubledQuotes :: Text -> Text+unescapeDoubledQuotes t+    | Text.isInfixOf doubledQuote t = Text.replace doubledQuote singleQuote t+    | otherwise = t+  where+    doubledQuote = Text.pack "\"\""+    singleQuote = Text.singleton '"'++{- | Strip a leading UTF-8 BOM (EF BB BF) if present. Returns the trimmed+vector and the number of bytes removed (0 or 3).+-}+{-# INLINE stripBom #-}+stripBom :: VS.Vector Word8 -> (VS.Vector Word8, Int)+stripBom v+    | VS.length v >= 3+    , VS.unsafeIndex v 0 == 0xEF+    , VS.unsafeIndex v 1 == 0xBB+    , VS.unsafeIndex v 2 == 0xBF =+        (VS.drop 3 v, 3)+    | otherwise = (v, 0)
+ src/DataFrame/IO/CSV/Fast/TextMerge.hs view
@@ -0,0 +1,64 @@+{-# LANGUAGE BangPatterns #-}++{- | Parallel byte-level merge of per-chunk text columns: the byte copies+and offset rebase fan out over the chunk list, so a large text column does+not serialize the merge phase. Wraps the merged shared buffer + offsets as+'PackedText' (no 'Data.Text.Text' spine, no eager UTF-8 validation), exactly+as the pure 'mergeTextChunks' now produces.+-}+module DataFrame.IO.CSV.Fast.TextMerge (+    mergeTextChunksPar,+) where++import qualified Data.Text.Array as A+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as VUM++import Control.Monad (void)+import Control.Monad.ST (stToIO)++import DataFrame.IO.CSV.Fast.Workers (pooledRun)+import DataFrame.Internal.Column (Column (..))+import DataFrame.Internal.ColumnMerge (+    TextChunk (..),+    mergeTextChunks,+    spliceBitmaps,+    tcRows,+ )+import DataFrame.Internal.PackedText (mkPackedContiguous)++{- | Merge text chunks with @width@-way parallel byte copies + offset+rebase, then wrap the shared buffer as 'PackedText'. Single chunks take+the pure (zero-copy) path.+-}+mergeTextChunksPar :: Int -> [TextChunk] -> IO Column+mergeTextChunksPar _ [c] = pure $! mergeTextChunks [c]+mergeTextChunksPar width cs = do+    let byteOffs = scanl (+) 0 (map tcUsed cs)+        rowOffs = scanl (+) 0 (map tcRows cs)+        totalBytes = last byteOffs+        totalRows = last rowOffs+    marr <- stToIO (A.new (max 1 totalBytes))+    offsMV <- VUM.unsafeNew (totalRows + 1)+    VUM.unsafeWrite offsMV 0 0+    -- Byte copy + offset rebase, parallel over chunks (disjoint ranges).+    void . pooledRun width $+        [ do+            stToIO (A.copyI (tcUsed c) marr bOff (tcBytes c) 0)+            let co = tcOffsets c+                n = tcRows c+                fill !i+                    | i > n = pure ()+                    | otherwise = do+                        VUM.unsafeWrite+                            offsMV+                            (rOff + i)+                            (bOff + VU.unsafeIndex co i)+                        fill (i + 1)+            fill 1+        | (c, bOff, rOff) <- zip3 cs byteOffs rowOffs+        ]+    arr <- stToIO (A.unsafeFreeze marr)+    offs <- VU.unsafeFreeze offsMV+    let !bm = spliceBitmaps [(tcBitmap c, tcRows c) | c <- cs]+    pure (PackedText bm (mkPackedContiguous arr offs))
+ src/DataFrame/IO/CSV/Fast/Workers.hs view
@@ -0,0 +1,51 @@+{- | Thread fan-out primitives shared by the parallel scan, the chunk+parse and the parallel merge: plain 'forkIO' workers joined through+'MVar's (no sparks), with a counter-based pool for finer-grained chunks.+-}+module DataFrame.IO.CSV.Fast.Workers (+    forkJoin,+    pooledRun,+) where++import qualified Data.Vector as V+import qualified Data.Vector.Mutable as VM++import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (SomeException, throwIO, try)+import Control.Monad (when)+import Data.IORef (atomicModifyIORef', newIORef)++-- | Run each action in its own thread; rethrow the first failure in order.+forkJoin :: [IO a] -> IO [a]+forkJoin actions = do+    vars <- mapM spawn actions+    results <- mapM takeMVar vars+    either (throwIO :: SomeException -> IO [a]) pure (sequence results)+  where+    spawn act = do+        var <- newEmptyMVar+        _ <- forkIO (try act >>= putMVar var)+        pure var++{- | Run the actions on a pool of @width@ threads (work-stealing via a+shared counter), so finer-grained chunks balance load without running+every chunk's builders concurrently. Results keep their input order.+-}+pooledRun :: Int -> [IO a] -> IO [a]+pooledRun width actions+    | width >= n = forkJoin actions+    | otherwise = do+        next <- newIORef 0+        out <- VM.unsafeNew n+        let acts = V.fromListN n actions+            worker = do+                i <- atomicModifyIORef' next (\j -> (j + 1, j))+                when (i < n) $ do+                    r <- acts V.! i+                    VM.write out i r+                    worker+        _ <- forkJoin (replicate width worker)+        V.toList <$> V.freeze out+  where+    n = length actions
tests/Main.hs view
@@ -7,11 +7,18 @@ import Test.HUnit import Test.QuickCheck +import qualified Operations.ChunkParallel import qualified Operations.ReadCsv+import qualified Operations.TypedExtraction import qualified Properties.Csv  tests :: Test-tests = TestList Operations.ReadCsv.tests+tests =+    TestList+        ( Operations.ReadCsv.tests+            <> Operations.TypedExtraction.tests+            <> Operations.ChunkParallel.tests+        )  isSuccessful :: Result -> Bool isSuccessful (Success{}) = True
+ tests/Operations/ChunkParallel.hs view
@@ -0,0 +1,277 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++{- | Round-2 (WS-E2) behavior pins for chunk-parallel extraction: a chunked+read must be indistinguishable from the sequential one — same column types+(promotion resolved across chunks), same values, same errors.+-}+module Operations.ChunkParallel (tests) where++import qualified Data.Map as M+import qualified Data.Proxy as P+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE++import Control.Exception (ErrorCall, try)+import Data.Time (Day)+import Data.Word (Word8)+import DataFrame.IO.CSV (+    ReadOptions (..),+    TypeSpec (..),+    defaultReadOptions,+ )+import qualified DataFrame.IO.CSV.Fast as D+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.DataFrame (DataFrame, getColumn)+import DataFrame.Internal.Schema (Schema (..), SchemaType (..), elements)+import DataFrame.Operations.Typing (SafeReadMode (..))+import Test.HUnit++comma :: Word8+comma = 0x2C++-- | Read @body@ with a forced chunk count (bypasses the size/caps gate).+readChunked :: Int -> ReadOptions -> T.Text -> IO DataFrame+readChunked n opts body =+    D.readSeparatedFromBytesChunks n comma opts (TE.encodeUtf8 body)++readSequential :: ReadOptions -> T.Text -> IO DataFrame+readSequential opts body =+    D.readSeparatedFromBytes comma opts (TE.encodeUtf8 body)++-- | Assert chunked == sequential for several chunk counts.+assertChunkInvariant :: String -> ReadOptions -> T.Text -> Assertion+assertChunkInvariant what opts body = do+    seqDf <- readSequential opts body+    mapM_+        ( \n -> do+            parDf <- readChunked n opts body+            assertEqual (what <> " (chunks=" <> show n <> ")") seqDf parDf+        )+        [2, 3, 5, 8]++expectColumn :: String -> T.Text -> DataFrame -> DI.Column -> Assertion+expectColumn what name df expected = case getColumn name df of+    Nothing -> assertFailure (what <> ": column missing")+    Just col -> assertEqual what expected col++-- A 200-row mixed dataset: nullable ints, doubles, quoted text with+-- embedded separators/newlines/doubled quotes, bools, nullable dates.+mixedBody :: T.Text+mixedBody = "i,d,t,b,dt\n" <> T.concat (map row [0 .. 199 :: Int])+  where+    row k =+        T.intercalate+            ","+            [ if k `mod` 7 == 3 then "" else T.pack (show k)+            , T.pack (show (fromIntegral k * 0.25 :: Double))+            , textCell k+            , if even k then "True" else "false"+            , if k `mod` 11 == 5+                then ""+                else "2024-01-" <> T.justifyRight 2 '0' (T.pack (show (1 + k `mod` 28)))+            ]+            <> "\n"+    textCell k = case k `mod` 5 of+        0 -> "\"a,b" <> T.pack (show k) <> "\""+        1 -> "\"line\nbreak" <> T.pack (show k) <> "\""+        2 -> "\"quote\"\"inside" <> T.pack (show k) <> "\""+        3 -> ""+        _ -> "plain" <> T.pack (show k)++testParallelMatchesSequentialMixed :: Test+testParallelMatchesSequentialMixed =+    TestLabel "par_matches_seq_mixed" . TestCase $+        assertChunkInvariant "mixed nulls+quoted" defaultReadOptions mixedBody++-- A Double cell far past the sample, in the last chunk: every chunk that+-- resolved Int must be re-parsed as Double by the merger.+testPromotionAcrossChunks :: Test+testPromotionAcrossChunks = TestLabel "par_promotion_int_double" . TestCase $ do+    let body =+            "a\n"+                <> T.concat [T.pack (show k) <> "\n" | k <- [0 .. 189 :: Int]]+                <> "190.5\n"+    assertChunkInvariant "late double promotes ints" defaultReadOptions body+    df <- readChunked 4 defaultReadOptions body+    expectColumn+        "merged column is Double"+        "a"+        df+        (DI.fromList @Double ([fromIntegral k | k <- [0 .. 189 :: Int]] <> [190.5]))++-- A Bool cell in the last chunk of an Int column: candidate escalates past+-- Double (the bool cell fails it) all the way to Text.+testDemotionToTextAcrossChunks :: Test+testDemotionToTextAcrossChunks = TestLabel "par_demotion_text" . TestCase $ do+    let body =+            "a\n"+                <> T.concat [T.pack (show k) <> "\n" | k <- [0 .. 189 :: Int]]+                <> "True\n"+    assertChunkInvariant "late bool demotes ints to Text" defaultReadOptions body+    df <- readChunked 4 defaultReadOptions body+    expectColumn+        "merged column is Text"+        "a"+        df+        (DI.fromList @T.Text ([T.pack (show k) | k <- [0 .. 189 :: Int]] <> ["True"]))++-- An all-null column must merge to the same all-null Maybe Text column.+testAllNullColumnAcrossChunks :: Test+testAllNullColumnAcrossChunks = TestLabel "par_all_null" . TestCase $ do+    let body =+            "a,b\n" <> T.concat [",x" <> T.pack (show k) <> "\n" | k <- [0 .. 199 :: Int]]+    assertChunkInvariant "all-null column" defaultReadOptions body+    df <- readChunked 4 defaultReadOptions body+    expectColumn+        "all-null column is Maybe Text"+        "a"+        df+        (DI.fromList @(Maybe T.Text) (replicate 200 Nothing))++-- All-null sample (NoAssumption), data only in the last chunk: all-null+-- chunks must be re-parsed at the type the data chunk resolved.+testLateDataAfterAllNullSample :: Test+testLateDataAfterAllNullSample = TestLabel "par_late_bool" . TestCase $ do+    let cell k+            | k < 150 = ""+            | even k = "True"+            | otherwise = "false"+        body =+            "a,b\n"+                <> T.concat [cell k <> ",x\n" | k <- [0 .. 199 :: Int]]+    assertChunkInvariant "late bools after null sample" defaultReadOptions body+    df <- readChunked 4 defaultReadOptions body+    expectColumn+        "column is Maybe Bool"+        "a"+        df+        ( DI.fromList @(Maybe Bool)+            [ if k < 150 then Nothing else Just (even k)+            | k <- [0 .. 199 :: Int]+            ]+        )++-- Schema + NoSafeRead failure in a late chunk: same error, same row index+-- as the sequential read.+testSchemaErrorSameRowAsSequential :: Test+testSchemaErrorSameRowAsSequential = TestLabel "par_schema_error" . TestCase $ do+    let body =+            "a\n"+                <> T.concat [T.pack (show k) <> "\n" | k <- [0 .. 179 :: Int]]+                <> "boom\n"+                <> T.concat [T.pack (show k) <> "\n" | k <- [181 .. 199 :: Int]]+        opts =+            defaultReadOptions+                { typeSpec =+                    SpecifyTypes+                        (M.toList (elements (Schema (M.fromList [("a", SType (P.Proxy @Int))]))))+                        (typeSpec defaultReadOptions)+                }+    seqErr <- try @ErrorCall (readSequential opts body)+    parErr <- try @ErrorCall (readChunked 4 opts body)+    case (seqErr, parErr) of+        (Left e1, Left e2) -> assertEqual "same schema error" (show e1) (show e2)+        _ -> assertFailure "both reads should raise on the unparseable schema cell"++-- MaybeRead missing tokens with chunked extraction.+testMaybeReadAcrossChunks :: Test+testMaybeReadAcrossChunks = TestLabel "par_mayberead" . TestCase $ do+    let cell k = if k `mod` 13 == 7 then "NA" else T.pack (show k)+        body = "a\n" <> T.concat [cell k <> "\n" | k <- [0 .. 199 :: Int]]+        opts = defaultReadOptions{safeRead = MaybeRead}+    assertChunkInvariant "MaybeRead missing tokens" opts body+    df <- readChunked 4 opts body+    expectColumn+        "column is Maybe Int"+        "a"+        df+        ( DI.fromList @(Maybe Int)+            [ if k `mod` 13 == 7 then Nothing else Just k+            | k <- [0 .. 199 :: Int]+            ]+        )++-- Ragged rows (PadWithNull default) must behave identically chunked.+testRaggedRowsAcrossChunks :: Test+testRaggedRowsAcrossChunks = TestLabel "par_ragged" . TestCase $ do+    let row k+            | k `mod` 9 == 4 = T.pack (show k) <> "\n"+            | k `mod` 9 == 8 = T.pack (show k) <> ",x,extra\n"+            | otherwise = T.pack (show k) <> ",x" <> T.pack (show k) <> "\n"+        body = "a,b\n" <> T.concat (map row [0 .. 199 :: Int])+    assertChunkInvariant "ragged rows pad with null" defaultReadOptions body++-- Date column with nulls: boxed chunk columns must splice correctly.+testDateNullsAcrossChunks :: Test+testDateNullsAcrossChunks = TestLabel "par_date_nulls" . TestCase $ do+    -- A second column keeps null rows from looking like blank lines+    -- (blank lines are skipped by documented behavior).+    let cell k =+            if k `mod` 17 == 9+                then ""+                else "2023-05-" <> T.justifyRight 2 '0' (T.pack (show (1 + k `mod` 28)))+        body = "a,b\n" <> T.concat [cell k <> ",x\n" | k <- [0 .. 199 :: Int]]+    assertChunkInvariant "nullable dates" defaultReadOptions body+    df <- readChunked 4 defaultReadOptions body+    expectColumn+        "column is Maybe Day"+        "a"+        df+        ( DI.fromList @(Maybe Day)+            [ if k `mod` 17 == 9+                then Nothing+                else Just (read ("2023-05-" <> pad (1 + k `mod` 28)))+            | k <- [0 .. 199 :: Int]+            ]+        )+  where+    pad n = if n < 10 then '0' : show n else show n++-- An unclosed quote must still raise under the chunked (parallel) scan.+testUnclosedQuoteAcrossChunks :: Test+testUnclosedQuoteAcrossChunks = TestLabel "par_unclosed_quote" . TestCase $ do+    let body =+            "a\n"+                <> T.concat [T.pack (show k) <> "\n" | k <- [0 .. 199 :: Int]]+                <> "\"dangling"+    r <- try @D.CsvParseError (readChunked 4 defaultReadOptions body)+    case r of+        Left D.CsvUnclosedQuote -> pure ()+        other -> assertFailure ("expected CsvUnclosedQuote, got " <> show other)++-- WS-E2 ingest pin: the parallel byte-level merge wraps the merged shared+-- buffer as 'PackedText' (no Text spine), so the parallel path is also an+-- RSS win. Values stay identical to the sequential read.+testParallelTextFreezesPacked :: Test+testParallelTextFreezesPacked = TestLabel "par_text_freezes_packed" . TestCase $ do+    let body =+            "a,b\n"+                <> T.concat+                    [ "row" <> T.pack (show k) <> ",t" <> T.pack (show k) <> "\n"+                    | k <- [0 .. 199 :: Int]+                    ]+    df <- readChunked 4 defaultReadOptions body+    case getColumn "b" df of+        Nothing -> assertFailure "missing text column"+        Just col -> do+            assertBool "parallel text column is PackedText" (DI.isPackedText col)+            assertEqual+                "parallel text values byte-identical"+                (DI.fromList @T.Text ["t" <> T.pack (show k) | k <- [0 .. 199 :: Int]])+                col++tests :: [Test]+tests =+    [ testParallelMatchesSequentialMixed+    , testPromotionAcrossChunks+    , testDemotionToTextAcrossChunks+    , testAllNullColumnAcrossChunks+    , testLateDataAfterAllNullSample+    , testSchemaErrorSameRowAsSequential+    , testMaybeReadAcrossChunks+    , testRaggedRowsAcrossChunks+    , testDateNullsAcrossChunks+    , testUnclosedQuoteAcrossChunks+    , testParallelTextFreezesPacked+    ]
tests/Operations/ReadCsv.hs view
@@ -85,6 +85,7 @@ getRowEscaped sep df i = V.ifoldr go [] (columns df)   where     go :: Int -> Column -> [T.Text] -> [T.Text]+    go idx c@(PackedText _ _) acc = go idx (DI.materializePacked c) acc     go _ (BoxedColumn bm (c :: V.Vector a)) acc = case c V.!? i of         Just e -> escapeField sep textRep : acc           where
+ tests/Operations/TypedExtraction.hs view
@@ -0,0 +1,322 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++{- | Round-2 (WS-E1) behavior pins for the typed-extraction fastcsv path:+schema bypass, unified WS-B parser semantics, byte-level missing tests,+and the chunk-boundary scalar tail that replaced the full-file copy.+-}+module Operations.TypedExtraction (tests) where++import qualified Data.Map as M+import qualified Data.Proxy as P+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as TIO++import Control.Exception (ErrorCall, evaluate, try)+import Data.Time (Day)+import DataFrame.IO.CSV (+    ReadOptions (..),+    TypeSpec (..),+    defaultReadOptions,+ )+import qualified DataFrame.IO.CSV.Fast as D+import DataFrame.Internal.Column (Column)+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.DataFrame (DataFrame, dataframeDimensions, getColumn)+import DataFrame.Internal.Schema (Schema (..), SchemaType (..), elements)+import DataFrame.Operations.Typing (SafeReadMode (..))+import System.Directory (removeFile)+import Test.HUnit++tmpDir :: FilePath+tmpDir = "./tests/data/unstable_csv/"++withCsv :: String -> T.Text -> (FilePath -> IO a) -> IO a+withCsv name body action = do+    let path = tmpDir <> "typed_" <> name <> ".csv"+    TIO.writeFile path body+    r <- action path+    removeFile path+    pure r++expectColumn :: String -> T.Text -> DataFrame -> Column -> Assertion+expectColumn what name df expected = case getColumn name df of+    Nothing -> assertFailure (what <> ": column missing")+    Just col -> assertEqual what expected col++-- Divergence #1 (pinned new behavior): Int overflow rejects instead of+-- wrapping, so an overflowing cell demotes the column to Double.+testIntOverflowDemotesToDouble :: Test+testIntOverflowDemotesToDouble = TestLabel "typed_int_overflow_demotes" $+    TestCase $+        withCsv "overflow" "a\n1\n9223372036854775808\n" $ \path -> do+            df <- D.fastReadCsv path+            expectColumn+                "overflowing int column becomes Double"+                "a"+                df+                (DI.fromList @Double [1.0, 9.223372036854776e18])++-- Divergence #2 (pinned new behavior): doubles parse with strip-equivalent+-- semantics, so a padded double past the 100-row inference sample no longer+-- demotes the whole column to Text. (Inside the sample the Text-level+-- classifier still rejects padding, by design.)+testPaddedDoubleStaysDouble :: Test+testPaddedDoubleStaysDouble = TestLabel "typed_padded_double" $+    TestCase $ do+        let clean = [fromIntegral i + 0.5 :: Double | i <- [1 .. 120 :: Int]]+            cleanRows = T.unlines (map (T.pack . show) clean)+        withCsv "padded_double" ("a\n" <> cleanRows <> " 3.5\n") $ \path -> do+            df <- D.fastReadCsv path+            expectColumn+                "padded double cell past the sample stays Double"+                "a"+                df+                (DI.fromList @Double (clean <> [3.5]))++-- Schema bypasses inference: a numeric-looking column declared Text in the+-- schema must come back as Text (old code silently kept the inferred Int).+testSchemaTextBypassesInference :: Test+testSchemaTextBypassesInference = TestLabel "typed_schema_text" $+    TestCase $+        withCsv "schema_text" "a\n1\n2\n" $ \path -> do+            let schema = Schema (M.fromList [("a", SType (P.Proxy @T.Text))])+            df <- D.fastReadCsvWithSchema schema path+            expectColumn+                "schema Text wins over inferred Int"+                "a"+                df+                (DI.fromList @T.Text ["1", "2"])++-- Schema + NoSafeRead: a cell that cannot parse as the declared type+-- raises during the read (strict read), instead of hiding an error thunk.+testSchemaStrictFailureThrows :: Test+testSchemaStrictFailureThrows = TestLabel "typed_schema_strict_throws" $+    TestCase $+        withCsv "schema_strict" "a\n1\nx\n" $ \path -> do+            let schema = Schema (M.fromList [("a", SType (P.Proxy @Int))])+            result <- try @ErrorCall $ do+                df <- D.fastReadCsvWithSchema schema path+                _ <- evaluate (dataframeDimensions df)+                case getColumn "a" df of+                    Just col -> evaluate (show col)+                    Nothing -> pure ""+            case result of+                Left _ -> pure ()+                Right _ ->+                    assertFailure+                        "schema Int over unparseable cell should raise under NoSafeRead"++-- Schema + MaybeRead: unparseable cells become nulls.+testSchemaMaybeReadNullsFailures :: Test+testSchemaMaybeReadNullsFailures = TestLabel "typed_schema_mayberead" $+    TestCase $+        withCsv "schema_maybe" "a\n1\nx\n2\n" $ \path -> do+            let schema = Schema (M.fromList [("a", SType (P.Proxy @Int))])+                opts =+                    defaultReadOptions+                        { typeSpec =+                            SpecifyTypes+                                (M.toList (elements schema))+                                (typeSpec defaultReadOptions)+                        , safeRead = MaybeRead+                        }+            df <- D.fastReadCsvWithOpts opts path+            expectColumn+                "schema Int + MaybeRead nulls the bad cell"+                "a"+                df+                (DI.fromList @(Maybe Int) [Just 1, Nothing, Just 2])++-- Preserved: NoSafeRead treats only empty cells as null, so "NA" still+-- demotes a numeric column to Text.+testNoSafeReadNADemotesToText :: Test+testNoSafeReadNADemotesToText = TestLabel "typed_nosaferead_na" $+    TestCase $+        withCsv "na_demotes" "a\n1\nNA\n" $ \path -> do+            df <- D.fastReadCsv path+            expectColumn+                "NA is data under NoSafeRead"+                "a"+                df+                (DI.fromList @T.Text ["1", "NA"])++-- Preserved: MaybeRead honours the canonical missing-token set.+testMaybeReadMissingTokens :: Test+testMaybeReadMissingTokens = TestLabel "typed_mayberead_missing" $+    TestCase $+        withCsv "maybe_missing" "a\n1\nNA\n2\n" $ \path -> do+            let opts = defaultReadOptions{safeRead = MaybeRead}+            df <- D.fastReadCsvWithOpts opts path+            expectColumn+                "NA is null under MaybeRead"+                "a"+                df+                (DI.fromList @(Maybe Int) [Just 1, Nothing, Just 2])++-- Preserved: custom missing indicators extend the canonical set.+testMaybeReadCustomMissingToken :: Test+testMaybeReadCustomMissingToken = TestLabel "typed_mayberead_custom" $+    TestCase $+        withCsv "maybe_custom" "a\n1\nMISSING\n2\n" $ \path -> do+            let opts =+                    defaultReadOptions+                        { safeRead = MaybeRead+                        , missingIndicators =+                            "MISSING" : missingIndicators defaultReadOptions+                        }+            df <- D.fastReadCsvWithOpts opts path+            expectColumn+                "custom token is null under MaybeRead"+                "a"+                df+                (DI.fromList @(Maybe Int) [Just 1, Nothing, Just 2])++-- Preserved: quoted numerics parse as numbers (quote stripping happens+-- before the typed parse).+testQuotedNumbersParse :: Test+testQuotedNumbersParse = TestLabel "typed_quoted_numbers" $+    TestCase $+        withCsv "quoted_nums" "a,b\n\"42\",\"1.5\"\n\"7\",\"2.5\"\n" $ \path -> do+            df <- D.fastReadCsv path+            expectColumn "quoted ints" "a" df (DI.fromList @Int [42, 7])+            expectColumn "quoted doubles" "b" df (DI.fromList @Double [1.5, 2.5])++-- Preserved: Bool and Date inference still land on typed columns.+testBoolAndDateInference :: Test+testBoolAndDateInference = TestLabel "typed_bool_date" $+    TestCase $+        withCsv "bool_date" "b,d\nTrue,2020-01-01\nFalse,2021-12-31\n" $ \path -> do+            df <- D.fastReadCsv path+            expectColumn "bool column" "b" df (DI.fromList @Bool [True, False])+            expectColumn+                "date column"+                "d"+                df+                (DI.fromList [read @Day "2020-01-01", read "2021-12-31"])++-- Preserved: a null inside a date column yields Maybe Day.+testDateWithNulls :: Test+testDateWithNulls = TestLabel "typed_date_nulls" $+    TestCase $+        withCsv "date_nulls" "d,x\n2020-01-01,a\n,b\n2021-12-31,c\n" $ \path -> do+            df <- D.fastReadCsv path+            expectColumn+                "nullable date column"+                "d"+                df+                ( DI.fromList+                    [ Just (read @Day "2020-01-01")+                    , Nothing+                    , Just (read "2021-12-31")+                    ]+                )++-- The scalar tail (last <64 bytes after the SIMD chunks) must honour the+-- quote state carried out of the SIMD region: a quoted field that opens+-- before the 64-byte boundary and closes inside the tail may contain+-- separators and newlines.+testQuoteSpansChunkBoundary :: Test+testQuoteSpansChunkBoundary = TestLabel "typed_quote_spans_boundary" $+    TestCase $ do+        let pad = T.replicate 70 "x"+            body = "a,b\n" <> pad <> ",\"with,comma\nand newline\"\n"+        withCsv "boundary_quote" body $ \path -> do+            df <- D.fastReadCsv path+            assertEqual "one data row" 1 (fst (dataframeDimensions df))+            expectColumn+                "quoted field spanning the SIMD/tail boundary"+                "b"+                df+                (DI.fromList @T.Text ["with,comma\nand newline"])++-- An unclosed quote that opens inside the scalar tail must still raise.+testUnclosedQuoteInTail :: Test+testUnclosedQuoteInTail = TestLabel "typed_unclosed_in_tail" $+    TestCase $ do+        let pad = T.replicate 70 "x"+            body = "a\n" <> pad <> "\n\"dangling"+        withCsv "tail_unclosed" body $ \path -> do+            result <- try @D.CsvParseError (D.fastReadCsv path)+            case result of+                Left D.CsvUnclosedQuote -> pure ()+                other ->+                    assertFailure ("expected CsvUnclosedQuote, got " <> show other)++-- S2 pin: inference must land on exactly the same columns (types and+-- values) as an explicit schema declaring the true types.+testInferredMatchesSchema :: Test+testInferredMatchesSchema = TestLabel "typed_inferred_matches_schema" $+    TestCase $ do+        let rows =+                T.unlines+                    [ T.intercalate+                        ","+                        [ T.pack (show i)+                        , T.pack (show (fromIntegral i + 0.25 :: Double))+                        , "name" <> T.pack (show i)+                        , if even i then "True" else "false"+                        , "2024-01-0" <> T.pack (show (1 + i `mod` 9))+                        ]+                    | i <- [1 .. 150 :: Int]+                    ]+            body = "i,d,t,b,dt\n" <> rows+            schema =+                Schema+                    ( M.fromList+                        [ ("i", SType (P.Proxy @Int))+                        , ("d", SType (P.Proxy @Double))+                        , ("t", SType (P.Proxy @T.Text))+                        , ("b", SType (P.Proxy @Bool))+                        , ("dt", SType (P.Proxy @Day))+                        ]+                    )+        withCsv "inferred_vs_schema" body $ \path -> do+            inferred <- D.fastReadCsv path+            explicit <- D.fastReadCsvWithSchema schema path+            assertEqual "inferred == explicit schema" explicit inferred++-- Ingest pin: an inferred string column freezes to a shared-buffer+-- 'PackedText' (no per-row boxed Text header), values byte-identical.+testTextFreezesPacked :: Test+testTextFreezesPacked = TestLabel "typed_text_freezes_packed" $+    TestCase $+        withCsv "freezes_packed" "a,b\n1,x\n2,y\n" $ \path -> do+            df <- D.fastReadCsv path+            case getColumn "b" df of+                Nothing -> assertFailure "missing text column"+                Just col -> do+                    assertBool "text column is PackedText" (DI.isPackedText col)+                    assertEqual "values byte-identical" (DI.fromList @T.Text ["x", "y"]) col++-- The in-memory entry point must agree with the file-based one.+testFromBytesMatchesFile :: Test+testFromBytesMatchesFile = TestLabel "typed_from_bytes" $+    TestCase $ do+        let body = "a,b,c\n1,x,2.5\n2,y,3.5\n,z,\n"+        withCsv "from_bytes" body $ \path -> do+            fromFile <- D.fastReadCsv path+            fromBytes <- D.fastReadCsvFromBytes (TE.encodeUtf8 body)+            assertEqual "file and bytes parses agree" fromFile fromBytes++tests :: [Test]+tests =+    [ testIntOverflowDemotesToDouble+    , testPaddedDoubleStaysDouble+    , testSchemaTextBypassesInference+    , testSchemaStrictFailureThrows+    , testSchemaMaybeReadNullsFailures+    , testNoSafeReadNADemotesToText+    , testMaybeReadMissingTokens+    , testMaybeReadCustomMissingToken+    , testQuotedNumbersParse+    , testBoolAndDateInference+    , testDateWithNulls+    , testQuoteSpansChunkBoundary+    , testUnclosedQuoteInTail+    , testInferredMatchesSchema+    , testTextFreezesPacked+    , testFromBytesMatchesFile+    ]
tests/Properties/Csv.hs view
@@ -19,13 +19,16 @@ import qualified Data.List as L import qualified Data.Map as M import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import qualified Data.Text.IO as TIO import qualified Data.Vector as V +import DataFrame.IO.CSV (defaultReadOptions)+ import Data.Type.Equality (testEquality, (:~:) (Refl)) import DataFrame.IO.CSV.Fast (CsvParseError (..)) import qualified DataFrame.IO.CSV.Fast as D-import DataFrame.Internal.Column (Column (..))+import DataFrame.Internal.Column (Column (..), materializePacked) import DataFrame.Internal.DataFrame (     DataFrame (..),     columnIndices,@@ -114,12 +117,13 @@ columnAsText name df = do     idx <- M.lookup name (columnIndices df)     col <- columns df V.!? idx-    case col of+    case materializePacked col of         BoxedColumn _ (vec :: V.Vector a) ->             case testEquality (typeRep @a) (typeRep @T.Text) of                 Just Refl -> Just (V.toList vec)                 Nothing -> Nothing         UnboxedColumn{} -> Nothing+        PackedText{} -> Nothing  {- | Run a property in @IO@ against a generated CSV text, cleaning up the temp file afterwards no matter what.@@ -238,6 +242,31 @@                 assert (cs == 1)                 assert (rs >= 0) +{- | WS-E2 determinism: a chunk-parallel read returns exactly the same+DataFrame as the sequential read, for any chunk count.  The generated grid+mixes Text cells (quotes, embedded separators and newlines) with a+nullable numeric column so chunk-level type promotion is exercised too.+-}+prop_parallel_equals_sequential :: ColumnNames -> [[Cell]] -> Property+prop_parallel_equals_sequential (ColumnNames header) rows =+    forAll (chooseInt (2, 7)) $ \nChunks ->+        forAll (vectorOf (length rows) numCell) $ \numCol -> monadicIO $ do+            let cellRows =+                    zipWith (\r n -> map unCell r <> [n]) rows numCol+                csv = encodeCsv ',' (header <> ["num"]) cellRows+                bytes = TE.encodeUtf8 csv+            seqDf <- run (D.readSeparatedFromBytes 0x2C defaultReadOptions bytes)+            parDf <-+                run (D.readSeparatedFromBytesChunks nChunks 0x2C defaultReadOptions bytes)+            assert (seqDf == parDf)+  where+    numCell =+        frequency+            [ (4, T.pack . show <$> chooseInt (-1000, 1000))+            , (2, T.pack . show <$> (choose (-10, 10) :: Gen Double))+            , (1, pure "")+            ]+ tests :: [Property] tests =     [ property prop_bom_invariant@@ -245,4 +274,5 @@     , property prop_roundtrip_ascii     , property prop_unclosed_quote_throws     , property prop_boundary_sizes+    , property prop_parallel_equals_sequential     ]