Skip to content

Morton decimal shard ids + hive-partitioned output layout (issue 199)#205

Merged
espg merged 7 commits into
mainfrom
claude/199-decimal-hive-layout
Jul 10, 2026
Merged

Morton decimal shard ids + hive-partitioned output layout (issue 199)#205
espg merged 7 commits into
mainfrom
claude/199-decimal-hive-layout

Conversation

@espg

@espg espg commented Jul 10, 2026

Copy link
Copy Markdown
Member

Closes #199
Refs #198

Adopts the morton decimal shard-id string as the external form of every shard id, and lands the morton-hive store layout behind a config flag on both backends — the layout migration from the issue, per the sparse-coverage design record (docs/design/sparse_coverage.md, D1–D6). The packed uint64 word stays the canonical in-memory/wire/storage form; every string that leaves the process — CSR subgroup names, async .status object keys, log lines, --morton-cell, hive paths — carries the decimal repr (e.g. -4211322 instead of 11827859996358475782).

Phases

  • Phase 1 — decimal shard-id strings (flat layout)
  • Phase 2 — hive tree behind config flag + morton_hive.json manifest + commit stamp + node invariant + layout docs
  • Phase 3 — Lambda hive wiring (worker leaf templating, setup-mode manifest, dispatch) — scope added by espg
  • Three review-fold commits between/after the phases (details in the PR comments).

Phase 1 — decimal shard-id strings

Boundary pair (src/zagg/grids/morton.py): morton_decimal(word) -> str (mortie 0.9.0 decimal_repr(); raises ValueError on invalid/empty/negative words — negative gets a pointed "this looks like a legacy signed id" message) and morton_word(label) -> int (mortie's issue-104 _decimal_to_word parse-back; docstring records why the private numpy-only surface was chosen over the public-but-pandas from_hive_path, and the order-29 non-injectivity caveat).

Grid seam: grid.shard_label(shard_key) on the OutputGrid protocol — HealpixGrid renders the decimal morton string (D1); RectilinearGrid keeps packed-tile int digits. zagg.grids.base.shard_label(grid, key) falls back to str(int(key)) for minimal grid stand-ins; runner._safe_label additionally never raises, for error-reporting sites (async keeps fail-loud labels — they name status objects; sync falls back).

Surfaces routed through the label: CSR subgroup paths (K==1; K>1 keeps flattened block-index ints), .status/<run_id>/<label>.json, worker + dispatcher logging, the async payload-cap error message, --morton-cell (decimal parse for healpix catalogs, with a "regenerate the shard map" hint when the catalog's keys look legacy), the tdigest reader's name parse-back (explicit ≥2-digit grammar; a signed name in a block-keyed store fails loudly), and .github/scripts/run_benchmark.py's morton_cell (the harness could not dispatch at all post-break; flagged since it lives under .github/).

Hard break, no shims (pre-1.0): pre-#199 stores keep packed-int names and won't round-trip. The Lambda event shard_key and ShardMap shard_keys stay ints (canonical wire/storage form, D1).

Dependency: mortie>=0.9.0 (espg-authorized); neither the lambda extra nor the layer build pins mortie.

Phase 2 — hive store layout (output.store_layout: hive)

src/zagg/hive.py:

  • Config flag output.store_layout: flat|hive (default flat, byte-identical). Validation rejects hive with rectilinear grids, sharded: true, and consolidate_metadata.
  • Leaf paths (D2/D3): {store_root}/{sign+base}/{d1}/…/{full_id}.zarr via mortie 0.9.0's hive_path (spec-owned), re-checked against the node invariant before any write. Example: -5112333root/-5/1/1/2/3/3/3/-5112333.zarr.
  • Manifest (D6): morton_hive.json written once at template time — spec version, dataset identity, orders, explicit split schedule, declared-only pyramid block. Reruns verify the frozen keys (spec/dataset/cell_order/shard_order/split_schedule); the sweep-mutable pyramid and generated_at are excluded. Re-templating never removes leaves: an overwrite that changes frozen keys refuses when the digit tree has children (one delimiter-LIST) — clear the root instead.
  • Commit stamp (D4): the shard's FINAL write — completion flag + cell count + timestamp + granule count on the leaf root. Absent/unstamped/malformed all read as debris; leaves are created lazily (no-data shards leave no prefix) and overwritten wholesale on retry. The stamp-last op ordering is pinned by a recording-shim test.
  • Shared write path: hive.process_and_write_hive — leaf template (HealpixGrid.emit_shard_template/shard_spec), leaf-local block placement, CSR naming, stamp ordering — used by BOTH dispatchers (phase 3).
  • Docs: docs/hive_layout.md, in the mkdocs nav under Design.

Phase 3 — Lambda hive wiring

  • Worker leaf templating (deployment/aws/lambda_handler.py): under a hive config, _handle_process calls the same zagg.hive.process_and_write_hive the local dispatcher runs — the worker derives its leaf path from shard_key + the event config's orders, emits its own leaf template lazily, writes, and stamps as its FINAL PUT on error-free shards only. A write failure raises out to the handler's 500 envelope: the leaf is unstamped debris, overwritten wholesale on retry — the same recovery model as local. The flat write machinery is untouched (wrapped in the else branch verbatim; existing flat tests pin it).
  • mode: "setup" hive branch: writes morton_hive.json only via hive.ensure_manifest — no global template (D5), same frozen-key resume/overwrite semantics as local. The setup response now echoes the layout it acted on ("layout": "hive"|"flat"); flat setup is otherwise byte-identical.
  • Dispatch (src/zagg/runner.py): the lambda-backend hive rejection is removed. Per-shard events need no new keysstore_layout rides in the config dict already serialized into every event. One optional event key was added to setup only: "dataset" ({"short_name", "version"} from the ShardMap metadata, the same source the local path uses) so the manifest's identity block matches across backends; flat setup events omit it and stay byte-identical (pinned on the wire). Async status objects stay at the flat sibling prefix {store_root}.status/<run_id>/<label>.json — outside the digit tree, per O6.
  • Deploy ordering: the Lambda function must be redeployed with this code before the first hive dispatch — a pre-phase-3 function would emit the flat global template at the hive root and return an indistinguishable 200, so the dispatcher now requires the setup response's hive layout echo and fails fast at setup ("redeploy the function") against a stale deployment. Flat runs never require the echo, so a new dispatcher keeps working against old deployed functions for flat stores.
  • Out of scope, deliberately: per-shard MOC / coverage.moc (design being settled on Write store coverage MOC at end of run (dispatcher-side domain declaration) #200 — no stamp fields added for it); mode: "finalize" for hive (validation already rejects consolidate_metadata+hive); no deployment script or workflow changes (zagg/hive.py ships in the function bundle as part of the package).

How it was tested

  • Phase 3 + folds (tests/test_lambda_handler.py::TestProcessHive/TestSetupHive, tests/test_hive.py::TestRunnerWiring/TestInvokeLambdaSetupEvent): handler hive event → leaf at the exact mortie hive_path, real data written through the real template/write path, label-named CSR, stamp with worker counters; worker-death mid-shard → 500, unstamped debris with a stale CSR subgroup → retry overwrites wholesale (stale object gone) and stamps; no-data shard leaves no prefix; setup-mode hive → manifest only (no template arrays) with the layout echo, frozen-key mismatch → 500 "clear the store root", matching rerun resumes; wire-level setup-event pins (mocked boto3 client): hive events carry dataset, flat events omit it and match the pre-phase-3 baseline exactly, hive dispatch against an echo-less/flat-echo (stale) function fails fast with the redeploy message, flat dispatch tolerates echo-less old functions; stamp-last op-ordering pin via recording shims (one test covers both backends — they share process_and_write_hive). Local/lambda equivalence holds by construction.
  • Phases 1–2 + review folds: decimal⇄word round trips (orders 0–29, both hemispheres) + raising-contract pins; reader flavor discrimination both ways; legacy-catalog hint; _safe_label fallback + async/sync label asymmetry; manifest build/resume/mismatch/overwrite-guard; node-invariant tree walk; torn-write simulation with the stale-object wholesale-wipe pin; K==1/K==16 leaf-local block mapping; config-flag validation with flat default unchanged.
  • Local: uv run pytest -q1625 passed, 28 skipped (usual AWS-cred/network skips); ruff check / ruff format --check clean on every touched file; pre-commit mypy shows zero new errors vs main.
  • Pre-existing, not fixed (out of scope): ruff N818 on zagg.registry.UnknownCapability, codespell gir in rectilinear.py, format drift in six untouched files.

Questions for review

  1. Setup-event dataset key (the one event-schema addition): sourced from ShardMap metadata dispatcher-side so local and Lambda manifests carry identical identity blocks (dataset is a frozen key — a mixed local/lambda rerun must not mismatch). Alternative considered: sourcing from config.data_source worker-side (no event key, but a second identity source that can drift from the catalog). Confirm the event-key choice.
  2. Hive write-failure envelope on Lambda: a failed leaf write raises to the handler's generic 500 (debris + retry), rather than the flat path's record-and-continue write_error fold. The round-3 review traced it end to end and assessed accept-as-is (no stranding; same async mirror; only failed-shard duration/memory rollups differ) — accepted, with the noted pointer that a future fold belongs in process_and_write_hive, not the handler. Final call is yours.
  3. Private mortie API: morton_word rides mortie.morton_index._decimal_to_word (numpy-only; rationale in the docstring). The upstream ask (public export before the 1.x freeze) stands for you to file.
  4. Deferred: viz shard-map GeoJSON shard_key property still emits the packed int; coverage.moc + pyramid sweep are follow-ons (Write store coverage MOC at end of run (dispatcher-side domain declaration) #200 gates the MOC); per the issue comment, Write store coverage MOC at end of run (dispatcher-side domain declaration) #200 can be marked unblocked once this PR's phases are accepted.

@espg espg added the implement label Jul 10, 2026

@espg espg left a comment

Copy link
Copy Markdown
Member 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 (decimal shard-id strings on the flat layout), against issue #199's phase-1 scope, docs/design/sparse_coverage.md D1–D6, and mortie 0.9.0 source (tag 0.9.0).

What holds up. The boundary pair is correctly built on mortie's decode-through-kernel repr and the issue-104 parse-back; the southern-hemisphere sign, mixed orders, and the packed-word ⇄ decimal round trip are lossless and pinned by tests at orders 0/6/9/18 in both hemispheres. The reader's coord-array alignment correctly switched from int order to parsed-word order (northern-before-southern), pinned by the mixed-hemisphere regression test. All four issue surfaces (CSR subgroup names, .status keys, worker/dispatcher logs, --morton-cell) route through the shard_label seam; the sharded (#108) path is genuinely block-keyed at every K, so leaving it unchanged is right; the Lambda event shard_key staying a packed int matches D1 (canonical wire form). Rect's str(int) label is consistent end-to-end with _select_cells' int parse. A branch grep found no remaining packed-word leaks in externally rendered strings beyond the ones the PR itself defers.

Findings (inline comments carry the detail):

  • should-fixreaders/tdigest_tensor.py::_subgroup_words: the flavor heuristic is weaker than documented. Single-digit block keys "5"/"6" parse as valid decimal ids (lone base digit), so an all-{1..6} block-keyed store silently flips flavor; a leading-- name in the int fallback is provably wrong yet accepted; and the "coord array wins" mitigation is vacuous in-repo (nothing in zagg writes {field}/morton). Fail loudly on the provably-wrong cases.
  • should-fixrunner.py accumulators (lines 1210, 1522): label rendering in error-reporting paths can now raise (morton_decimal raises on invalid words), aborting run accumulation exactly for the malformed key it was reporting. mortie's own scalar repr never raises for this reason; use a defensive fallback in log-only sites.
  • should-fix (small)grids/morton.py::morton_decimal: negative input (a legacy signed id — the exact migration confusion) raises OverflowError, contradicting the documented ValueError contract.
  • question-for-authormorton_word's private _decimal_to_word: a public alternative exists (MortonIndexArray.from_hive_path(label, suffix="")) but requires pandas; the private numpy-only name is defensible — file the upstream export ask now and document the choice. (Answers PR question 1.)
  • question-for-author_select_cells: a legacy signed-key catalog + a well-formed decimal id yields an unhelpful "not in catalog"; decide whether a regenerate-hint is wanted or the bare failure is the intended hard-break surface.
  • nit — tests: pin morton_decimal(0) raising (the "never silently wrong path component" claim is untested on the emit side); optionally one order-29 area case.
  • nit — the payload-cap deferral's "no grid in scope" rationale is weaker than stated: _cell_work has the label in hand on exactly the runs that can hit the cap.

Deferred surfaces (PR question 4). ShardMap JSON/parquet packed-word shard_keys are squarely D1 canonical storage — genuinely fine to defer indefinitely. The viz GeoJSON shard_key property is interchange, not storage, so D1's "the signed decimal string [is] the external form" reads as convert-eventually; deferring past phase 1 is a scope call I'd record in the issue rather than leave implicit. Status-key shape (PR question 2) matches O6 (stay flat, basename-only change) — looks intended. Also noting for the docs sweep: src/zagg/auth.py's docstring event example ("shard_key": -6134114) and docs/deployment/lambda.md's examples carry legacy signed values that are not valid packed words — stale before this PR, but phase 2's layout docs are the natural place to fix them.

Conventions. mortie>=0.9.0 is the one dependency change and is authorized on the issue thread. The .github/scripts/run_benchmark.py edit is application-level harness code, not workflow config — I agree with the judgment call (no .github/workflows/ or deployment/aws/ build/deploy scripts touched; the handler edit is the worker's write path, in scope). The __main__.py format normalization is semantics-neutral but widens the diff against the "don't fix pre-existing drift" norm — mild preference for the offered minimal-hunk revert, non-blocking. No module crosses the ~1000-line guidance in this PR (runner.py at ~2.4k lines is a pre-existing offender, not this change). No blocking-correctness findings; the two reader/accumulator should-fixes are the ones I'd want addressed before phase 2 stacks the hive tree on top of the same seams.


Generated by Claude Code

Comment thread src/zagg/readers/tdigest_tensor.py Outdated
Comment on lines +215 to +227
def _subgroup_words(shard_keys: list[str]) -> dict[str, int]:
"""Parse CSR subgroup names to their numeric keys (issue #199).

Shard-keyed subgroups are named by the decimal morton string (D1), so the
numeric key is the parsed packed word; block-index-keyed subgroups (the K>1
layout) keep plain ints. The flavor is uniform per store, so parse all
names as decimal morton and fall back to ``int`` if any name isn't one
(e.g. ``"0"`` or a digit outside ``1..4``).
"""
try:
return {k: morton_word(k) for k in shard_keys}
except ValueError:
return {k: int(k) for k in shard_keys}

Copy link
Copy Markdown
Member 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 flavor heuristic is weaker than this docstring claims, in both directions:

  1. The docstring's examples of a non-decimal name ("0", digit outside 1..4) miss the single-digit case: _decimal_to_word accepts a lone base digit 1..6, so block indices "5" and "6" parse as valid order-0 decimal ids (verified against mortie 0.9.0: morton_word("5") == 5764607523034234880). A K>1 store whose populated block keys are all in {"1".."6"} (or longer names that happen to fit the grammar) silently flips the whole store to packed-word interpretation — no "0" needed to save it.

  2. In the int fallback branch, a name with a leading - can never be a block index (block keys are non-negative flattened indices), yet it maps to a nonsense negative "block key". Concretely: a decimal-named store poisoned by one debris subgroup (e.g. a pre-Adopt morton decimal shard ids + hive-partitioned output layout (manifest, commit stamp) #199 leftover after an overwrite=True rerun into the same store path — the template rewrite doesn't sweep old ad-hoc CSR subgroups) degrades ALL its -31123-style names to negative ints, silently.

  3. The PR body's mitigation ("the coord array wins when present") is currently vacuous in-repo: nothing in zagg writes the {field}/morton sibling — I could find no writer under src/zagg/ — so the heuristic is the only line of defense until phase 2's manifest.

Cheap tightening that fails loudly instead of silently mis-keying: in the fallback branch, raise (or at least warn) if any name starts with -; optionally also warn when the store mixes parse outcomes. Phase 2's morton_hive.json marker is the real fix, but this reader can return wrong answers with no error today.


Generated by Claude Code

Comment thread src/zagg/runner.py Outdated

def _accumulate(report, i, outcome):
shard_key = outcome["shard_key"]
label = shard_label(grid, outcome["shard_key"])

Copy link
Copy Markdown
Member 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 — label rendering in the error-reporting path can now itself raise. shard_label(grid, ...)morton_decimal raises ValueError on an empty/invalid word (and OverflowError on a negative legacy key — see the companion comment on morton.py). Before this change the log line interpolated the raw int and could never fail.

The failure chain: a malformed key in a catalog makes process_shard raise → _cell_work catches it and returns {"ok": False, ...} → this _accumulate re-renders the same bad key and raises out of the accumulation loop, aborting the whole run's reporting precisely for the shard it was trying to report as failed. Same pattern at the lambda accumulator (line 1522).

mortie itself is deliberate about this: MortonIndexScalar.__str__ never raises ("a repr must never raise" — it renders <invalid 0x...>). The raising morton_decimal is the right choice at the write path (a path component must never be silently wrong), but in these two log-only sites a defensive fallback (try: label = shard_label(...) except Exception: label = str(key), or routing through str(MortonIndexScalar(key))) keeps error reporting unkillable.


Generated by Claude Code

Comment thread src/zagg/grids/morton.py Outdated
Comment on lines +70 to +83
def morton_decimal(word) -> str:
"""Decimal morton string for one packed shard-key word (issue #199).

The external/path form of a shard id per the sparse-coverage design record
(``docs/design/sparse_coverage.md`` D1): the packed ``uint64`` word stays
the canonical in-memory/wire form, and every externally visible string —
CSR subgroup names, ``.status`` object keys, log lines — renders through
mortie's decode-through-kernel decimal repr (e.g. ``-31123``). Raises
``ValueError`` on an empty or invalid word (a path component must never be
silently wrong).
"""
from mortie import MortonIndexArray

return MortonIndexArray.from_words(np.asarray([int(word)], dtype=np.uint64)).decimal_repr()[0]

Copy link
Copy Markdown
Member 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 (small) — the docstring promises ValueError on an invalid word, but a negative input raises OverflowError from np.asarray([int(word)], dtype=np.uint64) (verified: morton_decimal(-1)OverflowError: Python integer -1 out of bounds for uint64). A negative int here is exactly the confusion this migration creates — someone passing a legacy signed decimal key (-4211322) where the packed word belongs — so it's worth normalizing to ValueError with a pointed message (e.g. "negative — is this a legacy signed decimal id? parse it with morton_word(str(id))"). As written, the opaque OverflowError also escapes any caller that catches only ValueError per the documented contract.

Related nit: nothing pins the advertised raise-on-invalid behavior — see the test comment.


Generated by Claude Code

Comment thread src/zagg/grids/morton.py
Comment on lines +86 to +95
def morton_word(label: str) -> int:
"""Parse a decimal morton string back to its packed word (issue #199).

The inverse of :func:`morton_decimal` at the zagg boundary — used where an
external decimal id re-enters (``--morton-cell``, CSR subgroup names on the
read path). Raises ``ValueError`` on a malformed id.
"""
from mortie.morton_index import _decimal_to_word

return _decimal_to_word(str(label))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

question-for-author (answers PR question 1) — I checked mortie 0.9.0 for a public parse-back. There IS one: MortonIndexArray.from_hive_path(label, suffix="") — with an empty suffix it skips the leaf-suffix check and the directory cross-check (a bare id has no digit chain to anchor on), so it is a pure public scalar parse-back with the same ValueError semantics. Two caveats that make the private name defensible anyway:

  1. MortonIndexArray is built lazily and requires pandas (_build_classes_require_pandas), while mortie.morton_index._decimal_to_word is numpy-only. Routing readers/tdigest_tensor.py through from_hive_path would drag the pandas ExtensionArray machinery into the read path per store open.
  2. _decimal_to_word is the documented issue-104 surface (its docstring reads like a contract, including the order-29 point/area non-injectivity note), and zagg wraps it exactly once here — small blast radius if it moves.

So: riding the private name seems fine for phase 1, but file the upstream ask (public numpy-only decimal_to_word export) now so the 1.x spec freeze catches it, and note in this docstring why the private import was chosen over from_hive_path (pandas). One substantive caveat worth a line in the docstring: order-29 point ids parse back to the area word (the documented non-injectivity) — irrelevant for shard keys at order ≤ 11, but this helper is exported in __all__ as a general boundary function.


Generated by Claude Code

Comment thread tests/test_grids.py
Comment on lines +795 to +800
def test_morton_word_rejects_malformed(self):
from zagg.grids.morton import morton_word

for bad in ("", "0", "7", "150", "abc", "11827859996358475782"):
with pytest.raises(ValueError):
morton_word(bad)

Copy link
Copy Markdown
Member 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 — good coverage of the parse direction (including the raw-packed-word rejection, which is the ambiguity that matters). Two small gaps:

  1. Nothing pins the emit direction's raising contract: the PR body sells morton_decimal as "raises on invalid/empty words so a path component is never silently wrong", but no test asserts morton_decimal(0) raises. One line here would pin it — and would surface the negative-input OverflowError inconsistency flagged on morton.py.
  2. The round trip covers orders 0/6/9/18 in both hemispheres (good — the southern sign is the case a naive label would drop, and morton_word(s) == word genuinely catches a missing sign). Orders 19–29 — where the decimal form extends past the legacy i64 — are untested; shard orders are ≤ 11 today so this is optional, but a single order-29 area case would pin the full grammar width (<U31).

(test_healpix_shard_label_is_decimal's label == morton_decimal(parent) is tautological with the implementation; the morton_word(label) == parent assert is what carries it — fine as is.)


Generated by Claude Code

Comment thread src/zagg/runner.py
Comment on lines +924 to +934
grid_type = (catalog_data.get("grid_signature") or {}).get("type")
if grid_type == "healpix":
try:
target = morton_word(morton_cell)
except ValueError as e:
raise ValueError(
f"--morton-cell {morton_cell!r} is not a decimal morton id "
f"(shard ids are decimal morton strings since issue #199): {e}"
) from e
else:
target = int(morton_cell)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

question-for-author — the grid-type gate and the pointed packed-word rejection are right, and rect stays consistent end-to-end (RectilinearGrid.shard_label = str(int) ⇄ the int(morton_cell) parse here — the protocol contract holds). One residual confusion worth deciding on: a pre-0.9 legacy catalog (signed-i64 shard_keys like -4211322) fed a decimal id will parse cleanly (morton_word("-4211322") succeeds — the legacy form is valid decimal grammar) and then miss every packed-word comparison, surfacing as a bare "shard '...' not in catalog". Hard break says such catalogs are debris, so failing is correct — but the error is indistinguishable from a typo'd id. If cheap, a hint when the catalog's keys look legacy (any negative / all < 2^63 at healpix signature) — "regenerate the shard map" — would save the first person who trips on it. Fine to decline as shim-adjacent; flagging so it's a decision, not an accident.


Generated by Claude Code

Comment thread src/zagg/runner.py Outdated
key = f"{int(shard_key)}.json"
# Status objects are named by the shard label — the decimal morton
# string for HEALPix (issue #199) — not the raw packed word.
key = f"{shard_label(grid, shard_key)}.json"

Copy link
Copy Markdown
Member 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 (re PR question 4, payload-cap deferral) — the write/poll symmetry here is correct (both sides share the one computed key). But note this line undercuts the deferral rationale for the packed-int payload-cap error in _invoke_lambda_cell: "no grid in scope at that depth" is true of the function signature, yet _cell_work is the caller and has both grid and (on async runs, the only runs that can hit the 256 KB Event cap) the already-rendered label right here. Threading a label= kwarg down (defaulting to str(shard_key)) is a two-line change that would make the one user-facing error message about a specific cell agree with every other rendered id in the run. Fine to fold into phase 2 — flagging that it's cheaper than the PR body implies.


Generated by Claude Code

@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Two commits pushed: 27c27bd folds the adversarial-review findings, 4b949c0 lands phase 2. PR body updated (both phases checked). Full suite: 1607 passed, 28 skipped; ruff/format clean on touched files; zero new mypy errors vs main.

Review findings — disposition

  • Reader flavor heuristicfolded. The flavor is now decided by an explicit grammar (_is_decimal_shard_name): a decimal shard id must have ≥ 2 digits (sign+base plus one digit per order; zagg shard orders are ≥ 1), so lone "1".."6" are block keys, closing the base-digit hole. In the int branch a signed name now raises ("the store layout is ambiguous") — a --prefixed name is provably not a block index, so a decimal store poisoned by pre-Adopt morton decimal shard ids + hive-partitioned output layout (manifest, commit stamp) #199 debris fails loudly instead of silently mis-keying. Tested both ways (test_lone_digit_names_are_block_keys, test_signed_name_in_block_keyed_store_raises). Agreed the {field}/morton coord point stands — nothing in-repo writes it; phase 2's manifest is the layout marker for hive stores, and the flat reader now fails closed rather than open.
  • Non-raising labels in error pathsfolded. New runner._safe_label (try shard_label, fall back to raw digits — mortie's "a repr must never raise" rationale cited in the docstring) at both accumulators; raising semantics kept at path-construction sites. Pinned by TestSafeLabel.
  • morton_decimal exception contractfolded. Negative input now raises ValueError with exactly the suggested remedy text ("…parse it with morton_word(str(id))") instead of the opaque OverflowError; morton_decimal(0) and the negative case are pinned (test_morton_decimal_raises_valueerror_on_invalid).
  • _decimal_to_word vs from_hive_pathfolded (docstring) / left standing (upstream). morton_word's docstring now records the pandas-vs-numpy rationale and the order-29 point→area non-injectivity caveat. The upstream ask (public numpy-only export before the 1.x freeze) is left for you to file — filing it is outside what I act on alone.
  • Test-coverage nitsfolded. morton_decimal(0) raise pinned; an order-29 area round trip pins the full 30-digit grammar width; the tautological label == morton_decimal(parent) assert dropped (the morton_word(label) == parent round trip carries the test).
  • Legacy-catalog UXfolded. A well-formed decimal id that misses in a healpix catalog whose keys look legacy (any key < 2^60 — packed words carry the 4-bit prefix, so they're always ≥ 2^60) gets "…look like legacy signed decimal ids… regenerate with python -m zagg.catalog". Hard break intact; tested that packed-word catalogs keep the bare message.
  • Payload-cap label threadingfolded. You were right that it's cheap: _cell_work renders the label once (status key + invoke), _invoke_lambda_cell takes label=None and the cap error prints cell {label or shard_key}. Pinned in test_oversized_async_payload_raises_before_dispatch.

Phase 2 — hive layout (grounded summary)

Per the issue and docs/design/sparse_coverage.md §2–§3:

  • src/zagg/hive.py: shard_leaf_path (mortie 0.9.0 hive_path — convention stays spec-owned — re-checked by check_node_invariant, D5), build_manifest/ensure_manifest/read_manifest (morton_hive.json, spec: "morton-hive/1", explicit split schedule, declared-only pyramid — D6/D11; reruns verify manifest match), stamp_commit/read_commit (root-attrs commit stamp; absent/unstamped/malformed all read as debris — D4).
  • src/zagg/config.py: output.store_layout: flat|hive (default flat, byte-identical), validated healpix-only, no sharded, no consolidate_metadata.
  • src/zagg/grids/healpix.py: emit_shard_template/shard_spec_spec parametrized so the flat template is byte-identical; the leaf gets a ROOT group so the stamp is one PUT on an object that exists anyway.
  • src/zagg/runner.py: _process_and_write_hive (lazy leaf create → no-data shards leave no prefix; leaf-local block indices via the existing shard_local_region seam; stamp only on error-free shards, as the FINAL write), _run_local writes the manifest instead of a shared template, the Lambda backend rejects hive with a pointed error (worker event-schema change needed — see PR-body question (1)).
  • tests/test_hive.py (39 tests): walker/node-invariant tree walk, debris/retry, torn-write simulation, manifest round-trip/mismatch, config validation, K==1/K==16 local block mapping, runner wiring, lambda rejection. docs/hive_layout.md documents layout, manifest, stamp, and reader implications.

Not in this commit, by design: write-throughput validation (moved to #202 per your comment), coverage.moc and the pyramid sweep (follow-on issues), Lambda hive wiring (PR-body question (1)); #200's unblock waits on that call.


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Round-2 adversarial review, fresh context: commits 27c27bd (review fold) and 4b949c0 (phase 2, hive layout), against the D1–D6 contract in docs/design/sparse_coverage.md §2–§3 and mortie 0.9.0's hive_path.

Round-1 folds — all five verified genuine, not superficial:

  • Flavor grammar: ≥2-digit floor + signed-name raise, both directions tested (test_lone_digit_names_are_block_keys, test_signed_name_in_block_keyed_store_raises); floor holds for order-1 ids. Residual multi-digit ambiguity from round 1 remains, acknowledged — nit inline.
  • _safe_label: non-raising, wired at both accumulation sites (local + Lambda), tested for fallback and happy path.
  • ValueError contract: negative input now raises ValueError with the legacy-id remedy named; morton_decimal(0) and -4211322 pinned.
  • Legacy-catalog hint: fires only when keys look legacy (< 2^60 at healpix); python -m zagg.catalog is a real entry point; both hint-fires and stays-bare tested.
  • Payload-cap label threading: label= kwarg down to _invoke_lambda_cell, message asserted in the test. Order-29 round trip and the morton_word docstring rationale also landed.

D-contract: leaf path is exactly mortie's hive_path (pinned by equality test, incl. the order-0 -3/-3.zarr shape via check_node_invariant's len(parts) >= 2); the tree-walk test enforces zero objects above leaves and manifest-only root (D5); stamp is genuinely the final write, only on error-free shards, with absent/unstamped/malformed all None (D4); no-data shards leave no prefix. I verified empirically that the retry's overwrite=True wipes the whole leaf prefix (zarr delete_dir), so torn-attempt debris is fully cleared — but the suite doesn't pin that, and the manifest rerun-check has two real problems (inline): comparing the sweep-mutable pyramid block will brick resumes once the §7 sweep exists, and the "pass overwrite=True to re-template" remedy leaves stale committed old-order leaves that a reader cannot distinguish from legal mixed-order data.

Config surface: the three rejections (rectilinear/sharded/consolidate) are tested; hive + aoi_mask threads correctly; Lambda rejection sits pre-dispatch in the lambda branch only — local runs never touch it, local dry-run returns before the manifest write, and a lambda dry-run fails fast with the pointed message (defensible). One question inline on the hoisted raising shard_label making sync-Lambda dispatch run-fatal.

Conventions: no CI/deploy/infra touched (mkdocs nav only); no new deps in round 2; hive.py is 216 tight lines; docs page matches the implementation (one addition suggested under the overwrite finding). read_commit has no production caller yet — fine, it's the follow-on reader's entry point and the docs point there, but worth remembering it is currently test-only surface.

Findings (6): 3 should-fix — manifest match-check includes pyramid (resume brick); overwrite remedy strands stale committed leaves; torn-write test doesn't pin wholesale-wipe. 1 question — raising label render on the sync-Lambda dispatch path. 2 nits — residual flavor-grammar ambiguity; runner.py module size vs the ~1000-line convention. None block the next phase.


Generated by Claude Code

Comment thread src/zagg/hive.py
Comment on lines +130 to +140
"""
import obstore

store = open_object_store(store_root, **store_kwargs)
existing = _read_json(store, MANIFEST_NAME)
if existing is not None and not overwrite:
drop = ("generated_at",)
if {k: v for k, v in existing.items() if k not in drop} != {
k: v for k, v in manifest.items() if k not in drop
}:
raise ValueError(

Copy link
Copy Markdown
Member 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 rerun match-check compares a field the design says will mutate, so the first pyramid sweep bricks every later resume. drop = ("generated_at",) excludes only the timestamp, but the design record is explicit that the pyramid block is "populated/updated by the §7 sweep" (docs/design/sparse_coverage.md §3, D11), and this PR's own docs call it "declared-only in round one". The moment the sweep lands and writes real orders into pyramid, build_manifest's fresh {"orders": [], "aggregation": {}} no longer equals the stored copy, and every subsequent run into that store raises here — with a remedy (overwrite=True) that would clobber the sweep's pyramid declaration to get going again.

Cheap fix now, while the manifest shape is young: exclude pyramid alongside generated_at (or, inverted, compare only the frozen keys — spec, dataset, cell_order, shard_order, split_schedule). The guard's real job is the flat path's _check_signature analogue — orders + identity — and none of the mutable-by-design fields belong in it.


Generated by Claude Code

Comment thread src/zagg/hive.py
Comment on lines +141 to +146
f"{MANIFEST_NAME} at {store_root} does not match this run "
f"(existing {existing!r} vs {manifest!r}); pass overwrite=True to "
f"re-template the store"
)
return existing
obstore.put(store, MANIFEST_NAME, json.dumps(manifest, indent=1).encode())

Copy link
Copy Markdown
Member 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 error message's remedy is a silent-corruption footgun: "pass overwrite=True to re-template the store" replaces only the JSON. Unlike the flat path — where emit_template(overwrite=True) routes through zarr's group overwrite and delete_dirs the whole group — a hive "re-template" leaves every committed leaf from the OLD configuration in place: stamped, complete: true, walker-discoverable. And because mixed shard orders are a legal, first-class feature of the layout (D2 — "coarse shards in sparse regions"), a reader walking a store re-templated from shard_order 6 to 5 has no way to tell the stale order-6 leaves from intentional mixed-order data. The stamp certifies them.

Concretely reachable via the message itself: mismatch → user does what the error says → new run writes its shards → old-order leaves it never touches survive as committed data.

Options, cheapest first: (a) reword the message to say overwrite replaces only the manifest and the root must be cleared manually when orders change; (b) have overwrite=True on an orders-mismatch refuse unless the root has no {sign+base} children (one delimiter-LIST); (c) actually sweep the digit tree on overwrite. I'd take (a)+(b); (c) is deletion machinery this phase doesn't need. At minimum docs/hive_layout.md should state that re-templating does not remove existing leaves.


Generated by Claude Code

Comment thread tests/test_hive.py
Comment on lines +339 to +368
def test_torn_write_leaves_debris_then_retry_succeeds(self, monkeypatch, cfg, tmp_path):
# Torn-write simulation: the worker dies after the dense write, before
# the stamp. The prefix exists (debris), read_commit says incomplete,
# and a clean retry overwrites it wholesale and stamps.
from zagg import runner
from zagg.store import open_store

grid = self._grid(cfg)
shard = _shard_word()
root = str(tmp_path / "store")
leaf = hive.shard_leaf_path(root, shard)

def torn(g, shard_key, urls, **kwargs):
carrier = self._carrier(grid, shard_key)
kwargs["write_chunk"](grid.block_index(int(shard_key)), carrier, {})
raise RuntimeError("worker died mid-shard")

monkeypatch.setattr(runner, "process_shard", torn)
with pytest.raises(RuntimeError, match="died mid-shard"):
runner._process_and_write_hive(
shard, [_rec(1)], grid, {}, root, cfg, store_kwargs={}, driver="s3"
)
assert os.path.exists(leaf) # the prefix exists...
assert hive.read_commit(open_store(leaf)) is None # ...but is debris

# Retry: same leaf, overwritten wholesale, stamped at the end.
monkeypatch.setattr(runner, "process_shard", self._streaming_fake(grid))
runner._process_and_write_hive(
shard, [_rec(1)], grid, {}, root, cfg, store_kwargs={}, driver="s3"
)

Copy link
Copy Markdown
Member 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 (test gap) — the torn-write test exercises the easy half of D4. The torn attempt and the retry write the same chunk set, so the test passes even if overwrite=True merely re-put the metadata documents without clearing anything. The load-bearing claim — "overwritten wholesale" — is that stale objects the retry does NOT rewrite are gone: a torn attempt may have written a CSR ragged subgroup or a dense chunk that the retry (different granule outcome, fewer populated chunks) never touches; if those survive, they read back as valid data inside a leaf whose stamp certifies it complete.

I verified the current stack does wipe correctly — pydantic-zarr's root GroupSpec.to_zarr(..., overwrite=True) routes through zarr.group(mode="w")AsyncGroup.from_storestore_path.delete_dir() (zarr 3.2.1), and both LocalStore and the obstore ObjectStore report supports_deletes=True. But nothing in the suite pins it, and it hangs off two upstream internals: the delete only happens because supports_deletes is true (the fallback branch raises on existing nodes instead — a store without deletes would fail retries outright), and pydantic-zarr only reaches the wipe through its extant-group branch.

Two-line strengthening: in torn(), also write a ragged subgroup (e.g. kwargs["write_chunk"](..., carrier, {"h": ([np.array([1.0])], [0])})), retry with the no-ragged streaming fake, and assert the h/{label} subgroup is absent post-retry. That pins wholesale-overwrite against upstream drift.


Generated by Claude Code

Comment thread src/zagg/runner.py Outdated
Comment on lines +1571 to +1575
shard_key, records = payload
# Rendered once per cell: the status-object name (below) and the
# payload-cap error message in _invoke_lambda_cell both carry it
# (issue #199). Raising here is correct — it becomes a path component.
label = shard_label(grid, shard_key)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

question-for-author — hoisting the raising shard_label out of the result_prefix branch quietly widens its blast radius to sync Lambda runs, where the label is never a path component. On async runs the raise is right (the label names the status object — round-1 agreement). But on sync runs its only consumer is the payload-cap error message in _invoke_lambda_cell, i.e. purely cosmetic — and an exception out of _cell_work is run-fatal on the Lambda path: dispatch() re-raises anything a future surfaces (src/zagg/dispatch.py future.result()raise), unlike the local backend where _cell_work catches per cell and one bad key counts as one cells_error. So a single malformed catalog key now aborts an entire sync fan-out mid-flight, where pre-hoist it would have gone to the worker and failed as one cell.

The comment says "Raising here is correct — it becomes a path component", which is only true for result_prefix is not None. Cheapest tightening if you agree: label = shard_label(...) if result_prefix is not None else _safe_label(grid, shard_key) — async keeps fail-loud path semantics, sync keeps error reporting unkillable, matching the round-1 fold's own rationale. Fine to decline as deliberate fail-fast on a corrupt catalog; flagging so the local/lambda asymmetry is a decision, not a side effect of the hoist.


Generated by Claude Code

Comment on lines +215 to +225
def _is_decimal_shard_name(name: str) -> bool:
"""Whether a CSR subgroup name fits the decimal morton *shard-id* grammar.

Grammar: optional sign, base digit ``1..6``, then one ``1..4`` digit per
order — with at least ONE order digit. zagg shard orders are >= 1, so a
real shard id always has >= 2 digits; that floor is what disambiguates the
lone digits ``"1".."6"``, which the raw grammar would accept as order-0 ids
but which in practice are small block-index keys (review finding, PR #205).
"""
body = name[1:] if name.startswith("-") else name
return len(body) >= 2 and body[:1] in "123456" and all(d in "1234" for d in body[1:])

Copy link
Copy Markdown
Member 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 (residual, acknowledged) — the fold is genuine: the ≥2-digit floor kills the lone-digit "5"/"6" flip (and holds for order-1 ids, whose bodies are always 2 digits), and the signed-name raise turns the debris-poisoning case from silent mis-keying into a loud failure — both directions now pinned in tests/test_readers.py. Recording the ambiguity that remains, per round 1's "or longer names that happen to fit the grammar": a K>1 store whose populated block keys all land in the grammar (e.g. only blocks 11–14 of a K=16 store hold data — no "0", no "5"+ to save it) still flips wholesale to packed-word interpretation with no error. Unreachable for K==1 stores (label-keyed by construction) and self-correcting the moment any non-grammar block is populated, so this is acceptably narrow for a heuristic — but the real disambiguator now exists on the write side (morton_hive.json / the flat store's own attrs). When the hive read path lands, consider stamping the flat K>1 store too (one attrs key naming the subgroup key flavor) so this function can stop guessing. No action needed this PR.


Generated by Claude Code

Comment thread src/zagg/runner.py

Copy link
Copy Markdown
Member 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 (conventions)runner.py is at 2,600 lines after this phase (+219 here), far past the repo's ~1000-line raise-it threshold (CLAUDE.md §4). Pre-existing, not this PR's doing — but the phase-2 additions didn't have to land here: _leaf_block_index and _process_and_write_hive (~110 lines) are hive-layout mechanics with no runner state beyond what's passed in, and hive.py sits at a comfortable 216. Moving them (runner keeps only the store_layout == "hive" dispatch branch) would stop the growth and put the leaf write path next to the stamp/manifest code it exercises. Worth doing either in the Lambda-wiring follow-on (which will touch this seam anyway) or now — either way the module's size deserves a line on the issue thread per the convention.


Generated by Claude Code

@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Round-2 fold pushed: c3a8f2d. Local: 1612 passed, 28 skipped; ruff/format clean on touched files; zero new mypy errors vs the previous head. CI on this head is green (ruff, test 3.12/3.13, docs build, lambda build x86_64+arm64 — checks); the two prior heads were green too. No ruff-bot inline comments were posted on any of the three pushed commits.

Round-2 findings — disposition

  • Manifest rerun match-check includes pyramidfolded. The comparison is now an explicit frozen-key projection (_FROZEN_MANIFEST_KEYS = spec, dataset, cell_order, shard_order, split_schedule); generated_at and the sweep-mutable pyramid are excluded, exactly the inversion you suggested. test_rerun_ignores_sweep_mutated_pyramid pins that a swept store resumes AND that the sweep's pyramid declaration is preserved rather than clobbered.
  • Re-template remedy strands stale committed leavesfolded, (a)+(b) as you proposed. (a) The mismatch error no longer says "pass overwrite=True"; it says clear the store root (or pick a new one), and the docstring + docs/hive_layout.md now state plainly that re-templating replaces the manifest only and never removes leaves. (b) ensure_manifest(overwrite=True) with changed frozen keys now refuses when the root has any {sign+base} child — one obstore.list_with_delimiter call, no tree walk; same-orders overwrite over existing data stays allowed. Both directions tested (test_overwrite_with_changed_orders_refuses_over_existing_shards, test_overwrite_with_same_orders_allowed_over_existing_shards). (c) declined as you suggested — no deletion machinery this phase.
  • Torn-write test doesn't pin wholesale wipefolded, your two-line strengthening verbatim: the torn attempt now writes a CSR h/{label} subgroup, the retry streams no ragged, and the test asserts the stale subgroup is gone post-retry (plus that it existed pre-retry) — pinning the delete_dir-based wholesale overwrite against upstream drift.
  • Sync-Lambda label hoist widens the raising pathfolded as the defensive fix you sketched: label = shard_label(...) only when result_prefix is not None (async — the label becomes the status-object path, fail-loud is right); sync falls back to _safe_label, so a malformed key stays one cell's problem instead of aborting the fan-out. Both sides pinned: test_sync_malformed_key_label_falls_back and test_async_malformed_key_is_fail_loud.
  • runner.py module sizefolded (the move was clean). _leaf_block_index / _process_and_write_hive relocated to src/zagg/hive.py as leaf_block_index / process_and_write_hive — no runner-private machinery came along (the one coupling, _resolve_urls, dissolved by passing resolved granule_urls in; the processing imports are call-time, no cycle). runner.py keeps only the store_layout == "hive" dispatch branch and is back to 2,520 lines (net +112 on this PR); hive.py is 346, next to the manifest/stamp code the leaf write exercises. The module's pre-existing size (2.4k before this PR, ~1000-line convention) still deserves its own discussion — leaving that for you to raise on the issue thread rather than opening one myself.
  • K>1 grammar residualleft standing as acknowledged (no code change, per the nit itself). The suggestion to stamp flat K>1 stores with a key-flavor attr is a good candidate for the hive-reader follow-on.

Left standing for review (unchanged from the PR body)

Lambda+hive wiring scope (PR-body question (1) — follow-on phase here vs fresh issue), the upstream mortie ask (public numpy-only parse-back), manifest identity fields, the viz GeoJSON packed-int property, and the #200 unblock once (1) is decided.


Generated by Claude Code

@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Status: both phases implemented and both adversarial-review rounds folded (head c3a8f2d). CI green on every pushed head (ruff, tests 3.12/3.13, docs, lambda builds x86_64+arm64); local suite 1612 passed / 28 skipped (pre-existing credential-gated skips); no ruff-bot findings. Applying waiting — one scope decision needed before this can be called finished:

Lambda + hive wiring. The hive layout is fully functional on the local backend, but the Lambda backend currently rejects store_layout: hive with a pointed error: the per-leaf template must be emitted inside the worker (the orchestrator writes only the manifest at template time), which needs an event-schema addition and a worker-side write path. Options:

I lean (b): the store-root conventions this PR freezes are exactly what #200 (coverage MOC) needs, and the worker event-schema change deserves its own review. Related: you asked on #199 to mark #200 unblocked "after all implementation phases here are finished" — say whether that means after this PR (conventions frozen) or after Lambda wiring lands, and I'll act accordingly next run.

Also left standing for you (details in the review threads): the upstream mortie ask (public numpy-only decimal_to_word export), whether morton_hive.json should carry more dataset-identity fields than short_name/version, the viz GeoJSON shard_key packed-int property, and the pre-existing runner.py size discussion (~2.4k lines before this PR; net +112 after the hive helpers moved to src/zagg/hive.py).


Generated by Claude Code

@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Scope settled (recording the in-session discussion so this thread is self-contained):

Removing waiting while phase 3 is in flight.


Generated by Claude Code

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

espg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Phase 3 — Lambda hive wiring — pushed: d193b79, per the scope update. PR body updated (all three phases checked). Local: 1619 passed, 28 skipped; ruff/format clean on touched files; zero new mypy errors vs main.

What the handler now does, per mode

  • mode: "process", hive config (deployment/aws/lambda_handler.py): the worker owns its whole leaf. It derives the leaf path from shard_key + the event config's orders (mortie hive_path via zagg.hive, which ships in the function bundle as part of the package), emits its own leaf template lazily on the first chunk write, writes dense chunks at leaf-local blocks + label-named CSR, and stamps completion as its final PUT — error-free shards only. This is not a parallel implementation: the handler calls zagg.hive.process_and_write_hive, the exact function the local dispatcher runs, so leaf semantics cannot drift between backends. Retry overwrite matches local: a torn worker leaves an unstamped prefix; the retry's leaf re-template delete_dirs it wholesale. The flat write machinery is byte-for-byte the old code, wrapped in the else branch — the existing flat handler tests pin it.
  • mode: "setup", hive config: writes morton_hive.json only, via hive.ensure_manifest — no global zarr template (zero metadata above the leaves, D5) — with the round-2 frozen-key semantics: matching reruns resume, an orders change gets the "clear the store root" refusal. Flat setup is untouched.
  • Dispatch (src/zagg/runner.py): the lambda-backend hive rejection is gone. Per-shard events carry no new keysstore_layout already rides inside the serialized config dict. The one event-schema addition (called out in PR-body question (1)): setup gains an optional "dataset" key ({"short_name", "version"} from the ShardMap metadata — the same source the local path uses) so the manifest identity block is identical across backends; dataset is a frozen manifest key, so a mixed local/lambda rerun into one root must not mismatch. Flat setup events omit the key and stay byte-identical (pinned). Async status objects stay at the flat sibling prefix {store_root}.status/<run_id>/<label>.json — outside the digit tree, per O6 — verified unchanged.

Test coverage

tests/test_lambda_handler.py TestProcessHive + TestSetupHive (6 tests) and tests/test_hive.py TestRunnerWiring (2 new): leaf at the exact mortie hive_path with real data through the real write path + stamp counters; worker death mid-shard → 500 + unstamped debris with a stale CSR object → retry wipes it wholesale and stamps (the round-2 stale-object pin, now on the handler path); no-data shard leaves no prefix; setup-hive writes the manifest and nothing else; frozen-key mismatch → 500 with the clear-the-root remedy; matching re-setup resumes; dispatcher passes the dataset identity and unchanged packed-int shard_key events; flat setup omits dataset. Local/lambda output equivalence holds by construction — both backends execute the same process_and_write_hive.

Deliberately excluded

Per-shard MOC / coverage.moc — the design is being settled on #200, so no MOC machinery and no stamp fields reserved for it; mode: "finalize" for hive (config validation already rejects consolidate_metadata with hive); any deployment script, template.yaml, or workflow change (none needed — zagg/hive.py is package code and rides the existing function bundle).


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Round-3 adversarial review of d193b79 only (phase 3 — Lambda hive wiring), fresh context, against the D2–D6 contract and the scope-settling comment. MOC work explicitly out of scope, not reviewed for absence. No blocking-correctness findings; 2 should-fix, 3 nits, 1 question-for-author (inline).

CI on head d193b79: all green — ruff, test (3.12), test (3.13, concluded 18:32 UTC), docs build, lambda build x86_64 + arm64 (deploy/pr-preview skipped as usual).

Findings

  • should-fix (inline, _handle_setup): a not-yet-redeployed function turns a hive run into a silent flat store — old setup returns the byte-identical 200 body, old workers pass store_layout: hive through unvalidated (no output-key whitelist pre-#199) and write flat data; suggest a one-line layout echo in the setup response + dispatcher assert, or at least a deploy-ordering sentence in the PR body. The dataset key itself is forward/backward safe.
  • should-fix (inline, test_hive.py): the flat-setup byte-identity pin stops at the kwarg level — both dispatch tests monkeypatch _invoke_lambda_setup, so the if dataset is not None event-dict conditional is unexercised.
  • question-for-author (inline, handler hive branch): PR question (2) assessed — the 500-envelope asymmetry is body-shape only; no partial-success stranding (wholesale wipe pinned on the handler path), same async mirror channel; the only loss is failed-shard duration/memory in the rollups. Recommend accepting as-is.
  • nit (inline): stamp-last op ordering is structural but unpinned as a test on either backend.
  • nit (inline): TestRunnerWiring class docstring still claims the lambda backend rejects hive.
  • nit (here): deployment/aws/lambda_handler.py is now 1,120 lines (1,054 on main — already past the ~1000-line convention before this PR; +66 here, mostly comments/indentation from the else-wrap). Same pre-existing-size discussion as runner.py from round 2; flagging for the record, not for this PR.

Verified sound (traced, not just read)

  • Shared code path is genuine: _handle_process's hive branch and the local _cell_work both call zagg.hive.process_and_write_hive literally (handler L920, runner L1232) — stamp-after-stream, error-free-only, overwrite=True wholesale leaf re-template. A stamped-shard-with-500 is reachable only via a post-stamp handler exception and converges on retry; a 200-without-stamp only on the deliberate zero-chunk no-prefix path.
  • Frozen dataset identity cannot diverge between backends: local passes the whole ShardMap metadata dict, the dispatcher passes the two-key projection, and build_manifest normalizes both through the same dataset.get(...) — missing metadata yields {"short_name": null, "version": null} on both paths, and the JSON event round-trip is type-preserving, so a mixed local/lambda rerun cannot brick the frozen-key resume check.
  • Flat path byte-identity holds: the else-branch is the old code verbatim (indentation + chunk_results init hoist + one line-wrap); store_box/write_error/profile-fold/telemetry stamping unchanged for flat.
  • Dispatch sweep clean: finalize is gated on consolidate_metadata, which hive config validation rejects, so no hive finalize invoke; a setup 500 (frozen-key mismatch) raises in _invoke_lambda_setup and aborts before fan-out; status objects stay at the flat sibling .status/ prefix; cost/summary rollups use .get() throughout; --morton-cell selection is layout-independent. shard_spec never touches populated_shards, so dense-layout configs can't produce different leaves per backend.
  • Leaf-path equality vs mortie's hive_path is pinned on the handler path; handler tests enter via _handle_process/_handle_setup, matching the file's existing convention (24 prior _handle_process call sites).
  • Conventions: 6 files touched, no deploy scripts/workflows/template.yaml, no new deps; hive.py at 352 lines; docs/hive_layout.md status section accurate for phase 3.

Generated by Claude Code

Comment thread deployment/aws/lambda_handler.py Outdated
Comment on lines +615 to +625
if get_store_layout(config) == "hive":
from zagg.hive import build_manifest, ensure_manifest

grid = from_config(config, parent_order=event.get("parent_order"))
ensure_manifest(
event["store_path"],
build_manifest(grid, dataset=event.get("dataset")),
overwrite=event.get("overwrite", False),
**_output_store_kwargs(event),
)
return {"statusCode": 200, "body": json.dumps({"ok": True, "mode": "setup"})}

Copy link
Copy Markdown
Member 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 — a stale deployment turns a hive run into a silent flat store. The dataset event key itself is forward/backward safe (an old handler reads only the keys it knows). The hazard is the whole hive mode against a not-yet-redeployed function: the old _handle_setup has no hive branch, so it emits the flat global template at the hive store root and returns literally the same body this branch does ({"ok": true, "mode": "setup"}, 200) — the dispatcher cannot tell the difference. Old workers then pass output.store_layout: hive straight through load_config_from_dict (the pre-#199 validator has no output-key whitelist — checked main's config.py, only bounds keys are whitelisted) and write flat global-block data. Net: the run "succeeds", no manifest, no leaves, no error anywhere, and the user who asked for hive gets a flat store with a template object squatting where the digit tree should be.

Cheap loud guard: have this branch return a distinguishing body (e.g. add "layout": "hive") and have _invoke_lambda_setup assert it when it sent a hive config — one line each side, and a stale deployment fails at setup instead of after a full fan-out. At minimum, one sentence in the PR body noting the function must be redeployed before the first hive run (deploy is espg-side, so the ordering is out of the code's hands).


Generated by Claude Code

Comment on lines +907 to +931
if get_store_layout(config) == "hive":
# Hive layout (issue #199 phase 3): the worker owns its WHOLE leaf
# zarr — it derives the leaf path from shard_key + the event's
# config orders, emits its own leaf template (lazily, on the first
# chunk), writes its data, and stamps completion as its FINAL PUT
# (D4), on error-free shards only. process_and_write_hive is the
# same code path the local dispatcher runs, so leaf semantics
# cannot drift between backends. A write failure raises out to the
# handler's exception envelope: the leaf is then unstamped debris,
# overwritten wholesale on retry — the same recovery model as the
# local path (no per-chunk error recording needed).
from zagg.hive import process_and_write_hive

metadata = process_and_write_hive(
shard_key,
event["granule_urls"],
grid,
s3_creds,
store_path,
config,
store_kwargs=_output_store_kwargs(event),
handoff=handoff,
aoi_payload=aoi_payload,
profile=profile,
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

question-for-author — answering PR-body question (2), the hive write-failure envelope. I traced the asymmetry end to end and it is body-shape only; nothing strands:

  • No partial-success stranding. A multi-field shard that dies after some CSR/dense PUTs leaves an unstamped prefix; the retry's emit_shard_template(overwrite=True) wipes the leaf wholesale before rewriting — test_hive_worker_death_leaves_debris_then_retry_cleans pins the stale subgroup gone on this exact handler path. A post-stamp handler exception (stamp landed, then e.g. the structured-log json.dumps throws) yields the one stamped-but-500 case, and the retry converges the same way (_leaf()'s comment covers committed-write redo). The reverse — 200 without a stamp — happens only on the deliberate zero-chunk path (no prefix at all), where absence is the answer (D4).
  • Same status channel both layouts. _handle_process never raises out (the except at the bottom folds everything into the 500 envelope), so _write_result mirrors a hive failure to result_url exactly as a flat one; async accounting is identical.
  • What actually differs: a flat write failure returns the full worker metadata (+error), so the summary rollups keep duration_s/max_memory_mb/total_obs for the failed shard; a hive write failure returns the bare {"error", "shard_key", "request_id"} envelope — body.get("duration_s", 0) reads 0, memory absent. That's the whole price, and it matches the local backend's _cell_work except-path (which also records only the exception).

I'd accept as-is: one shared code path is worth more than failed-shard telemetry. If you want the recorded-error body instead, the fold belongs in process_and_write_hive (catch around process_shard, fold into metadata), not here — a handler-side catch would diverge from local semantics again.


Generated by Claude Code

Comment thread tests/test_hive.py
Comment on lines +559 to +562
)
monkeypatch.setattr(
runner, "_invoke_lambda_setup", lambda *a, **kw: captured.update(setup=kw)
)

Copy link
Copy Markdown
Member 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 (test gap) — the "flat setup events stay byte-identical" pin stops one hop short of the wire. Both dispatch tests monkeypatch runner._invoke_lambda_setup and assert on the captured kwargs (dataset is None / the dict), so the conditional that actually shapes the event — if dataset is not None: event["dataset"] = dataset in _invoke_lambda_setup — is never exercised. A regression that unconditionally adds "dataset": None to the event would pass this suite while breaking the byte-identity claim in the PR body. One direct test of _invoke_lambda_setup with a mocked lambda_client capturing Payload (the pattern _invoke_lambda_cell tests already use) asserting "dataset" not in json.loads(payload) for flat and == {...} for hive would pin the real event dict.

nit — the TestRunnerWiring class docstring (line 479, just above this hunk) still reads "the lambda backend rejects hive with a pointed error" — stale as of this commit; it now asserts the opposite.


Generated by Claude Code

Comment on lines +931 to +934
stamp = hive.read_commit(leaf_store)
assert stamp["complete"] is True
assert stamp["cells_with_data"] == 5
assert stamp["granule_count"] == 1

Copy link
Copy Markdown
Member 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 — stamp-last is structural but unpinned as an op ordering. The leaf-path-equals-mortie-hive_path pin above is exactly right, and the debris tests pin "no stamp on failure" both ways. What no test on either backend pins is the D4 ordering claim itself — that the stamp PUT is the shard's final write. Today that's guaranteed structurally (stamp_commit runs after the synchronous process_shard stream returns in process_and_write_hive), but a future refactor that, say, stamps before a trailing CSR flush would pass every current test while breaking "presence certifies everything before it landed." Since both backends share the one function, a single ordering pin in test_hive.py covers Lambda too: wrap write_ragged_to_zarr/stamp_commit with recording shims and assert the stamp is the last recorded op. Cheap, and it turns the design doc's central invariant into a regression test.


Generated by Claude Code

@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Round-3 fold pushed: 4ec4e3a. Local: 1625 passed, 28 skipped; ruff/format clean on touched files; zero new mypy errors. PR body updated (deploy-ordering note added to the phase-3 section).

Round-3 findings — disposition

  • Stale-deployment hazardfolded, both halves of the suggested guard. _handle_setup now echoes the layout it acted on ("layout": "hive"|"flat" in the success body), and _invoke_lambda_setup requires the hive echo when the dispatched config is hive — a pre-phase-3 function returns the echo-less body and the run fails at setup with "the deployed function predates issue Adopt morton decimal shard ids + hive-partitioned output layout (manifest, commit stamp) #199 phase 3 … redeploy the function before dispatching hive runs", instead of after a full fan-out has written a flat store at the hive root. Flat dispatch never requires the echo, so a new dispatcher keeps working against old deployed functions for flat runs. All three directions pinned (TestInvokeLambdaSetupEvent: hive+echo proceeds, hive+missing/flat echo → pointed error [parametrized], flat+no-echo unaffected). Deploy-ordering sentence added to the PR body.
  • Event-dict test gapfolded, at the wire as suggested: TestInvokeLambdaSetupEvent calls _invoke_lambda_setup directly with a mocked boto3 client capturing Payload. Hive: event["dataset"] present with exact contents. Flat: "dataset" not in event and the full event dict equals the pre-phase-3 baseline key-for-key — the regression you sketched (an unconditional "dataset": None) now fails the suite. The stale TestRunnerWiring docstring ("rejects hive with a pointed error") is fixed in the same commit.
  • Stamp-last ordering pinfolded: test_stamp_is_the_final_write wraps write_dataframe_to_zarr / write_ragged_to_zarr / stamp_commit with recording shims and asserts ops == ["dense", "ragged", "stamp"]. One test, both backends — its docstring says exactly why: the local dispatcher and the Lambda handler execute the same process_and_write_hive, so the D4 op ordering cannot diverge between them.
  • Envelope asymmetry (PR-body question (2)) — accepted as-is, no code change, per the review's end-to-end trace: no partial-success stranding (the wholesale-wipe retry is pinned on the handler path), identical async status mirroring, and the only difference is failed-shard duration_s/max_memory_mb rollups reading empty. Recording the review's pointer for the record: if the recorded-error body is wanted later, the fold belongs in process_and_write_hive (catch around process_shard, fold into metadata) — a handler-side catch would diverge from local semantics again. PR-body question (2) updated accordingly; final call stays with the reviewer.
  • lambda_handler.py sizeleft standing, noted: 1,120 lines after this fold vs 1,054 pre-existing on main (the repo's ~1000-line threshold was already crossed before this PR; this PR's net is +66, most of it the hive setup/process branches). Like runner.py (2.4k on main), the module-size conversation belongs on the issue thread — leaving that to the human reviewer rather than opening one from here.

CI is triggered on 4ec4e3a (same jobs as the prior five heads, all of which went green). PR stays in draft for the next review round.


Generated by Claude Code

@espg espg marked this pull request as ready for review July 10, 2026 18:57
@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Merged main into this branch post-#197/#204 (cba9221, a merge commit — no rebase, no force-push) so the PR is mergeable again. The src/zagg/runner.py conflict was two adjacent insertions at the same spot — this PR's _safe_label and #204's _lambda_dispatch_order — resolved by keeping both; _select_cells itself composed cleanly with no edits needed: this PR's decimal --morton-cell parse + legacy-catalog hint in the single-shard early return, then #204's truncate-then-shuffle tail:

        return matches
    if max_cells:
        pairs = pairs[:max_cells]
    # Shuffle after selection/truncation (max_cells keeps its morton-first-N
    # subset) so concurrent fan-out doesn't write morton-contiguous -- i.e.
    # byte-prefix-sharing -- S3 keys to one partition (issue #197). Seeded from
    # the selected shard keys, so a rerun or resume sees the same order.
    random.Random(",".join(str(k) for k, _ in pairs)).shuffle(pairs)
    return pairs

The shuffle seed string is unchanged by the decimal work (pairs keys stay packed ints), and #204's bucketed lambda sort sits cleanly next to this PR's dataset threading in _run_lambda. Full composed suite: 1630 passed, 28 skipped (both PRs' tests together); ruff/format clean.


Generated by Claude Code

@espg espg merged commit 174c228 into main Jul 10, 2026
8 checks passed
@espg espg deleted the claude/199-decimal-hive-layout branch July 10, 2026 19:10
@espg

espg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Mergeable again. PR #204's merge to main conflicted with this branch in src/zagg/runner.py — both PRs added a function at the same location (_lambda_dispatch_order from issue #197, _safe_label from issue #199), and both touched _select_cells. Resolved by merging origin/main into the branch (merge commit, no rebase/force-push): _select_cells auto-merged cleanly with both behaviors composed — decimal --morton-cell parse + legacy-catalog hint in the single-shard branch, truncate-then-shuffle at the tail (seed string unchanged: in-memory keys stay packed ints) — and the two added functions now sit side by side. Note the branch history carries two resolution commits (cba9221 + e2fe76d): two concurrent resolutions of the same conflict differing only in function order; content-wise the head is the reviewed resolution.

Verified on the pushed head (e2fe76d): full suite 1630 passed / 28 skipped — this is the first run with both PRs' test suites composed (issue #197's shuffle/bucket-order tests + this PR's hive/decimal/handler tests); ruff check / ruff format --check clean on the merged file; git merge-tree clean against origin/main.

Merge-readiness summary (from the review discussion): all three phases done, three adversarial-review rounds folded, and no open question gates the merge — Q1 (dataset setup key) and Q2 (hive 500 envelope) were both traced sound by round 3 and merging as-is accepts them; the deploy-ordering item is post-merge operational (redeploy the function/layer before the first hive dispatch — the setup layout echo fails fast if forgotten). Remaining gate: CI green on e2fe76d.


Generated by Claude Code

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.

Adopt morton decimal shard ids + hive-partitioned output layout (manifest, commit stamp)

2 participants