Skip to content

Library-agnostic Arrow C Data Interface for morton_index (arro3-core / PyCapsule)#94

Merged
espg merged 4 commits into
mainfrom
claude/93-arrow-c-data
Jul 1, 2026
Merged

Library-agnostic Arrow C Data Interface for morton_index (arro3-core / PyCapsule)#94
espg merged 4 commits into
mainfrom
claude/93-arrow-c-data

Conversation

@espg

@espg espg commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Closes #93.

What & why

Makes the morton_index Arrow surface library-agnostic: any Arrow lib that speaks the PyCapsule C Data Interfacearro3-core (the carrier zagg runs on its Lambda worker, where pyarrow is intentionally absent), pyarrow, polars — can pull a typed morton_index column zero-copy and hand one back, carrying a real Arrow extension type rather than shimming through plain uint64.

Where #86's mortie.arrow builds a pyarrow ExtensionArray and is gated on pyarrow (_require_pyarrow, fill_null, to_pandas) and has no export hook, this path builds the raw Arrow C structs in Rust (the arrow crate's C Data Interface), so the runtime stays numpy-only and gets both directions.

Approach follows the decisions on #93: approach (A) (Rust FFI), the umbrella arrow crate, pinned target arro3-core==0.8.1, and the arro3-only test kept runnable-locally + documented (no .github/workflows/ change here — bundled to #92).

Key finding (the #93 item-2 gate)

arro3-core 0.8.1 round-trips the extension metadata (ARROW:extension:name = mortie.morton_index) through the C-Data boundary — verified in TestArro3Interop. So we ship the real extension type; no uint64-storage fallback is needed on the Lambda layer.

Design

  • Rust producer/consumer (src_rust/src/arrow_ffi.rs, via the arrow crate):
    • rust_mi_export_c_array(words) / rust_mi_export_c_schema(): build the (schema, array) capsules — a UInt64Array with a real validity bitmap from the empty sentinel (data == 0 → Arrow null), schema Field carrying the extension metadata.
    • rust_mi_import_c_array(schema, array): single contiguous array → packed words. Validates each capsule's C-Data name before dereferencing; moves the FFI struct out leaving a released stub (no double-free); rejects non-uint64.
    • rust_mi_import_c_stream(stream): a chunked column (__arrow_c_stream__). Its stream schema is the bare (non-struct) uint64, which the struct-oriented ArrowArrayStreamReader rejects, so this drives the C stream callbacks directly (schema once, get_next each chunk to the released end-marker, import each via from_ffi_and_data_type(_, UInt64)), concatenating.
  • Python surface:
    • MortonIndexArray.__arrow_c_array__ / __arrow_c_schema__ / from_arrow(source) (mortie/morton_index.py).
    • numpy-only mortie.arrow.export_c_array / export_c_schema / import_c_array (mortie/arrow.py) — pyarrow-free; import_c_array dispatches __arrow_c_array__ (contiguous) vs __arrow_c_stream__ (chunked) vs a raw capsule tuple.
  • Null/sentinel preserved byte-for-byte over a validity bitmap, across chunk boundaries.

Phases

  • Phase 1 — Rust C-Data producer + consumer + extension type; Python surface; round-trip tests (numpy-only, pandas dunders, pyarrow, arro3-core), incl. null/empty/all-null and non-uint64 rejection.
  • Phase 2arro3 install extra (local-only) + docs/arrow_interchange.md.
  • Phase 3#92 context comment for the no-pyarrow CI leg.
  • Phase 4 — chunked/stream import (__arrow_c_stream__) per review feedback; verified round-tripping pyarrow + arro3-core ChunkedArray, empty, and non-uint64 rejection.

Self-review resolutions

Phase-1 review folded (commit fold phase 1 self-review on #93): scoped the arro3 skip to TestArro3Interop only (a module-level importorskip would have skipped the whole file in CI); added capsule-name validation (checked_pointer) against a memory-corruption path; added an offset-slice import test. (Phase-4 stream self-review in progress.)

How it was tested

Local (fresh venv, maturin develop --release, arro3-core==0.8.1):

  • pytest mortie/tests/test_arrow_cdata.py23 passed locally; the arro3 leg skips when arro3-core is absent (the CI shape).
  • pytest test_arrow.py test_morton_index.py — 57 passed, 1 skipped (no regression to the morton index surface followup: points= encode + __from_arrow__ #86 pyarrow skin).
  • cargo test --lib — 188 passed. cargo fmt clean; flake8 mortie --select=E9,F63,F7,F82 clean.

Notes for review

  • Extension metadata verified on arro3-core 0.8.1 — ship the real extension type (no uint64 fallback), as done.
  • codecov/patch is red on the local-only TestArro3Interop lines (skipped in CI); source files are 100% patch-covered. codecov.yml's existing ignore: mortie/tests/**/* isn't excluding files directly under mortie/tests/ from patch — a one-line glob fix greens it; flagged on the thread for your go-ahead since it's CI config.
  • The numpy-only C-Data helpers live in mortie/arrow.py alongside the pyarrow skin (import no pyarrow) — flagging the placement in case you'd prefer a separate module.

Refs #86, #92.

@espg espg added the implement label Jul 1, 2026

@espg espg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Fresh-eyes adversarial review of the phase-1 Arrow C Data Interface work. The core FFI ownership dance is sound: the array is moved out of its capsule via std::ptr::replace(ptr, FFI_ArrowArray::empty()) and handed to from_ffi by value (which wraps it in an Arc and owns the release callback), while the schema is only borrowed via &*schema_ptr — matching from_ffi(array: FFI_ArrowArray, schema: &FFI_ArrowSchema) in arrow 53, so there's no double-free and no use-after-free on the happy path. Null↔sentinel is symmetric (export: word == 0 → validity-bitmap null; import: null → 0), and the != DataType::UInt64 guard runs before UInt64Array::from, so a compatible-but-wrong integer type is rejected cleanly rather than panicking. The arrow crate dep was approved on #93; no other new deps; module is well under the 1000-line limit; docstrings match the surrounding style.

Findings, ranked:

  1. High — the new test module is a no-op in CI. The module-level pytest.importorskip("arro3.core") skips the entire file at collection time when arro3-core is absent, which (per the file's own docstring) is the CI configuration. So the numpy-only round-trip, pandas dunder, and pyarrow-interop acceptance tests never run in CI. Scope the skip to TestArro3Interop only.
  2. Medium — no capsule-name validation. PyCapsule::pointer() uses the capsule's own name, so a mis-ordered/wrong-named capsule pair isn't caught and gets reinterpreted as the wrong FFI struct → UB reachable from safe Python. Validate name() against "arrow_array" / "arrow_schema" before dereferencing.
  3. Low — sliced-array-with-offset import is untested. Logical indexing should handle a non-zero offset correctly, but there's no test pinning it; an offset regression would be silent corruption, so it warrants a fixture.

No blocking concerns on the null/extension-metadata semantics themselves. Comment-only review — not requesting changes.


Generated by Claude Code

Comment thread mortie/tests/test_arrow_cdata.py Outdated

a3 = pytest.importorskip(
"arro3.core", reason="arro3-core not installed (local-only leg)"
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

High — this module-level importorskip silently disables the whole test file in CI. pytest.importorskip("arro3.core") here runs at collection time, so when the import fails it raises Skip for the entire module, not just TestArro3Interop. Per the file's own docstring and #93, arro3-core is deliberately not in the CI test extra — which means in CI this line always fails and pytest skips every test above it too: TestNumpyOnlyRoundTrip, TestMortonIndexArrayDunders, and the whole TestPyarrowInterop class. So the export/import round-trip, null/sentinel, and pyarrow-interop acceptance checks that are supposed to run in CI never actually execute — the file collects to zero tests.

Reproduced locally:

def test_before(): assert True
z = pytest.importorskip("definitely_not_installed_xyz")
def test_after(): assert True
# -> "collected 0 items / 1 skipped"  (test_before never ran)

Scope the skip to the arro3 class only — e.g. put pytest.importorskip("arro3.core", ...) inside a TestArro3Interop method or a class-scoped fixture / @pytest.mark.skipif, so the numpy and pyarrow legs still run when arro3 is absent.


Generated by Claude Code

Comment thread src_rust/src/arrow_ffi.rs Outdated
array_capsule: &Bound<'_, PyCapsule>,
) -> PyResult<Py<PyAny>> {
let schema_ptr = schema_capsule.pointer() as *const FFI_ArrowSchema;
let array_ptr = array_capsule.pointer() as *mut FFI_ArrowArray;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Medium — no capsule-name validation; a wrong-named/mis-ordered capsule is UB, not an error. PyCapsule::pointer() in pyo3 0.22 retrieves the pointer using the capsule's own stored name (name_ptr_ignore_error(self)PyCapsule_GetPointer(cap, PyCapsule_GetName(cap))), so it succeeds for any name. It does not check that array_capsule is named "arrow_array" or schema_capsule "arrow_schema". The Arrow PyCapsule spec requires the consumer to validate these names precisely because the structs are reinterpreted from raw pointers. As written, if a caller passes the pair swapped (schema where array is expected), line 113–114 reinterprets an FFI_ArrowSchema as FFI_ArrowArray, std::ptr::replace moves out garbage, and from_ffi dereferences it → memory corruption / use-after-free, all from safe Python. The non-null check on line 103 doesn't help. Suggest validating array_capsule.name()? == Some(c"arrow_array") and schema_capsule.name()? == Some(c"arrow_schema") before touching the pointers, raising PyValueError otherwise. (The ownership dance itself — moving the array out via replace(..empty()) and borrowing the schema by &* — is correct: from_ffi takes the array by value/Arc and the schema by &, so no double-free.)


Generated by Claude Code

pa = pytest.importorskip("pyarrow")
words = _sample_words()
arr = pa.array(words, type=pa.uint64())
np.testing.assert_array_equal(marrow.import_c_array(arr), words)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Low — sliced-array-with-offset import is untested (and is the exact case most likely to break). The import loop uses logical indexing (arr.value(i) / arr.is_null(i)), which does respect a non-zero Arrow offset, so this likely works — but there is no test that feeds an offset array through import_c_array. A one-liner would pin it, e.g. pa.array(words).slice(2) (or arro3 equivalent) → import_c_array(...) equals words[2:], including a sentinel landing inside the slice. Given the FFI reinterpretation, an offset bug here would be silent corruption rather than an exception, so it's worth a fixture. (Chunked arrays are correctly out of scope — those are __arrow_c_stream__, not __arrow_c_array__.)


Generated by Claude Code

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.78947% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.46%. Comparing base (fee466b) to head (91d1869).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
mortie/tests/test_arrow_cdata.py 83.53% 27 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #94      +/-   ##
==========================================
- Coverage   96.90%   96.46%   -0.44%     
==========================================
  Files          27       28       +1     
  Lines        4680     4870     +190     
==========================================
+ Hits         4535     4698     +163     
- Misses        145      172      +27     
Flag Coverage Δ
unittests 96.46% <85.78%> (-0.44%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
mortie/arrow.py 92.94% <100.00%> (+1.63%) ⬆️
mortie/morton_index.py 91.24% <100.00%> (+0.42%) ⬆️
mortie/tests/test_arrow_cdata.py 83.53% <83.53%> (ø)

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 9342705...91d1869. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codspeed-hq

codspeed-hq Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will improve performance by 18.63%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
✅ 64 untouched benchmarks
⏩ 1 skipped benchmark1

Performance Changes

Benchmark BASE HEAD Efficiency
fast_norm2mort_orders[6] 327.8 ns 269.4 ns +21.65%
fast_norm2mort_orders[14] 390.3 ns 331.9 ns +17.57%
fast_norm2mort_scalar 406.9 ns 348.6 ns +16.73%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/93-arrow-c-data (91d1869) with main (fee466b)2

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

  2. No successful run was found on main (9342705) during the generation of this report, so fee466b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@espg

espg commented Jul 1, 2026

Copy link
Copy Markdown
Owner Author

Chunked/stream input — from_arrow / import_c_array consume a single contiguous C-array (arrow_c_array); ChunkedArray/stream (arrow_c_stream) is out of scope (the #86 from_arrow path already covers chunked for the pandas side). Leave stream out, or add it?

add stream here too.

Coverage bot is complaining-- is this the stuff that's deferred to #92 ?

@espg espg removed the waiting label Jul 1, 2026
@espg

espg commented Jul 1, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Stream added (phase 4, commit phase 4 of issue #93). import_c_array / MortonIndexArray.from_arrow now accept a chunked column too: an object exposing __arrow_c_stream__. Since a chunked column's stream schema is the bare uint64 (extension) type — not a struct — the struct-oriented ArrowArrayStreamReader rejects it, so the new rust_mi_import_c_stream drives the C stream callbacks directly (pull schema once, get_next each chunk to the released end-marker, import each with the explicit uint64 type, concatenate). Verified round-tripping a pyarrow ChunkedArray and an arro3-core ChunkedArray (nulls preserved, no pyarrow on the arro3 path), plus empty and non-uint64 rejection.

On the coverage bot — no, it's not the #92 stuff. #92 is purely the no-pyarrow CI workflow leg; it has nothing to do with this red check. codecov/patch is flagging 23 lines, all in mortie/tests/test_arrow_cdata.py — specifically the TestArro3Interop methods, which skipif-skip in CI because arro3-core isn't a CI dependency (your local-only decision on #93). The actual source is fully covered: the codecov report shows mortie/arrow.py and mortie/morton_index.py both at <100.00%> patch. So it's covered source + intentionally-CI-skipped tests, not a real gap.

The wrinkle: codecov.yml already has ignore: ["mortie/tests/**/*", ...], but that glob isn't excluding files sitting directly under mortie/tests/ from patch coverage — that's why test_arrow_cdata.py still shows in the diff report and drags the patch to 85.35%. A one-line fix ("mortie/tests/**/*""mortie/tests", or add "mortie/tests/*") makes the existing intent actually work and greens the check. Since that's a change to CI config, I didn't touch it unilaterally (§1) — want me to apply that one-line codecov.yml fix, or would you rather handle it? (The required gates — test (3.10/3.11/3.12), ruff, wheel builds — are all green; codecov/patch is the only red, and it's informational.)

Everything else (the required test/lint/build checks) is green on the phase-4 commit.


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Fresh-eyes adversarial review of the phase-4 diff (0b2ae20..91d1869) — Arrow C stream import for morton_index. No blocking findings; no CLAUDE.md violations. Verified against arrow-array 53.4.1 source and by building + running the suite locally (maturin develop --release; test_arrow_cdata.py → 18 passed, 5 skipped [arro3/pandas legs, gated as designed]).

What I checked and confirmed sound:

  1. Unsafe C-stream FFI (rust_mi_import_c_stream) — the ptr::replace(..., empty()) move-out, the Drop-based release of both stream and schema on every exit path (including error/early returns), the is_released() end-of-stream check, the pre-initialized ffi_array making the get_next-error partial state a safe no-op-drop, and from_ffi_and_data_type(..., UInt64) honoring chunk offsets. No leak, no double-free. (inline)
  2. Dispatch order in import_c_array — for pyarrow, Array exposes only __arrow_c_array__ and ChunkedArray only __arrow_c_stream__ (verified), so array-before-stream is correct and the documented "prefer contiguous" tie-break is sensible for any object exposing both. No wrong-path bug.
  3. Null/sentinel + offset across chunks — preserved via append_words's logical accessors; cross-boundary nulls and sliced-chunk offsets are both exercised by the tests (inline).
  4. Extension-type schema gateDataType::try_from(&schema) yields UInt64 for an extension-tagged uint64 stream, so the type check behaves as the comment claims.
  5. CLAUDE.md conformancearrow_ffi.rs is 224 lines (well under ~1000); commit phase 4 of issue #93 is terse/title-only per repo style; no new dependency (arrow/ffi_stream pre-approved); docstrings match the surrounding freeform style; behavioral change has tests.

Two non-blocking notes left inline: (a) the new fn re-triggers the file's pre-existing clippy useless_conversion nit on PyResult<Py<PyAny>> (consistent with rust_mi_import_c_array, not newly introduced); (b) the pandas-facing chunked from_arrow/isna() test is skip-only without pandas (covered in CI extra; numpy-only chunked path carries coverage otherwise).


Generated by Claude Code

Comment thread src_rust/src/arrow_ffi.rs
Ok(numpy::PyArray1::from_vec_bound(py, words)
.into_any()
.unbind())
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Verified the unsafe C-stream FFI against arrow-array 53.4.1 and it holds up:

  • ptr::replace(ptr, FFI_ArrowArrayStream::empty()) moves the stream out and leaves a release: None stub, so the producer's capsule destructor is a no-op — no double-free. The owned local stream drops through impl Drop (which calls release) on every exit path, including the rc != 0 and dt != UInt64 early returns, so the producer stream is always released.
  • FFI_ArrowSchema (local schema) has its own Drop-based release, so no schema leak either.
  • End-of-stream via ffi_array.is_released() (== release.is_none()) matches the C Data Interface contract; ffi_array is pre-initialized to empty(), so on a get_next error (contract leaves out untouched) the local is a valid no-op-drop struct — safe partial state.
  • from_ffi_and_data_type(ffi_array, DataType::UInt64) consumes by value and honors array.offset(), so a sliced chunk (non-zero offset) imports correctly; append_words then uses logical accessors, preserving null→sentinel per chunk.
  • DataType::try_from(&schema) returns the storage type (ignoring ARROW:extension:* field metadata), so the extension-typed uint64 stream passes the type gate as the doc comment claims.

No blocking issue here. One nit: this fn re-triggers clippy useless_conversion on the PyResult<Py<PyAny>> return (same as the pre-existing rust_mi_import_c_array). It's a consistent pre-existing pattern across the file, so not introduced here — flagging only so the file's clippy noise is a known quantity.


Generated by Claude Code

mia = mortie.MortonIndexArray.from_arrow(ca)
np.testing.assert_array_equal(mia._data, words)
np.testing.assert_array_equal(mia.isna(), words == SENTINEL)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Coverage is good and I confirmed it locally (18 passed / 5 skipped, pyarrow leg green):

  • test_import_chunked_stream chunks [:3],[3:5],[5:] over _words_with_gap() (sentinels at idx 2 and 5), so nulls land at a chunk-end (idx 2) and a chunk-start (idx 5) — the cross-boundary null case is exercised.
  • Because pyarrow slices are zero-copy, arr[3:5]/arr[5:] carry non-zero offset, so the per-chunk offset path is covered too (I verified slice().offset != 0).
  • Empty stream and non-uint64 rejection are both pinned.

One gap worth noting: test_from_arrow_chunked is the only thing exercising the pandas-facing MortonIndexArray.from_arrow(chunked) + isna() over stream data, and it importorskip("pandas")s away. That's fine for local runs (it runs under the CI pandas extra), but it means the chunked→isna() assertion has no coverage in a pandas-absent env — the numpy-only import_c_array chunked path (test_import_chunked_stream) does carry the load there, so this is a note, not a request.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

make the morton-index Arrow surface library-agnostic (arro3-core / PyCapsule + extension type)

1 participant