packages feed

dataframe-parsing 1.0.1.0 → 1.0.2.0

raw patch · 10 files changed

+1222/−5 lines, 10 filesdep +HUnitdep +QuickCheckdep +dataframe-parsingdep ~dataframe-coredep ~textPVP ok

version bump matches the API change (PVP)

Dependencies added: HUnit, QuickCheck, dataframe-parsing, vector

Dependency ranges changed: dataframe-core, text

API changes (from Hackage documentation)

+ DataFrame.Internal.Parsing.Fast: isMissingField :: ByteString -> Bool
+ DataFrame.Internal.Parsing.Fast: isMissingFieldIn :: [ByteString] -> ByteString -> Bool
+ DataFrame.Internal.Parsing.Fast: isMissingFieldSlice :: ByteString -> Int -> Int -> Bool
+ DataFrame.Internal.Parsing.Fast: parseBoolField :: ByteString -> Maybe Bool
+ DataFrame.Internal.Parsing.Fast: parseBoolField# :: ByteString -> Int -> Int -> (# Int#, Int# #)
+ DataFrame.Internal.Parsing.Fast: parseBoolFieldSlice :: ByteString -> Int -> Int -> Maybe Bool
+ DataFrame.Internal.Parsing.Fast: parseDateField :: ByteString -> Maybe Day
+ DataFrame.Internal.Parsing.Fast: parseDateFieldSlice :: ByteString -> Int -> Int -> Maybe Day
+ DataFrame.Internal.Parsing.Fast: parseDoubleField :: ByteString -> Maybe Double
+ DataFrame.Internal.Parsing.Fast: parseDoubleField# :: ByteString -> Int -> Int -> (# Int#, Double# #)
+ DataFrame.Internal.Parsing.Fast: parseDoubleFieldSlice :: ByteString -> Int -> Int -> Maybe Double
+ DataFrame.Internal.Parsing.Fast: parseIntField :: ByteString -> Maybe Int
+ DataFrame.Internal.Parsing.Fast: parseIntField# :: ByteString -> Int -> Int -> (# Int#, Int# #)
+ DataFrame.Internal.Parsing.Fast: parseIntFieldSlice :: ByteString -> Int -> Int -> Maybe Int
+ DataFrame.Internal.Parsing.Fast.Common: isDigitByte :: Word8 -> Bool
+ DataFrame.Internal.Parsing.Fast.Common: isStripByte :: Word8 -> Bool
+ DataFrame.Internal.Parsing.Fast.Common: skipStrip :: ByteString -> Int -> Int -> Int
+ DataFrame.Internal.Parsing.Fast.Common: skipStripEnd :: ByteString -> Int -> Int -> Int
+ DataFrame.Internal.Parsing.Fast.Common: skipZeroes :: ByteString -> Int -> Int -> Int
+ DataFrame.Internal.Parsing.Fast.Common: takeDigits64 :: ByteString -> Int -> Int -> (Int -> Word64 -> r) -> r
+ DataFrame.Internal.Parsing.Fast.Double: parseDoubleField# :: ByteString -> Int -> Int -> (# Int#, Double# #)
+ DataFrame.Internal.Parsing.Fast.Int: parseIntField# :: ByteString -> Int -> Int -> (# Int#, Int# #)
+ DataFrame.Internal.Parsing.Fast.Token: isMissingFieldIn :: [ByteString] -> ByteString -> Bool
+ DataFrame.Internal.Parsing.Fast.Token: isMissingFieldSlice :: ByteString -> Int -> Int -> Bool
+ DataFrame.Internal.Parsing.Fast.Token: parseBoolField# :: ByteString -> Int -> Int -> (# Int#, Int# #)
+ DataFrame.Internal.Parsing.Fast.Token: parseDateFieldSlice :: ByteString -> Int -> Int -> Maybe Day

Files

+ bench/FieldParsers.hs view
@@ -0,0 +1,175 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Per-field microbenchmark for "DataFrame.Internal.Parsing.Fast"+against the reference parsers (WS-B exit criterion: parseDoubleField+>= 5x Data.Text.Read.double on doubles_100mb-shaped fields).++Style follows benchmarks/csv: monotonic-clock wall over a strict fold,+best of 5 runs, ns/field plus speedup printed per pairing.+-}+module Main (main) where++import qualified Data.ByteString.Char8 as C+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Vector as V++import Data.Bits (shiftR, xor)+import Data.Maybe (fromMaybe)+import Data.Time.Calendar (toModifiedJulianDay)+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import GHC.Exts (Double (..), Int (..))+import Text.Printf (printf)++import qualified DataFrame.Internal.Parsing as Ref+import DataFrame.Internal.Parsing.Fast++fieldCount :: Int+fieldCount = 1000000++-- | SplitMix64 finalizer; deterministic pseudo-random stream.+mix64 :: Word64 -> Word64+mix64 z0 =+    let z1 = (z0 `xor` (z0 `shiftR` 33)) * 0xFF51AFD7ED558CCD+        z2 = (z1 `xor` (z1 `shiftR` 33)) * 0xC4CEB9FE1A85EC53+     in z2 `xor` (z2 `shiftR` 33)++{- | Round-trip-precision doubles, the doubles_100mb shape+(full 17-significant-digit mantissas from @show@).+-}+doubleFields :: V.Vector C.ByteString+doubleFields = V.generate fieldCount gen+  where+    gen i =+        let w = mix64 (fromIntegral i + 1)+            mag = fromIntegral (w `shiftR` 11) / 9007199254740992 :: Double+            scaled = (mag - 0.5) * 2000000+         in C.pack (show scaled)++intFields :: V.Vector C.ByteString+intFields = V.generate fieldCount gen+  where+    gen i =+        let w = mix64 (fromIntegral i + 0x9E3779B97F4A7C15)+            spans = [99, 9999, 999999, 99999999, 9223372036854775807] :: [Word64]+            bound = spans !! (i `mod` 5)+            v = toInteger (w `mod` bound) - toInteger (bound `div` 2)+         in C.pack (show v)++boolFields :: V.Vector C.ByteString+boolFields = V.generate fieldCount gen+  where+    gen i = map C.pack ["True", "False", "true", "false", "TRUE", "FALSE"] !! (i `mod` 6)++dateFields :: V.Vector C.ByteString+dateFields = V.generate fieldCount gen+  where+    gen i =+        let w = mix64 (fromIntegral i + 7)+            y = 1900 + fromIntegral (w `mod` 200) :: Int+            m = 1 + fromIntegral ((w `shiftR` 8) `mod` 12) :: Int+            d = 1 + fromIntegral ((w `shiftR` 16) `mod` 28) :: Int+            pad2 n = (if n < 10 then "0" else "") ++ show n+         in C.pack (show y ++ "-" ++ pad2 m ++ "-" ++ pad2 d)++-- | mixed_100mb-flavoured missing-check stream: mostly non-missing.+missingFields :: V.Vector C.ByteString+missingFields = V.generate fieldCount gen+  where+    gen i =+        map C.pack ["12345", "NA", "hello world", "", "3.14159", "Nothing", "x", "null"]+            !! (i `mod` 8)++forceBS :: V.Vector C.ByteString -> Int+forceBS = V.foldl' (\ !acc b -> acc + C.length b) 0++forceT :: V.Vector T.Text -> Int+forceT = V.foldl' (\ !acc t -> acc + T.length t) 0++-- | Best-of-5 wall nanoseconds for a strict fold over the fields.+bench :: V.Vector a -> (a -> Double) -> IO Double+bench fields step = minimum <$> mapM run [1 .. 5 :: Int]+  where+    -- Seed the accumulator with the run index so the fold cannot be+    -- floated out and shared between the five runs.+    run k = do+        t0 <- getMonotonicTimeNSec+        let !s = V.foldl' (\ !acc f -> acc + step f) (fromIntegral k) fields+        s `seq` return ()+        t1 <- getMonotonicTimeNSec+        return (fromIntegral (t1 - t0))++report :: String -> Double -> Double -> IO ()+report name refNs fastNs = do+    let n = fromIntegral fieldCount+    printf+        "%-34s ref %8.1f ns/field   fast %7.1f ns/field   speedup %5.2fx\n"+        name+        (refNs / n)+        (fastNs / n)+        (refNs / fastNs)++mb :: Maybe Bool -> Double+mb = maybe 0 (\b -> if b then 1 else 2)++mi :: Maybe Int -> Double+mi = fromIntegral . fromMaybe 0++md :: Maybe Double -> Double+md = fromMaybe 0++main :: IO ()+main = do+    printf "fields per stream: %d (best of 5 runs)\n\n" fieldCount+    let doubleTexts = V.map TE.decodeUtf8Lenient doubleFields+        intTexts = V.map TE.decodeUtf8Lenient intFields+    printf+        "force: %d\n\n"+        ( forceBS doubleFields+            + forceBS intFields+            + forceBS boolFields+            + forceBS missingFields+            + forceBS dateFields+            + forceT doubleTexts+            + forceT intTexts+        )++    refD <- bench doubleFields (md . Ref.readByteStringDouble)+    refDT <- bench doubleTexts (md . Ref.readDouble)+    fastD <- bench doubleFields (md . parseDoubleField)+    fastDU <-+        bench doubleFields $ \f ->+            case parseDoubleField# f 0 (C.length f) of+                (# 0#, _ #) -> 0+                (# _, d #) -> D# d+    report "double (vs readByteStringDouble)" refD fastD+    report "double (vs Text readDouble)" refDT fastD+    report "double unboxed core (vs BS ref)" refD fastDU++    refI <- bench intFields (mi . Ref.readByteStringInt)+    refIT <- bench intTexts (mi . Ref.readInt)+    fastI <- bench intFields (mi . parseIntField)+    fastIU <-+        bench intFields $ \f ->+            case parseIntField# f 0 (C.length f) of+                (# 0#, _ #) -> 0+                (# _, n #) -> fromIntegral (I# n)+    report "int (vs readByteStringInt)" refI fastI+    report "int (vs Text readInt)" refIT fastI+    report "int unboxed core (vs BS ref)" refI fastIU++    refB <- bench boolFields (mb . Ref.readByteStringBool)+    fastB <- bench boolFields (mb . parseBoolField)+    report "bool (vs readByteStringBool)" refB fastB++    refM <- bench missingFields (\f -> if Ref.isNullishBS f then 1 else 0)+    fastM <- bench missingFields (\f -> if isMissingField f then 1 else 0)+    report "missing (vs isNullishBS)" refM fastM++    let mday = maybe 0 (fromIntegral . toModifiedJulianDay)+    refDay <- bench dateFields (mday . Ref.readByteStringDate "%Y-%m-%d")+    fastDay <- bench dateFields (mday . parseDateField)+    report "date %Y-%m-%d (vs parseTimeM)" refDay fastDay
dataframe-parsing.cabal view
@@ -1,7 +1,6 @@ cabal-version:      2.4 name:               dataframe-parsing-version:            1.0.1.0-+version:            1.0.2.0 synopsis:           Shared text/binary parsing helpers for the dataframe ecosystem. description:     Parsing primitives used by the @dataframe@ family: CSV-friendly text@@ -16,7 +15,7 @@ license-file:       LICENSE author:             Michael Chavinda maintainer:         mschavinda@gmail.com-copyright:          (c) 2024-2025 Michael Chavinda+copyright:          (c) 2024-2026 Michael Chavinda category:           Data tested-with:        GHC ==9.4.8 || ==9.6.7 || ==9.8.4 || ==9.10.3 || ==9.12.2 @@ -33,14 +32,50 @@     exposed-modules:                         DataFrame.Internal.Binary                         DataFrame.Internal.Parsing+                        DataFrame.Internal.Parsing.Fast+                        DataFrame.Internal.Parsing.Fast.Common+                        DataFrame.Internal.Parsing.Fast.Double+                        DataFrame.Internal.Parsing.Fast.Int+                        DataFrame.Internal.Parsing.Fast.Token                         DataFrame.Internal.Schema     build-depends:      base >= 4 && < 5,                         attoparsec >= 0.12 && < 0.15,                         bytestring >= 0.11 && < 0.13,                         bytestring-lexing >= 0.5 && < 0.6,                         containers >= 0.6.7 && < 0.9,-                        dataframe-core ^>= 1.0,+                        dataframe-core ^>= 1.1,                         text >= 2.0 && < 3,-                        time >= 1.12 && < 2+                        time >= 1.12 && < 2,+                        vector >= 0.12 && < 0.14     hs-source-dirs:     src+    default-language:   Haskell2010++test-suite tests+    import:             warnings+    type:               exitcode-stdio-1.0+    main-is:            Main.hs+    other-modules:      Properties.FastParsing+                        Unit.FastParsing+    build-depends:      base >= 4 && < 5,+                        bytestring >= 0.11 && < 0.13,+                        dataframe-parsing,+                        HUnit ^>= 1.6,+                        QuickCheck >= 2 && < 3,+                        text >= 2.0 && < 3+    hs-source-dirs:     tests+    ghc-options:        -O2+    default-language:   Haskell2010++benchmark field-parsers+    import:             warnings+    type:               exitcode-stdio-1.0+    main-is:            FieldParsers.hs+    build-depends:      base >= 4 && < 5,+                        bytestring >= 0.11 && < 0.13,+                        dataframe-parsing,+                        text >= 2.0 && < 3,+                        time >= 1.12 && < 2,+                        vector >= 0.12 && < 0.14+    hs-source-dirs:     bench+    ghc-options:        -O2     default-language:   Haskell2010
+ src/DataFrame/Internal/Parsing/Fast.hs view
@@ -0,0 +1,107 @@+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Fast, non-backtracking field parsers for CSV ingest (Round-2 WS-B).++Each parser is a pure function over a @(buf, start, end)@ byte slice+(the SIMD scan already yields field boundaries) and is bit-exact with+the reference parsers in "DataFrame.Internal.Parsing": the Double fast+path covers fixed-width mantissas (<= 19 significant digits per+component) via a power-of-ten table and falls back to+'DataFrame.Internal.Parsing.readByteStringDouble' whenever exactness+is in doubt. The unboxed @#@ variants avoid all 'Maybe' boxing; the+plain names are thin 'Maybe' wrappers over the whole ByteString.+-}+module DataFrame.Internal.Parsing.Fast (+    -- * Int fields+    parseIntField,+    parseIntFieldSlice,+    parseIntField#,++    -- * Double fields+    parseDoubleField,+    parseDoubleFieldSlice,+    parseDoubleField#,++    -- * Bool fields+    parseBoolField,+    parseBoolFieldSlice,+    parseBoolField#,++    -- * Date fields (default @%Y-%m-%d@ format)+    parseDateField,+    parseDateFieldSlice,++    -- * Missing-token test+    isMissingField,+    isMissingFieldSlice,+    isMissingFieldIn,+) where++import qualified Data.ByteString as BS++import Data.Time (Day)+import GHC.Exts (Double (..), Int (..))++import DataFrame.Internal.Parsing.Fast.Double (parseDoubleField#)+import DataFrame.Internal.Parsing.Fast.Int (parseIntField#)+import DataFrame.Internal.Parsing.Fast.Token (+    isMissingFieldIn,+    isMissingFieldSlice,+    parseBoolField#,+    parseDateFieldSlice,+ )++{- | Strip-tolerant @Int@ parse of a whole field; rejects overflow,+matching @readByteStringInt@ exactly.+-}+parseIntField :: BS.ByteString -> Maybe Int+parseIntField bs = parseIntFieldSlice bs 0 (BS.length bs)+{-# INLINE parseIntField #-}++parseIntFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Int+parseIntFieldSlice bs start end =+    case parseIntField# bs start end of+        (# 0#, _ #) -> Nothing+        (# _, n #) -> Just (I# n)+{-# INLINE parseIntFieldSlice #-}++{- | Strip-tolerant @Double@ parse of a whole field; bit-exact with+@readByteStringDouble@ (falls back to it outside the fast window).+-}+parseDoubleField :: BS.ByteString -> Maybe Double+parseDoubleField bs = parseDoubleFieldSlice bs 0 (BS.length bs)+{-# INLINE parseDoubleField #-}++parseDoubleFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Double+parseDoubleFieldSlice bs start end =+    case parseDoubleField# bs start end of+        (# 0#, _ #) -> Nothing+        (# _, d #) -> Just (D# d)+{-# INLINE parseDoubleFieldSlice #-}++-- | Exact-match Bool parse (@True|true|TRUE|False|false|FALSE@, no strip).+parseBoolField :: BS.ByteString -> Maybe Bool+parseBoolField bs = parseBoolFieldSlice bs 0 (BS.length bs)+{-# INLINE parseBoolField #-}++parseBoolFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Bool+parseBoolFieldSlice bs start end =+    case parseBoolField# bs start end of+        (# 0#, _ #) -> Nothing+        (# _, b #) -> Just (I# b /= 0)+{-# INLINE parseBoolFieldSlice #-}++{- | @%Y-%m-%d@ date parse: byte-level fast path for the padded+10-byte shape, 'Data.Time.parseTimeM' fallback for everything else.+-}+parseDateField :: BS.ByteString -> Maybe Day+parseDateField bs = parseDateFieldSlice bs 0 (BS.length bs)+{-# INLINE parseDateField #-}++{- | Byte-level test against the canonical missing-token list+(@Nothing NULL \"\" \" \" nan null N\/A NaN NAN NA@), no Text decode.+-}+isMissingField :: BS.ByteString -> Bool+isMissingField bs = isMissingFieldSlice bs 0 (BS.length bs)+{-# INLINE isMissingField #-}
+ src/DataFrame/Internal/Parsing/Fast/Common.hs view
@@ -0,0 +1,108 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE PolyKinds #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}++{- | Shared byte-level helpers for the fast slice parsers.++All functions operate on @(buf, start, end)@ slices with the hsthrift+one-bounds-check discipline: the caller guarantees+@0 <= start <= end <= length buf@ and everything below uses 'unsafeIndex'.+-}+module DataFrame.Internal.Parsing.Fast.Common (+    isStripByte,+    isDigitByte,+    skipStrip,+    skipStripEnd,+    skipZeroes,+    takeDigits64,+) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU++import Data.Word (Word64, Word8)+import GHC.Exts (+    Int (..),+    Int#,+    RuntimeRep,+    TYPE,+    Word64#,+    isTrue#,+    plusWord64#,+    timesWord64#,+    wordToWord64#,+    (+#),+    (>=#),+ )+import GHC.Word (Word64 (..))++{- | Bytes removed by 'Data.ByteString.Char8.strip' (Latin-1 'isSpace'):+HT LF VT FF CR SP and NBSP 0xA0. Probed over all 256 bytes; parity+with the strip-based reference parsers depends on this exact set.+-}+isStripByte :: Word8 -> Bool+isStripByte w = w == 0x20 || (w - 0x09) <= 4 || w == 0xA0+{-# INLINE isStripByte #-}++isDigitByte :: Word8 -> Bool+isDigitByte w = (w - 0x30) <= 9+{-# INLINE isDigitByte #-}++-- | Index of the first non-strip byte in @[i, end)@.+skipStrip :: BS.ByteString -> Int -> Int -> Int+skipStrip bs = go+  where+    go !i !end+        | i < end && isStripByte (BSU.unsafeIndex bs i) = go (i + 1) end+        | otherwise = i+{-# INLINE skipStrip #-}++-- | New exclusive end after dropping trailing strip bytes in @[i, end)@.+skipStripEnd :: BS.ByteString -> Int -> Int -> Int+skipStripEnd bs = go+  where+    go !i !end+        | end > i && isStripByte (BSU.unsafeIndex bs (end - 1)) = go i (end - 1)+        | otherwise = end+{-# INLINE skipStripEnd #-}++-- | Index of the first non-@\'0\'@ byte in @[i, end)@.+skipZeroes :: BS.ByteString -> Int -> Int -> Int+skipZeroes bs = go+  where+    go !i !end+        | i < end && BSU.unsafeIndex bs i == 0x30 = go (i + 1) end+        | otherwise = i+{-# INLINE skipZeroes #-}++{- | Consume ASCII digits from @i@, passing the stop index and the+wrapping 'Word64' accumulation to the (possibly unboxed-result)+continuation. Callers must bound the significant digit count before+trusting the value. The loop carries @Int#@/@Word64#@ explicitly:+GHC does not worker\/wrapper join points, so boxed loop arguments+would otherwise allocate 16 bytes per digit (ticky-verified).+-}+takeDigits64 ::+    forall (rep :: RuntimeRep) (r :: TYPE rep).+    BS.ByteString ->+    Int ->+    Int ->+    (Int -> Word64 -> r) ->+    r+takeDigits64 bs (I# i0) (I# end) k = go i0 (wordToWord64# 0##)+  where+    go :: Int# -> Word64# -> r+    go i acc+        | isTrue# (i >=# end) = k (I# i) (W64# acc)+        | isDigitByte w =+            case fromIntegral (w - 0x30) :: Word64 of+                W64# d ->+                    go+                        (i +# 1#)+                        ((acc `timesWord64#` wordToWord64# 10##) `plusWord64#` d)+        | otherwise = k (I# i) (W64# acc)+      where+        w = BSU.unsafeIndex bs (I# i)+{-# INLINE takeDigits64 #-}
+ src/DataFrame/Internal/Parsing/Fast/Double.hs view
@@ -0,0 +1,133 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Fast @Double@ slice parser, BIT-EXACT with @readByteStringDouble@+(bytestring-lexing's @readSigned readDecimal\/readExponential@).++Instead of replaying the reference parser's Integer-mantissa chain, the+fast path replays its exact floating-point operation sequence+(@fromInteger whole + fromInteger part \/ 10^k@, then @* 10^^e@) using+'Word64' digit accumulation and tables of @10 ^ k@ \/ @recip (10 ^ k)@+built with the very same @(^)@\/'recip' calls. Whenever a component+exceeds the 19-significant-digit 'Word64' window (or the exponent could+wrap 'Int'), exactness is in doubt and we fall back to the reference+parser on the raw slice.+-}+module DataFrame.Internal.Parsing.Fast.Double (parseDoubleField#) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU+import qualified Data.Vector.Unboxed as VU++import Data.Word (Word64)+import GHC.Exts (Double (..), Double#, Int#)++import DataFrame.Internal.Parsing (readByteStringDouble)+import DataFrame.Internal.Parsing.Fast.Common++{- | @10 ^ k@ for @k <= tableMax@, computed with the same @(^)@ the+reference parser uses, so every entry is bit-identical to its+runtime computation. Entries from @10^309@ up are @Infinity@, so+clamping larger exponents to 'tableMax' is exact.+-}+pow10Table :: VU.Vector Double+pow10Table = VU.generate (tableMax + 1) (10 ^)+{-# NOINLINE pow10Table #-}++-- | @recip (10 ^ k)@, replaying @10 ^^ negate k@ bit-for-bit.+recipPow10Table :: VU.Vector Double+recipPow10Table = VU.map recip pow10Table+{-# NOINLINE recipPow10Table #-}++tableMax :: Int+tableMax = 1024++{- | 'Word64' to 'Double' exactly as the reference parser's+'fromInteger' rounds it; values up to @2^53@ take the exact+'Int' conversion (int2Double#), larger ones the 'Integer' route.+-}+w2d :: Word64 -> Double+w2d w+    | w <= 9007199254740991 = fromIntegral (fromIntegral w :: Int)+    | otherwise = fromInteger (toInteger w)+{-# INLINE w2d #-}++-- | Exactness in doubt: hand the raw slice to the reference parser.+referenceSlice :: BS.ByteString -> Int -> Int -> (# Int#, Double# #)+referenceSlice bs start end =+    case readByteStringDouble (BSU.unsafeTake (end - start) (BSU.unsafeDrop start bs)) of+        Just (D# d) -> (# 1#, d #)+        Nothing -> (# 0#, 0.0## #)+{-# NOINLINE referenceSlice #-}++{- | Result is @(# ok, value #)@ with @ok@ 0 or 1. Caller guarantees+@0 <= start <= end <= length buf@.+-}+parseDoubleField# :: BS.ByteString -> Int -> Int -> (# Int#, Double# #)+parseDoubleField# bs start end0+    | i0 >= end = none+    | otherwise =+        let !c0 = BSU.unsafeIndex bs i0+            !neg = c0 == 0x2D+            !i1 = if neg || c0 == 0x2B then i0 + 1 else i0+         in if i1 >= end || not (isDigitByte (BSU.unsafeIndex bs i1))+                then none+                else+                    let !iz = skipZeroes bs i1 end+                     in takeDigits64 bs iz end $ \wEnd w ->+                            if wEnd - iz > 19+                                then referenceSlice bs start end0+                                else afterWhole neg w wEnd+  where+    !i0 = skipStrip bs start end0+    !end = skipStripEnd bs i0 end0++    none = (# 0#, 0.0## #)++    afterWhole !neg !w !i+        | i < end && BSU.unsafeIndex bs i == 0x2E =+            let !f0 = i + 1+                !fz = skipZeroes bs f0 end+             in takeDigits64 bs fz end $ \fEnd p ->+                    if fEnd == f0+                        then none+                        else+                            if fEnd - fz > 19+                                then referenceSlice bs start end0+                                else afterExponent neg (w2d w + (w2d p / pow10 (fEnd - f0))) fEnd+        | otherwise = afterExponent neg (w2d w) i++    afterExponent !neg !val !i+        | i >= end = done neg val+        | BSU.unsafeIndex bs i == 0x65 || BSU.unsafeIndex bs i == 0x45 =+            let !i1 = i + 1+                !eneg = i1 < end && BSU.unsafeIndex bs i1 == 0x2D+                !i2 = if i1 < end && (eneg || BSU.unsafeIndex bs i1 == 0x2B) then i1 + 1 else i1+             in if i2 >= end || not (isDigitByte (BSU.unsafeIndex bs i2))+                    then none+                    else+                        let !ez = skipZeroes bs i2 end+                         in takeDigits64 bs ez end $ \eEnd e ->+                                if eEnd /= end+                                    then none+                                    else+                                        if eEnd - ez > 18+                                            then referenceSlice bs start end0+                                            else done neg (val * scale eneg (fromIntegral e))+        | otherwise = none++    scale !eneg !ex+        | eneg = VU.unsafeIndex recipPow10Table k+        | otherwise = VU.unsafeIndex pow10Table k+      where+        !k = min ex tableMax+    {-# INLINE scale #-}++    pow10 !k = VU.unsafeIndex pow10Table (min k tableMax)+    {-# INLINE pow10 #-}++    done !neg !v = case if neg then negate v else v of+        D# d -> (# 1#, d #)+    {-# INLINE done #-}+{-# INLINE parseDoubleField# #-}
+ src/DataFrame/Internal/Parsing/Fast/Int.hs view
@@ -0,0 +1,53 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Non-backtracking @Int@ slice parser, bit-compatible with+@readByteStringInt@ (i.e. @Data.ByteString.Char8.readInt . strip@):+grammar @WS* sign? digit+ WS*@, whole slice consumed, 'Nothing' on+64-bit overflow.+-}+module DataFrame.Internal.Parsing.Fast.Int (parseIntField#) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU++import GHC.Exts (Int (..), Int#)++import DataFrame.Internal.Parsing.Fast.Common++{- | Result is @(# ok, value #)@ with @ok@ 0 or 1. Caller guarantees+@0 <= start <= end <= length buf@.+-}+parseIntField# :: BS.ByteString -> Int -> Int -> (# Int#, Int# #)+parseIntField# bs start end0+    | i0 >= end = none+    | otherwise =+        let !c0 = BSU.unsafeIndex bs i0+            !neg = c0 == 0x2D+            !i1 = if neg || c0 == 0x2B then i0 + 1 else i0+         in if i1 >= end || not (isDigitByte (BSU.unsafeIndex bs i1))+                then none+                else+                    let !iz = skipZeroes bs i1 end+                     in takeDigits64 bs iz end $ \dEnd w ->+                            if dEnd /= end || dEnd - iz > 19+                                then none+                                else+                                    if neg+                                        then+                                            if w <= 9223372036854775808+                                                then done (negate (fromIntegral w))+                                                else none+                                        else+                                            if w <= 9223372036854775807+                                                then done (fromIntegral w)+                                                else none+  where+    !i0 = skipStrip bs start end0+    !end = skipStripEnd bs i0 end0+    none = (# 0#, 0# #)+    done :: Int -> (# Int#, Int# #)+    done (I# n) = (# 1#, n #)+    {-# INLINE done #-}+{-# INLINE parseIntField# #-}
+ src/DataFrame/Internal/Parsing/Fast/Token.hs view
@@ -0,0 +1,110 @@+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Byte-level token tests: Bool fields, the canonical missing-token+set, and the default @%Y-%m-%d@ date shape. All length-bucketed with+first-byte dispatch; no Text decode, no list walk.+-}+module DataFrame.Internal.Parsing.Fast.Token (+    parseBoolField#,+    isMissingFieldSlice,+    isMissingFieldIn,+    parseDateFieldSlice,+) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU++import Data.Time (Day, fromGregorianValid)+import Data.Word (Word8)+import GHC.Exts (Int#)++import DataFrame.Internal.Parsing (readByteStringDate)+import DataFrame.Internal.Parsing.Fast.Common (isDigitByte)++{- | Exactly @True|true|TRUE|False|false|FALSE@, no strip (the+'readByteStringBool' grammar). Result is @(# ok, bool #)@.+-}+parseBoolField# :: BS.ByteString -> Int -> Int -> (# Int#, Int# #)+parseBoolField# bs s e = case e - s of+    4+        | ix 0 == 0x54 && rue 0x72 0x75 0x65 -> (# 1#, 1# #) -- True+        | ix 0 == 0x54 && rue 0x52 0x55 0x45 -> (# 1#, 1# #) -- TRUE+        | ix 0 == 0x74 && rue 0x72 0x75 0x65 -> (# 1#, 1# #) -- true+    5+        | ix 0 == 0x46 && alse 0x61 0x6C 0x73 0x65 -> (# 1#, 0# #) -- False+        | ix 0 == 0x46 && alse 0x41 0x4C 0x53 0x45 -> (# 1#, 0# #) -- FALSE+        | ix 0 == 0x66 && alse 0x61 0x6C 0x73 0x65 -> (# 1#, 0# #) -- false+    _ -> (# 0#, 0# #)+  where+    ix d = BSU.unsafeIndex bs (s + d)+    rue a b c = ix 1 == a && ix 2 == b && ix 3 == c+    alse a b c d = ix 1 == a && ix 2 == b && ix 3 == c && ix 4 == d+{-# INLINE parseBoolField# #-}++{- | Membership in the canonical missing list+@[\"Nothing\",\"NULL\",\"\",\" \",\"nan\",\"null\",\"N\/A\",\"NaN\",\"NAN\",\"NA\"]@+(case-sensitive, exact), dispatched on length then first byte.+-}+isMissingFieldSlice :: BS.ByteString -> Int -> Int -> Bool+isMissingFieldSlice bs s e = case e - s of+    0 -> True+    1 -> ix 0 == 0x20 -- " "+    2 -> ix 0 == 0x4E && ix 1 == 0x41 -- NA+    3 -> case ix 0 of+        0x6E -> ix 1 == 0x61 && ix 2 == 0x6E -- nan+        0x4E ->+            (ix 2 == 0x4E && (ix 1 == 0x61 || ix 1 == 0x41)) -- NaN NAN+                || (ix 1 == 0x2F && ix 2 == 0x41) -- N/A+        _ -> False+    4 ->+        (ix 0 == 0x4E && ix 1 == 0x55 && ix 2 == 0x4C && ix 3 == 0x4C) -- NULL+            || (ix 0 == 0x6E && ix 1 == 0x75 && ix 2 == 0x6C && ix 3 == 0x6C) -- null+    7 ->+        ix 0 == 0x4E -- Nothing+            && ix 1 == 0x6F+            && ix 2 == 0x74+            && ix 3 == 0x68+            && ix 4 == 0x69+            && ix 5 == 0x6E+            && ix 6 == 0x67+    _ -> False+  where+    ix :: Int -> Word8+    ix d = BSU.unsafeIndex bs (s + d)+    {-# INLINE ix #-}+{-# INLINE isMissingFieldSlice #-}++-- | Generic fallback for user-supplied missing-indicator lists.+isMissingFieldIn :: [BS.ByteString] -> BS.ByteString -> Bool+isMissingFieldIn toks f = f `elem` toks+{-# INLINE isMissingFieldIn #-}++{- | @%Y-%m-%d@: byte-level fast path for the padded 10-byte shape+(@dddd-dd-dd@); anything else (unpadded, whitespace-tolerant, long+years, invalid) falls back to 'readByteStringDate'.+-}+parseDateFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Day+parseDateFieldSlice bs s e+    | e - s == 10+        && isDigitByte (ix 0)+        && isDigitByte (ix 1)+        && isDigitByte (ix 2)+        && isDigitByte (ix 3)+        && ix 4 == 0x2D+        && isDigitByte (ix 5)+        && isDigitByte (ix 6)+        && ix 7 == 0x2D+        && isDigitByte (ix 8)+        && isDigitByte (ix 9) =+        fromGregorianValid+            (toInteger (dig 0 * 1000 + dig 1 * 100 + dig 2 * 10 + dig 3))+            (dig 5 * 10 + dig 6)+            (dig 8 * 10 + dig 9)+    | otherwise =+        readByteStringDate "%Y-%m-%d" (BSU.unsafeTake (e - s) (BSU.unsafeDrop s bs))+  where+    ix d = BSU.unsafeIndex bs (s + d)+    dig :: Int -> Int+    dig d = fromIntegral (ix d) - 0x30+{-# INLINE parseDateFieldSlice #-}
+ tests/Main.hs view
@@ -0,0 +1,26 @@+module Main where++import qualified System.Exit as Exit++import Test.HUnit+import Test.QuickCheck++import qualified Properties.FastParsing+import qualified Unit.FastParsing++isSuccessful :: Result -> Bool+isSuccessful (Success{}) = True+isSuccessful _ = False++runProp :: (String, Property) -> IO Result+runProp (name, prop) = do+    putStrLn ("=== prop " ++ name)+    quickCheckWithResult stdArgs{maxSuccess = 20000} prop++main :: IO ()+main = do+    result <- runTestTT (TestList Unit.FastParsing.tests)+    propsRes <- mapM runProp Properties.FastParsing.tests+    if failures result > 0 || errors result > 0 || not (all isSuccessful propsRes)+        then Exit.exitFailure+        else Exit.exitSuccess
+ tests/Properties/FastParsing.hs view
@@ -0,0 +1,246 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++{- | QuickCheck parity properties for "DataFrame.Internal.Parsing.Fast".++Every fast slice parser must agree with the existing reference parser+(`readByteStringInt` / `readByteStringDouble` / `readByteStringBool` /+`readByteStringDate` / `isNullishBS`) on arbitrary byte fields, with+Doubles compared bit-for-bit ('castDoubleToWord64').+-}+module Properties.FastParsing (tests) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as C+import qualified Data.Text.Encoding as TE++import Data.Word (Word64, Word8)+import GHC.Float (castDoubleToWord64)+import Test.QuickCheck++import DataFrame.Internal.Parsing (+    isNullish,+    isNullishBS,+    readByteStringBool,+    readByteStringDate,+    readByteStringDouble,+    readByteStringInt,+ )+import DataFrame.Internal.Parsing.Fast++-- | Bytes 'Data.ByteString.Char8.strip' removes (probed over all 256).+stripBytes :: [Word8]+stripBytes = [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0]++genPad :: Gen BS.ByteString+genPad =+    frequency+        [ (4, pure BS.empty)+        , (3, BS.pack <$> resize 3 (listOf (elements stripBytes)))+        ]++genDigits :: Int -> Int -> Gen BS.ByteString+genDigits lo hi = do+    n <- chooseInt (lo, hi)+    C.pack <$> vectorOf n (elements "0123456789")++-- | Digit runs that probe the 18/19/20-digit mantissa window edges.+genDigitRun :: Gen BS.ByteString+genDigitRun =+    frequency+        [ (6, genDigits 1 17)+        , (3, genDigits 18 21)+        , (1, genDigits 22 40)+        , (2, BS.append <$> genDigits 1 6 <*> pure "")+        ,+            ( 1+            , (BS.append . BS.pack . flip replicate 0x30 <$> chooseInt (1, 25))+                <*> genDigits 0 19+            )+        ]++genNumeric :: Gen BS.ByteString+genNumeric = do+    sign <- elements ["", "-", "+"]+    whole <- frequency [(8, genDigitRun), (1, pure "")]+    frac <-+        frequency+            [ (3, pure "")+            , (4, BS.append "." <$> genDigitRun)+            , (1, pure ".")+            ]+    ex <-+        frequency+            [ (4, pure "")+            , (3, genExponent)+            ]+    pre <- genPad+    post <- genPad+    pure (BS.concat [pre, sign, whole, frac, ex, post])+  where+    genExponent = do+        e <- elements ["e", "E"]+        s <- elements ["", "-", "+"]+        ds <-+            frequency+                [ (6, genDigits 1 3)+                , (2, genDigits 4 25)+                , (1, pure "")+                ]+        pure (BS.concat [e, s, ds])++genToken :: Gen BS.ByteString+genToken = do+    tok <-+        elements @BS.ByteString+            [ "Nothing"+            , "NULL"+            , ""+            , " "+            , "nan"+            , "null"+            , "N/A"+            , "NaN"+            , "NAN"+            , "NA"+            , "True"+            , "true"+            , "TRUE"+            , "False"+            , "false"+            , "FALSE"+            , "Infinity"+            , "-Infinity"+            , "na"+            , "Null"+            , "N/a"+            , "TRUe"+            , "FALSe"+            ]+    frequency [(4, pure tok), (1, flip BS.append tok <$> genPad)]++genDateLike :: Gen BS.ByteString+genDateLike = do+    y <- chooseInt (0, 12000)+    m <- chooseInt (0, 15)+    d <- chooseInt (0, 35)+    sep <- elements ["-", "/", "-"]+    let pad2 n = (if n < 10 then "0" else "") ++ show n+    body <-+        elements+            [ show y ++ "-" ++ pad2 m ++ "-" ++ pad2 d+            , show y ++ sep ++ show m ++ sep ++ show d+            , pad2 y ++ "-" ++ pad2 m ++ "-" ++ pad2 d+            ]+    pre <- genPad+    post <- genPad+    pure (BS.concat [pre, C.pack body, post])++genJunk :: Gen BS.ByteString+genJunk =+    frequency+        [ (3, C.pack <$> resize 12 (listOf (elements "0123456789.eE+- aZ_,")))+        , (1, BS.pack <$> resize 12 (listOf arbitrary))+        ]++-- | An arbitrary CSV field, weighted towards adversarial numerics.+newtype Field = Field {unField :: BS.ByteString}+    deriving (Eq, Show)++instance Arbitrary Field where+    arbitrary =+        Field+            <$> frequency+                [ (6, genNumeric)+                , (2, genToken)+                , (1, genDateLike)+                , (2, genJunk)+                ]+    shrink (Field b) = map (Field . BS.pack) (shrink (BS.unpack b))++bitsOf :: Maybe Double -> Maybe Word64+bitsOf = fmap castDoubleToWord64++-- | Embed a field in junk and parse it through the slice interface.+slicedOn ::+    (BS.ByteString -> Int -> Int -> a) ->+    BS.ByteString ->+    BS.ByteString ->+    BS.ByteString ->+    a+slicedOn p pre f post =+    p (BS.concat [pre, f, post]) (BS.length pre) (BS.length pre + BS.length f)++prop_intParity :: Field -> Property+prop_intParity (Field f) = parseIntField f === readByteStringInt f++prop_intShowRoundTrip :: Int -> Property+prop_intShowRoundTrip n = parseIntField (C.pack (show n)) === Just n++prop_intSlice :: Field -> Field -> Field -> Property+prop_intSlice (Field pre) (Field f) (Field post) =+    slicedOn parseIntFieldSlice pre f post === readByteStringInt f++prop_doubleParity :: Field -> Property+prop_doubleParity (Field f) =+    bitsOf (parseDoubleField f) === bitsOf (readByteStringDouble f)++prop_doubleShowRoundTrip :: Double -> Property+prop_doubleShowRoundTrip d =+    let f = C.pack (show d)+     in bitsOf (parseDoubleField f) === bitsOf (readByteStringDouble f)++prop_doubleSlice :: Field -> Field -> Field -> Property+prop_doubleSlice (Field pre) (Field f) (Field post) =+    bitsOf (slicedOn parseDoubleFieldSlice pre f post)+        === bitsOf (readByteStringDouble f)++prop_boolParity :: Field -> Property+prop_boolParity (Field f) = parseBoolField f === readByteStringBool f++prop_boolSlice :: Field -> Field -> Field -> Property+prop_boolSlice (Field pre) (Field f) (Field post) =+    slicedOn parseBoolFieldSlice pre f post === readByteStringBool f++prop_missingParity :: Field -> Property+prop_missingParity (Field f) = isMissingField f === isNullishBS f++prop_missingTextParity :: Field -> Property+prop_missingTextParity (Field f) =+    isMissingField f === isNullish (TE.decodeUtf8Lenient f)++prop_missingSlice :: Field -> Field -> Field -> Property+prop_missingSlice (Field pre) (Field f) (Field post) =+    slicedOn isMissingFieldSlice pre f post === isNullishBS f++prop_missingIn :: Field -> Property+prop_missingIn (Field f) =+    isMissingFieldIn ["NA", "nope", ""] f+        === (f `elem` ["NA", "nope", ""])++prop_dateParity :: Field -> Property+prop_dateParity (Field f) =+    parseDateField f === readByteStringDate "%Y-%m-%d" f++prop_dateSlice :: Field -> Field -> Field -> Property+prop_dateSlice (Field pre) (Field f) (Field post) =+    slicedOn parseDateFieldSlice pre f post+        === readByteStringDate "%Y-%m-%d" f++tests :: [(String, Property)]+tests =+    [ ("intParity", property prop_intParity)+    , ("intShowRoundTrip", property prop_intShowRoundTrip)+    , ("intSlice", property prop_intSlice)+    , ("doubleParity", property prop_doubleParity)+    , ("doubleShowRoundTrip", property prop_doubleShowRoundTrip)+    , ("doubleSlice", property prop_doubleSlice)+    , ("boolParity", property prop_boolParity)+    , ("boolSlice", property prop_boolSlice)+    , ("missingParity", property prop_missingParity)+    , ("missingTextParity", property prop_missingTextParity)+    , ("missingSlice", property prop_missingSlice)+    , ("missingIn", property prop_missingIn)+    , ("dateParity", property prop_dateParity)+    , ("dateSlice", property prop_dateSlice)+    ]
+ tests/Unit/FastParsing.hs view
@@ -0,0 +1,224 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Pinned adversarial cases for "DataFrame.Internal.Parsing.Fast".++Each case asserts parity with the reference parsers plus, where the+parser-semantics contract (benchmarks/csv/results/code-audit.md section 2)+pins a concrete value, the value itself.+-}+module Unit.FastParsing (tests) where++import qualified Data.ByteString as BS++import Data.Word (Word64)+import GHC.Float (castDoubleToWord64)+import Test.HUnit++import DataFrame.Internal.Parsing (+    isNullishBS,+    readByteStringBool,+    readByteStringDate,+    readByteStringDouble,+    readByteStringInt,+ )+import DataFrame.Internal.Parsing.Fast++bitsOf :: Maybe Double -> Maybe Word64+bitsOf = fmap castDoubleToWord64++intCases :: [BS.ByteString]+intCases =+    [ ""+    , "-"+    , "+"+    , "0"+    , "-0"+    , "042"+    , " \t42\n "+    , "- 42"+    , "+42"+    , "1 2"+    , "9223372036854775807"+    , "9223372036854775808"+    , "-9223372036854775808"+    , "-9223372036854775809"+    , "00000000000000000000042"+    , "18446744073709551616"+    , "99999999999999999999999999"+    , "12e3"+    , "0x10"+    , BS.pack [0xA0, 0x34, 0x32, 0xA0]+    ]++doubleCases :: [BS.ByteString]+doubleCases =+    [ ".5"+    , "+.5"+    , "5."+    , "5.e3"+    , "1e3.5"+    , "1_000"+    , "1e+3"+    , "1E-3"+    , " 1.5 "+    , "1e999"+    , "-1e999"+    , "0e999"+    , "-0e999"+    , "1e-999"+    , "0.1"+    , "-0"+    , "-0.0"+    , "2.2250738585072011e-308"+    , "1.7976931348623157e308"+    , "4.9406564584124654e-324"+    , "2.225073858507201e-308"+    , "0.30000000000000004"+    , "9007199254740993"+    , "9007199254740993.9007199254740993"+    , "12345678901234567890"+    , "12345678901234567890.12345678901234567890"+    , "123456789012345678901234567890e-25"+    , "1e00000000000000005"+    , "1e-00000000000000005"+    , "00000000000000000000001.5"+    , "1.00000000000000000000005"+    , "0.000000000000000000000000000005"+    , "1e308"+    , "1e309"+    , "1e-308"+    , "1e-309"+    , "1e1023"+    , "1e1024"+    , "1e1025"+    , "1e-1024"+    , "1e-1025"+    , "Infinity"+    , "NaN"+    , "1e"+    , "1e+"+    , "1e-"+    , "1.5e "+    , "1.2.3"+    , "--5"+    , "1e18446744073709551617"+    ]++pinnedDoubles :: [(BS.ByteString, Maybe Double)]+pinnedDoubles =+    [ ("1e+3", Just 1000)+    , (" 1.5 ", Just 1.5)+    , ("1e999", Just (1 / 0))+    , ("1e-999", Just 0)+    , (".5", Nothing)+    , ("5.", Nothing)+    , ("1e3.5", Nothing)+    , ("1_000", Nothing)+    , ("Infinity", Nothing)+    , ("NaN", Nothing)+    , -- The reference parser is NOT correctly rounded here; bit-exact+      -- parity means we must reproduce its 2.2250738585072e-308.+      ("2.2250738585072011e-308", Just 2.2250738585072e-308)+    ]++boolCases :: [BS.ByteString]+boolCases =+    [ "True"+    , "true"+    , "TRUE"+    , "False"+    , "false"+    , "FALSE"+    , " True"+    , "True "+    , "TRUe"+    , "tRue"+    , "FALSe"+    , ""+    , "T"+    , "Truee"+    ]++missingCases :: [BS.ByteString]+missingCases =+    [ "Nothing"+    , "NULL"+    , ""+    , " "+    , "nan"+    , "null"+    , "N/A"+    , "NaN"+    , "NAN"+    , "NA"+    , "na"+    , "Na"+    , "Null"+    , "N/a"+    , "  "+    , "NULL "+    , " NA"+    , "Nothing!"+    , "nothing"+    , "n"+    , "N"+    ]++dateCases :: [BS.ByteString]+dateCases =+    [ "2024-02-29"+    , "2023-02-29"+    , "2023-02-30"+    , "2023-13-01"+    , "2023-00-10"+    , "2023-01-00"+    , "2023-1-2"+    , " 2024-02-29 "+    , "0001-01-01"+    , "0000-01-01"+    , "10000-01-01"+    , "2023/01/02"+    , "2023-01-023"+    , ""+    ]++parityCase ::+    (Eq a, Show a) =>+    String -> (BS.ByteString -> a) -> (BS.ByteString -> a) -> BS.ByteString -> Test+parityCase name fast ref input =+    TestLabel (name ++ ": " ++ show input) . TestCase $+        assertEqual (name ++ " parity on " ++ show input) (ref input) (fast input)++pinnedCase :: BS.ByteString -> Maybe Double -> Test+pinnedCase input expected =+    TestLabel ("pinned double: " ++ show input) . TestCase $+        assertEqual+            ("pinned value for " ++ show input)+            (bitsOf expected)+            (bitsOf (parseDoubleField input))++tests :: [Test]+tests =+    map (parityCase "int" parseIntField readByteStringInt) intCases+        ++ map+            (parityCase "double" (bitsOf . parseDoubleField) (bitsOf . readByteStringDouble))+            doubleCases+        ++ map (uncurry pinnedCase) pinnedDoubles+        ++ map (parityCase "bool" parseBoolField readByteStringBool) boolCases+        ++ map (parityCase "missing" isMissingField isNullishBS) missingCases+        ++ map (parityCase "date" parseDateField (readByteStringDate "%Y-%m-%d")) dateCases+        ++ [ TestLabel "int maxBound" . TestCase $+                assertEqual+                    "maxBound"+                    (Just (maxBound :: Int))+                    (parseIntField "9223372036854775807")+           , TestLabel "int overflow" . TestCase $+                assertEqual "maxBound+1" Nothing (parseIntField "9223372036854775808")+           , TestLabel "int minBound" . TestCase $+                assertEqual+                    "minBound"+                    (Just (minBound :: Int))+                    (parseIntField "-9223372036854775808")+           , TestLabel "negative zero" . TestCase $+                assertEqual "-0.0 bits" (bitsOf (Just (-0.0))) (bitsOf (parseDoubleField "-0"))+           ]