Skip to content

Batch MOC set ops: mocs_and / mocs_intersect + scalar moc_intersects (issue #173) - #174

Merged
espg merged 17 commits into
mainfrom
claude/173-mocs-and
Aug 9, 2026
Merged

Batch MOC set ops: mocs_and / mocs_intersect + scalar moc_intersects (issue #173)#174
espg merged 17 commits into
mainfrom
claude/173-mocs-and

Conversation

@espg

@espg espg commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #173. Phase 2a of the batch MOC set-op family: mocs_and + mocs_intersect, plus the scalar twin moc_intersects. Implemented to the leans of the design-take comment, which espg approved implementing against. 2b (mocs_or/mocs_minus/mocs_xor/mocs_not) stays deferred until a consumer appears.

Un-blocked 2026-08-09. This PR originally stacked on claude/170-batch-module (base sha 8798a8d728ec60664df76d3d7ee8442c105df9c4) so the entry points could land in mortie/batch.py. #172 merged (squash dfcb88f7), GitHub retargeted this PR to main, and the stacked history was reconciled by merge commit (merge main after #172 — main's side taken for all #170 content, this branch's for the 2a additions; no force-push, per repo rules). CI is green on the merged head.

What this does

  • mocs_and(a, values, offsets) — the 1×N broadcast of moc_and: one shared operand against N ragged MOCs, ragged (values, offsets) out, one boundary crossing. The structural win is the hoist: the scalar normalizes + BMOC-encodes both operands per call, so a Python loop rebuilds the shared operand's BMOC N times; the batch builds it once (canonical_bmoc(&normalize(a)), split out of build_bmoc) and borrows it per item across the rayon threads — healpix 0.3.3's ops::and(lhs: BorrowedBmoc, rhs: BorrowedBmoc) takes borrowed views, so sharing is free. Per-item results are byte-identical to the scalar (normalize is deterministic, so the hoisted BMOC is the one the scalar would build; pinned by parity tests in both operand orders).
  • mocs_intersect(a, values, offsets) -> bool[N] — the predicate, deliberately not built on BMOC internals: a normalized cover is a sorted list of disjoint half-open ranges at depth 29 (start = nested << 2*(29-depth), the mapping documented at the top of src_rust/src/moc.rs), so per item it is a range-overlap merge walk (canonical_overlap) — no intersection materialized, early exit on first overlap. The shared operand is normalized and range-decoded once (canonical_ranges), and the walk seeks its start with partition_point, so a narrow item costs O(log m + window + n) rather than a scan of the shared side (both halves of that arrived via review: the first cut re-decoded the shared side per comparison and sat ~10x behind the materialize batch; the seek closed the reviewer's O(N·|a|) finding). Output is dense bool[N] (no offsets), same lowest-index deterministic error handling.
  • moc_intersects(a, b) -> bool — the scalar twin, the same canonical_overlap kernel with one entry point per arity; the batch parity tests use it as their oracle.

House pattern throughout (src_rust/src/moc/batch.rs): serial layout pre-validation (validate_layout, which is validate_batch with order/budget off — so the offsets contract and its error strings are shared verbatim with mocs_to_orders), chunked assembly (CHUNK = 2048), catch_unwind per item and around the hoisted shared-operand kernels (a malformed word in a is a ValueError named shared operand:, not an escaping PanicException), lowest-index failure surfaced (run_moc generic over the kernel's return type; extend_flags mirrors extend_chunk for the bool path), rayon under py.allow_threads.

Decisions to note:

  • No max_cells on either op — decided, not forgotten: the densify budget exists because to_order has an exponential blow-up term; an intersection is bounded by its inputs, and the scalar set ops carry no budget either.
  • Empty slots keep position: offsets[i] == offsets[i+1] for an empty intersection, all slots empty for an empty shared operand (layout still validated first) — so bool[N] and the ragged form agree on N, matching mocs_to_orders' convention.
  • Compaction trap (issue Batch MOC set operations: mocs_and / mocs_intersect and the 1xN broadcast family #173 design question 3): the predicate never compares covers for identity, only geometric overlap, so it dodges the trap by construction; a fully-occupied-subtree case pins it through both the materialize and predicate paths, with the compacted quartet on the shared side and the item side (Rust + Python).

Phases

  • Phase 1 — Rust kernels: canonical_ranges + canonical_overlap + moc_intersects + the build_bmoc split in moc.rs; mocs_and, mocs_intersect, validate_layout, generic run_moc, extend_flags in moc/batch.rs; rust_moc_intersects / rust_mocs_and / rust_mocs_intersect pyfunctions in lib.rs; Rust unit tests.
  • Phase 2 — Python surface: mortie.batch.mocs_and / mocs_intersect, mortie.moc.moc_intersects, flat exports, cross-linked See Also (scalar ↔ plural, per the Resurrect mortie/batch.py as the consolidated home for the bulk operators, with cross-linked scalar/plural docstrings #170 convention), docs pages (docs/api/batch.md, docs/api/moc.md).
  • Phase 3 — pytest suite (mortie/tests/test_moc_setops_batch.py): per-item byte parity over randomized MOCs / Antarctic basin fixtures / mixed-order covers in both operand orders; predicate ≡ non-empty mocs_and spans; fully-occupied-subtree pin; empty batch / single item / empty item / one-word items / whole-sphere and empty shared operand; determinism; layout errors; GIL release.
  • Phase 4 — benchmark (benchmarks/measure_mocs_and.py) + the predicate range-decode hoist it exposed; memory posture measured with the input-copy term named.
  • Adversarial self-review folded — 8 findings posted, all 8 addressed with fix commits (fold review: …); replies on each thread.

How it was tested

All green locally in an isolated venv, at the final head:

  • maturin develop --release
  • cargo test — 331 passed, cargo fmt --check clean, cargo clippy --all-targets — no warnings in the files this PR touches (the remaining 6 are pre-existing in prefix_trie.rs / coverage/tests.rs / geo2mort.rs, untouched here and left per the don't-fix-unrelated rule)
  • pytest -v — 1363 passed, 16 skipped (full suite; the 17 tests in test_moc_setops_batch.py included)
  • flake8 mortie --select=E9,F63,F7,F82 clean, numpydoc lint mortie/*.py clean

Benchmarks

benchmarks/measure_mocs_and.py, 10 cores, macOS/arm64, rayon on all cores (the ratio is the end-to-end gap a caller sees; RAYON_NUM_THREADS=1 isolates the algorithmic term). N ~1°-quad granule covers at order 6 (~4 cells each) against a 1166-cell order-8 AOI quad cover, 7–8% hit rate — the zagg stored-MOC shape. Scalar loop is moc_and(aoi, item) per item (and .size > 0 for the predicate arm).

N mocs_and loop speedup mocs_intersect loop speedup
1,000 0.000 s 0.048 s 121x 0.000 s 0.047 s 239x
10,000 0.003 s 0.464 s 140x 0.001 s 0.460 s 467x
100,000 0.027 s 4.630 s 169x 0.009 s 4.653 s 514x

Far above phase 1's 2.96–5.80x (mocs_to_orders had nothing shared to hoist; here the scalar loop pays the AOI's normalize+encode N times) — and the shared operand's size is the dial: the same N=10k against a 9-cell order-2 shared operand gives 14.6x / 16.3x, which is the honest floor for one-cell-AOI call sites. The predicate now beats the materialize form, as it should: no BMOC build, no result encode, partition_point seek per item.

Memory (--mem 100000): 3.8 MiB ragged input, 1.1 MiB ragged result, growth ≥ 5–9 MiB across repeated runs over the resident inputs for one mocs_and + one mocs_intersect call — the input copy (the binding's to_vec(), named per the #162 precedent) plus the result plus one chunk. ru_maxrss is a high-water mark, so the growth figure is a noisy lower bound, and is labeled as such by the script. The predicate materializes nothing but still pays the input copy; bool[N] is 100 KB at N=100k.

Questions for review

  • Pre-existing flake, flagged not fixed (§4): dissolve::tests::sub_hemisphere_cover_still_dissolves fails ~10–12% of runs (4/40 on this branch, 5/40 at the untouched base sha 8798a8d, single-test invocations) — dissolve(&cover, 1) intermittently returns the hemisphere-guard Err for a fixed 4-base-cell cover. dissolve.rs is untouched by this PR; the nondeterminism is presumably the HashMap iteration order feeding ring stitching. It deserves its own issue — leaving that call to espg since opening one is outside this PR's scope.
  • The predicate's honest framing (carried from the design take): proving a miss still takes the walk from the seek point, so on mostly-empty workloads the win over moc_and(...).size is the skipped BMOC build/encode/allocation plus the seek, not the short-circuit alone. The docstring says this explicitly.
  • run_moc's fallback panic message changed from "moc densify panicked" to "moc kernel panicked" now that three ops share it; no test pinned the old string.

@espg espg added implement blocked flag for claude labels Aug 9, 2026
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.62%. Comparing base (dfcb88f) to head (27ce479).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #174      +/-   ##
==========================================
+ Coverage   95.58%   95.62%   +0.03%     
==========================================
  Files          17       17              
  Lines        1698     1713      +15     
==========================================
+ Hits         1623     1638      +15     
  Misses         75       75              
Flag Coverage Δ
unittests 95.62% <100.00%> (+0.03%) ⬆️

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

Files with missing lines Coverage Δ
mortie/__init__.py 90.32% <ø> (ø)
mortie/batch.py 100.00% <100.00%> (ø)
mortie/moc.py 100.00% <100.00%> (ø)

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 dfcb88f...27ce479. Read the comment docs.

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

Comment thread src_rust/src/moc.rs Outdated
};
let mut j = 0;
let (mut sb, mut eb) = decode(b[0]);
for &(sa, ea) in a_ranges {

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 walk is O(m+n) as documented, but m here is the shared operand, and the batch pays it per item — so mocs_intersect is O(N·|a|), not O(N·|item|). The outer loop is for &(sa, ea) in a_ranges, and the only early exits are "overlap found" and "b exhausted". An item lying entirely after a never satisfies eb <= sa, so it breaks out of the inner loop once per a range and scans all of a_ranges before returning false — the common case for a global corpus against a regional AOI.

Measured on this branch, N=100k ~4-cell item covers (the benchmark's own corpus), varying only the shared operand:

shared operand len(a) mocs_intersect
AOI @6 270 9.2 ms
AOI @8 1166 15.0 ms
AOI @10 5014 37.2 ms
AOI @11 8939 58.8 ms

That is a straight line in len(a) — the incremental slope is 5.7e-5 µs per shared cell per item across all three steps, and at len(a)=8939 the len(a) term is ~87% of the per-item cost. In other words the hoist removed the shared operand's encode from the per-item loop but left its scan there.

a_ranges is sorted and already decoded, so this is cheap to fix: for each item range, binary-search the entry point instead of walking to it —

let mut i = a_ranges.partition_point(|&(_, ea)| ea <= sb);

— which makes the item side O(|item| · log|a|). For the call sites the issue surveyed (a one-word item against a large AOI: zagg/grids/aoi.py:250, moczarr/open.py:149) that is ~11 comparisons instead of ~1166.

Not a correctness bug — the two-pointer walk itself is right (I fuzzed it 6k pairs across depths 0–29 including the order-28/29 suffix region and Kind::Point words, no mismatches against !moc_and(..).is_empty()). But the docstrings sell this as the cheap op, and this is the term that will dominate for any caller whose shared cover is large, which is every consumer named in the issue.

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. canonical_overlap now seeks its left starting point with a_ranges.partition_point(|&(_, ea)| ea <= sb) before walking — disjoint sorted ranges have ascending ends, so the seek lands on exactly the range the serial walk would have reached, and a narrow item costs O(log m + window + n) instead of O(m). An item lying entirely after a now costs the binary search alone (the seek returns a_ranges.len() and the loop body never runs), which was the worst case you named.

Comment thread src_rust/src/moc/batch.rs
{
catch_unwind(AssertUnwindSafe(kernel))
.map_err(|e| format!("moc {i}: {}", crate::panic_msg(e, "moc densify panicked")))
.map_err(|e| format!("moc {i}: {}", crate::panic_msg(e, "moc kernel panicked")))

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)

Generalising run_moc over three kernels makes its own doc comment (just above, unchanged) false:

Defensive: validate_batch screens the one input the kernel cannot take (an out-of-range order), so no known input reaches the panic arm — which is why the tests drive this with an injected panicking kernel instead

That held while to_order was the only kernel. validate_layout does not screen morton words, and both new kernels decode them (build_bmoc / normalizemort2nested, which panics on the empty sentinel and on an invalid prefix). On this branch:

>>> vals = np.array([good, 0, good], np.uint64)
>>> mortie.mocs_and(a, vals, [0, 1, 2, 3])
ValueError: moc 1: Morton index cannot be zero
>>> mortie.mocs_intersect(a, vals, [0, 1, 2, 3])
ValueError: moc 1: Morton index cannot be zero

So for mocs_and / mocs_intersect the panic arm is a reachable, caller-triggerable path — and in fact the only per-item error class these two ops have. Two consequences worth folding in:

  1. The comment should stop claiming no known input reaches it, and should stop offering that as the reason the tests use an injected kernel — for these two ops a real input reaches it end-to-end through rayon, and nothing tests that (see the separate note on test_offsets_must_exactly_cover_the_values).
  2. The renamed fallback string ("moc kernel panicked", flagged under Questions for review) is fine, but note it is only the fallback — the messages users will actually see here come from panic_msg, so no test pins the new one either way.

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. The doc now says plainly that the panic arm is a live path for the set ops — layout validation does not screen morton words, and a malformed word (e.g. the empty word 0) panics in mort2nested and surfaces as the item-named ValueError — while remaining defensive for the densify. Both regimes are now tested: the injected-kernel tests stay, and malformed_word_names_lowest_index_across_chunks drives the real rayon path (see the reply on the determinism finding).

Comment thread src_rust/src/moc/batch.rs Outdated
return Ok(out);
}
// The hoist: one normalize + BMOC encode for the shared operand.
let a_bmoc = canonical_bmoc(&normalize(a));

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 hoist runs outside run_moc, so a malformed word in the shared operand escapes the batch's documented error posture entirely. mocs_and's Python docstring promises ValueError, "Fail-fast, naming the lowest-index offending MOC"; what a caller actually gets is:

>>> mortie.mocs_and(np.array([0], np.uint64), vals, offs)
pyo3_runtime.PanicException: Morton index cannot be zero

Same for mocs_intersect via canonical_ranges(&normalize(a)) on line 353. Two problems, neither fatal but both cheap to fix:

  • The exception type is outside the documented contract (Raises: ValueError), and it crosses py.allow_threads as an unwind rather than a returned Err.
  • The message is byte-identical to the item-side one minus the moc {i}: prefix, so a caller cannot tell whether a or an item was the bad operand — which is exactly the attribution the rest of this module goes out of its way to provide.

The scalar moc_and panics the same way, so this is not a regression. But the batch is the entry point that made the naming promise, and a is the single operand it currently cannot name. Wrapping the two hoists in catch_unwind and mapping to Err(format!("shared operand: {}", ...)) is a few lines and makes the Raises section true. Worth at least a test pinning whatever behaviour you decide is correct — there is none today for a bad a.

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. Both hoists (mocs_and's canonical_bmoc(&normalize(a)) and mocs_intersect's canonical_ranges(&normalize(a))) are now wrapped in the same catch_unwind treatment as the per-item kernels, surfacing as ValueError: shared operand: <panic message> — named as the shared operand's failure, distinguishable from any item's, and catchable (the issue #108 posture). The # Errors sections on both entry points document it, and it is pinned in Rust (malformed_word_names_lowest_index_across_chunks) and Python (test_malformed_word_is_a_named_value_error).

Comment thread benchmarks/measure_mocs_and.py Outdated
assert hits[i] == loop_hits[i]

frac = np.mean(hits)
print(f"n={n:>7} hit-rate={frac:5.1%} moc cells={len(values)}")

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 headline speedups are governed by a dial this script never prints and never varies: len(aoi). The per-N line reports moc cells={len(values)} (the item side) but not the shared operand's size, and the AOI is hard-wired to one wide order-8 quad. On this branch that AOI is 1166 cells against ~4-cell items — and it is essentially the entire scalar-loop baseline:

scalar loop, 2000 calls, a = the order-8 AOI (1166 cells): 0.091 s
scalar loop, 2000 calls, a = a single order-1 cell:        0.002 s

Run the same comparison at N=10k with the shared operand shrunk to one cell and the table collapses:

order-8 AOI : batch 0.003 s   loop 0.456 s   -> 156x
1-cell a    : batch 0.0018 s  loop 0.0133 s  ->   7.3x

7.3x is back inside phase 1's 2.96–5.80x band. The PR body reads the 85 → 183 → 161 curve as "the hoist's once-per-batch term amortizes", but the ratio is roughly N·|a| / (|a| + N·|item|): it moves with |a| at least as strongly as with N, and issue #173's acceptance criterion ("report the gap as a curve over N, not a single ratio") is only interpretable if |a| is stated alongside. Two cheap additions: print len(aoi) in the per-N line, and add one small-|a| row so the floor is visible.

Second, the batch arm runs on 10 cores while the loop arm is serial, so rayon is folded into every ratio printed. The issue's thesis is specifically that the hoist (not the core count) is the structural win; a RAYON_NUM_THREADS=1 arm would separate "one boundary crossing + one BMOC build" from "×10 cores" and is what would actually demonstrate it.

Minor, same file: the parity check samples only indices 0, n//2, n-1, so the benchmark would not notice a chunk-seam bug at index 2048 — worth spot-checking CHUNK and CHUNK-1 too, since the batch is chunked at 2048 and every sampled index misses the seam at every N in the sweep.

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

Addressed in the script and in the PR body's framing:

  • every result row now prints aoi cells=<|a|> (order K) next to the item-side count, and aoi_order=K on the command line varies it;
  • the parity spot-check now includes the 2048/2047 chunk-seam indices whenever N crosses the seam;
  • the module docstring carries both caveats explicitly — the ratio is driven by |a| (with your one-cell ~7x contrast noted) and folds all-core rayon against a serial loop, with RAYON_NUM_THREADS=1 named as the way to isolate the algorithmic term.

The PR body's benchmark table now states |a| for the quoted runs. I did not add an automatic |a|-sweep or a single-thread arm to the default run — the default stays the consumer-shaped case — since the knobs are now one argument away and documented; if you want either swept by default, happy to extend.

Comment thread benchmarks/measure_mocs_and.py Outdated
peak = rss_mb()
out_mb = (out_vals.nbytes + out.nbytes + hits.nbytes) / (1024 * 1024)
print(f"n={n} input={in_mb:.1f} MiB result={out_mb:.1f} MiB "
f"resident-before={base:.1f} MiB peak={peak:.1f} MiB "

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)

rss_mb() returns ru_maxrss, which is a high-water mark, not current RSS. So base is "peak so far", and growth = peak - base is not the ops' footprint — it is how far the ops exceeded whatever corpus() already peaked at. That makes the reported growth a lower bound which silently collapses toward 0 whenever the corpus build's transients out-peak the calls (at N=100k corpus() runs polygons_to_morton_mocs over 400k vertices, so this is not hypothetical). The label makes it worse: resident-before={base} names a watermark as resident.

This matters because the number is quoted as measured fact in the mocs_and docstring — "5.3 MiB of peak growth over the resident inputs" — and #162 exists precisely because a memory claim travelled without its measurement basis. Re-running --mem 100000 on this branch here gives growth=4.8 MiB, so the figure is also machine/allocator dependent and reads as more precise than it is.

Suggested: take the baseline from current RSS (ps -o rss= -p <pid> on darwin, /proc/self/statm on Linux) and keep ru_maxrss only for the peak — that turns peak - base into a real "growth over resident" — or, if you would rather not add the platform branch, rename the field to peak-before and say in the docstring that the figure is a lower bound and approximate.

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. rss_mb()'s docstring now states it reads a high-water mark and that growth computed from it is a lower bound that collapses toward zero when an earlier phase out-peaked the calls; the printout labels are now watermark-before= / watermark-after= / growth>=... (watermark lower bound), and the mocs_and docstring's measured sentence carries the same qualifier. The PR body's memory paragraph is updated to match.

Comment thread mortie/batch.py Outdated
by intersecting once and testing membership — which would silently drop
dense regions that compact to a parent cell.

Memory: nothing is materialized per item; peak is the input copy the

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)

"nothing is materialized per item" is not true — nor is the summary line's "materializing nothing", nor moc.py's new module line calling this "the allocation-free intersection predicate".

Every item goes through normalize(item) inside mocs_intersect, and normalize allocates a Vec<(u64, u64, u64, u8)>32 bytes per input word, 4x the item's own bytes — plus the output Vec<u64> it returns. That is a real per-item allocation, and it is the dominant per-item allocation for a large item MOC.

The Rust docstring on moc::batch::mocs_intersect gets this right — "allocating nothing past the item's normalize" — and so does moc::moc_intersects ("no allocation past the normalize"). The qualifier was dropped on the way up to Python, in the one place a user reads it.

It is a small term in absolute bytes (the allocations die inside the rayon closure, so only ~one per worker thread is live at a time), but it is exactly the shape of unqualified memory sentence #162 was filed about, and the fix is to carry the Rust wording verbatim: e.g. "allocates nothing per item beyond that item's normalize (~4x the item's bytes, transient, one per worker)". The bool[N]-only claim about the output is fine as written.

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 all four places: mocs_intersect's extended summary now says "no intersection is materialized, and the only per-item allocation is that item's normalize scratch"; its Memory paragraph names the in-flight normalize scratch (one chunk at most); moc_intersects's summary says "materializes no intersection — both covers are normalized (the only allocation) and walked"; and moc.py's module line no longer calls it "allocation-free" (now "the intersection predicate (no BMOC build, no materialized result)"). The Rust docstrings' "past the item's normalize" qualifier is now the Python surface's claim too.

Comment thread mortie/tests/test_moc_setops_batch.py Outdated
stop.set()
b.join()

assert progressed > 1000, (

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 assertion cannot fail. The instrument was copied from test_moc_batch.test_gil_released_during_batch but the workload was not: the 20 iterations here total 7.8 ms of Rust (400 tiny order-6 MOCs against an order-7 AOI — mocs_and + mocs_intersect are ~0.2 ms each at this size), whereas the sibling test whose > 1000 threshold this reuses verbatim spends ~3.1 s (a single mocs_to_orders(..., 12) call on that corpus measures 155 ms here).

At an ~8 ms window the threshold is met even with the GIL held the whole time, because CPython's 5 ms switch interval hands the counter thread a slice regardless. Measured on this machine with a pure-Python GIL-holding busy-wait of the same duration:

GIL-held busywait   2.5 ms -> progressed      0
GIL-held busywait   8.0 ms -> progressed  69823   <- the window this test creates
GIL-held busywait  20.0 ms -> progressed 144338
GIL-held busywait  50.0 ms -> progressed 287215

69,823 is 70x the threshold, so removing py.allow_threads from both new pyfunctions would leave this test green — it does not test what its name and docstring claim, and issue #173's "GIL genuinely released" acceptance item is not actually covered.

Two ways out: raise the item count / AOI order until the Rust window is on the order of a second (matching the sibling's posture), or make it relative rather than absolute — record progressed for a same-wall-time pure-Python control in the same process and assert the batch arm beats it by a wide multiple. The relative form is the more robust one, since it does not silently rot when the kernels get faster (which this PR's own hoist just did).

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 with a stronger instrument rather than just a heavier corpus. The rewritten test (1) calibrates the counter's free rate under time.sleep (which releases the GIL), (2) samples progress around each individual call so inter-call scheduling gaps never count toward the total, over at least 0.3 s of accumulated in-call time on a heavy corpus (order-9 AOI against its own ~10^5 flat cells), and (3) asserts progress exceeds 20% of the free rate over the in-call time. With allow_threads removed, the counter starves for the whole call — at most one stray ~5 ms interpreter slice leaks in around the call boundary — so the assertion fails by orders of magnitude instead of passing on the gaps, which is exactly the failure mode you demonstrated against the fixed > 1000 threshold.

)


def test_offsets_must_exactly_cover_the_values():

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)

One item on issue #173's acceptance list is not reached by this suite: "Lowest-index determinism across chunk boundaries, many runs, with differing failure classes."

What exists is (1) these serial pre-validation errors on 2–3 item batches, and (2) the Rust-side predicate_panic_is_caught_and_named_by_lowest_index, which calls extend_flags directly on a hand-built Vec<Result<..>> — no rayon, no chunking, no kernel, and indices 0/1/2/7, all far below CHUNK = 2048. So the property the acceptance item is about — that rayon's schedule cannot change which MOC gets named when failures land in different chunks — is pinned for neither mocs_and nor mocs_intersect.

Unlike mocs_to_orders, these ops now make that directly constructible, because they have a real per-item failure class reachable from input (a malformed morton word — see the note on run_moc):

vals = np.full(3000, good_word, np.uint64)
vals[10] = 0                                  # chunk 0
vals[2500] = np.uint64(13) << np.uint64(60)   # chunk 1, invalid prefix
off = np.arange(3001, dtype=np.int64)

I ran that 30x against both ops on this branch: always moc 10: Morton index cannot be zero, never the chunk-1 index. So the implementation is correct — there is just nothing holding it there. A ~30-iteration loop asserting the exact message would close the acceptance item and give the reachable panic path its first end-to-end test through rayon.

Two smaller gaps in the same area, take or leave:

  • mocs_and_spans_more_than_one_chunk covers the seam on the success path only; no test crosses a chunk boundary with a failure in either op.
  • The compaction trap is only ever placed on the shared side (children as a, in both the Python and Rust broadcast tests). The item side is the other half of canonical_overlap — the lazily-decoded b cursor — and never sees a cover that compacts away all of its input words. test_moc_intersects_fully_occupied_subtree does test both orders, but only through the scalar.

(Aside, found while building the repro above: an invalid prefix also reports "Morton index cannot be zero", because morton.rs::mort2nested collapses both DecodeError variants into the one panic message. Pre-existing, not this PR — noting it only so "differing failure classes" is not read as something the kernel can currently produce.)

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

Addressed, on the construction you supplied. malformed_word_names_lowest_index_across_chunks (Rust) drives the real rayon path for both new ops: valid one-word items with the empty word 0 injected at index 10 (chunk 0) and index CHUNK + 2 (chunk 1), asserted over 10 repeated runs to always name moc 10: — plus the second failure class, a malformed shared operand, asserted to surface as shared operand: rather than item-attributed or escaping as a panic. Python-side, test_malformed_word_is_a_named_value_error pins the same contract through the bindings as a catchable ValueError.

The compaction-trap gap is also closed: broadcast_hits_the_fully_occupied_subtree now runs the quartet on the item side too (shared = one deep cell, item = the fully-occupied subtree), through both the materialize and predicate paths.

@codspeed-hq

codspeed-hq Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 71 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing claude/173-mocs-and (27ce479) with main (dfcb88f)

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 Aug 9, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Status: all four phases plus the adversarial-review fold are complete and pushed — mocs_and / mocs_intersect / moc_intersects landed with parity, compaction-trap, determinism, malformed-word and GIL coverage, benchmarks (121–169x materialize, 239–514x predicate against a 1166-cell shared operand; 14–16x floor at |a|=9) and a measured memory posture; all 8 review findings were folded with replies on their threads, the full local gate is green (cargo test 331, pytest 1363, fmt/clippy/flake8/numpydoc clean), and the PR stays draft + blocked awaiting #172's merge (then a retarget to main by a later run). One flag for espg under "Questions for review": a pre-existing ~10% flake in dissolve::tests::sub_hemisphere_cover_still_dissolves, reproduced at the untouched base sha, left unfixed per §4.

Base automatically changed from claude/170-batch-module to main August 9, 2026 06:04
@espg

espg commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

The pre-existing dissolve flake flagged under "Questions for review" resolves to the existing issue #155 (same fingerprint, filed 2026-08-07) — this PR's repro numbers are now recorded there. No action needed on this PR.

@espg
espg merged commit 6e51659 into main Aug 9, 2026
24 checks passed
@espg
espg deleted the claude/173-mocs-and branch August 9, 2026 06:48
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.

Batch MOC set operations: mocs_and / mocs_intersect and the 1xN broadcast family

1 participant