Skip to content

Decimal-string display & casting layer for morton_index#105

Merged
espg merged 7 commits into
mainfrom
claude/104-decimal-display-casting
Jul 10, 2026
Merged

Decimal-string display & casting layer for morton_index#105
espg merged 7 commits into
mainfrom
claude/104-decimal-display-casting

Conversation

@espg

@espg espg commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Closes #104. Refs #48 (emit-conventions decisions), #62 (spec page the hive convention lands on).

What this does

The user-visible half of the repr/casting layer settled on #48: printed shard ids downstream (zagg) currently resolve to raw packed-word integers because the morton_index skin never renders the decode-through-kernel decimal string. The Rust layer (to_decimal_repr, rust_mi_decimal_repr, from_legacy_decimal) is already merged and pinned; this PR wires the Python surface to it.

Phases

  • Phase 1 — element display = the decimal string. MortonIndexArray._word_repr / _formatter / __repr__ now render each element as its decimal Morton id (-31123 style) instead of the old base=X order=Y label; the len=N, order=K summary stays in the array-level header. Adds the lightweight scalar wrapper MortonIndexScalar(np.uint64), returned from element access/iteration: __str__/__repr__/__format__ are the decimal string, int() is the packed word, and comparisons/hashing stay word-valued. The empty sentinel renders <NA>; an invalid prefix renders <invalid 0x...> rather than raising (a repr must never raise). __format__ is overridden because numpy's numeric formatting would otherwise print the packed word through f"{shard_key}" — exactly the downstream case the issue calls out.
  • Phase 2 — vectorized emit + legacy escape hatch. to_decimal() returns the decimal strings as a fixed-width numpy <U31 array (sign + base digit + 29 order digits is the widest form, so the width is order-independent and stable across arrays). to_legacy_i64() emits the signed decimal int64 for orders ≤ 18 with a hard ValueError above (pairs with the existing from_legacy; a mixed array with any element above 18 errors — never truncated, never data-dependent, per the Version 1.0 Sweep (Follow up to new morton datatype) #48 emit conventions).
  • Phase 3 — hive_path() + inverse parser. hive_path(root="", suffix=".zarr") emits {root}/{sign+base}/{d1}/…/{d_order}/{full_id}.zarr (one digit per level, full-id leaf inside its own node; order-0 -3-3/-3.zarr), so mixed shard orders nest naturally — every order is a node. from_hive_path parses the leaf basename back to words via the new _decimal_to_word (full validation: base digit 1–6, order digits 1–4, order ≤ 29) and cross-checks the digit directories against the leaf id when the chain is present.
  • Phase 4 — golden fixtures. The string layer is pinned through the existing packed_u64_golden.json (all 1,080 records): to_decimal/_word_repr/scalar str equal the pinned decimal_repr strings, to_legacy_i64 equals the pinned decimal read as an integer for orders 0–18, and hive_pathfrom_hive_path round-trips every fixture word, with literal layout pins for representative ids. Plus the documented non-injectivity test: an order-29 point renders identically to the area on the same path (words differ, strings don't, and parsing the string lands on the area word).
  • Phase 5 — folded self-review findings (round 1). (a) MortonIndexScalar.__reduce__ so pickling preserves the wrapper across process boundaries (multiprocessing/dask) + pinning test; (b) tests pinning __format__ for string specs, numeric specs (raise), and old-style %-formatting (bypasses __format__, emits the raw word — documented in-line); (c) from_hive_path accepts a single os.PathLike; (d) first cut of the directory cross-check anchor. The NA-boxing and per-call-FFI review notes are answered on their threads as recorded decisions.
  • Phase 6 — folded self-review findings (round 2). The converging review demonstrated phase 5's anchor (equality with the leaf's own sign+base) regressed the wrong-base case (-4/1/1/2/3/-31123.zarr silently parsed). The anchor is now the sign+base shape (optional - + digit 1..6) at its slot: wrong-base and wrong-descent mis-filings raise again (pinned), arbitrary roots still skip, and a real root above a full chain parses (pinned).
  • Phase 7 — Windows-flavored paths. from_hive_path splits PurePath inputs via their own .parts (a WindowsPath honors backslashes; raw strings keep exact "/"-split semantics), pinned with a PureWindowsPath round-trip that runs on the ubuntu CI.

How it was tested

  • maturin develop --release (no Rust change; ext rebuilt at current main), flake8 mortie --select=E9,F63,F7,F82 clean; the 88-col style pass is clean on all touched files.
  • pytest -v578 passed, 11 skipped (30 new: TestDecimalDisplay, TestHivePath, and 4 golden string-layer pins in test_packed_golden.py).
  • Three adversarial self-review rounds ran (phase 1; phases 2–4; phase 5) and converged — every diff-scoped finding is folded (phases 5–7) or answered on its thread as a recorded decision.

Questions for review

  • Hive path shape: the zagg design record (docs/design/sparse_coverage.md) gives {sign+base}/{d1}/{d2}/…/ with a {full_id}.zarr leaf; with "every order is a node" I read the leaf as sitting inside its own digit directory — -31123-3/1/1/2/3/-31123.zarr, and order-0 -3-3/-3.zarr. If you read the ellipsis as stopping at d_{order-1} instead, say so and I'll flip it (it's one line + the pinned fixtures).
  • __format__ formats the decimal string under any format spec (so f"{key:>10}" pads the string; numeric specs like :d raise — int(key) is the escape; "%d" % key bypasses __format__ and emits the raw word). All three behaviors are pinned by tests; flagging in case you'd rather have numeric specs fall through to the word.
  • __getitem__ on a missing entry returns MortonIndexScalar(0) (renders <NA>, but pd.isna() is False) — pre-existing deviation from the strict ExtensionArray contract, kept deliberately; see the review thread if you want it switched to pd.NA.
  • from_hive_path's anchored cross-check accepts that a root component coincidentally shaped like {sign+base} at exactly its slot with a non-matching descent raises (strict-on-suspicious); flip to lenient if you'd rather never false-reject.

@espg espg added the implement label Jul 10, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 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 improved benchmark
❌ 2 regressed benchmarks
✅ 64 untouched benchmarks
⏩ 1 skipped benchmark1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
fast_norm2mort_orders[6] 269.4 ns 327.8 ns -17.8%
fast_norm2mort_orders[10] 315.3 ns 373.6 ns -15.61%
coverage_triangle[4] 130.1 µs 113.4 µs +14.81%

Tip

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


Comparing claude/104-decimal-display-casting (41b3bac) with main (4fb4cd2)2

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.

  2. No successful run was found on main (5b92649) during the generation of this report, so 4fb4cd2 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.14%. Comparing base (4fb4cd2) to head (41b3bac).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #105      +/-   ##
==========================================
+ Coverage   93.73%   94.14%   +0.40%     
==========================================
  Files           9        9              
  Lines        1230     1299      +69     
==========================================
+ Hits         1153     1223      +70     
+ Misses         77       76       -1     
Flag Coverage Δ
unittests 94.14% <100.00%> (+0.40%) ⬆️

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

Files with missing lines Coverage Δ
mortie/morton_index.py 93.70% <100.00%> (+2.46%) ⬆️

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 5b92649...41b3bac. Read the comment docs.

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

@espg espg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Fresh-context adversarial review of phase 1 (commit 2b495d9: element display = decimal string, MortonIndexScalar wrapper). Phases 2 and 3 landed on the branch while this review was in flight and are not covered here — they need their own pass.

Issue #104 scope items 1 and 3: met. _word_repr/_formatter/__repr__ render the decode-through-kernel decimal string, the len=N, order=K header stays, and element access and iteration return MortonIndexScalar with str/repr = decimal string and int() = packed word (all verified by running the branch).

Verified safe (claims I attacked and could not break):

  • The except ValueError catch in __str__ is total: rust_mi_decimal_repr maps every empty/invalid word to PyValueError via Option (src_rust/src/lib.rs L1111-1127) — no panic path, so "a repr must never raise" holds. _word_repr(0) still returns <NA> (pre-existing test passes).
  • Arithmetic on the scalar degrades to plain np.uint64 (s + 1, -s, s * 2 all verified), so derived words never carry a misleading decimal repr.
  • pandas paths: Series repr renders decimal strings with <NA> for missing; to_csv emits the decimal string (empty for NA rows); astype(str) gives decimal strings — consistent with the issue #48 always-strings interchange convention.
  • Per-element FFI in _word_repr is bounded: at most 6 calls in __repr__; measured ~1.6 us/element (about 5x the vectorized to_decimal() path), fine for display.
  • Conventions: flake8 blocking selectors and the 88-col style pass are clean, module was 516 lines at phase 1, no new dependencies, docstrings match the surrounding freeform style, tests ship in the same commit, and all 21 relevant tests pass locally.

Findings, ranked (inline comments):

  1. [low] pickle drops the subclass — a shard key round-tripped through multiprocessing/dask reverts to raw-word display; __reduce__ fixes it.
  2. [low] __format__ has no direct test (neither the working >10 spec nor the documented ValueError on numeric specs), and "%d" % s silently emits the raw word — pin whichever numeric-spec answer is chosen.
  3. [note, pre-existing] the boxed sentinel scalar prints <NA> but pd.isna() on it is False; flagged for a recorded decision, not a change.

Nothing blocking the next phase.


Generated by Claude Code

Comment thread mortie/morton_index.py
Comment on lines +79 to +90
def __str__(self):
word = int(self)
if word == 0:
return "<NA>"
try:
return _rustie.rust_mi_decimal_repr(
np.asarray([word], dtype=np.uint64)
)[0]
except ValueError:
return f"<invalid {word:#018x}>"

__repr__ = __str__

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)

[low] Pickle drops the wrapper: a MortonIndexScalar comes back as a plain np.uint64 and prints the raw packed word.

Verified on this branch:

>>> s = a[0]                      # MortonIndexScalar, str(s) == "-31123"
>>> p = pickle.loads(pickle.dumps(s))
>>> type(p).__name__, str(p)
('uint64', '10403315139225845764')

numpy scalars reduce to multiarray.scalar(dtype, bytes), which rebuilds the base type (copy.copy/deepcopy do preserve the subclass — it is only pickle). So a shard key crossing a process boundary (multiprocessing/dask, i.e. exactly the zagg-style consumers this display layer targets per issue #104) silently reverts to raw-word display. A two-line __reduce__ returning (MortonIndexScalar, (int(self),)) restores it; worth a pinning test either way.


Generated by Claude Code

Comment thread mortie/morton_index.py
Comment on lines +92 to +97
def __format__(self, spec):
# numpy's numeric __format__ would print the packed word; the display
# form of a morton_index is its decimal string, so f"{shard_key}" (and
# any string spec, e.g. ">10") formats that instead. int(self) remains
# the escape hatch to format the raw word numerically.
return format(str(self), spec)

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)

[low] __format__ is a behavioral change with no direct test.

Nothing in the suite pins a nonempty format spec. Verified behavior on this branch:

  • f"{s:>10}"' -31123' (works as the docstring claims)
  • f"{s:d}"ValueError: Unknown format code 'd' for object of type 'str' (the behavior the PR body flags as an open question)

Since the numeric-spec question is posed under "Questions for review", a test pinning whichever answer is chosen belongs in this PR (CLAUDE.md §4: every behavioral change needs tests in the same PR) — right now only the empty spec is exercised via f"{s}".

Also verified but undocumented: old-style %-formatting bypasses __format__ entirely — "%d" % s emits the raw word 10403315139225845764 while f"{s}" gives -31123. Worth one docstring line so that asymmetry reads as a documented choice rather than an oversight.


Generated by Claude Code

Comment thread mortie/morton_index.py
result = self._data[item]
if np.isscalar(result) or isinstance(result, np.integer):
return np.uint64(result)
return MortonIndexScalar(result)

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)

[note, pre-existing] The boxed missing element now prints as NA without being NA.

__getitem__ on a sentinel row returns MortonIndexScalar(0) rather than dtype.na_value (pd.NA). Verified:

>>> s2  # Series with one missing entry
0    -31123
1      <NA>
dtype: morton_index
>>> pd.isna(s2.iloc[1])
False

The mismatch predates this diff (it returned np.uint64(0) before), but the new <NA> repr makes the scalar look like a missing marker that pd.isna / NA-propagation will not honor. The strict ExtensionArray contract is that __getitem__ returns self.dtype.na_value for missing entries. If sentinel-boxing is deliberate (likely, given the all-zero-word design), fine — flagging so it is a recorded decision rather than an accident; out of scope to change in this phase.


Generated by Claude Code

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

Recorded as deliberate, not changed in this PR: the all-zero sentinel is the array's storage-level missing value, and boxing it as MortonIndexScalar(0) keeps __getitem__ total over the raw words (the strict dtype.na_value return is a pre-existing deviation, as noted). The <NA> rendering matches the array formatter's treatment of the same word, so display is at least consistent; making __getitem__ return pd.NA is an ExtensionArray-contract change that belongs to its own decision, standing for review here.


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Adversarial review of phases 2–4 (commits c0c90fd..d0ec61a): to_decimal, to_legacy_i64, hive_path/from_hive_path/_decimal_to_word, and the string-layer golden pins. All claims below were verified by running the code at this head.

Findings (3 inline):

  1. Mediumfrom_hive_path's directory cross-check raises a false ValueError for a bare leaf under a root whenever the root has ≥ order+1 components ("s3://bucket/-3.zarr" fails; order-0 ids fail under any root), contradicting the docstring's "one under an arbitrary root skips the check". The loose direction (partial contradicting digit dirs accepted) is flagged in the PR body, but this false-reject direction is not.
  2. Low — a single pathlib.Path argument to from_hive_path raises TypeError: 'PosixPath' object is not iterable.
  3. Low / non-blockingfrom_hive_path makes one single-element Rust call per path (~0.54 s / 100k vs ~0.08 s emit); recorded for the file.

Probed and confirmed correct: _decimal_to_word rejects Arabic-Indic/fullwidth/superscript digits, U+2212 minus, +-signed, and whitespace-padded ids (the isdigit() and isascii() pair is sufficient); to_legacy_i64's order-18 cap is exact and never data-dependent (southern all-4 order-18 -6444444444444444444 fits int64 and inverts bit-exactly through from_legacy, while the smallest order-19 id 1.11e19 already overflows); the <U31 width claim holds (northern order-29 renders 30 chars, southern 31, kernel-bounded so numpy's silent U31 truncation is unreachable); sentinel words raise through decimal_repr before any emit path returns; the order-29 point→area collapse through hive round-trip is documented and pinned by test_golden_repr_not_injective_point_vs_area; pandas Series.array round-trips all three new methods.

Conventions: mortie/morton_index.py is 644 lines (under the ~1000 cap), no new dependencies, both flake8 gates clean, all 31 tests in the touched suites pass locally, commit style matches §3.


Generated by Claude Code

Comment thread mortie/morton_index.py
Comment on lines +584 to +592
head = 2 if dec.startswith("-") else 1
levels = [dec[:head], *dec[head:]]
if len(parts) - 1 >= len(levels):
got = parts[-1 - len(levels):-1]
if got != levels:
raise ValueError(
f"hive path {p!r} directories {'/'.join(got)!r} "
f"do not match leaf id {dec!r}"
)

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)

Medium — the directory cross-check false-positives on exactly the case the docstring promises to accept. The docstring says a bare {full_id}.zarr "or one under an arbitrary root" skips the check, but the gate is component-count-based (len(parts) - 1 >= len(levels)), so any bare leaf whose root has at least order + 1 components is misread as a full digit chain and raises. Verified against this commit:

MIA.from_hive_path("-31123.zarr")                  # ok (documented)
MIA.from_hive_path("s3://bucket/-3.zarr")          # ValueError: directories 'bucket' do not match leaf id '-3'
MIA.from_hive_path("x/-3.zarr")                    # ValueError: directories 'x' do not match leaf id '-3'
MIA.from_hive_path("s3://bucket/data/-31123.zarr") # ok (root shorter than chain, check skipped)

Order-0 ids are the worst case: any root at all trips it. The flip side of the same heuristic is that partially-contradicting digit dirs pass silently (MIA.from_hive_path("9/9/-31123.zarr") parses fine), which the PR body flags — but the false-reject direction isn't flagged anywhere and contradicts the docstring as written.

Root components are inherently indistinguishable from digit dirs, so the count heuristic can't be made airtight; concrete options: (a) only run the check when the expected chain actually matches positionally, i.e. treat a mismatch at parts[-1-len(levels)] (the sign+base slot, which never looks like a bare 1..4 digit... except it can equal another id's base — so realistically) — (b) accept the ambiguity but make the behavior match the docs: keep the check strictly opt-in for paths produced by hive_path by requiring the base-level component dec[:head] to be present at its slot before enforcing, or (c) keep the code and rewrite the docstring to state the real rule ("checked whenever the path has at least order+1 leading components — do not pass bare leaves under a root"). Note test_single_path_and_bare_leaf only covers the zero-root bare leaf, so the promised under-a-root case is untested.

Related edge (same slice): a doubled slash inside an otherwise-correct full chain also raises ("-3//1/1/2/3/-31123.zarr" → mismatch on the empty component). Strict is defensible, just noting it falls out of the same arithmetic.


Generated by Claude Code

Comment thread mortie/morton_index.py Outdated
Comment on lines +572 to +577
if isinstance(paths, str):
paths = [paths]
words = []
for p in paths:
parts = str(p).rstrip("/").split("/")
leaf = parts[-1]

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)

Low — a single pathlib.Path input fails with a confusing TypeError. The str(p) inside the loop suggests PathLike elements are intended to work (and they do, inside a list), but the singleton guard is isinstance(paths, str) only, so:

MIA.from_hive_path(Path("-3/1/1/2/3/-31123.zarr"))
# TypeError: 'PosixPath' object is not iterable

Verified on this commit. Either widen the guard (isinstance(paths, (str, os.PathLike))) or say "str only" in the docstring — as written, "a single path (str)" plus per-element str(p) reads as if Path should work both ways.


Generated by Claude Code

Comment thread mortie/morton_index.py
Comment on lines +63 to +64
nested = np.asarray([(base << (2 * order)) | within], dtype=np.uint64)
return int(_rustie.rust_mi_from_nested(nested, order)[0])

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Low (non-blocking) — one single-element Rust FFI call per parsed path. _decimal_to_word packs one word per call, so from_hive_path pays per-element crossing overhead: measured on this commit, 100k paths parse in ~0.54 s vs ~0.08 s to emit them (hive_path) and ~0.03 s for to_decimal. Fine for the expected manifest sizes; if bulk parsing ever matters, grouping ids by order and batching rust_mi_from_nested per group is the obvious vectorization. Not asking for a change — just pinning the number so the tradeoff is a recorded decision rather than a surprise.


Generated by Claude Code

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

Accepted as a recorded tradeoff: hive paths are manifest-scale I/O (LIST results), not bulk compute, so ~0.54 s / 100k parses is well inside budget and the per-call clarity wins. If a bulk consumer appears, the vectorization you sketch (group by order, batch rust_mi_from_nested per group) is the plan of record.


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Fresh-context adversarial review of the phase-5 commit (efcc196), exercised against the built extension in a clean interpreter.

Checked out clean:

  • __reduce__ is correct across the full uint64 range: round-tripped words with bit 63 set and 2**64-1 through all pickle protocols 0-5 plus copy.deepcopy; int(self) never overflows (Python int) and np.uint64.__new__ accepts the full range without warnings. Type and decimal display survive the boundary.
  • __format__ pinning tests and the %-formatting doc line match observed behavior ("%d" % s emits the raw word; f"{s:d}" raises).
  • The two paths the phase targets behave as intended: "-3/1/1/2/3/-31123.zarr" validates, "-3/1/1/2/4/-31123.zarr" raises, "data/-3/-31123.zarr" (short tail) parses as root. Full suite: 58 passed; flake8 E9/F63/F7/F82 clean; commit message and module size conform to conventions.

Findings (inline):

  1. The exact-equality anchor regresses the wrong-base mis-filing: "-4/1/1/2/3/-31123.zarr" raised in phase 4 and silently parses in phase 5 — the anchor component is now unvalidatable by construction. Suggested a shape-match anchor (or a documented decision + pinning test). Also noted the residual coincidental-root false-reject (real, verified for both signs) deserves a docstring sentence.
  2. Minor: PathLike support is posix-separator-only; Windows Path inputs raise (loudly) and CI won't catch it.

No blocking issues beyond the decision in (1).


Generated by Claude Code

Comment thread mortie/morton_index.py Outdated
Comment on lines +597 to +604
# Enforce the directory cross-check only when the chain is
# anchored: the {sign+base} component at its expected slot.
# (Root components are indistinguishable from digit dirs by
# count alone, so an unanchored tail is treated as root.)
if (
len(parts) - 1 >= len(levels)
and parts[-1 - len(levels)] == levels[0]
):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Anchoring on equality with levels[0] regresses the mis-filed-base case (verified). Phase 4's count-based check raised on "-4/1/1/2/3/-31123.zarr" — a full digit chain filed under the wrong base cell. Phase 5 silently accepts it, because the one component used to detect the chain (the sign+base slot) is now the one component that can never be validated: any wrong value there demotes the whole chain to "root". A wrong-base mis-filing is arguably the most likely one in practice (coarsest split), and it's exactly the class this cross-check exists for.

>>> MIA.from_hive_path("-4/1/1/2/3/-31123.zarr")   # phase 5: parses as -31123
>>> # phase 4: ValueError: directories '-4/1/1/2/3' do not match leaf id '-31123'

A middle ground that keeps the phase-5 fix for arbitrary roots but recovers this case: engage the check when the anchor slot matches the sign+base shape (optional - + digit 1..6) rather than exact equality with levels[0]"-4" then anchors and raises, while "data", "s3:", "archive" roots still skip. Not raising here may also be an acceptable documented tradeoff, but the current tests (test_misfiled_leaf_raises) don't pin either way on a wrong anchor, so the regression is invisible to the suite — worth a decision plus a pinning test whichever way it goes.

Relatedly, the residual false-reject the comment alludes to is real and worth one docstring sentence: a root component coincidentally equal to the anchor token at exactly its slot still rejects, e.g. "-3/a/b/c/d/-31123.zarr" raises, and for positive ids the anchor is a bare digit so "v/3/x/y/z/w/31123.zarr" raises too (numeric root dirs like dates make single-digit collisions plausible). Verified both against efcc196.


Generated by Claude Code

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 phase 6 exactly as suggested: the anchor is now the sign+base shape (optional - + digit 1..6) rather than equality with the leaf's own token, so "-4/1/1/2/3/-31123.zarr" and "2/1/1/2/3/41123.zarr" raise again (both pinned in test_misfiled_leaf_raises), while "data"/"s3:" roots still skip, and a real root above the chain stays fine (test_root_ending_in_digit_chain_still_parses). The residual single-digit-root collision false-reject is the accepted tradeoff and is implied by the docstring's shape wording; it stays strict-on-suspicious rather than lenient.


Generated by Claude Code

Comment thread mortie/morton_index.py
Order-29 ids parse to the *area* word (see
:func:`_decimal_to_word`).
"""
if isinstance(paths, (str, os.PathLike)):

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)

Minor, non-blocking: the new os.PathLike acceptance splits str(p) on "/" only, so on Windows a Path("-3/1/1/2/3/-31123.zarr") stringifies with backslashes and every hive-layout PathLike raises (malformed decimal Morton id '-3\\1\\1\\2\\3\\-31123' — verified via PureWindowsPath). It fails loudly rather than mis-parsing, and CI is ubuntu-only so it's untested either way, but since build-wheels.yml ships Windows wheels: either normalize (pathlib.PurePath(p).parts when the input is a PathLike, or str(p).replace(os.sep, "/")) or note posix-separator-only in the docstring.


Generated by Claude Code

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 phase 7: PurePath inputs are now split via their own .parts (so a WindowsPath honors backslashes), with a PureWindowsPath round-trip pinned in test_pathlike_input — testable on the ubuntu CI since the flavor, not the host, decides the split. Raw strings keep the exact "/"-split semantics.


Generated by Claude Code

@espg espg added the waiting label Jul 10, 2026
@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

All phases complete (1–7), local gates green (flake8 + 578 pytest), review threads addressed or answered; marking waiting for your review. One process note: CLAUDE.md §2 says to flip a fully-phased PR out of draft, but this routine runs under a drafts-only constraint (never mark ready-for-review), so per the CLAUDE.md conflict rule I'm leaving it draft and flagging it here — flip it when you're satisfied. Four small design questions are under "Questions for review" in the body; none block review.


Generated by Claude Code

@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

CI note: everything is green (tests 3.10–3.12, arro3-no-pyarrow, ruff, codecov patch/project, all wheel builds) except the CodSpeed gate, which flags fast_norm2mort_orders[6] (−17.8%) and [10] (−15.6%). That's runner noise, not a real regression: this PR contains no Rust changes at all (pure Python display layer + tests), the report itself headlines "will not alter performance" and warns "Different runtime environments detected", and the same two benchmarks swung +12% on the equally-Rust-free PR #102 (its report). Acknowledging the flag is a CodSpeed-dashboard action on your account, so I'm leaving it standing rather than dismissing it myself.


Generated by Claude Code

@espg espg mentioned this pull request Jul 10, 2026
@espg espg marked this pull request as ready for review July 10, 2026 01:25
@espg espg merged commit 55f8714 into main Jul 10, 2026
20 of 21 checks passed
@espg espg deleted the claude/104-decimal-display-casting branch July 10, 2026 01:25
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.

Decimal-string display & casting layer: element repr, vectorized to_decimal, scalar wrapper, legacy i64 emit, hive_path

2 participants