packages feed

dataframe-3.0.0.0: tests/IR/ExprJsonRoundtrip.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

{- | Round-trip, pipeline, end-to-end, and wire-shape contract tests for the
expression/pipeline serializer.
-}
module IR.ExprJsonRoundtrip (tests) where

import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Char8 as BS8
import Data.Either (fromRight, isLeft)
import Test.HUnit

import qualified Data.Vector.Unboxed as VU
import qualified DataFrame as D
import qualified DataFrame.Functions as F
import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)
import qualified DataFrame.Internal.Column as DI
import DataFrame.Internal.Expression (Expr, UExpr (..))
import DataFrame.Internal.Interpreter (interpret)
import DataFrame.Operators (ifThenElse, (.>.))

import DataFrame.LinearModel (defaultLinearConfig)
import DataFrame.Model (fit, predict)
import DataFrame.Transform (Transform (..), applyTransform)
import DataFrame.Transform.Serialize (
    loadTransformFromFile,
    saveTransformToFile,
 )

import DataFrame.Expr.Serialize (
    SomeExpr (..),
    decodeExprAny,
    decodeExprAt,
    decodeExprFromBytes,
    encodeExpr,
    loadExprAtFromFile,
    loadPipelineFromFile,
    saveExprToFile,
    savePipelineToFile,
 )
import System.IO.Temp (withSystemTempDirectory)

interpD :: D.DataFrame -> Expr Double -> [Double]
interpD df e = case interpret @Double df e of
    Right (TColumn c) -> either (const []) VU.toList (toVector @Double @VU.Vector c)
    Left err -> error (show err)

close :: [Double] -> [Double] -> Bool
close a b = length a == length b && and (zipWith (\x y -> abs (x - y) <= 1e-9) a b)

df3 :: D.DataFrame
df3 = D.fromNamedColumns [("x", DI.fromList [1, 2, 3 :: Double])]

regDF :: D.DataFrame
regDF =
    D.fromNamedColumns
        [ ("x1", DI.fromList xs1)
        , ("x2", DI.fromList xs2)
        , ("y", DI.fromList [2 * a - 3 * b + 1 | (a, b) <- zip xs1 xs2])
        ]
  where
    xs1 = [1, 2, 3, 4, 5, 6, 7, 8] :: [Double]
    xs2 = [2, 1, 4, 3, 6, 5, 8, 7] :: [Double]

{- | An expression round-trips when encode → decode → re-encode yields the same
JSON. Avoids needing an 'Eq' instance on the GADT.
-}
roundtrips :: (Columnable a) => Expr a -> Bool
roundtrips e = fromRight False $ do
    v1 <- encodeExpr e
    SomeExpr _ e' <- decodeExprAny v1
    v2 <- encodeExpr e'
    Right (v1 == v2)

testCase :: String -> Bool -> Test
testCase name ok = TestCase (assertBool name ok)

-- Row-wise expression node kinds.
exprRoundtripTests :: [Test]
exprRoundtripTests =
    [ testCase "col" (roundtrips (F.col @Double "x"))
    , testCase "lit" (roundtrips (F.lit @Int 42))
    , testCase "arithmetic" (roundtrips (F.col @Double "x" * F.lit 2 + F.lit 1))
    , testCase "division" (roundtrips (F.col @Double "x" / F.lit 2))
    , testCase "comparison" (roundtrips (F.col @Double "x" .>. F.lit 0))
    , testCase "unary sqrt" (roundtrips (sqrt (F.col @Double "x")))
    , testCase
        "if/then/else"
        ( roundtrips
            ( ifThenElse (F.col @Double "x" .>. F.lit 0) (F.lit 1.0) (F.lit (-1.0)) ::
                Expr Double
            )
        )
    ]

-- Aggregation and window node kinds (the decoder cases added for full parity).
aggRoundtripTests :: [Test]
aggRoundtripTests =
    [ testCase "sum" (roundtrips (F.sum (F.col @Double "x")))
    , testCase "count" (roundtrips (F.count (F.col @Double "x")))
    , testCase "minimum" (roundtrips (F.minimum (F.col @Double "x")))
    , testCase "maximum" (roundtrips (F.maximum (F.col @Int "x")))
    , testCase "mean" (roundtrips (F.mean (F.col @Double "x")))
    , testCase "variance" (roundtrips (F.variance (F.col @Double "x")))
    , testCase "median" (roundtrips (F.median (F.col @Double "x")))
    , testCase "over" (roundtrips (F.over ["g"] (F.sum (F.col @Double "x"))))
    ]

-- Documented limitation: collect's list-typed output has no type tag, so it
-- cannot be encoded.
limitationTests :: [Test]
limitationTests =
    [ testCase
        "collect cannot encode"
        (isLeft (encodeExpr (F.collect (F.col @Double "x"))))
    ]

-- Pin the wire shape Python decodes against: (x * 2) + 1 over doubles.
contractTests :: [Test]
contractTests =
    [ TestCase $ case Aeson.eitherDecodeStrict (BS8.pack contractJson) >>= decodeExprAt @Double of
        Left err -> assertFailure ("contract decode failed: " <> err)
        Right e ->
            assertBool "contract interprets to [3,5,7]" (close (interpD df3 e) [3, 5, 7])
    , testCase
        "collect agg node fails to decode"
        (isLeft (decodeExprFromBytes (BS8.pack collectJson)))
    ]

contractJson :: String
contractJson =
    "{\"node\":\"binary\",\"out_type\":\"double\",\"op\":\"add\",\"arg_type\":\"double\","
        <> "\"lhs\":{\"node\":\"binary\",\"out_type\":\"double\",\"op\":\"mult\",\"arg_type\":\"double\","
        <> "\"lhs\":{\"node\":\"col\",\"out_type\":\"double\",\"name\":\"x\"},"
        <> "\"rhs\":{\"node\":\"lit\",\"out_type\":\"double\",\"value\":2.0}},"
        <> "\"rhs\":{\"node\":\"lit\",\"out_type\":\"double\",\"value\":1.0}}"

collectJson :: String
collectJson =
    "{\"node\":\"agg\",\"out_type\":\"double\",\"agg\":\"collect\",\"arg_type\":\"double\","
        <> "\"arg\":{\"node\":\"col\",\"out_type\":\"double\",\"name\":\"x\"}}"

-- File round-trips for a single expression, a pipeline, and a fitted model.
ioTests :: [Test]
ioTests =
    [ TestCase $ withSystemTempDirectory "expr-ser" $ \dir -> do
        let fp = dir ++ "/expr.json"
            e = F.col @Double "x" * F.lit 2 + F.lit 1
        sr <- saveExprToFile fp e
        assertEqual "save expr ok" (Right ()) sr
        loaded <- loadExprAtFromFile @Double fp
        case loaded of
            Left err -> assertFailure ("load expr failed: " <> err)
            Right e' ->
                assertBool
                    "loaded expr interprets equal"
                    (close (interpD df3 e') (interpD df3 e))
    , TestCase $ withSystemTempDirectory "expr-ser" $ \dir -> do
        let fp = dir ++ "/pipeline.json"
            nes =
                [ ("z", UExpr (F.col @Double "x" * F.lit 2))
                , ("w", UExpr (F.col @Double "x" + F.lit 10))
                ]
        sr <- savePipelineToFile fp nes
        assertEqual "save pipeline ok" (Right ()) sr
        loaded <- loadPipelineFromFile fp
        case loaded of
            Left err -> assertFailure ("load pipeline failed: " <> err)
            Right nes' -> do
                let df' = applyTransform (Transform nes') df3
                assertBool "z = 2x" (close (interpD df' (F.col @Double "z")) [2, 4, 6])
                assertBool "w = x+10" (close (interpD df' (F.col @Double "w")) [11, 12, 13])
    , TestCase $ withSystemTempDirectory "expr-ser" $ \dir -> do
        let fp = dir ++ "/transform.json"
            t = Transform [("z", UExpr (F.col @Double "x" * F.lit 2))]
        sr <- saveTransformToFile fp t
        assertEqual "save transform ok" (Right ()) sr
        loaded <- loadTransformFromFile fp
        case loaded of
            Left err -> assertFailure ("load transform failed: " <> err)
            Right t' ->
                assertBool
                    "transform z = 2x"
                    (close (interpD (applyTransform t' df3) (F.col @Double "z")) [2, 4, 6])
    , TestCase $ withSystemTempDirectory "expr-ser" $ \dir -> do
        -- End-to-end: fit OLS, persist its prediction, reload, compare outputs.
        let fp = dir ++ "/model.json"
            m = fit defaultLinearConfig (F.col @Double "y") regDF
        sr <- saveExprToFile fp (predict m)
        assertEqual "save model ok" (Right ()) sr
        loaded <- loadExprAtFromFile @Double fp
        case loaded of
            Left err -> assertFailure ("load model failed: " <> err)
            Right e' ->
                assertBool
                    "reloaded prediction matches original"
                    (close (interpD regDF e') (interpD regDF (predict m)))
    ]

tests :: [Test]
tests =
    exprRoundtripTests
        ++ aggRoundtripTests
        ++ limitationTests
        ++ contractTests
        ++ ioTests