Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5b6b2ee
phase 1 of issue #480: the zagg-coverage-toc/1 writer and reader helpers
espg Aug 18, 2026
59a626b
phase 2 of issue #480: spec §10, the zagg-coverage-toc/1 grammar
espg Aug 18, 2026
164304c
phase 3 of issue #480: regenerate the temporal fixture with its root …
espg Aug 18, 2026
5afceb1
phase 4 of issue #480: tests for the temporal coverage section
espg Aug 18, 2026
bd568c5
fold review: drop the type: ignore from the section merge (issue #480)
espg Aug 18, 2026
a685a91
fold review: preserve an unknown-revision section instead of clobberi…
espg Aug 18, 2026
84f99c3
fold review: make the sweep's temporal skip test converge on what it …
espg Aug 18, 2026
32b3614
fold review: drop a shard from the temporal map when any of its leave…
espg Aug 18, 2026
b8bdbff
fold review: refuse a truncated companion at array level (issue #480)
espg Aug 18, 2026
df05071
fold review: perform the declared-centroids check the spec makes a MU…
espg Aug 18, 2026
b0e1468
fold review: compose a partial refresh with the standing section (iss…
espg Aug 18, 2026
00305ca
fold review: read cell_order strictly instead of defaulting to group …
espg Aug 18, 2026
408e345
fold review: say what temporal_shards and the merged field list actua…
espg Aug 18, 2026
439fcef
fold review: keep the new refresh sentence clear of the pre-existing …
espg Aug 18, 2026
851081e
fold review: pin the multi-field union of the shard word (issue #480)
espg Aug 18, 2026
f79aaac
fold review: state what the root digest's value axis actually is (iss…
espg Aug 18, 2026
e7d2060
fold review: record the root fold's memory bound where the reader wil…
espg Aug 18, 2026
2faa767
fold review: pin the absence rule's precondition, not just the missin…
espg Aug 18, 2026
044d705
fold review: regenerate the temporal fixture with a resolvable zagg v…
espg Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/hive_layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,14 @@ The example above is `zagg.hive.build_root_coverage` output for the shards
`root_coverage_words`; the test suite parses it straight out of this file so
the reference example can never drift from the implementation.

A temporal-declaring store adds one more key here: `temporal`, the
`zagg-coverage-toc/1` section (per-shard toc envelope words plus an optional
root time-digest) whose grammar is normative in
[`specification.md`](specification.md) §10 — one metadata GET then answers
"which shards hold data DURING my window" before any leaf is opened. A store
with no temporal channel carries no such key and its root object is
byte-identical to a pre-#480 one; absence is never a refusal.

A range is an inclusive run of same-order cells within one base cell,
consecutive in digit-tail rank; endpoints are decimal **strings** (packed
u64 words exceed 2^53 and raw JSON numbers get mangled by float-based
Expand Down
279 changes: 275 additions & 4 deletions docs/specification.md

Large diffs are not rendered by default.

69 changes: 68 additions & 1 deletion src/zagg/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,13 @@ def refresh_root_coverage(store_root: str, **store_kwargs) -> dict | None:
skipped when it carries one (never classified by position) — as is a
``{stem}.pyramid.zarr`` column (issue #383), the one derived family that
lives at the leaf's OWN node. A
supersedes it). A successful refresh also re-arms the
supersedes it). A temporal-declaring store (spec §10, issue #480) also has
its ``zagg-coverage-toc/1`` section rebuilt from this same walk —
fail-open per SHARD, so an unreadable companion costs the section that
shard and never the refresh; and a walk that lost any shard COMPOSES its
rebuild with the standing section (§10.4) instead of replacing it, so the
escape hatch can never be the thing that deletes the section. A successful
refresh also re-arms the
:func:`warn_if_stale` once-per-episode latch for this store. Returns the
envelope written, or ``None`` — deleting any existing root object — when
no stamped leaf exists (absence is truthful, a stale cache is not, and
Expand All @@ -269,13 +275,33 @@ def refresh_root_coverage(store_root: str, **store_kwargs) -> dict | None:
import obstore
from obstore.exceptions import NotFoundError

from zagg.coverage_toc import (
build_temporal_section,
read_leaf_temporal,
temporal_cell_order,
temporal_fields,
)
from zagg.grids.morton import morton_words_from_decimals
from zagg.store import open_store

manifest = read_manifest(store_root, **store_kwargs)
if manifest is None:
raise ValueError(f"no {MANIFEST_NAME} at {store_root} — not a hive store root")
order = int(manifest["shard_order"])
# The §10 temporal section (issue #480) is rebuilt from the SAME walk, so
# the escape hatch regenerates it rather than deleting it — and, because
# this walk is whole-store by construction, its root time-digest is the
# authoritative one (spec §10's whole-coverage rule).
toc_fields = temporal_fields(manifest)
cell_order = temporal_cell_order(manifest)
if toc_fields and cell_order is None:
logger.warning(
f"refresh: {store_root} declares temporal fields but carries no cell_order — "
f"rebuilding no §10 section rather than guessing a group"
)
toc_fields = {}
contributions: dict[str, list] = {}
toc_failed: set[str] = set()
store = open_object_store(store_root, **store_kwargs)
root = store_root.rstrip("/")
# Decimals accumulate through the walk and parse once at the end (issue
Expand Down Expand Up @@ -352,6 +378,26 @@ def refresh_root_coverage(store_root: str, **store_kwargs) -> dict | None:
)
continue
decimals.append(decimal)
if toc_fields and decimal not in toc_failed:
try:
got = read_leaf_temporal(
f"{root}/{rel}", cell_order, toc_fields, **store_kwargs
)
except Exception as e: # fail-open: the section is a cache
# Shard-scoped, not leaf-scoped: §10.2's word must
# contain EVERY instant in a listed shard, which a
# word joined over the window leaves that happened to
# read cannot promise. Drop the shard — absent means
# "unknown", which stays a candidate.
logger.warning(
f"refresh: dropping shard {decimal} from the temporal section "
f"— leaf {rel} did not read ({e})"
)
toc_failed.add(decimal)
contributions.pop(decimal, None)
got = None
if got is not None:
contributions.setdefault(decimal, []).append(got)
# D15: windowed stamps carry the leaf's actual time range;
# the rebuilt root summary re-derives the union from this
# walk's stamps (truth), superseding any cached value.
Expand All @@ -369,11 +415,32 @@ def refresh_root_coverage(store_root: str, **store_kwargs) -> dict | None:
except (FileNotFoundError, NotFoundError):
pass
return None
section = build_temporal_section(contributions, toc_fields, source="refresh")
if toc_failed:
# Fail-open per leaf is fail-DESTRUCTIVE in aggregate. This walk PUTs
# its envelope outright (no union, by design), so a section rebuilt
# from a partial read publishes the losses as fact, and an all-failed
# walk deletes the section entirely — at exactly the moment an
# operator reached for the escape hatch because something was already
# wrong. Compose with the standing section instead, through the same
# §10.4 seam the sweep writes across.
from zagg.coverage_toc import TEMPORAL_KEY, merge_temporal_sections
from zagg.hive import read_root_coverage

standing = read_root_coverage(store_root, **store_kwargs)
section = merge_temporal_sections(
standing.get(TEMPORAL_KEY) if isinstance(standing, dict) else None, section
)
logger.warning(
f"refresh: the temporal section is PARTIAL — {len(toc_failed)} shard(s) did "
f"not read; composing with the standing section rather than replacing it"
)
envelope = build_root_coverage(
morton_words_from_decimals(decimals),
order,
source="refresh",
time_range=union_time_range(*time_ranges),
temporal=section,
)
obstore.put(store, ROOT_COVERAGE_NAME, json.dumps(envelope, indent=1).encode())
return envelope
Expand Down
Loading
Loading