Skip to content

Commit 361b370

Browse files
Pigbibicodex
andcommitted
feat: add daily preview artifact consumer
Co-Authored-By: Codex <noreply@openai.com>
1 parent 4ea245e commit 361b370

4 files changed

Lines changed: 461 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
name: QAR D3 Daily Preview Artifact
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
as_of:
7+
description: "Representative daily report date"
8+
required: true
9+
default: "2026-06-20"
10+
type: string
11+
frozen_generated_at:
12+
description: "Harness-only deterministic producer timestamp"
13+
required: true
14+
default: "2026-07-15T00:00:00Z"
15+
type: string
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
build-and-verify:
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 20
24+
steps:
25+
- name: Checkout repository # v6
26+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
27+
28+
- name: Set up Python # v6
29+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
30+
with:
31+
python-version: "3.11"
32+
33+
- name: Install package
34+
run: python -m pip install -e ".[test]"
35+
36+
- name: Build two frozen-clock workspaces
37+
id: build
38+
env:
39+
INPUT_AS_OF: ${{ inputs.as_of }}
40+
FROZEN_GENERATED_AT: ${{ inputs.frozen_generated_at }}
41+
run: |
42+
set -euo pipefail
43+
EVIDENCE_DIR="$RUNNER_TEMP/qar-d3-evidence"
44+
mkdir -p "$EVIDENCE_DIR"
45+
python scripts/d3_build_daily_preview.py \
46+
--as-of "$INPUT_AS_OF" \
47+
--political-events examples/political_events.example.csv \
48+
--political-watchlist examples/political_watchlist.example.csv \
49+
--frozen-generated-at "$FROZEN_GENERATED_AT" \
50+
--base-sha "$GITHUB_SHA" \
51+
--temp-root "$RUNNER_TEMP" \
52+
--evidence-path "$EVIDENCE_DIR/build_evidence.json" \
53+
--workspace-path-file "$RUNNER_TEMP/qar-d3-workspace.txt"
54+
echo "workspace=$(cat "$RUNNER_TEMP/qar-d3-workspace.txt")" >> "$GITHUB_OUTPUT"
55+
echo "evidence=$EVIDENCE_DIR/build_evidence.json" >> "$GITHUB_OUTPUT"
56+
57+
- name: Upload exact three-file preview artifact # v7
58+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
59+
with:
60+
name: qar-daily-preview
61+
path: ${{ steps.build.outputs.workspace }}/*
62+
if-no-files-found: error
63+
64+
- name: Upload build evidence # v7
65+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
66+
with:
67+
name: qar-daily-preview-build-evidence
68+
path: ${{ steps.build.outputs.evidence }}
69+
if-no-files-found: error
70+
71+
- name: Download preview artifact # v7
72+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
73+
with:
74+
name: qar-daily-preview
75+
path: ${{ runner.temp }}/qar-d3-download
76+
77+
- name: Download build evidence # v7
78+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
79+
with:
80+
name: qar-daily-preview-build-evidence
81+
path: ${{ runner.temp }}/qar-d3-evidence-download
82+
83+
- name: Verify downloaded artifact against full build evidence
84+
env:
85+
INPUT_AS_OF: ${{ inputs.as_of }}
86+
FROZEN_GENERATED_AT: ${{ inputs.frozen_generated_at }}
87+
run: |
88+
set -euo pipefail
89+
python scripts/d3_verify_daily_preview.py \
90+
--workspace "$RUNNER_TEMP/qar-d3-download" \
91+
--evidence-path "$RUNNER_TEMP/qar-d3-evidence-download/build_evidence.json" \
92+
--base-sha "$GITHUB_SHA" \
93+
--as-of "$INPUT_AS_OF" \
94+
--political-events examples/political_events.example.csv \
95+
--political-watchlist examples/political_watchlist.example.csv \
96+
--frozen-generated-at "$FROZEN_GENERATED_AT" | tee -a "$GITHUB_STEP_SUMMARY"
97+
98+
- name: Cleanup private workspace after evidence
99+
if: ${{ always() }}
100+
run: |
101+
set -euo pipefail
102+
if [ -f "$RUNNER_TEMP/qar-d3-workspace.txt" ]; then
103+
workspace="$(cat "$RUNNER_TEMP/qar-d3-workspace.txt")"
104+
rm -rf -- "$(dirname "$workspace")"
105+
fi

scripts/d3_build_daily_preview.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
"""Build two frozen-clock representative daily preview workspaces and evidence."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import hashlib
7+
import json
8+
import os
9+
import re
10+
import shutil
11+
import subprocess
12+
import tempfile
13+
from pathlib import Path
14+
from unittest.mock import patch
15+
16+
from quant_advisor_research.advisory_report import build_advisory_report
17+
from quant_advisor_research.preview_bundle import read_preview_bundle
18+
from quant_advisor_research.preview_workspace import build_preview_workspace
19+
20+
EVIDENCE_VERSION = "qar.d3.build_evidence.v1"
21+
FIXED_FILES = ("manifest.json", "report.html", "report.json")
22+
DEPENDENCY_INVENTORY = [
23+
".github/workflows/qar_d3_daily_preview_artifact.yml",
24+
"scripts/d3_build_daily_preview.py",
25+
"scripts/d3_verify_daily_preview.py",
26+
"src/quant_advisor_research/advisory_report.py",
27+
"src/quant_advisor_research/artifact_integrity.py",
28+
"src/quant_advisor_research/artifacts.py",
29+
"src/quant_advisor_research/contracts.py",
30+
"src/quant_advisor_research/csv_utils.py",
31+
"src/quant_advisor_research/period_contract.py",
32+
"src/quant_advisor_research/preview_bundle.py",
33+
"src/quant_advisor_research/preview_workspace.py",
34+
"src/quant_advisor_research/time_contract.py",
35+
"tests/test_d3_preview_artifact.py",
36+
"examples/political_events.example.csv",
37+
"examples/political_watchlist.example.csv",
38+
"pyproject.toml",
39+
]
40+
41+
42+
def _base_sha() -> str:
43+
return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
44+
45+
46+
def _file_hashes(workspace: Path) -> dict[str, dict[str, object]]:
47+
return {
48+
name: {"sha256": hashlib.sha256((workspace / name).read_bytes()).hexdigest(), "size": (workspace / name).stat().st_size}
49+
for name in FIXED_FILES
50+
}
51+
52+
53+
def _build_report(*, as_of: str, events: Path, watchlist: Path, frozen_generated_at: str) -> dict[str, object]:
54+
with patch("quant_advisor_research.advisory_report.utc_now_iso", return_value=frozen_generated_at):
55+
return build_advisory_report(
56+
as_of=as_of,
57+
cadence="daily",
58+
political_events_path=events,
59+
political_watchlist_path=watchlist,
60+
)
61+
62+
63+
def build_evidence(args: argparse.Namespace) -> tuple[Path, dict[str, object]]:
64+
if any(not Path(path).is_file() for path in DEPENDENCY_INVENTORY):
65+
raise RuntimeError("dependency_inventory_invalid")
66+
temp_root = Path(args.temp_root)
67+
temp_root.mkdir(parents=True, exist_ok=True)
68+
parent = Path(tempfile.mkdtemp(prefix="qar-d3-parent-", dir=temp_root))
69+
succeeded = False
70+
try:
71+
first_report = _build_report(
72+
as_of=args.as_of,
73+
events=Path(args.political_events),
74+
watchlist=Path(args.political_watchlist),
75+
frozen_generated_at=args.frozen_generated_at,
76+
)
77+
second_report = _build_report(
78+
as_of=args.as_of,
79+
events=Path(args.political_events),
80+
watchlist=Path(args.political_watchlist),
81+
frozen_generated_at=args.frozen_generated_at,
82+
)
83+
first = build_preview_workspace(first_report, parent)
84+
second = build_preview_workspace(second_report, parent)
85+
read_preview_bundle(first)
86+
read_preview_bundle(second)
87+
first_files = _file_hashes(first)
88+
second_files = _file_hashes(second)
89+
if first_files != second_files or any((first / name).read_bytes() != (second / name).read_bytes() for name in FIXED_FILES):
90+
raise RuntimeError("repeat_build_not_equal")
91+
manifest = json.loads((first / "manifest.json").read_text(encoding="utf-8"))
92+
source = manifest["source"]
93+
base_sha = args.base_sha or _base_sha()
94+
if not re.fullmatch(r"[0-9a-f]{40}", base_sha):
95+
raise RuntimeError("base_sha_invalid")
96+
if source != {
97+
"schema_version": "5",
98+
"contract_version": "model_recommendations.v5",
99+
"cadence": "daily",
100+
"as_of": args.as_of,
101+
"generated_at": args.frozen_generated_at,
102+
}:
103+
raise RuntimeError("source_contract_mismatch")
104+
evidence = {
105+
"evidence_version": EVIDENCE_VERSION,
106+
"base_sha": base_sha,
107+
"source": {
108+
"fixture_paths": [Path(args.political_events).as_posix(), Path(args.political_watchlist).as_posix()],
109+
"provenance": "repository_representative_fixture",
110+
},
111+
"deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2},
112+
"workflow_dependency_inventory": DEPENDENCY_INVENTORY,
113+
"bundle": {
114+
"contract": manifest["bundle_contract"],
115+
"source": source,
116+
"files": first_files,
117+
},
118+
"repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": second_files},
119+
}
120+
evidence_path = Path(args.evidence_path)
121+
evidence_path.parent.mkdir(parents=True, exist_ok=True)
122+
evidence_path.write_text(json.dumps(evidence, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")
123+
Path(args.workspace_path_file).write_text(str(first) + "\n", encoding="utf-8")
124+
print(json.dumps({"workspace": str(first), "evidence": str(evidence_path), "base_sha": evidence["base_sha"]}, sort_keys=True))
125+
succeeded = True
126+
return first, evidence
127+
finally:
128+
if not succeeded:
129+
try:
130+
parent.rmdir()
131+
except OSError:
132+
shutil.rmtree(parent, ignore_errors=True)
133+
134+
135+
def parse_args() -> argparse.Namespace:
136+
parser = argparse.ArgumentParser()
137+
parser.add_argument("--as-of", required=True)
138+
parser.add_argument("--political-events", required=True)
139+
parser.add_argument("--political-watchlist", required=True)
140+
parser.add_argument("--frozen-generated-at", required=True)
141+
parser.add_argument("--temp-root", default=os.environ.get("RUNNER_TEMP", tempfile.gettempdir()))
142+
parser.add_argument("--evidence-path", required=True)
143+
parser.add_argument("--workspace-path-file", required=True)
144+
parser.add_argument("--base-sha")
145+
return parser.parse_args()
146+
147+
148+
if __name__ == "__main__":
149+
build_evidence(parse_args())

scripts/d3_verify_daily_preview.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env python3
2+
"""Verify a downloaded D3 bundle against the complete build evidence payload."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import hashlib
7+
import json
8+
import re
9+
from pathlib import Path
10+
11+
from quant_advisor_research.preview_bundle import read_preview_bundle
12+
13+
EVIDENCE_VERSION = "qar.d3.build_evidence.v1"
14+
FIXED_FILES = ("manifest.json", "report.html", "report.json")
15+
EVIDENCE_KEYS = {"evidence_version", "base_sha", "source", "deterministic_clock", "bundle", "repeat_build"}
16+
DEPENDENCY_INVENTORY = [
17+
".github/workflows/qar_d3_daily_preview_artifact.yml",
18+
"scripts/d3_build_daily_preview.py",
19+
"scripts/d3_verify_daily_preview.py",
20+
"src/quant_advisor_research/advisory_report.py",
21+
"src/quant_advisor_research/artifact_integrity.py",
22+
"src/quant_advisor_research/artifacts.py",
23+
"src/quant_advisor_research/contracts.py",
24+
"src/quant_advisor_research/csv_utils.py",
25+
"src/quant_advisor_research/period_contract.py",
26+
"src/quant_advisor_research/preview_bundle.py",
27+
"src/quant_advisor_research/preview_workspace.py",
28+
"src/quant_advisor_research/time_contract.py",
29+
"tests/test_d3_preview_artifact.py",
30+
"examples/political_events.example.csv",
31+
"examples/political_watchlist.example.csv",
32+
"pyproject.toml",
33+
]
34+
35+
36+
def _file_hashes(workspace: Path) -> dict[str, dict[str, object]]:
37+
return {
38+
name: {"sha256": hashlib.sha256((workspace / name).read_bytes()).hexdigest(), "size": (workspace / name).stat().st_size}
39+
for name in FIXED_FILES
40+
}
41+
42+
43+
def _canonical_json(value: object) -> bytes:
44+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
45+
46+
47+
def verify(*, workspace: Path, evidence_path: Path, expected_base_sha: str, expected_as_of: str, expected_events: str, expected_watchlist: str, frozen_generated_at: str) -> dict[str, object]:
48+
if any(not Path(path).is_file() for path in DEPENDENCY_INVENTORY):
49+
raise ValueError("dependency_inventory_invalid")
50+
evidence = json.loads(evidence_path.read_text(encoding="utf-8"))
51+
if not isinstance(evidence, dict) or set(evidence) != EVIDENCE_KEYS | {"workflow_dependency_inventory"}:
52+
raise ValueError("evidence_shape_invalid")
53+
if not re.fullmatch(r"[0-9a-f]{40}", expected_base_sha):
54+
raise ValueError("base_sha_invalid")
55+
read_preview_bundle(workspace)
56+
if {path.name for path in workspace.iterdir()} != set(FIXED_FILES):
57+
raise ValueError("file_set_invalid")
58+
manifest = json.loads((workspace / "manifest.json").read_text(encoding="utf-8"))
59+
if (workspace / "manifest.json").read_bytes() != _canonical_json(manifest):
60+
raise ValueError("manifest_noncanonical")
61+
files = _file_hashes(workspace)
62+
expected = {
63+
"evidence_version": EVIDENCE_VERSION,
64+
"base_sha": expected_base_sha,
65+
"source": {
66+
"fixture_paths": [Path(expected_events).as_posix(), Path(expected_watchlist).as_posix()],
67+
"provenance": "repository_representative_fixture",
68+
},
69+
"deterministic_clock": {"frozen_generated_at": frozen_generated_at, "producer_invocations": 2},
70+
"workflow_dependency_inventory": DEPENDENCY_INVENTORY,
71+
"bundle": {"contract": "qar.preview_bundle.v1", "source": manifest["source"], "files": files},
72+
"repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": files},
73+
}
74+
if evidence != expected:
75+
raise ValueError("build_evidence_mismatch")
76+
source = manifest.get("source", {})
77+
if source != {
78+
"schema_version": "5",
79+
"contract_version": "model_recommendations.v5",
80+
"cadence": "daily",
81+
"as_of": expected_as_of,
82+
"generated_at": frozen_generated_at,
83+
}:
84+
raise ValueError("source_contract_mismatch")
85+
result = {"status": "passed", "base_sha": expected_base_sha, "files": files, "bundle_contract": manifest["bundle_contract"], "readback": "passed", "evidence_binding": "exact_full_payload"}
86+
print(json.dumps(result, sort_keys=True))
87+
return result
88+
89+
90+
def parse_args() -> argparse.Namespace:
91+
parser = argparse.ArgumentParser()
92+
parser.add_argument("--workspace", required=True, type=Path)
93+
parser.add_argument("--evidence-path", required=True, type=Path)
94+
parser.add_argument("--base-sha", required=True)
95+
parser.add_argument("--as-of", required=True)
96+
parser.add_argument("--political-events", required=True)
97+
parser.add_argument("--political-watchlist", required=True)
98+
parser.add_argument("--frozen-generated-at", required=True)
99+
return parser.parse_args()
100+
101+
102+
if __name__ == "__main__":
103+
args = parse_args()
104+
verify(
105+
workspace=args.workspace,
106+
evidence_path=args.evidence_path,
107+
expected_base_sha=args.base_sha,
108+
expected_as_of=args.as_of,
109+
expected_events=args.political_events,
110+
expected_watchlist=args.political_watchlist,
111+
frozen_generated_at=args.frozen_generated_at,
112+
)

0 commit comments

Comments
 (0)