Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
32 changes: 30 additions & 2 deletions docs/hive_layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ output:
need `bounds.temporal` to enumerate generative windows.
- **Stamps carry the truth, the manifest the schema** (D15): each windowed
leaf's commit stamp records its `window` label and the ACTUAL written
`time_range` as ISO-8601 UTC strings; the root `coverage.moc` summary
`time_range` as ISO-8601 UTC strings (both ends at whole-second
granularity, which narrows the tail by up to 1 s — see
[The commit stamp](#the-commit-stamp)); the root `coverage.moc` summary
carries the run's time-range union (cache, regenerable); temporal *extent*
never lives in the manifest, which stays write-once. Appending a new year
to a `yearly` store adds leaves the schedule already describes — no
Expand All @@ -194,7 +196,13 @@ same second (say `12:00:01.0Z` → `12:00:01.4Z`) the window dispatches as an
empty `ge x`/`lt x` pair, so it is rejected — the point form is the spelling
for one-second intent. A sub-second range that *straddles* a second boundary
(`12:00:01.9Z` → `12:00:02.1Z`) is still valid; it renders to the one-second
window its truncated bounds describe. On the raster path
window its truncated bounds describe. The `epoch` is the one time value held
to a stricter rule ([issue #390](https://github.com/englacial/zagg/issues/390)):
it too renders at whole-second granularity, but it defines *every* window
conversion rather than one edge of one window, so a sub-second `epoch` is
**refused** rather than truncated — `2018-01-01T00:00:00.5Z` would shift every
boundary by half a second, invisibly. Declare it at second precision. On the
raster path
([issue #247](https://github.com/englacial/zagg/issues/247)) membership is
the acquisition's STAC `datetime`: `time_field` is optional (fixed to
`datetime`) and the `epoch`/`scale`/`units` conversion knobs are rejected.
Expand Down Expand Up @@ -613,6 +621,26 @@ A windowed leaf's stamp ([Time windows](#time-windows-morton-hive2)) declares
`spec: "morton-hive/2"` and adds `window` (the label) plus `time_range` — the
actual `[t_min, t_max]` written, as ISO-8601 UTC strings.

**Reader caveat — `t_max` floors, so the recorded range can end up to 1 s
early.** Both ends render through `windows.iso_utc`'s whole-second
granularity (`isoformat(timespec="seconds")`), so each truncates to the second
*containing* it. On `t_min` that is harmless: the recorded start is at or
before the true first observation. On `t_max` it is not — a last observation
at `12:00:00.7` is recorded as `12:00:00`, so the closed `[t_min, t_max]` is
a slight **under**-estimate of the extent written, not an envelope around it.
**A consumer that prunes leaves on the stamp must treat `t_max` as inclusive
with 1 s of slack** (test the query against `t_max + 1 s`); pruning on the
recorded value alone can skip a leaf that genuinely holds data in the queried
interval. Sub-second inputs reach the rendering on both pipelines — a float
`delta_time` column on the point path (`windows.iso_time_range`), a
millisecond-bearing STAC `datetime` on the raster path
(`processing/raster._us_iso`). The narrowing is recorded rather than fixed
(ruled on [PR #398](https://github.com/englacial/zagg/pull/398)): no in-tree
consumer prunes on `time_range` — every use is a union into the regenerable

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 — the enumeration is one use short on the post-#410 tree.

"every use is a union into the regenerable root coverage.moc summary or the overview rollup" misses MocFamily.finish in src/zagg/sweep.py:340-342, which uses time_range as a skip-if-current predicate, not a union into a summary:

covered = covered and (
    union_time_range(existing.get("time_range"), time_range)
    == existing.get("time_range")
)

That gates whether the root coverage object is rewritten at all. It is not pruning and it is not a defect — both sides of the comparison are floored by the same renderer, so a rerun whose true t_max advanced by < 1 s produces an identical recorded value and there is genuinely nothing to rewrite. But the sentence is written as an exhaustive survey ("every use is …"), and it is the load-bearing half of the "recorded rather than fixed" ruling, so a reader auditing the claim will find a third call site and have to re-derive that it is harmless.

Suggest widening it — e.g. "no in-tree consumer prunes on time_range: every use is a union into the regenerable root coverage.moc summary or the overview rollup, or a skip-if-current comparison between two identically-rendered values (sweep.MocFamily.finish)". The rest of the caveat checks out against the merged tree: windows.iso_time_range -> iso_utc -> isoformat(timespec="seconds") and raster._us_iso's int(us) // 1_000_000 both floor to the containing second, the #the-commit-stamp anchor resolves (docs/hive_layout.md:591), and the caveat reads coherently where main's +643 lines left it.

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

Accepted — fixed in bcc95d23, essentially your suggested wording. The sentence now reads:

no in-tree consumer prunes on time_range — every use is a union into the regenerable root coverage.moc summary or the overview rollup, or a skip-if-current comparison between two identically-rendered values (sweep.MocFamily.finish, which rewrites the root object only when the union widens what is already recorded) — and rendering the range outward instead would change the bytes of a stamp field external readers decode.

I added the parenthetical on what the predicate gates because that is the half that makes it self-evidently harmless to a reader auditing the claim: both sides of union_time_range(existing.get("time_range"), time_range) == existing.get("time_range") (src/zagg/sweep.py:340-342) come through the same renderer, so a rerun whose true t_max advanced by < 1 s compares equal and there is genuinely nothing to rewrite — no pruning, no data skipped.

Staged alone; docs-only, no test impact.

root `coverage.moc` summary or the overview rollup — and rendering the range
outward instead would change the bytes of a stamp field external readers
decode.

## Coverage

Where the data is, declared hierarchically
Expand Down
70 changes: 57 additions & 13 deletions src/zagg/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import re
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from importlib import resources
from typing import Any, NotRequired, TypedDict

Expand Down Expand Up @@ -1436,9 +1436,30 @@ def _validate_windowing(config: PipelineConfig) -> None:
"delta_time)"
)
try:
_windows.parse_utc(epoch)
parsed_epoch = _windows.parse_utc(epoch)
except ValueError as e:
raise ValueError(f"output.windowing.epoch: {e}") from e
# Validate the RENDERED epoch, not the parsed one (issue #390). get_windowing
# canonicalizes through windows.iso_utc (timespec="seconds"), so a sub-second
# epoch parses clean here and is then silently truncated at the one place it
# is consumed — shifting EVERY window conversion by the dropped fraction. The
# predicate calls the renderer and round-trips it rather than repeating the
# truncation rule, so the two cannot drift (the PR #367 pattern).
# The message renders parsed_epoch rather than repr-ing the declared value:
# yaml.safe_load types an ISO timestamp scalar as a datetime, which parse_utc
# passes through, so repr would hand the author a stdlib constructor call
# instead of the value they wrote. The drop is reported in microseconds
# because that is exactly what iso_utc discards and nothing else: an integer,
# so no scientific notation for the 1 µs case, and never negative.
rendered_epoch = _windows.iso_utc(parsed_epoch)

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)

non-blocking — the merge brought in a second consumer of the rendered windowing epoch that runs before this guard, and it turns a sub-second epoch into a false "two different clocks" diagnostic.

_validate_time_source (added by #473, now config.py:1010) is called from validate_config at config.py:408, while _validate_windowing — and therefore this guard — is called at config.py:537. The cross-check inside it compares the declared output.time_source.epoch against get_windowing(config)["epoch"], i.e. the value already truncated by iso_utc:

if parse_utc(block["epoch"]) != parse_utc(windowing["epoch"]):
    disagree.append(("epoch", block["epoch"], windowing["epoch"]))

So a config that declares both blocks with the same sub-second epoch is refused by the wrong check, with a message that is factually untrue. Repro on 2d30bef1 (hive + healpix, schedule: yearly, scale: gps, both epochs literally "2018-01-01T00:00:00.5Z"):

output.time_source disagrees with output.windowing
  (epoch: 2018-01-01T00:00:00.5Z vs 2018-01-01T00:00:00+00:00)
  — window routing and the spec §8.3 toc words would run on two different clocks …

The two declarations are byte-identical; the only thing that "disagrees" is one side having been rendered. Dropping output.time_source from the same config yields the correct #390 message, so the guard is reachable — it is just preempted here, which is precisely the failure mode #390 exists to name.

This narrows the PR body's merge note. It reasons about time_source only in the two directions it is consumed ("full parsed precision" / "absent-block fallback consumes get_windowing's already-guarded output") and concludes "No relocation was needed". There is a third direction — _validate_time_source compares against the rendered epoch, ahead of the guard — and on that one the conclusion does not hold.

Fail-closed either way, so not blocking. Two cheap fixes if you want the right message: move _validate_windowing(config) ahead of _validate_time_source(config) in validate_config (the ordering comments at :397-408 give no reason it must precede), or have the cross-check compare parse_utc(block["epoch"]) against parse_utc(windowing_block["epoch"]) off the raw block rather than the normalized dict. Leaving it as-is is also defensible — but then it is worth a line in the merge note saying so, since the note currently reads as an exhaustive sweep of the #410 epoch surface.

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

Accepted — fixed in 12039a3b, via your second option (compare declared against declared) rather than the reorder.

The reorder was the wrong lever here on inspection: _validate_time_source is called at config.py:408, above the pipeline-kind branch, so it runs on every kind, while _validate_windowing runs inside the kind branches (:537 spatial, :1215 raster). Hoisting the windowing validator to sit ahead of it would start applying windowing checks to temporal/event pipelines that today return early at :411 — a scope change well past this finding.

So the cross-check now reads the epoch off the raw block:

declared_epoch = config.output["windowing"]["epoch"]
if parse_utc(block["epoch"]) != parse_utc(declared_epoch):
    disagree.append(("epoch", block["epoch"], declared_epoch))

Reaching that line means get_windowing took its point branch and parsed a declared epoch off the block, so the key is present and parses — the raster branch fixes scale to utc, which returns at the TOC_SOURCE_SCALES check above, and a malformed block returns from the try. The comparison is still between two parsed instants, so the purpose you flagged (both blocks on one clock for routing and word-encoding) is untouched; the only change is which spelling of the windowing side it compares and quotes. It is strictly tighter, too: a whole-second time_source epoch against a sub-second windowing epoch previously compared equal (the rendering truncated the difference away) and now reports the real disagreement.

Pinned in tests/test_config.py::TestTimeSource::test_an_agreeing_sub_second_pair_gets_the_sub_second_refusal — your byte-identical "2018-01-01T00:00:00.5Z"-in-both-blocks config, asserting the #390 carries sub-second precision message. It fails on the parent commit with exactly the message you quoted. The second half of the test pins that a genuine epoch disagreement still raises and now quotes the declared 2018-01-01T00:00:00.5Z rather than the rendered form.

On the merge note: you are right that "No relocation was needed" no longer holds as written — no relocation, but the cross-check did need adjusting. Flagged to the parent for a body patch.

uv run ruff check src tests / ruff format --check show only main's two standing items (N818 registry.py:64, the tests/data/benchmark/README.md format diff); tests/test_hive_windows.py tests/test_config.py green at 499 passed.

if _windows.parse_utc(rendered_epoch) != parsed_epoch:
raise ValueError(
f"output.windowing.epoch {parsed_epoch.isoformat()!r} carries sub-second "
f"precision that the canonical whole-second rendering drops: it is "
f"recorded as {rendered_epoch!r}, which would shift every window "
f"conversion by {parsed_epoch.microsecond} µs — declare the epoch at "
f"second precision"
)
scale = block.get("scale") or "utc"
if scale not in _windows.EPOCH_SCALES:
raise ValueError(
Expand Down Expand Up @@ -3510,6 +3531,16 @@ def _is_nan_fill(meta: dict) -> bool:
return isinstance(fill, float) and np.isnan(fill)


#: The raster branch's fixed windowing epoch (issue #247, ratified): raster
#: window membership is the acquisition's STAC ``datetime``, already an ISO-8601
#: UTC instant, so the encoding is the identity one and is never author-declared.
#: Held as an *instant*, not a spelling — ``get_windowing`` renders it through
#: ``windows.iso_utc``, the same renderer its point branch canonicalizes the
#: declared epoch with, so the two branches cannot write different spellings of
#: the same instant if that rendering ever changes (issue #390).
_UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)


def get_windowing(config: PipelineConfig) -> dict | None:
"""The normalized temporal windowing declaration, or ``None`` (issue #246).

Expand All @@ -3520,17 +3551,25 @@ def get_windowing(config: PipelineConfig) -> dict | None:
{"schedule", "time_field", "epoch", "scale", "units", "windows"}

``epoch`` and explicit-window boundaries are canonicalized to ISO-8601
UTC strings; ``windows`` is ``None`` except for ``schedule: explicit``,
where a ``{label, timestamp}`` point entry is desugared to its second-wide
range (issue #355) so the normalized list is uniformly ``{label, start,
end}``.
UTC strings. The ``epoch`` canonicalization is lossless on any config that
passed ``validate_config`` — ``timespec="seconds"`` would otherwise truncate
a sub-second epoch and shift every window conversion, so
:func:`_validate_windowing` refuses one outright (issue #390). A config
built without that check (``load_config_from_dict``, a hand-rolled worker
payload) still truncates here; explicit bounds truncate either way,
documented on :func:`_explicit_window_bounds`. ``windows`` is ``None``
except for ``schedule: explicit``, where a ``{label, timestamp}`` point
entry is desugared to its second-wide range (issue #355) so the normalized
list is uniformly ``{label, start, end}``.
On the raster branch (``reader: raster``) ``time_field`` is the fixed STAC
``datetime`` and ``epoch``/``scale``/``units`` are hardcoded to the
Unix-epoch UTC-seconds encoding any ISO instant normalizes to, rather than
``datetime`` and ``epoch``/``scale``/``units`` are fixed to the Unix-epoch
UTC-seconds encoding any ISO instant normalizes to, rather than
canonicalizing a declared ``epoch`` off the block (``_validate_windowing``
rejects those conversion knobs there). The same dict feeds the manifest
temporal block (:func:`zagg.hive.build_manifest`) and the dispatch fan-out,
so the two can never disagree.
rejects those conversion knobs there); the fixed epoch is still rendered
through the same ``windows.iso_utc`` as the declared one, so the branches
cannot spell one instant two ways (issue #390). The same dict feeds the
manifest temporal block (:func:`zagg.hive.build_manifest`) and the dispatch
fan-out, so the two can never disagree.
"""
from zagg import windows as _windows

Expand All @@ -3554,11 +3593,16 @@ def get_windowing(config: PipelineConfig) -> dict | None:
# #247, ratified): the manifest records the resolved field plus the
# fixed encoding any ISO-8601 UTC instant normalizes to (UTC seconds
# since the Unix epoch). _validate_windowing rejects the conversion
# knobs on raster configs, so nothing here can disagree with it.
# knobs on raster configs, so nothing here can disagree with it. The
# epoch is *rendered* through iso_utc rather than spelled out, for the
# reason _validate_windowing's #390 guard calls the renderer instead of
# restating its truncation rule: a literal here would be a copy of
# iso_utc's output format, free to drift from the spelling the point
# branch below gives the very same instant (see _UNIX_EPOCH).
return {
"schedule": block["schedule"],
"time_field": "datetime",
"epoch": "1970-01-01T00:00:00+00:00",
"epoch": _windows.iso_utc(_UNIX_EPOCH),
"scale": "utc",
"units": "seconds",
"windows": declared,
Expand Down
137 changes: 137 additions & 0 deletions tests/test_hive_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
on the mortie spec page (mortie#62).
"""

import re
from datetime import datetime, timezone

import pytest

from zagg import hive
Expand Down Expand Up @@ -41,6 +44,21 @@ def _windowed(cfg, schedule="yearly", **over):
return cfg


def _raster_windowed(schedule="yearly"):
"""A minimal raster + hive + windowing config (issue #247)."""
c = default_config("atl06")
c.data_source = {
"reader": "raster",
"bands": {"red": {"asset": "red", "dtype": "uint16"}},
}
c.aggregation = {}
c.output["grid"] = {"type": "healpix", "parent_order": 6, "child_order": 12}
c.output["store_layout"] = "hive"
c.output["windowing"] = {"schedule": schedule}
validate_config(c)
return c


# ── config block (phase 2) ───────────────────────────────────────────────────


Expand Down Expand Up @@ -234,6 +252,96 @@ def test_bad_epoch_rejected(self, cfg):
with pytest.raises(ValueError, match="ISO-8601"):
validate_config(cfg)

@pytest.mark.parametrize(
"epoch, declared, rendered, shift",
[
(
"2018-01-01T00:00:00.5Z",
"2018-01-01T00:00:00.500000+00:00",
"2018-01-01T00:00:00+00:00",
"500000",
),
# The fraction survives a non-UTC declaration: the guard compares
# INSTANTS, not spellings, so it is the dropped fraction that
# refuses this — never the offset (Python accepts sub-minute
# offsets, and a whole-second one of those round-trips clean).
(
"2018-01-01T05:30:00.001+05:30",
"2018-01-01T00:00:00.001000+00:00",
"2018-01-01T00:00:00+00:00",
"1000",
),
# The smallest representable violation — a microsecond-precision
# timestamp pasted in from a data file — reads as an integer count,
# not as 1e-06.
(
"2018-01-01T00:00:00.000001Z",
"2018-01-01T00:00:00.000001+00:00",
"2018-01-01T00:00:00+00:00",
"1",
),
# The production path is datetime-typed, not str: yaml.safe_load
# resolves an ISO timestamp scalar to an aware datetime and
# windows.parse_utc passes it through, so the message must render
# the author's value rather than repr a stdlib constructor call.
(
datetime(2018, 1, 1, 0, 0, 0, 500000, tzinfo=timezone.utc),
"2018-01-01T00:00:00.500000+00:00",
"2018-01-01T00:00:00+00:00",
"500000",
),
],
)
def test_subsecond_epoch_rejected(self, cfg, epoch, declared, rendered, shift):
# The epoch parses at full precision but is CONSUMED through
# windows.iso_utc (timespec="seconds"), so a sub-second epoch used to
# validate clean and then shift every window conversion by the dropped
# fraction (issue #390 — the PR #367 validate-vs-render mismatch class).
# The message quotes the declared instant, the rendered value and the
# shift, so the refusal is tied to what get_windowing would actually emit.
_windowed(cfg, epoch=epoch)
with pytest.raises(
ValueError,
match=rf"epoch {re.escape(repr(declared))} carries sub-second precision.*"
rf"recorded as {re.escape(repr(rendered))}.*shift every window "
rf"conversion by {shift} µs",
):
validate_config(cfg)

@pytest.mark.parametrize(
"epoch",
[
"2018-01-01T00:00:00Z",
"2018-01-01T00:00:00+00:00",
# Naive input is taken AS UTC (windows.parse_utc), and a whole-second
# offset-bearing epoch still round-trips — only the fraction is lost.
"2018-01-01T00:00:00",
"2018-01-01T05:30:00+05:30",
],
)
def test_whole_second_epoch_accepted(self, cfg, epoch):
# The guard is exactly "the rendering is lossless", not "the declaration
# is spelled canonically": every whole-second spelling stays legal and
# canonicalizes to the same instant.
_windowed(cfg, epoch=epoch)
validate_config(cfg)
assert get_windowing(cfg)["epoch"] == "2018-01-01T00:00:00+00:00"

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)

The two deliberate carve-outs are unpinned. The guard sits after two early returns in _validate_windowing, and both exemptions are correct — but nothing in the suite says so, and a refactor that hoists the guard would flip them silently.

(1) schedule: none. The early return at config.py:962 means an inert block keeps a sub-second epoch. Verified at c733d7c:

cfg.output["windowing"] = {"schedule": "none", "time_field": "delta_time",
                           "epoch": "2018-01-01T00:00:00.5Z", "scale": "gps"}
validate_config(cfg)          # passes
get_windowing(cfg)            # None

That is right — get_windowing returns None, the epoch is never rendered, and per the comment at :955-958 an inert block must stay equivalent to an absent one. But it is exactly the case where the new refusal would be a false positive if the guard ever moved above that return, and TestWindowingConfig has no test for it.

(2) The raster branch. The PR body leans on this in its scope note — "the raster branch hardcodes epoch ... so this guard is on the point-pipeline branch only" — and the early return at config.py:1001 is what makes it true. The existing raster-knob rejection test covers epoch being present at all, which subsumes the sub-second case today, but the scope claim itself is inferred rather than asserted.

(1) is the one worth adding: two lines in this class, and it pins the interaction between the new guard and the schedule: none equivalence rule that the surrounding tests already treat as load-bearing (test_absent_is_none at :50).

Separately, the comment at :243-244"the offset is whole minutes, so only the sub-second part is ever what iso_utc drops" — generalises further than it should. Python's fromisoformat accepts sub-minute offsets (2018-01-01T00:00:30+00:00:30 parses, and round-trips clean through the guard — I checked). The guard is still correct there because it compares instants, not spellings, which is the stronger statement and the one the comment should make.


Generated by Claude Code

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

Both folded in dd5ed4d.

(1) test_schedule_none_keeps_subsecond_epoch added to TestWindowingConfig, right after the accept test:

    def test_schedule_none_keeps_subsecond_epoch(self, cfg):
        # The guard sits AFTER the ``schedule: none`` early return, and must:
        # an inert block is equivalent to an absent one (test_absent_is_none),
        # get_windowing returns None, and the epoch is never rendered — so
        # there is no conversion to shift and nothing to refuse.
        cfg.output["store_layout"] = "hive"
        cfg.output["windowing"] = {
            "schedule": "none",
            "time_field": "delta_time",
            "epoch": "2018-01-01T00:00:00.5Z",
            "scale": "gps",
        }
        validate_config(cfg)
        assert get_windowing(cfg) is None

That is the pair you described — it validates and returns None — so a hoist of the guard above config.py:962 now fails a test rather than silently turning the equivalence rule into a refusal.

Raster carve-out left as you judged: the existing raster-knob rejection test refuses epoch being present at all, which subsumes the sub-second case, so a second test there would only re-assert the earlier return.

(2) Comment fixed to the stronger statement:

            # The fraction survives a non-UTC declaration: the guard compares
            # INSTANTS, not spellings, so it is the dropped fraction that
            # refuses this — never the offset (Python accepts sub-minute
            # offsets, and a whole-second one of those round-trips clean).

Thanks for checking +00:00:30 against the guard rather than against the old comment — the whole-minute framing was doing work it could not support.


Generated by Claude Code


def test_schedule_none_keeps_subsecond_epoch(self, cfg):
# The guard sits AFTER the ``schedule: none`` early return, and must:
# an inert block is equivalent to an absent one (test_absent_is_none),
# get_windowing returns None, and the epoch is never rendered — so
# there is no conversion to shift and nothing to refuse.
cfg.output["store_layout"] = "hive"
cfg.output["windowing"] = {
"schedule": "none",
"time_field": "delta_time",
"epoch": "2018-01-01T00:00:00.5Z",
"scale": "gps",
}
validate_config(cfg)
assert get_windowing(cfg) is None

def test_bad_scale_and_units(self, cfg):
_windowed(cfg, scale="tt")
with pytest.raises(ValueError, match="scale"):
Expand Down Expand Up @@ -590,6 +698,20 @@ def test_raster_windowing_validates_on_hive(self):
validate_config(c)
assert get_windowing(c)["time_field"] == "datetime"

def test_raster_epoch_renders_through_iso_utc(self):
# Issue #390: the raster epoch is FIXED (issue #247) but not SPELLED —
# get_windowing renders it with windows.iso_utc like every other
# instant it emits. Asserting against the renderer's own output rather
# than a literal is the point: this stays true through a change to
# iso_utc's precision or offset form, and only a re-introduced literal
# in config.py can break it.
from zagg import windows as _windows

c = _raster_windowed()
assert get_windowing(c)["epoch"] == _windows.iso_utc(
_windows.parse_utc(datetime(1970, 1, 1, tzinfo=timezone.utc))
)


# ── manifest temporal block + spec bump (phase 2) ────────────────────────────

Expand Down Expand Up @@ -630,6 +752,21 @@ def test_yearly_manifest_declares_v2_temporal(self, cfg):
"append_policy": "new-window",
}

def test_raster_and_point_epochs_spell_one_instant_alike(self, cfg):
# The drift the rendered raster epoch prevents (issue #390): both
# branches of get_windowing feed the SAME manifest field, so a given
# instant has to reach it spelled one way. A point config declaring
# the Unix epoch and a raster config (whose epoch is fixed to it)
# therefore must produce byte-identical temporal epochs — this is the
# assertion a re-introduced literal on either branch breaks, since a
# literal is free to disagree with whatever iso_utc emits.
_windowed(cfg, epoch="1970-01-01T00:00:00Z", scale="utc")
validate_config(cfg)
point = hive.build_manifest(self._grid(cfg), windowing=get_windowing(cfg))
rcfg = _raster_windowed()
raster = hive.build_manifest(self._grid(rcfg), windowing=get_windowing(rcfg))
assert raster["temporal"]["epoch"] == point["temporal"]["epoch"]

def test_explicit_manifest_carries_windows_and_retemplate_policy(self, cfg):
_windowed(cfg, schedule="explicit")
m = hive.build_manifest(self._grid(cfg), windowing=get_windowing(cfg))
Expand Down
Loading