Batch MOC set ops: mocs_and / mocs_intersect + scalar moc_intersects (issue #173) - #174
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
| }; | ||
| let mut j = 0; | ||
| let (mut sb, mut eb) = decode(b[0]); | ||
| for &(sa, ea) in a_ranges { |
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
🤖 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.
| { | ||
| 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"))) |
There was a problem hiding this comment.
🤖 from Claude (review)
Generalising run_moc over three kernels makes its own doc comment (just above, unchanged) false:
Defensive:
validate_batchscreens the one input the kernel cannot take (an out-of-rangeorder), 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 / normalize → mort2nested, 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 zeroSo 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:
- 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). - 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.
There was a problem hiding this comment.
🤖 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).
| return Ok(out); | ||
| } | ||
| // The hoist: one normalize + BMOC encode for the shared operand. | ||
| let a_bmoc = canonical_bmoc(&normalize(a)); |
There was a problem hiding this comment.
🤖 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 zeroSame 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 crossespy.allow_threadsas an unwind rather than a returnedErr. - The message is byte-identical to the item-side one minus the
moc {i}:prefix, so a caller cannot tell whetheraor 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.
There was a problem hiding this comment.
🤖 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).
| assert hits[i] == loop_hits[i] | ||
|
|
||
| frac = np.mean(hits) | ||
| print(f"n={n:>7} hit-rate={frac:5.1%} moc cells={len(values)}") |
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
🤖 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, andaoi_order=Kon 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=1named 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.
| 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 " |
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
🤖 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.
| 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 |
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
🤖 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.
| stop.set() | ||
| b.join() | ||
|
|
||
| assert progressed > 1000, ( |
There was a problem hiding this comment.
🤖 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).
There was a problem hiding this comment.
🤖 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(): |
There was a problem hiding this comment.
🤖 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_chunkcovers 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 (
childrenasa, in both the Python and Rust broadcast tests). The item side is the other half ofcanonical_overlap— the lazily-decodedbcursor — and never sees a cover that compacts away all of its input words.test_moc_intersects_fully_occupied_subtreedoes 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.)
There was a problem hiding this comment.
🤖 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.
Merging this PR will not alter performance
Comparing Footnotes
|
|
🤖 from Claude Status: all four phases plus the adversarial-review fold are complete and pushed — |
|
🤖 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. |
Closes #173. Phase 2a of the batch MOC set-op family:
mocs_and+mocs_intersect, plus the scalar twinmoc_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 sha8798a8d728ec60664df76d3d7ee8442c105df9c4) so the entry points could land inmortie/batch.py. #172 merged (squashdfcb88f7), GitHub retargeted this PR tomain, 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 ofmoc_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 ofbuild_bmoc) and borrows it per item across the rayon threads — healpix 0.3.3'sops::and(lhs: BorrowedBmoc, rhs: BorrowedBmoc)takes borrowed views, so sharing is free. Per-item results are byte-identical to the scalar (normalizeis 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 ofsrc_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 withpartition_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 densebool[N](no offsets), same lowest-index deterministic error handling.moc_intersects(a, b) -> bool— the scalar twin, the samecanonical_overlapkernel 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 isvalidate_batchwith order/budget off — so the offsets contract and its error strings are shared verbatim withmocs_to_orders), chunked assembly (CHUNK = 2048),catch_unwindper item and around the hoisted shared-operand kernels (a malformed word inais aValueErrornamedshared operand:, not an escapingPanicException), lowest-index failure surfaced (run_mocgeneric over the kernel's return type;extend_flagsmirrorsextend_chunkfor the bool path), rayon underpy.allow_threads.Decisions to note:
max_cellson either op — decided, not forgotten: the densify budget exists becauseto_orderhas an exponential blow-up term; an intersection is bounded by its inputs, and the scalar set ops carry no budget either.offsets[i] == offsets[i+1]for an empty intersection, all slots empty for an empty shared operand (layout still validated first) — sobool[N]and the ragged form agree on N, matchingmocs_to_orders' convention.Phases
canonical_ranges+canonical_overlap+moc_intersects+ thebuild_bmocsplit inmoc.rs;mocs_and,mocs_intersect,validate_layout, genericrun_moc,extend_flagsinmoc/batch.rs;rust_moc_intersects/rust_mocs_and/rust_mocs_intersectpyfunctions inlib.rs; Rust unit tests.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).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-emptymocs_andspans; fully-occupied-subtree pin; empty batch / single item / empty item / one-word items / whole-sphere and empty shared operand; determinism; layout errors; GIL release.benchmarks/measure_mocs_and.py) + the predicate range-decode hoist it exposed; memory posture measured with the input-copy term named.fold review: …); replies on each thread.How it was tested
All green locally in an isolated venv, at the final head:
maturin develop --releasecargo test— 331 passed,cargo fmt --checkclean,cargo clippy --all-targets— no warnings in the files this PR touches (the remaining 6 are pre-existing inprefix_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 intest_moc_setops_batch.pyincluded)flake8 mortie --select=E9,F63,F7,F82clean,numpydoc lint mortie/*.pycleanBenchmarks
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=1isolates 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 ismoc_and(aoi, item)per item (and.size > 0for the predicate arm).mocs_andmocs_intersectFar above phase 1's 2.96–5.80x (
mocs_to_ordershad 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_pointseek 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 onemocs_and+ onemocs_intersectcall — the input copy (the binding'sto_vec(), named per the #162 precedent) plus the result plus one chunk.ru_maxrssis 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
dissolve::tests::sub_hemisphere_cover_still_dissolvesfails ~10–12% of runs (4/40 on this branch, 5/40 at the untouched base sha8798a8d, single-test invocations) —dissolve(&cover, 1)intermittently returns the hemisphere-guardErrfor a fixed 4-base-cell cover.dissolve.rsis untouched by this PR; the nondeterminism is presumably theHashMapiteration order feeding ring stitching. It deserves its own issue — leaving that call to espg since opening one is outside this PR's scope.moc_and(...).sizeis 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.