Skip to content

moc xor + domain-bounded not#59

Merged
espg merged 6 commits into
mainfrom
claude/54-moc-xor-not
Jun 25, 2026
Merged

moc xor + domain-bounded not#59
espg merged 6 commits into
mainfrom
claude/54-moc-xor-not

Conversation

@espg

@espg espg commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Refs #54. Adds the two remaining BMOC-backed set-algebra verbs from the #54 follow-up — symmetric difference (xor) and domain-bounded complement (not) — per the approved plan and @espg's decisions on that thread. Unblocked now that #58 (the packed-u64 migration) has landed, so the order-0 whole-sphere domain is pinned to the post-migration encoding.

One commit per phase, title-only messages.

Phases

  • Phase 1 — xor (symmetric difference)
  • Phase 2 — not (domain-bounded complement)

Phase 1 — xor

Mirrors the merged moc_or/moc_and/moc_minus exactly (src_rust/src/moc.rs):

  • src_rust/src/moc.rs: moc_xor(a, b) = bmoc_to_morton(build_bmoc(a).xor(&build_bmoc(b))), with the same empty-operand handling as moc_or (a △ ∅ = a). The healpix 0.3.3 BMOC exposes xor on the Bmoc trait, so this is the identical decode → op → encode → normalize shape as the other three verbs.
  • src_rust/src/lib.rs: rust_moc_xor PyO3 binding (wraps the compute in py.allow_threads), registered in _rustie.
  • mortie/coverage.py: moc_xor(a, b) Python wrapper; exported from mortie/__init__.py.
  • Tests:
    • Rust (moc.rs): ref_xor brute-force harness (op = |x, y| x ^ y) mirroring ref_or/ref_and/ref_minus; dedicated test_xor_brute_force, test_xor_self_and_empty, and test_xor_order_zero_operand (an order-0 whole-base-cell operand against a finer cover with cells [5, 6, 300]@4 — 5/6 inside base cell 0, 300 outside — the deferred #53 self-review gap; asserts the exact survivor set after densification); xor folded into the disjoint-cover test and the 300-iteration mixed-order fuzz.
    • Python (test_coverage.py::TestMOCSetOps): test_xor_brute_force, test_xor_equals_or_minus_and, test_xor_self_and_empty, test_xor_disjoint_equals_or, and test_xor_order_zero_operand (now exercises genuine partial overlap at the Python boundary — see self-review fold below).

Phase 2 — not (domain-bounded complement)

Per the decisions on #54: not(cover, domain) ≡ moc_minus(domain, cover); default domain = the 12 order-0 base cells. Pure-Python orchestration in coverage.py over the merged moc_minus — no new Rust primitive.

  • mortie/coverage.py: moc_not(cover, domain=None); exported from mortie/__init__.py.
    • domain is None → the 12 order-0 base cells, built via norm2mort(zeros(12), arange(12), 0) (a _whole_sphere() helper) so it tracks the post-packed-u64 migration (Option A) + norm2mort consolidation #58 packed-u64 order-0 encoding rather than a hand-rolled constant.
    • domain given → a single morton index or list/array (the "shard" cell case).
    • Out-of-domain cover cells are clipped and warned (UserWarning), documented in the docstring's Warns section: the clip is implicit in moc_minus(domain, cover), which only subtracts the in-domain part of cover.
  • Tests (test_coverage.py::TestMOCNot): whole-sphere default, complement-partitions-domain (cover ∪ not == domain, cover ∩ not == ∅), shard case (no warning), double-complement round-trip (not(not(cover)) == cover ∩ domain), out-of-domain warn-and-clip (pytest.warns), and empty-cover-returns-domain.

How it was tested

Built in the worktree venv (maturin develop --release):

cargo test --lib                       -> 160 passed
cargo fmt --check                      -> clean
flake8 mortie --select=E9,F63,F7,F82   -> clean
pytest mortie/tests/                   -> 399 passed, 8 skipped

The 8 skips are pre-existing/environmental (shapely/parquet/coarse-cell/issue-#20 placeholder), unchanged by this PR.

Questions for review

  1. xor empty-operand semantics. a △ ∅ returns normalize(a) (and ∅ △ ∅ = ∅), matching moc_or. Confirm that's the intended convention.
  2. MAX_DEPTH = 18 in moc.rs. Both new verbs inherit the same BMOC MAX_DEPTH = 18 range-linearization ceiling as the existing ops — not raised to the kernel's order-29 here. Raising the MOC ceiling to 29 is out of scope for Follow-ups from the BMOC set-algebra PR (#53) #54; flag if you want it tracked separately.
  3. moc_not home. Kept in Python over moc_minus per your approval. The out-of-domain detection costs one extra moc_minus(cover, domain) purely to decide whether to warn; covers are small so this is cheap, but flag if you'd rather skip the warning check.

@espg espg added the implement label Jun 20, 2026
@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.19%. Comparing base (022ca5f) to head (2cb13b5).
⚠️ Report is 17 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #59      +/-   ##
==========================================
+ Coverage   92.91%   93.19%   +0.28%     
==========================================
  Files          25       25              
  Lines        3372     3498     +126     
==========================================
+ Hits         3133     3260     +127     
+ Misses        239      238       -1     
Flag Coverage Δ
unittests 93.19% <100.00%> (+0.28%) ⬆️

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

Files with missing lines Coverage Δ
mortie/__init__.py 85.00% <ø> (ø)
mortie/coverage.py 97.08% <100.00%> (+0.65%) ⬆️
mortie/tests/test_coverage.py 99.04% <100.00%> (+0.24%) ⬆️

... 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 f370b69...2cb13b5. 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 Phase 1 (xor) only — commit e361dec. The implementation is sound; the one real issue is a test that doesn't test what its comment claims.

Correctness — looks good. moc_xor mirrors moc_or/moc_and/moc_minus exactly (same build_bmocBmoc::xorbmoc_to_mortonnormalize shape), and the empty-operand handling matches moc_or (a △ ∅ = a, ∅ △ ∅ = ∅). Answering Question (1) from the PR body: yes, that convention is internally consistent with the other verbs. Bmoc::xor exists in the pinned healpix 0.3.3, so it resolves. The Python wrapper and exports mirror the siblings 1:1.

MAX_DEPTH = 18 (PR Question (2)) — nothing broken for xor. xor shares the identical build_bmoc/cell_range path as the already-merged ops, so it inherits exactly the same depth-18 set-op ceiling — no regression introduced here, and raising it is correctly scoped out of #54.

Main finding — test_xor_order_zero_operand (Python) doesn't exercise the gap it documents. Its comment describes an operand "inside and outside that base cell" with "inside cells cancel," but it actually passes the fully-disjoint southern cover, so nothing is inside base cell 0 and nothing cancels. As written it's a duplicate of test_xor_disjoint_equals_or, and the #53 coverage gap is exercised only on the Rust side (test_xor_order_zero_operand in moc.rs, which does straddle base cell 0 via [5, 6, 300]). Inline comment has the fix options. This is the one I'd want addressed before merge.

Smaller, non-blocking:

  • No xor test sets bit 63 on a coarse (order-0) operand — the southern/bit-63 round-trip is covered for and/minus with order-8 covers but not for a coarse xor operand. Optional add.
  • The Rust order-0 test's is_empty() assert is weak on its own, but the accompanying ref_xor equality already pins the exact result, so it's fine.

Test coverage is otherwise solid: brute-force equality, the xor == (a∪b)\(a∩b) identity, self/empty idempotence, disjoint==or, and xor folded into the 300-iteration mixed-order fuzz and the disjoint-cover test. Terse and convention-adherent throughout. No dependency, lint, or module-size (§4) concerns.


Generated by Claude Code

Comment thread mortie/tests/test_coverage.py Outdated
Comment on lines +727 to +738
def test_xor_order_zero_operand(self):
# An order-0 whole-base-cell operand against a finer cover that has cells
# inside and outside that base cell (the deferred #53 coverage gap). The
# inside cells cancel against the base cell's coverage; the outside cells
# survive.
base0 = mortie.norm2mort(0, 0, 0) # base cell 0 at order 0
a = np.array([base0], dtype=np.uint64)
b = self._cover(self.S_LATS, self.S_LONS) # southern → a different base cell
got = set(int(x) for x in mortie.moc_xor(a, b))
assert got == self._ref(a, b, 8, lambda x, y: x ^ y)
# b is wholly outside base cell 0, so xor == or == base0 ∪ b.
np.testing.assert_array_equal(mortie.moc_xor(a, b), mortie.moc_or(a, 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 comment here contradicts what the test actually does, and the test doesn't exercise the case it claims to.

The header comment (lines 728-731) says this is "a finer cover that has cells inside and outside that base cell ... The inside cells cancel against the base cell's coverage; the outside cells survive." But b is self._cover(self.S_LATS, self.S_LONS) — the southern square, which line 734 itself notes is "a different base cell," and line 737 then correctly states "b is wholly outside base cell 0, so xor == or." So b has no cells inside base cell 0: nothing cancels, and the inside-cancellation path described in the comment is never reached.

Two consequences:

  1. The descriptive comment is inaccurate — it reads like it was copied from the Rust test_xor_order_zero_operand, which does construct an operand with cells both inside and outside base cell 0 ([5, 6, 300] at depth 4, with 5/6 inside and 300 outside). The Python twin lost that property.
  2. As written this is functionally a duplicate of test_xor_disjoint_equals_or (1) above — both assert moc_xor == moc_or on a fully-disjoint pair, the only difference being that a is an order-0 single base cell rather than an order-8 cover. So the MOC boolean set algebra via the patched healpix BMOC fork #53 coverage gap ("coarse order-0 operand whose interior partially overlaps a finer operand") is genuinely exercised only on the Rust side, not in Python.

Suggest either (a) make b actually straddle base cell 0 (e.g. a cover with leaves inside base cell 0 plus some outside) so the cancellation path is tested at the Python boundary too, or (b) if you intend this to stay a disjoint order-0 case, fix the comment to say so and note that the inside-overlap path is covered by the Rust test — but then it adds little over (1).


Generated by Claude Code

Comment thread src_rust/src/moc.rs
Comment on lines +114 to +122
pub fn moc_xor(a: &[u64], b: &[u64]) -> Vec<u64> {
if a.is_empty() {
return normalize(b);
}
if b.is_empty() {
return normalize(a);
}
bmoc_to_morton(build_bmoc(a).xor(&build_bmoc(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)

Implementation looks correct and mirrors moc_or/moc_and/moc_minus faithfully — same decode → op → encode → normalize shape via build_bmoc/bmoc_to_morton, and the empty-operand handling matches moc_or exactly (a △ ∅ = normalize(a), ∅ △ b = normalize(b), ∅ △ ∅ = ∅, since normalize(&[]) is empty). Bmoc::xor is present in the pinned healpix 0.3.3 (bmoc/ops.rs), so the call resolves.

One observation, not a blocker: unlike moc_minus (whose doc-comment calls out the upstream mixed-order infinite-loop fix in the pinned fork), there's no note here on whether xor was exercised against that same upstream mixed-order hazard. The 300-iteration mixed-order fuzz at the bottom of the module now covers xor, so this is fine — just flagging that the asymmetry in the doc-comments is intentional.


Generated by Claude Code

Comment thread src_rust/src/moc.rs Outdated
Comment on lines +496 to +503
// 300 is in another base cell (outside).
let base0 = nested2mort(0, 0);
let a = vec![base0];
let b: Vec<u64> = [5u64, 6, 300].iter().map(|&n| nested2mort(n, 4)).collect();
// Reference at depth 4 (the deepest operand cell).
assert_eq!(moc_xor(&a, &b), ref_xor(&a, &b, 4));
// The two inside cells cancel against base0's coverage; 300 (outside)
// survives, so the result is non-empty.

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 is the Rust test that genuinely exercises the deferred #53 gap, and it does so correctly: at depth 4 a base cell spans 4^4 = 256 = 1<<8 nested hashes, so 5 and 6 are inside base cell 0 and 300 is in base cell 1 (outside). The ref_xor brute-force assert pins the inside-cancel-plus-outside-survives behavior, and the non-empty assert guards against a vacuous pass.

Two strengthenings worth considering (non-blocking):

  • The non-empty assert only proves something survived. Adding an assert that the result equals just 300's coverage (i.e. 5/6 fully cancelled and nothing of base cell 0 leaked) would pin the cancellation more tightly than ref_xor equality alone already does — though ref_xor equality is in fact sufficient, so this is optional.
  • Consider also a southern (bit-63) order-0 operand, e.g. base cell 8 at order 0, to confirm the coarse-operand path round-trips the hemisphere prefix the same way the merged test_southern_hemisphere does for and/minus. Currently no xor test sets bit 63 on a coarse operand.

Generated by Claude Code

@codspeed-hq

codspeed-hq Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 67 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing claude/54-moc-xor-not (2cb13b5) with main (bfcbbf6)

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.

@espg

espg commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Phase 2 (not) is in (eb4cbb5), and this commit also folds the Phase-1 self-review findings:

  • Python test_xor_order_zero_operand now exercises real partial overlap at the Python boundary. The self-review correctly caught that it had been passing a fully-disjoint southern cover — so it duplicated test_xor_disjoint_equals_or and never exercised inside-cancellation (the genuine #53 gap). It now uses base0 against [5, 6]@4 (inside base cell 0) + [7]@5_base (outside), and asserts the two inside cells drop and the outside cell survives.
  • Rust test_xor_order_zero_operand survivor assert strengthened — it now densifies the result to depth 4 and asserts 300@4 present, 5@4/6@4 absent, rather than just !is_empty().

The two optional Rust suggestions left standing: a southern/bit-63 coarse operand case is already covered for and/minus/or via test_southern_hemisphere, and the fuzz spans both hemispheres for xor; I didn't add a separate bit-63 coarse-xor case since the fuzz exercises the same path. Flag if you'd like it pinned explicitly.

Phase 2 self-review running next.


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)

Phase-2 adversarial review of moc_not + _whole_sphere (commit eb4cbb5). No blocking defects. The core semantics are correct and I verified them against a fresh build (maturin develop --release, the 7 new/reworked tests pass):

  • moc_not(cover, domain) == moc_minus(domain, cover) ✓ (matches @espg's locked decision and the "Python orchestration over moc_minus" approval).
  • Default domain == the 12 order-0 base cells under the post-#58 packed-u64 encoding ✓ — built via norm2mort, bit 63 set on bases 7–11, matches the internal helper and a hand-checked 12-cell set.
  • Out-of-domain → warn + clip, documented in the docstring Warns block ✓; pytest.warns(..., match="outside") actually asserts the warning.
  • No circular-import risk: from .tools import norm2mort is safe — tools.py imports only _healpix, _rustie, and numpy, never coverage; __init__ imports coverage before tools but coverage pulls tools directly, so the order resolves.
  • coverage.py is 443 lines, well under the ~1000 limit (§4). flake8 E9/F-class clean. Scalar cover and scalar domain both work via np.asarray(...).ravel(). Double-complement idempotence holds for cover ⊆ domain; the round-trip test correctly lands on cover ∩ domain (I confirmed it's compress(inside), not compress(cover), when cover spills out).

Non-blocking findings (inline):

  1. Empty-domain emits a spurious warningmoc_not(cover, domain=∅) warns "cells outside domain" for any non-empty cover, though the result is correctly . Defensible but misleading; no test pins the empty-domain path.
  2. Warn predicate cost / wording — the extra moc_minus(cover, domain) is discarded (PR question (3) already flags it); separately, a cover cell coarser than and straddling the domain boundary is flagged "outside" even though it's partly inside.
  3. test_default_domain_is_whole_sphere is near-tautological — it asserts the verbatim implementation (moc_not(cover) == moc_minus(whole_sphere, cover)); test_complement_partitions_domain is the real guarantee. Consider pinning an actual expected complement instead.
  4. Missing the hard clip case — out-of-domain test uses disjoint whole base cells; the straddling (coarse-cover-cell) clip, where clipping does non-trivial work, is untested.
  5. Styleimport warnings localized in one test method while the sibling uses module-level pytest.warns; move to file top for consistency (§4).

Nothing here blocks advancing. (1) and (4) are the two I'd most want addressed before this goes ready-for-review.


Generated by Claude Code

Comment thread mortie/coverage.py
# only ever subtracts the in-domain part of `cover`).
if moc_minus(cover, domain).size > 0:
warnings.warn(
"moc_not: `cover` has cells outside `domain`; they cannot be "

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)

Spurious warning when domain is empty. With domain=∅, the out-of-domain predicate moc_minus(cover, domain).size > 0 is true for any non-empty cover, so moc_not(cover, domain=np.array([], dtype=np.uint64)) warns "cover has cells outside domain" — even though, semantically, an empty domain trivially has everything outside it and the (correct) result is . Confirmed empirically: result is [], but a UserWarning fires.

It's defensible (the cells genuinely aren't in the domain), but the message implies user error where there is none. Worth either special-casing domain.size == 0 (return empty, no warn) or noting the empty-domain behavior in the docstring. Either way there's no test pinning the empty-domain path — see my note on the test class.


Generated by Claude Code

Comment thread mortie/coverage.py
Comment on lines +432 to +436

# Cells of `cover` outside `domain` cannot be complemented within it; warn
# and clip them (the clip is implicit in `moc_minus(domain, cover)`, which
# only ever subtracts the in-domain part of `cover`).
if moc_minus(cover, domain).size > 0:

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 warn predicate computes a full extra moc_minus(cover, domain) purely to decide whether to warn, then discards it and computes moc_minus(domain, cover) for the result — two BMOC round-trips where the docstring's own framing ("the clip is implicit in moc_minus(domain, cover)") says only the second is load-bearing. PR question (3) already flags the cost; covers are small so it's cheap, fine to keep. Two smaller notes if you do keep it:

  • The predicate flags a cover cell that is coarser than and straddles the domain boundary as "outside" (e.g. domain = one order-4 cell, cover = its order-0 parent → warns), even though part of that cover cell lies inside the domain. That's a reasonable reading of "outside", but the warning text ("cells outside domain") reads as if whole cells are wholly outside. Confirmed via partial-overlap probe.
  • Comment nit: line 434's "the clip is implicit in moc_minus(domain, cover)" duplicates the docstring Warns block almost verbatim; one of the two is enough.

Generated by Claude Code

Comment thread mortie/tests/test_coverage.py Outdated
Comment on lines +765 to +770
def test_default_domain_is_whole_sphere(self):
# moc_not(cover) complements within the 12 order-0 base cells.
cover = self._cover(self.A_LATS, self.A_LONS)
got = mortie.moc_not(cover)
expected = mortie.moc_minus(self._whole_sphere(), cover)
np.testing.assert_array_equal(got, expected)

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 test is near-tautological: it asserts moc_not(cover) == moc_minus(whole_sphere, cover), which is verbatim the implementation of moc_not (line 443: return moc_minus(domain, cover)) with the default-domain branch. It pins the wiring (default domain == 12 base cells, no transformation) but proves nothing about the result against an independent ground truth — if moc_minus and the default-domain build were both wrong in the same direction, this passes.

test_complement_partitions_domain is the stronger guarantee (union == domain, intersection == ∅) and isn't tautological. Consider either dropping this one or strengthening it to pin an actual expected complement (e.g. for a known small cover, assert the exact survivor base cells), so the default-domain encoding is checked against a value rather than against moc_minus itself.


Generated by Claude Code

Comment thread mortie/tests/test_coverage.py Outdated
# complement is the finer cells not yet enumerated within the shard.
shard = np.atleast_1d(mortie.norm2mort(0, 0, 0)) # base cell 0 @ order 0
enumerated = mortie.norm2mort([5, 6, 7], [0, 0, 0], 4) # inside the shard
import warnings

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)

Style: import warnings inline in the method body, while the sibling test_out_of_domain_warns_and_clips uses the module-level pytest.warns. Move the import to the top of the file (it's stdlib, no reason to localize it) and/or use pytest.warns + pytest.warns(None)-style assertion consistently across the class. Minor, but it's the kind of inconsistency §4 ("match the surrounding code") calls out.


Generated by Claude Code

Comment on lines +808 to +817
def test_out_of_domain_warns_and_clips(self):
# cover with a cell outside the domain: warn, and clip to the domain.
shard = np.atleast_1d(mortie.norm2mort(0, 0, 0)) # base cell 0
inside = mortie.norm2mort([5, 6], [0, 0], 4)
outside = np.atleast_1d(mortie.norm2mort([7], [5], 4)) # base 5 — outside
cover = np.concatenate([np.atleast_1d(inside), outside])
with pytest.warns(UserWarning, match="outside"):
got = mortie.moc_not(cover, domain=shard)
# the clip makes it equal to complementing only the in-domain part.
np.testing.assert_array_equal(got, mortie.moc_minus(shard, inside))

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 warn-and-clip path is genuinely asserted (pytest.warns(UserWarning, match="outside")) — good, this is the strongest test in the class. Two coverage gaps in the same spirit, since this is the only "tricky" case:

  • inside (base 0) and outside (base 5) are disjoint whole base cells, so the clip is the easy case. The harder one — a cover cell coarser than domain that straddles the boundary (partially in, partially out) — is untested. That's the case where "clip away the out-of-domain part" actually does non-trivial work, and where the warn predicate's behavior is least obvious.
  • The clip assertion compares to moc_minus(shard, inside), i.e. it re-derives the expected value from the same inside array; it confirms the out-of-domain cell was dropped but, like test_default_domain_is_whole_sphere, leans on moc_minus rather than a pinned literal. Fine here given pytest.warns carries the real assertion.

Generated by Claude Code

@espg

espg commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Folded the Phase-2 self-review's diff-scoped findings (adb722b):

  • Empty-domain spurious warning fixed. moc_not(cover, domain=[]) now short-circuits to without emitting the (vacuously-true) "cells outside domain" warning. New test_empty_domain_returns_empty_without_warning.
  • Tautological default-domain test replaced with independent ground truth. test_default_domain_is_whole_sphere no longer asserts the verbatim implementation; it now pins that a cover confined to base cell 0 complements to every other base cell (1-11) in full, with the covered cells absent.
  • Hard clip case now tested. Added test_straddling_coarse_cover_cell_clips: a coarse cover cell that straddles the domain boundary (an order-1 cell covering an in-domain order-2 cell plus 3 out-of-domain siblings). Asserts the in-domain neighbor survives, the straddled cell is dropped, and the out-of-domain warning fires — exercising the clip on partial overlap rather than just whole disjoint cells.
  • Style: hoisted import warnings to module scope (was localized in one test method).

Left standing (non-blocking, judged correct as-is): the warn predicate's extra moc_minus(cover, domain) is intentional — covers are small and it's the cleanest "has out-of-domain cells" test; and it correctly flags straddling coarse cells, since those genuinely have out-of-domain parts that get clipped.

Both phases are complete and CI is green (test 3.10/3.11/3.12, ruff, CodSpeed, wheels). Final local run: cargo test 160 passed, pytest 401 passed / 8 skipped (pre-existing env skips), cargo fmt --check + flake8 --select=E9,F63,F7,F82 clean. Leaving in draft, marking waiting for your review.


Generated by Claude Code

@espg espg added the waiting label Jun 20, 2026
Comment thread mortie/tests/test_coverage.py
@espg

espg commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

I'd like an example jupyter notebook that documents these new moc / bmoc operations that we've enabled -- both here and in #53 . This notebook should be binder runable (i.e., on binder.org); this may require updating the repo (i.e., requirements.txt, environment.yml, etc) so that repo2docker (which is what binder uses) can run cleanly against our repo. You should tell me explicitly what updates you decided to make this work, and these updates should not infer with our existing packaging / publishing pipeline via toml.

For the notebook doc to be useful, it should have some narrative markdown describing what the operation (xor , not, etc) does for the morton indices, some example code showing it's use, and also some plot that visualize what is happening (i.e., before and after the operator). Feel free to ask any questions if you have them.

@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 fresh-context review of Phase 3 (6bd5993) — the docs notebook + Binder config. This phase is solid; the originating ask is met. Findings are two non-blocking robustness notes (inline); nothing requires a code change to land.

Pipeline untouched (the hard requirement). Confirmed git diff 6bd5993~1 6bd5993 -- pyproject.toml Cargo.toml .github/ environment.yml is empty — the change is purely additive (binder/environment.yml, binder/postBuild, examples/morton_set_algebra.ipynb, one README badge line). The "repo2docker reads only binder/ when present" isolation claim in the environment.yml header is correct, so the root dev env and the maturin/PyPI publish pipeline are not in repo2docker's path.

Build sanity. binder/postBuild is committed mode 100755 with a correct #!/bin/bash shebang and set -euo pipefail; python -m pip install . will compile the abi3/pyo3 extension using rustc/cargo from conda-forge rust on PATH. No stray artifacts in the commit (no *.so, .venv/, *~, target/).

Notebook correctness (read every cell).

  • Uses the current public API only — mort2polygon, mort2healpix, morton_coverage, moc_or/and/minus/xor/not. Verified against source: mort2polygon returns [lat, lon] rows (the helper's column_stack([latlon[:,1], latlon[:,0]]) → (lon,lat) is right), mort2healpix returns (cell_id, order) (unpacked as _, order correctly), morton_coverage(lats, lons, order=...) arg order matches. No stale len(str(abs(int(m))))-1 decimal-digit order inference anywhere.
  • All set-op orders are 5 — well under the order-18 BMOC ceiling, so no ValueError.
  • Both identity asserts are valid against the implementation: xor == or minus and, and not(a, domain) == minus(domain, a) (confirmed moc_not is literally moc_minus(domain, cover) in coverage.py:448). The whole-sphere default + UserWarning-on-clip narrative matches coverage.py:428–448.
  • Before/after plots present (show_op two-panel) for every verb; narrative descriptions are accurate.
  • Order-ceiling note is correct and matches reality (point path 29, set-ops 18) and links live issues #60 / #61 (both open, both about exactly this cap).
  • Badge/labpath URLs (mybinder.org/v2/gh/espg/mortie/HEAD?labpath=examples%2Fmorton_set_algebra.ipynb) are well-formed and consistent across README and the notebook's own header.

Conventions. Dependency-light by design (numpy + matplotlib only); no runtime dep added to pyproject.toml; terse and reviewable. Good.

Could not execute the notebook in this review env (no numpy installed here), so the assertions/plots were verified by reading the kernel against the source rather than by running — worth a clean Binder build before clearing draft.


Generated by Claude Code

Comment thread binder/postBuild
# the example notebooks import the in-tree version. Build isolation pulls the
# maturin build backend; the Rust toolchain comes from binder/environment.yml.
set -euo pipefail
python -m pip install .

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)

python -m pip install . is correct and the abi3/pyo3 build will find rustc/cargo from the conda-forge rust package on PATH. One robustness note (non-blocking): pip's default build isolation ignores the maturin you pinned in environment.yml and fetches the maturin backend fresh from PyPI into the isolated build env. That's the normal maturin flow and works, but it means (a) the maturin>=1.0,<2.0 pin in environment.yml is not what actually builds the wheel — the build-system requires in pyproject.toml is — and (b) it adds a PyPI fetch at build time. If you want the conda-pinned maturin to be authoritative and skip the network fetch, python -m pip install --no-build-isolation . would do it (maturin + a Rust toolchain are both already in the env). Either is fine for Binder; flagging so the env pin isn't mistaken for the source of truth.


Generated by Claude Code

Comment thread binder/environment.yml
channels:
- conda-forge
dependencies:
- python=3.11

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 notebook imports mortie in a Jupyter kernel, but the env has no jupyter/notebook/jupyterlab (and the README badge uses labpath=, i.e. JupyterLab). repo2docker auto-installs a Jupyter stack when none is declared, so this will still launch — but pinning it implicitly leaves the kernel/lab versions to repo2docker's defaults. Consider adding jupyterlab (or at least notebook) to make the labpath launch self-contained and reproducible. Non-blocking since repo2docker backfills it.


Generated by Claude Code

@espg

espg commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Phase 3 (6bd5993) adds the Binder-runnable documentation notebook you asked for, plus the self-review fold (2cb13b5).

What's in it

examples/morton_set_algebra.ipynb — documents all five MOC set-algebra verbs (moc_or/moc_and/moc_minus/moc_xor from #53 + moc_not from this PR). For each verb: narrative markdown on what it does to morton covers, runnable example code, and a before/after plot (inputs a,b on the left; the operation's surviving cells on the right). Two overlapping order-5 covers drive every example; xor and not carry inline identity asserts (xor == or\and, not(a,dom) == minus(dom,a)). It uses the current public API only (mort2polygon, mort2healpix, the moc_* funcs) — deliberately not the stale len(str(abs(int(m))))-1 order inference the older notebooks still have (that cleanup is tracked in #66).

Repo updates I made (and why) — explicitly, per your ask

Everything is purely additive; I changed nothing in pyproject.toml, Cargo.toml, .github/workflows/, or the root environment.yml, so the maturin/PyPI publish pipeline is untouched (verified: the commit's diff against its parent touches only the four files below).

  1. binder/environment.yml (new) — the repo2docker build env: python, numpy, matplotlib, jupyterlab, plus rust + maturin (conda-forge) so the extension can be compiled on Binder. Key point: because a binder/ directory exists, repo2docker reads only binder/ and ignores the root environment.yml — so the conda dev env and the toml pipeline are isolated from the Binder config by construction.
  2. binder/postBuild (new, executable) — python -m pip install ., which compiles + installs mortie from the checked-out source (the moc xor/not here aren't on PyPI yet, so Binder must build from source rather than pip install mortie).
  3. README.md — one Binder badge pointing at the new notebook.

How it was tested

Built the extension in a venv (maturin develop --release) and executed the notebook end-to-end with jupyter nbconvert --to notebook --execute — all 20 cells run clean, outputs (8 plots/values) are embedded in the committed file. yaml validated. The one thing I couldn't verify locally is a real mybinder.org image build (it's an external service) — worth a smoke-launch from the badge before this clears draft.

Self-review fold (2cb13b5)

The fresh-context review landed it as-is with two non-blocking config notes; I took one and explain the other:

  • Folded: pinned jupyterlab in binder/environment.yml so the labpath= launch is reproducible rather than relying on repo2docker's backfill.
  • Left as-is, with reasoning: the review suggested --no-build-isolation in postBuild to make the env's maturin pin authoritative. I kept build isolation on — with it off, pip needs the maturin build backend importable from the conda env, which conda-forge's maturin doesn't reliably provide; the isolated build (backend from PyPI, rust/cargo from conda on PATH) is the robust path. The maturin line in the env is just toolchain availability.

Note on CI

The CodSpeed check shows a test_coverage_triangle_order4 regression, but its own report says "No successful run was found on main022ca5f was used instead as the comparison base … changes unrelated to this pull request" — it's an unrelated-base artifact, and this phase is docs-only (no Rust/Python source touched), so it can't have moved that benchmark. Flagging rather than acting.

All phases (xor, not, notebook) are complete and self-reviewed; leaving in draft and marking waiting for your review.


Generated by Claude Code

@espg espg added the waiting label Jun 25, 2026
@espg espg marked this pull request as ready for review June 25, 2026 20:15
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