packages feed

jsonlogic-hs-0.1.0.0: test/NumberSpec.hs

{-# LANGUAGE OverloadedStrings #-}

-- | The fast paths of "JsonLogic.Core.Number" against the exact general
-- paths they stand in for. Each fast path is used only when it is provably
-- exact; these properties check that claim over hundreds of thousands of
-- doubles, chosen to include the hard cases (whole numbers at 2^53 and
-- above, short decimals, numbers needing 16 or 17 digits, subnormals).
module NumberSpec (spec) where

import Data.Bits (shiftR, (.&.))
import Data.Word (Word64)
import GHC.Float (castWord64ToDouble)
import Test.Hspec
import Test.Hspec.QuickCheck (modifyMaxSuccess)
import Test.QuickCheck hiding ((.&.))

import JsonLogic.Core.Number

-- | Any positive finite double, from its bit pattern.
genAnyDouble :: Gen Double
genAnyDouble = (castWord64ToDouble . (.&. 0x7FFFFFFFFFFFFFFF) <$> (arbitrary :: Gen Word64)) `suchThat` finitePositive

-- | Doubles of the kinds rules usually hold: short decimals, money, ratios,
-- and whole numbers either side of 2^53.
genCommonDouble :: Gen Double
genCommonDouble =
  oneof
    [ (/ 100) . fromInteger <$> choose (1, 10000000)
    , (/ 1000) . fromInteger <$> choose (1, 100000)
    , (\a b -> fromInteger a / fromInteger b) <$> choose (1, 1000000) <*> choose (1, 1000)
    , fromInteger <$> choose (1, 2 ^ (60 :: Int))
    , (* 1e-9) . fromInteger <$> choose (1, 1000000)
    , (\w -> fromIntegral (w `shiftR` 11 :: Word64)) <$> arbitrary
    ]
    `suchThat` finitePositive

finitePositive :: Double -> Bool
finitePositive d = d > 0 && not (isInfinite d) && not (isNaN d)

-- | The slow, obviously correct remainder: exact in 'Rational'.
remainderExact :: Double -> Double -> Double
remainderExact x y
  | isNaN x || isNaN y || isInfinite x || y == 0 = 0 / 0
  | isInfinite y = x
  | x == 0 = x
  | exact == 0 = if x < 0 then -0.0 else 0
  | otherwise = fromRational exact
  where
    rx = toRational x
    ry = toRational y
    exact = rx - ry * fromInteger (truncate (rx / ry))

-- | Run a property this many times instead of the usual hundred.
times :: Int -> SpecWith a -> SpecWith a
times n = modifyMaxSuccess (const n)

sameDouble :: Double -> Double -> Bool
sameDouble a b = (isNaN a && isNaN b) || (a == b && isNegativeZero a == isNegativeZero b)

spec :: Spec
spec = describe "JsonLogic.Core.Number" $ do
  describe "shortest digits" $ do
    times 100000 $ it "the fast path agrees with the exact algorithm on any double" $ forAll genAnyDouble $ \d -> shortestDigits d === shortestDigitsExact d
    times 100000 $ it "the fast path agrees with the exact algorithm on common doubles" $ forAll genCommonDouble $ \d -> shortestDigits d === shortestDigitsExact d
    it "accepts the halfway point above 2^53, as ECMAScript does" $
      numberToString 2.2531e21 `shouldBe` "2.2531e+21"
    it "takes the even digit when two candidates are equally close" $
      numberToString (9007199254740993 / 10) `shouldBe` "900719925474099.2"
    it "writes the extremes" $
      map numberToString [5e-324, 1.7976931348623157e308, 0.1, 123.456, 1e21, 1e-7]
        `shouldBe` ["5e-324", "1.7976931348623157e+308", "0.1", "123.456", "1e+21", "1e-7"]

  describe "reading decimals" $ do
    times 100000 $
      it "the fast path is exact for every mantissa below 2^53 and exponent within 22" $
        forAll (choose (1, 2 ^ (53 :: Int) - 1)) $ \m -> forAll (choose (-22, 22)) $ \e ->
          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)
    times 20000 $
      it "is exact beyond the fast path too" $
        forAll (choose (1, 10 ^ (30 :: Int))) $ \m -> forAll (choose (-340, 320)) $ \e ->
          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)
    times 50000 $
      it "is exact for the 16- to 19-digit decimals JavaScript and JSON writers produce" $
        forAll (choose (10 ^ (15 :: Int), 10 ^ (19 :: Int))) $ \m -> forAll (choose (-40, 30)) $ \e ->
          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)
    times 20000 $
      it "rounds correctly into and out of the subnormal range" $
        forAll (choose (1, 10 ^ (20 :: Int))) $ \m -> forAll (choose (-345, -300)) $ \e ->
          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)
    it "rounds an exact tie to even" $ do
      -- 2^53 + 1 is halfway between two doubles; 2^53 + 3 rounds up to 2^53 + 4
      decimalToDouble 9007199254740993 0 `shouldBe` 9007199254740992
      decimalToDouble 9007199254740995 0 `shouldBe` 9007199254740996
      decimalToDouble 90071992547409930 (-1) `shouldBe` 9007199254740992
    it "overflows to infinity and underflows to zero" $ do
      isInfinite (decimalToDouble 17976931348623159 292) `shouldBe` True
      decimalToDouble 17976931348623157 292 `shouldBe` 1.7976931348623157e308
      decimalToDouble 24703282292062327 (-340) `shouldBe` 0
      decimalToDouble 24703282292062328 (-340) `shouldBe` 5e-324
    times 50000 $ it "reads back every double it writes" $ forAll genAnyDouble $ \d -> stringToNumber (numberToString d) === d

  describe "remainder" $ do
    times 50000 $
      it "the integer path agrees with the exact remainder" $
        forAll (choose (negate (2 ^ (53 :: Int)), 2 ^ (53 :: Int))) $ \x -> forAll (choose (-100000, 100000)) $ \y ->
          let (a, b) = (fromInteger x, fromInteger y)
           in counterexample (show (a, b)) (sameDouble (remainderJs a b) (remainderExact a b))
    times 50000 $
      it "agrees with the exact remainder on any doubles" $
        forAll ((,) <$> genAnyDouble <*> genAnyDouble) $ \(a, b) ->
          conjoin
            [ counterexample (show (x, y)) (sameDouble (remainderJs x y) (remainderExact x y))
            | x <- [a, negate a]
            , y <- [b, negate b]
            ]