Skip to content

mortie.arrow.from_wkbs: the pyarrow skin over the WKB batch (issue #163) - #167

Merged
espg merged 21 commits into
mainfrom
claude/163-arrow-from-wkbs
Aug 8, 2026
Merged

mortie.arrow.from_wkbs: the pyarrow skin over the WKB batch (issue #163)#167
espg merged 21 commits into
mainfrom
claude/163-arrow-from-wkbs

Conversation

@espg

@espg espg commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #163

Independently mergeable. #158 has landed on main, so the core from_wkbs
this skin calls is now on the base branch and there is no merge ordering left to
respect. The base is main and the diff is this PR's work only:
mortie/arrow.py, mortie/tests/test_arrow_wkb_batch.py, and narrow doc /
docstring edits in mortie/geometry.py, docs/api/arrow.md and
docs/coverage_methods.md.

Historical, so the merge commits in this branch's history read correctly: this
PR was stacked on claude/157-rust-wkb while #158 was open, and picked the
base up by merging it (never rebasing — this repo forbids force pushes). 06b2d78
is the final such merge, of main after #158 landed.

What

mortie.arrow.from_wkbs(column, order=..., ...) — the pyarrow skin over the WKB
batch. A geoparquet / STAC geometry column goes in as it comes off the file
(binary or large_binary, chunked or not, sliced or not) and the core's ragged
(values, out_offsets) pair comes back, every scalar parameter forwarded
unchanged. It is #154's core/skin split: lazily imported and pyarrow-gated
exactly like mortie.arrow.polygons_to_morton_mocs, so a numpy-only install is
untouched and the arro3-no-pyarrow leg is unaffected (that leg runs only
test_arrow_cdata.py, and nothing added here imports pyarrow at module scope).

This is a correctness change, not a performance one. As the
rescope comment
recorded, the memory argument evaporated when #158's phase-3 fold byte-capped the
chunk: arrow buffer slices already measure 1.07× there. What is left — and what
the follow-up demonstrated
— is that hand-rolling the extraction is a footgun with four traps, each of which
returns wrong data or a wrong diagnosis rather than an error.

Approach

One private helper, _wkb_blobs_from_arrow, turns a column into one zero-copy
memoryview per row and hands that list to the core. There is no second
assembly path and no second memory posture: the core's byte-capped chunk loop
(2048 blobs / 64 MiB, e6621f4 + 2774d82) does all the work, so the peak bound
and the fail-fast ordering are the core's, unchanged.

lo = offsets[chunk.offset:chunk.offset + n]
hi = offsets[chunk.offset + 1:chunk.offset + n + 1]
blobs.extend(values[a:b] for a, b in zip(lo, hi))

The four traps, and where each is handled

# trap why it is silent handled by
1 ChunkedArray has no .buffers() — and that is the default shape, since a parquet column reads back chunked (the ATL03 column comes back as 90 chunks) hasattr(col, "buffers") is False; the recipe's first line simply does not exist walk col.chunks in order. Not combine_chunks(), which copies the whole column and defeats the point
2 a sliced array's buffers() are the original array's slice()/take are zero-copy metadata — pa.array([b"AAA",b"BBBB",b"CCCCC",b"DDDDDD"]).slice(2,2) reads back as [b"AAA", b"BBBB"] with no error. This is live for englacial/zagg: filter_bbox goes through table.take index offsets at chunk.offset + i, per chunk (each chunk carries its own offset)
3 large_binary offsets are int64, plain binary's are int32 reading the same buffer at the wrong width yields a different, non-erroring set of spans — the first blob still looks plausible branch on pa.types.is_large_binary
4 a null spans zero bytes[b"AA", None, b"BBB"] has offsets [0, 2, 2, 5] it arrives as an empty blob, not as an absent geometry refuse, naming the index (see below)

Fail-fast frame

Indices are the logical column's, always. The skin flattens the chunks into
one sequence in column order before the core sees them, so a malformed blob in
the third arrow chunk reports its column index, and the Rust chunk loop (which
chunks on its own, unrelated boundaries) does not renumber it either. A slice is
its own column: an offender at logical row 3 of a slice reports blob 3.

The null decision — refuse, with the index named

ValueError: blob 23: null entry in WKB column; a null is the absence of a geometry, not an empty one, and covering nothing is not a cover. This follows
the issue's recommendation and the core's fail-fast posture, and matches how
_ragged_from_arrow already treats a null polygon (polygon 3: null polygon in batch).

Honest caveat on this one. Unlike traps 1–3, trap 4 is not a silent-wrong-data
bug in the WKB context: a zero-byte blob cannot parse, so without the guard the
core raises blob 23: truncated WKB: 1 more byte(s) needed at offset 0, 0 remain
— the right index, but a misleading diagnosis (a corrupt geometry, when what
happened is that the row had no geometry). The guard converts a wrong diagnosis
into the right one. The silent-empty-geometry framing in the issue holds for
consumers doing something other than parsing; it does not hold here, and the PR
should not claim it does.

Testing

mortie/tests/test_arrow_wkb_batch.py (534 lines, 83 tests, all green;
full suite 1334 passed, 16 skipped on the merged tip).

Each trap has a test that fails without its guard — verified by reverting the
guard locally, watching the test fail, and restoring:

trap reverted tests that fail
ChunkedArray branch removed test_a_chunked_column_has_no_buffers_and_is_walked_chunk_by_chunk
chunk.offset0 test_a_sliced_column_reads_its_own_rows_not_the_first_ones, test_a_sliced_and_chunked_column_offsets_every_chunk, test_a_malformed_blob_in_a_sliced_column_uses_the_slice_frame
offsets always int32 18 tests, incl. test_large_binary_offsets_are_read_as_int64 and every large_binary dialect case
null check removed test_a_null_spans_zero_bytes_and_is_refused_by_index, test_a_null_is_named_in_the_column_frame_not_the_chunk_frame, test_a_null_in_a_sliced_chunk_is_named_by_its_logical_index

The trap tests pin the trap itself as well as the fix, so they stay honest if
pyarrow changes: assert not hasattr(chunked, "buffers"), the naive offset-blind
recipe asserted to yield [b"AAA", b"BBBB"], int64 vs int32 reads asserted
unequal, and the [0, 2, 2, 5] null offsets asserted directly.

Shape matrix: BinaryArray, LargeBinaryArray, ChunkedArray (including a
real parquet round-trip, which is where the chunked shape comes from), sliced,
sliced-and-chunked (each chunk with its own offset), zero-length chunks
interleaved between populated ones, single-element, and five spellings of empty
(binary, large_binary, one empty chunk, zero chunks, a zero-length slice).

Parity: per-blob against the scalar from_wkb(..., moc=True); whole-column
against the core fed the same blobs. Plus the dialect fixtures the ATL03 corpus
lacks — holes, multipart, antimeridian, south and north pole, Z, M — each ×
both byte orders × binary/large_binary (32 cases), and a mixed-endianness +
EWKB/SRID column.

Forwarding: order (checked at both range ends), tolerance, max_cells
(including the budget warning still naming blob 0), and normalize — the last
asserted to actually change the cover, so the parameter cannot be dropped and
still pass.

Refusals: non-binary Arrow columns by type (string, int64,
fixed_size_binary, chunked double) and non-Arrow inputs by name.

Real-corpus parity

The 555,867-blob ATL03 v007 WKB column
(data/atl03_v007/atl03_v007_full.parquet, 276.7 MiB of WKB, 90 chunks), at
order 6: the skin's values and offsets are byte-identical to the core fed
column.to_pylist()sha256(values)[:16] = f17ca97bba7fe963,
sha256(offsets)[:16] = a15cfc3a6ca66a31, identical across all three paths
(skin / to_pylist / the hand-rolled offset-corrected memoryview recipe).

Memory — measured, and the headline claim confirmed with a correction

The claim to check was: this removes the ~305 MB Python-bytes term
(englacial/zagg#408) while holding the core's ~1.07× peak.

Confirmed on the first half, with a term the claim omitted. Two independent
measurements agree; the second (adversarial review, same ATL03 column, order 6)
is the one quoted here because it is the tighter method — tracemalloc off, and
peak sampled rather than read off ru_maxrss:

stage to_pylist() mortie.arrow.from_wkbs offset-corrected memoryview recipe
building the blob sequence +306.6 MiB +107.9 MiB +106.4 MiB
end-to-end, from the loaded column 487.1 MiB 286.9 MiB
  • The 306.6 MiB to_pylist() term is the ~305 MB / ~322 MB the issue and
    Rust WKB reader: backend-free geometry ingest, plus from_wkbs (issue #157) #158's own docstring name (276.7 MiB of payload + ~17.5 MiB of bytes
    headers + 4.4 MiB of list). It is gone.
  • What replaces it is 107.9 MiB, and that is not zero — it decomposes
    exactly: sys.getsizeof(memoryview) = 184 B × 555,867 = 97.5 MiB, plus
    4.5 MiB of list. The issue's rescope comment already flagged these as "cheap
    each, not free".
  • Net saving 198.7 MiB−64.8% of the to_pylist term and −41%
    end-to-end
    (487.1 → 286.9 MiB). Anyone quoting "removes 305 MB" should
    quote "removes 305 MB and adds back 108 MB" instead.
  • The hand-rolled offset-corrected recipe lands at 106.4 MiB, i.e. the skin
    costs ~1.5 MiB over doing it by hand correctly — that is the price of the
    four guards, and it is noise against 198.7 MiB.

The cover-phase multiplier is 1.07×, not the 1.10× first reported here. A
5 ms RSS sampler gives 1.07× for the skin, to_pylist and the recipe alike
— the same figure the core publishes for arrow buffer slices, so the chunk
bound is intact and the earlier 1.10× was measurement noise. The sampler also
confirms the "0.62× is an artifact" diagnosis: ru_maxrss is a process-lifetime
high-water mark and simply cannot see growth underneath an earlier peak, where
the sampler can.

Methodology note, worth keeping. The review's first pass read +363.6 /
+164.9 MiB for the two paths — inflated because tracemalloc's own
bookkeeping
added ~57 MiB over 555,867 traced allocations. Measure this column
with tracing off; the numbers above are all tracing-off.

Bench — no throughput win, and that is expected

Same column, order 6, five repeats for the build step:

build the sequence full call (3 runs)
to_pylist() → core 0.24 s 7.1 / 8.8 / 9.7 s
mortie.arrow.from_wkbs 0.20 s 7.3 / 9.3 / 12.1 s

The build step is ~17% faster and is ~3% of the call; the cover dominates and is
byte-for-byte the same work. Read honestly: there is no speed win here, and
the full-call spread is run-to-run noise on a loaded machine, not a regression.
The win is the 200 MiB and the four traps. As the issue's rescope states, the
per-chunk byte copy stays regardless — releasing the GIL needs owned bytes.

Docs

  • mortie.arrow.from_wkbs added to docs/api/arrow.md and described in
    docs/coverage_methods.md.
  • Phase 2 retargets the core's own Notes. 70b021d (on the base branch)
    ships a paragraph saying "mortie has no typed entry point for it yet (tracked
    as mortie.arrow.from_wkbs: arrow skin over the WKB batch, so a geoparquet column skips Python bytes objects #163) … Until then the correct call is
    from_wkbs(column.to_pylist(), ...)". That becomes false the moment this
    merges, so it now points at mortie.arrow.from_wkbs and keeps the four traps
    as the reason, with the ~322 MB / ~112 MB comparison in place of the
    one-sided figure.

Phases

  • Phase 1 — the skin. _wkb_blobs_from_arrow + mortie.arrow.from_wkbs,
    the four traps, the 70-test module, and the docs pages (5b48bd6)
  • Phase 2 — retarget the core's Notes at the skin now that it exists
    (0feac3b)
  • Review fold — the four adversarial-review findings (158d9ca,
    87b1d63, 5e7f823, 9ce19af, ad6794c)

Review fold

finding disposition
[medium] the "lowest-index" guarantee is false across failure classes documented, not restructured (5e7f823). The null pre-pass is one vectorised is_null() per chunk; making it per-blob to satisfy a flat claim would trade the vectorisation for a diagnostic nicety, and keeping both the null diagnosis and the flat ordering needs per-blob null flags inside rust_wkbs_coverage_mocs — a Rust signature change. mortie.arrow.from_wkbs now carries a Notes section stating the real contract (two ordered gates; a null preempts a lower-index malformed blob; within each class the lowest index wins), and two new tests pin the cross-class order and the within-class order so it cannot drift
[low] a zero-chunk ChunkedArray is never type-checked fixed (158d9ca): the declared type is checked on the column, before the chunk loop; the per-chunk check is gone. New test_a_non_binary_column_with_zero_chunks_is_refused_by_type, parametrized over float64 / int64 / string / binary(2) — the crossed case the two existing tests missed
[medium] the stated blocker for geoarrow support is false implemented (87b1d63, docs ad6794c) — see the rewritten open question (2) below
[low] three surfaces still call all four traps silent fixed (9ce19af) in mortie/geometry.py, mortie/arrow.py and the test module docstring, matching docs/coverage_methods.md's split: three silent, the fourth a wrong diagnosis

Un-stacking (06b2d78)

The branch picked its base up by merging throughout, never rebasing — the
fold commits' parent was already pushed, and rebasing published commits would
need a force push, which this repo forbids outright. Two such merges are in the
history: 5ab6d38 took the base tip (5ca54d4, #157 phase 4) while #158 was
open, and 06b2d78 takes main after #158 landed.

06b2d78 conflicted on six files, all of them the branch's pre-#158 copy of a
file #158 owns. Resolution rule: #158-owned files take main's version
wholesale
mortie/tests/test_wkb_basins.py, test_wkb_batch_memory.py and
test_wkb_reader.py (add/add) and mortie/__init__.py, none of which this PR
touches; mortie/geometry.py and docs/coverage_methods.md start from main
and re-apply only this PR's edits (0feac3b, 9ce19af, ad6794c) on top.
mortie/arrow.py and mortie/tests/test_arrow_wkb_batch.py are byte-identical
to what the branch had. git diff origin/main...HEAD --name-only is now exactly
docs/api/arrow.md, docs/coverage_methods.md, mortie/arrow.py,
mortie/geometry.py, mortie/tests/test_arrow_wkb_batch.py — no #158 content
in the diff, and src_rust/ is byte-identical to main.

Gates

Re-run on the merged tip 06b2d78: cargo fmt --check clean; cargo clippy
8 warnings, all pre-existingsrc_rust/ is byte-for-byte main's, so
none are new; cargo test --lib 317 passed, 1 ignored; pytest 1334
passed / 16 skipped
(the growth over the 1258 reported earlier is #158's
phases 4–5 arriving with main, not new tests here);
flake8 mortie --count --select=E9,F63,F7,F82 0, and the non-blocking
--max-line-length=88 pass reports nothing new (see below); numpydoc lint mortie/*.py clean. No Rust was changed by this PR.

The four trap guards and the geoarrow unwrap were re-checked explicitly after
the merge: the 34 tests covering ChunkedArray-has-no-.buffers(),
chunk.offset, large_binary/int64 offsets, null-refused-by-index and the
extension-storage unwrap all pass, as does the full 83-test module.

flake8 --max-line-length=88 (non-blocking) reports two findings in
mortie/arrow.py364:89 E501 and 65:1 C901 '_build_type' is too complex (11) — and both are already on main at the identical lines (0ad6f9f,
"fold review: document the batch entry points", issue #153). This PR adds zero
style findings. Left alone per the "don't fix unrelated pre-existing failures"
rule; happy to reflow the E501 if wanted.

Module sizes: mortie/arrow.py 555 → 770 (aim ~1,000, fine);
mortie/tests/test_arrow_wkb_batch.py 534 (new); mortie/geometry.py
1,661 → 1,664 (+3, all docstring prose) — see the question below.

Questions for review

  1. geometry.py is at 1,664 lines, well past this repo's ~1,000 aim. I did
    not push it there — it arrives that way from Rust WKB reader: backend-free geometry ingest, plus from_wkbs (issue #157) #158, now on main — and this
    PR changes it by +3 lines of docstring. Flagging rather than acting: does
    that want a split issue against Parse WKB in Rust: backend-free geometry ingest, plus the plural from_wkbs batch #157/Rust WKB reader: backend-free geometry ingest, plus from_wkbs (issue #157) #158, or is it already known?

  2. geoarrow-typed columns — resolved, and the original reason was wrong.
    untestable without adding a test dependency: pa.ExtensionType ships in
    pyarrow itself, so the fixture is a dozen lines and no new dependency. The
    review demonstrated the risk concretely — the ATL03 column carries
    ARROW:extension:name = geoarrow.wkb in its field metadata and reads back as
    plain binary only because nothing registered the extension, so the
    moment any transitive dependency pulls in geoarrow-pyarrow, the same file
    would have started raising TypeError at batch mortie coverage in ShardMap.build (issue #396) englacial/zagg#400.
    _wkb_blobs_from_arrow now unwraps an extension type whose storage is
    binary / large_binary to that storage, and refuses any other extension by
    name (... got the extension type geoarrow.point over int64). The detection
    uses pa.BaseExtensionType, so it catches pyarrow's own canonical extension
    types as well as the Python-registered ones. Tested with a locally defined
    pa.ExtensionType across the flat, chunked, zero-chunk, sliced, null and
    non-binary-storage cases (87b1d63).

    zagg adoption caveat, now moot on this branch. Before the unwrap,
    batch mortie coverage in ShardMap.build (issue #396) englacial/zagg#400 would have had to either pin the absence of a geoarrow
    registration or defensively call
    col.storage if hasattr(col.type, "storage_type") else col at the boundary.
    With 87b1d63 neither is needed: the same file covers the same whether or
    not geoarrow is installed.

  3. The 106.6 MiB of memoryview objects is the last removable term, and it
    cannot go without a Rust binding that takes (values_buffer, offsets)
    directly instead of Vec<Bound<PyAny>>rust_wkbs_coverage_mocs materializes
    every entry as a Python object regardless of how lazily the sequence produces
    them, so no Python-side cleverness helps. Worth a follow-up issue on mocs_to_orders: ragged batch moc_to_order — plus an API sweep for bulk-by-default operators #156's
    phase-5 roster, or is 200 MiB of the 307 enough?

  4. binary_view / large_binary_view (pyarrow ≥ 16) have a different physical
    layout and are refused by type here. Deliberate — a view array is a different
    extraction problem, and no producer in this stack emits one. Confirm that is
    the right line?

@espg espg added the implement label Aug 8, 2026
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.53%. Comparing base (e59da18) to head (06b2d78).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #167      +/-   ##
==========================================
+ Coverage   95.43%   95.53%   +0.10%     
==========================================
  Files          12       12              
  Lines        1644     1681      +37     
==========================================
+ Hits         1569     1606      +37     
  Misses         75       75              
Flag Coverage Δ
unittests 95.53% <100.00%> (+0.10%) ⬆️

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

Files with missing lines Coverage Δ
mortie/arrow.py 99.36% <100.00%> (+0.19%) ⬆️
mortie/geometry.py 95.63% <ø> (ø)

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 e59da18...06b2d78. Read the comment docs.

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

Comment thread mortie/arrow.py Outdated
ImportError
If pyarrow is not installed.
ValueError
Fail-fast naming the **lowest-index** offending blob, in the logical

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] The "lowest-index" guarantee stated here is false when a null and a malformed blob are both present: the null wins, whatever its index.

The null scan runs over the whole column inside _wkb_blobs_from_arrow, and that completes before the core parses blob 0 — so a null anywhere preempts a parse failure anywhere, including one at a lower index.

Reproduced on 0feac3b (w163 worktree, rebuilt with maturin develop --release, mortie.__file__ in the worktree):

b = corpus(30)
b[3] = truncated_blob()                       # bad blob at logical 3
col = pa.array(b[:20] + [None] + b[21:])      # null at logical 20

marrow.from_wkbs(col, order=6)
# ValueError: blob 20: null entry in WKB column; ...

core_from_wkbs(b[:20] + [b""] + b[21:], order=6)   # same rows, null as b""
# ValueError: blob 3: truncated WKB: 1 rings declared at offset 9 with only 0 bytes left

Same across chunks (bad at 3 in chunk 0, null at 25 in chunk 2 → blob 25). The reverse order is fine (null 3 + bad 25 → blob 3), and the within-class ordering is correct everywhere I probed — I re-ran test_the_rust_chunk_boundary_does_not_renumber_an_arrow_column and the sliced-frame test green.

So what actually holds is "the lowest-index null, else the lowest-index blob the core rejects" — nulls are a separate, earlier pass over the full column. Two cheap ways out: (1) say so in this Raises entry ("nulls are refused before any blob is parsed, so a null preempts a lower-index malformed blob"), or (2) drop the eager scan and let the core report, which costs the null diagnosis this PR exists to add. (1) matches the PR's own honest-caveat posture.

Nothing pins the cross-class order today, which is why it slipped: every null test contains only nulls, every malformed test only malformed blobs.

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

Confirmed and documented rather than restructured5e7f823.

Reproduced exactly as written (null at 20 → blob 20: null entry; the same rows with the null as b"" through the core → blob 3: truncated WKB). The vectorised pre-pass is the right implementation — is_null() is one call per chunk, and making it per-blob to satisfy a flat "lowest index wins" claim would trade the vectorisation for a diagnostic nicety. Restructuring is not cheap either: dropping the scan costs the null diagnosis this PR exists to add, and keeping the diagnosis and the flat ordering would need per-blob null flags handed into rust_wkbs_coverage_mocs, i.e. a Rust signature change — well outside this PR.

So the contract now says what actually holds, in a Notes section on mortie.arrow.from_wkbs, framed as the same two-gate shape the core's own from_wkbs Notes already use for its input-contract pre-pass:

Two ordered gates, as on mortie.from_wkbs itself: nulls are screened by a vectorised pre-pass over the whole column, and only then are the blobs parsed and covered. Each gate reports its own lowest-index offender, so a null preempts a malformed blob at a lower index — a null at row 20 is raised ahead of a truncated blob at row 3. Within each class the lowest index wins.

The Raises entry no longer claims "lowest-index" flatly and points at the Notes, and _wkb_blobs_from_arrow's Raises says the null scan is a whole-column pre-pass that fires before any blob is parsed.

Pinned so it cannot drift, which is the part that was missing:

  • test_a_null_preempts_a_lower_index_malformed_blob[flat|chunked] — your exact fixture (bad blob at 3, null at 20), asserting blob 20: null entry from the skin and blob 3: from the core on the same rows with the null spelled b"";
  • test_the_lowest_index_wins_within_each_failure_class — null 3 + bad 25 → blob 3: null entry; bad 3 + bad 25 → blob 3:.

docs/coverage_methods.md picked up the one-line version too (ad6794c).

Comment thread mortie/arrow.py Outdated
base = 0
for chunk in chunks:
large = pa.types.is_large_binary(chunk.type)
if not (large or pa.types.is_binary(chunk.type)):

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] A ChunkedArray with zero chunks is never type-checked, so a non-binary column is silently accepted and answered as empty instead of refused.

The type check lives inside the chunk loop, so it never runs when chunks is empty:

for t in (pa.float64(), pa.int64(), pa.string(), pa.binary(2)):
    marrow.from_wkbs(pa.chunked_array([], type=t), order=6)
# -> (array([], dtype=uint64), array([0]))  for all four; no TypeError

The one-chunk spellings do raise, so the two existing tests miss each other exactly here: test_a_non_binary_column_is_refused_by_type[chunked_double] uses one chunk, and test_an_empty_column_keeps_the_contract[no_chunks] uses the zero-chunk shape but only with pa.binary().

[low] because the input is degenerate and the answer is empty rather than wrong — but the documented contract is "refused by type", and a ChunkedArray carries .type whether or not it has chunks, so the check can be hoisted to the column and the per-chunk one dropped (chunks of a ChunkedArray are all its .type by construction).

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

Fixed — 158d9ca. Reproduced first: all four of pa.chunked_array([], type=t) for float64 / int64 / string / binary(2) came back (array([], dtype=uint64), array([0])) with no TypeError.

The check is hoisted to the column exactly as you suggest, and the per-chunk one is gone:

# Checked on the column, not per chunk: a ``ChunkedArray`` carries its
# ``.type`` with zero chunks (where a per-chunk check never runs and a
# non-binary column would be answered as empty), and every chunk is that
# type by construction.
large = pa.types.is_large_binary(column.type)
if not (large or pa.types.is_binary(column.type)):
    raise TypeError(...)

New test for the crossed case the two existing shapes missed — test_a_non_binary_column_with_zero_chunks_is_refused_by_type, parametrized over your four types, asserting len(column.chunks) == 0 and column.type == type_ first so the shape itself stays pinned. test_an_empty_column_keeps_the_contract[no_chunks] still passes on the binary spelling, and [chunked_double] still refuses.

One follow-on the hoist made free: test_a_chunked_geoarrow_extension_column_is_unwrapped now also covers a zero-chunk extension column, which type-checks off the column's .type for the same reason.

Comment thread mortie/arrow.py Outdated
if not (large or pa.types.is_binary(chunk.type)):
raise TypeError(
"WKB column must hold binary or large_binary values; got "
f"{chunk.type}"

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] Open question (2): refusing an extension-over-binary column breaks this PR's own target column under a plausible install, and the stated blocker — "untestable without adding a test dependency" — is false.

The ATL03 column carries ARROW:extension:name = geoarrow.wkb in its field metadata (confirmed reading data/atl03_v007/atl03_v007_full.parquet directly). It arrives as plain binary only because nothing in the process registered the extension. The moment anything imports geoarrow-pyarrow — a normal thing for a geoparquet consumer to end up with transitively — the same file reads back as an ExtensionArray and this raise fires:

TypeError: WKB column must hold binary or large_binary values; got extension<geoarrow.wkb<...>>

i.e. englacial/zagg#400 breaks on an unrelated import, with a message that does not say what to do about it.

And it is testable with no new dependencypa.ExtensionType is in pyarrow itself:

class WkbType(pa.ExtensionType):
    def __init__(self):
        super().__init__(pa.binary(), "geoarrow.wkb")
    def __arrow_ext_serialize__(self):
        return b""
    @classmethod
    def __arrow_ext_deserialize__(cls, storage_type, serialized):
        return WkbType()

ext = pa.ExtensionArray.from_storage(WkbType(), pa.array(corpus(3)))
marrow.from_wkbs(ext.storage, order=6)   # verified == core(corpus(3), order=6)
marrow.from_wkbs(ext, order=6)           # TypeError today

That is the whole fixture, and I ran it on 0feac3b: .storage round-trips byte-identically through the skin, so the unwrap is provably correct rather than speculative.

Recommend unwrapping when pa.types.is_binary/large_binary(chunk.type.storage_type), with that fixture as the test. If it stays refused, the message should at least name the escape hatch (... ; unwrap a geoarrow extension column with .storage) so the failure is self-solving.

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

You are right on both halves, and the unwrap is implemented87b1d63 (docs in ad6794c). The stated blocker was false: pa.ExtensionType is pyarrow's own, so the fixture costs nothing, and open question (2) is rewritten in the PR body to record that rather than leave the wrong reason standing.

The unwrap, in _wkb_blobs_from_arrow alongside the hoisted type check:

col_type = column.type
extension = isinstance(col_type, pa.BaseExtensionType)
if extension:
    col_type = col_type.storage_type
    if not (pa.types.is_binary(col_type) or pa.types.is_large_binary(col_type)):
        raise TypeError(
            "WKB column must hold binary or large_binary values; got the "
            f"extension type {column.type.extension_name} over {col_type}"
        )

and chunk = chunk.storage at the top of the chunk loop, so a sliced extension array keeps its own offset (verified: ext.slice(4, 3).storage reports offset == 4, len == 3) and trap 2 survives the unwrap unchanged.

pa.BaseExtensionType rather than pa.ExtensionType deliberately: it catches C++-side canonical extension types (pa.uuid(), pa.json_()) as well as the Python-subclassed ones geoarrow-pyarrow registers, so a non-binary extension is refused by name in both worlds instead of falling through to chunk.buffers().

Tests — no new dependency, a module-level _WkbExtType(pa.ExtensionType) standing in for geoarrow.wkb:

  • test_a_geoarrow_extension_column_is_unwrapped_to_its_storage[binary|large_binary] — parity with the core, and byte-identical to from_wkbs(column.storage, ...);
  • test_a_chunked_geoarrow_extension_column_is_unwrapped — the shape a geoparquet read actually produces (chunked and typed), plus the zero-chunk extension column;
  • test_a_sliced_geoarrow_extension_column_reads_its_own_rows;
  • test_a_null_in_a_geoarrow_extension_column_is_still_refused_by_index — the null pre-pass is not lost to the unwrap;
  • test_an_extension_over_non_binary_storage_is_refused_by_its_namegeoarrow.point over int64TypeError: ... got the extension type geoarrow.point over int64.

The englacial/zagg#400 caveat is recorded in the PR body and is now moot on this branch: the same file covers the same whether or not geoarrow-pyarrow is registered.

Comment thread mortie/geometry.py Outdated
:func:`mortie.arrow.from_wkbs` (issue #163) — and that is what to call:
``marrow.from_wkbs(column, order=...)`` returns exactly this pair. Do
not improvise the extraction. Four traps sit between a column and its
blobs, each of which yields wrong data or silently-empty geometries

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] This rewritten paragraph still claims all four traps yield "wrong data or silently-empty geometries rather than an error" — the PR body's own honest caveat says trap 4 does not, and the code confirms it.

Measured on 0feac3b:

core_from_wkbs([blob, b"", blob], order=6)
# ValueError: blob 1: truncated WKB: 1 more byte(s) needed at offset 0, 0 remain

Identical with tolerance=0.5 and with max_cells=8, and the scalar from_wkb(b"", order=6, ...) raises the same for every moc / tolerance / max_cells combination I tried. A zero-byte blob can never become a cover, so in this function's contract a null-as-empty-blob is a misleading diagnosis, not a silent wrong answer — which is precisely what the PR body says the change should not claim.

Three shipped surfaces still claim it, and this one is the public docstring mkdocstrings renders:

  • mortie/geometry.py:725 (this line) — "yields wrong data or silently-empty geometries rather than an error", with the null trap listed at line 730 ("arrives as an empty blob instead of being refused");
  • mortie/arrow.py:430 — "The four ways a hand-rolled version of this goes silently wrong";
  • mortie/tests/test_arrow_wkb_batch.py:6 — "four separate traps, each of which yields wrong data with no error".

docs/coverage_methods.md already gets this right — it names only offset / chunk boundaries / offset width as the silent three and lists nulls separately. The same split works here: three traps silent, the fourth a wrong diagnosis. Worth fixing because this paragraph is the one phase 2 deliberately rewrote to be accurate post-merge.

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

Fixed on all three surfaces — 9ce19af — matching docs/coverage_methods.md's split (three silent, the fourth a wrong diagnosis).

mortie/geometry.py (this paragraph, the one mkdocstrings renders):

Four traps sit between a column and its blobs. Three are silent — they yield different, valid-looking data rather than an error: … The fourth is a wrong diagnosis: a null entry spans zero bytes, so it arrives as an empty blob and this function reports it as a truncated geometry rather than as a missing one.

mortie/arrow.py_wkb_blobs_from_arrow's list is now "the first three are silent … the fourth is a wrong diagnosis rather than wrong data", and from_wkbs's summary no longer sweeps the null into "different data with no error"; it names it separately as "an empty blob reported as a truncated geometry rather than as a missing one".

mortie/tests/test_arrow_wkb_batch.py:6 — "the first three of which yield wrong data with no error, the fourth a wrong diagnosis", with item 4 restated as "sails through as an empty blob and the core reports it as a truncated geometry, not as a missing one".

docs/coverage_methods.md was already right and is untouched on this point (ad6794c only adds the geoarrow and pre-pass sentences from the other two threads).

@codspeed-hq

codspeed-hq Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 71 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing claude/163-arrow-from-wkbs (06b2d78) with main (e59da18)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 (06b2d78) during the generation of this report, so e59da18 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@espg
espg marked this pull request as ready for review August 8, 2026 14:03
@espg espg added the waiting label Aug 8, 2026
@espg

espg commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Un-stacked and ready. PR #158 merged, its branch was deleted, and this PR is now based on main and independently mergeable — MERGEABLE, 16 checks green, only the ~27-minute CodSpeed Rust job outstanding (and src_rust/ here is byte-identical to main, so it has nothing new to measure).

The un-stack was done by merging main in (06b2d78), never rebasing — CLAUDE.md §1 forbids force-pushing outright, and a rebase would have required one. The acceptance check that matters:

$ git diff origin/main...HEAD --name-only
docs/api/arrow.md
docs/coverage_methods.md
mortie/arrow.py
mortie/geometry.py
mortie/tests/test_arrow_wkb_batch.py

Only this PR's work; no #158 content reintroduced. git diff origin/main -- src_rust/ Cargo.toml Cargo.lock is empty. That check mattered because the branch carried a pre-phase-4 copy of #158's test files — resolving the wrong way would have silently reverted the fix for the Linux ru_maxrss-survives-execve bug that had made those memory assertions vacuous on CI.

Resolution rule: #158-owned files took main wholesale; this PR's own files were untouched by the merge (verified byte-identical against pre-merge copies); the three shared files took main as the base with only this PR's specific edits re-applied on top.

One flagged trap turned out not to be one: main:mortie/geometry.py:724 still reads the old trap-count prose, so #158 never independently made the "three silent / one misleading diagnosis" correction — it exists once, from 9ce19af, applied once. Verified with git grep rather than assumed.

Post-merge verification: the four traps and the geoarrow .storage unwrap all still pass (34 selected tests; test_arrow_wkb_batch.py 83 passed), cargo test --lib 317 passed, pytest 1334 passed / 16 skipped, zero new clippy or flake8 findings.

@espg
espg merged commit 2f28a90 into main Aug 8, 2026
23 checks passed
@espg
espg deleted the claude/163-arrow-from-wkbs branch August 8, 2026 14:11
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.

mortie.arrow.from_wkbs: arrow skin over the WKB batch, so a geoparquet column skips Python bytes objects

1 participant