Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
598d31a
phase 1 of issue #194
espg Aug 24, 2026
4d50dee
phase 2 of issue #194
espg Aug 24, 2026
67d52fa
phase 3 of issue #194
espg Aug 24, 2026
0c8a1a8
phase 4 of issue #194
espg Aug 24, 2026
8f11d8d
fold review: validate to_geometry before the dissolve branch (issue #…
espg Aug 24, 2026
b3a727e
fold review: name both dissolve arms in the geometry register row (is…
espg Aug 24, 2026
b072530
fold review: blame the public parameter name, not the kernel's (issue…
espg Aug 24, 2026
8f38df9
fold review: keep numpy cast errors out of the offsets refusal (issue…
espg Aug 24, 2026
ad570df
fold review: keep split_children's 0-D refusal (issue #194)
espg Aug 24, 2026
4573978
fold review: strict intake at the two array_like arrow seams (issue #…
espg Aug 24, 2026
16d43ac
fold review: extend the pre-change goldens to the toc and convert sea…
espg Aug 24, 2026
7f2c5bf
phase 5 of issue #194
espg Aug 25, 2026
01dbf36
fold review: strict intake at norm2uniq, the UNIQ producer (issue #194)
espg Aug 25, 2026
73839af
fold review: dtype-strict UNIQ intake at orders_of_uniq (issue #194)
espg Aug 25, 2026
40324c8
fold review: run the oversized-int probe only where one can hide (iss…
espg Aug 25, 2026
ca710fc
fold review: document _as_offsets' no-copy pass-through (issue #194)
espg Aug 25, 2026
a7c1154
fold review: pin what the phase-5 valid-path tests name (issue #194)
espg Aug 25, 2026
bdf42b2
fold review: record norm2mort's up-front negative refusal (issue #194)
espg Aug 25, 2026
f21eb65
fold review: capture the phase-5 entry points in the pre-change golde…
espg Aug 25, 2026
8c15e86
fold review: name the folded UNIQ surfaces in the phase-5 test class …
espg Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- **BREAKING: strict input validation family-wide — previously-accepted-and-mangled
word/offset arrays now raise** (issue #194, ruled 2026-08-24; lands ahead of
the 1.0 release). The toc module's validators (`_as_offsets` / `_as_u64`)
are hoisted to a shared home and applied at every polymorphic choke point,
retiring the batch family's silent `np.asarray(..., dtype=...)` coercions:
a **float-typed** word or offset array raises `ValueError` instead of
truncating (`2.9` no longer becomes a group boundary at 2 — the issue #185
panic-arc class), a **negative** word raises instead of wrapping into a
different — possibly valid — packed word, and a **uint64 offset ≥ 2⁶³**
raises instead of wrapping negative through the int64 cast (the PR #192
class). Every refusal names the parameter and the first offending value.
Affected entry points, by module:

| module | entry points now strict |
|---|---|
| `_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.

| `orders` | `generate_morton_children`, `clip2order`, `orders_of`, `is_point`, `infer_order_from_morton`, `validate_morton` |
| `convert` | `mort2norm`, `mort2geo`, `mort2bbox`, `mort2polygon` (and `mort2healpix` through them) |
| `buffer` | `morton_buffer`, `morton_buffer_meters` |
| `moc_object` | `Moc` / `moc` word sources and set-operation operands (float arrays remain *geometry* there, by the documented polymorphism) |

Two deliberate edges: **zero-size input of any dtype passes as a typed
empty** (an untyped `[]` is not numeric, it is empty — the ruling `Toc`
already applied to its source, now uniform; this also *loosens* the toc
functions, which previously refused `[]`), and **`split_children` keeps
accepting the signed `int64` bit-view of packed words** (the trie branches
on the decimal characteristic, whose first column *is* the sign) while
refusing floats like everything else. Valid inputs are unaffected —
byte-identity is pinned against pre-change goldens
(`mortie/tests/data/strict_validation_goldens.json`, captured at
`4900a7e`).

- **BREAKING: one polymorphic function per operation — the plural batch names
are removed** (issue #187, ruled 2026-08-19). Every scalar/batch pair now has
**one** public entry point: the input shape (or the keyword-only `offsets=`)
Expand Down
43 changes: 26 additions & 17 deletions mortie/_moc.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@
cost — and the array-first consumers keep calling them on plain ndarrays.
:class:`~mortie.moc_object.Moc` is the **object layer** over them, and every
one of its methods is a single delegation to a function on this page.

Input validation is strict family-wide (issue #194): float-typed word or offset
arrays are refused rather than truncated, negative words and past-int64 offsets
are refused rather than wrapped, and the refusal names the argument and the
offending value.
"""

import warnings

import numpy as np

from . import _rustie
from ._validate import _as_u64
from .batch import (
_common_ancestors,
_mocs_and,
Expand Down Expand Up @@ -64,7 +70,7 @@ def compress_moc(morton):
numpy.ndarray
Sorted, compacted morton indices (``uint64``).
"""
morton = np.asarray(morton, dtype=np.uint64).ravel()
morton = _as_u64(morton, "morton").ravel()
return np.asarray(_rustie.rust_moc_normalize(morton))


Expand Down Expand Up @@ -136,6 +142,8 @@ def moc_to_order(morton, order, max_cells=_FLAT_COVER_WARN_THRESHOLD, *,
If ``order`` is outside 0-29, or the estimated densified count exceeds
``max_cells``. In the ragged form, also for offsets that are
non-monotone, out of bounds, or do not exactly cover ``morton``.
Float-typed or negative ``morton`` and float or past-int64
``offsets`` are refused by name (issue #194), never silently cast.

See Also
--------
Expand All @@ -144,7 +152,7 @@ def moc_to_order(morton, order, max_cells=_FLAT_COVER_WARN_THRESHOLD, *,
"""
if offsets is not None:
return _mocs_to_orders(morton, offsets, order, max_cells)
morton = np.asarray(morton, dtype=np.uint64).ravel()
morton = _as_u64(morton, "morton").ravel()
if not 0 <= order <= 29:
raise ValueError(f"Order must be between 0 and 29, got {order}")
if max_cells is not None:
Expand Down Expand Up @@ -184,8 +192,8 @@ def moc_or(a, b):
moc_minus : difference ``a \ b``.
compress_moc : ``moc_or(a, b) == compress_moc(concatenate([a, b]))``.
"""
a = np.asarray(a, dtype=np.uint64).ravel()
b = np.asarray(b, dtype=np.uint64).ravel()
a = _as_u64(a, "a").ravel()
b = _as_u64(b, "b").ravel()
return np.asarray(_rustie.rust_moc_or(a, b))


Expand Down Expand Up @@ -226,8 +234,8 @@ def moc_and(a, b, *, offsets=None):
"""
if offsets is not None:
return _mocs_and(a, b, offsets)
a = np.asarray(a, dtype=np.uint64).ravel()
b = np.asarray(b, dtype=np.uint64).ravel()
a = _as_u64(a, "a").ravel()
b = _as_u64(b, "b").ravel()
return np.asarray(_rustie.rust_moc_and(a, b))


Expand Down Expand Up @@ -274,8 +282,8 @@ def moc_intersects(a, b, *, offsets=None):
"""
if offsets is not None:
return _mocs_intersect(a, b, offsets)
a = np.asarray(a, dtype=np.uint64).ravel()
b = np.asarray(b, dtype=np.uint64).ravel()
a = _as_u64(a, "a").ravel()
b = _as_u64(b, "b").ravel()
return bool(_rustie.rust_moc_intersects(a, b))


Expand All @@ -302,8 +310,8 @@ def moc_minus(a, b):
moc_or : union of two covers.
moc_and : intersection of two covers.
"""
a = np.asarray(a, dtype=np.uint64).ravel()
b = np.asarray(b, dtype=np.uint64).ravel()
a = _as_u64(a, "a").ravel()
b = _as_u64(b, "b").ravel()
return np.asarray(_rustie.rust_moc_minus(a, b))


Expand Down Expand Up @@ -334,8 +342,8 @@ def moc_xor(a, b):
moc_and : intersection of two covers.
moc_minus : difference ``a \ b`` (the directional half of ``xor``).
"""
a = np.asarray(a, dtype=np.uint64).ravel()
b = np.asarray(b, dtype=np.uint64).ravel()
a = _as_u64(a, "a").ravel()
b = _as_u64(b, "b").ravel()
return np.asarray(_rustie.rust_moc_xor(a, b))


Expand Down Expand Up @@ -403,11 +411,11 @@ def moc_not(cover, domain=None):
>>> enumerated = mortie.from_geometry(aoi, moc=True) # doctest: +SKIP
>>> gaps = mortie.moc_not(enumerated, domain=shard) # doctest: +SKIP
"""
cover = np.asarray(cover, dtype=np.uint64).ravel()
cover = _as_u64(cover, "cover").ravel()
if domain is None:
domain = _whole_sphere()
else:
domain = np.asarray(domain, dtype=np.uint64).ravel()
domain = _as_u64(domain, "domain").ravel()

if domain.size == 0:
# The complement within an empty domain is empty for any cover; the
Expand Down Expand Up @@ -471,7 +479,8 @@ def common_ancestor(morton, *, offsets=None):
(non-existent) whole-sphere root. In the ragged form the message names
the lowest-index offending group *within its kind* (layout errors are
screened in their own pass, ahead of the per-group content check), and
bad offsets raise here too.
bad offsets raise here too. Float-typed or negative ``morton`` and
float or past-int64 ``offsets`` are refused by name (issue #194).

See Also
--------
Expand All @@ -492,7 +501,7 @@ def common_ancestor(morton, *, offsets=None):
"""
if offsets is not None:
return _common_ancestors(morton, offsets)
morton = np.asarray(morton, dtype=np.uint64).ravel()
morton = _as_u64(morton, "morton").ravel()
return np.uint64(_rustie.rust_moc_min(morton))


Expand Down Expand Up @@ -551,7 +560,7 @@ def split_base_cells(words, sort=False):
>>> sorted(int(np.uint64(k) >> np.uint64(60)) - 1 for k in groups)
[2, 5]
"""
words = np.asarray(words, dtype=np.uint64).ravel()
words = _as_u64(words, "words").ravel()
if words.size == 0:
return {}

Expand Down
37 changes: 3 additions & 34 deletions mortie/_toc.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@

from . import _rustie

# The family's shared strict validators (hoisted from this module, issue #194).
from ._validate import _as_offsets, _as_u64

Q_START_NS = 1 << 31
"""Start quantum: 2^31 ns (~2.15 s); a range's start code floors to this."""

Expand All @@ -71,40 +74,6 @@
(~4 s short of year 2142); the end code must fit its 31-bit field."""


def _as_u64(values, name):
"""Validate non-negative integer input and return it as uint64."""
arr = np.atleast_1d(np.asarray(values))
if arr.dtype.kind not in "iu":
raise ValueError(
f"{name} must be integer-typed, got dtype {arr.dtype}")
if arr.dtype.kind == "i" and arr.size and np.any(arr < 0):
raise ValueError(f"{name} must be non-negative")
return arr.astype(np.uint64)


def _as_offsets(offsets):
"""Validate arrow list offsets and return them as contiguous int64.

Integer-typed by the same rule :func:`_as_u64` applies to words: a float
offset array would otherwise cast silently, truncating ``2.9`` to a group
boundary at 2 rather than saying so. The same standard rules out the
``uint64`` values the cast cannot represent -- at or above ``2**63`` they
would wrap negative, and the Rust validator would then describe the
wrapped copy rather than the offset that was passed. Monotonicity and
bounds stay the Rust validator's job -- it names the offending group.
"""
arr = np.atleast_1d(np.asarray(offsets))
if arr.dtype.kind not in "iu":
raise ValueError(
f"offsets must be integer-typed, got dtype {arr.dtype}")
if arr.dtype.kind == "u" and arr.size:
too_big = arr > np.iinfo(np.int64).max
if too_big.any():
raise ValueError(
f"offsets must fit in int64, got {int(arr[too_big][0])}")
return np.ascontiguousarray(arr.astype(np.int64).ravel())


def _as_scalar_ns(value, name):
"""Validate a scalar ns argument and return it as a plain int."""
value = operator.index(value)
Expand Down
116 changes: 116 additions & 0 deletions mortie/_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Shared strict input validators for word and offset arrays (issue #194).

The toc module's validation discipline, hoisted to one home so the whole
family answers bad input the same way: refuse float-typed words and offsets
instead of truncating them, range-check before any narrowing cast instead of
wrapping, and name the parameter and the offending value. The strict form
has caught two real bug classes -- the issue #185 uncatchable-panic arc and
PR #192's silent uint64 wrap -- so with issue #187's consolidation giving
each operation one polymorphic entry point, that posture is applied at every
choke point rather than kept as a toc-only stance.

Zero-size input is the one deliberate acceptance: an untyped empty container
(``[]``, ``()``, ``np.array([])``) is float64 by numpy's default, but it is
not numeric, it is empty (the ruling :class:`~mortie.toc_object.Toc` already
applied to its source argument) -- so it passes through as a typed empty
array rather than being refused for a dtype it never chose.
"""

import numpy as np


def _as_u64(values, name):
"""Validate non-negative integer input and return it as uint64.

Float input is refused rather than truncated, and negative input is
refused rather than wrapped -- packed words are unsigned, and a negative
here is almost always a signed *reinterpretation* of a word whose top
bit is set (base cells 7-11; spec section 1) or a legacy signed id, both
of which would wrap into a different, possibly valid, word.

Parameters
----------
values : array_like
Integer-typed values (any shape); zero-size input of any dtype is
accepted as empty.
name : str
Parameter name to blame in refusal messages.

Returns
-------
numpy.ndarray
The values as ``uint64``, at least 1-D; no copy when the input is
already ``uint64``.

Raises
------
ValueError
If ``values`` is not integer-typed, or any value is negative --
naming ``name`` and the first offending value.
"""
arr = np.atleast_1d(np.asarray(values))
if arr.size == 0:
return arr.astype(np.uint64)
if arr.dtype.kind not in "iu":
raise ValueError(
f"{name} must be integer-typed, got dtype {arr.dtype}")
if arr.dtype.kind == "i":
flat = arr.ravel()
neg = flat[flat < 0]
if neg.size:
raise ValueError(
f"{name} must be non-negative, got {int(neg[0])}")
return arr.astype(np.uint64, copy=False)


def _as_offsets(offsets):
"""Validate arrow list offsets and return them as contiguous int64.

Integer-typed by the same rule :func:`_as_u64` applies to words: a float
offset array would otherwise cast silently, truncating ``2.9`` to a group
boundary at 2 rather than saying so. The same standard rules out the
``uint64`` values the cast cannot represent -- at or above ``2**63`` they
would wrap negative, and the Rust validator would then describe the
wrapped copy rather than the offset that was passed. Monotonicity and
bounds stay the Rust validator's job -- it names the offending group.

Parameters
----------
offsets : array_like
Integer-typed arrow list offsets; zero-size input of any dtype is
accepted and left for the kernel's own emptiness refusal.

Returns
-------
numpy.ndarray
The offsets as a contiguous 1-D ``int64`` array.

Raises
------
ValueError
If ``offsets`` is not integer-typed, or a value is at or above
``2**63`` -- naming the first offending value.
"""
arr = np.atleast_1d(np.asarray(offsets))
if arr.size == 0:
return np.ascontiguousarray(arr.astype(np.int64).ravel())
if arr.dtype.kind not in "iu":
# A Python int past int64 coerces to float64 in the untyped asarray
# 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.

except OverflowError:
flat = np.atleast_1d(np.asarray(offsets, dtype=object)).ravel()
bad = next((v for v in flat if isinstance(v, int)
and not -2**63 <= v < 2**63), None)
raise ValueError(
f"offsets must fit in int64, got {bad}") from None
raise ValueError(
f"offsets must be integer-typed, got dtype {arr.dtype}")
if arr.dtype.kind == "u":
too_big = arr > np.iinfo(np.int64).max
if too_big.any():
raise ValueError(
f"offsets must fit in int64, got {int(arr[too_big][0])}")
return np.ascontiguousarray(arr.astype(np.int64).ravel())
Loading
Loading