Skip to content

Strict input validation family-wide (issue #194) - #213

Open
espg wants to merge 20 commits into
mainfrom
claude/194-strict-validation
Open

Strict input validation family-wide (issue #194)#213
espg wants to merge 20 commits into
mainfrom
claude/194-strict-validation

Conversation

@espg

@espg espg commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Closes #194.

Unifies the family's input-validation posture on the strict form, per the ruling recorded on the issue (option (a), confirmed 2026-08-24; trigger fired by PR #195's merge, 4900a7e): float-typed word/offset arrays are refused rather than truncated, out-of-range values are refused before any narrowing cast rather than wrapped, and every refusal names the parameter and the offending value — the toc module's existing _as_offsets/_as_u64 discipline, hoisted to a shared home (mortie/_validate.py) and applied at the polymorphic choke points from #187/#195, retiring the batch family's silent np.asarray(..., dtype=np.int64) coercions.

The posture caught a live in-repo bug while landing. test_mort_inverse.py::TestMort2Geo::test_array_input built its morton array from a mixed Python-int list (np.array([int(w1), int(w2), int(w3)])); the base-8 word tops int64, so numpy promoted the whole list to float64, and the old silent cast then zeroed every word's suffix bits — three order-6 cells silently decoded as order-0 base cells, and the test (which only checks lengths) passed anyway. Repro on 4900a7e: 0x3848000000000006 → 0x3848000000000000 (all three words corrupted). The strict validators refuse that array outright; the test now pins dtype=np.uint64 with a comment recording the incident. That is the #185/#192 bug class, live in our own tree.

Phases

  • Phase 1 — shared validators + toc adoption (598d31a). mortie/_validate.py hoists _as_u64 / _as_offsets from _toc.py (which now imports them), with three deliberate deltas: the non-negative refusal names the first offending value (additive; existing message pins still match); zero-size input of any dtype passes as a typed empty — the ruling Toc already applied to its source ("it is not numeric, it is empty"), now uniform; and an oversized Python-int offset is named as out-of-int64-range rather than refused as float64 (numpy's promotion would otherwise degrade the message). Validator unit tests pin the float refusal, negative-value naming, the ≥2^63 offset wrap refusal (the PR Segmented toc reduce: tocs_reduce (issue #177 v1) #192 class, by value), and that bit-63 words (base cells 7–11, spec §1) survive unchanged.
  • Phase 2 — batch kernels + polymorphic entry points (4d50dee). batch.py (polygons_to_morton_mocs, _mocs_to_orders, _mocs_and, _mocs_intersect, _common_ancestors, _children_of), _moc.py (all 11 public operators, both arms), geometry.py from_wkb(offsets=), orders.py (generate_morton_children scalar arm, clip2order, orders_of, is_point, infer_order_from_morton, validate_morton). Per-entry-point refusal tests (29 word seams × float + negative; 7 offsets seams × float + uint64-wrap) + byte-identity against pre-change goldens.
  • Phase 3 — remaining word surfaces (67d52fa). convert.py (mort2norm, mort2geo, mort2bbox, mort2polygon), buffer.py (both), moc_object.py (Moc word sources + operands; its docstring had documented the wrap), geometry.py to_geometry/to_wkb/to_wkt word intake, prefix_trie.py split_children (floats refused; signed bit-view kept — see Questions). Includes the test_array_input fix above.
  • Phase 4 — CHANGELOG breaking-register entry (grouped table, matching the register's style).
  • Phase 5 — UNIQ/normed intakes (espg ruling, 2026-08-24: the gap rides this PR). unique2parent, uniq2geo, and norm2mort's normed/parent adopt the shared validators — a new signed _as_i64 (which _as_offsets now delegates to) for the int64-domain UNIQ ids, _as_u64 for normed/parent. The pinned regression: unique2parent(np.asarray([16.5, 20.9])) returned array([0, 1]) (16.5/20.9 truncated to UNIQ 16/20); it now raises unique must be integer-typed, got dtype float64. Negative UNIQ ids stay int64-representable, so the domain check keeps its own Not a valid UNIQ cell number … -5 message — pinned too. The phase-5 review fold extended the seam to the two UNIQ surfaces left behind: norm2uniq (the documented producer of UNIQ ids — norm2uniq(-3, 0, 4) answered 1021, a real order-3 cell in base 11, and norm2uniq(11.5, 0, 4) answered a float UNIQ whose refusal only landed two calls later, blaming uniq) and orders_of_uniq (whose guard was value-based, so an integral float column still decoded).

Inventory (the review artifact)

Every public entry point accepting packed-word arrays or arrow offsets, and its posture before this PR:

Entry point words offsets posture before after
toc_reduce, toc_and/…, time2toc, span2toc, toc2time, to/from_gps_ns, to_datetime64 (_toc.py) strict strict strict (_as_u64/_as_offsets) unchanged (shared home)
decimal_to_word array form (morton_index.py) n/a (strings) strict (string-typed, names offender) unchanged
rank_to_xy / xy_to_rank (rank_xy.py) strict strict (own range checks) unchanged
orders_of_uniq (UNIQ ints) (orders.py) value-based value-strict (only non-integral floats refused, so orders_of_uniq([16.0]) still decoded to order 1 while unique2parent([16.0]) refused the same column) strict (phase-5 fold; _as_i64)
unique2parent, uniq2geo (UNIQ ints), norm2mort + norm2uniq (normed ints) silent truncate batch-style strict (phase 5, espg ruling; norm2uniq added in the fold)
moc_to_order, moc_and, moc_intersects, common_ancestor (both arms), moc_or/minus/xor/min/not, compress_moc, split_base_cells (_moc.py) silent wrap silent truncate/wrap batch-style strict (phase 2)
polygons_to_morton_mocs (batch.py) — (float verts legit) silent truncate/wrap batch-style strict (phase 2)
from_wkb(offsets=) (geometry.py) silent truncate (docstring admitted it) batch-style strict (phase 2)
generate_morton_children, clip2order, orders_of, is_point, infer_order_from_morton, validate_morton (orders.py) silent wrap batch-style strict (phase 2)
mort2norm, mort2geo, mort2bbox, mort2polygon (+ mort2healpix via them) (convert.py) silent wrap batch-style strict (phase 3)
morton_buffer, morton_buffer_meters (buffer.py) silent wrap batch-style strict (phase 3)
to_geometry / to_wkb / to_wkt word intake (geometry.py) silent wrap batch-style strict (phase 3)
Moc(...) / moc(...) word path + set-op operands (moc_object.py) silent wrap (documented) batch-style strict (phase 3; float arrays remain geometry by the documented polymorphism)
split_children / morton_polygon_from_array (prefix_trie.py) silent wrap batch-style floats refused; signed int64 bit-view kept (see Questions)
arrow.py pyarrow skins (from_wkb, polygons_to_morton_mocs, to_morton_index) typed pyarrow columns typed strict by construction unchanged
arrow.py array_like intakes (from_morton_index, export_c_array) silent wrap batch-style (their docstrings document array_like, not typed columns) strict (review fold)
pandas.py MortonIndexArray EA EA casting contract pandas-mediated coercion unchanged — see Questions

Recounted against the table as it stands (17 data rows, 4 + 11 + 1 + 1): strict already / unchanged — 4 rows (toc_*, decimal_to_word, rank_to_xy/xy_to_rank, the arrow.py pyarrow skins). Posture changed — 11 rows (~39 coercion sites: the ~36 of phases 1–3 plus the phase-5 fold's three — norm2uniq's two operands and orders_of_uniq's float acceptance). Carve-out — 1 row (split_children). Deferred with rationale — 1 row (pandas.py's EA).

Formerly scoped out — landed as phase 5 at espg's direction (in-session ruling, 2026-08-24: "if the fix is small, and it blocks us, and touches the same area, add it as a phase"). unique2parent, uniq2geo and norm2mort each truncated a float through np.asarray(..., dtype=np.int64) before orders_of_uniq's own strict check could refuse it; phase 5 validates at those intakes with the shared validators, and the pre-existing UNIQ domain refusals (negative / out-of-range → Not a valid UNIQ cell number) keep their own message register downstream.

How it was tested

  • mortie/tests/test_strict_validation.py (140 tests): per-entry-point refusals (float words, float offsets, negative words naming the value, ≥2^63 uint64 offsets naming the value, oversized Python-int offsets naming the value), plus byte-identity goldens: generate_strict_goldens.py captured the touched entry points' answers for a fixed valid input set (mixed-order words across northern and southern base cells, so bit-63 words are pinned) at 4900a7e, before any validator was adopted — committed in phase 1, asserted after phases 2–5 (test_valid_paths_byte_identical_to_pre_change_goldens replays the capture and compares all 52 entries). The capture was extended twice during the review folds — first mort2bbox, mort2polygon, from_wkb(offsets=) and the whole toc family (whose validators moved house in phase 1), then the four phase-5 surfaces (norm2mort, norm2uniq, unique2parent, uniq2geo, the last over a mixed order-4/order-5 UNIQ column so uniq2geo's group-by-order dispatch is exercised) — and each time re-captured from the 4900a7e source itself, not from this branch; all 48 previously committed entries reproduced byte-identically against the pre-change capture, which independently confirms the committed JSON is the pre-change record. Two surfaces are deliberately out of the capture and say so in the generator's docstring: to_geometry/to_wkb/to_wkt need the shapely test extra (the generator stays numpy-only; they are pinned by test_geometry.py plus this PR's refusal rows), and time2toc([]) is the one valid answer this PR does not claim is unchanged.
  • Full local gates per phase: pytest (2008 passed, 16 skipped), flake8 --select=E9,F63,F7,F82, ruff --select E,F,W,I --ignore E501 and numpydoc lint on touched files, codespell on new files.
  • Pre-existing, untouched: convert.py:895 carries an unused on_antimeridian (ruff F841), and test_mort_inverse.py:4 has an unsorted import block (ruff I001); both predate this PR and sit outside its diff context — flagged, not fixed, per convention.

Questions for review

  1. RESOLVED (espg, 2026-08-24): split_children keeps the signed int64 bit-view carve-out as documented — the trie branches on the decimal characteristic, whose first column is the sign, and the golden fixtures pin the form. (Original question: whether to go strict-negative there too and move the fixtures to uint64.)
  2. RESOLVED (espg, 2026-08-24): the zero-size posture is blessed as shipped, with a pre-tag revisit marker. The two-rule reading stands — shape-following operations accept empty (op([]) → []), reductions keep their empty-segment refusals. Espg flagged it for one more look before the 1.0 tag; the revisit note lives on issue Version 1.0 Sweep (Follow up to new morton datatype) #48 (the 1.0 tracking issue). No code change. (Original question: whether to refuse untyped empties family-wide, which would have broken documented moc empty paths.)
  3. pandas.py deferred; arrow.py picked up (corrected during the review fold). The original deferral claimed the arrow skins "receive dtype-carrying columns (strict by construction)". That is true of from_wkb and polygons_to_morton_mocs (pyarrow in, pyarrow out) but false of from_morton_index (arrow.py:234) and export_c_array (arrow.py:700), whose docstrings document array_like — a raw numpy array carries whatever dtype the caller gave it. Both silently demoted an order-4 word to order 0 on float input, export_c_array doing so on the way across an FFI boundary, so both now take _as_u64. What remains deferred is only pandas.py's EA, whose intake is governed by the pandas casting contract (_from_sequence etc.) where refusing floats interacts with astype round-trips — a genuinely separate question, and a follow-up issue can pick it up.
  4. RESOLVED (espg, 2026-08-24): "no blessed path; strays get a pointed refusal." The strict refusal of negative/int64-wrapped words stands. Rationale of record: mortie's own stack is uint64 end-to-end (the spec grammar, zagg's stores, moczarr, the arrow extension type); mortie is still its own primary user so there is little to migrate, and 1.0 should present a stable, well-defined surface so future consumers find the behavior dependable. (Original question: whether any blessed interop path relied on the accidental int64 wrap of bit-63 words.)

Parallel-work note: #152 (MortonIndexScalar constructor) and #176 (docs pages) are in flight; this PR does not touch morton_index.py and touches docs only where a docstring documented the old truncate/wrap behavior. decimal_to_word needed no change (already strict).

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

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.64%. Comparing base (4900a7e) to head (8c15e86).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #213      +/-   ##
==========================================
+ Coverage   96.53%   96.64%   +0.10%     
==========================================
  Files          20       21       +1     
  Lines        2365     2382      +17     
==========================================
+ Hits         2283     2302      +19     
+ Misses         82       80       -2     
Flag Coverage Δ
unittests 96.64% <100.00%> (+0.10%) ⬆️

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

Files with missing lines Coverage Δ
mortie/_moc.py 100.00% <100.00%> (ø)
mortie/_toc.py 100.00% <100.00%> (ø)
mortie/_validate.py 100.00% <100.00%> (ø)
mortie/arrow.py 99.36% <100.00%> (+<0.01%) ⬆️
mortie/batch.py 100.00% <100.00%> (ø)
mortie/buffer.py 91.30% <100.00%> (+0.39%) ⬆️
mortie/convert.py 98.26% <100.00%> (+0.01%) ⬆️
mortie/geometry.py 99.34% <100.00%> (-0.01%) ⬇️
mortie/moc_object.py 98.77% <100.00%> (+<0.01%) ⬆️
mortie/orders.py 100.00% <100.00%> (+1.96%) ⬆️
... and 1 more

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 4900a7e...8c15e86. Read the comment docs.

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

@codspeed-hq

codspeed-hq Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will degrade performance by 15.42%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 70 untouched benchmarks
⏩ 1 skipped benchmark1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_norm2mort_batch 1.5 ms 1.8 ms -15.42%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/194-strict-validation (8c15e86) with main (4900a7e)

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.

Comment thread mortie/geometry.py
from .orders import _rust_mort2nested

morton = np.atleast_1d(np.asarray(morton, dtype=np.uint64))
morton = _as_u64(morton, "morton")

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)

blockingto_geometry / to_wkb / to_wkt are only strict on the non-default path. _per_cell_polygons got the validator, but to_geometry routes to it only when dissolve=False (geometry.py:775-776); the default dissolve=True goes to _dissolved_polygons, and dissolve.py still carries the old silent coercion at lines 118, 831 and 986 (np.atleast_1d(np.asarray(morton, dtype=np.uint64))). So the default spelling of three public entry points listed as "strict (phase 3)" in the inventory still truncates floats and wraps negatives.

Repro on 0c8a1a8:

>>> import numpy as np, mortie
>>> w = np.asarray(mortie.norm2mort([0],[0],4), dtype=np.uint64)   # order-4 word
>>> mortie.orders_of(w)
array([4], dtype=uint8)
>>> f = w.astype(np.float64)          # float64 cannot hold the word exactly
>>> mortie.to_geometry(f, dissolve=False)
ValueError: morton must be integer-typed, got dtype float64
>>> mortie.to_geometry(f)             # DEFAULT dissolve=True: accepted
<MULTIPOLYGON (((90 90, 0 90, ...   # the whole base cell, not the order-4 cell
>>> np.uint64(np.float64(w[0])), mortie.orders_of(np.array([np.uint64(np.float64(w[0]))]))
(1152921504606846976, array([0], dtype=uint8))   # suffix zeroed -> order 0

This is the exact corruption the PR body cites as "the posture caught a live in-repo bug while landing" (test_array_input), still live on the default path of to_geometry.

Negatives are worse — they reach the kernel and produce the #185 panic-arc surface instead of a named refusal:

>>> mortie.to_wkt(np.asarray([3, -7], dtype=np.int64))
thread '<unnamed>' panicked at mortie-core/src/morton.rs:29:17: Morton index cannot be zero
ValueError: Morton index cannot be zero

vs dissolve=False, which gives morton must be non-negative, got -7.

The test set has the same hole: WORD_CALLS_P3 (test_strict_validation.py:249) pins only to_geometry(w, dissolve=False), and to_wkb / to_wkt are not tested at all — so CI is green with the default path unvalidated. Suggest validating once at the top of to_geometry (and to_wkb / to_wkt, which each call to_geometry) rather than inside _per_cell_polygons, plus dissolve=True rows in WORD_CALLS_P3.

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

Folded in 8f11d8d — the seam moved up to the shared public intake, exactly as suggested.

to_geometry now validates before the dissolve branch, so both arms (and to_wkb / to_wkt, which route here) refuse alike; dissolve.py is untouched, since _dissolved_polygons and _per_cell_polygons have no caller but this one:

    _check_latitude(latitude)
    # One validation seam for both arms -- and so for to_wkb/to_wkt, which
    # route here.  The dissolved arm's own coercion (dissolve.py) would
    # otherwise truncate floats and wrap negatives on the *default* spelling.
    morton = _as_u64(morton, "morton")
    if dissolve:

_per_cell_polygons keeps its own _as_u64 as a private backstop (second pass sees uint64, no rescan).

Your repro, on 8f11d8d — all twelve combinations now refuse:

to_geometry True  float -> morton must be integer-typed, got dtype float64
to_geometry True  neg   -> morton must be non-negative, got -7
to_wkt      True  neg   -> morton must be non-negative, got -7      # was the Rust panic arc
...

and valid covers are unchanged (to_geometry(w).area == 13.504372296124904, per-cell …917).

Tests: WORD_CALLS_P3 gained five rows — to_geometry_dissolved, to_wkb, to_wkb_per_cell, to_wkt, to_wkt_per_cell — so each runs through both the float and the negative refusal. They are ungated like the existing to_geometry row, since shapely is in the test extra.

Docstrings: to_geometry's Raises now names the morton refusal for both arms, and to_wkb / to_wkt gained the ValueError entry they were missing entirely.

Comment thread CHANGELOG.md Outdated
|---|---|
| `_moc` | `compress_moc`, `moc_to_order`, `moc_or`, `moc_and`, `moc_intersects`, `moc_minus`, `moc_xor`, `moc_min`, `moc_not`, `common_ancestor`, `split_base_cells` (both arms where polymorphic) |
| `batch` | `polygons_to_morton_mocs` and every ragged kernel behind the `offsets=` forms |
| `geometry` | `from_wkb(offsets=)`; the `to_geometry` / `to_wkb` / `to_wkt` word intake |

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)

should-fix — this row is not accurate as written. Only the dissolve=False arm of to_geometry / to_wkb / to_wkt gained validation (the validator sits in _per_cell_polygons); the default dissolve=True arm goes through dissolve.py's untouched np.asarray(..., dtype=np.uint64) and still truncates/wraps — see the repro on mortie/geometry.py:700. Either fix the seam (preferred) or narrow this row and the corresponding inventory row in the PR body so the breaking-register does not over-promise.

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 seam (preferred option) in 8f11d8d, then tightened this row in b3a727e rather than narrowing it.

The row now pins the property by name instead of leaving "word intake" to be read charitably:

| geometry | from_wkb(offsets=); the to_geometry / to_wkb / to_wkt word intake — validated at the shared seam, so both dissolve arms refuse alike (the default dissolve=True route through dissolve included) |

So the breaking-register no longer over-promises, and it now says the thing that was actually at issue — the default spelling — out loud, which is the part a future reader would otherwise have to re-derive.

Comment thread mortie/batch.py
"""
values = np.ascontiguousarray(np.asarray(values, dtype=np.uint64).ravel())
offsets = np.ascontiguousarray(np.asarray(offsets, dtype=np.int64).ravel())
values = np.ascontiguousarray(_as_u64(values, "values").ravel())

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)

should-fix — the refusal blames the kernel's parameter name, not the public one, so the same argument gets two different names depending on whether offsets= was passed. The CHANGELOG promises "Every refusal names the parameter" (CHANGELOG.md:20); today it names whichever private kernel it landed in.

Measured on 0c8a1a8 with F = np.asarray([1.5, 2.0]):

moc_to_order(morton=F)                       -> morton must be integer-typed, got dtype float64
moc_to_order(morton=F, offsets=..)           -> values must be integer-typed, got dtype float64   <- same arg
moc_and(a=W, b=F)                            -> b must be integer-typed, got dtype float64
moc_and(a=W, b=F, offsets=..)                -> values must be integer-typed, got dtype float64   <- same arg
common_ancestor(morton=F, offsets=..)        -> values must be integer-typed, got dtype float64
generate_morton_children(parent_morton=F)    -> words must be integer-typed, got dtype float64
Moc(W) & F                                   -> words must be integer-typed, got dtype float64

values and words are not parameters of any public signature. Note generate_morton_children already has the machinery for this — orders.py:517-523 rewrites children_ofgenerate_morton_children in the kernel's message — but _as_u64's text contains no children_of, so it sails through unrenamed while the scalar arm correctly says parent_morton.

Cheapest fix: pass the caller-facing name down (_mocs_to_orders(values, ..., name="morton") etc., or have the public wrappers validate before delegating). The parametrized tests currently encode the inconsistency (("moc_to_order_ragged", "values", ...) at test_strict_validation.py:148, ("generate_morton_children_array", "words", ...) at :162), so they would need updating too — worth doing, since as-is the suite locks in the confusing register.

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

Folded in b072530. I took the public wrappers validate before delegating option rather than threading a name= parameter into the kernels.

Why that one: the kernels are private and their own parameter names (values, words) are correct for them — the bug is that a private name was reaching a public message, not that the private name is wrong. Validating in the public arm fixes it where the public name actually lives, keeps the kernels' signatures untouched, and leaves their _as_u64 in place as a backstop that costs nothing (the second pass sees uint64 and returns without a rescan). Threading a name through would have put the public vocabulary inside five private signatures and made every future kernel caller responsible for passing it.

Four sites in _moc.py and one in orders.py:

    if offsets is not None:
        # Name the caller-facing parameter before delegating -- the kernel's
        # own pass stays as the backstop and sees uint64 (no rescan).
        return _mocs_and(_as_u64(a, "a"), _as_u64(b, "b"), offsets)

Your measured table, re-run on b072530:

moc_to_order(morton=F)                     -> morton must be integer-typed, got dtype float64
moc_to_order(morton=F, offsets=..)         -> morton  ...                    <- was `values`
moc_and(a=W, b=F)                          -> b       ...
moc_and(a=W, b=F, offsets=..)              -> b       ...                    <- was `values`
moc_intersects(a=W, b=F, offsets=..)       -> b       ...                    <- was `values`
common_ancestor(morton=F, offsets=..)      -> morton  ...                    <- was `values`
generate_morton_children(parent_morton=F)  -> parent_morton ...              <- was `words`
Moc(W) & F                                 -> operand ...                    <- was `words`
split_base_cells(words=F)                  -> words   ...                    <- correct already: `words` *is* its public parameter

One addition beyond your list: moc_object._words(operand) was also blaming "words", which is not a parameter of any Moc operator either — it is now "operand", the name the docstrings use for the right-hand side.

Note this also makes the orders.py:517-523 children_ofgenerate_morton_children rename redundant for the dtype refusal (the public validator now fires first); it stays for the kernel's other messages, e.g. the max_cells refusal.

Tests: the six parametrized rows that encoded the inconsistency now assert the public names — moc_to_order_raggedmorton, moc_and_ragged / moc_intersects_raggedb, common_ancestor_raggedmorton, generate_morton_children_arrayparent_morton, Moc_operandoperand.

Comment thread mortie/_validate.py Outdated
# above; re-try the exact cast so an oversized *integer* is refused as
# out of range rather than as a float.
try:
np.asarray(offsets, dtype=np.int64)

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)

should-fix — this probe cast runs for every non-integer dtype, not just the Python-int-past-int64 case it is commented for, and it leaks numpy's own failure out of the family's message register.

>>> from mortie._validate import _as_offsets
>>> _as_offsets(['a'])
ValueError: invalid literal for int() with base 10: 'a'          # was, pre-hoist:
                                                                  # offsets must be integer-typed, got dtype <U1
>>> _as_offsets([None, 1])
TypeError: int() argument must be a string, ... not 'NoneType'    # not even a ValueError
>>> _as_offsets(np.array([0.0, np.nan]))
RuntimeWarning: invalid value encountered in cast                 # spurious warning on a refusal path
ValueError: offsets must be integer-typed, got dtype float64

The old _toc._as_offsets returned the named message for all three (it never attempted the cast). The NaN case is the sharp one: under -W error (or a downstream filterwarnings = error pytest config) the probe's RuntimeWarning becomes the raised exception and the caller never sees the named ValueError:

>>> warnings.simplefilter("error"); _as_offsets(np.array([0.0, np.nan]))
RuntimeWarning: invalid value encountered in cast

Suggest gating the probe to the case it exists for — if arr.dtype.kind in "fO" and the input is not already an ndarray, or just scan offsets for a Python int outside int64 directly — and add a <U/None/NaN row to TestValidators. _as_u64 has no equivalent hole (_as_u64(np.array(['a']), 'morton') still says morton must be integer-typed, got dtype <U1), which is itself evidence the two validators have drifted apart.

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

Folded in 8f38df9. I went further than gating the probe: the trial cast is gone, replaced by a direct scan, so no numpy exception or warning can reach the caller from this path at all.

    if arr.dtype.kind not in "iu":
        # A Python int past int64 lands as float64 (or, further out, object)
        # in the untyped asarray above, so an oversized *integer* would
        # otherwise be blamed on its promoted dtype.  Look for one directly
        # rather than probe-casting: a trial cast would leak numpy's own
        # message for strings and None, and would raise a RuntimeWarning on
        # NaN that becomes the exception under warnings-as-errors -- in every
        # case burying this family's named refusal (issue #194 review).
        if arr.dtype.kind in "fO":
            flat = np.atleast_1d(np.asarray(offsets, dtype=object)).ravel()
            bad = next((v for v in flat.tolist() if isinstance(v, int)
                        and not -2**63 <= v < 2**63), None)
            if bad is not None:
                raise ValueError(f"offsets must fit in int64, got {bad}")
        raise ValueError(
            f"offsets must be integer-typed, got dtype {arr.dtype}")

A scan beats a suppressed probe here because it needs no errstate/catch_warnings scaffolding to be correct, and it is exact — there is no cast whose failure mode has to be classified. It only runs on the refusal path, which was already O(n).

All three of your cases, under -W error:

["a"]                  -> ValueError: offsets must be integer-typed, got dtype <U1
[None, 1]              -> ValueError: offsets must be integer-typed, got dtype object
np.array([0.0, np.nan])-> ValueError: offsets must be integer-typed, got dtype float64   # no RuntimeWarning

Including "O" alongside "f" also picked up a case neither of us listed: a Python int large enough to land as object rather than float64 ([0, 10**40]) is now named too — offsets must fit in int64, got 10000… — where the old probe's OverflowError branch happened to catch it and the kind == "f"-only gate would have lost it.

Tests in TestValidators: a parametrized <U1 / object(None) / object(mixed) row asserting the family message, a NaN row wrapped in warnings.simplefilter("error"), and an object-dtype oversized-int row. The existing [0, 10**19] and uint64 ≥ 2**63 pins are unchanged and still pass.

On the drift you noted: _as_u64 never grew a probe, which is why it had no equivalent hole — the two validators now agree again, both refusing on dtype alone and reaching for values only to name one.

Comment thread mortie/prefix_trie.py Outdated
If *morton_array* is empty or not 1-D.
"""
morton_array = np.ascontiguousarray(np.asarray(morton_array, dtype=np.uint64))
arr = np.atleast_1d(np.asarray(morton_array))

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)

should-fix — the new np.atleast_1d silently retires the 0-D refusal. Previously np.asarray(scalar, dtype=np.uint64) stayed 0-D and the very next check (morton_array.ndim != 1) raised; now the scalar is promoted to 1-D before that check ever runs, so a scalar is accepted:

# origin/main
>>> split_children(np.uint64(1152921504606846980))
ValueError: morton_array must be a non-empty 1-D integer array
# this branch (0c8a1a8)
>>> split_children(np.uint64(1152921504606846980))
[MortonChild(characteristic='11111', len=1, nchildren=0)]
>>> split_children(1152921504606846980)          # plain int, same
[MortonChild(characteristic='11111', len=1, nchildren=0)]

Same for morton_polygon_from_array, which delegates here. That is a loosening in the opposite direction from the PR's stated posture, it is untested, and the Raises section two lines below still says "If morton_array is empty or not 1-D". Either drop the atleast_1d (do the dtype check on np.asarray(morton_array) directly, whose .ndim is then still 0 for a scalar) or make the loosening deliberate and document it. While you are here: the Raises block also does not mention the new float refusal — the Parameters entry does, but Raises is what numpydoc readers scan.

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

Folded in ad570df — dropped the atleast_1d, which was the loosening, and reordered so the rank check runs before the cast.

    # Not atleast_1d: a 0-D scalar must keep its rank so the 1-D check below
    # still refuses it, as it did before this seam was validated.
    arr = np.asarray(morton_array)
    if arr.size and arr.dtype.kind not in "iu":
        ...
    if arr.ndim != 1 or arr.size == 0:
        raise ValueError("morton_array must be a non-empty 1-D integer array")
    morton_array = np.ascontiguousarray(arr.astype(np.uint64, copy=False))

The ascontiguousarray had to move below the rank check too — it has atleast_1d semantics of its own, so leaving it above would have re-promoted the scalar past the very check it feeds.

Back to main's behavior on ad570df:

split_children(np.uint64(1152921504606846980))    -> ValueError: morton_array must be a non-empty 1-D integer array
split_children(1152921504606846980)               -> ValueError: ... (same)
morton_polygon_from_array(<either>, 1)            -> ValueError: ... (same)

Tests: test_split_children_still_refuses_scalars, parametrized over np.uint64 and plain int, asserting both split_children and the delegating morton_polygon_from_array.

Raises is corrected as well — it now reads "If morton_array is float-typed (issue #194), or is empty, a scalar, or not 1-D", so both the new float refusal and the scalar case are where numpydoc readers look, not only in Parameters.

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

Ruling recorded (espg, in-session 2026-08-24): the signed int64 bit-view carve-out on split_children stays as documented — the trie branches on the decimal characteristic, whose first column is the sign, and the golden fixtures pin the form. PR-body Question 1 is marked RESOLVED; no code change.

Comment thread mortie/convert.py
# squeeze on one side alone made the round trip lose its shape.
is_scalar = np.ndim(morton) == 0
morton = np.atleast_1d(np.asarray(morton, dtype=np.uint64))
morton = _as_u64(morton, "morton")

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)

should-fix (inventory accuracy, not this line) — the PR-body inventory row

| unique2parent, orders_of_uniq (UNIQ ints) | strict | — | strict (float + wrap guarded, #185 arc) | unchanged |

is wrong for unique2parent, and uniq2geo is missing from the inventory entirely. Both do np.atleast_1d(np.asarray(x, dtype=np.int64)) (convert.py:170 and convert.py:631) before handing off to orders_of_uniq, so the float truncates first and the strict check downstream never sees it:

>>> mortie.orders_of_uniq(np.asarray([16.5, 20.9]))
ValueError: Not a valid UNIQ cell number for orders 0-29: np.float64(16.5) is not ...
>>> mortie.unique2parent(np.asarray([16.5, 20.9]))   # truncates to 16, 20 -- accepted
array([0, 1])
>>> mortie.uniq2geo(np.asarray([16.5, 20.9]))        # 16.5 and 20.9 decode as different cells
(array([19.47122063, 19.47122063]), array([ 45., 135.]))

Same class for norm2mort (convert.py:220-221), also absent from the inventory: norm2mort(np.asarray([16.5, 20.9]), [0, 0], 4) is accepted and truncates.

UNIQ ids and normalized addresses are arguably out of the issue's "packed word / arrow offset" scope — that is a fine ruling to make — but then the inventory should say so rather than claim unique2parent is already strict. Since the inventory is the review artifact, an inaccurate row is the thing most likely to be trusted later.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Confirmed and folded into the PR body (no code change — see the ruling below). Your three repros reproduce exactly on 0c8a1a8:

orders_of_uniq([16.5, 20.9]) -> RAISED  (Not a valid UNIQ cell number ...)
unique2parent([16.5, 20.9])  -> array([0, 1])                     # truncated
uniq2geo([16.5, 20.9])       -> (19.552…, 19.552…), (45., 135.)   # truncated
norm2mort([16.5, 20.9],…,4)  -> [1224979098644774916, …]          # truncated

The row was wrong, and I agree the inventory being the review artifact is exactly why. It is now split in two, with the scoped-out half stating the posture honestly instead of claiming a strictness it does not have:

| orders_of_uniq (UNIQ ints) | strict | — | strict (float + wrap guarded, #185 arc) | unchanged |
| unique2parent, uniq2geo (UNIQ ints), norm2mort (normed ints) | silent truncate | — | batch-style | unchanged — scoped out, see below |

plus a "Scoped out, deliberately" paragraph under the table carrying your repro, the three convert.py line numbers (170 / 631 / 220), and the ruling: UNIQ ids and normalized addresses are not packed words or arrow offsets, which is the domain issue #194 rules on, so tightening them is a separate issue rather than a silent scope expansion here. The row-count summary moved with it (the deferred count now names this row alongside pandas.py).

I took the ruling you offered rather than the code change because widening to UNIQ/normed intake mid-PR would change what #194 was ruled on — but it is a real gap, and it is now written down as one instead of hidden behind a wrong "strict".

"moc_object_and": _ints(
(mortie.Moc(cover_a) & mortie.Moc(cover_b)).words),
}
return g

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)

nit — the golden set is described as "every touched entry point" (PR body, and the test docstring at test_strict_validation.py:107) but omits several touched surfaces, so "valid inputs are unaffected" is pinned less tightly than claimed:

  • mort2bbox, mort2polygon — both changed in phase 3, neither captured (only mort2norm / mort2geo are).
  • to_geometry / to_wkb / to_wkt — changed in phase 3, not captured (and see the seam bug on mortie/geometry.py:700).
  • from_wkb(offsets=) — the phase-2 offsets seam, not captured.
  • The whole toc family. Phase 1 changed _as_u64 under _toc.py in three ways (zero-size now accepted where it was refused, negative message text changed, and astype(..., copy=False) where the toc copy was unconditional), yet no toc_* entry appears in the capture. time2toc([]) going from a refusal to an empty cover is exactly the kind of drift a golden would have surfaced; today it is covered only by the CHANGELOG prose.

Also worth noting for the record (checked, and it is fine): the generator's phase-2 edit (4d50dee) is a pure maincapture split with no value changes, so the JSON committed in phase 1 really is the pre-change record.

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

Folded in 16d43ac. I extended the capture rather than only narrowing the claim — and captured it from the 4900a7e source, not from this branch.

Method (so it is auditable): a detached worktree at 4900a7e, the branch's _rustie.abi3.so copied in — sound because git diff --name-only 4900a7e HEAD is pure Python, no Rust or Cargo.* change — and the new generator run against the old package via PYTHONPATH. Pre-change was verified in that interpreter before capturing: no mortie/_validate.py on disk, and orders_of([1.5, 2.0]) still answered [1, 2].

The check that matters: the 34 original entries came back byte-identical to the committed JSON. That is an independent confirmation of the thing you checked by reading the phase-2 diff — the committed capture really is the pre-change record — arrived at from the other direction.

34 → 48 entries. Added: mort2bbox, mort2polygon, from_wkb_ragged (the phase-2 offsets seam, via hard-coded WKB hex so the generator stays numpy-only), and the whole toc family — time2toc, span2toc, toc2time, toc_reduce, toc_reduce_ragged, toc_normalize, toc_and, toc_merge, from_gps_ns, to_gps_ns, to_datetime64. The post-change tree reproduces all 48.

Two deliberate omissions, now stated in the generator's docstring rather than papered over:

  • to_geometry / to_wkb / to_wkt need shapely, which is a test extra, not a runtime dep. Keeping the generator numpy-only means the golden test never turns on an optional install; those three are pinned by test_geometry.py's behavior suite plus the five refusal rows added in 8f11d8d.
  • time2toc([]) is not captured, on purpose: the untyped-empty acceptance deliberately changed that answer from a refusal to an empty cover. A golden there would pin the one valid path this PR does not claim is unchanged — it belongs in the CHANGELOG and Q2, where it is.

Docstrings corrected in both places: "every touched entry point" is gone from the generator and from test_valid_paths_byte_identical_to_pre_change_goldens, replaced by an enumeration of what is covered and why those two are not. The PR body's "all 34 entries" is updated to 48 with the same detail.

@espg

espg commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude (review)

should-fix — Questions for review, Q3: the arrow.py deferral rationale is factually wrong for two of the four skins it names.

The PR body defers arrow.py on the grounds that "the arrow skins receive dtype-carrying columns (strict by construction)". That holds for from_wkb and polygons_to_morton_mocs (pyarrow in, pyarrow out), but from_morton_index and export_c_array are documented array_like intakes that coerce exactly the way the batch family used to:

  • arrow.py:234data = np.asarray(getattr(array, "_data", array), dtype=np.uint64)
  • arrow.py:700np.ascontiguousarray(np.asarray(getattr(words, "_data", words), dtype=np.uint64))

export_c_array's own docstring says "Any uint64 array-like (e.g. a raw numpy array or a MortonIndexArray)" — a raw numpy array carries whatever dtype the caller gave it, so nothing is strict by construction here.

Repro on 0c8a1a8:

>>> import numpy as np, mortie
>>> from mortie import arrow
>>> w = np.asarray(mortie.norm2mort([0], [0], 4), dtype=np.uint64)
>>> int(w[0])
1152921504606846980
>>> a = arrow.from_morton_index(w.astype(np.float64))   # accepted
>>> a[0].value
1152921504606846976                                     # order-4 word silently demoted to order 0
>>> arrow.export_c_array(w.astype(np.float64))          # accepted, same corruption, then handed to Rust
(<capsule ...>, <capsule ...>)

That is the same corruption the PR body reports finding in test_array_input, on a path that exports words across an FFI boundary. Since the deferral is being recorded as a reasoned one, the rationale should either be corrected (these two are array_like, not typed columns) or these two seams should be picked up here — they need no pandas-casting-contract analysis, unlike the pandas.py row, which is a genuinely separate question.

For the record on the other questions I checked and found nothing to add beyond the inline threads: Q2's empty-accept does not break toc_reduce's documented empty refusal (toc_reduce([]) still raises toc_reduce of an empty array (the merge has no identity element)), and the CHANGELOG discloses the time2toc([]) loosening honestly. Q1's carve-out is defensible as scoped, but its implementation has a separate loosening — see the mortie/prefix_trie.py thread.

@espg

espg commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Accepted — the Q3 rationale was wrong for exactly the two skins you name, and both are folded in 4573978 rather than re-deferred.

Your repro is the deciding fact: export_c_array's own docstring says "Any uint64 array-like (e.g. a raw numpy array or a MortonIndexArray)", and a raw numpy array carries whatever dtype the caller gave it. "Strict by construction" was only ever true of the pyarrow-in/pyarrow-out pair.

Both intakes now take _as_u64 with the parameter name from their own docstrings:

# from_morton_index (arrow.py:234)
data = _as_u64(getattr(array, "_data", array), "array")

# export_c_array (arrow.py:700)
data = np.ascontiguousarray(_as_u64(getattr(words, "_data", words), "words"))

On 4573978:

from_morton_index(w.astype(np.float64)) -> ValueError: array must be integer-typed, got dtype float64
from_morton_index(np.int64([3, -7]))    -> ValueError: array must be non-negative, got -7
export_c_array(w.astype(np.float64))    -> ValueError: words must be integer-typed, got dtype float64
export_c_array(np.int64([3, -7]))       -> ValueError: words must be non-negative, got -7

and valid words are untouched — from_morton_index(w)[0].value == 1152921504606846980, export_c_array(w) still returns its capsule pair.

Tests: TestArrowArrayLikeIntakes in test_strict_validation.py — float + negative refusals and a valid-path row for each. The export_c_array rows are ungated (the C Data Interface surface is numpy + Rust, no pyarrow); the from_morton_index rows use pytest.importorskip("pyarrow"), mirroring test_arrow.py. Both Raises sections gained a ValueError entry, and export_c_array's says why it is the sharper of the two: unvalidated words cross an FFI boundary from there.

PR body updated — the inventory row is split (from_wkb / polygons_to_morton_mocs / to_morton_index strict by construction and unchanged; from_morton_index / export_c_array batch-style → strict), the row counts moved with it, and Q3 now reads as a pandas.py-only deferral with the corrected reasoning spelled out. The pandas EA stays deferred on your grounds: its intake really is governed by the casting contract, which is a different question.

Thanks for checking Q1/Q2 as well — the prefix_trie loosening you split out was real and is folded in ad570df.

@espg

espg commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

espg rulings recorded (in-session, 2026-08-24):

  1. The UNIQ/normed intake gap rides this PR as phase 5 ("if the fix is small, and it blocks us, and touches the same area, add it as a phase"). The three scoped-out sites — unique2parent, uniq2geo, and norm2mort's normed/parent intake — adopt the strict posture via the shared validators; the body's "scoped out" note becomes "landed as phase 5 at espg's direction." Commit to follow, with the unique2parent(np.asarray([16.5, 20.9])) → array([0, 1]) truncation pinned as the named regression, and the same review + fold cycle over the new phase.
  2. Question 1 is RESOLVED: split_children keeps the signed int64 bit-view carve-out as documented (the trie branches on the decimal characteristic, whose first column is the sign; the golden fixtures pin the form).

Questions 2 (zero-size posture) and 4 (int64-wrap interop) remain open with espg.

@espg

espg commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Two more espg rulings recorded (in-session, 2026-08-24) — both standing questions now resolved, no code changes from either:

  • Question 4 RESOLVED — "no blessed path; strays get a pointed refusal." The strict refusal of negative / int64-wrapped words stands. Rationale of record: mortie's own stack is uint64 end-to-end (spec grammar, zagg stores, moczarr, the arrow extension type); mortie is still its own primary user, so there is little to migrate, and 1.0 should present a stable, well-defined surface so future consumers find the behavior dependable.
  • Question 2 RESOLVED — the zero-size posture is blessed as shipped, with a pre-tag revisit marker. The two-rule reading stands: shape-following operations accept empty (op([]) → []); reductions keep their empty-segment refusals. Espg flagged it for one more look before the 1.0 tag — that revisit note is recorded on issue Version 1.0 Sweep (Follow up to new morton datatype) #48.

With Question 1 already ruled (split_children keeps its signed bit-view carve-out) and Question 3 folded during review, every question on this PR is settled; only the phase-5 review/fold cycle remains.

Comment thread mortie/convert.py
normed = np.atleast_1d(np.asarray(normed, dtype=np.int64))
parent = np.atleast_1d(np.asarray(parent, dtype=np.int64))
normed = _as_u64(normed, "normed")
parent = _as_u64(parent, "parent")

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)

should-fix — phase 5 hardens norm2mort's normed/parent but leaves its exact sibling norm2uniq(normed, parent, order) (convert.py:522) on the old silent path, so the producer of UNIQ ids stays loose while the two consumers just went strict.

On 7f2c5bf:

>>> mortie.norm2uniq(11.5, 0, 4)
1035.5                     # int answer is 1035 -- a float UNIQ, returned
>>> mortie.norm2uniq(-3, 0, 4)
1021
>>> mortie.orders_of_uniq(1021), mortie.unique2parent(1021)
(array([3], dtype=uint8), 11)

The negative case is precisely the hazard the new Raises block two lines up names — "silently cast into a different, possibly valid, word": normed=-3 at order 4 lands on a real order-3 cell in base 11, with no error at any point downstream.

The float case now produces a worse message than before phase 5, because the wall moved in front of the wrong parameter:

>>> mortie.uniq2geo(np.asarray([mortie.norm2uniq(11.5, 0, 4)]))
ValueError: uniq must be integer-typed, got dtype float64

The caller's mistake was normed, two calls earlier — the same misblame the fold at b072530 ("blame the public parameter name, not the kernel's") went out of its way to remove.

norm2uniq is also the documented way to obtain UNIQ ids for unique2parent / uniq2geo (USAGE.md:470-489), and it is absent from the inventory table entirely. Either extend the phase to norm2uniq (_as_u64 on both operands, same as here — its internal callers at convert.py:715/782/983/1072 all pass validated mort2norm output, so nothing in-tree would start refusing), or add an explicit scope note saying why the sibling is left out.

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

Folded in 01dbf36norm2uniq takes the same unsigned intake as norm2mort, on both operands.

One deliberate difference from the sibling: the validators are called for their refusal, not rebound.

_as_u64(normed, "normed")
_as_u64(parent, "parent")
bcast = np.broadcast(np.asarray(normed), np.asarray(parent))

norm2uniq's scalar/array form and output dtype fall out of the caller's operands (there is no is_scalar switch — the scalar path is plain Python arithmetic returning a Python int, and an int64 column returns int64). Rebinding to the uint64 validator's atleast_1d output would have turned norm2uniq(11, 0, 4) into array([1035], dtype=uint64) and promoted every int64 column, so the valid path is left byte-identical and only the refusals are new. Pinned in test_norm2uniq_valid_path_keeps_form_and_dtype, and the four internal callers (convert.py:715/782/983/1072) pass mort2norm output, which is non-negative int64 — nothing in-tree started refusing (full suite green).

Both hazards you named are now pinned as regressions: norm2uniq(-3, 0, 4) used to answer UNIQ 1021 (a real order-3 cell in base 11) and now raises normed must be non-negative, got -3; norm2uniq(11.5, 0, 4) used to answer the float UNIQ 1035.5 whose misblamed refusal landed two calls later, and now raises normed must be integer-typed. CHANGELOG's convert row and the PR-body inventory row both name norm2uniq now.

Comment thread mortie/convert.py
"""
is_scalar = np.ndim(unique) == 0
u = np.atleast_1d(np.asarray(unique, dtype=np.int64))
u = _as_i64(unique, "unique")

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)

should-fix — the inventory row | orders_of_uniq (UNIQ ints) | strict | ... | **strict** (float + wrap guarded, #185 arc) | unchanged | over-claims, and phase 5 turns that into a live inconsistency: orders_of_uniq guards only non-integral floats, so it still accepts an integral float, while the two functions that call it now refuse the same value.

On 7f2c5bf:

>>> mortie.orders_of_uniq(np.asarray([16.0]))
array([1], dtype=uint8)
>>> mortie.unique2parent(np.asarray([16.0]))
ValueError: unique must be integer-typed, got dtype float64

The guard is orders.py:193 (arr.dtype.kind == "f" and not np.all(np.equal(np.mod(arr, 1), 0))) — a value-based check, not the dtype-based one the family's definition of strict uses everywhere else. orders_of_uniq is public and exported (__init__.py:124/164), and it is the same int64-domain UNIQ intake the espg ruling's wording covers, so after phase 5 it is the one UNIQ entry point where a float column still decodes.

Either bring it onto _as_i64 and move it out of the "strict already" bucket, or downgrade the row's claim (e.g. "integral floats accepted; value-based guard") so the table does not read as parity with the phase-5 seams.

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

Took option (a) — orders_of_uniq is on _as_i64 as of 73839af, and the inventory row moved out of the "strict already" bucket.

Rationale for hardening rather than downgrading the row: the PR's whole claim is one posture, and this was the last UNIQ entry point where a float column's fate depended on which decoder saw it first (orders_of_uniq([16.0]) -> 1 vs unique2parent([16.0]) refusing). Its two in-tree callers already validate the same column with _as_i64 before handing it over, so the tightening costs them nothing.

Two message changes fall out of it, both deliberate and both re-pinned in test_uniq_orders_raises_valueerror_not_overflow:

  • orders_of_uniq(1.5) — was Not a valid UNIQ cell number …: 1.5 is not an integer, now uniq must be integer-typed, got dtype float64 (the family register, and the same text uniq2geo gives for the same input).
  • orders_of_uniq(2**63) — was … is out of the int64 range, now uniq must fit in int64, got 9223372036854775808.

The bespoke float/uint64/OverflowError block is gone; the UNIQ domain refusal (Not a valid UNIQ cell number for orders 0-29) is untouched and still owns out-of-range values, including negatives. CHANGELOG's orders row records the change with the [16.0] example, and test_strict_validation.py gains a refusal + valid-path pair in the phase-5 class. Full suite green.

Comment thread mortie/_validate.py
# otherwise be blamed on its promoted dtype. Look for one directly
# rather than probe-casting (issue #194 review).
if arr.dtype.kind in "fO":
flat = np.atleast_1d(np.asarray(values, dtype=object)).ravel()

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)

should-fix — the object-dtype probe is now on the bulk UNIQ path, where it is both expensive and (for the common input) incapable of firing.

np.asarray(values, dtype=object) on an ndarray of float64 yields Python floats, so isinstance(v, int) is never true:

>>> any(isinstance(v, int) for v in np.asarray(np.array([1.0, 2.0]), dtype=object).tolist())
False

Only a list/tuple input can carry an oversized Python int into a float64 promotion — which is exactly the case the comment above describes. For an ndarray the whole tolist() materialization is dead work in front of a refusal it cannot change. Measured on 7f2c5bf:

5M-element float64 ndarray -> unique2parent: 0.333 s to the ValueError
2M-element float64 ndarray -> tracemalloc peak 96 MB

This was latent in _as_offsets (offsets arrays are n_groups+1 long), but phase 5 puts it on unique2parent / uniq2geo, whose inputs are per-observation UNIQ columns — and "a UNIQ column that came back float from a parquet/pandas read" is the single most likely way a caller trips this validator. A one-line gate keeps the message and drops the cost:

if arr.dtype.kind == "O" or not isinstance(values, np.ndarray):

(or check arr.dtype.kind == "O" plus a list/tuple test — anything that skips the object round-trip when the caller already handed over a typed float ndarray).

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

Folded in 40324c8 — the probe is gated, on the input's own dtype rather than on isinstance(values, np.ndarray):

if arr.dtype.kind == "O" or getattr(values, "dtype", None) is None:

Same effect for the case you measured, and it also skips the round-trip for a float pandas.Series (no ndarray instance, but a dtype that likewise cannot hide a Python int). Only an untyped container — the one shape that can carry an oversized int into a float64 promotion — and a genuine object array still get probed.

Measured on the same 5M float64 column: 0.333 s → 0.000 s to the identical unique must be integer-typed, got dtype float64. All existing refusal texts are unchanged; the big-Python-int cases ([0, 10**40] list, [16, 10**19] list, object array) still name the value.

Pinned by test_i64_skips_the_object_probe_for_typed_float_input, which spies on np.asarray(..., dtype=object) and asserts zero probes for a float ndarray and exactly one for an untyped list — a behavioural pin rather than a timing one.

Comment thread mortie/_validate.py
this family's own message: no numpy cast error or warning (strings,
``None``, ``NaN``) is allowed to surface in its place.
"""
return np.ascontiguousarray(_as_i64(offsets, "offsets").ravel())

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)

nit — the delegation quietly drops _as_offsets' guaranteed private copy. The pre-phase-5 body ended np.ascontiguousarray(arr.astype(np.int64).ravel())astype defaults to copy=True, so the result was always a fresh, writeable buffer the caller could not reach. _as_i64 ends arr.astype(np.int64, copy=False), so an already-int64 C-contiguous input now comes back as a view aliasing the caller's array, and a read-only input yields a read-only result:

>>> src = np.array([0, 2, 4], dtype=np.int64)
>>> np.shares_memory(src, _as_offsets(src))
True
>>> ro = np.array([0, 3, 6], dtype=np.int64); ro.flags.writeable = False
>>> _as_offsets(ro).flags.writeable
False

No current consumer breaks — I checked all seven call sites (batch.py:175/472/579/655/764, geometry.py:427, _toc.py:419); none mutate the array or retain it past the call, and the Rust kernels take it read-only (polygons_to_morton_mocs and toc_reduce both accept a non-writeable offsets array on this commit). So this is arguably a free copy elimination.

But it is an unannounced contract change on a validator whose docstring (unchanged in this diff) still reads like a normalized owned buffer — "Returns: The offsets as a contiguous 1-D int64 array" — and the batch kernels run over it with the GIL released. Either say so in the _as_offsets Returns/Notes ("may alias the input when it is already contiguous int64"), or keep copy=True at this seam and let _as_i64 stay the no-copy primitive.

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

Took the documentation option in ca710fc — the copy stays eliminated, the contract now says so.

_as_offsets' Returns section reads:

Not a private copy: like _as_u64 and _as_i64 this is a no-copy validator, so an input that is already contiguous int64 comes back as a view aliasing the caller's array (a read-only input therefore yields a read-only result). No consumer mutates or retains it — the ragged kernels read it under a released GIL and take it read-only — so the buffer is validated, not owned.

That keeps _as_i64 the single no-copy primitive of the family rather than giving one of its two wrappers a different ownership rule, and it records your seven-call-site audit (batch.py:175/472/579/655/764, geometry.py:427, _toc.py:419) as the reason the aliasing is safe. If a future consumer does need to own the buffer, the fix belongs at that call site (np.array(...)), not in a validator that every ragged kernel pays for.

Comment thread mortie/tests/test_strict_validation.py Outdated
normed, parent, order = mortie.mort2norm(words)
assert normed.tolist() == [11, 7]
assert parent.tolist() == [0, 3]
assert np.atleast_1d(order).tolist() in ([4], [4, 4])

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)

should-fix — phase 5 changed three entry points' intakes and added zero golden entries, but the CHANGELOG's blanket claim still covers them: "Valid inputs are unaffected — byte-identity is pinned against pre-change goldens (mortie/tests/data/strict_validation_goldens.json, captured at 4900a7e)", and the PR body says the capture holds "the touched entry points' answers".

The committed goldens have 48 keys and none of them is unique2parent, uniq2geo, or norm2mort:

clip2order, common_ancestor(_ragged), compress_moc, from_gps_ns, from_wkb_ragged,
generate_morton_children_{array,scalar}, infer_order_from_morton, is_point,
moc_*, mort2bbox, mort2geo, mort2norm_{normed,parent,order}, mort2polygon,
morton_buffer(_meters), orders_of, polygons_to_morton_mocs, span2toc,
split_base_cells_values, split_children_roots, time2toc, to_datetime64,
to_gps_ns, toc2time, toc_*, validate_morton, words, words_b

norm2mort appears in generate_strict_goldens.py only as a fixture builder (lines 74-78), never as a captured entry. The precedent is right there in this PR's own history — fold 16d43ac "extend the pre-change goldens to the toc and convert seams" did exactly this for phase 3's convert surfaces, and re-captured from 4900a7e.

The in-class substitutes do not close the gap evenly. test_unique2parent_valid_ints_unchanged pins [0, 1] and test_norm2mort_valid_roundtrip_unchanged roundtrips, but uniq2geo's valid path has no numeric pin at all — see test_uniq2geo_valid_matches_unique2parent_cells above, which only asserts shape and isfinite. That is the one phase-5 function whose output is float coordinates through a group-by-order dispatch, i.e. the one where a silent numeric shift would be hardest to notice.

Either add the three entries to the capture (re-run from 4900a7e as the fold did), or narrow the CHANGELOG/PR-body claim to the phases the goldens actually cover.

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

Folded in f21eb65 — the capture is extended, re-run from the pre-change source, not narrowed.

Same technique as the 16d43ac fold: git worktree add <tmp> 4900a7e, this venv's _rustie.abi3.so dropped into that tree's mortie/, generator run with PYTHONPATH pointing at it (mortie.__file__ verified to resolve there). Four entries added, 48 → 52:

  • norm2mort — the three mixed-hemisphere order-4 words the fixtures already build (base cells 0/8/11, so bit-63 words are in the pin).
  • norm2uniq — added alongside, since the fold above put it on the same seam.
  • unique2parent and uniq2geo — over a mixed order-4/order-5 UNIQ column, so uniq2geo's group-by-order dispatch (the case you flagged as hardest to notice) is what gets pinned, not a single-order path.

All 48 previously committed entries reproduced byte-identically from the 4900a7e run — the JSON diff is 35 insertions(+), no modified lines — which is the same independent confirmation the earlier fold relied on. The four new values are pre-change captures, and they happen to equal the current answers, which is the point of the pin.

Docstring claims fixed with it: the generator's coverage paragraph now names the phase-5 entry points (and says "every phase" rather than "all three phases"); the PR body's testing bullet says 52 entries, phases 2–5, and describes both fold re-captures. The CHANGELOG's blanket byte-identity claim now holds as written.

uniq2geo's valid path also gained a real numeric pin in the test file — see the reply on the nit below (a7c1154).

Comment thread mortie/tests/test_strict_validation.py Outdated
def test_uniq2geo_valid_matches_unique2parent_cells(self):
lat, lon = mortie.uniq2geo(np.asarray([16, 20]))
assert lat.shape == lon.shape == (2,)
assert np.isfinite(lat).all() and np.isfinite(lon).all()

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)

nit — two of the three valid-path tests in this class pin less than their names promise.

test_uniq2geo_valid_matches_unique2parent_cells never matches anything against unique2parent: it asserts a shape and isfinite, both of which would hold for any pair of arrays the function happened to return. Cells 16 and 20 are order-1 base-cell-0/1 centres with fixed coordinates — pinning the two (lat, lon) values (or comparing the decoded base cells against unique2parent([16, 20]), as the name says) costs one line and actually detects a shift.

And on line 486, assert np.atleast_1d(order).tolist() in ([4], [4, 4]) accepts either form, so it pins nothing about mort2norm's order return — the array/scalar distinction is a documented contract elsewhere in this module (issue #187, the rule the is_scalar comment in norm2mort cites). Pick the one that is correct for a 2-element input and assert it.

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

Both strengthened in a7c1154.

test_uniq2geo_valid_matches_unique2parent_cells now does what its name says, three ways: it asserts unique2parent([16, 20]) == [0, 1] (the base cells the name claims), compares the coordinates element-wise against mort2geo(norm2mort([0, 0], [0, 1], 1)) — the same two cells through the morton decoder — and pins the literals lat == 19.552022266396 (atol 1e-11) and lon == [45.0, 135.0]. A shift through the group-by-order dispatch now fails on the literals even if both decoders shifted together.

For the order return: mort2norm's Returns is explicit — "order : int … always a python int, since the words must share one order" — so the two-element case has exactly one correct form. The assertion is now assert order == 4 and isinstance(order, int), with a comment citing that contract line. (np.atleast_1d(order).tolist() in ([4], [4, 4]) would have passed for an array return, i.e. for a regression of issue #187's rule.)

uniq2geo's valid answer is additionally pinned in the pre-change goldens as of f21eb65, over a mixed order-4/order-5 column.

Comment thread CHANGELOG.md Outdated
| `batch` | `polygons_to_morton_mocs` and every ragged kernel behind the `offsets=` forms |
| `geometry` | `from_wkb(offsets=)`; the `to_geometry` / `to_wkb` / `to_wkt` word intake — validated at the shared seam, so **both** `dissolve` arms refuse alike (the default `dissolve=True` route through `dissolve` included) |
| `orders` | `generate_morton_children`, `clip2order`, `orders_of`, `is_point`, `infer_order_from_morton`, `validate_morton` |
| `convert` | `mort2norm`, `mort2geo`, `mort2bbox`, `mort2polygon` (and `mort2healpix` through them); the UNIQ/normed intakes `unique2parent`, `uniq2geo`, and `norm2mort`'s `normed`/`parent` (phase 5, espg ruling 2026-08-24 — `unique2parent([16.5, 20.9])` used to truncate to UNIQ 16/20 and answer `[0, 1]`; UNIQ ids stay int64-domain, so negatives keep their own `Not a valid UNIQ` refusal downstream) |

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)

nit — the row's negatives clause under-claims for norm2mort. "UNIQ ids stay int64-domain, so negatives keep their own Not a valid UNIQ refusal downstream" is true of unique2parent / uniq2geo, but norm2mort took _as_u64, so its negatives are now refused up front with a new message — a behaviour change the register does not record.

Before (4900a7e body, replayed):

>>> norm2mort(np.asarray([-3], dtype=np.int64), 0, 4)
thread '<unnamed>' panicked at mortie-core/src/decimal_morton.rs:497:5:
nested index 18446744073709551613 too large for depth 4 (base 72057594037927935 > 11)
ValueError: nested index 18446744073709551613 too large for depth 4 ...

After:

ValueError: normed must be non-negative, got -3

Strictly better (it also stops the stderr panic dump), and it is genuinely part of the #185 panic-arc story this entry opens with — worth half a clause rather than being covered by a sentence that says the opposite. Something like "…negatives keep their own Not a valid UNIQ refusal downstream, while norm2mort's unsigned normed/parent now refuse them by name instead of reaching the kernel's panic".

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

Folded in bdf42b2. The clause now reads:

UNIQ ids stay int64-domain, so negatives keep their own Not a valid UNIQ refusal downstream, while the unsigned normed/parent operands refuse them by name up front — normed must be non-negative, got -3 where norm2mort used to wrap to 18446744073709551613 and panic inside the kernel (the #185 arc again, stderr dump included), and where norm2uniq(-3, 0, 4) used to answer UNIQ 1021, a real order-3 cell in base 11, with no error anywhere downstream.

Split by operand rather than left as one sentence, since the two halves now genuinely differ: the int64-domain UNIQ ids keep the downstream domain message, the unsigned normed/parent operands refuse up front. The norm2uniq half comes from the fold at 01dbf36 (thread above), and both are pinned by test_norm2mort_refuses_negative_naming_value / test_norm2uniq_refuses_negative_naming_value.

@espg

espg commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude (review)

nit (PR body, not diff-anchorable) — the inventory tally no longer reconciles with the table it summarizes.

Strict already: 4 rows. Posture changed: 13 rows (~36 coercion sites). Carve-out: 1 row. Deferred with rationale: 1 row (pandas.py's EA).

That is 4 + 13 + 1 + 1 = 19, but the table has 17 data rows. Counting by the "after" column:

  • unchanged / strict already — 5: toc_*, decimal_to_word, rank_to_xy/xy_to_rank, orders_of_uniq, and the arrow.py pyarrow skins (currently uncounted in either bucket)
  • posture changed — 10: the phase-5 UNIQ/normed row, _moc.py, polygons_to_morton_mocs, from_wkb(offsets=), orders.py, convert.py's mort2*, buffer.py, to_geometry/to_wkb/to_wkt, Moc/moc, and the arrow.py array_like intakes
  • carve-out 1, deferred 1

Phase 5 added a row to the table; the summary line looks like it was incremented rather than recounted. The ~36 coercion sites figure is the one a reviewer would actually use to size the change, so it is worth getting the row arithmetic to agree with it. (The orders_of_uniq row's bucket is separately contested — see the inline comment on convert.py:172.)

@espg

espg commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Recounted and rewritten in the PR body (no commit — body edit only), in reply to the tally nit on 7f2c5bf.

Your 5/10/1/1 was right against the table as it stood; the fold above moved orders_of_uniq from the unchanged bucket to the changed one (73839af), so the arithmetic that now holds is 4 + 11 + 1 + 1 = 17:

  • strict already / unchanged — 4: toc_*, decimal_to_word, rank_to_xy/xy_to_rank, the arrow.py pyarrow skins (the previously uncounted row — thanks, it was the source of one of the two missing rows).
  • posture changed — 11: the phase-5 UNIQ/normed row, orders_of_uniq, _moc.py, polygons_to_morton_mocs, from_wkb(offsets=), orders.py, convert.py's mort2*, buffer.py, to_geometry/to_wkb/to_wkt, Moc/moc, arrow.py's array_like intakes.
  • carve-out 1 (split_children), deferred 1 (pandas.py's EA).

The ~36 coercion sites figure is now ~39, stated as "the ~36 of phases 1–3 plus the phase-5 fold's three — norm2uniq's two operands and orders_of_uniq's float acceptance", so the row count and the site count move together from here. Also corrected while in there: the goldens count (48 → 52), the test count (100 → 140), the suite count (1968 → 2008 passed), and the pre-existing F841's line reference (convert.py:874:895, drifted by the fold's edits).

@espg

espg commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

CodSpeed note (not chased, on record): the Performance Analysis check on 8c15e86 reports -15.42% with CodSpeed's own caveat that benchmarks with significant changes were compared across different runtime environments, "which may affect the accuracy of the results." Both the Rust and Python benchmark jobs passed; every substantive check (ruff, numpydoc, tests 3.10–3.12, arro3-no-pyarrow, wheels, codecov) is green.

Worth a look when reading the CodSpeed report though, because this PR does add real work to hot intakes: _as_u64 runs an O(n) negativity scan on int64-typed word inputs (uint64 inputs skip it), and norm2mort / orders_of / mort2norm sit on encode/decode hot paths. If any pinned benchmark feeds int64 words, part of that delta could be genuine validation cost rather than environment noise — a call for the report, not for this thread.

@espg espg mentioned this pull request Aug 31, 2026
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.

Validation posture: batch family truncates/wraps where toc refuses — unify via #187

1 participant