Skip to content

API consolidation: one polymorphic function per operation (issue #187) - #195

Merged
espg merged 46 commits into
mainfrom
claude/187-polymorphic-api
Aug 24, 2026
Merged

API consolidation: one polymorphic function per operation (issue #187)#195
espg merged 46 commits into
mainfrom
claude/187-polymorphic-api

Conversation

@espg

@espg espg commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Refs #187.

Collapses the scalar/batch pairs to one polymorphic function per operation, per the leans ruled on the issue thread (#187 (comment)): the un-suffixed singular name survives, the input shape selects the form, and the docstring says whether the function is batch vectorized.

What this does

The plural twins in mortie/batch.py are the kernels — they already carry the ragged validation, the GIL release and the rayon fan-out, and their per-item results are byte-identical to the scalar. So the collapse is a delegation, not a reimplementation.

Two shapes of pair, and they need different spellings:

(1) The item is itself an array (every MOC operation, and the toc reduce). A mortie MOC is a uint64 array, so the item and the column have the same rank and there is nothing for asarray coercion to discriminate on. These take a keyword-only offsets=:

# mortie/moc.py
def moc_to_order(morton, order, max_cells=_FLAT_COVER_WARN_THRESHOLD, *,
                 offsets=None):
    if offsets is not None:
        return mocs_to_orders(morton, offsets, order, max_cells)
    ...

offsets is exactly how the plural already spells the batch, so the collapsed signature is the scalar's signature plus the one argument that distinguishes the forms, both call sites keep their shapes, and the arrow list layout that polygons_to_morton_mocs emits and mocs_to_orders consumes stays zero-copy. Keyword-only, so it can never be reached positionally by code written against the current signature.

(2) The item is a genuine scalar (decimal_to_word, generate_morton_children). Here numpy semantics apply literally and the rank of the input is the discriminator — no new argument at all.

Phase 0 — inventory and classification (the P0 table)

Every scalar/plural pair on the public surface (mortie/__init__.py's __all__). In-repo reference counts only — this routine's GitHub access is scoped to espg/mortie, so the zagg/moczarr caller counts the P0 plan asked for are not in this table; they need a human pass or a wider scope.

operation surviving name retiring twin shape of the pair phase
densify MOC to a flat order moc_to_order mocs_to_orders ragged column (offsets) 1 ✅
MOC intersection moc_and mocs_and 1 × N broadcast (offsets) 1 ✅
MOC overlap predicate moc_intersects mocs_intersect 1 × N broadcast (offsets) 1 ✅
deepest common ancestor common_ancestor (alias moc_min) common_ancestors ragged column (offsets) 1 ✅
toc semilattice reduce toc_reduce tocs_reduce ragged column (offsets) 2 ✅
decimal-string parse decimal_to_word decimals_to_words true elementwise — numpy semantics literally 2 ✅
refine parents to children generate_morton_children children_of elementwise, dense (n, 4**d) result 2 ✅
WKB ingest from_wkb from_wkbs packed column (offsets=) / sequence — the ruled "(a+)" design, question (4) 4 ✅
polygon coverage MOC polygons_to_morton_mocs morton_coverage_moc ragged, batch-native signature — the plural survives here, per the ruling on question (3) of the plan 4 ✅

Not pairs, recorded so the table is exhaustive:

Phases

  • Phase 1 (fa0c559) — the P0 table + the ragged MOC family: moc_to_order, moc_and, moc_intersects, common_ancestor / moc_min.
  • Phase 2 (d9352f3) — the scalar-item pairs and the toc reduce: toc_reduce, decimal_to_word, generate_morton_children.
  • Phase 3 (725807c) — the docstring sweep: 68 docstrings across 13 files now state whether the function is batch vectorized, which is the explicit ask on the issue thread ("we don't have full parity with all functions … so we need to note in the docstrings if the function is batch vectorized or not"). Three markers: **Batch vectorized**: array in, array out, elementwise. (43), **Not batch vectorized**: one <thing> per call. (18, and the moc_or/moc_minus/moc_xor ones say there is no batch kernel behind them), and **Batch native**: reached polymorphically by … on the seven plural kernels. Two were deliberately left unmarked as ambiguous — see question (6).
  • Review fold (6cb2a16) — both adversarial reviews folded, including two blocking defects. Detail in API consolidation: one polymorphic function per operation (issue #187) #195 (comment).
  • Phase 4 (dd49f02) — the retirement and the from_wkb collapse, per the 2026-08-19 rulings (recorded under "Questions for review" below). The plural names are removed outright — no shims, no aliases: mocs_to_orders, mocs_and, mocs_intersect, common_ancestors, tocs_reduce, decimals_to_words, children_of, from_wkbs, and the scalar morton_coverage_moc (the batch-native polygons_to_morton_mocs survives as the MOC coverer's only entry point). The kernels live on as private functions (_mocs_to_orders, …) behind the polymorphic entry points. from_wkb collapses per the ruled "(a+)" design — offsets= ⇒ packed column (zero-copy memoryview slices into the caller's buffer, fed to the existing byte-capped chunking kernel), list/tuple/object-ndarray ⇒ sequence batch, buffer spellings ⇒ one blob; moc is the ruled tri-state (default None: scalar→flat, batch→MOC pair; explicit moc=False on a batch raises naming the missing ragged-flat kernel). mortie.arrow.from_wkbs renamed to mortie.arrow.from_wkb. Refusal messages that named a retired delegate now name the survivor (toc_reduce of an empty segment, generate_morton_children only refines) — rewritten at the Python delegation sites, no Rust change. Docs (docs/api/*, coverage_methods, benchmarks, index, morton_index_datatype, specification), README, USAGE, CHANGELOG (breaking, with a one-line-per-name migration table), both example notebooks, and the benchmarks/ scripts all swept; mkdocs build --strict green.
  • Phase 5 (2e09bd7) — the two ruled folds. norm2mort keeps a length-1 array an array: the form now follows the input rank (scalar out only when both operands are scalars), not the result's size. validate_morton is marked batch vectorized and its order check covers every element — it compared order against depths[0] alone, so a mixed-order array passed on the strength of its first element. Both pinned by mutation-checked tests; both in the CHANGELOG (the norm2mort shape change is a small break). The fold round extended the rank rule to mort2norm — the documented "exact inverse", which phase 5 had left on the old size rule, making the pair asymmetric.
  • Phase 6 (d99fb60) — scalar-return unification on np.uint64 (the phase-3 fold's standing item, espg-approved). The audit swept mortie.__all__, every non-__all__ public in the submodules, and every method/property on Moc / Toc / MortonIndexArray / MortonIndexScalar. Already np.uint64: norm2mort, common_ancestor / moc_min, decimal_to_word. Flipped from Python int: time2toc, span2toc, toc_merge, toc_reduce. Deliberately not unified, because they are not words: times in ns (toc2time, from_datetime64, from_gps_ns, to_gps_ns), HEALPix orders (infer_order_from_morton), UNIQ cell ids (geo2uniq, norm2uniq, unique2parent), and the explicit escapes decimal_to_word(dtype=int) / dtype=MortonIndexScalar / private _decimal_to_word; the toc set-algebra kernels (toc_normalize, toc_and) always return arrays, so they have no scalar to unify. MortonIndexScalar satisfies the unification rather than escaping it — it subclasses np.uint64 and overrides only the display dunders. Each flip is pinned by a type-asserting test verified to fail when reverted individually, and the exclusion boundary is pinned too. CHANGELOG entry as a 1.0-window break, with the uint64 arithmetic hazards spelled out.
  • Phase 7 (4edc7b2) — numpy>=2, per your ruling on question (9) below. NEP 50 is what makes phase 6's uint64 word semantics correct; below it a word's arithmetic promotes to float64, inexact above 2^53 while mortie words run near 2^62, and bitwise ufuncs against a Python int raise outright. The floor is declared in exactly two places and both now agree — pyproject.toml (with the reason in a comment) and binder/environment.yml (whose pinned mortie release has been numpy-2 compatible since 0.5.2, so the notebooks pay nothing). No CI workflow was touched: every job already installs numpy unpinned, which resolves to numpy 2 and so agrees with the new floor. The phase-6 docstring that had hedged across both numpy generations is simplified to the numpy-2 semantics — the package should not document behaviour it no longer supports. Three tests pin the property the floor exists for (arithmetic and bitwise ops stay uint64; a word near the top of the range survives +1 exactly where a float64 round-trip would not), so a downgrade fails loudly instead of silently rounding words. CHANGELOG entry in the same breaking register.
  • Review folds — every phase carried a fresh-context adversarial review and a separate fold round: phase 4 → 10 findings (77f9e793ffe81f), phase 5 → 8 findings (58c7c05f230413), phase 6 → 8 findings (71bcf0d28721fe). One commit per finding, a reply on every thread.
  • Merge forward (e0a940a) — origin/main gained Moc object: geometry-first coverage API (issue #196) #197 (the Moc object) and Toc object: temporal coverage composing like Moc (issue #198) #199 (the Toc object, which renamed mortie/toc.pymortie/_toc.py). Merged as a normal merge commit, four conflicts resolved keeping both sides: notably Toc object: temporal coverage composing like Moc (issue #198) #199's _TocNamespace deprecation shim rostered tocs_reduce, which this PR retires, so the name was dropped from the shim roster and a test added that a name this release retires is not shimmed.
  • Merge forward, second round (bd3480f) — origin/main gained ExampleUsage.ipynb cell 15 computes a wrong normalized address — silently wrong for base cells 0-3, raises for 8-11 #142 (the re-executed example notebook), Spec: normative section for the toc word grammar (frozen-for-1.x) #193 (the normative toc word grammar, spec §11) and workspace split: extract mortie-core (pure-Rust codec, no pyo3) from mortie_rustie #200 (the mortie-core crate split). One conflict (CHANGELOG.md), resolved keeping both sides' Unreleased entries. The retirement sweep re-run over what main added caught two reintroductions of retired plural names, fixed in the merge resolution: docs/specification.md §11.5 said "tocs_reduce refuses an empty group" — respelled onto the surviving batch form (toc_reduce(words, offsets=)) — and examples/toc_temporal_coverage.ipynb (which arrived with Toc object: temporal coverage composing like Moc (issue #198) #199 via the first merge-forward) still rostered tocs_reduce in its flat-API list; the retired name is dropped from the roster. A third round (9f569a9) followed within the hour: main cut the 0.9.11 release (bd3c0c3, the release bot's CHANGELOG/Cargo version sync), so the [0.9.11] heading is merged in with the branch's breaking entries staying under Unreleased — confirming the standing note that the next release after this merges must be 1.0. Gates re-run green on the result.

How it was tested

mortie/tests/test_polymorphic_api.py (32 tests) asserts, per operation, that the polymorphic form is byte-identical both to the plural sibling and to a Python loop over the single-item form, over a ragged fixture that includes an empty MOC and a single-cell MOC; that the bare call is exactly the old scalar; that offsets cannot be passed positionally; that moc_intersects(..., offsets=) agrees slot-for-slot with the non-empty slots of moc_and(..., offsets=); that the batch operands are not interchangeable; that decimal_to_word preserves input shape, keeps a bare str scalar, unwraps 0-d to a scalar, and refuses a MortonIndexScalar dtype on array input; that generate_morton_children gives (4**d,) for a scalar parent and (n, 4**d) for an array, refuses higher rank, and honours max_cells in both forms; plus the layout edges (plain-list offsets, single-group, zero-MOC column, non-monotone, max_cells=None). Every refusal is pinned to its actual message text, anchored.

Phases 4-6 added the retirement pins (every retired name gone from the package root, from __all__, and from its own module), the from_wkb dispatch rules (each of the three buckets, the moc tri-state including the batch moc=False refusal, byte-identity to both the old scalar and the old batch, the packed column's layout errors, and a delegation spy proving both batch forms reuse the byte-capped chunking kernel with every coverage knob forwarded), the phase-5 rank/every-element pins, and the phase-6 type pins.

Gates, on this branch at e0eba6b (re-run in full on the merged result):

Pre-existing hits left alone per §4 (all present on main, none touched by this diff): F841 on on_antimeridian in mortie/convert.py, F811 in mortie/morton_index.py, and two --doctest-modules failures (mortie/batch.py's +SKIP chain and mortie/linestring.py's cell count) — doctests are not run in CI.

One correction to an earlier version of this body: it claimed the "error surface passes through unchanged". That is false and the review caught it — with offsets the message text and the validation precedence both differ (order=99, max_cells=-1 raises different errors with and without offsets, because the batch screens layout in its own pass first). What is unchanged is that every refusal is still a catchable ValueError.

Questions for review

The five questions below were ruled by espg on 2026-08-19 (relayed off-thread); recorded here so the thread is self-contained:

  1. Retirement window — ruled (a): outright removal, targeting 1.0. Phase 4 removes the plural names with no deprecation shims and no aliases. Semver consequence: once this merges, main carries breaking removals, so the next release cut from main must be 1.0 — see the status comment for the release-ordering note.
  2. offsets= spelling — ruled (a): keep as implemented.
  3. generate_morton_children — ruled (a): numpy semantics as written; children_of retires with the plurals.
  4. from_wkb/from_wkbs collapse — ruled, the "(a+)" design: offsets= present ⇒ packed-column batch (uint8 values buffer + arrow list offsets, zero-copy); list/tuple/object-ndarray ⇒ sequence batch (each entry coerced as the scalar accepts); bytes/hex-str/bytearray/memoryview/uint8-ndarray-without-offsets ⇒ one blob. No batch= flag. moc=None tri-state: scalar defaults to flat cover (old from_wkb), batch defaults to MOC output (old from_wkbs, byte-identical including the chunked memory posture — pinned by delegation-spy and parity tests); explicit moc=False on a batch raises; moc=True works everywhere. mortie.batch.from_wkbs retires.
  5. norm2mort length-1 squeeze — ruled: fold here (phase 5). validate_morton — ruled (a) (phase 5). Scalar np.uint64 unification — approved (phase 6).

New questions from phase 4:

  1. RESOLVED — approved as landed (ruled 2026-08-24): the mortie.arrow.from_wkbsmortie.arrow.from_wkb rename stays; the downstream zagg migration is tracked as mortie 1.0 floor bump: migrate retired call sites (arrow from_wkbs rename; shardmap fallback off morton_coverage_moc) englacial/zagg#513. Lean of record: the consolidation's invariant is one name per operation across every skin; the polymorphic core already accepts a packed column under the singular name (via offsets=), so a pyarrow column going in under a different name would re-introduce exactly the singular/plural split this PR removes. The skin keeps no dispatch of its own — a column is inherently the batch shape — so the rename is a pure respelling.
  2. RESOLVED — defer, ruled (2026-08-24): no public array-in multipart-MOC spelling is added now; the thin scalar convenience over the batch remains the additive post-1.0 escape hatch if a consumer appears — none exists (zagg's fallback uses only the single-ring form, tracked for migration in mortie 1.0 floor bump: migrate retired call sites (arrow from_wkbs rename; shardmap fallback off morton_coverage_moc) englacial/zagg#513). Context of record: the public routes are polygons_to_morton_mocs (one MOC per ring, no holes), from_geometry/from_wkb/from_wkt with moc=True, and Moc (no order knob); the kernel survives privately (mortie.coverage._morton_coverage_moc) and the example notebook demonstrates the from_geometry(..., moc=True) route.
  3. RESOLVED — the ruled one-line hardening landed (ruled 2026-08-24; e0eba6b, on top of the review-fold guard affa852): from_wkb's moc refuses any value that is not None or a bool with a TypeError (the module's convention for type refusals) naming the parameter, the received value and type, and the positional-migration hazard it guards — a float landing in moc from a positionally-migrated from_wkbs(blobs, order, tol) call is loud, never a silent tolerance drop. The guard sits at the polymorphic entry point ahead of the dispatch, so all three forms (scalar, sequence batch, packed column) pass through it. The arrow skin does not flow through the entry point (it calls the batch kernel directly) but is safe by signature — it takes no moc at all, so the positional migration arrow.from_wkbs(col, order, tol)arrow.from_wkb(col, order, tol) binds tol to tolerance, exactly what it meant, and a stray moc= keyword is refused loudly by Python — pinned by test_the_skin_has_no_moc_slot_to_poison. Tests pin bool/None accepted in every form and float/str/int refused with the message text in scalar, sequence-batch and packed-column forms; the CHANGELOG line sits under the migration table. (The alternative — moc keyword-only — would have broken scalar from_wkb(blob, 8, True) callers, which the phase-4 ruling says survive as-is.)
  4. RESOLVED — the numpy floor is raised to numpy>=2 (ruled 2026-08-19, my lean (a) approved). Reasoning of record: the np.uint64 scalar unification is only correct under NEP 50; CI has only ever tested numpy 2 (every job installs it unpinned); so >=1.20 was an untested and known-wrong declaration, and 1.0 is the honest moment to state the floor the package actually supports. Landed as phase 7 (4edc7b2) — floor bumped in both places that declare it, the hedged numpy-1 prose deleted rather than maintained, and the NEP 50 property pinned by tests.
  5. RESOLVED — approved as-is (ruled 2026-08-24): kernel-focused suites keep importing the private kernels (_mocs_and, _from_wkbs, _morton_coverage_moc, …) rather than being rewritten onto the public spellings — the kernels are unchanged and those suites pin kernel behavior (layout errors, GIL release, memory posture); the public delegation surface is pinned separately in test_polymorphic_api.py (retirement pins, dispatch-rule tests, byte-identity to both kernels and scalar loops, the tri-state, and a delegation spy proving both batch forms reuse the chunked kernel).

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

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.43590% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.53%. Comparing base (feb374e) to head (9f569a9).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
mortie/morton_index.py 84.61% 2 Missing ⚠️
mortie/geometry.py 97.72% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #195      +/-   ##
==========================================
- Coverage   96.67%   96.53%   -0.14%     
==========================================
  Files          20       20              
  Lines        2283     2365      +82     
==========================================
+ Hits         2207     2283      +76     
- Misses         76       82       +6     
Flag Coverage Δ
unittests 96.53% <97.43%> (-0.14%) ⬇️

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

Files with missing lines Coverage Δ
mortie/__init__.py 91.17% <100.00%> (ø)
mortie/_moc.py 100.00% <100.00%> (ø)
mortie/_toc.py 100.00% <100.00%> (ø)
mortie/arrow.py 99.36% <100.00%> (ø)
mortie/batch.py 100.00% <100.00%> (ø)
mortie/buffer.py 90.90% <ø> (ø)
mortie/convert.py 98.25% <100.00%> (-0.70%) ⬇️
mortie/coverage.py 96.90% <100.00%> (ø)
mortie/linestring.py 96.15% <ø> (ø)
mortie/moc_object.py 98.76% <100.00%> (ø)
... and 7 more

... and 1 file with indirect coverage changes


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 bd3c0c3...9f569a9. Read the comment docs.

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

@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)

Adversarial review of fa0c559 ("phase 1 of issue #187"), fresh context, against CLAUDE.md and the ruling at #187 (comment).

No blocking correctness defect. The four delegations are wired correctly and I could not make the polymorphic form disagree with a loop over the scalar form on any value. The findings below are documentation claims that are false as written, and tests that do not discriminate what they claim to.

Everything below was run against a clean extraction of fa0c559 (git archive fa0c559 | tar -x into a scratch dir, with the built _rustie.abi3.so copied in), because the working tree already carries uncommitted phase-2 work and would not have reflected the reviewed commit.

Findings, ranked

# severity finding
(1) high — false doc moc.py:86-88 "The input shape selects the form — there is no separate plural entry point" — both halves false. A keyword selects the form, not the shape; and mocs_to_orders is still in __all__ (__init__.py:163) and still called by this PR's own test at test_polymorphic_api.py:51.
(2) high — false doc moc.py:439-440 "the message names the lowest-index offending group" — false. common_ancestor(v, offsets=[0,0,3,99])group 2: offset 99 exceeds value array length 6 while group 0 is empty. batch.py:697-703 carries the correct qualifier ("within its kind"; layout checked batch-wide first) and this docstring drops it.
(3) medium — test test_polymorphic_api.py:164 match="group 2|2:" pins nothing — the 2: arm matches any message containing those two characters, including ones naming no group. Same looseness at :75 (match="moc 0" also matches a layout error) and :133 (bare raises, no match).
(4) medium — false doc test_polymorphic_api.py:7-9 "the error surface passes through unchanged" — message text and validation precedence both change. moc_to_order(v, 99, max_cells=-1) raises the order error; adding offsets= makes it raise the max_cells error instead.
(5) medium — API/test gap moc_and/moc_intersects lose the scalar's commutativity in the offsets= form, and when len(a) == len(values) a swapped call silently returns a different answer rather than raising. No test pins which operand is the column.
(6) low — coverage offsets as list / single-group / offsets=[0] / non-monotone / max_cells=None with offsets= all untested (all verified working). The max_cells=None case is the one that would catch a delegation that dropped the forward. Non-integral float offsets truncate silently ([0., 7.9, 8.] partitions at 7). Keyword-only asserted for moc_to_order only.
(7) low — style §4 Function-local from .batch import … ×4 (:130, 207, 252, 459), no comment. I verified there is no cycle to break and no lazy-load benefit (__init__.py imports mortie.batch anyway). No deadlock risk.
(8) low — consistency batch.py still calls these four "the scalar (one MOC) form" (e.g. :435) and its module docstring still says "every scalar's docstring points back at its plural here". Stale after this diff — presumably phase 3's sweep, flagging so it is not lost.
(9) low — sequencing Within moc.py, four functions now carry "Batch vectorized (issue #187)" while moc_or, moc_minus, moc_xor, moc_not, compress_moc, split_base_cells carry nothing, so "unmarked" is ambiguous between not vectorized and not yet swept. Deferred to phase 3 per the PR body, which is reasonable — noting the intermediate state.

What I checked and found correct

  • Argument order into every kernel. mocs_to_orders(values, offsets, order, max_cells) (batch.py:350) vs the call at moc.py:131 — matches, including the order/max_cells transposition relative to the scalar signature. mocs_and(a, values, offsets), mocs_intersect(a, values, offsets), common_ancestors(values, offsets) likewise.
  • max_cells default propagation. Both defaults are _FLAT_COVER_WARN_THRESHOLD imported from coverage, and the value is genuinely forwarded — the budget-refusal test at :73 would fail if it were dropped.
  • moc_min = common_ancestor (moc.py:466) is a plain alias, so it does inherit the keyword: inspect.signature(mortie.moc_min)(morton, *, offsets=None).
  • No value-level disagreement. 500 randomized ragged columns (orders 1-5, 0-40 words, 1-5 groups, empty groups, empty shared covers, empty columns): moc_and, moc_intersects and moc_to_order in the offsets= form were byte-identical to a Python loop over the single-item form, 0 mismatches each. The moc_intersectsmoc_and agreement claim (moc.py:242-244) also held in all 500.
  • Are the tests discriminating? Mostly yes — swapping order/max_cells into mocs_to_orders, or wiring moc_and to a different kernel, both trip existing tests. The exceptions are the max_cells=None direction (6) and the operand order (5).

Gate claims in the PR body — verified

Re-run on the clean fa0c559 checkout:

  • pytest1557 passed, 16 skipped ✅ exactly as claimed.
  • New file collects 13 tests, all passing ✅ as claimed.
  • flake8 mortie --select=E9,F63,F7,F82 → clean ✅.
  • numpydoc lint mortie/moc.py → clean ✅ (offsets is documented on all four; PR01-PR09/RT01/SS01 pass).
  • mortie/moc.py:220 is 100 chars ✅ and is pre-existing docstring prose, correctly left alone per §4.

Unverified: the "baseline on main was 1544 passed, 16 skipped" claim — I did not run the suite on main. 1557 − 13 = 1544 is arithmetically consistent, but that is not an independent check.

Conventions

  • §4 module limit: moc.py 531 lines, batch.py 864 — both under ~1000. ✅
  • §4 dependencies: none added. ✅
  • §3 commit message: phase 1 of issue #187 — title only, matches the log. ✅
  • §7: the "Batch vectorized" note is in the Extended Summary rather than forced into the parameter table, which is what §7 asks for. ✅
  • The offsets= design is a real deviation from the ruled "numpy semantics exactly — input shape describes the batch". PR Question (2) puts it to review correctly; my only objection is finding (1), that the docstring asserts the ruled design while the code implements the alternative.

Generated by Claude Code

Comment thread mortie/moc.py Outdated
Comment thread mortie/moc.py Outdated
Comment thread mortie/tests/test_polymorphic_api.py Outdated
Comment thread mortie/tests/test_polymorphic_api.py Outdated
Comment thread mortie/_moc.py Outdated
Comment thread mortie/_moc.py Outdated
Comment thread mortie/tests/test_polymorphic_api.py Outdated
Comment on lines +130 to +133
def test_moc_and_offsets_rejects_offsets_not_covering_values(column):
values, offsets = column
shared = values[:2]
with pytest.raises(ValueError):

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 gaps for the offsets argument itself. I exercised each of these against fa0c559 and they all behave correctly — so these are missing tests, not bugs. Listing them here because this is the only test in the file that touches offsets validation, and it does so with a bare pytest.raises(ValueError):

input behavior I observed tested?
offsets as a Python list works on all four functions no
offsets=[0, n] (single group) works no
offsets=[0] (zero-MOC column) (array([], uint64), array([0])); moc_intersects → empty bool array no
offsets=[] ValueError: offsets must have at least one element no
non-monotone [0, 8, 4, 8] ValueError: moc 1: offsets must be monotonically non-decreasing (4 < 8) only via the bare raises here
max_cells=None with offsets= works no — see below

Two of these are worth adding specifically:

(1) max_cells=None with offsets=. test_moc_to_order_offsets_keeps_per_item_budget_refusal (:73) proves max_cells is forwarded in the raising direction, but nothing covers the None direction. A delegation that hardcoded the default instead of forwarding — mocs_to_orders(morton, offsets, order) — would still pass every test in this file except that one, and would then refuse a legitimate max_cells=None densify.

(2) Non-integral float offsets truncate silently. np.asarray(..., dtype=np.int64) in the kernel floors rather than raising, so the column is silently mis-partitioned:

mortie.moc_to_order(v8, 3, offsets=np.array([0., 7.9, 8.]))[1]
# array([ 0, 28, 32])   <- partitioned at 7, no warning

That is pre-existing kernel behavior, not introduced here — but this PR is what puts it behind the singular public name, so it is worth a decision (document it, or reject non-integral offsets) rather than leaving it undiscovered.

Also: keyword-only enforcement is asserted for moc_to_order only (:79). moc_and, moc_intersects and common_ancestor have the same * guard and no equivalent test — cheap to parametrize.


Generated by Claude Code

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

Mostly superseded in 6cb2a16; the one remaining gap is closed in cc80347, and one item stays standing for you.

Your coverage table — five of the six rows became tests in 6cb2a16, in a new "Layout and budget edges shared by every offsets form" section: test_offsets_accepts_a_plain_list, test_offsets_single_group_and_empty_column (covers both [0, n] and [0]), test_offsets_rejects_non_monotone_layout, and test_offsets_honours_max_cells_none.

Your (1), max_cells=None with offsets= — that was the sharpest one, since a delegation that hardcoded the default would have passed everything else in the file. It is now pinned by value, not just by not-raising:

got_v, _ = mortie.moc_to_order(values, 12, max_cells=None, offsets=offsets)
assert got_v.size > (1 << 20)

Keyword-only enforcement — this was the last open item, and it is fixed in cc80347. It was asserted for moc_to_order only, with a bare pytest.raises(TypeError). It is now parametrized across all five offsets= entry points and pinned to the message:

@pytest.mark.parametrize("call", [
    lambda v, o: mortie.moc_to_order(v, 4, None, o),
    lambda v, o: mortie.moc_and(v[:2], v, o),
    lambda v, o: mortie.moc_intersects(v[:2], v, o),
    lambda v, o: mortie.common_ancestor(v, o),
    lambda v, o: mortie.toc_reduce(v, o),
], ids=[...])
def test_offsets_is_keyword_only_on_every_form(column, call):
    with pytest.raises(TypeError, match=r"positional argument"):
        call(values, offsets)

Your (2), non-integral float offsets truncating silently — left as designed, and now documented rather than undiscovered. It is the batch family's np.asarray(..., dtype=np.int64) convention, so changing it for one function would split the family; 50f491b spelled it out on the offsets parameter of the packed-column form ("float offsets truncate toward zero"). Rejecting non-integral offsets across the family is an API decision rather than a diff-scoped fix, so that one stays standing for you — it is the "document it, or reject it" fork you named, and I only did the documenting half.


Generated by Claude Code

Comment thread mortie/_moc.py
Comment on lines +241 to +244
Without ``offsets``, ``True`` if the two covers share any area. With
``offsets``, a ``bool`` array of length ``len(offsets) - 1``, agreeing
item-for-item with the non-empty slots of the ``offsets`` form of
:func:`moc_and`.

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)

Verification note, no change requested — this is the one new claim in the diff I most expected to break, and it holds.

``offsets``, a ``bool`` array of length ``len(offsets) - 1``, agreeing
item-for-item with the non-empty slots of the ``offsets`` form of
:func:`moc_and`.

I fuzzed it over 500 randomized ragged columns against fa0c559 — orders 1-5, 0-40 words per column, 1-5 groups per column, including empty groups, empty shared covers, and empty columns — asserting moc_intersects(a, v, offsets=o) == (np.diff(out_offsets) > 0) where out_offsets comes from moc_and(a, v, offsets=o). 0 mismatches.

The same run also pinned the delegation itself: moc_and, moc_intersects and moc_to_order in the offsets= form were byte-identical to a Python loop over the single-item form in every one of the 500 trials (0 mismatches each). So I could not construct an input where the polymorphic form and the loop-over-scalar form disagree on values — the divergences I did find are confined to error messages and validation precedence (separate comment on the test module docstring).

One gap this leaves: test_moc_intersects_offsets_agrees_with_moc_and_slots (mortie/tests/test_polymorphic_api.py:122) exercises exactly one fixture, whose expected answer is [True, True, False, False]. That is discriminating enough for a wiring error, but the claim in this docstring is a general one — a handful of parametrized shapes (empty a, containment in both directions, a column that is entirely empty slots) would make the docstring and the test cover the same ground.


Generated by Claude Code

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

Acknowledged, no change made — you explicitly requested none, and the claim you fuzzed still holds at the current head.

Recording the disposition so this thread is not left silent:

  • The moc_intersects docstring claim (the bool array agrees item-for-item with the non-empty slots of moc_and's offsets form) is unchanged since your 500-trial run, and test_moc_intersects_offsets_agrees_with_moc_and_slots still asserts it against moc_and's output offsets. The delegation it rides on is unchanged too, beyond the phase-4 rename of the kernel to _mocs_intersect.
  • Your finding that the polymorphic form and the loop-over-scalar form never disagree on values is consistent with everything since: the divergences that did get folded (the separate thread on the test module docstring, fixed in cc80347) are confined to message text and validation precedence, exactly as you scoped them.

Left standing for you: the one gap you named — broadening test_moc_intersects_offsets_agrees_with_moc_and_slots from its single [True, True, False, False] fixture to a parametrized set (empty a, containment in both directions, an all-empty-slot column) so the test covers the same ground as the general docstring claim. That is a test-breadth addition rather than a defect, and this PR is complete and awaiting a merge decision, so I did not widen the diff for it. Say the word and it is a small follow-up.


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)

Adversarial review of d9352f3 ("phase 2 of issue #187") onlyfa0c559 is covered separately. Everything below was verified by running Python against the installed build, not read off the diff.

The delegation shape is right and matches phase 1: three thin branches, no reimplementation, kernels untouched. toc_reduce is clean. The two real defects are both in decimal_to_word, and both are places where the docstring promises something the code does not do.

Ranked findings

# severity finding
1 blocking decimal_to_word(..., dtype=MortonIndexScalar) with array input is silently accepted and returns a plain uint64 array. MortonIndexScalar subclasses np.uint64, so np.dtype(MortonIndexScalar) == np.uint64 is True and the new guard passes it. The Raises clause says it raises. The new test only tries dtype=int. → morton_index.py:161
2 blocking A 0-d string array returns a 0-d array, not a scalar — breaking the ruling on question (2) ("numpy semantics exactly (0-d/scalar in → scalar out)", #187 (comment)) and the docstring's own "numpy semantics literally". np.str_ works only because it subclasses str. → morton_index.py:124
3 non-blocking max_cells is silently ignored on the scalar form (max_cells=1 returned 1024 cells). The docstring's justification — "the scalar form's result is bounded by its one parent" — is false: order 3 → order 29 is 4**26 cells. → orders.py:400
4 non-blocking A 2-D parent array is silently flattened: (2, 2) in gives (4, 16), structure gone, no error. decimal_to_word in the same commit preserves shape and has a test for it. One commit, one "numpy semantics" banner, two answers about rank. → orders.py:431
5 non-blocking Docstring claim "passing an array used to silently describe only its first element"verified against main, and it is true only for target_order > parent_order. For target_order == parent_order the old code returned (1, n) containing all parents; the new form returns (n, 1). Please scope the sentence. → orders.py:390
6 non-blocking decimal_to_word(["12341", 5]) now silently parses 5 as the order-0 id "5" — a call that previously raised. Pre-existing hole in decimals_to_words (whose own comment states the opposite intent), newly reachable through this collapse. Also: the array-form ValueError names the offending string, not its index, though the docstring says "in input order". → morton_index.py:150
7 non-blocking Two new tests use bare pytest.raises(ValueError) where the documented claim is about the messagetest_toc_reduce_offsets_refuses_an_empty_group (the docstring promises the group is named; phase 1's sibling test used match=), and test_generate_morton_children_array_honours_max_cells. Both would pass on an unrelated ValueError. → tests :192, :260
8 nit Errors surface the delegate's name, not the called function's: decimal_to_word(None)"decimals_to_words expects…"; generate_morton_children(arr, 1)"children_of only refines…"; empty group → "tocs_reduce of an empty segment". If the plurals retire in phase 4, these messages will name functions that no longer exist.
9 nit Return-type divergence across the PR: singular toc_reduce → Python int, common_ancestornp.uint64, decimal_to_wordnp.uint64. Documented per-function, inherited rather than ruled. Worth a line under "Questions for review".

Missing edge cases (none covered today): 0-d array and np.str_ for decimal_to_word; dtype=MortonIndexScalar and the three accepted uint64 spellings for the array form; bytes input; empty-array shapes (decimal_to_word([])(0,), generate_morton_children([], k)(0, 1)); 2-D parents; max_cells with a scalar; the mixed-order refusal through the new path; offsets/max_cells keyword-only enforcement for the phase-2 functions (phase 1 pinned this for its own).

Discrimination check — the happy-path tests do hold up. Inverting if offsets is not None breaks the toc parity test; wiring decimal_to_word to the wrong kernel breaks its plural-and-loop assertion; dropping the max_cells forward makes the budget test fail; widening np.ndim(...) > 0 to >= 0 breaks test_generate_morton_children_scalar_stays_one_dimensional. The gaps are edges, not wiring.

Conventions (§3, §4, §7) — clean

  • numpydoc lint mortie/toc.py mortie/orders.py mortie/morton_index.pyno output, clean.
  • flake8 mortie --select=E9,F63,F7,F82 — clean (rc 0).
  • flake8 --max-line-length=88 on the four touched files — one hit, morton_index.py:346 F811 redefinition of unused '_pandas_ext'. Pre-existing: the same F811 is on main (at line 326 there). Flagging per §4, not asking for a fix in this PR.
  • pytest -q full suite — 1567 passed, 16 skipped (1544 baseline + 23 in this file).
  • Module sizes vs the ~1000-line limit: toc.py 765, batch.py 864, orders.py 442, morton_index.py 360 — all under, but batch.py is the one to watch before phase 3 adds to it.
  • Commit message phase 2 of issue #187 — title-only, matches §3 and recent git log. No new dependencies. No CI/workflow files touched.

One process note

The PR body's Phase 2 checklist lists four pairstoc_reduce, decimal_to_word, generate_morton_children, from_wkb — and this commit lands three. The checkbox is still unchecked, so I read that as deliberate rather than a miss; worth a one-line status note on the thread either way so the next run does not re-derive it.

For reproducibility: the working tree at review time carried uncommitted phase-3 edits across 13 modules. I diffed them — docstring-only additions, no behavioural change — so every result above is attributable to d9352f3. Line references are to the committed blobs at d9352f3.


Generated by Claude Code

Comment thread mortie/morton_index.py Outdated
Comment thread mortie/morton_index.py
Comment thread mortie/orders.py Outdated
Comment thread mortie/orders.py Outdated
Comment thread mortie/orders.py Outdated
Comment thread mortie/toc.py Outdated
Comment thread mortie/tests/test_polymorphic_api.py Outdated
Comment thread mortie/tests/test_polymorphic_api.py
Comment thread mortie/morton_index.py Outdated
Comment on lines +150 to +151
If ``s`` is a malformed decimal Morton id -- naming the first
offender, in input order, for the array form.

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)

Two smaller accuracy points on the widened Raises contract.

(1) "naming the first offender, in input order" — the message names the string, not its position. Verified:

decimal_to_word(np.array(["12341", "zzz", "9999"]))
  -> ValueError: malformed decimal Morton id 'zzz'

There is no index, so on a 10M-row array a caller cannot locate the offending row. Contrast the phase-1 wording elsewhere in this PR ("naming the lowest-index offender") and children_of's word 4217: … style, which do carry the index. Either soften the claim to "naming the first malformed id" or add the index in decimals_to_words. Nothing in the new tests asserts either way.

(2) A mixed list now silently coerces a non-string into an id, on a path that previously raised. Verified:

decimal_to_word(["12341", 5])
  -> array([1639310264362860548, 5764607523034234880], dtype=uint64)

The 5 became the order-0 id "5". decimals_to_words guards this only when the whole array coerces to a non-U/O dtype — its own comment at line 236-238 states the intent explicitly ("Do not let numpy's str-coercion silently turn e.g. the integer 1 into the order-0 id") — but numpy types ["12341", 5] as <U…, so the guard never fires. That is a pre-existing hole in the kernel, but this commit is what routes decimal_to_word (previously str-only, and a TypeError for this input) into it, so the widening is new here. A list/tuple element-type check alongside the existing object-array check would close it. Worth a test either way.


Generated by Claude Code

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

Your (1) is fixed in cc80347; your (2) still reproduces and is left standing for you, because closing it is an API narrowing rather than a diff-scoped fix.

(1) "naming the first offender, in input order". You were right that the message names the string, not its position, so a caller on a 10M-row array cannot locate the row. I took the soften-the-claim option rather than adding an index, since adding one changes the message text of a shipped kernel. decimal_to_word's Raises now reads:

ValueError
If s is a malformed decimal Morton id -- for the array form, naming the first malformed id in input order, not its index, so a wide array gives no row to look at.

That also brings the public docstring into line with the kernel's own, which already said "Naming the first malformed id, in input order".

(2) The mixed list. Re-verified at the current head — unchanged:

decimal_to_word(["12341", 5])
  -> array([1639310264362860548, 5764607523034234880], dtype=uint64)

The 5 still becomes the order-0 id "5". Your reading of the cause is right: _decimals_to_words' guard is dtype-driven, and numpy types ["12341", 5] as <U… rather than object, so the guard's own stated intent ("Do not let numpy's str-coercion silently turn e.g. the integer 1 into the order-0 id") is defeated for the mixed case — the object-array branch below it only catches dtype.kind == "O".

I did not fix it because the fix is to start rejecting input that is accepted today, on a public parse surface, which is a scope decision and not mine to take on a review comment (CLAUDE.md §6). The two shapes I can see are (a) reject when a U-dtype array came from a sequence containing non-strings, or (b) document the coercion. Standing for you, along with a note that this is pre-existing kernel behaviour rather than something the polymorphic collapse introduced — as you said, the PR is what puts it behind the singular public name.


Generated by Claude Code

@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Phase 3 landed (725807c) and both adversarial reviews are folded in as 6cb2a16. Finding by finding, including the two the phase-2 review called blocking — both were real.

Blocking, and both were true

dtype=MortonIndexScalar leaked through the array guard. MortonIndexScalar subclasses np.uint64, so np.dtype(MortonIndexScalar) == np.uint64 is True and my guard let it past — the call silently returned bare words while the docstring said it raises. Ruled out by identity before the dtype check now:

if isinstance(dtype, type) and issubclass(dtype, MortonIndexScalar):
    uint64_asked = False

A 0-d string array returned a 0-d array, not a scalar. That contradicts the ruling this PR is built on — "numpy semantics exactly (0-d/scalar in → scalar out)" (#187 (comment)) — and the docstring's own "numpy semantics literally". decimal_to_word now unwraps: return words if words.ndim else np.uint64(words), pinned by test_decimal_to_word_zero_dim_input_returns_a_scalar.

Also folded

  • Two false doc claims in mortie/moc.py. "The input shape selects the form — there is no separate plural entry point to keep in parity" was wrong twice over: a keyword selects the form, and mocs_to_orders is still exported (and still called by this PR's own tests). And "names the lowest-index offending group" is not what the kernel does — offsets=[0, 0, 3, 99] reports group 2 while group 0 is empty, because layout errors are screened in their own pass first. Both now say what actually happens, and the second borrows mortie/batch.py's existing "within its kind" qualifier that my wrapper had dropped.
  • max_cells was inert on scalar generate_morton_children, while the docstring justified that with "the scalar form's result is bounded by its one parent" — false, since one order-3 parent densifies to 4²⁶ children at order 29. Rather than document the hole, the scalar path now refuses pre-emptively on the same 4**d estimate the array path uses.
  • A 2-D parent array was silently flattened (2, 2)(4, 16), which contradicts the shape preservation decimal_to_word promises in the same PR. Higher rank now raises instead — a flattened result cannot be indexed back to its parents.
  • The "used to silently describe only its first element" claim was over-broad. The review checked it against main: true when target_order is finer than the parents' order, but at equal order the old code returned (1, n) of all the parents. The docstring now says exactly that.
  • Loose test pins. match="group 2|2:" passes on any message containing 2: — that arm pinned nothing. Every refusal assertion is now anchored to its actual text (r"^group 2: empty input has no common ancestor", r"^moc 0: moc_to_order would densify", and so on), and the three bare pytest.raises(ValueError) calls have matches.
  • The batch form is not commutative and nothing said so. moc_and/moc_intersects are commutative as two-cover operations, but with offsets the operands have different jobs — a is shared, b is the column — so a swap with a matching group count silently answers a different question. Documented on both, and test_moc_and_batch_operands_are_not_interchangeable pins that the two answers differ.
  • The four function-local from .batch import statements are gone — the review verified there is no cycle to break, and module-level is the house style in these files.
  • Missing layout/budget coverage added: plain-list offsets, a single-group column, a zero-MOC column (offsets=[0]), non-monotone offsets, and max_cells=None over a column.

Not acted on — raising instead

  • decimal_to_word(["12341", 5]) now parses 5 as the id "5" where the old scalar raised. That is decimals_to_words' existing contract, inherited by delegation rather than introduced here, so changing it would be a change to the plural's semantics — out of this PR's agreed scope (§6). Flagging it as a real, if narrow, behaviour change.
  • Refusals name the delegate, not the function called (tocs_reduce of an empty segment out of toc_reduce, children_of out of generate_morton_children). Cosmetic today; it becomes wrong once phase 4 retires those names, so it is worth fixing then rather than twice.
  • Scalar return types diverge across the packagetoc_reduce returns int, common_ancestor returns np.uint64. Pre-existing per-function convention, not something this PR introduced; say the word if you want it unified in phase 4.
  • mortie/moc.py is 553 lines and mortie/orders.py 496 after phase 3 — both well inside the ~1000-line guidance, so no split question arises here.

Gates after the fold

pytest 1576 passed, 16 skipped (baseline on main: 1544 / 16; +32 in mortie/tests/test_polymorphic_api.py). flake8 mortie --select=E9,F63,F7,F82 clean. numpydoc lint mortie/*.py clean. No Rust change in any phase, so no cargo gate applies.

Phases 1-3 are done. Phase 4 is genuinely blocked on the five questions in the PR body — chiefly (1) whether the retirement is a removal or a deprecation shim, and (4) how from_wkb/from_wkbs should collapse given that a uint8 buffer of one blob is both a buffer and a sequence. Applying waiting.


Generated by Claude Code

@espg espg added the waiting label Aug 17, 2026
@codspeed-hq

codspeed-hq Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will improve performance by 10.45%

⚠️ 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

⚡ 1 improved benchmark
✅ 70 untouched benchmarks
⏩ 1 skipped benchmark1

Performance Changes

Benchmark BASE HEAD Efficiency
coverage_triangle[4] 140.2 µs 126.9 µs +10.45%

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/187-polymorphic-api (9f569a9) with main (feb374e)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 (bd3c0c3) during the generation of this report, so feb374e was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@espg espg mentioned this pull request Aug 17, 2026
Comment thread mortie/geometry.py
# _wkb_bytes from this module at import time.
from .batch import _from_wkbs

return _from_wkbs(blobs, order=order, tolerance=tolerance,

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)

Blocking test gap — the batch delegation's parameter forwarding is not pinned by anything, and tolerance / max_cells are exactly the two the migration hazard corrupts.

The forward here is correct as written. But nothing in the suite would notice if it weren't. I dropped both parameters on the floor:

# mortie/geometry.py, _from_wkb_batch
     return _from_wkbs(blobs, order=order, tolerance=None,
                       max_cells=None, normalize=normalize,
                       latitude=latitude)

and ran the whole suite:

$ python -m pytest -q --no-cov -p no:cacheprovider
1695 passed, 16 skipped, 8024 warnings in 102.70s

Identical to the unmutated run (1695 passed, 16 skipped in 115.99s). No test passes tolerance= or max_cells= through either polymorphic batch form — grep over mortie/tests/ finds them only on marrow.from_wkb (test_arrow_wkb_batch.py:320,352, which reaches _from_wkbs directly, bypassing this function) and on the scalar from_wkb (test_wkb_no_backend.py:160-161). latitude is pinned (test_authalic.py:587) and normalize is pinned via the arrow skin, so those two are covered; tolerance and max_cells are not, in either the sequence form or the offsets= form.

The PR body says the batch is "byte-identical … pinned by delegation-spy and parity tests". The spy (test_polymorphic_api.py:537) takes **kwargs and asserts nothing about them, so it pins the route, not the payload.

Suggested pin, one test, both forms:

def test_from_wkb_batch_forwards_every_parameter(blobs):
    for kw in (dict(tolerance=2.0), dict(max_cells=8), dict(normalize=False),
               dict(latitude="geodetic-spherical")):
        want = _from_wkbs(blobs, order=7, **kw)
        for got in (mortie.from_wkb(blobs, order=7, **kw),
                    mortie.from_wkb(b"".join(blobs), order=7,
                                    offsets=np.cumsum([0] + [len(b) for b in blobs]),
                                    **kw)):
            np.testing.assert_array_equal(got[0], want[0])
            np.testing.assert_array_equal(got[1], want[1])

I verified that assertion passes on the real code (all four kwargs, both forms) — so it is a pure gain, not a redesign.

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 in 77f9e79 — the pin now exists and the mutation you ran fails it.

The spy captures kwargs (calls.append((list(entries), kwargs))) and asserts the exact forwarded dict for both batch forms, and two new tests were added next to it: test_from_wkb_batch_forwards_every_coverage_knob (your suggested parity test, tolerance / max_cells / normalize / latitude, sequence form and offsets= form, against _from_wkbs) and test_from_wkb_batch_coverage_knobs_bind_behaviourally (a behavioural pin: max_cells=8 and tolerance=2.0 produce a strictly smaller cover than the default, so the knobs cannot be inert even if the kernel parity ever became vacuous).

I reproduced your mutation to check it is now caught — with tolerance=None, max_cells=None hardcoded in _from_wkb_batch's forward:

$ python -m pytest -q --no-cov -p no:cacheprovider mortie/tests/test_polymorphic_api.py
FAILED ...::test_from_wkb_batch_routes_through_the_chunked_kernel
FAILED ...::test_from_wkb_batch_forwards_every_coverage_knob
FAILED ...::test_from_wkb_batch_coverage_knobs_bind_behaviourally
3 failed, 44 passed

Unmutated: 1699 passed, 16 skipped.

Comment thread mortie/geometry.py
_wkb_column_views(data, offsets), moc, order, normalize,
tolerance, max_cells, latitude,
)
if isinstance(data, (list, tuple)) or (

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)

Undocumented API narrowing: three input shapes from_wkbs accepted now raise TypeError — including a pandas Series, which this function's own docstring names as the motivating case.

The dispatch admits exactly list / tuple / object-ndarray. The retired from_wkbs had no dispatch at all — it just iterated — so it accepted any sequence or iterable. Measured, kernel vs. new entry point, same blobs:

  kernel  _from_wkbs(pandas Series): OK ((62,), [0, 26, 62])
  public  from_wkb(pandas Series)  : TypeError: WKB input must be bytes, a hex string, or a byte buffer; got Series

  kernel  _from_wkbs(S-dtype ndarray): OK ((62,), [0, 26, 62])
  public  from_wkb(S-dtype ndarray)  : TypeError: WKB input must be a buffer of bytes; got one of 77-byte items (format '77s')

  kernel  _from_wkbs(generator): OK ((62,), [0, 26, 62])
  public  from_wkb(generator)  : TypeError: WKB input must be bytes, a hex string, or a byte buffer; got generator

(list, tuple, object-ndarray, list of hex str, list of memoryview, and [] all still work identically — verified.)

Three things make this worth a decision rather than a shrug:

  1. The docstring claims the opposite. Line 502: "list / tuple / object-ndarray — a sequence batch: each entry is coerced exactly as the scalar form accepts (the pandas case)." A pd.Series of WKB is the pandas case (gdf.geometry.to_wkb() hands one back); .to_numpy() is required now and nothing says so.
  2. The ruling says both pre-existing call shapes survive as a pure rename. For Series / S-dtype / generator callers this is a break, not a rename.
  3. Nothing records it. The CHANGELOG row is from_wkbs(blobs, ...)from_wkb(blobs, ...) with no accept-list note.

The S/bytes_-dtype case is the sharpest: np.array(blobs) on a list of bytes produces dtype='S' by default, so the most obvious "make it an array" move lands in the error branch, and the message it gets ("must be a buffer of bytes; got one of 77-byte items") describes buffer geometry rather than telling the caller to pass a list or an object array.

Options, cheapest first: (a) widen the sequence branch to np.ndarray with dtype.kind in "OS" plus a collections.abc.Sequence/iterator fallback — restores the old accept-list; (b) keep the narrow rule but add the three shapes to the CHANGELOG and drop "(the pandas case)" from the docstring, or respell it "the pandas case, via .to_numpy()"; (c) keep the rule but make the scalar-path TypeError name the batch spellings when it sees something sequence-shaped. Either way the docstring and the CHANGELOG need to match whichever is chosen.

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

Option (b), folded in 9e06135 — the dispatch stays as ruled, the docs and the CHANGELOG now match it.

The narrowing is deliberate, not an oversight: espg's ruling 4 on this PR fixes from_wkb's dispatch as exactly offsets= → packed column, list / tuple / object-ndarray → sequence batch, buffer spellings → one blob, with no wider sniffing. Widening to dtype.kind in "OS" plus a Sequence/iterator fallback (your option (a)) is precisely the sniffing that ruling excludes, so it is not mine to take — it stays standing for @espg if the accept-list should be reopened.

What changed instead, so nobody discovers this at the call site:

  • from_wkb's docstring — the sequence bullet now reads "the pandas case, via series.to_numpy()", and a new paragraph after the three bullets states that they are the whole dispatch and names the three shapes (Series, generator, S-dtype) with the materializing spelling for each. The data parameter repeats it where a reader looking up the argument will see it.
  • CHANGELOG.md — the from_wkbs row now carries the note inline ("materialize anything else first: list(gen), series.to_numpy(), arr.astype(object)"), and the from_wkb note paragraph spells out that the dispatch is exhaustive by design and what that costs those three callers.
  • A test pins it as behaviour rather than prose: test_from_wkb_batch_dispatch_is_exhaustive asserts the S-dtype array and the generator raise TypeError, and that both documented materializations (.astype(object), list(...)) give byte-identical results to the list form.

I left the scalar-path TypeError text alone (your option (c)). It is shared with the single-blob path, where "must be a buffer of bytes; got one of 77-byte items" is the correct diagnosis; making it name the batch spellings would need a sequence-shape sniff at exactly the site the ruling closed. Your point that np.array(blobs) is the obvious wrong move stands — it is now the first thing the CHANGELOG row says.

Comment thread mortie/geometry.py
one blob.
"""
if moc is not None and not moc:
raise ValueError(

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)

Silent wrong answer on the positional migration the PR itself flags (question 8): the guard tests moc for truthiness, so a tolerance float bound to moc passes through and the tolerance is dropped without a word.

if moc is not None and not moc means every truthy object — a float, a string, an array — reads as "MOC output requested" and proceeds. So from_wkbs(blobs, order, tol) migrated positionally does not raise; it silently returns a different, finer cover:

tight = _from_wkbs([blob, blob2], 7, 2.0)       # old: from_wkbs(blobs, order, tolerance)
mig   = mortie.from_wkb([blob, blob2], 7, 2.0)  # naive positional migration
loose = _from_wkbs([blob, blob2], 7)            # no tolerance at all
  intended (tolerance=2.0) cells: 193
  migrated call            cells: 883
  tolerance dropped entirely: True | equals intended: False

4.6× the cells, no error, no warning. This is the one failure mode worse than a TypeError — and the CHANGELOG note ("tolerance= must be spelled as a keyword") is documentation, not a gate; the caller who reads it did not need it.

A one-line type guard closes it without touching the shape the ruling protects, because the surviving scalar calls all pass a bool:

    if moc is not None and not isinstance(moc, (bool, np.bool_)):
        raise TypeError(
            f"from_wkb's `moc` is a bool (tri-state with None), got "
            f"{type(moc).__name__} -- migrating a positional from_wkbs("
            f"blobs, order, tol) call? spell it tolerance=."
        )

I checked what this would cost: mortie.from_wkb(blob, 6, True) and from_wkb(blobs, order=6, moc=np.True_) both still pass, and the whole existing suite exercises moc only as True / False / unset. Placing it in from_wkb (not just _from_wkb_batch) also catches the scalar spelling from_wkb(blob, 8, 0.5), which today silently returns a MOC instead of a flat cover.

If a guard is judged out of scope for phase 4, then at minimum the error message the batch already raises should be reachable here — right now moc=2.0 is the one value that neither raises nor honours what the caller meant.

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 in affa852 — the guard is in, essentially as you wrote it, in from_wkb itself so it catches the scalar spelling too.

    if moc is not None and not isinstance(moc, (bool, np.bool_)):
        raise TypeError(
            "from_wkb's `moc` is a bool (tri-state with None), got "
            f"{type(moc).__name__} -- migrating a positional "
            "from_wkbs(blobs, order, tol) call?  from_wkb's third positional "
            "is moc; pass tolerance= as a keyword."
        )

from_wkb(blobs, 7, 2.0) now raises instead of quietly returning the 883-cell cover where 193 was asked for, and from_wkb(blob, 8, 0.5) — the scalar case you flagged as returning a MOC where a flat cover was meant — raises the same way.

Pinned by test_from_wkb_refuses_a_non_bool_moc: both forms raise on a float third positional; from_wkb(blob, 8, True) and from_wkb(blob, 8, np.True_) still agree byte-for-byte; moc=False still returns the flat 1-D cover; from_wkb(blobs, 8, np.True_) still returns the ragged pair. Full suite 1699 passed, 16 skipped — nothing in it was passing a non-bool moc.

Also documented: from_wkb's Raises gained the TypeError with the reason, and the CHANGELOG's positional-migration sentence now says the migration raises rather than only that tolerance= should be a keyword.

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

Ruled (2026-08-24, review question 8) and closed out in e0eba6b: the guard is the accepted design, hardened one notch — the message now names the received value and type (got 0.5 (float)), not just the type, and the pins are widened to what the ruling asked: float/str/int each refused with the message text in all three dispatch forms (scalar, sequence batch, packed column — the guard sits ahead of the dispatch, so one guard covers them), bool/None accepted everywhere, and the arrow skin covered by test_the_skin_has_no_moc_slot_to_poison — it bypasses this entry point but takes no moc at all, so arrow.from_wkbs(col, order, tol) migrated positionally binds tol to tolerance, which is what it meant. CHANGELOG line under the migration table updated to match.

Comment thread mortie/geometry.py Outdated
the endpoint that failed.
"""
try:
view = memoryview(data).cast("B")

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)

The documented TypeError never fires: memoryview(x).cast("B") accepts any C-contiguous buffer regardless of item size, so the packed-column path is laxer than the scalar path it claims to mirror.

The docstring above says:

TypeError — If data is not a contiguous buffer of one-byte items.

cast("B") only requires C-contiguity. A float64 or int32 array is silently reinterpreted byte-wise:

f = np.frombuffer(packed + b"\0" * (8 - len(packed) % 8), dtype=np.float64)
_wkb_column_views(f, [0, len(blob), f.nbytes])
mortie.from_wkb(f, offsets=[0, len(blob), f.nbytes], order=6)
mortie.from_wkb(f, order=6)          # the same object, scalar path
  _wkb_column_views(float64 array) -> ACCEPTED, 2 views, first 010300000001
  from_wkb(float64 buffer, offsets=...) -> OK 344 cells [0, 205, 344]
  from_wkb(int32 buffer,   offsets=...) -> OK 344 cells
  scalar from_wkb(float64 array): TypeError WKB input must be a buffer of bytes; got one of 8-byte items (format 'd')

So the same object is refused by name on the scalar path (_wkb_bytes checks itemsize) and accepted on the column path — the opposite of the "the layout checks mirror the batch family's offset validation" framing, and the accept-list widening is undocumented. In practice the reinterpretation usually ends in a confusing WKB parse error at some blob index rather than a clean "that is not a byte buffer", which is a worse diagnostic for the arrow-column callers this form exists for.

Fix is one line, and reuses the check the scalar path already has:

    try:
        view = memoryview(data)
        if view.itemsize != 1 or not view.c_contiguous:
            raise TypeError
        view = view.cast("B")
    except TypeError:
        raise TypeError(...) from None

Two smaller things in the same function while it is open:

  • OverflowError escapes the documented Raises. np.asarray(offsets, dtype=np.int64) on a Python int past int64 raises before any validation runs: mortie.from_wkb(packed, offsets=[0, 10**19, len(packed)])OverflowError: Python int too large to convert to C long. That contradicts the PR body's "every refusal is still a catchable ValueError". (The rest of the batch family has the same convention, so this may be a deliberate carry-over — but the docstring's Raises should say so.)
  • Float offsets are silently truncated. offsets=[0.0, 26.7, 77.0] is accepted as [0, 26, 77]. Same family convention, so probably fine by design; worth a word in the offsets parameter description since this form is fed by external buffers.

The rest of the validation I checked and it is right: offsets[0] != 0, monotonicity by lowest index, per-blob over-run, the exact-cover endpoint, offsets=[], offsets=[0] over empty data, a list passed as data (clean TypeError), and the zero-copy claim (views[0].obj is bufTrue).

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 in 50f491b — both halves.

_wkb_column_views now takes memoryview(data) first, refuses itemsize != 1 by name (mirroring _wkb_bytes' message shape), and only then casts to "B" for shape normalization:

>>> mortie.from_wkb(f64_buffer, order=6, offsets=[0, n0, f.nbytes])
TypeError: with offsets, the WKB input must be one packed, contiguous buffer of
bytes (the arrow binary-column layout); got one of 8-byte items (format 'd')
>>> ... int32 ...
... got one of 4-byte items (format 'i')

So the packed path and the scalar path now refuse the same object, and the arrow-column caller gets that instead of a WKB parse error at some blob index.

The int64 coercion is wrapped, so the documented "every refusal is a catchable ValueError" holds:

>>> mortie.from_wkb(packed, offsets=[0, 10**19, len(packed)], order=6)
ValueError: offsets must fit in int64 (arrow list offsets)

Tests in test_from_wkb_packed_column_layout_errors: float64 and int32 buffers both raise TypeError matching got one of \d+-byte items, and the past-int64 offset raises ValueError, not OverflowError.

On float offsets — left as designed (it is the batch family's np.asarray(..., dtype=np.int64) convention, and changing it here alone would split the family), but the offsets parameter now says so explicitly: "Coerced with np.asarray(..., dtype=np.int64) as the rest of the batch family is, so float offsets truncate toward zero." The Raises section gained the int64 ValueError and now says the TypeError is the same by-item-size refusal the scalar path makes.

Comment thread docs/coverage_methods.md Outdated
with the plural batch names), the MOC coverer's only entry point. The plural
*MOCs* is the contract: many→many, one MOC per input ring — as against the
many→**one** union of a multipart ring-set, which is covered through
`from_geometry` / `from_wkb` / `from_wkt` with `moc=True` (or `mortie.Moc`).

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)

This replacement list is not reachable from a numpy-only install, and the one route that is has no order knob — so "multipart/hole MOC at a chosen order" has no public spelling after the retirement.

The same sentence appears in CHANGELOG.md:27, USAGE.md:332 and mortie/batch.py. Enumerating the four routes it names, against what the retired morton_coverage_moc(rings_lats, rings_lons, order=8) did:

route multipart/holes order knob numpy-only
polygons_to_morton_mocs ✗ (one MOC per ring)
from_geometry(..., moc=True) ✗ needs shapely/spherely
from_wkt(..., moc=True) ✗ needs a backend
from_wkb(..., moc=True) if you already have a blob
mortie.Moc / Moc.from_polygon

Measured:

>>> mortie.Moc.from_polygon(rings_lats, rings_lons)   # numpy-only, but no order=
<495568 words>                                        # the order-18 default
>>> mortie.Moc.from_polygon(rings_lats, rings_lons, order=8)
TypeError: Moc.from_polygon() got an unexpected keyword argument 'order'
>>> mortie.polygons_to_morton_mocs(np.concatenate(rings_lats), np.concatenate(rings_lons), [0, 4, 8], order=8)[1]
[0, 339, 411]                                         # two separate MOCs, not a donut

The old one-line call produced 411 words; the surviving numpy-only route produces 495,568 — 1,200× larger, because it is a different order. So a numpy-only caller who wants a hole-carved MOC at order 8 has to hand-encode a WKB blob to get back what morton_coverage_moc gave directly.

That matters more here than it would elsewhere because this very page sells the numpy-only property two paragraphs down ("mortie parses the bytes itself, so no geometry backend is involved"), and the module docstring for from_wkb leads with "no backend needed".

The PR body raises the shape of this as question 7 but frames it as "no public array-in spelling", without the numpy-only angle or the Moc-has-no-order gap, and the docs present the list as a complete replacement. Two ways out, both small:

(a) add order= to Moc / Moc.from_polygon (it already forwards tolerance / max_cells / latitude to the same kernel, so this is a passthrough), which makes the numpy-only route complete and lets the docs stand as written; or
(b) take the ruled escape hatch — a thin public multipart wrapper over the batch — and keep morton_coverage_moc's ring-set signature under a new name.

If neither lands in this PR, the sentence should say plainly which routes need a geometry backend and that Moc covers at the default order only, so a reader does not discover it at the call site.

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

Prose corrected in e2e0270 (and the CHANGELOG half in 3ffe81f); the API gap itself stays standing for @espg.

Your options (a) order= on Moc and (b) a public multipart wrapper are both new public surface, which is a scope decision, not a review fold — and it is already the PR body's open question 7. So I did what the last paragraph of your comment asks: made the docs say plainly which route needs what, instead of presenting the list as a complete replacement.

docs/coverage_methods.md now enumerates the union routes as a list rather than a run-on:

  • from_wkb(blob, moc=True, order=...) — the numpy-only route, when the geometry is already WKB bytes.
  • from_geometry / from_wkt with moc=True — same result at a chosen order, but both need a geometry backend.
  • mortie.Moc / Moc.from_polygon — numpy-only, from GeoJSON or a list of ring arrays, but no order: it covers at the default finest order (tolerance / max_cells are its only knobs).

and then states the gap outright: "a numpy-only caller who holds ring arrays and wants a hole-carved MOC at a chosen order has no one-line spelling today — that gap is issue #187's open question 7."

The same correction went everywhere the claim repeats: USAGE.md (the polygons_to_morton_mocs paragraph), README.md:125 ("only from_wkb (bytes in) and Moc are backend-free, and Moc has no order knob"), mortie/batch.py's polygons_to_morton_mocs docstring (which named from_geometry first without saying it needs a backend), and CHANGELOG.md's note paragraph.

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

Ruled (2026-08-24, review question 7): defer — no public array-in multipart-MOC spelling is added now. The thin scalar convenience over the batch remains the additive post-1.0 escape hatch if a consumer appears; none exists today — zagg's fallback uses only the single-ring form, and its migration onto the surviving names is tracked as englacial/zagg#513. The kernel stays private (mortie.coverage._morton_coverage_moc), the documented routes stand as swept. Recorded as RESOLVED in the PR body.

Comment thread mortie/arrow.py
"""Batch MOC coverage over an Arrow WKB column (issue #163).

The Arrow skin of :func:`mortie.from_wkbs`: a geoparquet / STAC geometry
The Arrow skin of :func:`mortie.from_wkb`'s batch form (renamed from

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)

Docs sweep miss: the sibling function in this same file still names mortie.morton_coverage_moc twice, and it renders on the published API page.

from_wkb here was swept, but mortie.arrow.polygons_to_morton_mocs (lines 367 and 391) was not:

mortie/arrow.py:367:        caller (and covered with :func:`mortie.morton_coverage_moc`'s
mortie/arrow.py:391:        :func:`mortie.morton_coverage_moc` on that ring.  A

Both are in the public docstring of a member listed in docs/api/arrow.md, so they survive into the rendered site. I built the docs from this commit (mkdocs build --strict — green, as the PR body says) and grepped the output:

site/api/arrow/index.html:1378: caller (and covered with :func:<code>mortie.morton_coverage_moc</code>'s
site/api/arrow/index.html:1497: :func:<code>mortie.morton_coverage_moc</code> on that ring.  A

Line 367 is worse than a stale cross-reference — it is actionable advice pointing at a function that no longer exists ("decompose such a footprint yourself and cover it with morton_coverage_moc's list-of-rings form"). Line 391 is a byte-identity claim against a name a reader cannot call. mortie/batch.py got exactly this fix (its equivalent lines now say "the retired scalar morton_coverage_moc" / point at from_geometry); arrow.py did not.

--strict does not catch it because mkdocstrings renders :func: roles as literal text rather than resolving them, so a dangling reference is invisible to the build. Per the phase-4 ruling ("a plural surviving in rendered docs after removal is a defect"), this is one.

One more, non-rendered but same sweep: mortie/coverage.py:39 still says the threshold is "the pre-emptive refusal in mortie._moc.moc_to_order / mortie.batch.mocs_to_orders".

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 in b2fffc8 — both arrow.py sites and the coverage.py one.

arrow.py:367 was the worse of the two, as you say: actionable advice pointing at a function that no longer exists. It now points at the surviving multipart routes and notes the Moc caveat, which is the same treatment batch.py got:

caller (and, if the union is what is wanted, covered through a multipart route instead: mortie.from_wkb / mortie.from_geometry with moc=True, or mortie.Moc's list-of-rings form, which takes no order).

arrow.py:391's byte-identity claim is now made against a callable name: "byte-identical to mortie.polygons_to_morton_mocs on that ring alone (the identity the retired scalar morton_coverage_moc used to pin)" — the retired name survives only as history, never as a :func: role a reader might try to call.

coverage.py:39 now reads "mortie._moc.moc_to_order (and in its batch kernel, the private mortie.batch._mocs_to_orders)".

Rebuilt the docs to confirm: grep morton_coverage_moc site/api/arrow/index.html is down to the one "the retired scalar morton_coverage_moc" history mention plus its source listing, and grep -rn ":func:\mortie.morton_coverage_moc`" mortie/ docs/ *.md(excluding tests) is now empty repo-wide. Noted your point that--strict` cannot see this class of defect — the grep is what I checked against, not the build.

Comment thread mortie/moc_object.py Outdated

Coverage is **multi-order by default** — coarse cells inside, fine cells
along the boundary, down to :func:`~mortie.morton_coverage_moc`'s default
along the boundary, down to :func:`~mortie.coverage._morton_coverage_moc`'s default

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)

The public Moc API page now sends readers to a private function seven times.

The rename was applied mechanically, so Moc's class docstring, its Parameters, its Raises, its See Also, and Moc.from_polygon all now point at mortie.coverage._morton_coverage_moc — lines 367, 396, 399, 410, 417, 474 here, plus 131 / 199 / 202 / 275 in the module's helpers. Moc is a rendered public member (docs/api/moc_object.md), so these publish:

site/api/moc_object/index.html:1720: along the boundary, down to :func:<code>~mortie.coverage._morton_coverage_moc</code>'s default
site/api/moc_object/index.html:1780: drops to this value; see :func:<code>~mortie.coverage._morton_coverage_moc</code>.
site/api/moc_object/index.html:1843: rings (see :func:<code>~mortie.coverage._morton_coverage_moc</code>), or a coverage knob
site/api/moc_object/index.html:1856: mortie.coverage._morton_coverage_moc : the coverage kernel this wraps.
...

A See Also entry naming a leading-underscore function is a dead end for a reader — mkdocstrings does not render it (it is excluded from the API pages by [tool.numpydoc_validation]'s private-member exclusion and by docs/api/coverage.md, which correctly dropped the member), and the CHANGELOG explicitly says "private names carry no compatibility promise". So the public documentation for the object layer is now anchored to something the docs deliberately do not document and the project reserves the right to change.

mortie/batch.py and mortie/coverage.py handled the same problem the other way — they describe the coverer by what it is and point at the surviving public routes. The same treatment reads better here, e.g. line 367:

Coverage is multi-order by default — coarse cells inside, fine cells along the boundary, down to the MOC coverer's default finest order

and the See Also pointing at mortie.from_geometry / mortie.polygons_to_morton_mocs rather than the kernel. The import at line 54 and the two call sites (348, 495) are of course fine as they are — this is only about the prose.

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 in 1c2be68 — prose only, no code touched, as you scoped it.

All six public-facing references are gone; the rendered docs/api/moc_object.md now carries exactly one mention of the private name, in the See Also parenthetical where it is honest commentary rather than a destination:

  • 367 → "down to the MOC coverer's default finest order"
  • 396 / 399 (tolerance / max_cells) → "the coverage kernel's angular stop criterion, as on mortie.polygons_to_morton_mocs" / "as on mortie.polygons_to_morton_mocs"
  • 410 (Raises) → "the coverage kernel rejects the rings"
  • 417 (See Also) → mortie.polygons_to_morton_mocs (the public coverer, with order) and mortie.from_geometry, with the kernel named once parenthetically: "this class wraps the same coverage kernel (mortie.coverage._morton_coverage_moc, private)"
  • 474 (from_polygon) → "resolved by the coverage kernel's one even-odd descent: disjoint parts union, a nested ring carves a hole" — which also says what the rule is, rather than deferring to a page that does not render

The four references in private helpers (131, 199, 202, 275, in _geojson_ring_groups / _ring_latlons / _reject_coverage_knobs) are left as-is: internal commentary between private functions, not published.

Rebuilt the docs: site/api/moc_object/index.html is down from seven prose hits to the one parenthetical, plus the two syntax-highlighted source listings of the import and call sites, which are unavoidable and correct.

Comment thread mortie/batch.py Outdated
crosses the Python/Rust boundary **once**, the GIL is released for the
batch, and Rust parallelizes across parents. Row ``i`` is bit-identical to
:func:`generate_morton_children` on ``words[i]`` alone.
:func:`mortie.generate_morton_children` on ``words[i]`` alone.

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)

This line lost its docstring indentation — it starts at column 0.

$ grep -n "^:func:" mortie/batch.py
17::func:`polygons_to_morton_mocs`, whose ragged batch-native signature has no
28::func:`mortie.arrow.polygons_to_morton_mocs` take pyarrow columns and stay in
768::func:`mortie.generate_morton_children` on ``words[i]`` alone.

17 and 28 are module-docstring continuations and are fine. 768 is inside _children_of's function docstring, whose body is indented four spaces, so inspect.cleandoc / numpydoc see a dedented line mid-paragraph:

    batch, and Rust parallelizes across parents.  Row ``i`` is bit-identical to
:func:`mortie.generate_morton_children` on ``words[i]`` alone.

The preceding line was rewrapped in this commit (The dense batch kernel of :func:mortie.generate_morton_children (issue / #156): the whole parent array) and the continuation was left behind. numpydoc lint does not catch it (private members are excluded), which is why it is still here.

While the paragraph is being rewrapped: line 277 in this file is now 100 characters —

mortie/batch.py:277: (100)         the scalar form of :func:`mortie.from_wkb` for those.  Also for ``order`` outside 1-29, both

— which is new in this commit. ruff is configured at line-length = 88 and the PR gate ignores E501, so nothing fails, but the PR body calls out the one pre-existing 100-char line as deliberately left alone; this is a second one added rather than inherited.

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 in 2716d0f — a pure formatting commit, no prose changed.

The _children_of paragraph is rewrapped whole, so the continuation is back inside the docstring body:

    The dense batch kernel of :func:`mortie.generate_morton_children` (issue
    #156): the whole parent array crosses the Python/Rust boundary **once**,
    the GIL is released for the batch, and Rust parallelizes across parents.
    Row ``i`` is bit-identical to :func:`mortie.generate_morton_children` on
    ``words[i]`` alone.

grep -n "^:func:" mortie/batch.py is now just 17 and 28 — the two module-docstring continuations, which are fine.

Line 277 is rewrapped too. While there I checked the whole file against the parent commit rather than only that line: awk 'length > 88' on dd49f02^:mortie/batch.py reports nothing, so the 91- and 92-char lines at what are now _mocs_to_orders and _common_ancestors were also added by this commit, not inherited. Both are rewrapped in the same commit; the only line over 88 left in the file is a doctest +ELLIPSIS output line, which cannot be wrapped without breaking the doctest.

calls = []
real = batch._from_wkbs

def spy(entries, **kwargs):

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)

The delegation spy proves the route but not the payload — **kwargs is swallowed and never asserted on.

    def spy(entries, **kwargs):
        calls.append(list(entries))
        return real(entries, **kwargs)

calls records only the entries, so the test's two assertions (len(calls) == 2, and that the packed form hands over memoryviews) hold no matter what _from_wkb_batch forwards. Combined with the absence of any tolerance= / max_cells= parity test over the polymorphic batch, this is why the suite still passes with both parameters dropped from the delegation (measured — see the comment on mortie/geometry.py:483).

Capturing the kwargs costs one line and turns this into the pin the PR body describes:

    def spy(entries, **kwargs):
        calls.append((list(entries), kwargs))
        return real(entries, **kwargs)
    ...
    assert [k for _, k in calls] == [
        dict(order=6, tolerance=None, max_cells=None, normalize=True,
             latitude="authalic")
    ] * 2

Two smaller test-quality notes in this block:

  • test_from_wkb_offsets_is_keyword_only (line 551) asserts pytest.raises((TypeError, ValueError)) with no match=. Every other refusal in this file is pinned to its message text and anchored; this one passes on any error, including one raised for an unrelated reason. match=r"positional arguments" would pin what it means to test.
  • test_generate_morton_children_refusals_name_the_survivor covers two of the three kernel messages that carry the retired name. The third — word {i}: is at order {o} but word 0 is at order {p}; children_of returns a dense (n, 4**d) block — goes through the same str.replace and is not pinned publicly. I verified it does come out respelled (... ; generate_morton_children returns a dense (n, 4**d) block ...), so this is a missing pin rather than a bug.

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

All three fixed, across two commits.

The spy77f9e79 (folded with the blocking gap you raised on mortie/geometry.py:483, since they are the same root cause). It captures (list(entries), kwargs) and asserts the exact forwarded dict for both forms, and the calls it makes now carry non-default knobs so the assertion has something to discriminate:

    assert [kw for _, kw in calls] == [
        dict(order=6, tolerance=2.0, max_cells=None, normalize=False,
             latitude="geodetic-spherical")
    ] * 2

test_from_wkb_offsets_is_keyword_only1b3ac1d. Now pytest.raises(TypeError, match=r"takes from 1 to 6 positional arguments but 7"), which is the message Python actually raises for the keyword-only slot, so the test fails if offsets ever stops being keyword-only rather than passing on any error.

The third kernel message1b3ac1d, added to test_generate_morton_children_refusals_name_the_survivor:

    with pytest.raises(
        ValueError,
        match=r"generate_morton_children returns a dense \(n, 4\*\*d\) block",
    ):
        mortie.generate_morton_children(mixed, 8)

with mixed two words at orders 4 and 5. Confirmed live it comes out respelled, as you said: word 1: is at order 5 but word 0 is at order 4; generate_morton_children returns a dense (n, 4**d) block, so every parent must sit at one order.

Comment thread CHANGELOG.md Outdated
| `decimals_to_words(arr)` | `decimal_to_word(arr)` (array in, array out) |
| `children_of(words, order, max_cells)` | `generate_morton_children(words, order, max_cells=max_cells)` |
| `from_wkbs(blobs, ...)` | `from_wkb(blobs, ...)` — see below |
| `morton_coverage_moc(lats, lons, ...)` | `polygons_to_morton_mocs(lats, lons, [0, len(lats)], ...)` for one ring; `from_geometry` / `from_wkb` / `from_wkt` with `moc=True` (or `mortie.Moc`) for multipart/holes |

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)

Minor: this row's "call instead" changes the return shape, and the row does not say so.

morton_coverage_moc returned a bare uint64 array; polygons_to_morton_mocs returns a (values, out_offsets) pair. A caller who applies this row literally gets a tuple bound where an array was:

moc = mortie.polygons_to_morton_mocs(lats, lons, [0, len(lats)], order=10)
len(moc)   # 2, not the cell count

Every other row in the table is a drop-in respelling, so this one reads like one too. polygons_to_morton_mocs(lats, lons, [0, len(lats)], ...)[0] — or values, _ = ... as README.md and USAGE.md correctly spell it — would make the row self-contained.

I ran every row in this table against the branch. All nine migrations produce byte-identical results to their retired originals (including this one once unpacked, with tolerance / max_cells / normalize variants, and mortie.arrow.from_wkb), so the table's semantics are right — this is purely about the spelling of the one row whose arity changed.

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 in 3ffe81f — the row unpacks now, as README.md / USAGE.md already did:

values, _ = polygons_to_morton_mocs(lats, lons, [0, len(lats)], ...) for one ring — the batch-native call returns the ragged (values, out_offsets) pair, so the one ring's MOC is values, not the return itself; for multipart/holes see the note below

Two things fell out of rewriting it. The multipart half was crowding the cell and was also the sentence flagged as inaccurate on docs/coverage_methods.md:43 (numpy-only reachability, Moc having no order), so it moved down into the note paragraph where there is room to say which route needs a geometry backend and which has no order knob. And the row is now the only one in the table that is not a bare drop-in, which is the point — the values, _ = prefix makes the arity change visible at a glance rather than in prose.

@espg

espg commented Aug 19, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude (review)

Fresh-context adversarial review of dd49f02 (phase 4) only — the plural-name retirement and the from_wkb collapse. Phases 1–3 were reviewed separately and are not relitigated here; nothing in phase 4 contradicts them. Everything below was verified by running code against a scratch checkout of dd49f02 (miniforge 3.10, the branch's own _rustie.abi3.so), never by reading alone.

10 inline comments posted.

Ranked findings

# severity file finding
1 blocking (test gap) mortie/geometry.py:483 Batch delegation's tolerance / max_cells forwarding is pinned by nothing — mutating both to None leaves the suite at 1695 passed, 16 skipped, unchanged
2 behavioral regression mortie/geometry.py:585 Sequence dispatch narrows the accept-list: pandas Series, S-dtype ndarray and generators all worked under from_wkbs, all now TypeError. The docstring names "(the pandas case)"; the CHANGELOG is silent
3 silent wrong answer mortie/geometry.py:473 moc is tested for truthiness, so a positionally-migrated tolerance binds to moc and is dropped without error — 883 cells where 193 were asked for
4 false doc claim mortie/geometry.py:406 memoryview(x).cast("B") accepts any C-contiguous buffer, so the documented one-byte-item TypeError never fires; a float64/int32 column is silently reinterpreted, while the scalar path refuses the same object by name. Plus OverflowError escaping the documented Raises
5 false doc claim docs/coverage_methods.md:43 The documented replacement set for multipart/hole MOC coverage is unreachable numpy-only, and the one numpy-only route (Moc) has no order — 495,568 words where the retired call gave 411
6 retirement incomplete mortie/arrow.py:367,391 mortie.arrow.polygons_to_morton_mocs' docstring still names mortie.morton_coverage_moc twice, and it renders on the published API page (verified in the built site). Also mortie/coverage.py:39
7 doc quality mortie/moc_object.py:367 (+6) Public Moc docs now point at the private _morton_coverage_moc seven times, rendered on docs/api/moc_object.md
8 formatting mortie/batch.py:768 Docstring continuation line dedented to column 0 by a rewrap; plus one new 100-char line at :277
9 test quality tests/test_polymorphic_api.py:537,551 Delegation spy swallows **kwargs (root cause of (1)); keyword-only test asserts raises((TypeError, ValueError)) with no match=; the third respelled kernel message is unpinned
10 doc precision CHANGELOG.md:27 The one migration row whose arity changes (array → (values, offsets) tuple) is spelled like a drop-in

None of (1)–(10) is a wrong answer from correctly-spelled code; (1) is a gap that lets one become possible, and (3) is the one path where a plausible caller gets a wrong answer silently.

What I checked and found correct

Retirement completeness. All nine retired names are absent from the package root, from __all__, and from mortie.batch / mortie.toc / mortie.morton_index / mortie.coverage / mortie.arrow. No live call to a retired name survives anywhere — I AST-scanned every script under benchmarks/ and bench_*.py for Name / Attribute / ImportFrom references and found zero (the four textual hits there are prose in module docstrings). No Rust message string names a retired public name beyond the three the wrappers respell (src_rust/src/toc.rs:423, decimal_morton/batch.rs:306,313,366); the one mocs_and hit in moc/batch.rs:672 is a #[test] assert message, not a user-facing string.

from_wkb dispatch, adversarially. Every ruled bucket lands where it should — bytes / hex str / bytearray / memoryview / 1-D uint8 ndarray / np.str_ hex → one blob; list / tuple / object-ndarray → sequence batch; offsets= → packed column. Probed beyond the rule: 2-D uint8 (→ one blob, per the ruling's "no shape-sniffing"; note it silently covers only the first blob, but that is the pre-existing scalar trailing-bytes behaviour, not new here), non-contiguous uint8 (clean ValueError from the reader), U-dtype hex array (TypeError), empty list/tuple/object-array (all → the empty batch pair, matching the kernel), offsets=[], offsets=[0] over empty data, offsets=0, 2-D offsets, offsets on a list, negative / past-end / non-monotone / offsets[0]!=0 offsets. Everything either lands in the ruled bucket or raises; the only silent-wrong-answer surfaces are (3) and (4) above.

Byte identity. Scalar default ≡ explicit moc=False (748 cells). Both batch forms ≡ _from_wkbs under order / tolerance / max_cells / normalize / latitude (checked pairwise, all True) — the forwarding is correct, it is just untested. moc=True on a batch ≡ the default. Scalar moc=None on LINEAR geometry preserves the old behaviour exactly: default returns the per-line cover, moc=True / tolerance / max_cells still raise moc / tolerance / max_cells apply only to polygonal geometry, and a linestring inside a batch is refused by index. Tri-state confirmed across None / True / False / 0 / 1 / 0.0 / "" / np.False_ / np.True_.

Error-message rewrites. Both str.replace sites produce the right text and chain correctly (__cause__ is None, __suppress_context__ set by from None). I checked for collision hazards: no message reaching either site contains the retired substring in another role (the only rust_* names that would be caught are not in message bodies), and the layout/budget messages that never mention the retired name pass through untouched. Both rewritten messages verified live, plus the third (... generate_morton_children returns a dense (n, 4**d) block ...).

CHANGELOG migration table. I ran all ten rows. Every one produces a byte-identical result to its retired original, including the morton_coverage_mocpolygons_to_morton_mocs row with tolerance / max_cells / normalize variants, and mortie.arrow.from_wkb. Only the tuple-arity spelling of one row is off (finding 10).

Docs that must run. Executed the edited README.md and USAGE.md blocks: the coverage block, the moc_to_order(..., offsets=) chain, the common_ancestor(..., offsets=) block and the generate_morton_children block all run, and the three README/USAGE MOC calls are byte-identical to the retired morton_coverage_moc calls they replace (113 / 871 / 85 / 251 cells). Notebook edits verified semantically, not just syntactically — the two substituted cells in morton_coverage_example.ipynb are byte-identical to what they replaced: the donut cell (414 cells, from_geometry(Polygon(shell, [hole]), moc=True)morton_coverage_moc([outer, hole], ...)) and the full-Antarctica multipart cell over the real 44 MB basin file (549 cells identical, and also identical under tolerance=1.5 → 198 and max_cells=1000 → 199). Worth noting for the record since the MultiPolygon reconstruction is not obviously equivalent to the list-of-rings even-odd descent, and it also reports is_valid == False — mortie is unaffected, but it costs +678 ms to build.

_wkb_column_views validation. Off-by-one, ordering and endpoint checks are all correct and match the batch family's conventions (lowest-index rule, endpoint named); zero-copy confirmed (views[0].obj is bufTrue, and the spy sees memoryview entries). The two gaps are in finding (4).

Rewired test files. Read the diffs for test_dense_batch.py, test_decimal_parse.py, test_toc.py, test_wkb_batch.py, test_moc_batch.py, test_moc_setops_batch.py. The moves onto private kernels are mechanical — no assertion lost its point, and the one message-match that changed ("use decimal_to_word""takes the scalar path") still discriminates. The new retirement pins in test_polymorphic_api.py are real (they check __all__ and module attributes, not just the root). Test gaps are in findings (1) and (9).

Gates re-run on dd49f02

gate result
python -m pytest -q --no-cov 1695 passed, 16 skipped in 116 s
flake8 mortie --select=E9,F63,F7,F82 clean
numpydoc lint mortie/*.py clean
ruff check --select=E,F,W,I --ignore=E501 mortie/ 9 errors — byte-identical to the parent 2de31ed, so zero introduced
mkdocs build --strict green (as claimed). Note it cannot catch finding (6): mkdocstrings renders :func: roles as literal text, so a dangling cross-reference is invisible to --strict

The PR body reports "1576 passed" at 6cb2a16; the count is now 1695 (phase 4's new tests plus the 2de31ed merge of #197), worth updating before ready-for-review.

Conventions

  • Commit messagephase 4 of issue #187, title-only, matches git log. ✅
  • Module sizebatch.py 899 (was 886), geometry.py 818 (was 651). Both under mortie's CLAUDE.md ~1000-line aim; geometry.py's +167 is the largest single-file growth in the diff but leaves headroom. ✅
  • Lint / types / docstrings — no new hits on any gate. ✅
  • Notebook churn (nit, no comment posted) — both notebooks were re-serialized with \uXXXX escapes, so ~33 em-dashes and other non-ASCII characters in unrelated cells show as changed lines. morton_set_algebra.ipynb is 21 added / 21 deleted lines of which one is a real edit (morton_coverage_mocpolygons_to_morton_mocs in the closing summary). Re-saving with ensure_ascii=False would keep future notebook diffs readable.

Generated by Claude Code

Comment thread binder/environment.yml
# Tracks the package floor in pyproject.toml (issue #187): mortie's word
# scalars need NEP 50. The pinned mortie release below has been numpy-2
# compatible since 0.5.2, so this floor costs the notebooks nothing.
- numpy>=2

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) The bump itself is safe — I verified it — but this is not the only conda env in the repo. The root environment.yml still declares a bare, unpinned numpy and is the one declaration in the tree that still permits numpy 1.

environment.yml at the repo root (the conda dev env), unchanged by this commit:

dependencies:
  - flake8
  - numpy          # <- no floor
  - pytest
  - pytest-cov

In practice conda-forge solves that to numpy 2 today and a subsequent pip install -e . would enforce the floor anyway, so this is a consistency defect rather than a live breakage — but the CHANGELOG entry says "binder/environment.yml tracks it" and the sibling env is the obvious place a reader would look next. Making it - numpy>=2 costs nothing.

What I did verify about this file:

  • The 0.5.2 claim is right. CHANGELOG.md:489 under ## [0.5.2] - 2025-12-10 is "update for numpy 2 compat (update for numpy 2 compat #13)", and 0.8.2 postdates it.
  • mortie==0.8.2 really does run under numpy 2, empirically, not just by declaration:
    $ pip install "mortie==0.8.2" "numpy>=2" && python -c "
    import numpy as np, mortie
    print('numpy', np.__version__, 'mortie', mortie.__version__)
    print(mortie.geo2mort(np.array([45.0]), np.array([10.0]), 8))"
    numpy 2.2.6 mortie 0.8.2
    [1929335043491102728]
    
  • The resolution cannot conflict. 0.8.2's PyPI metadata declares numpy>=1.20 with no upper cap, so pip install mortie==0.8.2 will not touch a numpy 2 that conda already placed:
    $ curl -s https://pypi.org/pypi/mortie/0.8.2/json | jq -r '.info.requires_dist[0], .info.requires_python'
    numpy>=1.20
    >=3.10
    
  • python=3.11 + numpy>=2 is satisfiable on conda-forge, and no other pin in this file caps numpy (the healpy>=1.16 floor is a floor, so the solver is free to take a numpy-2-compatible healpy).

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 in 5f07103. The root environment.yml now carries the floor, with the same pointer comment the binder env has:

dependencies:
  - flake8
  # Tracks the package floor in pyproject.toml (issue #187): mortie's word
  # scalars need NEP 50.
  - numpy>=2
  - pytest

And the CHANGELOG claim you caught is now accurate for both rather than naming one: "Both conda envs in the tree track it — the dev environment.yml and binder/environment.yml."

I swept for any third declaration so the claim is exhaustive, not just less wrong — git ls-files for env/requirements files returns exactly those two, and the only other tracked numpy mention outside .github/workflows/ is mkdocs.yml:37 docstring_style: numpy, which is unrelated. Both files parse to numpy>=2:

environment.yml        -> ['numpy>=2']
binder/environment.yml -> ['numpy>=2']

Thanks for the verification legwork on the binder side — the 0.5.2 provenance, the empirical mortie==0.8.2 under numpy 2, and the requires_dist check that the pip step cannot pull conda's numpy back down. Nothing in it needed changing.

Comment thread mortie/_toc.py Outdated
means one type on every entry point. Mind that ``uint64`` arithmetic is
not Python's: under NEP 50 a word mixed with a Python ``int`` stays
``uint64``, so it **wraps at 2**64** instead of promoting to a big
integer (the wrap raises a ``RuntimeWarning`` first, so ``-W

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) The simplified prose keeps a claim that is over-broad: the wrap does not always raise a RuntimeWarning first — shifts truncate silently.

Flagging this because phase 7 is the docstring-honesty commit and it re-committed the sentence into the new paragraph; the substance was inherited from phase 6, so treat it as a note rather than a phase-7 regression. The paragraph is specifically encouraging bit-packed-word operations, which makes the shift case the likely one.

On the installed numpy 2.2.2:

$ python -c "
import warnings, numpy as np
def probe(label, fn):
    with warnings.catch_warnings(record=True) as rec:
        warnings.simplefilter('always'); r = fn()
    print(f'{label}: {r!r}  warnings={[w.category.__name__ for w in rec]}')
mx = np.uint64(2**64-1)
probe('max + 1', lambda: mx + 1)
probe('uint64(0) - 1', lambda: np.uint64(0) - 1)
probe('max * 2', lambda: mx * 2)
probe('max << 1', lambda: mx << np.uint64(1))
"
max + 1:       np.uint64(0)                      warnings=['RuntimeWarning']
uint64(0) - 1: np.uint64(18446744073709551615)   warnings=['RuntimeWarning']
max * 2:       np.uint64(18446744073709551614)   warnings=['RuntimeWarning']
max << 1:      np.uint64(18446744073709551614)   warnings=[]     <-- silent

So -W error::RuntimeWarning catches the + - * wraps but not a left shift off the top of the word. Scoping the parenthetical to arithmetic ("the arithmetic wrap raises a RuntimeWarning first…") would make it exact. CHANGELOG.md:107-111 carries the same generalization (phase 6's entry, out of scope for this commit).

Everything else in the rewritten paragraphs I verified on numpy 2.2.2 and it is accurate:

$ python -c "
import numpy as np, mortie
w = mortie.time2toc(10**9)
print('w + 1      ->', repr(w + 1))          # np.uint64(3147483649)
print('w + 1.0    ->', (w + 1.0).dtype)      # float64
print('int(w)     ->', int(w), type(int(w))) # 3147483648 <class 'int'>
"
$ python -W error::RuntimeWarning -c "import numpy as np; np.uint64(2**64-1) + 1"
RuntimeWarning: overflow encountered in scalar add

I also grepped the rest of the tree for surviving numpy-1 hedges — mortie/*.py, docs/, README.md, USAGE.md, BUILDING.md — for "numpy < 2", "numpy >= 2", "NEP 50", "float64", "1.20". Nothing else hedges across numpy generations; the only other NEP 50 mentions are mortie/convert.py:567 and mortie/tests/test_convert.py:271, both stating numpy-2 behaviour flatly. This docstring was the only hedge, and it is the only place carrying the word-type note.

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 in 238e78b, scoped to arithmetic as you suggested:

integer (an *arithmetic* wrap raises a ``RuntimeWarning`` first, so ``-W
error::RuntimeWarning`` turns it into a failure; a bit shift off the top
of the word truncates silently, with no warning to catch), and mixing
with a Python ``float`` gives ``float64``.

I reproduced your probe on numpy 2.2.2 before editing, and extended it to the Python-int shift spelling since that is the one a call site would actually write — it is silent too:

max + 1          np.uint64(0)                     warnings=['RuntimeWarning']
uint64(0) - 1    np.uint64(18446744073709551615)  warnings=['RuntimeWarning']
max * 2          np.uint64(18446744073709551614)  warnings=['RuntimeWarning']
max << 1         np.uint64(18446744073709551614)  warnings=[]      <-- silent
max << 1 (int)   np.uint64(18446744073709551614)  warnings=[]      <-- silent

I also fixed the same generalization at CHANGELOG.md:97-101 in that commit, even though you scoped it out as phase 6's. It is the identical untrue claim in the more widely read of the two places, the fix is one sentence, and leaving the two texts disagreeing about the same behaviour would be worse than the small scope stretch. It now reads "...but a bit shift off the top of the word (w << 1) truncates silently, with no warning to catch." Flagging it explicitly so it is visible rather than folded in quietly — happy to split it back out if you would rather it rode with a phase-6 fix.

Thanks for the tree-wide sweep confirming this docstring was the only surviving numpy-1 hedge; numpydoc lint mortie/*.py is clean after the edit.

@espg

espg commented Aug 19, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude (review)

Phase 7 adversarial review — 4edc7b2 ("phase 7 of issue #187")

Fresh context, worktree at 4edc7b2. Six inline comments posted. The floor decision itself is right and the binder half of it is verified safe; the defects are all in the tests — one of the three does not discriminate the floor it exists to guard.

Ranked findings

# Sev Where Finding
1 High mortie/tests/test_polymorphic_api.py:997 test_word_arithmetic_is_exact_near_the_top_of_the_range passes on numpy 1.26.4. big + np.uint64(1) is uint64 on numpy 1 too, and line 999 is pure float64. big + 1 is the discriminating spelling.
2 Medium …test_polymorphic_api.py:984 word >> np.uint64(32) is not "against a Python int" as the comment two lines up claims; >> is the one bitwise op left uncovered. Test still fails on numpy 1 via word | 1, so no false pass — a coverage/claim gap.
3 Medium …test_polymorphic_api.py:972 test_numpy_is_at_or_above_the_declared_floor asserts the runtime numpy, never the declaration. Reverting pyproject.toml to numpy>=1.20 leaves it green (reproduced). importlib.metadata is not a usable fix here — stale editable metadata still reports numpy>=1.20; read pyproject.toml instead.
4 Med-low CHANGELOG.md:85 "the np.uint64 word semantics above" — that entry is at line 95, below this one. Swap the entries or say "below".
5 Low binder/environment.yml:18 (about root environment.yml) The root conda dev env still declares a bare, unpinned numpy — the one declaration in the tree that still permits numpy 1, while the entry says "binder/environment.yml tracks it".
6 Low mortie/_toc.py:129 "the wrap raises a RuntimeWarning first" is over-broad: max << 1 truncates silently. Inherited from phase 6 but re-committed in the simplified prose; scope the parenthetical to arithmetic.

Independent floor-declaration sweep

I swept the whole tree for anywhere a numpy version is declared, pinned, implied or documented — every pyproject.toml section, binder/, all five workflows, the root conda env, Cargo.toml, README/USAGE/BUILDING/docs/**, notebooks, and uv.lock.

File / location What it declares Verdict
pyproject.toml [project].dependencies numpy>=2 ✅ bumped, with rationale
pyproject.toml requires-python >=3.10 ✅ coherent — numpy 2.0/2.1/2.2 all support 3.10 (2.2.6 requires_python: >=3.10); 2.3+ needs 3.11. Every Python in every matrix (3.10–3.13) has a numpy 2 release. Not unsatisfiable.
pyproject.toml extras pandas / pyarrow / arro3 / test / bench / examples no numpy pin, direct or transitive-capping ✅ nothing permits or forces numpy 1
pyproject.toml [dependency-groups].docs mkdocs stack, no numpy ✅ n/a
pyproject.toml [build-system].requires maturin>=1.0,<2.0 — no numpy build pin ✅ n/a (maturin/pyo3 build, no oldest-supported-numpy)
pyproject.toml [tool.maturin] module/bindings config only ✅ n/a
binder/environment.yml numpy>=2 ✅ bumped; safe (see below)
binder/postBuild mortie==0.8.2 (declares numpy>=1.20, no cap) ✅ resolves cleanly against numpy 2 and runs on it — verified
environment.yml (root, conda dev env) bare numpy ⚠️ the one miss — permits numpy 1. Finding 5.
.github/workflows/test.yml pip install -e ".[test]", unpinned ✅ inherits >=2
.github/workflows/build-wheels.yml:410 pip install numpy then --no-deps wheel install ⚠️ note only: --no-deps means the declared floor is never enforced in that job, but the unpinned install lands numpy 2 on all of 3.10–3.13
.github/workflows/codspeed.yml / docs.yml / lint.yml pip install -e ".[bench]" / uv sync --group docs / numpydoc only ✅ no numpy pin
Cargo.toml:16 numpy = "0.22" (rust-numpy crate) ✅ unrelated to the Python floor; rust-numpy 0.22 links both generations — the built .so imported fine under numpy 1.26.4 in my scratch env
uv.lock already carries { name = "numpy", specifier = ">=2" } ✅ and it is .gitignored (.gitignore:38), so not a repo declaration
README.md, USAGE.md, docs/index.md, docs/** prose "numpy is the only runtime dependency", no version ✅ no stale floor
BUILDING.md:10 "Python packages: numpy" (no version) ➖ no contradiction; a >=2 here would be a courtesy, not a fix
examples/*.ipynb no pip install, no numpy pin ✅ n/a

grep -rn "1\.20" over the tree returns only the two deliberate historical references (pyproject.toml:27, CHANGELOG.md:88) plus unrelated holoviews>=1.20 / healpy-1.20 comments. Answer to "did phase 7 miss any declaration": one — the root environment.yml.

Checked and found correct

  • Every remaining docstring claim in _toc.py, on numpy 2.2.2: wrap at 2**64 (np.uint64(2**64-1) + 1 -> np.uint64(0)), RuntimeWarning emitted before the arithmetic wrap, python -W error::RuntimeWarning really turning it into RuntimeWarning: overflow encountered in scalar add, w + 1.0 -> float64, and int(w) returning the unbounded Python int. Only the shift caveat (finding 6) is unstated.
  • No surviving numpy-1 hedge anywhere else. Grepped mortie/*.py, docs/, *.md for "numpy < 2", "numpy >= 2", "NEP 50", "float64", "1.20". The only other NEP 50 mentions (mortie/convert.py:567, mortie/tests/test_convert.py:271) state numpy-2 behaviour flatly. This was the only hedge and the only copy of the word-type note.
  • The binder bump is safe — verified three ways, not just by declaration: the 0.5.2 numpy-2-compat claim matches CHANGELOG.md:489; 0.8.2's PyPI metadata is numpy>=1.20 with no cap; and pip install mortie==0.8.2 "numpy>=2" + geo2mort actually runs (numpy 2.2.6, [1929335043491102728]).
  • The test-block comment's claim "nothing else in the suite would notice a downgrade" is true, and I would not have believed it without checking. Full suite on a scratch copy of 4edc7b2 under numpy 1.26.4:
    2 failed, 1813 passed, 40 skipped in 155.63s
    FAILED ...::test_numpy_is_at_or_above_the_declared_floor
    FAILED ...::test_word_arithmetic_stays_uint64_under_nep50
    
    1813 other tests pass on numpy 1. That is the justification for adding these tests at all — and it is also why finding 1 matters: with only two of the three discriminating, the exactness test is decorative.
  • Version-string parsing in the floor test: int(np.__version__.split(".")[0]) survives "2.0.0rc1", "2.3.0.dev0+git…", "2.2.6". No defect there — the problem is what it asserts (finding 3), not how it parses.

Gates

Gate Result
pytest -q --no-cov -p no:cacheprovider @ 4edc7b2 1845 passed, 16 skipped (115.8s) — +3 over the 1842 baseline at 28721fe, exactly the three new tests
flake8 mortie --select=E9,F63,F7,F82 clean (exit 0)
ruff check --select=E,F,W,I --ignore=E501 on mortie/_toc.py, mortie/tests/test_polymorphic_api.py All checks passed
ruff check (full repo config, E,F,W,I,D + numpy pydocstyle) on the two changed .py files All checks passed
numpydoc lint mortie/_toc.py clean (exit 0)

Conventions

  • Commit message: title-only, phase 7 of issue #187 — matches the repo style. ✅
  • CI/deploy config: no .github/workflows/** file was touched. The diff is pyproject.toml, binder/environment.yml, mortie/_toc.py, mortie/tests/test_polymorphic_api.py, CHANGELOG.md. binder/ is a repo2docker notebook runtime, not CI/CD, and the floor bump is espg-ruled, so the right files were touched — with the root environment.yml omission noted above. ✅
  • Tests with the behavioral change: present, but see findings 1–3 on whether they hold. ⚠️
  • Module size: mortie/_toc.py net −8 lines; no cap concern. ✅

Generated by Claude Code

@espg

espg commented Aug 19, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Ready for review. Your ruling on the numpy floor landed as phase 7, the review/fold cycle ran on it, origin/main is merged forward (0.9.10), and CI is green. This supersedes my earlier status note about the runner backlog — that cleared and everything ran.

Phase 7 — numpy>=2 (ruled, question (9) now RESOLVED in the body)

Reasoning of record, as you gave it: the np.uint64 scalar unification is only correct under NEP 50, CI has only ever tested numpy 2 (every job installs it unpinned), so >=1.20 was an untested and known-wrong declaration, and 1.0 is the honest moment to state the floor the package actually supports.

  • Both declarations agree now. pyproject.toml (reason in a comment) and binder/environment.yml; the review found a third I had missed — the root environment.yml dev env — and that is fixed too. No CI workflow was touched: every job already installs numpy unpinned, which resolves to numpy 2 and agrees with the new floor.
  • The hedge is deleted, not maintained. Phase 6 had documented both numpy generations because I could not change the floor; that prose is gone and the docstrings state the numpy-2 semantics plainly. A stale copy of the same claim in the CHANGELOG went with it.
  • The floor is now pinned by tests, and they were verified against a real numpy 1.26.4 environment — which is what made the fold worth doing, because my first attempt did not work. The exactness test I wrote passed on numpy 1.26.4, i.e. it would have sat there green under exactly the broken floor it claimed to guard: big + np.uint64(1) is uint64 on both generations. The discriminating spelling is a Python int operand — big + 1 returns 1.8446744065119617e+19 on numpy 1 and an exact word on numpy 2. All three floor tests now fail under numpy 1.26.4 (previously one of three), and the declaration test reads the requirement out of pyproject.toml rather than only asking the interpreter, so reverting the floor turns it red.

CI on 238e78b

16 checks green: test (3.10) · test (3.11) · test (3.12) · ruff · numpydoc validation · codecov/patch · codecov/project · arro3-no-pyarrow · Build source distribution · Build (verify only) · all seven wheel builds (ubuntu, macOS, macOS-intel, Linux ARM64, Windows) · Python benchmarks.

test (3.10) passing is worth noting on its own: it is the direct evidence that numpy>=2 is satisfiable on the oldest Python this package supports (numpy 2.0–2.2 cover 3.10).

CodSpeed: no regression"Merging this PR will not alter performance", 71 untouched benchmarks, zero flagged. No noise to interpret on this one.

One job was still executing when I flipped: Rust benchmarks. It is in its Run Rust benchmarks step — CodSpeed's instrumented execution, which is slow by construction — and it is progressing, not stalled. It is a performance-measurement job, not a correctness gate, there are no required status contexts on the repo, and CodSpeed has already reported the comparison above. If it comes back red it is a perf question for you rather than something this branch needs to change; nothing about the diff touches Rust.

Local gates at 238e78b

pytest 1846 passed, 16 skipped · flake8 --select=E9,F63,F7,F82 clean · numpydoc lint mortie/*.py clean · ruff --select=E,F,W,I --ignore=E501 clean on every file touched · mkdocs build --strict green.

Standing for you

The release-ordering constraint from my previous comment is unchanged and is the thing to decide first: this PR forecloses a 0.9.x, so the next release cut from main after it merges must be 1.0. The Moc/Toc objects moczarr wants are already on main and are additive — 0.9.10 shipped the Moc half today — so either cut the objects release before merging this, or merge this and go to 1.0.

Questions (6)-(8) and (10) in the body are open: the mortie.arrow.from_wkbsfrom_wkb rename (my lean, a one-line revert if you disagree), the missing public array-in multipart MOC spelling left by retiring morton_coverage_moc, the positional-migration hazard the moc guard now catches, and why the kernel-focused tests import the private kernels.

Applying waiting.


Generated by Claude Code

@espg
espg marked this pull request as ready for review August 19, 2026 16:48
@espg espg added the waiting label Aug 19, 2026
@espg

espg commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Cleared the outstanding review-thread backlog on this PR. One commit: cc80347. No phase was added, no scope changed, and the PR stays implement + waiting on a maintainer merge decision.

Ruff bot

Three reviewdog findings from 2026-08-19 on e0a940a were unanswered. All three are fixed in the docstrings themselves — no # noqa, no change to [tool.ruff] or its per-file ignores.

finding site fix
D205 bench_cross_order.py module docstring one-line summary, blank line, then the description
D205 benchmarks/measure_straddle_overrefinement.py, hemisphere_ring same
D209 same docstring closing quotes moved to their own line

Reproduced each before the fix and re-checked after, with the repo's own config (lint.select = ["E", "F", "W", "I", "D"], numpy pydocstyle convention):

$ ruff check --output-format=concise bench_cross_order.py benchmarks/measure_straddle_overrefinement.py
# before: D205 at bench_cross_order.py:1, D205 + D209 at measure_straddle:123
# after:  none of the three

Both files are in this PR's diff only because morton_coverage_moc was swapped for the private coverer; filter_mode: diff_context is why exactly these docstrings were flagged and not their neighbours.

Not fixed, flagged instead (CLAUDE.md §4 — pre-existing, outside the diff context, never reported by the bot): D205/D209 pairs at measure_straddle_overrefinement.py:93, :100, :180, D205/D400 on its module docstring at :1, and D103 on the un-docstringed helpers in both files.

One process note: replies to the two measure_straddle_overrefinement.py ruff comments are rejected by the API (in_reply_to (invalid)), and their thread nodes no longer resolve — reviewdog appears to have pruned them once the flagged lines changed (the PR's review-thread count went 52 → 50 across the push). The bench_cross_order.py thread was replied to and resolved normally; the substance for the other two is recorded here instead.

The 17 unanswered review-bot threads

These were the 2026-08-17 phase-1/phase-2 batches, most marked outdated. I re-read the code at head for each rather than trusting the outdated marker. Outcome: 1 still applied and is fixed here, 13 were already superseded by later commits, 3 are answered but deliberately left open because they carry a decision for you.

Still applied → fixed in cc80347 — the test module docstring's claim that "the error surface passes through unchanged". Your four probes still reproduce at head: max_cells=-1 reports differently with and without offsets=, order=99 loses the offending value in the batch form, and the precedence between the two genuinely flips. The docstring now states the narrow claim (catchable ValueError, lowest-index offender within its kind) instead of the strong one.

Superseded (13) — mostly by 6cb2a16, the phase-3 fold, plus later phases: the moc_to_order "input shape selects the form" wording (also mooted by the phase-4 plural retirement); the two "lowest-index offender" over-claims; the loose match= patterns and the two bare pytest.raises(ValueError); the moc_and batch-commutativity gap; the four function-local from .batch import; the MortonIndexScalar dtype leak and the 0-d array case (both blocking at the time); the inert scalar max_cells; the silent rank > 1 flatten; the "describes only its first element" scoping; and toc_reduce's delegate-named message and int-vs-uint64 return, the latter settled by the phase-6 unification.

While replying I closed four small leftovers that the folds had not reached, all in cc80347: keyword-only enforcement is now parametrized across all five offsets= entry points and pinned to the message (it covered moc_to_order alone, with a bare raises); the empty-group refusal test moved off index 0 so a hardcoded index fails it; the three accepted uint64 dtype spellings are pinned for array input; max_cells is passed to the kernel by keyword; and decimal_to_word's Raises now says it names the malformed id, not its index.

Left open for you (3) — each has a reply on its thread explaining why:

  1. Non-integral float offsets truncate silently. Documented as the batch family's convention; rejecting them is an API narrowing across the family, not a diff-scoped fix.
  2. decimal_to_word(["12341", 5]) coerces the 5 into the order-0 id — verified still reproducing. Fixing it means refusing input accepted today on a public parse surface. Pre-existing kernel behaviour; this PR is what puts it behind the singular name.
  3. Broadening test_moc_intersects_offsets_agrees_with_moc_and_slots from one fixture to a parametrized set. Test breadth, not a defect, and the reviewer requested no change.

Local gates

maturin develop --release            ok
pytest -q                            1851 passed, 16 skipped in 465s
flake8 mortie --select=E9,F63,F7,F82 clean
numpydoc lint mortie/*.py            clean
ruff check <touched files>           clean (mortie/*), 3 reported findings gone (benchmarks)

No Rust changed, so cargo test / clippy were not required.

State

implement + waiting, unchanged — the ball stays with the maintainer for the merge decision. Nothing about the phases checklist, the public API, or the CHANGELOG moved; cc80347 is docstrings, test pins, and one keyword argument.


Generated by Claude Code

@espg

espg commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

espg's final rulings on the four standing review questions (in-session, 2026-08-24) are applied; the PR body records each as RESOLVED. Two commits:

  • bd3480f — merge forward (main had moved: ExampleUsage.ipynb cell 15 computes a wrong normalized address — silently wrong for base cells 0-3, raises for 8-11 #142, Spec: normative section for the toc word grammar (frozen-for-1.x) #193, workspace split: extract mortie-core (pure-Rust codec, no pyo3) from mortie_rustie #200). One conflict (CHANGELOG.md), resolved keeping both sides' Unreleased entries. The retirement sweep re-run over main's additions caught two reintroductions of retired plural names, fixed in the resolution: docs/specification.md §11's "tocs_reduce refuses an empty group" is respelled onto the surviving batch form (toc_reduce(words, offsets=)), and examples/toc_temporal_coverage.ipynb's flat-API roster drops tocs_reduce.
  • e0eba6bfold review: refuse non-bool moc on from_wkb (issue #187), the one code change ruled (question 8): the guard's TypeError now names the received value and type (got 0.5 (float)) alongside the parameter and the positional-migration hazard; tests pin float/str/int refused with the message text in all three dispatch forms (the guard sits ahead of the dispatch at the one polymorphic entry point), bool/None accepted everywhere, and the arrow skin pinned safe by signature (test_the_skin_has_no_moc_slot_to_poison — it takes no moc, so a positionally-migrated arrow.from_wkbs(col, order, tol) binds tol to tolerance correctly). CHANGELOG line updated under the migration table.

The rulings of record:

Gates re-run in full on the merged result at e0eba6b: pytest 1854 passed, 30 skipped; flake8 mortie --select=E9,F63,F7,F82 clean; numpydoc lint clean on the touched module; ruff check --select=E,F,W,I --ignore=E501 clean on every touched file; mkdocs build --strict green. Pre-existing hits listed in the body stay untouched.

All ten review questions are now ruled and recorded; the PR stays ready-for-review and waiting — the merge into main is the 1.0 one-way door and is espg's.

@espg

espg commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Follow-up: main cut 0.9.11 (bd3c0c3) minutes after the merge-forward above, which re-dirtied the PR. Merged forward again as 9f569a9 — CHANGELOG-only conflict, resolved by keeping the branch's breaking entries under Unreleased and taking main's [0.9.11] heading (the #193 spec entry now correctly sits under 0.9.11); Cargo.toml's version sync auto-merged. Full gates re-run green on the result (pytest 1854 passed / 30 skipped, flake8, mkdocs --strict). Head is 9f569a9.

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.

2 participants