packages feed

keel-abi (empty) → 0.1.0.0

raw patch · 14 files changed

+2241/−0 lines, 14 filesdep +basedep +keel-abidep +keel-dyn

Dependencies added: base, keel-abi, keel-dyn, process

Files

+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Revision history for keel-abi++## 0.1.0.0 -- 2026-08-18++* First release.
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Zhe Zhang++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,30 @@+# keel-abi++Hand-written `Storable` bindings for the two frozen C ABIs of the+data-science world:++- the Apache Arrow **C Data Interface** and **C Stream Interface**+  (`ArrowSchema` / `ArrowArray` / `ArrowArrayStream`), and+- **DLPack**'s `DLManagedTensorVersioned`,++in both directions: import (consume a foreign producer's structs) and+export (produce structs for a foreign consumer, e.g. pyarrow or numpy).++The shipped library contains no C sources and depends only on `base`.+The struct layouts are written out by hand for 64-bit pointers — the+library refuses to build on any other architecture — and a+test-suite-only C file of `_Static_assert(offsetof(...))` checks fails+CI if the hand layouts ever disagree with a real C compiler.++The managed layer (`Keel.Abi.Arrow`, `Keel.Abi.DLPack`) enforces the+protocols' ownership rules: release callbacks run exactly once,+double-release is a no-op, exceptions never escape into the foreign+caller, and consumption is exception-safe under `bracket`/`finally`.+The raw layer (`*.Raw`) is exported for callers who need the bare+structs.++Round-trips against real pyarrow (arrays and streams, both directions)+and numpy (DLPack, both directions) are covered by the test suites.++Part of the keel workspace — see the+[project repository](https://github.com/skymanbp/keel).
+ keel-abi.cabal view
@@ -0,0 +1,97 @@+cabal-version:      3.0+name:               keel-abi+version:            0.1.0.0+synopsis:           Arrow C Data/Stream Interface and DLPack, no cbits+description:+    Hand-written @Storable@ bindings for the two frozen C ABIs of the+    data-science world: the Apache Arrow C Data \/ C Stream Interface and+    DLPack's @DLManagedTensorVersioned@ — import and export directions.+    .+    The shipped library contains no C sources and needs no C toolchain+    beyond what GHC itself bundles: the structs are frozen ABIs, so their+    layouts are written out by hand for 64-bit pointers — the library+    refuses to build on any other architecture — and a /test-suite-only/+    C file of @_Static_assert(offsetof(...))@ checks fails CI if the+    hand layouts ever disagree with a real C compiler.+license:            MIT+license-file:       LICENSE+author:             Zhe Zhang+maintainer:         Zhe Zhang+category:           Foreign, Data+build-type:         Simple+homepage:           https://github.com/skymanbp/keel+bug-reports:        https://github.com/skymanbp/keel/issues+extra-doc-files:+    CHANGELOG.md+    README.md+tested-with:        GHC ==9.10.3 || ==9.12.4 || ==9.14.1++source-repository head+    type:     git+    location: https://github.com/skymanbp/keel++common warnings+    ghc-options:      -Wall+    default-language: GHC2021++library+    import:           warnings+    -- the hand-written struct offsets assume sizeof(void*) == 8; on a+    -- 32-bit target they would compile silently and corrupt memory, so+    -- refuse to build there instead+    if !(arch(x86_64) || arch(aarch64))+        buildable: False+    exposed-modules:+        Keel.Abi.Arrow+        Keel.Abi.Arrow.Raw+        Keel.Abi.DLPack+        Keel.Abi.DLPack.Raw+    hs-source-dirs:   src+    build-depends:    base >=4.20 && <4.23++test-suite keel-abi-layout+    import:           warnings+    type:             exitcode-stdio-1.0+    main-is:          Layout.hs+    hs-source-dirs:   test+    c-sources:        test/cbits/layout_gate.c+    cc-options:       -std=c11+    build-depends:+        base,+        keel-abi++test-suite keel-abi-managed+    import:           warnings+    type:             exitcode-stdio-1.0+    main-is:          Managed.hs+    hs-source-dirs:   test+    ghc-options:      -with-rtsopts=-T+    build-depends:+        base,+        keel-abi++test-suite keel-abi-pyarrow+    import:           warnings+    type:             exitcode-stdio-1.0+    main-is:          PyArrow.hs+    other-modules:    PyEmbed+    hs-source-dirs:   test+    ghc-options:      -threaded+    build-depends:+        base,+        keel-abi >=0.1 && <0.2,+        keel-dyn >=0.1 && <0.2,+        process >=1.6 && <1.7++test-suite keel-abi-dlpack+    import:           warnings+    type:             exitcode-stdio-1.0+    main-is:          DLPackNumpy.hs+    other-modules:    PyEmbed+    hs-source-dirs:   test+    ghc-options:      -threaded+    build-depends:+        base,+        keel-abi >=0.1 && <0.2,+        keel-dyn >=0.1 && <0.2,+        process >=1.6 && <1.7
+ src/Keel/Abi/Arrow.hs view
@@ -0,0 +1,256 @@+-- | Managed ownership over the raw Arrow C Data Interface structs.+--+-- The C Data Interface protocol: the /consumer/ allocates struct+-- storage and passes it to the producer to fill; whoever ends up holding+-- a filled struct must call its release callback exactly once. This+-- module packages both sides:+--+-- __Consumer (import)__: 'withArrowSchemaImport' \/ 'withArrowArrayImport'+-- allocate zeroed storage, hand it to your action (pass the pointer to+-- the foreign producer, then read the filled struct), and guarantee the+-- release callback runs on exit — including when the action throws.+-- A zeroed, never-filled struct has a null release member and releasing+-- it is a no-op, so allocation and release stay balanced no matter what+-- the producer did. The 'mallocArrowSchemaImport' variants give the same+-- storage as a 'ForeignPtr' whose finalizer releases at GC time, for+-- structs that must outlive a lexical scope.+--+-- __Producer (export)__: 'exportArrowSchema' \/ 'exportArrowArray' fill a+-- consumer-provided struct from a template record plus a cleanup action.+-- The installed release callback is one process-wide trampoline that+-- runs the cleanup exactly once, frees its 'StablePtr', and nulls the+-- struct's release member per spec — no per-export @\"wrapper\"@+-- 'FunPtr' is created, so there is nothing to free from inside its own+-- invocation (the classic exporter hazard).+module Keel.Abi.Arrow+  ( -- * Consumer side: allocate, let a producer fill, release+    withArrowSchemaImport+  , withArrowArrayImport+  , withArrowArrayStreamImport+  , mallocArrowSchemaImport+  , mallocArrowArrayImport+  , mallocArrowArrayStreamImport++    -- * Producer side: fill a consumer's struct+  , exportArrowSchema+  , exportArrowArray+  , ArrowStreamProducer (..)+  , exportArrowArrayStream+  ) where++import Control.Exception (SomeException, bracket, finally, mask_, try)+import Foreign.C.String (CString)+import Foreign.C.Types (CInt (..))+import Foreign.Concurrent qualified as FC+import Foreign.ForeignPtr (ForeignPtr)+import Foreign.Marshal.Alloc (callocBytes, free)+import Foreign.Ptr (FunPtr, Ptr, freeHaskellFunPtr, nullFunPtr, nullPtr)+import Foreign.StablePtr+  ( StablePtr+  , castPtrToStablePtr+  , castStablePtrToPtr+  , deRefStablePtr+  , freeStablePtr+  , newStablePtr+  )+import Foreign.Storable (Storable, peek, poke, sizeOf)+import System.IO.Unsafe (unsafePerformIO)++import Keel.Abi.Arrow.Raw++-- ---------------------------------------------------------------------+-- Consumer side++withImport :: forall s a. Storable s => (Ptr s -> IO ()) -> (Ptr s -> IO a) -> IO a+withImport releaseIt act =+  bracket+    (callocBytes (sizeOf (undefined :: s)))+    (\p -> releaseIt p `finally` free p)+    act++-- | Zeroed 'ArrowSchema' storage for a producer to fill; released (if+-- filled) and freed on exit, exception-safe.+withArrowSchemaImport :: (Ptr ArrowSchema -> IO a) -> IO a+withArrowSchemaImport = withImport releaseArrowSchema++-- | Zeroed 'ArrowArray' storage for a producer to fill; released (if+-- filled) and freed on exit, exception-safe.+withArrowArrayImport :: (Ptr ArrowArray -> IO a) -> IO a+withArrowArrayImport = withImport releaseArrowArray++-- | Zeroed 'ArrowArrayStream' storage for a producer to fill; released+-- (if filled) and freed on exit, exception-safe.+withArrowArrayStreamImport :: (Ptr ArrowArrayStream -> IO a) -> IO a+withArrowArrayStreamImport = withImport releaseArrowArrayStream++-- masked: an async exception between the allocation and the finalizer+-- registration would leak the block+mallocImport :: forall s. Storable s => (Ptr s -> IO ()) -> IO (ForeignPtr s)+mallocImport releaseIt = mask_ $ do+  p <- callocBytes (sizeOf (undefined :: s))+  FC.newForeignPtr p (releaseIt p `finally` free p)++-- | Like 'withArrowSchemaImport' but GC-managed: the release callback+-- (if the struct was filled) and the storage are reclaimed by the+-- finalizer. Prefer the @with@ variant when lifetime is lexical —+-- finalizers give no promptness guarantee.+mallocArrowSchemaImport :: IO (ForeignPtr ArrowSchema)+mallocArrowSchemaImport = mallocImport releaseArrowSchema++-- | GC-managed 'ArrowArray' import target; see 'mallocArrowSchemaImport'.+mallocArrowArrayImport :: IO (ForeignPtr ArrowArray)+mallocArrowArrayImport = mallocImport releaseArrowArray++-- | GC-managed 'ArrowArrayStream' import target; see+-- 'mallocArrowSchemaImport'.+mallocArrowArrayStreamImport :: IO (ForeignPtr ArrowArrayStream)+mallocArrowArrayStreamImport = mallocImport releaseArrowArrayStream++-- ---------------------------------------------------------------------+-- Producer side++foreign import ccall "wrapper"+  wrapSchemaRelease :: (Ptr ArrowSchema -> IO ()) -> IO (FunPtr (Ptr ArrowSchema -> IO ()))++foreign import ccall "wrapper"+  wrapArrayRelease :: (Ptr ArrowArray -> IO ()) -> IO (FunPtr (Ptr ArrowArray -> IO ()))++-- One process-wide trampoline per struct kind, never freed. It reads the+-- cleanup action out of private_data, runs it once, and marks the struct+-- released. Consumers may call release from any OS thread; a threaded+-- RTS handles that, a non-threaded one blocks the call until the RTS is+-- idle (standard foreign-export semantics).++{-# NOINLINE schemaReleaseTrampoline #-}+schemaReleaseTrampoline :: FunPtr (Ptr ArrowSchema -> IO ())+schemaReleaseTrampoline = unsafePerformIO . wrapSchemaRelease $ \p -> do+  s <- peek p+  -- null the release member BEFORE the cleanup runs: the consumer then+  -- sees the spec-mandated released state no matter what the cleanup does+  poke p s { schemaRelease = nullFunPtr, schemaPrivateData = nullPtr }+  runCleanup (schemaPrivateData s)++{-# NOINLINE arrayReleaseTrampoline #-}+arrayReleaseTrampoline :: FunPtr (Ptr ArrowArray -> IO ())+arrayReleaseTrampoline = unsafePerformIO . wrapArrayRelease $ \p -> do+  a <- peek p+  poke p a { arrayRelease = nullFunPtr, arrayPrivateData = nullPtr }+  runCleanup (arrayPrivateData a)++-- Runs the carried cleanup and frees its StablePtr. The cleanup runs+-- under 'try' with the exception dropped: this executes inside a+-- callback invoked by foreign code, and a Haskell exception escaping+-- into a C caller is undefined behaviour (in practice it aborts the+-- process) — the C Data Interface expects release callbacks not to fail.+runCleanup :: Ptr () -> IO ()+runCleanup pd = do+  let sp = castPtrToStablePtr pd :: StablePtr (IO ())+  cleanup <- deRefStablePtr sp+  _ <- try @SomeException cleanup+  freeStablePtr sp++-- | Fill @out@ as an exported schema: every field is taken from the+-- template except @release@\/@private_data@, which are overwritten with+-- the trampoline and the cleanup action. The cleanup must free whatever+-- the template's pointers own (format\/name\/metadata strings, children,+-- dictionary) and runs exactly once, from whichever thread the consumer+-- releases on. It must not throw: a thrown exception is caught and+-- discarded — the C caller of the release callback cannot receive it.+exportArrowSchema :: Ptr ArrowSchema -> ArrowSchema -> IO () -> IO ()+exportArrowSchema out template cleanup = mask_ $ do+  sp <- newStablePtr cleanup+  poke out+    template+      { schemaRelease = schemaReleaseTrampoline+      , schemaPrivateData = castStablePtrToPtr sp+      }++-- | Fill @out@ as an exported array; see 'exportArrowSchema'. The+-- cleanup must keep the buffers alive until it runs and then free them+-- (typically: 'Foreign.ForeignPtr.touchForeignPtr' captures, or explicit+-- 'free's of malloc'd buffers plus the buffer-pointer table).+exportArrowArray :: Ptr ArrowArray -> ArrowArray -> IO () -> IO ()+exportArrowArray out template cleanup = mask_ $ do+  sp <- newStablePtr cleanup+  poke out+    template+      { arrayRelease = arrayReleaseTrampoline+      , arrayPrivateData = castStablePtrToPtr sp+      }++-- ---------------------------------------------------------------------+-- Producer side: streams++-- | A Haskell implementation of an exported 'ArrowArrayStream'. The+-- consumer's struct pointer is dropped from each signature — callbacks+-- are closures, so carry state by capture, not through @private_data@.+data ArrowStreamProducer = ArrowStreamProducer+  { producerGetSchema :: Ptr ArrowSchema -> IO CInt+    -- ^ Fill the out-schema (e.g. via 'exportArrowSchema'); return 0,+    -- or an errno-style code on failure. A thrown exception is caught+    -- and reported to the consumer as @EIO@ (5).+  , producerGetNext :: Ptr ArrowArray -> IO CInt+    -- ^ Fill the out-array with the next chunk, or zero the whole+    -- struct (null release member) to signal end-of-stream; return 0,+    -- or an errno-style code on failure. A thrown exception is caught+    -- and reported to the consumer as @EIO@ (5).+  , producerGetLastError :: IO CString+    -- ^ Description of the last error, or 'nullPtr'. The string must+    -- stay valid until the next stream call. A thrown exception is+    -- caught and reported as 'nullPtr'.+  , producerCleanup :: IO ()+    -- ^ Runs exactly once when the consumer releases the stream. Must+    -- not throw: a thrown exception is caught and discarded.+  }++foreign import ccall "wrapper"+  wrapStreamGetSchema :: StreamGetSchemaFn -> IO (FunPtr StreamGetSchemaFn)++foreign import ccall "wrapper"+  wrapStreamGetNext :: StreamGetNextFn -> IO (FunPtr StreamGetNextFn)++foreign import ccall "wrapper"+  wrapStreamGetLastError :: StreamGetLastErrorFn -> IO (FunPtr StreamGetLastErrorFn)++{-# NOINLINE streamReleaseTrampoline #-}+streamReleaseTrampoline :: FunPtr (Ptr ArrowArrayStream -> IO ())+streamReleaseTrampoline = unsafePerformIO . wrapStreamRelease $ \p -> do+  s <- peek p+  poke p s { streamRelease = nullFunPtr, streamPrivateData = nullPtr }+  runCleanup (streamPrivateData s)++foreign import ccall "wrapper"+  wrapStreamRelease :: (Ptr ArrowArrayStream -> IO ()) -> IO (FunPtr (Ptr ArrowArrayStream -> IO ()))++-- These callbacks execute inside a call from foreign code, where a+-- Haskell exception must not escape (undefined behaviour in the C+-- caller) — it is caught and mapped to the value the protocol can+-- carry: an errno-style code, or a null error string.+guardErrno :: IO CInt -> IO CInt+guardErrno act = either (\(_ :: SomeException) -> 5 {- EIO -}) id <$> try act++guardLastError :: IO CString -> IO CString+guardLastError act = either (\(_ :: SomeException) -> nullPtr) id <$> try act++-- | Fill @out@ as an exported stream backed by the producer's Haskell+-- callbacks. The release callback (the shared trampoline again) frees+-- the three callback 'FunPtr's — none of them is the one executing —+-- and then runs 'producerCleanup'.+exportArrowArrayStream :: Ptr ArrowArrayStream -> ArrowStreamProducer -> IO ()+exportArrowArrayStream out producer = mask_ $ do+  gsF <- wrapStreamGetSchema (\_self o -> guardErrno (producerGetSchema producer o))+  gnF <- wrapStreamGetNext (\_self o -> guardErrno (producerGetNext producer o))+  geF <- wrapStreamGetLastError (\_self -> guardLastError (producerGetLastError producer))+  sp <- newStablePtr $ do+    freeHaskellFunPtr gsF+    freeHaskellFunPtr gnF+    freeHaskellFunPtr geF+    producerCleanup producer+  poke out+    ArrowArrayStream+      { streamGetSchema = gsF+      , streamGetNext = gnF+      , streamGetLastError = geF+      , streamRelease = streamReleaseTrampoline+      , streamPrivateData = castStablePtrToPtr sp+      }
+ src/Keel/Abi/Arrow/Raw.hs view
@@ -0,0 +1,344 @@+-- | The Apache Arrow C Data Interface and C Stream Interface structs,+-- 1:1 and unmanaged.+--+-- Layouts are written out by hand against the frozen ABI+-- (<https://arrow.apache.org/docs/format/CDataInterface.html>), so this+-- module needs no headers and the library ships no C sources. The+-- test-suite layout gate (@test\/cbits\/layout_gate.c@) re-derives every+-- offset with a real C compiler and fails the build on any disagreement;+-- 'arrowSchemaLayout' & friends exist for that gate.+--+-- Everything here is raw: 'Ptr'-level records, manual ownership. The+-- release-callback discipline of the spec applies — consumers must call+-- 'releaseArrowSchema' \/ 'releaseArrowArray' exactly once when done, and+-- a moved (released) struct has a null 'schemaRelease' \/ 'arrayRelease'.+-- Managed import\/export lives one layer up, in @Keel.Abi.Arrow@.+module Keel.Abi.Arrow.Raw+  ( -- * ArrowSchema+    ArrowSchema (..)+  , emptyArrowSchema+  , releaseArrowSchema+  , arrowFlagDictionaryOrdered+  , arrowFlagNullable+  , arrowFlagMapKeysSorted++    -- * ArrowArray+  , ArrowArray (..)+  , emptyArrowArray+  , releaseArrowArray++    -- * ArrowArrayStream+  , ArrowArrayStream (..)+  , StreamGetSchemaFn+  , StreamGetNextFn+  , StreamGetLastErrorFn+  , callStreamGetSchema+  , callStreamGetNext+  , callStreamGetLastError+  , releaseArrowArrayStream++    -- * Layout tables (consumed by the test-suite layout gate)+  , arrowSchemaLayout+  , arrowArrayLayout+  , arrowArrayStreamLayout+  ) where++import Control.Monad (unless)+import Data.Int (Int64)+import Foreign.C.String (CString)+import Foreign.C.Types (CInt (..))+import Foreign.Ptr (FunPtr, Ptr, nullFunPtr, nullPtr)+import Foreign.Storable (Storable (..))++-- ---------------------------------------------------------------------+-- ArrowSchema++-- | @struct ArrowSchema@. Field semantics (format strings, ownership)+-- are exactly those of the C Data Interface spec.+data ArrowSchema = ArrowSchema+  { schemaFormat :: CString+  , schemaName :: CString+  , schemaMetadata :: CString+  , schemaFlags :: Int64+  , schemaNChildren :: Int64+  , schemaChildren :: Ptr (Ptr ArrowSchema)+  , schemaDictionary :: Ptr ArrowSchema+  , schemaRelease :: FunPtr (Ptr ArrowSchema -> IO ())+  , schemaPrivateData :: Ptr ()+  }++-- | The @ARROW_FLAG_*@ bits of 'schemaFlags'.+arrowFlagDictionaryOrdered, arrowFlagNullable, arrowFlagMapKeysSorted :: Int64+arrowFlagDictionaryOrdered = 1+arrowFlagNullable = 2+arrowFlagMapKeysSorted = 4++oSchemaFormat, oSchemaName, oSchemaMetadata, oSchemaFlags, oSchemaNChildren,+  oSchemaChildren, oSchemaDictionary, oSchemaRelease, oSchemaPrivateData,+  szArrowSchema :: Int+oSchemaFormat = 0+oSchemaName = 8+oSchemaMetadata = 16+oSchemaFlags = 24+oSchemaNChildren = 32+oSchemaChildren = 40+oSchemaDictionary = 48+oSchemaRelease = 56+oSchemaPrivateData = 64+szArrowSchema = 72++-- | @(sizeof, [(field, offset)])@ in declaration order — compared+-- verbatim against C @offsetof@ by the layout gate.+arrowSchemaLayout :: (Int, [(String, Int)])+arrowSchemaLayout =+  ( szArrowSchema+  , [ ("format", oSchemaFormat)+    , ("name", oSchemaName)+    , ("metadata", oSchemaMetadata)+    , ("flags", oSchemaFlags)+    , ("n_children", oSchemaNChildren)+    , ("children", oSchemaChildren)+    , ("dictionary", oSchemaDictionary)+    , ("release", oSchemaRelease)+    , ("private_data", oSchemaPrivateData)+    ]+  )++instance Storable ArrowSchema where+  sizeOf _ = szArrowSchema+  alignment _ = 8+  peek p =+    ArrowSchema+      <$> peekByteOff p oSchemaFormat+      <*> peekByteOff p oSchemaName+      <*> peekByteOff p oSchemaMetadata+      <*> peekByteOff p oSchemaFlags+      <*> peekByteOff p oSchemaNChildren+      <*> peekByteOff p oSchemaChildren+      <*> peekByteOff p oSchemaDictionary+      <*> peekByteOff p oSchemaRelease+      <*> peekByteOff p oSchemaPrivateData+  poke p s = do+    pokeByteOff p oSchemaFormat (schemaFormat s)+    pokeByteOff p oSchemaName (schemaName s)+    pokeByteOff p oSchemaMetadata (schemaMetadata s)+    pokeByteOff p oSchemaFlags (schemaFlags s)+    pokeByteOff p oSchemaNChildren (schemaNChildren s)+    pokeByteOff p oSchemaChildren (schemaChildren s)+    pokeByteOff p oSchemaDictionary (schemaDictionary s)+    pokeByteOff p oSchemaRelease (schemaRelease s)+    pokeByteOff p oSchemaPrivateData (schemaPrivateData s)++-- | All-null\/zero template — the starting point for building an export,+-- and the spec's representation of a released\/moved struct.+emptyArrowSchema :: ArrowSchema+emptyArrowSchema =+  ArrowSchema+    { schemaFormat = nullPtr+    , schemaName = nullPtr+    , schemaMetadata = nullPtr+    , schemaFlags = 0+    , schemaNChildren = 0+    , schemaChildren = nullPtr+    , schemaDictionary = nullPtr+    , schemaRelease = nullFunPtr+    , schemaPrivateData = nullPtr+    }++foreign import ccall "dynamic"+  callSchemaRelease :: FunPtr (Ptr ArrowSchema -> IO ()) -> Ptr ArrowSchema -> IO ()++-- | Call the struct's release callback unless it is already null (i.e.+-- the struct was moved or released before). Idempotent per the spec:+-- the callback itself nulls the release member.+releaseArrowSchema :: Ptr ArrowSchema -> IO ()+releaseArrowSchema p = do+  fp <- peekByteOff p oSchemaRelease+  unless (fp == nullFunPtr) (callSchemaRelease fp p)++-- ---------------------------------------------------------------------+-- ArrowArray++-- | @struct ArrowArray@. Buffer count and meaning follow the format+-- string of the corresponding 'ArrowSchema'.+data ArrowArray = ArrowArray+  { arrayLength :: Int64+  , arrayNullCount :: Int64+  , arrayOffset :: Int64+  , arrayNBuffers :: Int64+  , arrayNChildren :: Int64+  , arrayBuffers :: Ptr (Ptr ())+  , arrayChildren :: Ptr (Ptr ArrowArray)+  , arrayDictionary :: Ptr ArrowArray+  , arrayRelease :: FunPtr (Ptr ArrowArray -> IO ())+  , arrayPrivateData :: Ptr ()+  }++oArrayLength, oArrayNullCount, oArrayOffset, oArrayNBuffers, oArrayNChildren,+  oArrayBuffers, oArrayChildren, oArrayDictionary, oArrayRelease,+  oArrayPrivateData, szArrowArray :: Int+oArrayLength = 0+oArrayNullCount = 8+oArrayOffset = 16+oArrayNBuffers = 24+oArrayNChildren = 32+oArrayBuffers = 40+oArrayChildren = 48+oArrayDictionary = 56+oArrayRelease = 64+oArrayPrivateData = 72+szArrowArray = 80++-- | @(sizeof, [(field, offset)])@ in declaration order.+arrowArrayLayout :: (Int, [(String, Int)])+arrowArrayLayout =+  ( szArrowArray+  , [ ("length", oArrayLength)+    , ("null_count", oArrayNullCount)+    , ("offset", oArrayOffset)+    , ("n_buffers", oArrayNBuffers)+    , ("n_children", oArrayNChildren)+    , ("buffers", oArrayBuffers)+    , ("children", oArrayChildren)+    , ("dictionary", oArrayDictionary)+    , ("release", oArrayRelease)+    , ("private_data", oArrayPrivateData)+    ]+  )++instance Storable ArrowArray where+  sizeOf _ = szArrowArray+  alignment _ = 8+  peek p =+    ArrowArray+      <$> peekByteOff p oArrayLength+      <*> peekByteOff p oArrayNullCount+      <*> peekByteOff p oArrayOffset+      <*> peekByteOff p oArrayNBuffers+      <*> peekByteOff p oArrayNChildren+      <*> peekByteOff p oArrayBuffers+      <*> peekByteOff p oArrayChildren+      <*> peekByteOff p oArrayDictionary+      <*> peekByteOff p oArrayRelease+      <*> peekByteOff p oArrayPrivateData+  poke p a = do+    pokeByteOff p oArrayLength (arrayLength a)+    pokeByteOff p oArrayNullCount (arrayNullCount a)+    pokeByteOff p oArrayOffset (arrayOffset a)+    pokeByteOff p oArrayNBuffers (arrayNBuffers a)+    pokeByteOff p oArrayNChildren (arrayNChildren a)+    pokeByteOff p oArrayBuffers (arrayBuffers a)+    pokeByteOff p oArrayChildren (arrayChildren a)+    pokeByteOff p oArrayDictionary (arrayDictionary a)+    pokeByteOff p oArrayRelease (arrayRelease a)+    pokeByteOff p oArrayPrivateData (arrayPrivateData a)++-- | All-null\/zero template — the starting point for building an export,+-- and the spec's representation of a released\/moved struct (an+-- end-of-stream @get_next@ writes exactly this).+emptyArrowArray :: ArrowArray+emptyArrowArray =+  ArrowArray+    { arrayLength = 0+    , arrayNullCount = 0+    , arrayOffset = 0+    , arrayNBuffers = 0+    , arrayNChildren = 0+    , arrayBuffers = nullPtr+    , arrayChildren = nullPtr+    , arrayDictionary = nullPtr+    , arrayRelease = nullFunPtr+    , arrayPrivateData = nullPtr+    }++foreign import ccall "dynamic"+  callArrayRelease :: FunPtr (Ptr ArrowArray -> IO ()) -> Ptr ArrowArray -> IO ()++-- | Call the struct's release callback unless it is already null.+releaseArrowArray :: Ptr ArrowArray -> IO ()+releaseArrowArray p = do+  fp <- peekByteOff p oArrayRelease+  unless (fp == nullFunPtr) (callArrayRelease fp p)++-- ---------------------------------------------------------------------+-- ArrowArrayStream++-- | @get_schema@: fills the out-struct, returns 0 or an errno-style code.+type StreamGetSchemaFn = Ptr ArrowArrayStream -> Ptr ArrowSchema -> IO CInt++-- | @get_next@: fills the out-struct, or marks end-of-stream by leaving+-- its release member null. Returns 0 or an errno-style code.+type StreamGetNextFn = Ptr ArrowArrayStream -> Ptr ArrowArray -> IO CInt++-- | @get_last_error@: description of the last error, or null.+type StreamGetLastErrorFn = Ptr ArrowArrayStream -> IO CString++-- | @struct ArrowArrayStream@ — all members are function pointers plus+-- @private_data@; drive it with the @call*@ wrappers below.+data ArrowArrayStream = ArrowArrayStream+  { streamGetSchema :: FunPtr StreamGetSchemaFn+  , streamGetNext :: FunPtr StreamGetNextFn+  , streamGetLastError :: FunPtr StreamGetLastErrorFn+  , streamRelease :: FunPtr (Ptr ArrowArrayStream -> IO ())+  , streamPrivateData :: Ptr ()+  }++oStreamGetSchema, oStreamGetNext, oStreamGetLastError, oStreamRelease,+  oStreamPrivateData, szArrowArrayStream :: Int+oStreamGetSchema = 0+oStreamGetNext = 8+oStreamGetLastError = 16+oStreamRelease = 24+oStreamPrivateData = 32+szArrowArrayStream = 40++-- | @(sizeof, [(field, offset)])@ in declaration order.+arrowArrayStreamLayout :: (Int, [(String, Int)])+arrowArrayStreamLayout =+  ( szArrowArrayStream+  , [ ("get_schema", oStreamGetSchema)+    , ("get_next", oStreamGetNext)+    , ("get_last_error", oStreamGetLastError)+    , ("release", oStreamRelease)+    , ("private_data", oStreamPrivateData)+    ]+  )++instance Storable ArrowArrayStream where+  sizeOf _ = szArrowArrayStream+  alignment _ = 8+  peek p =+    ArrowArrayStream+      <$> peekByteOff p oStreamGetSchema+      <*> peekByteOff p oStreamGetNext+      <*> peekByteOff p oStreamGetLastError+      <*> peekByteOff p oStreamRelease+      <*> peekByteOff p oStreamPrivateData+  poke p s = do+    pokeByteOff p oStreamGetSchema (streamGetSchema s)+    pokeByteOff p oStreamGetNext (streamGetNext s)+    pokeByteOff p oStreamGetLastError (streamGetLastError s)+    pokeByteOff p oStreamRelease (streamRelease s)+    pokeByteOff p oStreamPrivateData (streamPrivateData s)++-- | Invoke a stream's @get_schema@ member (pass the struct's own+-- pointer as the first argument).+foreign import ccall "dynamic"+  callStreamGetSchema :: FunPtr StreamGetSchemaFn -> StreamGetSchemaFn++-- | Invoke a stream's @get_next@ member.+foreign import ccall "dynamic"+  callStreamGetNext :: FunPtr StreamGetNextFn -> StreamGetNextFn++-- | Invoke a stream's @get_last_error@ member.+foreign import ccall "dynamic"+  callStreamGetLastError :: FunPtr StreamGetLastErrorFn -> StreamGetLastErrorFn++foreign import ccall "dynamic"+  callStreamRelease :: FunPtr (Ptr ArrowArrayStream -> IO ()) -> Ptr ArrowArrayStream -> IO ()++-- | Call the stream's release callback unless it is already null.+releaseArrowArrayStream :: Ptr ArrowArrayStream -> IO ()+releaseArrowArrayStream p = do+  fp <- peekByteOff p oStreamRelease+  unless (fp == nullFunPtr) (callStreamRelease fp p)
+ src/Keel/Abi/DLPack.hs view
@@ -0,0 +1,124 @@+-- | Managed ownership over DLPack's versioned exchange tensor.+--+-- The DLPack contract: the producer hands over a+-- 'DLManagedTensorVersioned' (usually inside a @dltensor_versioned@+-- PyCapsule); the consumer checks the major version, uses the data, and+-- calls the deleter exactly once. This module packages both sides the+-- same way "Keel.Abi.Arrow" does for Arrow structs: consumption under+-- 'Control.Exception.finally', production through a process-wide deleter+-- trampoline plus a 'Foreign.StablePtr.StablePtr'-carried cleanup.+module Keel.Abi.DLPack+  ( AbiError (..)++    -- * Consumer side+  , consumeManagedTensor++    -- * Producer side+  , newManagedTensor+  ) where++import Control.Exception (Exception, SomeException, finally, mask_, onException, throwIO, try)+import Data.Int (Int64)+import Data.Word (Word32, Word64)+import Foreign.Marshal.Alloc (free, mallocBytes)+import Foreign.Ptr (FunPtr, Ptr, nullPtr, plusPtr)+import Foreign.StablePtr+  ( StablePtr+  , castPtrToStablePtr+  , castStablePtrToPtr+  , deRefStablePtr+  , freeStablePtr+  , newStablePtr+  )+import Foreign.Storable (peek, poke, pokeElemOff, sizeOf)+import System.IO.Unsafe (unsafePerformIO)++import Keel.Abi.DLPack.Raw++-- | Failure modes of the exchange protocol itself.+newtype AbiError = DLPackMajorUnsupported Word32+    -- ^ The producer filled the struct under a DLPack major version+    -- newer than these bindings ('dlpackMajorVersion') understand.+  deriving (Eq, Show)++instance Exception AbiError++-- | Take ownership of a produced tensor: run the action on the peeked+-- struct, then invoke the deleter — also when the action throws. If the+-- producer's major version is newer than 'dlpackMajorVersion', the+-- tensor is deleted unused (the version\/deleter prologue is stable+-- across majors by design) and 'DLPackMajorUnsupported' is thrown.+consumeManagedTensor+  :: Ptr DLManagedTensorVersioned+  -> (DLManagedTensorVersioned -> IO a)+  -> IO a+consumeManagedTensor p act = do+  m <- peek p+  let major = dlverMajor (mtvVersion m)+  if major > dlpackMajorVersion+    then do+      callTensorDeleter p+      throwIO (DLPackMajorUnsupported major)+    else act m `finally` callTensorDeleter p++foreign import ccall "wrapper"+  wrapDeleter+    :: (Ptr DLManagedTensorVersioned -> IO ())+    -> IO (FunPtr (Ptr DLManagedTensorVersioned -> IO ()))++-- One process-wide deleter, never freed: runs the cleanup carried in+-- manager_ctx, then frees the struct block itself (the deleter deletes+-- @self@ per spec). The cleanup runs under 'try' with the exception+-- dropped: the deleter is invoked by foreign code, and a Haskell+-- exception escaping into a C caller is undefined behaviour.+{-# NOINLINE deleterTrampoline #-}+deleterTrampoline :: FunPtr (Ptr DLManagedTensorVersioned -> IO ())+deleterTrampoline = unsafePerformIO . wrapDeleter $ \p -> do+  m <- peek p+  let sp = castPtrToStablePtr (mtvManagerCtx m) :: StablePtr (IO ())+  cleanup <- deRefStablePtr sp+  _ <- try @SomeException cleanup+  freeStablePtr sp+  free p++-- | Allocate and fill a 'DLManagedTensorVersioned' for handoff to a+-- consumer. The shape array lives in the same allocation as the struct;+-- the tensor is CPU-device, compact row-major (null strides), zero byte+-- offset, version 'dlpackMajorVersion'.'dlpackMinorVersion'. The cleanup+-- runs exactly once — from the consumer's deleter call, on whatever+-- thread that happens — and must free\/unpin the data buffer; the+-- struct block frees itself afterwards. It must not throw: a thrown+-- exception is caught and discarded (the C caller cannot receive it).+newManagedTensor+  :: DLDataType+  -> [Int64] -- ^ shape (row-major, compact)+  -> Ptr () -- ^ data+  -> Word64 -- ^ flags ('dlpackFlagReadOnly' \/ 'dlpackFlagIsCopied' \/ 0)+  -> IO () -- ^ cleanup, owns the data buffer+  -> IO (Ptr DLManagedTensorVersioned)+newManagedTensor dt shape dat flags cleanup = mask_ $ do+  let ndim = length shape+      structSz = sizeOf (undefined :: DLManagedTensorVersioned)+  p <- mallocBytes (structSz + ndim * 8)+  flip onException (free p) $ do+    let shapeP = p `plusPtr` structSz+    mapM_ (uncurry (pokeElemOff shapeP)) (zip [0 ..] shape)+    sp <- newStablePtr cleanup+    poke p+      DLManagedTensorVersioned+        { mtvVersion = DLPackVersion dlpackMajorVersion dlpackMinorVersion+        , mtvManagerCtx = castStablePtrToPtr sp+        , mtvDeleter = deleterTrampoline+        , mtvFlags = flags+        , mtvTensor =+            DLTensor+              { dltData = dat+              , dltDevice = DLDevice kDLCPU 0+              , dltNDim = fromIntegral ndim+              , dltDType = dt+              , dltShape = shapeP+              , dltStrides = nullPtr+              , dltByteOffset = 0+              }+        }+    pure p
+ src/Keel/Abi/DLPack/Raw.hs view
@@ -0,0 +1,315 @@+-- | The DLPack tensor-exchange structs, 1:1 and unmanaged.+--+-- Targets DLPack v1.x: the exchanged object is 'DLManagedTensorVersioned'+-- (the pre-1.0 unversioned @DLManagedTensor@ is deliberately not bound).+-- Layouts are hand-written against @dlpack.h@+-- (<https://github.com/dmlc/dlpack>) and verified by the test-suite+-- layout gate; the shipped library has no C sources.+--+-- Ownership follows the DLPack contract: the consumer of a+-- 'DLManagedTensorVersioned' calls 'callTensorDeleter' exactly once when+-- done; the producer keeps everything the tensor points at alive until+-- then via 'mtvManagerCtx'.+module Keel.Abi.DLPack.Raw+  ( -- * Version+    DLPackVersion (..)+  , dlpackMajorVersion+  , dlpackMinorVersion++    -- * Device+  , DLDevice (..)+  , kDLCPU+  , kDLCUDA+  , kDLCUDAHost+  , kDLOpenCL+  , kDLVulkan+  , kDLMetal+  , kDLVPI+  , kDLROCM++    -- * Data type+  , DLDataType (..)+  , kDLInt+  , kDLUInt+  , kDLFloat+  , kDLOpaqueHandle+  , kDLBfloat+  , kDLComplex+  , kDLBool++    -- * Tensor+  , DLTensor (..)+  , DLManagedTensorVersioned (..)+  , dlpackFlagReadOnly+  , dlpackFlagIsCopied+  , callTensorDeleter++    -- * Layout tables (consumed by the test-suite layout gate)+  , dlPackVersionLayout+  , dlDeviceLayout+  , dlDataTypeLayout+  , dlTensorLayout+  , dlManagedTensorVersionedLayout+  ) where++import Control.Monad (unless)+import Data.Int (Int32, Int64)+import Data.Word (Word16, Word32, Word64, Word8)+import Foreign.Ptr (FunPtr, Ptr, nullFunPtr)+import Foreign.Storable (Storable (..))++-- ---------------------------------------------------------------------+-- DLPackVersion++-- | @DLPackVersion@ — the ABI version the producer filled the struct+-- with. Consumers must check 'dlverMajor' against 'dlpackMajorVersion'.+data DLPackVersion = DLPackVersion+  { dlverMajor :: Word32+  , dlverMinor :: Word32+  }+  deriving (Eq, Show)++-- | The DLPack version these bindings target (v1.1).+dlpackMajorVersion, dlpackMinorVersion :: Word32+dlpackMajorVersion = 1+dlpackMinorVersion = 1++oVerMajor, oVerMinor, szDLPackVersion :: Int+oVerMajor = 0+oVerMinor = 4+szDLPackVersion = 8++-- | @(sizeof, [(field, offset)])@ in declaration order.+dlPackVersionLayout :: (Int, [(String, Int)])+dlPackVersionLayout =+  (szDLPackVersion, [("major", oVerMajor), ("minor", oVerMinor)])++instance Storable DLPackVersion where+  sizeOf _ = szDLPackVersion+  alignment _ = 4+  peek p = DLPackVersion <$> peekByteOff p oVerMajor <*> peekByteOff p oVerMinor+  poke p v = do+    pokeByteOff p oVerMajor (dlverMajor v)+    pokeByteOff p oVerMinor (dlverMinor v)++-- ---------------------------------------------------------------------+-- DLDevice++-- | @DLDevice@. 'dldevType' is the @DLDeviceType@ enum (a C @int@).+data DLDevice = DLDevice+  { dldevType :: Int32+  , dldevId :: Int32+  }+  deriving (Eq, Show)++-- | @DLDeviceType@ values (the ones keel can ever produce or consume;+-- the full enum is larger but frozen upstream).+kDLCPU, kDLCUDA, kDLCUDAHost, kDLOpenCL, kDLVulkan, kDLMetal, kDLVPI, kDLROCM :: Int32+kDLCPU = 1+kDLCUDA = 2+kDLCUDAHost = 3+kDLOpenCL = 4+kDLVulkan = 7+kDLMetal = 8+kDLVPI = 9+kDLROCM = 10++oDevType, oDevId, szDLDevice :: Int+oDevType = 0+oDevId = 4+szDLDevice = 8++-- | @(sizeof, [(field, offset)])@ in declaration order.+dlDeviceLayout :: (Int, [(String, Int)])+dlDeviceLayout = (szDLDevice, [("device_type", oDevType), ("device_id", oDevId)])++instance Storable DLDevice where+  sizeOf _ = szDLDevice+  alignment _ = 4+  peek p = DLDevice <$> peekByteOff p oDevType <*> peekByteOff p oDevId+  poke p d = do+    pokeByteOff p oDevType (dldevType d)+    pokeByteOff p oDevId (dldevId d)++-- ---------------------------------------------------------------------+-- DLDataType++-- | @DLDataType@: type code, bit width, vector lanes (1 for scalars).+-- Example: @DLDataType kDLFloat 64 1@ is a C @double@.+data DLDataType = DLDataType+  { dldtCode :: Word8+  , dldtBits :: Word8+  , dldtLanes :: Word16+  }+  deriving (Eq, Show)++-- | @DLDataTypeCode@ values.+kDLInt, kDLUInt, kDLFloat, kDLOpaqueHandle, kDLBfloat, kDLComplex, kDLBool :: Word8+kDLInt = 0+kDLUInt = 1+kDLFloat = 2+kDLOpaqueHandle = 3+kDLBfloat = 4+kDLComplex = 5+kDLBool = 6++oDtCode, oDtBits, oDtLanes, szDLDataType :: Int+oDtCode = 0+oDtBits = 1+oDtLanes = 2+szDLDataType = 4++-- | @(sizeof, [(field, offset)])@ in declaration order.+dlDataTypeLayout :: (Int, [(String, Int)])+dlDataTypeLayout =+  (szDLDataType, [("code", oDtCode), ("bits", oDtBits), ("lanes", oDtLanes)])++instance Storable DLDataType where+  sizeOf _ = szDLDataType+  alignment _ = 2+  peek p =+    DLDataType+      <$> peekByteOff p oDtCode+      <*> peekByteOff p oDtBits+      <*> peekByteOff p oDtLanes+  poke p t = do+    pokeByteOff p oDtCode (dldtCode t)+    pokeByteOff p oDtBits (dldtBits t)+    pokeByteOff p oDtLanes (dldtLanes t)++-- ---------------------------------------------------------------------+-- DLTensor++-- | @DLTensor@ — a borrowed view; owns nothing. 'dltShape' (and+-- 'dltStrides' when non-null) point at @ndim@ @int64_t@s owned by the+-- producer. Null 'dltStrides' means compact row-major. Strides are in+-- /elements/, not bytes.+data DLTensor = DLTensor+  { dltData :: Ptr ()+  , dltDevice :: DLDevice+  , dltNDim :: Int32+  , dltDType :: DLDataType+  , dltShape :: Ptr Int64+  , dltStrides :: Ptr Int64+  , dltByteOffset :: Word64+  }++oTData, oTDevice, oTNDim, oTDType, oTShape, oTStrides, oTByteOffset,+  szDLTensor :: Int+oTData = 0+oTDevice = 8+oTNDim = 16+oTDType = 20+oTShape = 24+oTStrides = 32+oTByteOffset = 40+szDLTensor = 48++-- | @(sizeof, [(field, offset)])@ in declaration order.+dlTensorLayout :: (Int, [(String, Int)])+dlTensorLayout =+  ( szDLTensor+  , [ ("data", oTData)+    , ("device", oTDevice)+    , ("ndim", oTNDim)+    , ("dtype", oTDType)+    , ("shape", oTShape)+    , ("strides", oTStrides)+    , ("byte_offset", oTByteOffset)+    ]+  )++instance Storable DLTensor where+  sizeOf _ = szDLTensor+  alignment _ = 8+  peek p =+    DLTensor+      <$> peekByteOff p oTData+      <*> peekByteOff p oTDevice+      <*> peekByteOff p oTNDim+      <*> peekByteOff p oTDType+      <*> peekByteOff p oTShape+      <*> peekByteOff p oTStrides+      <*> peekByteOff p oTByteOffset+  poke p t = do+    pokeByteOff p oTData (dltData t)+    pokeByteOff p oTDevice (dltDevice t)+    pokeByteOff p oTNDim (dltNDim t)+    pokeByteOff p oTDType (dltDType t)+    pokeByteOff p oTShape (dltShape t)+    pokeByteOff p oTStrides (dltStrides t)+    pokeByteOff p oTByteOffset (dltByteOffset t)++-- ---------------------------------------------------------------------+-- DLManagedTensorVersioned++-- | @DLManagedTensorVersioned@ — the owned exchange object of DLPack+-- v1.x. The consumer calls 'callTensorDeleter' exactly once when done.+data DLManagedTensorVersioned = DLManagedTensorVersioned+  { mtvVersion :: DLPackVersion+  , mtvManagerCtx :: Ptr ()+  , mtvDeleter :: FunPtr (Ptr DLManagedTensorVersioned -> IO ())+  , mtvFlags :: Word64+  , mtvTensor :: DLTensor+  }++-- | @DLPACK_FLAG_BITMASK_READ_ONLY@: the consumer must not write through+-- 'dltData'.+dlpackFlagReadOnly :: Word64+dlpackFlagReadOnly = 1++-- | @DLPACK_FLAG_BITMASK_IS_COPIED@: the tensor is a copy, not a view.+dlpackFlagIsCopied :: Word64+dlpackFlagIsCopied = 2++oMtvVersion, oMtvManagerCtx, oMtvDeleter, oMtvFlags, oMtvTensor,+  szDLManagedTensorVersioned :: Int+oMtvVersion = 0+oMtvManagerCtx = 8+oMtvDeleter = 16+oMtvFlags = 24+oMtvTensor = 32+szDLManagedTensorVersioned = 80++-- | @(sizeof, [(field, offset)])@ in declaration order.+dlManagedTensorVersionedLayout :: (Int, [(String, Int)])+dlManagedTensorVersionedLayout =+  ( szDLManagedTensorVersioned+  , [ ("version", oMtvVersion)+    , ("manager_ctx", oMtvManagerCtx)+    , ("deleter", oMtvDeleter)+    , ("flags", oMtvFlags)+    , ("dl_tensor", oMtvTensor)+    ]+  )++instance Storable DLManagedTensorVersioned where+  sizeOf _ = szDLManagedTensorVersioned+  alignment _ = 8+  peek p =+    DLManagedTensorVersioned+      <$> peekByteOff p oMtvVersion+      <*> peekByteOff p oMtvManagerCtx+      <*> peekByteOff p oMtvDeleter+      <*> peekByteOff p oMtvFlags+      <*> peekByteOff p oMtvTensor+  poke p m = do+    pokeByteOff p oMtvVersion (mtvVersion m)+    pokeByteOff p oMtvManagerCtx (mtvManagerCtx m)+    pokeByteOff p oMtvDeleter (mtvDeleter m)+    pokeByteOff p oMtvFlags (mtvFlags m)+    pokeByteOff p oMtvTensor (mtvTensor m)++foreign import ccall "dynamic"+  callDeleter+    :: FunPtr (Ptr DLManagedTensorVersioned -> IO ())+    -> Ptr DLManagedTensorVersioned+    -> IO ()++-- | Invoke the tensor's deleter — the consumer-side "I am done" call.+-- A null deleter (legal per spec: the producer has nothing to free) is+-- a no-op.+callTensorDeleter :: Ptr DLManagedTensorVersioned -> IO ()+callTensorDeleter p = do+  fp <- peekByteOff p oMtvDeleter+  unless (fp == nullFunPtr) (callDeleter fp p)
+ test/DLPackNumpy.hs view
@@ -0,0 +1,113 @@+-- | Conformance test: exchange DLPack v1.x versioned tensors with numpy,+-- in-process, in both directions.+--+-- CPython is loaded at run time through keel-dyn (see "PyEmbed").+--+-- * import direction: numpy exports @arange(6, float64)@ as a+--   @dltensor_versioned@ PyCapsule; the script writes the raw pointer+--   into a Haskell-allocated slot and renames the capsule to+--   @used_dltensor_versioned@ (ownership transferred to us); Haskell+--   verifies version\/dtype\/shape\/values under 'consumeManagedTensor',+--   whose exit calls numpy's deleter;+-- * export direction: Haskell builds a 2x3 float64 tensor with+--   'newManagedTensor'; a minimal producer class hands the capsule to+--   @np.from_dlpack@; Python verifies shape and values and drops the+--   array — numpy's consumer must call our deleter trampoline, which+--   runs our cleanup.+--+-- Requires numpy >= 2.1 (DLPack 1.0 protocol support); older or absent+-- numpy SKIPs unless @KEEL_ABI_REQUIRE_NUMPY@ is set (CI sets it).+module Main (main) where++import Data.IORef (newIORef, readIORef, writeIORef)+import Data.Int (Int64)+import Foreign.Marshal.Alloc (callocBytes, free, mallocBytes)+import Foreign.Ptr (Ptr, castPtr, nullPtr)+import Foreign.Storable (peek, peekElemOff, pokeElemOff)++import Keel.Abi.DLPack+import Keel.Abi.DLPack.Raw+import PyEmbed++main :: IO ()+main = withEmbeddedPython "numpy" "KEEL_ABI_REQUIRE_NUMPY" $ \runScript -> do+  importDirection runScript+  exportDirection runScript+  putStrLn "keel-abi-dlpack: tensors round-tripped both directions against numpy"++-- ---------------------------------------------------------------------+-- Direction 1: numpy -> Haskell++importDirection :: RunScript -> IO ()+importDirection runScript = do+  slot <- callocBytes 8 :: IO (Ptr (Ptr DLManagedTensorVersioned))+  runScript "dlpack-import" $+    "import numpy as np, ctypes\n\+    \a = np.arange(6, dtype=np.float64)\n\+    \cap = a.__dlpack__(max_version=(1, 1))\n\+    \ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p\n\+    \ctypes.pythonapi.PyCapsule_GetPointer.argtypes = [ctypes.py_object, ctypes.c_char_p]\n\+    \p = ctypes.pythonapi.PyCapsule_GetPointer(cap, b'dltensor_versioned')\n\+    \ctypes.pythonapi.PyCapsule_SetName.argtypes = [ctypes.py_object, ctypes.c_char_p]\n\+    \ctypes.pythonapi.PyCapsule_SetName(cap, b'used_dltensor_versioned')\n\+    \ctypes.c_void_p.from_address(" <> addr slot <> ").value = p\n"+  mtp <- peek slot+  expect (mtp /= nullPtr) "python wrote no tensor pointer"++  vals <- consumeManagedTensor mtp $ \m -> do+    expect (dlverMajor (mtvVersion m) == dlpackMajorVersion)+      ("producer major version: " <> show (dlverMajor (mtvVersion m)))+    let t = mtvTensor m+    expect (dldtCode (dltDType t) == kDLFloat) "dtype code not float"+    expect (dldtBits (dltDType t) == 64) "dtype bits not 64"+    expect (dldtLanes (dltDType t) == 1) "dtype lanes not 1"+    expect (dldevType (dltDevice t) == kDLCPU) "device not CPU"+    expect (dltNDim t == 1) ("ndim: " <> show (dltNDim t))+    sh <- peekElemOff (dltShape t) 0+    expect (sh == 6) ("shape[0]: " <> show sh)+    -- strides: null means compact; a non-null [1] is equivalent for 1-D+    strideOk <-+      if dltStrides t == nullPtr+        then pure True+        else (== (1 :: Int64)) <$> peekElemOff (dltStrides t) 0+    expect strideOk "strides neither null nor [1]"+    expect (dltByteOffset t == 0) "byte_offset nonzero"+    mapM (peekElemOff (castPtr (dltData t) :: Ptr Double)) [0 .. 5]+  expect (vals == [0, 1, 2, 3, 4, 5]) ("values: " <> show vals)+  free slot++-- ---------------------------------------------------------------------+-- Direction 2: Haskell -> numpy++exportDirection :: RunScript -> IO ()+exportDirection runScript = do+  cleanupRan <- newIORef False+  buf <- mallocBytes (6 * 8) :: IO (Ptr Double)+  mapM_ (uncurry (pokeElemOff buf)) (zip [0 ..] [1 .. 6])+  mt <-+    newManagedTensor+      (DLDataType kDLFloat 64 1)+      [2, 3]+      (castPtr buf)+      0+      (free buf >> writeIORef cleanupRan True)++  runScript "dlpack-export" $+    "import numpy as np, ctypes, gc\n\+    \ctypes.pythonapi.PyCapsule_New.restype = ctypes.py_object\n\+    \ctypes.pythonapi.PyCapsule_New.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p]\n\+    \class _KeelTensor:\n\+    \    def __init__(self, p):\n\+    \        self._p = p\n\+    \    def __dlpack__(self, max_version=None, dl_device=None, copy=None):\n\+    \        return ctypes.pythonapi.PyCapsule_New(ctypes.c_void_p(self._p), b'dltensor_versioned', None)\n\+    \    def __dlpack_device__(self):\n\+    \        return (1, 0)\n\+    \b = np.from_dlpack(_KeelTensor(" <> addr mt <> "))\n\+    \if b.shape != (2, 3) or b.tolist() != [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]:\n\+    \    raise RuntimeError('dlpack import mismatch: %r' % (b.tolist(),))\n\+    \del b\n\+    \gc.collect()\n"++  done <- readIORef cleanupRan+  expect done "numpy never called our deleter (cleanup did not run)"
+ test/Layout.hs view
@@ -0,0 +1,80 @@+-- | Layout gate: compare every offset\/size in the Haskell layout tables+-- against a real C compiler's @offsetof@\/@sizeof@ (computed in+-- @cbits\/layout_gate.c@, which also carries compile-time+-- @_Static_assert@s for the same numbers).+module Main (main) where++import Control.Monad (forM, unless)+import Foreign.C.Types (CSize (..))+import Foreign.Marshal.Array (allocaArray, peekArray)+import Foreign.Ptr (Ptr)+import System.Exit (exitFailure)++import Keel.Abi.Arrow.Raw+import Keel.Abi.DLPack.Raw++foreign import ccall unsafe "keel_layout_ArrowSchema"+  c_layout_ArrowSchema :: Ptr CSize -> IO CSize++foreign import ccall unsafe "keel_layout_ArrowArray"+  c_layout_ArrowArray :: Ptr CSize -> IO CSize++foreign import ccall unsafe "keel_layout_ArrowArrayStream"+  c_layout_ArrowArrayStream :: Ptr CSize -> IO CSize++foreign import ccall unsafe "keel_layout_DLPackVersion"+  c_layout_DLPackVersion :: Ptr CSize -> IO CSize++foreign import ccall unsafe "keel_layout_DLDevice"+  c_layout_DLDevice :: Ptr CSize -> IO CSize++foreign import ccall unsafe "keel_layout_DLDataType"+  c_layout_DLDataType :: Ptr CSize -> IO CSize++foreign import ccall unsafe "keel_layout_DLTensor"+  c_layout_DLTensor :: Ptr CSize -> IO CSize++foreign import ccall unsafe "keel_layout_DLManagedTensorVersioned"+  c_layout_DLManagedTensorVersioned :: Ptr CSize -> IO CSize++-- | Returns the list of mismatch descriptions (empty = pass).+checkStruct+  :: String+  -> (Int, [(String, Int)])+  -> (Ptr CSize -> IO CSize)+  -> IO [String]+checkStruct structName (hsSize, fields) probe =+  allocaArray (length fields) $ \out -> do+    cSize <- probe out+    cOffs <- peekArray (length fields) out+    let sizeErrs =+          [ structName <> ": sizeof C=" <> show cSize <> " hs=" <> show hsSize+          | fromIntegral cSize /= hsSize+          ]+        fieldErrs =+          [ structName <> "." <> fname+              <> ": offsetof C=" <> show cOff <> " hs=" <> show hsOff+          | ((fname, hsOff), cOff) <- zip fields cOffs+          , fromIntegral cOff /= hsOff+          ]+    pure (sizeErrs <> fieldErrs)++main :: IO ()+main = do+  errs <- fmap concat . forM checks $ \(nm, layout, probe) ->+    checkStruct nm layout probe+  unless (null errs) $ do+    mapM_ putStrLn errs+    exitFailure+  putStrLn ("keel-abi: layout gate passed (" <> show (length checks) <> " structs)")+  where+    checks =+      [ ("ArrowSchema", arrowSchemaLayout, c_layout_ArrowSchema)+      , ("ArrowArray", arrowArrayLayout, c_layout_ArrowArray)+      , ("ArrowArrayStream", arrowArrayStreamLayout, c_layout_ArrowArrayStream)+      , ("DLPackVersion", dlPackVersionLayout, c_layout_DLPackVersion)+      , ("DLDevice", dlDeviceLayout, c_layout_DLDevice)+      , ("DLDataType", dlDataTypeLayout, c_layout_DLDataType)+      , ("DLTensor", dlTensorLayout, c_layout_DLTensor)+      , ("DLManagedTensorVersioned", dlManagedTensorVersionedLayout, c_layout_DLManagedTensorVersioned)+      ]
+ test/Managed.hs view
@@ -0,0 +1,186 @@+-- | Unit tests for the managed layer ("Keel.Abi.Arrow") that need no+-- foreign producer: Haskell plays both exporter and consumer.+module Main (main) where++import Control.Monad (forM_, unless)+import Data.IORef (modifyIORef', newIORef, readIORef)+import Foreign.ForeignPtr (finalizeForeignPtr, withForeignPtr)+import GHC.Stats (GCDetails (..), RTSStats (..), getRTSStats, getRTSStatsEnabled)+import System.Mem (performMajorGC)+import Foreign.Marshal.Alloc (callocBytes, free, mallocBytes)+import Foreign.Ptr (Ptr, castPtr, nullFunPtr, nullPtr)+import Foreign.Storable (peek, peekElemOff, poke, pokeElemOff, sizeOf)++import Keel.Abi.Arrow+import Keel.Abi.Arrow.Raw+import Keel.Abi.DLPack+import Keel.Abi.DLPack.Raw++expect :: Bool -> String -> IO ()+expect ok msg = unless ok (fail msg)++main :: IO ()+main = do+  -- 1. releasing a never-filled import target is a no-op (with* variant)+  r <- withArrowArrayImport $ \p -> do+    a <- peek p+    expect (arrayRelease a == nullFunPtr) "import target not zeroed"+    pure (42 :: Int)+  expect (r == 42) "with-import did not return the action result"++  -- 2. export -> consumer-release lifecycle, array side+  cnt <- newIORef (0 :: Int)+  p <- callocBytes (sizeOf (undefined :: ArrowArray))+  exportArrowArray p emptyArrowArray { arrayLength = 3 } (modifyIORef' cnt (+ 1))+  a0 <- peek p+  expect (arrayLength a0 == 3) "template field lost on export"+  expect (arrayRelease a0 /= nullFunPtr) "export installed no release"+  releaseArrowArray p+  releaseArrowArray p -- double release must be a no-op+  n <- readIORef cnt+  expect (n == 1) ("cleanup ran " <> show n <> " times, want 1")+  a1 <- peek p+  expect (arrayRelease a1 == nullFunPtr) "release not nulled"+  expect (arrayPrivateData a1 == nullPtr) "private_data not nulled"+  free p++  -- 3. same lifecycle, schema side+  scnt <- newIORef (0 :: Int)+  sp <- callocBytes (sizeOf (undefined :: ArrowSchema))+  exportArrowSchema sp emptyArrowSchema (modifyIORef' scnt (+ 1))+  releaseArrowSchema sp+  releaseArrowSchema sp+  sn <- readIORef scnt+  expect (sn == 1) ("schema cleanup ran " <> show sn <> " times, want 1")+  free sp++  -- 4. with-import releases an exported struct on scope exit+  wcnt <- newIORef (0 :: Int)+  withArrowArrayImport $ \wp ->+    exportArrowArray wp emptyArrowArray (modifyIORef' wcnt (+ 1))+  wn <- readIORef wcnt+  expect (wn == 1) ("bracket cleanup ran " <> show wn <> " times, want 1")++  -- 5. malloc variant: finalizeForeignPtr runs the release deterministically+  fcnt <- newIORef (0 :: Int)+  fp <- mallocArrowArrayImport+  withForeignPtr fp $ \rawp ->+    exportArrowArray rawp emptyArrowArray (modifyIORef' fcnt (+ 1))+  finalizeForeignPtr fp+  fn <- readIORef fcnt+  expect (fn == 1) ("finalizer cleanup ran " <> show fn <> " times, want 1")++  -- 6. exported stream: drive it as a consumer, entirely in-process+  streamCnt <- newIORef (0 :: Int)+  gnCalls <- newIORef (0 :: Int)+  stp <- callocBytes (sizeOf (undefined :: ArrowArrayStream))+  exportArrowArrayStream stp+    ArrowStreamProducer+      { producerGetSchema = \o -> do+          exportArrowSchema o emptyArrowSchema (pure ())+          pure 0+      , producerGetNext = \o -> do+          i <- readIORef gnCalls+          modifyIORef' gnCalls (+ 1)+          if i < 2+            then exportArrowArray o emptyArrowArray { arrayLength = fromIntegral (i + 1) } (pure ())+            else poke' o -- end-of-stream: zeroed struct, null release+          pure 0+      , producerGetLastError = pure nullPtr+      , producerCleanup = modifyIORef' streamCnt (+ 1)+      }+  st <- peek stp+  -- schema call+  withArrowSchemaImport $ \so -> do+    rc <- callStreamGetSchema (streamGetSchema st) stp so+    expect (rc == 0) "get_schema returned nonzero"+  -- two batches then end-of-stream+  lens <- mapM (const (nextLen st stp)) [1 :: Int, 2, 3]+  expect (lens == [Just 1, Just 2, Nothing]) ("stream batches: " <> show lens)+  releaseArrowArrayStream stp+  releaseArrowArrayStream stp -- double release no-op+  stn <- readIORef streamCnt+  expect (stn == 1) ("stream cleanup ran " <> show stn <> " times, want 1")+  free stp++  -- 7. DLPack: produce and consume entirely in-process+  dcnt <- newIORef (0 :: Int)+  dbuf <- mallocBytes (6 * 8) :: IO (Ptr Double)+  mapM_ (uncurry (pokeElemOff dbuf)) (zip [0 ..] [1 .. 6])+  mt <-+    newManagedTensor+      (DLDataType kDLFloat 64 1)+      [2, 3]+      (castPtr dbuf)+      dlpackFlagReadOnly+      (free dbuf >> modifyIORef' dcnt (+ 1))+  dvals <- consumeManagedTensor mt $ \m -> do+    expect (dlverMajor (mtvVersion m) == dlpackMajorVersion) "tensor version major"+    expect (mtvFlags m == dlpackFlagReadOnly) "tensor flags lost"+    let t = mtvTensor m+    expect (dltNDim t == 2) ("tensor ndim: " <> show (dltNDim t))+    dsh <- mapM (peekElemOff (dltShape t)) [0, 1]+    expect (dsh == [2, 3]) ("tensor shape: " <> show dsh)+    expect (dltStrides t == nullPtr) "strides not null (compact expected)"+    mapM (peekElemOff (castPtr (dltData t) :: Ptr Double)) [0 .. 5]+  expect (dvals == [1 .. 6]) ("tensor values: " <> show dvals)+  dn <- readIORef dcnt+  expect (dn == 1) ("tensor cleanup ran " <> show dn <> " times, want 1")++  -- 8. a throwing cleanup must not escape into the (foreign) caller:+  -- the struct is still marked released and the process survives+  ecnt <- newIORef (0 :: Int)+  ep <- callocBytes (sizeOf (undefined :: ArrowArray))+  exportArrowArray ep emptyArrowArray+    (modifyIORef' ecnt (+ 1) >> ioError (userError "cleanup boom"))+  releaseArrowArray ep+  e1 <- peek ep+  expect (arrayRelease e1 == nullFunPtr) "throwing cleanup: release not nulled"+  releaseArrowArray ep -- and release stays a no-op afterwards+  en <- readIORef ecnt+  expect (en == 1) ("throwing cleanup ran " <> show en <> " times, want 1")+  free ep++  -- 9. a throwing stream callback surfaces as errno EIO, not a crash+  tp <- callocBytes (sizeOf (undefined :: ArrowArrayStream))+  exportArrowArrayStream tp+    ArrowStreamProducer+      { producerGetSchema = \_ -> ioError (userError "schema boom")+      , producerGetNext = \_ -> ioError (userError "next boom")+      , producerGetLastError = pure nullPtr+      , producerCleanup = pure ()+      }+  tst <- peek tp+  trc <- withArrowSchemaImport (callStreamGetSchema (streamGetSchema tst) tp)+  expect (trc == 5) ("throwing get_schema returned " <> show trc <> ", want 5 (EIO)")+  releaseArrowArrayStream tp+  free tp++  -- 10. leak gate: 10k full export/release cycles must not grow the live+  -- Haskell heap (a leaked StablePtr or cleanup closure would). C-side+  -- malloc leaks are invisible here — that is 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 .. 10000] $ \_ ->+    withArrowArrayImport $ \lp ->+      exportArrowArray lp emptyArrowArray (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 10k cycles (leak)")++  putStrLn "keel-abi: managed-layer lifecycle tests passed (10 scenarios)"+  where+    poke' o = poke o emptyArrowArray+    nextLen st stp = withArrowArrayImport $ \ao -> do+      rc <- callStreamGetNext (streamGetNext st) stp ao+      expect (rc == 0) "get_next returned nonzero"+      a <- peek ao+      pure $+        if arrayRelease a == nullFunPtr+          then Nothing+          else Just (arrayLength a)
+ test/PyArrow.hs view
@@ -0,0 +1,310 @@+-- | Conformance test: round-trip the Arrow C Data Interface AND the+-- C Stream Interface against pyarrow, in-process, in both directions.+--+-- CPython is loaded at run time through keel-dyn (see "PyEmbed").+-- Four directions:+--+-- 1. array  pyarrow -> Haskell: @[1, 2, 3, None, 5] :: int64@; verify+--    format\/length\/null bitmap\/values, release, check the callbacks+--    null themselves per spec (raw-layer conformance, managed alloc);+-- 2. array  Haskell -> pyarrow: hand-built @[10, 20, 30]@ with raw+--    @\"wrapper\"@ release callbacks; Python verifies and drops it,+--    which must call back into Haskell and free our buffers;+-- 3. stream pyarrow -> Haskell: a two-batch RecordBatchReader; verify+--    struct schema (+s with int64 child \"x\"), both batches, and the+--    end-of-stream convention;+-- 4. stream Haskell -> pyarrow: an 'ArrowStreamProducer' serving two+--    batches through the managed export layer; Python @read_all()@s,+--    verifies, and drops it — our cleanup must run.+module Main (main) where++import Control.Monad (forM)+import Data.Bits ((.&.))+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)+import Data.Int (Int64)+import Data.Word (Word8)+import Foreign.C.String (newCString, peekCString)+import Foreign.Marshal.Alloc (callocBytes, free, mallocBytes)+import Foreign.Ptr (FunPtr, Ptr, castPtr, freeHaskellFunPtr, nullFunPtr, nullPtr)+import Foreign.Storable (peek, peekElemOff, poke, pokeElemOff, sizeOf)++import Keel.Abi.Arrow+import Keel.Abi.Arrow.Raw+import PyEmbed++foreign import ccall "wrapper"+  mkSchemaRelease :: (Ptr ArrowSchema -> IO ()) -> IO (FunPtr (Ptr ArrowSchema -> IO ()))++foreign import ccall "wrapper"+  mkArrayRelease :: (Ptr ArrowArray -> IO ()) -> IO (FunPtr (Ptr ArrowArray -> IO ()))++main :: IO ()+main = withEmbeddedPython "pyarrow" "KEEL_ABI_REQUIRE_PYARROW" $ \runScript -> do+  arrayImportDirection runScript+  arrayExportDirection runScript+  streamImportDirection runScript+  streamExportDirection runScript+  putStrLn "keel-abi-pyarrow: arrays and streams round-tripped both directions"++-- ---------------------------------------------------------------------+-- Direction 1: array, pyarrow -> Haskell++arrayImportDirection :: RunScript -> IO ()+arrayImportDirection runScript =+  withArrowArrayImport $ \impArr ->+    withArrowSchemaImport $ \impSch -> do+      runScript "array-import" $+        "import pyarrow as pa\n\+        \arr = pa.array([1, 2, 3, None, 5], type=pa.int64())\n\+        \arr._export_to_c(" <> addr impArr <> ", " <> addr impSch <> ")\n"++      sch <- peek impSch+      fmt <- peekCString (schemaFormat sch)+      expect (fmt == "l") ("schema format: expected \"l\", got " <> show fmt)+      expect (schemaFlags sch .&. arrowFlagNullable /= 0) "schema not flagged nullable"++      arr <- peek impArr+      expect (arrayLength arr == 5) ("length: " <> show (arrayLength arr))+      expect (arrayNullCount arr == 1) ("null_count: " <> show (arrayNullCount arr))+      expect (arrayNBuffers arr == 2) ("n_buffers: " <> show (arrayNBuffers arr))+      expect (arrayOffset arr == 0) ("offset: " <> show (arrayOffset arr))++      validity <- peekElemOff (arrayBuffers arr) 0+      expect (validity /= nullPtr) "validity bitmap missing despite a null"+      vbyte <- peek (castPtr validity :: Ptr Word8)+      expect (vbyte .&. 0x1F == 0x17) -- rows 0,1,2,4 valid, row 3 null+        ("validity bitmap byte: " <> show vbyte)++      dataBuf <- peekElemOff (arrayBuffers arr) 1+      vals <- mapM (peekElemOff (castPtr dataBuf :: Ptr Int64)) [0, 1, 2, 4]+      expect (vals == [1, 2, 3, 5]) ("values: " <> show vals)++      -- release early (the with-brackets would anyway), then verify the+      -- callbacks nulled themselves per spec+      releaseArrowArray impArr+      releaseArrowSchema impSch+      arrAfter <- peek impArr+      schAfter <- peek impSch+      expect (arrayRelease arrAfter == nullFunPtr) "array release not nulled"+      expect (schemaRelease schAfter == nullFunPtr) "schema release not nulled"++-- ---------------------------------------------------------------------+-- Direction 2: array, Haskell -> pyarrow (raw-layer wrappers on purpose)++arrayExportDirection :: RunScript -> IO ()+arrayExportDirection runScript = do+  expData <- mallocBytes (3 * 8) :: IO (Ptr Int64)+  mapM_ (uncurry (pokeElemOff expData)) (zip [0 ..] [10, 20, 30])+  bufArr <- mallocBytes (2 * 8) :: IO (Ptr (Ptr ()))+  pokeElemOff bufArr 0 nullPtr+  pokeElemOff bufArr 1 (castPtr expData)+  expFmt <- newCString "l"++  schemaReleased <- newIORef False+  arrayReleased <- newIORef False+  sRel <- mkSchemaRelease (schemaReleaseAction schemaReleased)+  aRel <- mkArrayRelease (arrayReleaseAction arrayReleased)++  expArr <- callocBytes (sizeOf (undefined :: ArrowArray))+  expSch <- callocBytes (sizeOf (undefined :: ArrowSchema))+  poke expSch+    emptyArrowSchema+      { schemaFormat = expFmt+      , schemaFlags = arrowFlagNullable+      , schemaRelease = sRel+      }+  poke expArr+    emptyArrowArray+      { arrayLength = 3+      , arrayNBuffers = 2+      , arrayBuffers = bufArr+      , arrayRelease = aRel+      }++  runScript "array-export" $+    "import pyarrow as pa, gc\n\+    \imp = pa.Array._import_from_c(" <> addr expArr <> ", " <> addr expSch <> ")\n\+    \got = imp.to_pylist()\n\+    \if got != [10, 20, 30]:\n\+    \    raise RuntimeError('roundtrip mismatch: %r' % (got,))\n\+    \del imp\n\+    \gc.collect()\n"++  sDone <- readIORef schemaReleased+  aDone <- readIORef arrayReleased+  expect sDone "pyarrow never called our schema release callback"+  expect aDone "pyarrow never called our array release callback"+  free expArr+  free expSch+  freeHaskellFunPtr sRel+  freeHaskellFunPtr aRel++-- Raw exporter-side release callbacks: free what we allocated, then null+-- the release member per spec.+schemaReleaseAction :: IORef Bool -> Ptr ArrowSchema -> IO ()+schemaReleaseAction flag p = do+  s <- peek p+  free (schemaFormat s)+  poke p s { schemaRelease = nullFunPtr }+  writeIORef flag True++arrayReleaseAction :: IORef Bool -> Ptr ArrowArray -> IO ()+arrayReleaseAction flag p = do+  a <- peek p+  d <- peekElemOff (arrayBuffers a) 1+  free d+  free (arrayBuffers a)+  poke p a { arrayRelease = nullFunPtr }+  writeIORef flag True++-- ---------------------------------------------------------------------+-- Direction 3: stream, pyarrow -> Haskell++streamImportDirection :: RunScript -> IO ()+streamImportDirection runScript =+  withArrowArrayStreamImport $ \stp -> do+    runScript "stream-import" $+      "import pyarrow as pa\n\+      \schema = pa.schema([('x', pa.int64())])\n\+      \batches = [pa.record_batch([pa.array([1, 2, 3])], schema=schema),\n\+      \           pa.record_batch([pa.array([4, 5])], schema=schema)]\n\+      \reader = pa.RecordBatchReader.from_batches(schema, batches)\n\+      \reader._export_to_c(" <> addr stp <> ")\n"++    st <- peek stp+    withArrowSchemaImport $ \so -> do+      rc <- callStreamGetSchema (streamGetSchema st) stp so+      expect (rc == 0) "stream get_schema returned nonzero"+      sch <- peek so+      fmt <- peekCString (schemaFormat sch)+      expect (fmt == "+s") ("stream schema format: " <> show fmt)+      expect (schemaNChildren sch == 1) "stream schema child count"+      childP <- peekElemOff (schemaChildren sch) 0+      child <- peek childP+      cfmt <- peekCString (schemaFormat child)+      cname <- peekCString (schemaName child)+      expect (cfmt == "l") ("child format: " <> show cfmt)+      expect (cname == "x") ("child name: " <> show cname)++    b1 <- readBatch st stp+    b2 <- readBatch st stp+    b3 <- readBatch st stp+    expect (b1 == Just [1, 2, 3]) ("batch 1: " <> show b1)+    expect (b2 == Just [4, 5]) ("batch 2: " <> show b2)+    expect (b3 == Nothing) ("expected end-of-stream, got " <> show b3)++-- Read one record batch (struct array, one int64 child) from an+-- imported stream; Nothing = end-of-stream per the null-release rule.+readBatch :: ArrowArrayStream -> Ptr ArrowArrayStream -> IO (Maybe [Int64])+readBatch st stp =+  withArrowArrayImport $ \ao -> do+    rc <- callStreamGetNext (streamGetNext st) stp ao+    expect (rc == 0) "stream get_next returned nonzero"+    a <- peek ao+    if arrayRelease a == nullFunPtr+      then pure Nothing+      else do+        expect (arrayNChildren a == 1) "batch child count"+        childP <- peekElemOff (arrayChildren a) 0+        child <- peek childP+        dataBuf <- peekElemOff (arrayBuffers child) 1+        vals <- forM [0 .. fromIntegral (arrayLength child) - 1] $+          peekElemOff (castPtr dataBuf :: Ptr Int64)+        pure (Just vals)++-- ---------------------------------------------------------------------+-- Direction 4: stream, Haskell -> pyarrow (managed export layer)++streamExportDirection :: RunScript -> IO ()+streamExportDirection runScript = do+  cleanupRan <- newIORef False+  batchIx <- newIORef (0 :: Int)+  stp <- callocBytes (sizeOf (undefined :: ArrowArrayStream))+  exportArrowArrayStream stp+    ArrowStreamProducer+      { producerGetSchema = \o -> buildStructSchema o >> pure 0+      , producerGetNext = \o -> do+          i <- readIORef batchIx+          modifyIORef' batchIx (+ 1)+          case batchVals i of+            Just vs -> buildStructBatch o vs >> pure 0+            Nothing -> poke o emptyArrowArray >> pure 0+      , producerGetLastError = pure nullPtr+      , producerCleanup = writeIORef cleanupRan True+      }++  runScript "stream-export" $+    "import pyarrow as pa, gc\n\+    \r = pa.RecordBatchReader._import_from_c(" <> addr stp <> ")\n\+    \tbl = r.read_all()\n\+    \got = tbl.column('x').to_pylist()\n\+    \if got != [10, 20, 30, 40, 50]:\n\+    \    raise RuntimeError('stream mismatch: %r' % (got,))\n\+    \del r, tbl\n\+    \gc.collect()\n"++  done <- readIORef cleanupRan+  expect done "pyarrow never released our exported stream"+  free stp+  where+    batchVals :: Int -> Maybe [Int64]+    batchVals 0 = Just [10, 20, 30]+    batchVals 1 = Just [40, 50]+    batchVals _ = Nothing++-- Build the stream schema: struct "+s" with a single nullable int64+-- child named "x". Parent release cascades into the child per spec.+buildStructSchema :: Ptr ArrowSchema -> IO ()+buildStructSchema out = do+  childFmt <- newCString "l"+  childName <- newCString "x"+  child <- callocBytes (sizeOf (undefined :: ArrowSchema))+  exportArrowSchema child+    emptyArrowSchema+      { schemaFormat = childFmt+      , schemaName = childName+      , schemaFlags = arrowFlagNullable+      }+    (free childFmt >> free childName)+  kids <- mallocBytes 8+  pokeElemOff kids 0 child+  parentFmt <- newCString "+s"+  exportArrowSchema out+    emptyArrowSchema+      { schemaFormat = parentFmt+      , schemaNChildren = 1+      , schemaChildren = kids+      }+    (releaseArrowSchema child >> free child >> free kids >> free parentFmt)++-- Build one record batch: a struct array (validity-only buffer list)+-- with a single int64 child. Parent release cascades into the child.+buildStructBatch :: Ptr ArrowArray -> [Int64] -> IO ()+buildStructBatch out vs = do+  let n = length vs+  dataBuf <- mallocBytes (n * 8) :: IO (Ptr Int64)+  mapM_ (uncurry (pokeElemOff dataBuf)) (zip [0 ..] vs)+  childBufs <- mallocBytes (2 * 8) :: IO (Ptr (Ptr ()))+  pokeElemOff childBufs 0 nullPtr+  pokeElemOff childBufs 1 (castPtr dataBuf)+  child <- callocBytes (sizeOf (undefined :: ArrowArray))+  exportArrowArray child+    emptyArrowArray+      { arrayLength = fromIntegral n+      , arrayNBuffers = 2+      , arrayBuffers = childBufs+      }+    (free dataBuf >> free childBufs)+  kids <- mallocBytes 8+  pokeElemOff kids 0 child+  parentBufs <- mallocBytes 8 :: IO (Ptr (Ptr ()))+  pokeElemOff parentBufs 0 nullPtr+  exportArrowArray out+    emptyArrowArray+      { arrayLength = fromIntegral n+      , arrayNBuffers = 1+      , arrayBuffers = parentBufs+      , arrayNChildren = 1+      , arrayChildren = kids+      }+    (releaseArrowArray child >> free child >> free kids >> free parentBufs)
+ test/PyEmbed.hs view
@@ -0,0 +1,113 @@+-- | Shared harness for conformance tests that embed CPython in-process+-- through keel-dyn: discovery (out of process), load, init, script+-- runner, finalize, and the SKIP-vs-required policy.+module PyEmbed+  ( RunScript+  , withEmbeddedPython+  , expect+  , addr+  ) where++import Control.Exception (IOException, try)+import Control.Monad (unless, when)+import Foreign.C.String (CString, withCString)+import Foreign.C.Types (CInt (..))+import Foreign.Ptr (FunPtr, Ptr, ptrToIntPtr)+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.Process (readProcessWithExitCode)++import Keel.Dyn++-- | @runScript label script@ — run python source, fail the test with+-- @label@ if it raises (the traceback goes to stderr).+type RunScript = String -> String -> IO ()++expect :: Bool -> String -> IO ()+expect ok msg = unless ok (fail msg)++-- | A pointer, rendered for splicing into python source as an integer.+addr :: Ptr a -> String+addr = show . ptrToIntPtr++foreign import ccall safe "dynamic"+  mkPyInitEx :: FunPtr (CInt -> IO ()) -> CInt -> IO ()++foreign import ccall safe "dynamic"+  mkPyRun :: FunPtr (CString -> IO CInt) -> CString -> IO CInt++foreign import ccall safe "dynamic"+  mkPyFinalize :: FunPtr (IO CInt) -> IO CInt++runPy :: String -> [String] -> IO (Maybe String)+runPy exe args = do+  r <- try (readProcessWithExitCode exe args "")+        :: IO (Either IOException (ExitCode, String, String))+  pure $ case r of+    Right (ExitSuccess, out, _) -> Just (filter (`notElem` "\r\n") out)+    _ -> Nothing++-- Find an interpreter that can import the given module, and the shared+-- library behind it. Out of process, so a broken python cannot kill us.+-- POSIX candidates cover plain builds (LIBDIR + INSTSONAME/LDLIBRARY)+-- and macOS framework builds, where INSTSONAME is framework-relative+-- and must be joined to PYTHONFRAMEWORKPREFIX instead.+findPython :: String -> IO (Maybe FilePath)+findPython pymodule = go ["python", "python3"]+  where+    dllScript =+      "import sys, sysconfig, os\n\+      \if os.name == 'nt':\n\+      \    print(os.path.join(sys.base_prefix, 'python%d%d.dll' % sys.version_info[:2]))\n\+      \else:\n\+      \    g = sysconfig.get_config_var\n\+      \    libdir = g('LIBDIR') or ''\n\+      \    cands = [os.path.join(libdir, n) for n in (g('INSTSONAME') or '', g('LDLIBRARY') or '') if n]\n\+      \    fw = g('PYTHONFRAMEWORKPREFIX') or ''\n\+      \    if fw:\n\+      \        cands += [os.path.join(fw, n) for n in (g('INSTSONAME') or '', g('LDLIBRARY') or '') if n]\n\+      \    print(next((c for c in cands if os.path.exists(c)), ''))\n"+    go [] = pure Nothing+    go (exe : rest) = do+      ok <- runPy exe ["-c", "import " <> pymodule]+      case ok of+        Nothing -> go rest+        Just _ -> do+          p <- runPy exe ["-c", dllScript]+          pure $ case p of+            Just path | not (null path) -> Just path+            _ -> Nothing++-- | Load CPython through keel-dyn and hand a 'RunScript' to the action;+-- initialize before, finalize after. When no interpreter that imports+-- @pymodule@ exists, SKIP (print why, exit 0) — unless the named env+-- var is set non-empty\/non-zero, which turns absence into failure+-- (publish-stage CI sets it).+withEmbeddedPython+  :: String -- ^ python module the test needs, e.g. @\"pyarrow\"@+  -> String -- ^ require-env-var, e.g. @\"KEEL_ABI_REQUIRE_PYARROW\"@+  -> (RunScript -> IO ())+  -> IO ()+withEmbeddedPython pymodule requireVar action = do+  found <- findPython pymodule+  required <- lookupEnv requireVar+  case found of+    Nothing -> case required of+      Just v | v /= "" && v /= "0" ->+        fail (requireVar <> " set but no usable python+" <> pymodule <> " found")+      _ -> putStrLn ("SKIP - no usable python+" <> pymodule <> " on this machine")+    Just dll -> do+      -- RTLD_GLOBAL: C extension modules (manylinux policy) leave+      -- Python's own symbols undefined and need them process-visible+      lib <- either (\e -> fail ("load python: " <> show e)) pure =<< loadLibraryGlobal dll+      pyInitEx <- requireSym lib "Py_InitializeEx"+      pyRun <- requireSym lib "PyRun_SimpleString"+      pyFin <- requireSym lib "Py_FinalizeEx"+      mkPyInitEx pyInitEx 0+      let runScript label script = do+            rc <- withCString script (mkPyRun pyRun)+            expect (rc == 0) (label <> ": python script failed (traceback above)")+      action runScript+      fin <- mkPyFinalize pyFin+      when (fin /= 0) (putStrLn "note: Py_FinalizeEx reported errors (ignored)")+      closeLibrary lib
+ test/cbits/layout_gate.c view
@@ -0,0 +1,247 @@+/* Test-only layout gate for keel-abi. The shipped library has NO C+ * sources; this file exists so the test suite fails to COMPILE (via+ * _Static_assert) or fails at RUN TIME (via the keel_layout_* probes,+ * compared against the Haskell-side layout tables) if the hand-written+ * Storable offsets in Keel.Abi.*.Raw ever disagree with a real C+ * compiler on the build platform.+ *+ * Struct definitions vendored from their frozen-ABI specifications:+ *  - Apache Arrow C Data / C Stream Interface (Apache-2.0), which the+ *    spec instructs consumers to copy verbatim:+ *    https://arrow.apache.org/docs/format/CDataInterface.html+ *    https://arrow.apache.org/docs/format/CStreamInterface.html+ *  - dlpack.h v1.1 (Apache-2.0), trimmed to the exchanged structs:+ *    https://github.com/dmlc/dlpack/blob/main/include/dlpack/dlpack.h+ */+#include <stddef.h>+#include <stdint.h>++/* ------------------------------------------------------------------ */+/* Arrow C Data Interface                                             */++struct ArrowSchema {+  const char* format;+  const char* name;+  const char* metadata;+  int64_t flags;+  int64_t n_children;+  struct ArrowSchema** children;+  struct ArrowSchema* dictionary;+  void (*release)(struct ArrowSchema*);+  void* private_data;+};++struct ArrowArray {+  int64_t length;+  int64_t null_count;+  int64_t offset;+  int64_t n_buffers;+  int64_t n_children;+  const void** buffers;+  struct ArrowArray** children;+  struct ArrowArray* dictionary;+  void (*release)(struct ArrowArray*);+  void* private_data;+};++struct ArrowArrayStream {+  int (*get_schema)(struct ArrowArrayStream*, struct ArrowSchema* out);+  int (*get_next)(struct ArrowArrayStream*, struct ArrowArray* out);+  const char* (*get_last_error)(struct ArrowArrayStream*);+  void (*release)(struct ArrowArrayStream*);+  void* private_data;+};++/* ------------------------------------------------------------------ */+/* DLPack v1.x (versioned exchange structs only)                      */++typedef struct {+  uint32_t major;+  uint32_t minor;+} DLPackVersion;++typedef enum {+  kDLCPU = 1,+  kDLCUDA = 2,+  kDLCUDAHost = 3,+  kDLOpenCL = 4,+  kDLVulkan = 7,+  kDLMetal = 8,+  kDLVPI = 9,+  kDLROCM = 10+} DLDeviceType;++typedef struct {+  DLDeviceType device_type;+  int32_t device_id;+} DLDevice;++typedef struct {+  uint8_t code;+  uint8_t bits;+  uint16_t lanes;+} DLDataType;++typedef struct {+  void* data;+  DLDevice device;+  int32_t ndim;+  DLDataType dtype;+  int64_t* shape;+  int64_t* strides;+  uint64_t byte_offset;+} DLTensor;++struct DLManagedTensorVersioned {+  DLPackVersion version;+  void* manager_ctx;+  void (*deleter)(struct DLManagedTensorVersioned* self);+  uint64_t flags;+  DLTensor dl_tensor;+};++/* ------------------------------------------------------------------ */+/* Compile-time gate: the literals below are the exact numbers the     */+/* Haskell Storable instances use. 64-bit only by design.              */++_Static_assert(sizeof(void*) == 8, "keel-abi targets 64-bit platforms only");++_Static_assert(sizeof(struct ArrowSchema) == 72, "ArrowSchema size");+_Static_assert(offsetof(struct ArrowSchema, format) == 0, "ArrowSchema.format");+_Static_assert(offsetof(struct ArrowSchema, name) == 8, "ArrowSchema.name");+_Static_assert(offsetof(struct ArrowSchema, metadata) == 16, "ArrowSchema.metadata");+_Static_assert(offsetof(struct ArrowSchema, flags) == 24, "ArrowSchema.flags");+_Static_assert(offsetof(struct ArrowSchema, n_children) == 32, "ArrowSchema.n_children");+_Static_assert(offsetof(struct ArrowSchema, children) == 40, "ArrowSchema.children");+_Static_assert(offsetof(struct ArrowSchema, dictionary) == 48, "ArrowSchema.dictionary");+_Static_assert(offsetof(struct ArrowSchema, release) == 56, "ArrowSchema.release");+_Static_assert(offsetof(struct ArrowSchema, private_data) == 64, "ArrowSchema.private_data");++_Static_assert(sizeof(struct ArrowArray) == 80, "ArrowArray size");+_Static_assert(offsetof(struct ArrowArray, length) == 0, "ArrowArray.length");+_Static_assert(offsetof(struct ArrowArray, null_count) == 8, "ArrowArray.null_count");+_Static_assert(offsetof(struct ArrowArray, offset) == 16, "ArrowArray.offset");+_Static_assert(offsetof(struct ArrowArray, n_buffers) == 24, "ArrowArray.n_buffers");+_Static_assert(offsetof(struct ArrowArray, n_children) == 32, "ArrowArray.n_children");+_Static_assert(offsetof(struct ArrowArray, buffers) == 40, "ArrowArray.buffers");+_Static_assert(offsetof(struct ArrowArray, children) == 48, "ArrowArray.children");+_Static_assert(offsetof(struct ArrowArray, dictionary) == 56, "ArrowArray.dictionary");+_Static_assert(offsetof(struct ArrowArray, release) == 64, "ArrowArray.release");+_Static_assert(offsetof(struct ArrowArray, private_data) == 72, "ArrowArray.private_data");++_Static_assert(sizeof(struct ArrowArrayStream) == 40, "ArrowArrayStream size");+_Static_assert(offsetof(struct ArrowArrayStream, get_schema) == 0, "ArrowArrayStream.get_schema");+_Static_assert(offsetof(struct ArrowArrayStream, get_next) == 8, "ArrowArrayStream.get_next");+_Static_assert(offsetof(struct ArrowArrayStream, get_last_error) == 16, "ArrowArrayStream.get_last_error");+_Static_assert(offsetof(struct ArrowArrayStream, release) == 24, "ArrowArrayStream.release");+_Static_assert(offsetof(struct ArrowArrayStream, private_data) == 32, "ArrowArrayStream.private_data");++_Static_assert(sizeof(DLPackVersion) == 8, "DLPackVersion size");+_Static_assert(offsetof(DLPackVersion, major) == 0, "DLPackVersion.major");+_Static_assert(offsetof(DLPackVersion, minor) == 4, "DLPackVersion.minor");++_Static_assert(sizeof(DLDevice) == 8, "DLDevice size");+_Static_assert(offsetof(DLDevice, device_type) == 0, "DLDevice.device_type");+_Static_assert(offsetof(DLDevice, device_id) == 4, "DLDevice.device_id");++_Static_assert(sizeof(DLDataType) == 4, "DLDataType size");+_Static_assert(offsetof(DLDataType, code) == 0, "DLDataType.code");+_Static_assert(offsetof(DLDataType, bits) == 1, "DLDataType.bits");+_Static_assert(offsetof(DLDataType, lanes) == 2, "DLDataType.lanes");++_Static_assert(sizeof(DLTensor) == 48, "DLTensor size");+_Static_assert(offsetof(DLTensor, data) == 0, "DLTensor.data");+_Static_assert(offsetof(DLTensor, device) == 8, "DLTensor.device");+_Static_assert(offsetof(DLTensor, ndim) == 16, "DLTensor.ndim");+_Static_assert(offsetof(DLTensor, dtype) == 20, "DLTensor.dtype");+_Static_assert(offsetof(DLTensor, shape) == 24, "DLTensor.shape");+_Static_assert(offsetof(DLTensor, strides) == 32, "DLTensor.strides");+_Static_assert(offsetof(DLTensor, byte_offset) == 40, "DLTensor.byte_offset");++_Static_assert(sizeof(struct DLManagedTensorVersioned) == 80, "DLManagedTensorVersioned size");+_Static_assert(offsetof(struct DLManagedTensorVersioned, version) == 0, "DLManagedTensorVersioned.version");+_Static_assert(offsetof(struct DLManagedTensorVersioned, manager_ctx) == 8, "DLManagedTensorVersioned.manager_ctx");+_Static_assert(offsetof(struct DLManagedTensorVersioned, deleter) == 16, "DLManagedTensorVersioned.deleter");+_Static_assert(offsetof(struct DLManagedTensorVersioned, flags) == 24, "DLManagedTensorVersioned.flags");+_Static_assert(offsetof(struct DLManagedTensorVersioned, dl_tensor) == 32, "DLManagedTensorVersioned.dl_tensor");++/* ------------------------------------------------------------------ */+/* Run-time probes: fill `out` with offsetof per field in declaration  */+/* order, return sizeof. The Haskell test compares these against the   */+/* layout tables exported by the Raw modules, closing the loop between */+/* the C compiler's view and the Storable instances.                   */++size_t keel_layout_ArrowSchema(size_t* out) {+  out[0] = offsetof(struct ArrowSchema, format);+  out[1] = offsetof(struct ArrowSchema, name);+  out[2] = offsetof(struct ArrowSchema, metadata);+  out[3] = offsetof(struct ArrowSchema, flags);+  out[4] = offsetof(struct ArrowSchema, n_children);+  out[5] = offsetof(struct ArrowSchema, children);+  out[6] = offsetof(struct ArrowSchema, dictionary);+  out[7] = offsetof(struct ArrowSchema, release);+  out[8] = offsetof(struct ArrowSchema, private_data);+  return sizeof(struct ArrowSchema);+}++size_t keel_layout_ArrowArray(size_t* out) {+  out[0] = offsetof(struct ArrowArray, length);+  out[1] = offsetof(struct ArrowArray, null_count);+  out[2] = offsetof(struct ArrowArray, offset);+  out[3] = offsetof(struct ArrowArray, n_buffers);+  out[4] = offsetof(struct ArrowArray, n_children);+  out[5] = offsetof(struct ArrowArray, buffers);+  out[6] = offsetof(struct ArrowArray, children);+  out[7] = offsetof(struct ArrowArray, dictionary);+  out[8] = offsetof(struct ArrowArray, release);+  out[9] = offsetof(struct ArrowArray, private_data);+  return sizeof(struct ArrowArray);+}++size_t keel_layout_ArrowArrayStream(size_t* out) {+  out[0] = offsetof(struct ArrowArrayStream, get_schema);+  out[1] = offsetof(struct ArrowArrayStream, get_next);+  out[2] = offsetof(struct ArrowArrayStream, get_last_error);+  out[3] = offsetof(struct ArrowArrayStream, release);+  out[4] = offsetof(struct ArrowArrayStream, private_data);+  return sizeof(struct ArrowArrayStream);+}++size_t keel_layout_DLPackVersion(size_t* out) {+  out[0] = offsetof(DLPackVersion, major);+  out[1] = offsetof(DLPackVersion, minor);+  return sizeof(DLPackVersion);+}++size_t keel_layout_DLDevice(size_t* out) {+  out[0] = offsetof(DLDevice, device_type);+  out[1] = offsetof(DLDevice, device_id);+  return sizeof(DLDevice);+}++size_t keel_layout_DLDataType(size_t* out) {+  out[0] = offsetof(DLDataType, code);+  out[1] = offsetof(DLDataType, bits);+  out[2] = offsetof(DLDataType, lanes);+  return sizeof(DLDataType);+}++size_t keel_layout_DLTensor(size_t* out) {+  out[0] = offsetof(DLTensor, data);+  out[1] = offsetof(DLTensor, device);+  out[2] = offsetof(DLTensor, ndim);+  out[3] = offsetof(DLTensor, dtype);+  out[4] = offsetof(DLTensor, shape);+  out[5] = offsetof(DLTensor, strides);+  out[6] = offsetof(DLTensor, byte_offset);+  return sizeof(DLTensor);+}++size_t keel_layout_DLManagedTensorVersioned(size_t* out) {+  out[0] = offsetof(struct DLManagedTensorVersioned, version);+  out[1] = offsetof(struct DLManagedTensorVersioned, manager_ctx);+  out[2] = offsetof(struct DLManagedTensorVersioned, deleter);+  out[3] = offsetof(struct DLManagedTensorVersioned, flags);+  out[4] = offsetof(struct DLManagedTensorVersioned, dl_tensor);+  return sizeof(struct DLManagedTensorVersioned);+}