packages feed

keel-onnx-0.1.0.0: test/Demo.hs

-- | The end-to-end deliverable: train in scikit-learn, export with
-- skl2onnx, run the model in Haskell through keel-onnx, and assert
-- prediction agreement with python's own onnxruntime to 1e-6.
--
-- The model is a LinearRegression on deterministic data (fits exactly),
-- exported to float32. Input and output names are NOT hard-coded — they
-- are read back through session introspection.
--
-- Needs python with sklearn+skl2onnx+onnxruntime, and an ONNX Runtime
-- shared library: the python wheel's own copy when present (the
-- reference predictions come from that exact runtime, and an older
-- system-wide copy — e.g. Windows ML's System32 one — may not even
-- load the freshly exported model), the keel search policy otherwise.
-- Anything missing => SKIP unless @KEEL_ONNX_REQUIRE@ is set
-- (publish-stage CI sets it).
module Main (main) where

import Control.Exception (IOException, try)
import Control.Monad (forM_, unless)
import Data.ByteString qualified as BS
import Data.Vector.Storable qualified as VS
import GHC.Stats (GCDetails (..), RTSStats (..), getRTSStats, getRTSStatsEnabled)
import System.Environment (lookupEnv, setEnv)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.Mem (performMajorGC)
import System.Process (readProcessWithExitCode)

import Keel.Onnx

expect :: Bool -> String -> IO ()
expect ok msg = unless ok (fail msg)

runPy :: [String] -> IO (Maybe String)
runPy args = do
  r <- try (readProcessWithExitCode "python" args "")
        :: IO (Either IOException (ExitCode, String, String))
  pure $ case r of
    Right (ExitSuccess, out, _) -> Just out
    _ -> Nothing

-- The wheel's own shared library is a legitimate runtime for local
-- development; library file names differ per platform (and carry
-- version suffixes on Linux/macOS), so glob rather than guess.
findWheelOrt :: IO (Maybe FilePath)
findWheelOrt = do
  out <- runPy
    [ "-c"
    , "import onnxruntime, os, glob\n\
      \d = os.path.join(os.path.dirname(onnxruntime.__file__), 'capi')\n\
      \for pat in ('onnxruntime.dll', 'libonnxruntime.so*', 'libonnxruntime*.dylib'):\n\
      \    g = sorted(glob.glob(os.path.join(d, pat)))\n\
      \    if g:\n\
      \        print(g[0])\n\
      \        break\n"
    ]
  pure $ case fmap (filter (`notElem` "\r\n")) out of
    Just p | not (null p) -> Just p
    _ -> Nothing

trainScript :: String
trainScript =
  "import sys\n\
  \import numpy as np\n\
  \from sklearn.linear_model import LinearRegression\n\
  \from skl2onnx import to_onnx\n\
  \import onnxruntime as rt\n\
  \X = np.array([[0,0],[1,0],[0,1],[1,1],[2,1],[1,2],[3,2],[2,3]], dtype=np.float64)\n\
  \y = 3*X[:,0] - 2*X[:,1] + 1\n\
  \model = LinearRegression().fit(X, y)\n\
  \onx = to_onnx(model, X.astype(np.float32))\n\
  \with open(sys.argv[1], 'wb') as f:\n\
  \    f.write(onx.SerializeToString())\n\
  \xt = np.array([[0.5,1.5],[2,0],[1,3],[4,1]], dtype=np.float32)\n\
  \sess = rt.InferenceSession(sys.argv[1], providers=['CPUExecutionProvider'])\n\
  \iname = sess.get_inputs()[0].name\n\
  \ref = sess.run(None, {iname: xt})[0]\n\
  \print('\\n'.join('%.9g' % v for v in ref.ravel()))\n"

-- Binary LogisticRegression, zipmap disabled so the second output is a
-- plain float tensor: outputs = label (int64 [n]), probabilities
-- (float32 [n,2]). Prints labels then flattened probabilities.
classifierScript :: String
classifierScript =
  "import sys\n\
  \import numpy as np\n\
  \from sklearn.linear_model import LogisticRegression\n\
  \from skl2onnx import to_onnx\n\
  \import onnxruntime as rt\n\
  \X = np.array([[0,0],[1,0],[0,1],[1,1],[2,1],[1,2],[3,2],[2,3]], dtype=np.float64)\n\
  \y = (X[:,0] + X[:,1] > 2).astype(np.int64)\n\
  \clf = LogisticRegression().fit(X, y)\n\
  \onx = to_onnx(clf, X.astype(np.float32), options={id(clf): {'zipmap': False}})\n\
  \with open(sys.argv[1], 'wb') as f:\n\
  \    f.write(onx.SerializeToString())\n\
  \xt = np.array([[0.5,1.5],[2,0],[1,3],[4,1]], dtype=np.float32)\n\
  \sess = rt.InferenceSession(sys.argv[1], providers=['CPUExecutionProvider'])\n\
  \iname = sess.get_inputs()[0].name\n\
  \labels, probs = sess.run(None, {iname: xt})\n\
  \print(' '.join(str(int(v)) for v in labels.ravel()))\n\
  \print('\\n'.join('%.9g' % v for v in probs.ravel()))\n"

-- Same test points as the python side, row-major float32.
xTest :: VS.Vector Float
xTest = VS.fromList [0.5, 1.5, 2, 0, 1, 3, 4, 1]

main :: IO ()
main = do
  pyOk <- runPy ["-c", "import sklearn, skl2onnx, onnxruntime"]
  -- wheel first: the reference values are computed by the wheel's
  -- runtime, so the Haskell side must pin the same library; without a
  -- wheel the env stays unset and the keel policy applies as usual
  wheel <- findWheelOrt
  case wheel of
    Just dll -> setEnv "KEEL_ONNXRUNTIME" dll
    Nothing -> pure ()
  ort <- either (const Nothing) Just <$> loadOnnxRuntime
  required <- lookupEnv "KEEL_ONNX_REQUIRE"
  case (pyOk, ort) of
    (Just _, Just o) -> run o
    _ -> case required of
      Just v | v /= "" && v /= "0" ->
        fail "KEEL_ONNX_REQUIRE set but python stack or ONNX Runtime unavailable"
      _ ->
        putStrLn "keel-onnx-demo: SKIP - needs python(sklearn+skl2onnx+onnxruntime) and an ONNX Runtime library"

run :: Ort -> IO ()
run ort = do
  putStrLn ("ONNX Runtime version: " <> ortVersion ort)

  tmpOut <- runPy ["-c", "import tempfile; print(tempfile.gettempdir())"]
  tmp <- maybe (fail "cannot determine temp dir") (pure . filter (`notElem` "\r\n")) tmpOut
  let modelPath = tmp </> "keel-onnx-demo.onnx"

  refOut <- runPy ["-c", trainScript, modelPath]
  refs <- case refOut of
    Nothing -> fail "python train/export/reference script failed"
    Just out -> pure (map read (words out) :: [Float])
  expect (length refs == 4) ("expected 4 reference predictions, got " <> show (length refs))

  model <- BS.readFile modelPath
  withOrtEnv ort $ \env ->
    withSessionFromBytes env model $ \sess -> do
      ins <- inputNames sess
      outs <- outputNames sess
      inName <- case ins of
        [n] -> pure n
        _ -> fail ("expected 1 input, got " <> show ins)
      outName <- case outs of
        [n] -> pure n
        _ -> fail ("expected 1 output, got " <> show outs)
      putStrLn ("model interface: " <> inName <> " -> " <> outName)

      -- declared-interface introspection: float32 input [-1, 2]
      -- (dynamic batch axis), float32 output
      iinfos <- inputInfos sess
      case iinfos of
        [Just ti] -> do
          expect (tiShape ti == [-1, 2]) ("input shape decl: " <> show (tiShape ti))
          expect (tiElementType ti == 1) ("input dtype decl: " <> show (tiElementType ti))
        other -> fail ("input infos: " <> show other)

      [(shape, ys)] <- runFloats sess [(inName, [4, 2], xTest)] [outName]
      expect (shape == [4, 1]) ("output shape: " <> show shape)
      expect (VS.length ys == 4) ("output count: " <> show (VS.length ys))
      let diffs =
            [ abs (realToFrac got - realToFrac ref :: Double)
            | (got, ref) <- zip (VS.toList ys) refs
            ]
      expect (all (<= 1e-6) diffs)
        ("prediction disagreement vs python onnxruntime: " <> show diffs)
      putStrLn ("regression: max |haskell - python| = " <> show (maximum diffs) <> " <= 1e-6")

      -- leak gate: 500 inferences must leave the live Haskell heap flat
      -- (the copied-out output vectors are transient and must retire
      -- under GC); C-side allocator leaks are the publish-stage
      -- valgrind lane's job
      statsOn <- getRTSStatsEnabled
      expect statsOn "RTS stats disabled - test suite must be built with -with-rtsopts=-T"
      performMajorGC
      live0 <- gcdetails_live_bytes . gc <$> getRTSStats
      forM_ [1 :: Int .. 500] $ \_ -> do
        _ <- runFloats sess [(inName, [4, 2], xTest)] [outName]
        pure ()
      performMajorGC
      live1 <- gcdetails_live_bytes . gc <$> getRTSStats
      let grownKiB = (fromIntegral live1 - fromIntegral live0) `div` 1024 :: Integer
      expect (grownKiB < 1024)
        ("live heap grew " <> show grownKiB <> " KiB over 500 inferences (leak)")
      putStrLn ("leak gate: live heap delta " <> show grownKiB <> " KiB over 500 inferences")

  -- Part 2: classifier with two outputs — int64 labels (exact match)
  -- and float32 probabilities (1e-6), through runTensors' typed path.
  let clfPath = tmp </> "keel-onnx-demo-clf.onnx"
  clfOut <- runPy ["-c", classifierScript, clfPath]
  (refLabels, refProbs) <- case fmap lines clfOut of
    Just (labelLine : probLines) ->
      pure
        ( map read (words labelLine) :: [Int]
        , map read (concatMap words probLines) :: [Float]
        )
    _ -> fail "python classifier script failed"
  expect (length refLabels == 4) ("expected 4 reference labels, got " <> show (length refLabels))
  expect (length refProbs == 8) ("expected 8 reference probabilities, got " <> show (length refProbs))

  clfModel <- BS.readFile clfPath
  withOrtEnv ort $ \env ->
    withSessionFromBytes env clfModel $ \sess -> do
      ins <- inputNames sess
      outs <- outputNames sess
      inName <- case ins of
        [n] -> pure n
        _ -> fail ("classifier: expected 1 input, got " <> show ins)
      (labelName, probName) <- case outs of
        [a, b] -> pure (a, b)
        _ -> fail ("classifier: expected 2 outputs, got " <> show outs)
      putStrLn ("classifier interface: " <> inName <> " -> " <> labelName <> ", " <> probName)

      [labelT, probT] <- runTensors sess [(inName, [4, 2], xTest)] [labelName, probName]
      case labelT of
        Int64Tensor lshape ls -> do
          expect (lshape == [4]) ("label shape: " <> show lshape)
          expect (map fromIntegral (VS.toList ls) == refLabels)
            ("labels: " <> show (VS.toList ls) <> " /= " <> show refLabels)
        t -> fail ("label output is not int64: " <> show t)
      case probT of
        FloatTensor pshape ps -> do
          expect (pshape == [4, 2]) ("prob shape: " <> show pshape)
          let pdiffs =
                [ abs (realToFrac got - realToFrac ref :: Double)
                | (got, ref) <- zip (VS.toList ps) refProbs
                ]
          expect (all (<= 1e-6) pdiffs)
            ("probability disagreement: " <> show pdiffs)
          putStrLn ("classifier: max |haskell - python| = " <> show (maximum pdiffs) <> " <= 1e-6")
        t -> fail ("probability output is not float32: " <> show t)

  -- The Ort handle is deliberately NOT closed: onnxruntime owns thread
  -- pools and unloading the DLL at process end is the safe path.
  putStrLn "keel-onnx-demo: regression + classifier agreed with python to 1e-6"