packages feed

data-sketches-0.4.0.0: test/CrossValidationSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module CrossValidationSpec where

import Control.Monad (forM_, when, unless)
import Control.Monad.IO.Class (liftIO)
import Data.Char (isSpace)
import Data.Word
import System.Directory (doesFileExist)
import System.IO (hFlush, hClose, hSetBuffering, BufferMode(..), hPutStrLn, hGetLine)
import System.Process
import Test.Hspec
import qualified Hedgehog as H
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range

import qualified DataSketches.Quantiles.RelativeErrorQuantile as REQ
import qualified DataSketches.Quantiles.KLL as KLL

findHarnessDir :: IO (Maybe FilePath)
findHarnessDir = do
  let candidates = ["java-harness", "../java-harness"]
  go candidates
  where
    go [] = pure Nothing
    go (d:ds) = do
      exists <- doesFileExist (d ++ "/SketchHarness.class")
      if exists then pure (Just d) else go ds

javaClasspathFor :: FilePath -> String
javaClasspathFor dir = dir ++ ":" ++ dir ++ "/lib/*"

runJavaHarness :: FilePath -> [String] -> IO [String]
runJavaHarness harnessDir commands = do
  let cp = CreateProcess
        { cmdspec = RawCommand "java" ["-cp", javaClasspathFor harnessDir, "SketchHarness"]
        , cwd = Nothing
        , env = Nothing
        , std_in = CreatePipe
        , std_out = CreatePipe
        , std_err = Inherit
        , close_fds = False
        , create_group = False
        , delegate_ctlc = False
        , detach_console = False
        , create_new_console = False
        , new_session = False
        , child_group = Nothing
        , child_user = Nothing
        , use_process_jobs = False
        }
  (Just hin, Just hout, _, ph) <- createProcess cp
  hSetBuffering hin LineBuffering
  hSetBuffering hout LineBuffering

  forM_ commands $ \cmd -> hPutStrLn hin cmd
  hFlush hin
  hClose hin

  let readUntilDone acc = do
        line <- hGetLine hout
        if line == "DONE"
          then pure (reverse acc)
          else readUntilDone (line : acc)

  results <- readUntilDone []
  _ <- waitForProcess ph
  pure results

parseJavaDouble :: String -> Double
parseJavaDouble s
  | s == "NaN" = 0/0
  | otherwise = read (trim s)
  where trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace

spec :: Spec
spec = do
  mDir <- runIO findHarnessDir
  case mDir of
    Nothing ->
      specify "Java harness not found (skipping cross-validation)" $ pendingWith
        "Compile java-harness/SketchHarness.java first"
    Just harnessDir -> do
      describe "REQ Sketch cross-validation with Java" $
        reqCrossValidation harnessDir
      describe "KLL Sketch cross-validation with Java" $
        kllCrossValidation harnessDir

-- | Generate integer-valued doubles in a range. These are exactly
-- representable in both float and double, eliminating precision as a
-- source of disagreement between the Java (float) and Haskell (double)
-- REQ sketches.
genIntDouble :: H.Range Int -> H.Gen Double
genIntDouble r = fromIntegral <$> Gen.int r

-- REQ sketch: property tests comparing Haskell vs Java.
--
-- Java REQ uses float (32-bit); Haskell uses Double (64-bit).
-- We use integer-valued doubles so both representations are identical
-- and exact-mode results must match exactly.
reqCrossValidation :: FilePath -> Spec
reqCrossValidation harnessDir = do

  specify "REQ: exact mode count/min/max match Java (HighRanksAreAccurate)" $ hedgehog $
    H.property $ do
      values <- H.forAll $ Gen.list (Range.linear 1 50) $
        genIntDouble (Range.linear 1 1000)
      let k = 50 :: Word32
      liftIO $ do
        sk <- REQ.mkReqSketch k REQ.HighRanksAreAccurate
        forM_ values $ REQ.insert sk
        hCount <- REQ.count sk
        hMin <- REQ.minimum sk
        hMax <- REQ.maximum sk

        let jCmds =
              [ "REQ " ++ show k ++ " hra lt"
              , "INSERT " ++ unwords (fmap show values)
              , "COUNT"
              , "MIN"
              , "MAX"
              , "END"
              ]
        jResults <- runJavaHarness harnessDir jCmds
        let jCount = read @Word64 (jResults !! 0)
            jMin = parseJavaDouble (jResults !! 1)
            jMax = parseJavaDouble (jResults !! 2)

        hCount `shouldBe` jCount
        hMin `shouldBe` jMin
        hMax `shouldBe` jMax

  specify "REQ: exact mode ranks match Java exactly (HighRanksAreAccurate, <)" $ hedgehog $
    H.property $ do
      values <- H.forAll $ Gen.list (Range.linear 10 50) $
        genIntDouble (Range.linear 1 200)
      queryValues <- H.forAll $ Gen.list (Range.linear 1 5) $
        genIntDouble (Range.linear 0 210)
      let k = 50 :: Word32
      liftIO $ do
        sk <- REQ.mkReqSketch k REQ.HighRanksAreAccurate
        forM_ values $ REQ.insert sk
        hRanks <- mapM (REQ.rank sk) queryValues

        let jCmds =
              [ "REQ " ++ show k ++ " hra lt"
              , "INSERT " ++ unwords (fmap show values)
              ] ++
              fmap (\q -> "RANK " ++ show q) queryValues ++
              [ "END" ]
        jResults <- runJavaHarness harnessDir jCmds
        let jRanks = fmap parseJavaDouble jResults

        forM_ (zip3 queryValues hRanks jRanks) $ \(qv, hr, jr) ->
          unless (isNaN hr && isNaN jr) $ do
            hr `shouldBe` jr

  specify "REQ: exact mode ranks match Java exactly (LowRanksAreAccurate, <)" $ hedgehog $
    H.property $ do
      values <- H.forAll $ Gen.list (Range.linear 10 50) $
        genIntDouble (Range.linear 1 200)
      queryValues <- H.forAll $ Gen.list (Range.linear 1 5) $
        genIntDouble (Range.linear 0 210)
      let k = 50 :: Word32
      liftIO $ do
        sk <- REQ.mkReqSketch k REQ.LowRanksAreAccurate
        forM_ values $ REQ.insert sk
        hRanks <- mapM (REQ.rank sk) queryValues

        let jCmds =
              [ "REQ " ++ show k ++ " lra lt"
              , "INSERT " ++ unwords (fmap show values)
              ] ++
              fmap (\q -> "RANK " ++ show q) queryValues ++
              [ "END" ]
        jResults <- runJavaHarness harnessDir jCmds
        let jRanks = fmap parseJavaDouble jResults

        forM_ (zip3 queryValues hRanks jRanks) $ \(qv, hr, jr) ->
          unless (isNaN hr && isNaN jr) $ do
            hr `shouldBe` jr

  specify "REQ: estimation mode ranks are both valid approximations (k=6, 200 items)" $ hedgehog $
    H.property $ do
      values <- H.forAll $ Gen.list (Range.singleton 200) $
        genIntDouble (Range.linear 1 1000)
      queryValues <- H.forAll $ Gen.list (Range.linear 1 3) $
        genIntDouble (Range.linear 1 1000)
      let k = 6 :: Word32
      liftIO $ do
        sk <- REQ.mkReqSketch k REQ.HighRanksAreAccurate
        forM_ values $ REQ.insert sk
        hRanks <- mapM (REQ.rank sk) queryValues

        let jCmds =
              [ "REQ " ++ show k ++ " hra lt"
              , "INSERT " ++ unwords (fmap show values)
              ] ++
              fmap (\q -> "RANK " ++ show q) queryValues ++
              [ "END" ]
        jResults <- runJavaHarness harnessDir jCmds
        let jRanks = fmap parseJavaDouble jResults

        -- In estimation mode, each implementation made independent random
        -- compaction decisions (different RNG seeds), so the retained items
        -- differ. Both ranks should approximate the true rank, and we check
        -- that the two approximations don't diverge beyond 2x the sketch's
        -- error bound (~14% per side for k=6).
        let n = fromIntegral (length values) :: Double
        forM_ (zip3 queryValues hRanks jRanks) $ \(qv, hr, jr) ->
          unless (isNaN hr && isNaN jr) $ do
            let trueRank = fromIntegral (length (filter (< qv) values)) / n
            assertWithinBound ("Haskell rank of " ++ show qv) 0.15 hr trueRank
            assertWithinBound ("Java rank of " ++ show qv) 0.15 jr trueRank

-- KLL sketch cross-validation.
--
-- Both Java and Haskell KLL use doubles, so all values are identical
-- in both representations. In exact mode results must match exactly.
kllCrossValidation :: FilePath -> Spec
kllCrossValidation harnessDir = do

  specify "KLL: exact mode count/min/max match Java exactly" $ hedgehog $
    H.property $ do
      values <- H.forAll $ Gen.list (Range.linear 1 100) $
        Gen.double (Range.linearFrac 1 1000)
      let k = 200
      liftIO $ do
        sk <- KLL.mkKllSketch k
        forM_ values $ KLL.insert sk
        hCount <- KLL.count sk
        hMin <- KLL.minimum sk
        hMax <- KLL.maximum sk

        let jCmds =
              [ "KLL " ++ show k
              , "INSERT " ++ unwords (fmap show values)
              , "COUNT"
              , "MIN"
              , "MAX"
              , "END"
              ]
        jResults <- runJavaHarness harnessDir jCmds
        let jCount = read @Word64 (jResults !! 0)
            jMin = parseJavaDouble (jResults !! 1)
            jMax = parseJavaDouble (jResults !! 2)

        hCount `shouldBe` jCount
        hMin `shouldBe` jMin
        hMax `shouldBe` jMax

  specify "KLL: exact mode ranks match Java exactly (k=200, few items)" $ hedgehog $
    H.property $ do
      values <- H.forAll $ Gen.list (Range.linear 10 50) $
        Gen.double (Range.linearFrac 1 100)
      queryValues <- H.forAll $ Gen.list (Range.linear 1 5) $
        Gen.double (Range.linearFrac 0 110)
      let k = 200
      liftIO $ do
        sk <- KLL.mkKllSketch k
        forM_ values $ KLL.insert sk
        hRanks <- mapM (KLL.rank sk) queryValues

        let jCmds =
              [ "KLL " ++ show k
              , "INSERT " ++ unwords (fmap show values)
              ] ++
              fmap (\q -> "RANK " ++ show q) queryValues ++
              [ "END" ]
        jResults <- runJavaHarness harnessDir jCmds
        let jRanks = fmap parseJavaDouble jResults

        forM_ (zip3 queryValues hRanks jRanks) $ \(qv, hr, jr) ->
          unless (isNaN hr && isNaN jr) $ do
            hr `shouldBe` jr

  specify "KLL: estimation mode ranks are both valid approximations (k=200, 500 items)" $ hedgehog $
    H.property $ do
      values <- H.forAll $ Gen.list (Range.singleton 500) $
        Gen.double (Range.linearFrac 1 1000)
      queryValues <- H.forAll $ Gen.list (Range.linear 1 3) $
        Gen.double (Range.linearFrac 1 1000)
      let k = 200
      liftIO $ do
        sk <- KLL.mkKllSketch k
        forM_ values $ KLL.insert sk
        hRanks <- mapM (KLL.rank sk) queryValues

        let jCmds =
              [ "KLL " ++ show k
              , "INSERT " ++ unwords (fmap show values)
              ] ++
              fmap (\q -> "RANK " ++ show q) queryValues ++
              [ "END" ]
        jResults <- runJavaHarness harnessDir jCmds
        let jRanks = fmap parseJavaDouble jResults

        -- Different compaction decisions from different RNGs. Verify both
        -- approximate the true rank rather than comparing to each other.
        let n = fromIntegral (length values) :: Double
        forM_ (zip3 queryValues hRanks jRanks) $ \(qv, hr, jr) ->
          unless (isNaN hr && isNaN jr) $ do
            let trueRank = fromIntegral (length (filter (< qv) values)) / n
            assertWithinBound ("Haskell rank of " ++ show qv) 0.05 hr trueRank
            assertWithinBound ("Java rank of " ++ show qv) 0.05 jr trueRank

-- | Assert that actual is within tolerance of expected.
assertWithinBound :: String -> Double -> Double -> Double -> IO ()
assertWithinBound label tolerance actual expected =
  when (abs (actual - expected) > tolerance) $
    expectationFailure $ label ++ ": expected " ++ show expected
      ++ " +/- " ++ show tolerance
      ++ " but got " ++ show actual
      ++ " (delta=" ++ show (abs (actual - expected)) ++ ")"

hedgehog :: H.Property -> IO ()
hedgehog prop = do
  result <- H.check prop
  unless result $ expectationFailure "Hedgehog property failed"