From 5eca2849b1b63ec313e5c88f3097009a1c36e605 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 20 Jul 2026 01:58:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(wire):=20S2/#107=20=E2=80=94=20=C2=A72=20h?= =?UTF-8?q?eader=20builder,=20pinned=20key=20order,=20closed=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_header emits the 13 §2 keys in the contract-pinned order so independently written headers byte-match the §10 fixture (proven: byte parity with the Track-A metadata.json AND equality with the arrow shard's embedded header). The closed provenance sub-object rejects extra/missing keys naming them; provenance key order is re-pinned regardless of caller dict order. Mirrors the fixture generator without importing it — the fixture stays canonical. Part of epic #105. Closes #107. Co-Authored-By: Claude Fable 5 --- tests/test_wire_header.py | 67 ++++++++++++++++++++ views_postprocessing/unfao/wire/header.py | 75 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/test_wire_header.py create mode 100644 views_postprocessing/unfao/wire/header.py diff --git a/tests/test_wire_header.py b/tests/test_wire_header.py new file mode 100644 index 0000000..62d38b6 --- /dev/null +++ b/tests/test_wire_header.py @@ -0,0 +1,67 @@ +"""§2 header builder (wire/header.py) — byte parity with both fixture envelopes.""" + +import json +import zipfile +from pathlib import Path + +import pytest +from views_frames.io import arrow as vf_arrow + +from views_postprocessing.unfao.wire import header + +_FIX = Path(__file__).resolve().parent / "fixtures" / "wire_contract" + +_FIXTURE_KWARGS = dict( + sample_count=4, + dtype="float32", + target="lr_ged_sb", + time_id=543, + run_id="fixture_run_0", + generated_at="2026-07-15T00:00:00Z", + provenance={ + "ensemble": "fixture_ensemble", + "pipeline_core_version": "0.0.0-fixture", + "reconciled": False, + }, + sharding_index=0, + sharding_count=1, +) + + +def test_byte_parity_with_the_track_a_header(): + with zipfile.ZipFile(_FIX / "fixture_run_0__lr_ged_sb__m000543.tap.zip") as zf: + canonical = zf.read("metadata.json") + built = json.dumps(header.build_header(**_FIXTURE_KWARGS), indent=2).encode() + assert built == canonical # ONE header, byte-for-byte (§2 key-order rule) + + +def test_equals_the_arrow_shard_embedded_header(): + state = vf_arrow.load(_FIX / "fixture_run_0__lr_ged_sb__m000543.arrow.parquet") + assert header.build_header(**_FIXTURE_KWARGS) == state["metadata"] + + +def test_extra_provenance_key_raises(): + kwargs = dict(_FIXTURE_KWARGS) + kwargs["provenance"] = {**kwargs["provenance"], "git_sha": "abc"} + with pytest.raises(header.HeaderError, match="git_sha"): + header.build_header(**kwargs) + + +def test_missing_provenance_key_raises(): + kwargs = dict(_FIXTURE_KWARGS) + kwargs["provenance"] = {"ensemble": "e"} + with pytest.raises(header.HeaderError, match="missing"): + header.build_header(**kwargs) + + +def test_provenance_key_order_is_pinned_regardless_of_input_order(): + kwargs = dict(_FIXTURE_KWARGS) + kwargs["provenance"] = dict(reversed(list(kwargs["provenance"].items()))) + built = json.dumps(header.build_header(**kwargs), indent=2).encode() + with zipfile.ZipFile(_FIX / "fixture_run_0__lr_ged_sb__m000543.tap.zip") as zf: + assert built == zf.read("metadata.json") + + +def test_contract_version_matches_the_canonical_manifest(): + manifest = json.loads((_FIX / "fixture_run_0__lr_ged_sb__manifest.json").read_text()) + assert manifest["contract_version"] == header.CONTRACT_VERSION diff --git a/views_postprocessing/unfao/wire/header.py b/views_postprocessing/unfao/wire/header.py new file mode 100644 index 0000000..b776c7a --- /dev/null +++ b/views_postprocessing/unfao/wire/header.py @@ -0,0 +1,75 @@ +"""The §2 contract header builder (ADR-013). + +One header, both envelopes: the dict built here becomes ``metadata.json`` inside a +Hop-A archive and the ``views_frames`` embedded metadata of a Hop-B arrow shard — +byte-for-byte, which is why **key order is pinned** (§2's key-order rule: writers +emit exactly the order below so independently written headers can byte-match the +§10 fixture; readers must never depend on it). + +Closed sub-objects (§2.1 rule-1 clarification, 2026-07-19): ``id_semantics``, +``provenance``, and ``sharding`` accept no extra keys — adding one is a contract +amendment, not a free addition. This builder enforces that on the only sub-object +the caller supplies (``provenance``); the other two are constructed here. + +Mirrors the §10 fixture generator's header (``scripts/build_wire_fixture.py``) — +deliberately not imported from it: the fixture's bytes stay canonical and this +module must independently reproduce them (that is the test). +""" + +from __future__ import annotations + +CONTRACT_VERSION = "1.5" + +_PROVENANCE_KEYS = ("ensemble", "pipeline_core_version", "reconciled") + +_ID_SEMANTICS = {"time": "views_month_id", "unit": "priogrid_id"} + + +class HeaderError(ValueError): + """The declared header inputs violate the §2 schema.""" + + +def build_header( + *, + sample_count: int, + dtype: str, + target: str, + time_id: int, + run_id: str, + generated_at: str, + provenance: dict, + sharding_index: int, + sharding_count: int, +) -> dict: + """The §2 header, keys in the pinned contract order. + + ``provenance`` must carry exactly the three §2.2 keys; everything else is + declared by the caller (typically passed through from the Hop-A header — + the sink mints nothing, §10.2). + """ + if set(provenance) != set(_PROVENANCE_KEYS): + extra = sorted(set(provenance) - set(_PROVENANCE_KEYS)) + missing = sorted(set(_PROVENANCE_KEYS) - set(provenance)) + raise HeaderError( + f"provenance is a closed sub-object (§2.2): exactly {list(_PROVENANCE_KEYS)}; " + f"extra={extra}, missing={missing}." + ) + return { + "contract_version": CONTRACT_VERSION, + "frame_type": "prediction", + "representation": "samples", + "sample_count": sample_count, + "dtype": dtype, + "spatial_level": "pgm", + "target": target, + "time_id": time_id, + "run_id": run_id, + "generated_at": generated_at, + "id_semantics": dict(_ID_SEMANTICS), + "provenance": {key: provenance[key] for key in _PROVENANCE_KEYS}, + "sharding": { + "scheme": "per_month", + "index": sharding_index, + "count": sharding_count, + }, + }