Skip to content

Commit 16ee626

Browse files
Pigbibicodex
andcommitted
feat(release): bind producer provenance
Co-Authored-By: Codex <noreply@openai.com>
1 parent 9814e8f commit 16ee626

5 files changed

Lines changed: 323 additions & 16 deletions

File tree

src/export.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import hashlib
44
from pathlib import Path
5+
import re
6+
import subprocess
57
from typing import Any
68

79
import pandas as pd
@@ -13,6 +15,12 @@
1315
DEFAULT_STRATEGY_PROFILE = "crypto_live_pool_rotation"
1416
DEFAULT_ARTIFACT_TYPE = "live_pool"
1517
DEFAULT_ARTIFACT_CONTRACT_VERSION = "crypto_live_pool_rotation.live_pool.v1"
18+
REQUIRED_IDENTITY_ARTIFACTS = (
19+
"live_pool",
20+
"live_pool_legacy",
21+
"latest_ranking",
22+
"latest_universe",
23+
)
1624

1725

1826
def _sha256_file(path: Path) -> str:
@@ -23,6 +31,49 @@ def _sha256_file(path: Path) -> str:
2331
return digest.hexdigest()
2432

2533

34+
def resolve_clean_source_revision(repo_root: Path | None = None) -> str:
35+
"""Resolve the commit whose clean tracked tree is executing this producer."""
36+
root = Path(repo_root) if repo_root is not None else Path(__file__).resolve().parents[1]
37+
try:
38+
revision = subprocess.run(
39+
["git", "rev-parse", "--verify", "HEAD^{commit}"],
40+
cwd=root,
41+
check=True,
42+
capture_output=True,
43+
text=True,
44+
).stdout.strip()
45+
subprocess.run(
46+
["git", "diff", "--quiet", "HEAD", "--"],
47+
cwd=root,
48+
check=True,
49+
)
50+
subprocess.run(
51+
["git", "diff", "--cached", "--quiet", "HEAD", "--"],
52+
cwd=root,
53+
check=True,
54+
)
55+
except (OSError, subprocess.CalledProcessError) as exc:
56+
raise RuntimeError("Cannot bind artifact bytes to a clean producer commit.") from exc
57+
if not re.fullmatch(r"[0-9a-f]{40}", revision):
58+
raise RuntimeError("Producer HEAD did not resolve to a lowercase 40-character commit.")
59+
return revision
60+
61+
62+
def _normalize_input_timestamp(value: Any, *, as_of_date: str) -> str:
63+
timestamp = pd.Timestamp(value)
64+
if pd.isna(timestamp):
65+
raise ValueError("input_timestamp must be a finite panel date.")
66+
if timestamp.tzinfo is None:
67+
timestamp = timestamp.tz_localize("UTC")
68+
else:
69+
timestamp = timestamp.tz_convert("UTC")
70+
timestamp = timestamp.normalize()
71+
expected = pd.Timestamp(as_of_date).tz_localize("UTC")
72+
if timestamp != expected:
73+
raise ValueError("input_timestamp must equal live_pool.as_of_date at UTC midnight.")
74+
return timestamp.strftime("%Y-%m-%dT00:00:00Z")
75+
76+
2677
def export_latest_universe(panel: pd.DataFrame, output_dir: str | Any, as_of_date: pd.Timestamp) -> dict[str, Any]:
2778
"""Export the latest dynamic universe to JSON."""
2879
snapshot = panel.xs(as_of_date, level="date")
@@ -170,6 +221,7 @@ def build_strategy_artifact_manifest(
170221
artifact_type: str = DEFAULT_ARTIFACT_TYPE,
171222
contract_version: str = DEFAULT_ARTIFACT_CONTRACT_VERSION,
172223
source_project: str = "crypto-live-pool-pipelines",
224+
input_timestamp: Any,
173225
generated_at: Any | None = None,
174226
) -> dict[str, Any]:
175227
"""Build the profile-aware artifact manifest consumed by downstream runtimes."""
@@ -190,8 +242,8 @@ def build_strategy_artifact_manifest(
190242
artifacts = {}
191243
for artifact_name, filename in artifact_files.items():
192244
path = output_path / filename
193-
if not path.exists():
194-
continue
245+
if not path.is_file():
246+
raise FileNotFoundError(f"Required identity artifact is missing: {path}")
195247
artifacts[artifact_name] = {
196248
"path": filename,
197249
"sha256": _sha256_file(path),
@@ -209,6 +261,16 @@ def build_strategy_artifact_manifest(
209261
mode = str(live_pool.get("mode", "")).strip()
210262
version = str(live_pool.get("version", "")).strip()
211263
source_project_text = str(live_pool.get("source_project") or source_project)
264+
input_timestamp_text = _normalize_input_timestamp(input_timestamp, as_of_date=as_of_date)
265+
runtime_evidence_identity = {
266+
"strategy_profile": str(strategy_profile),
267+
"mode": mode,
268+
"source_revision": resolve_clean_source_revision(),
269+
"input_timestamp": input_timestamp_text,
270+
"artifact_contract": str(contract_version),
271+
"artifact_version": version,
272+
"artifacts": {name: dict(artifacts[name]) for name in REQUIRED_IDENTITY_ARTIFACTS},
273+
}
212274
return {
213275
"manifest_type": "strategy_artifact",
214276
"contract_version": str(contract_version),
@@ -225,6 +287,7 @@ def build_strategy_artifact_manifest(
225287
"generated_at": generated_at_text,
226288
"primary_artifact": "live_pool",
227289
"artifacts": artifacts,
290+
"runtime_evidence_identity": runtime_evidence_identity,
228291
}
229292

230293

@@ -250,6 +313,7 @@ def export_strategy_artifact_manifest(
250313
artifact_type: str = DEFAULT_ARTIFACT_TYPE,
251314
contract_version: str = DEFAULT_ARTIFACT_CONTRACT_VERSION,
252315
source_project: str = "crypto-live-pool-pipelines",
316+
input_timestamp: Any,
253317
) -> dict[str, Any]:
254318
manifest = build_strategy_artifact_manifest(
255319
output_dir=output_dir,
@@ -258,6 +322,7 @@ def export_strategy_artifact_manifest(
258322
artifact_type=artifact_type,
259323
contract_version=contract_version,
260324
source_project=source_project,
325+
input_timestamp=input_timestamp,
261326
)
262327
write_json(Path(output_dir) / "artifact_manifest.json", manifest)
263328
return manifest

src/pipeline.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ def build_live_pool_outputs(
388388
else:
389389
panel = panel.join(result.predictions, how="left")
390390
panel = build_final_scores(panel, config)
391+
input_timestamp = resolve_scoring_input_timestamp(panel, score_mask)
391392

392393
output_dir = config["paths"].output_dir
393394
export_latest_universe(panel, output_dir, latest_date)
@@ -418,6 +419,7 @@ def build_live_pool_outputs(
418419
output_dir=output_dir,
419420
live_pool=live_payload,
420421
source_project=source_project,
422+
input_timestamp=input_timestamp,
421423
)
422424

423425
# Compute and export BTC cycle indicators for crypto DCA strategies
@@ -455,3 +457,14 @@ def build_live_pool_outputs(
455457
"ml_backend": result.ml_backend,
456458
"universe_mode": resolved_mode,
457459
}
460+
461+
462+
def resolve_scoring_input_timestamp(panel: pd.DataFrame, score_mask: Any) -> pd.Timestamp:
463+
"""Return the maximum date among rows admitted to final scoring/export."""
464+
scoring_rows = panel.loc[score_mask]
465+
if scoring_rows.empty:
466+
raise ValueError("No panel rows were admitted to final scoring/export.")
467+
dates = pd.to_datetime(scoring_rows.index.get_level_values("date"), errors="coerce")
468+
if dates.isna().any():
469+
raise ValueError("Final scoring/export rows contain an invalid date.")
470+
return pd.Timestamp(dates.max()).normalize()

src/publish.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import annotations
22

33
import os
4+
from copy import deepcopy
45
from dataclasses import dataclass
6+
import hashlib
57
from pathlib import Path
8+
import re
69
from typing import Any
710

811
import pandas as pd
@@ -53,6 +56,7 @@ class ReleaseArtifacts:
5356
live_pool: dict[str, Any]
5457
live_pool_legacy: dict[str, Any]
5558
artifact_manifest: dict[str, Any]
59+
runtime_evidence_identity: dict[str, Any]
5660

5761

5862
def parse_bool(value: Any, default: bool = False) -> bool:
@@ -117,6 +121,59 @@ def _require_file(path: Path) -> None:
117121
raise FileNotFoundError(f"Required release artifact is missing: {path}")
118122

119123

124+
def _sha256_file(path: Path) -> str:
125+
digest = hashlib.sha256()
126+
with path.open("rb") as handle:
127+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
128+
digest.update(chunk)
129+
return digest.hexdigest()
130+
131+
132+
def _validate_runtime_evidence_identity(
133+
*,
134+
identity: Any,
135+
artifact_manifest: dict[str, Any],
136+
live_pool: dict[str, Any],
137+
paths: dict[str, Path],
138+
) -> dict[str, Any]:
139+
if not isinstance(identity, dict):
140+
raise ValueError("artifact_manifest.json runtime_evidence_identity must be an object.")
141+
expected_artifacts = {
142+
"live_pool": paths["live_pool.json"],
143+
"live_pool_legacy": paths["live_pool_legacy.json"],
144+
"latest_ranking": paths["latest_ranking.csv"],
145+
"latest_universe": paths["latest_universe.json"],
146+
}
147+
manifest_artifacts = artifact_manifest.get("artifacts")
148+
identity_artifacts = identity.get("artifacts")
149+
if not isinstance(manifest_artifacts, dict) or set(manifest_artifacts) != set(expected_artifacts):
150+
raise ValueError("artifact_manifest.json must bind exactly the four release artifacts.")
151+
if not isinstance(identity_artifacts, dict) or identity_artifacts != manifest_artifacts:
152+
raise ValueError("runtime_evidence_identity artifacts must equal artifact_manifest.json artifacts.")
153+
for name, path in expected_artifacts.items():
154+
entry = manifest_artifacts.get(name)
155+
if (
156+
not isinstance(entry, dict)
157+
or entry.get("path") != path.name
158+
or entry.get("sha256") != _sha256_file(path)
159+
):
160+
raise ValueError(f"Runtime identity digest mismatch for {name}.")
161+
if identity.get("strategy_profile") != artifact_manifest.get("strategy_profile"):
162+
raise ValueError("Runtime identity strategy_profile mismatch.")
163+
if identity.get("mode") != live_pool.get("mode"):
164+
raise ValueError("Runtime identity mode mismatch.")
165+
if identity.get("artifact_contract") != artifact_manifest.get("contract_version"):
166+
raise ValueError("Runtime identity artifact_contract mismatch.")
167+
if identity.get("artifact_version") != live_pool.get("version"):
168+
raise ValueError("Runtime identity artifact_version mismatch.")
169+
if not re.fullmatch(r"[0-9a-f]{40}", str(identity.get("source_revision", ""))):
170+
raise ValueError("Runtime identity source_revision is invalid.")
171+
expected_timestamp = f"{live_pool.get('as_of_date')}T00:00:00Z"
172+
if identity.get("input_timestamp") != expected_timestamp:
173+
raise ValueError("Runtime identity input_timestamp mismatch.")
174+
return deepcopy(identity)
175+
176+
120177
def load_release_artifacts(output_dir: Path | str, mode: str) -> ReleaseArtifacts:
121178
output_path = Path(output_dir)
122179
paths = {name: output_path / name for name in REQUIRED_OUTPUT_FILES}
@@ -147,6 +204,12 @@ def load_release_artifacts(output_dir: Path | str, mode: str) -> ReleaseArtifact
147204
raise ValueError("live_pool_legacy.json must contain a non-empty symbols mapping.")
148205

149206
version = build_release_version(as_of_date, mode)
207+
runtime_evidence_identity = _validate_runtime_evidence_identity(
208+
identity=artifact_manifest.get("runtime_evidence_identity"),
209+
artifact_manifest=artifact_manifest,
210+
live_pool=live_pool,
211+
paths=paths,
212+
)
150213
return ReleaseArtifacts(
151214
as_of_date=as_of_date,
152215
version=version,
@@ -161,6 +224,7 @@ def load_release_artifacts(output_dir: Path | str, mode: str) -> ReleaseArtifact
161224
live_pool=live_pool,
162225
live_pool_legacy=live_pool_legacy,
163226
artifact_manifest=artifact_manifest,
227+
runtime_evidence_identity=runtime_evidence_identity,
164228
)
165229

166230

@@ -260,6 +324,7 @@ def build_firestore_payload(
260324
"artifact_contract_version": str(artifacts.artifact_manifest.get("contract_version", "")),
261325
"generated_at": generated_at,
262326
"source_project": settings.source_project,
327+
"runtime_evidence_identity": deepcopy(artifacts.runtime_evidence_identity),
263328
}
264329

265330

@@ -269,6 +334,8 @@ def build_release_manifest(
269334
storage_layout: dict[str, Any],
270335
firestore_payload: dict[str, Any],
271336
) -> dict[str, Any]:
337+
if firestore_payload.get("runtime_evidence_identity") != artifacts.runtime_evidence_identity:
338+
raise ValueError("Firestore runtime_evidence_identity must equal artifact_manifest.json.")
272339
return {
273340
"version": artifacts.version,
274341
"mode": settings.mode,
@@ -284,6 +351,7 @@ def build_release_manifest(
284351
"live_pool_legacy": storage_layout["objects"]["live_pool_legacy.json"],
285352
"artifact_manifest": storage_layout["objects"]["artifact_manifest.json"],
286353
},
354+
"runtime_evidence_identity": deepcopy(artifacts.runtime_evidence_identity),
287355
"firestore": {
288356
"collection": settings.firestore_collection,
289357
"document": settings.firestore_document,
@@ -392,8 +460,15 @@ def run_release_publish(
392460
max_age_days=max_age_days,
393461
require_manifest=True,
394462
require_artifact_manifest=True,
463+
require_runtime_evidence_identity=True,
395464
require_freshness=require_freshness,
396465
)
466+
artifacts = load_release_artifacts(artifacts.output_dir, settings.mode)
467+
if (
468+
manifest.get("runtime_evidence_identity") != artifacts.runtime_evidence_identity
469+
or firestore_payload.get("runtime_evidence_identity") != artifacts.runtime_evidence_identity
470+
):
471+
raise ValueError("Runtime evidence identity changed before publish.")
397472

398473
upload_release_artifacts(settings, artifacts, storage_layout)
399474
publish_firestore_summary(settings, firestore_payload)

0 commit comments

Comments
 (0)