moczarr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
moczarr/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """moczarr: sparse-DGGS xarray reader for morton-hive zarr stores."""
2
+
3
+ from moczarr.convention import (
4
+ COMMIT_ATTR,
5
+ COVERAGE_SIDECAR,
6
+ HIVE_SPEC,
7
+ HIVE_SPEC_V2,
8
+ MANIFEST_NAME,
9
+ MORTON_CONVENTION_ENTRY,
10
+ MORTON_CONVENTION_UUID,
11
+ ROOT_COVERAGE_NAME,
12
+ check_node_invariant,
13
+ is_point_word,
14
+ leaf_path,
15
+ morton_decimal,
16
+ morton_word,
17
+ parse_manifest,
18
+ split_leaf_name,
19
+ )
20
+ from moczarr.coverage import (
21
+ COVERAGE_SPEC,
22
+ aoi_mask,
23
+ box_and,
24
+ box_words,
25
+ decode_bitmap,
26
+ parse_leaf_coverage,
27
+ parse_root_coverage,
28
+ ranges_contain,
29
+ ranges_words,
30
+ root_coverage_and,
31
+ )
32
+ from moczarr.exceptions import NoCoverageError
33
+ from moczarr.fabricate import FLOAT64_EXACT_MAX_ORDER, fabricate_cell_ids
34
+ from moczarr.join import join_coarse, parent_cells
35
+ from moczarr.open import open_hive
36
+ from moczarr.ranges import MortonRanges
37
+
38
+ # moczarr.moc_index (MortonMocIndex) is imported by module path, not here:
39
+ # the package root stays xarray-import-free (the repo's lazy-import posture),
40
+ # and the index reaches most users through open_hive(index_kind="moc").
41
+ from moczarr.store import (
42
+ bitmap_and,
43
+ load_root_coverage,
44
+ open_object_store,
45
+ read_commit,
46
+ read_coverage_bitmap,
47
+ read_leaf_coverage,
48
+ read_manifest,
49
+ walk_leaves,
50
+ warn_if_stale,
51
+ )
52
+
53
+ try:
54
+ from moczarr._version import __version__
55
+ except ImportError: # pragma: no cover - version file is generated at build time
56
+ __version__ = "0.0.0+unknown"
57
+
58
+ __all__ = [
59
+ "COMMIT_ATTR",
60
+ "COVERAGE_SIDECAR",
61
+ "COVERAGE_SPEC",
62
+ "FLOAT64_EXACT_MAX_ORDER",
63
+ "HIVE_SPEC",
64
+ "HIVE_SPEC_V2",
65
+ "MANIFEST_NAME",
66
+ "MORTON_CONVENTION_ENTRY",
67
+ "MORTON_CONVENTION_UUID",
68
+ "MortonRanges",
69
+ "NoCoverageError",
70
+ "ROOT_COVERAGE_NAME",
71
+ "__version__",
72
+ "aoi_mask",
73
+ "bitmap_and",
74
+ "box_and",
75
+ "box_words",
76
+ "check_node_invariant",
77
+ "decode_bitmap",
78
+ "fabricate_cell_ids",
79
+ "is_point_word",
80
+ "join_coarse",
81
+ "leaf_path",
82
+ "load_root_coverage",
83
+ "morton_decimal",
84
+ "morton_word",
85
+ "open_hive",
86
+ "open_object_store",
87
+ "parent_cells",
88
+ "parse_leaf_coverage",
89
+ "parse_manifest",
90
+ "parse_root_coverage",
91
+ "ranges_contain",
92
+ "ranges_words",
93
+ "read_commit",
94
+ "read_coverage_bitmap",
95
+ "read_leaf_coverage",
96
+ "read_manifest",
97
+ "root_coverage_and",
98
+ "split_leaf_name",
99
+ "walk_leaves",
100
+ "warn_if_stale",
101
+ ]
moczarr/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
moczarr/convention.py ADDED
@@ -0,0 +1,385 @@
1
+ """The morton-hive layout convention, read side (pure functions, no I/O).
2
+
3
+ The layout is owned by the mortie spec (espg/mortie#62); zagg writes it
4
+ (``zagg.hive``); moczarr reads it. Summary::
5
+
6
+ {store_root}/
7
+ morton_hive.json <- static manifest; root-only exception
8
+ coverage.moc <- root ranges MOC; root-only exception
9
+ {sign+base}/{d1}/.../{d_n}/ <- digit component(s) per level
10
+ {full_id}.zarr/ <- vanilla zarr v3 leaf
11
+ {full_id}_{window}.zarr/ <- time-windowed leaf (morton-hive/2)
12
+
13
+ - Ids are morton decimal strings: sign + base digit (``1..6``), then one
14
+ digit ``1..4`` per order. A string prefix is a spatial ancestor.
15
+ - Path components chunk the digit tail per the manifest's ``path_grouping``
16
+ (spec §6.1; zagg D21): ``path_grouping`` digits per component, the LAST
17
+ component carrying the remainder when the order does not divide evenly
18
+ (leading components stay full-width, so component boundaries are ancestor
19
+ prefixes shared by deeper shards regardless of their order). ``1`` — the
20
+ default, and every existing store retroactively — is a value of the one
21
+ generic chunking, never a separate code path; readers chunk per the
22
+ manifest, never by assumption.
23
+ - Below the root a node holds only digit children and ``*.zarr`` objects
24
+ (the node invariant); the manifest and root ``coverage.moc`` are the two
25
+ root-only exceptions.
26
+ - A leaf is complete iff its root zarr attrs carry the commit stamp
27
+ (``morton_hive_commit``); an unstamped ``.zarr/`` prefix is debris.
28
+ - Windowed leaf names split on the FIRST ``_`` (morton ids and window labels
29
+ never contain one); labels use the frozen charset ``[0-9A-Za-z-]{1,32}``.
30
+
31
+ Everything here is arithmetic on ids and dict validation — the store layer
32
+ (phase 2) supplies the bytes. Golden vectors in ``tests/`` pin this
33
+ implementation against zagg's writer so the two cannot drift silently.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+
40
+ import numpy as np
41
+
42
+ #: Manifest convention versions (``spec`` field). A ``/1`` store is a ``/2``
43
+ #: store with ``schedule: none``; ``/2`` adds the temporal block.
44
+ HIVE_SPEC = "morton-hive/1"
45
+ HIVE_SPEC_V2 = "morton-hive/2"
46
+ #: Root manifest object name.
47
+ MANIFEST_NAME = "morton_hive.json"
48
+ #: Root-group attrs key carrying the commit stamp.
49
+ COMMIT_ATTR = "morton_hive_commit"
50
+ #: In-leaf occupancy-bitmap sidecar object name; same name at the store root
51
+ #: holds the shard-order ranges MOC (different location, different encoding).
52
+ COVERAGE_SIDECAR = "coverage.moc"
53
+ ROOT_COVERAGE_NAME = "coverage.moc"
54
+
55
+ #: Spec §5: the permanent, self-declared zarr-convention UUID for
56
+ #: morton-declared stores (minted once; readers may key on it).
57
+ MORTON_CONVENTION_UUID = "3e22156d-ea9e-4e01-95fe-e3809a4b41e7"
58
+ #: Spec §5: the self-declared ``zarr_conventions`` entry, verbatim. The list
59
+ #: may carry other entries alongside (e.g. the generic dggs registry one).
60
+ MORTON_CONVENTION_ENTRY = {
61
+ "schema_url": "https://github.com/espg/mortie/blob/main/docs/specification.md#dggs-attrs",
62
+ "spec_url": "https://github.com/espg/mortie/blob/main/docs/specification.md",
63
+ "uuid": MORTON_CONVENTION_UUID,
64
+ "name": "morton-dggs",
65
+ "description": "Packed-u64 morton (HEALPix) DGGS convention",
66
+ }
67
+
68
+ #: Frozen window-label charset (no ``_``, so leaf names split unambiguously).
69
+ _LABEL_RE = re.compile(r"^[0-9A-Za-z-]{1,32}$")
70
+
71
+ #: Spec §1 suffix bands: ``0..=27`` area (order == suffix), ``28..=47``
72
+ #: order-28/29 area preorder, ``48..=63`` order-29 POINT (no area claim).
73
+ _POINT_SUFFIX_MIN = 48
74
+ _SUFFIX_MASK = np.uint64(0x3F)
75
+
76
+
77
+ def is_point_word(word) -> bool | np.ndarray:
78
+ """Whether packed word(s) encode an order-29 POINT (spec §1/§4).
79
+
80
+ Kind is carried by the word's 6-bit suffix — ``48..=63`` is the point
81
+ band; everything below (``0..=47``) is an AREA element, exact at its
82
+ encoded order — never by store or array metadata (§4). Suffix-mask
83
+ implementation, golden-tested against the spec §1 table; swaps to
84
+ mortie's public kind predicate once released (espg/mortie#116 —
85
+ mortie 0.9.0 has none). Scalar in -> bool; array in -> bool array.
86
+ """
87
+ words = np.asarray(word, dtype=np.uint64)
88
+ mask = (words & _SUFFIX_MASK) >= np.uint64(_POINT_SUFFIX_MIN)
89
+ return bool(mask) if words.ndim == 0 else mask
90
+
91
+
92
+ def point_to_area29(word):
93
+ """Order-29 AREA twin(s) of point word(s); area words pass through.
94
+
95
+ Spec §1 suffix arithmetic on the same body: point ``48 + t28*4 + t29``
96
+ -> area ``28 + t28*5 + (t29 + 1)``. The twin shares the point's full
97
+ path, so containment arithmetic (§4: membership of a point at a coarser
98
+ level is ordinary truncation) runs uniformly in area space — the
99
+ normalization :func:`moczarr.coverage.aoi_mask` applies. Scalar in ->
100
+ int; array in -> ``uint64`` array.
101
+ """
102
+ words = np.asarray(word, dtype=np.uint64)
103
+ # Suffix arithmetic in int64 (values <= 63): negative intermediates for
104
+ # area words are discarded by the where, without uint64 wrap warnings.
105
+ suffix = (words & _SUFFIX_MASK).astype(np.int64)
106
+ r2 = suffix - _POINT_SUFFIX_MIN
107
+ area_suffix = (29 + (r2 >> 2) * 5 + (r2 & 3)).astype(np.uint64)
108
+ twins = np.where(suffix >= _POINT_SUFFIX_MIN, (words & ~_SUFFIX_MASK) | area_suffix, words)
109
+ return int(twins) if twins.ndim == 0 else twins.astype(np.uint64)
110
+
111
+
112
+ def area29_to_point(word):
113
+ """Max-encoded POINT twin(s) of order-29 AREA word(s) — the ``p`` parse.
114
+
115
+ Inverse of :func:`point_to_area29`: area ``28 + t28*5 + (t29 + 1)`` ->
116
+ point ``48 + t28*4 + t29`` (spec §1). Raises on any word that is not an
117
+ order-29 area encoding — points exist only at order 29 (§2/§4).
118
+ """
119
+ words = np.asarray(word, dtype=np.uint64)
120
+ suffix = (words & _SUFFIX_MASK).astype(np.int64) # int64: no wrap on invalid input
121
+ r = suffix - 28
122
+ # Order-29 area suffixes are 28 + t28*5 + (t29+1), t29 in 0..3 -> r % 5 != 0.
123
+ ok = (suffix > 27) & (suffix < _POINT_SUFFIX_MIN) & (r % 5 != 0)
124
+ if not np.all(ok):
125
+ raise ValueError(
126
+ "not an order-29 area word: the point twin exists only at order 29 (spec §1/§4)"
127
+ )
128
+ point_suffix = (_POINT_SUFFIX_MIN + (r // 5) * 4 + (r % 5 - 1)).astype(np.uint64)
129
+ points = (words & ~_SUFFIX_MASK) | point_suffix
130
+ return int(points) if points.ndim == 0 else points.astype(np.uint64)
131
+
132
+
133
+ def morton_word(label: str | int) -> int:
134
+ """Packed ``uint64`` morton word of a decimal id (pass-through for ints).
135
+
136
+ Accepts the §2 ``p`` kind-suffix on full order-29 POINT ids: the stem
137
+ parses as the area word and the §1 point suffix is restored moczarr-side
138
+ (:func:`area29_to_point`) — mortie 0.9.0 predates the ``p`` grammar
139
+ (espg/mortie#120/#121). An UNMARKED order-29 string parses as the AREA
140
+ word, the normative §4 tie-break. Rides mortie's private-but-documented
141
+ ``_decimal_to_word`` (numpy-only; the public array classes require
142
+ pandas — upstream ask for a public export stands, same note as zagg's
143
+ boundary helper).
144
+ """
145
+ if isinstance(label, (int, np.integer)):
146
+ return int(label)
147
+ from mortie.morton_index import _decimal_to_word
148
+
149
+ text = str(label)
150
+ if text.endswith("p"):
151
+ stem = text[:-1]
152
+ if decimal_order(stem) != 29:
153
+ raise ValueError(
154
+ f"{label!r}: the 'p' kind-suffix is legal only on a full order-29 "
155
+ f"POINT id (points exist only at order 29; spec §2)"
156
+ )
157
+ return int(area29_to_point(int(_decimal_to_word(stem))))
158
+ return int(_decimal_to_word(text))
159
+
160
+
161
+ def morton_decimal(word: str | int) -> str:
162
+ """Decimal morton string of a packed word (pass-through for strings).
163
+
164
+ POINT words (§1 suffix ``48..=63``) render with the terminal ``p`` kind
165
+ marker — the §2 render/interchange form — via the order-29 area twin's
166
+ digits (same path; kind restored by the marker, so the round-trip is
167
+ lossless for BOTH kinds, pinned by goldens).
168
+ """
169
+ if isinstance(word, str):
170
+ return word
171
+ from mortie import MortonIndexArray
172
+
173
+ value, marker = int(word), ""
174
+ if is_point_word(value):
175
+ value, marker = point_to_area29(value), "p"
176
+ rendered = MortonIndexArray.from_words(np.asarray([value], dtype=np.uint64)).decimal_repr()[0]
177
+ return rendered + marker
178
+
179
+
180
+ def decimal_order(decimal: str) -> int:
181
+ """HEALPix order of a decimal id (one digit per level past the base)."""
182
+ return len(decimal) - (2 if decimal.startswith("-") else 1)
183
+
184
+
185
+ def decimal_base(decimal: str) -> str:
186
+ """The ``{sign+base}`` component of a decimal id."""
187
+ return decimal[:2] if decimal.startswith("-") else decimal[:1]
188
+
189
+
190
+ def decimal_rank(decimal: str) -> int:
191
+ """Base-4 value of a decimal id's digit tail (digits ``1..4`` -> ``0..3``).
192
+
193
+ The bit/rank convention of the coverage encodings: ascending packed-word
194
+ (Z-)order within one base cell at a fixed order.
195
+ """
196
+ rank = 0
197
+ for ch in decimal[len(decimal_base(decimal)) :]:
198
+ rank = rank * 4 + (int(ch) - 1)
199
+ return rank
200
+
201
+
202
+ def rank_tail(rank: int, depth: int) -> str:
203
+ """Inverse of :func:`decimal_rank`: the width-``depth`` digit tail."""
204
+ digits = []
205
+ for _ in range(depth):
206
+ digits.append(str(rank % 4 + 1))
207
+ rank //= 4
208
+ return "".join(reversed(digits))
209
+
210
+
211
+ def is_base_component(name: str) -> bool:
212
+ """Whether ``name`` is a ``{sign+base}``-shaped hive root child."""
213
+ base = name[1:] if name.startswith("-") else name
214
+ return len(base) == 1 and base in "123456"
215
+
216
+
217
+ def group_digits(digits: str, path_grouping: int) -> list[str]:
218
+ """Chunk a digit tail into hive path components (spec §6.1, zagg D21).
219
+
220
+ Left to right, ``path_grouping`` digits per component; the LAST component
221
+ carries the remainder when ``len(digits) % path_grouping != 0``. Leading
222
+ components stay full-width so every component boundary is a spatial
223
+ ancestor prefix (§2) shared by deeper shards regardless of their order —
224
+ mixed-order shards share ancestor nodes, the property the digit tree
225
+ exists for.
226
+ """
227
+ return [digits[i : i + path_grouping] for i in range(0, len(digits), path_grouping)]
228
+
229
+
230
+ def manifest_path_grouping(manifest: dict) -> int:
231
+ """The manifest's ``path_grouping`` (D21: absent reads as ``1``).
232
+
233
+ Assumes a :func:`parse_manifest`-validated manifest; the single accessor
234
+ keeps the absent->1 normalization in one place.
235
+ """
236
+ return int(manifest.get("path_grouping", 1))
237
+
238
+
239
+ def validate_label(label: str) -> str:
240
+ """Validate a window label against the frozen charset; returns it."""
241
+ if not isinstance(label, str) or not _LABEL_RE.match(label):
242
+ raise ValueError(
243
+ f"window label {label!r} does not match the frozen grammar "
244
+ f"({_LABEL_RE.pattern}; morton-hive/2, mortie#62)"
245
+ )
246
+ return label
247
+
248
+
249
+ def leaf_name(full_id: str, window: str | None = None) -> str:
250
+ """The leaf zarr basename: ``{full_id}_{window}.zarr``, or bare."""
251
+ if window is None:
252
+ return f"{full_id}.zarr"
253
+ validate_label(window)
254
+ return f"{full_id}_{window}.zarr"
255
+
256
+
257
+ def split_leaf_name(name: str) -> tuple[str, str | None]:
258
+ """``(full_id, window-or-None)`` from a leaf basename — split on the FIRST ``_``.
259
+
260
+ Morton decimal ids never contain ``_`` and window labels cannot
261
+ (charset), so the first underscore is the one separator. Raises on a
262
+ non-``.zarr`` name or a malformed window label.
263
+ """
264
+ if not name.endswith(".zarr"):
265
+ raise ValueError(f"{name!r} is not a leaf zarr name")
266
+ stem = name.removesuffix(".zarr")
267
+ if "_" not in stem:
268
+ return stem, None
269
+ full_id, window = stem.split("_", 1)
270
+ validate_label(window)
271
+ return full_id, window
272
+
273
+
274
+ def leaf_path(shard: str | int, window: str | None = None, *, path_grouping: int = 1) -> str:
275
+ """Store-relative hive path of a shard's leaf zarr.
276
+
277
+ ``path_grouping`` is the manifest's digit-chunking (spec §6.1; default
278
+ ``1`` — every pre-D21 store). At ``1`` the path is computed by mortie's
279
+ ``hive_path`` (the convention owner) and re-checked against the node
280
+ invariant so drift on either side fails loudly; the grouped form chunks
281
+ the decimal here (:func:`group_digits`) until mortie grows the grouped
282
+ ``hive_path`` (espg/mortie#62). ``window`` selects the time-windowed
283
+ leaf at the same node.
284
+ """
285
+ from mortie import MortonIndexArray
286
+
287
+ word = morton_word(shard)
288
+ if word < 0:
289
+ raise ValueError(
290
+ f"shard {shard!r} is a negative int, not a packed morton word; a "
291
+ f"packed morton word is required. Parse a decimal id by passing it "
292
+ f"as a string (e.g. morton_word('-5112333')) instead."
293
+ )
294
+ if is_point_word(word):
295
+ raise ValueError(
296
+ f"shard {shard!r} is an order-29 POINT word: points never live in "
297
+ f"hive paths (spec §2/§6.6)"
298
+ )
299
+ if path_grouping == 1:
300
+ rel = MortonIndexArray.from_words(np.asarray([word], dtype=np.uint64)).hive_path()[0]
301
+ if window is not None:
302
+ node, _sep, bare = rel.rpartition("/")
303
+ rel = f"{node}/{leaf_name(bare.removesuffix('.zarr'), window)}"
304
+ else:
305
+ decimal = morton_decimal(word)
306
+ base = decimal_base(decimal)
307
+ components = [base, *group_digits(decimal[len(base) :], path_grouping)]
308
+ rel = f"{'/'.join(components)}/{leaf_name(decimal, window)}"
309
+ check_node_invariant(rel, path_grouping=path_grouping)
310
+ return rel
311
+
312
+
313
+ def check_node_invariant(rel_path: str, *, path_grouping: int = 1) -> None:
314
+ """Raise unless ``rel_path`` is a legal hive leaf path.
315
+
316
+ Below the root only digit components are allowed — ``{sign+base}``
317
+ (optional ``-``, one digit ``1..6``) at the first level, then digit
318
+ (``1..4``) components chunked per ``path_grouping`` (spec §6.1): every
319
+ component full-width except the last, which carries the remainder —
320
+ terminating in ``{full_id}.zarr`` (or the windowed
321
+ ``{full_id}_{window}.zarr``) whose id equals the concatenated
322
+ components. This is the walker's contract: any other name under the root
323
+ (bar the manifest and the root ``coverage.moc``) breaks child
324
+ classification.
325
+ """
326
+ parts = rel_path.strip("/").split("/")
327
+ leaf = parts[-1]
328
+ ok = len(parts) >= 2 and leaf.endswith(".zarr")
329
+ if ok:
330
+ head, digits = parts[0], parts[1:-1]
331
+ try:
332
+ full_id, _window = split_leaf_name(leaf)
333
+ except ValueError:
334
+ full_id = None # malformed window label -> not a legal leaf
335
+ if full_id is not None and full_id.endswith("p"):
336
+ raise ValueError(
337
+ f"path {rel_path!r} carries the 'p' point kind-suffix: paths never "
338
+ f"do (spec §2/§6.6 — an order-29 path component reads as AREA per "
339
+ f"the §4 tie-break; kind rides the packed words)"
340
+ )
341
+ ok = is_base_component(head)
342
+ ok = ok and all(1 <= len(d) <= path_grouping and set(d) <= set("1234") for d in digits)
343
+ # Only the LAST component may be short (the remainder rides last).
344
+ ok = ok and all(len(d) == path_grouping for d in digits[:-1])
345
+ ok = ok and full_id == head + "".join(digits)
346
+ if not ok:
347
+ raise ValueError(
348
+ f"path {rel_path!r} violates the hive node invariant (path_grouping={path_grouping})"
349
+ )
350
+
351
+
352
+ def parse_manifest(payload: object) -> dict:
353
+ """Validate a ``morton_hive.json`` payload; returns it as a dict.
354
+
355
+ Loud on malformed input (a manifest is the reader's bootstrap — there is
356
+ no degraded mode without it): unknown ``spec``, missing/non-integer
357
+ orders, a ``/2`` manifest without its temporal block, or a temporal block
358
+ without a schedule all raise ``ValueError``.
359
+ """
360
+ if not isinstance(payload, dict):
361
+ raise ValueError(f"manifest is not a mapping: {type(payload).__name__}")
362
+ spec = payload.get("spec")
363
+ if spec not in (HIVE_SPEC, HIVE_SPEC_V2):
364
+ raise ValueError(f"unknown manifest spec {spec!r} (expected {HIVE_SPEC} or {HIVE_SPEC_V2})")
365
+ for key in ("cell_order", "shard_order"):
366
+ value = payload.get(key)
367
+ if not isinstance(value, int):
368
+ raise ValueError(f"manifest {key} must be an integer (got {value!r})")
369
+ if payload["cell_order"] < payload["shard_order"]:
370
+ raise ValueError(
371
+ f"manifest cell_order {payload['cell_order']} is above shard_order "
372
+ f"{payload['shard_order']} (cells nest inside shards)"
373
+ )
374
+ grouping = payload.get("path_grouping", 1)
375
+ if isinstance(grouping, bool) or not isinstance(grouping, int) or grouping < 1:
376
+ # Spec §6.1 defines path_grouping as an integer digit count (absent
377
+ # reads as 1, D21); no list form exists in the spec grammar.
378
+ raise ValueError(f"manifest path_grouping must be an integer >= 1 (got {grouping!r})")
379
+ temporal = payload.get("temporal")
380
+ if spec == HIVE_SPEC_V2:
381
+ if not isinstance(temporal, dict) or not temporal.get("schedule"):
382
+ raise ValueError(f"a {HIVE_SPEC_V2} manifest requires a temporal block with a schedule")
383
+ elif temporal is not None:
384
+ raise ValueError(f"a {HIVE_SPEC} manifest must not carry a temporal block")
385
+ return payload