-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add D3 daily preview artifact acceptance slice #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| name: QAR vNext D3 Daily Preview Artifact | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - ".github/workflows/qar_vnext_d3_daily_preview_artifact.yml" | ||
| - "scripts/d3_build_daily_preview_artifact.py" | ||
| - "scripts/d3_verify_daily_preview_artifact.py" | ||
| - "src/quant_advisor_research/preview_bundle.py" | ||
| - "src/quant_advisor_research/advisory_report.py" | ||
| - "tests/test_d3_daily_preview_artifact.py" | ||
|
Comment on lines
+7
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a PR changes Useful? React with 👍 / 👎. |
||
| workflow_dispatch: | ||
| inputs: | ||
| as_of: | ||
| description: "Representative daily report date" | ||
| required: true | ||
| default: "2026-06-20" | ||
| type: string | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| build-and-verify: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 15 | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| fetch-depth: 0 | ||
| - name: Setup Python | ||
| uses: actions/setup-python@v6 | ||
| with: | ||
| python-version: "3.11" | ||
| - name: Install advisor package | ||
| run: python -m pip install -e . | ||
| - name: Build representative daily preview | ||
| env: | ||
| AS_OF: ${{ inputs.as_of || '2026-06-20' }} | ||
| BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} | ||
| run: | | ||
| set -euo pipefail | ||
| python scripts/d3_build_daily_preview_artifact.py \ | ||
| --as-of "${AS_OF}" \ | ||
| --political-events examples/political_events.example.csv \ | ||
| --political-watchlist examples/political_watchlist.example.csv \ | ||
| --artifact-dir "${RUNNER_TEMP}/qar-daily-preview" \ | ||
| --evidence-path "${RUNNER_TEMP}/qar-daily-preview-build-evidence.json" \ | ||
| --base-sha "${BASE_SHA}" \ | ||
| --frozen-generated-at "2026-07-15T00:00:00Z" \ | ||
| > "${RUNNER_TEMP}/qar-daily-preview-build-evidence.log" | ||
| cat "${RUNNER_TEMP}/qar-daily-preview-build-evidence.log" | ||
| - name: Upload daily preview artifact | ||
| uses: actions/upload-artifact@v7 | ||
| with: | ||
| name: qar-daily-preview | ||
| path: ${{ runner.temp }}/qar-daily-preview | ||
| if-no-files-found: error | ||
| - name: Download daily preview artifact | ||
| uses: actions/download-artifact@v7 | ||
| with: | ||
| name: qar-daily-preview | ||
| path: ${{ runner.temp }}/qar-daily-preview-downloaded | ||
| - name: Read back uploaded artifact | ||
| env: | ||
| BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} | ||
| run: | | ||
| set -euo pipefail | ||
| python scripts/d3_verify_daily_preview_artifact.py \ | ||
| --artifact-dir "${RUNNER_TEMP}/qar-daily-preview-downloaded" \ | ||
| --evidence-path "${RUNNER_TEMP}/qar-daily-preview-download-evidence.json" \ | ||
| --build-evidence-path "${RUNNER_TEMP}/qar-daily-preview-build-evidence.json" \ | ||
| --base-sha "${BASE_SHA}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # QAR vNext D3 daily action artifact | ||
|
|
||
| This workflow is an isolated, repository-representative fixture acceptance slice: | ||
|
|
||
| 1. Two independent `build_advisory_report(..., cadence="daily")` invocations read the fixed examples CSVs under a harness-only frozen `generated_at` clock. This proves representative-fixture determinism; it is not a live wall-clock guarantee. | ||
| 2. Existing `qar.preview_bundle.v1` writes exactly `report.json`, `report.html`, and `manifest.json` under a unique `$RUNNER_TEMP` destination. | ||
| 3. The bundle is read back before `actions/upload-artifact@v7`. | ||
| 4. The uploaded artifact is downloaded to a separate temporary directory and read back again, bound to the build evidence by full base SHA, source metadata, and all three file hashes. | ||
|
|
||
| Both build and download harnesses fail closed unless the exact contract is schema `"5"`, manifest contract `model_recommendations.v5`, cadence `daily`, and bundle contract `qar.preview_bundle.v1`. | ||
|
|
||
| Evidence explicitly identifies `source_kind=repository_representative_fixture`; it is not live producer or production-trusted evidence. The workflow does not modify weekly/monthly workflows, publisher, archive/feed, Pages, identity, or persistence. Issue #50 remains the production-trust hardening gate. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| #!/usr/bin/env python3 | ||
| """Build and locally verify the D3 representative daily preview artifact.""" | ||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import hashlib | ||
| import json | ||
| import re | ||
| import shutil | ||
| import sys | ||
| import tempfile | ||
| from pathlib import Path | ||
| from unittest.mock import patch | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | ||
|
|
||
| from quant_advisor_research import advisory_report | ||
| from quant_advisor_research.preview_bundle import PreviewBundleError, build_preview_bundle, read_preview_bundle | ||
|
|
||
|
|
||
| BASE_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") | ||
| SOURCE_KIND = "repository_representative_fixture" | ||
| SOURCE_SCHEMA_VERSION = "5" | ||
| SOURCE_CONTRACT_VERSION = "model_recommendations.v5" | ||
| BUNDLE_CONTRACT = "qar.preview_bundle.v1" | ||
| CHECKS = [ | ||
| "exact_three_files", | ||
| "canonical_json_readback", | ||
| "manifest_hashes", | ||
| "manifest_source_pair", | ||
| "relative_html_links", | ||
| "repeat_build_bytes", | ||
| ] | ||
|
|
||
|
|
||
| def _require_base_sha(value: str) -> str: | ||
| if BASE_SHA_PATTERN.fullmatch(value) is None: | ||
| raise PreviewBundleError("base_sha_invalid") | ||
| return value | ||
|
|
||
|
|
||
| def _sha256(path: Path) -> str: | ||
| return hashlib.sha256(path.read_bytes()).hexdigest() | ||
|
|
||
|
|
||
| def _require_contract(report: dict[str, object], manifest: dict[str, object]) -> None: | ||
| source = manifest.get("source") | ||
| if not isinstance(source, dict): | ||
| raise PreviewBundleError("contract_drift") | ||
| if report.get("schema_version") != SOURCE_SCHEMA_VERSION or report.get("cadence") != "daily": | ||
| raise PreviewBundleError("contract_drift") | ||
| if manifest.get("bundle_contract") != BUNDLE_CONTRACT: | ||
| raise PreviewBundleError("contract_drift") | ||
| expected_source = { | ||
| "schema_version": SOURCE_SCHEMA_VERSION, | ||
| "contract_version": SOURCE_CONTRACT_VERSION, | ||
| "cadence": "daily", | ||
| "as_of": report.get("as_of"), | ||
| "generated_at": report.get("generated_at"), | ||
| } | ||
| if source != expected_source: | ||
| raise PreviewBundleError("contract_drift") | ||
|
|
||
|
|
||
| def _evidence( | ||
| *, output: Path, report: dict[str, object], manifest: dict[str, object], base_sha: str, repeat_equal: bool | ||
| ) -> dict[str, object]: | ||
| return { | ||
| "source_kind": SOURCE_KIND, | ||
| "base_sha": base_sha, | ||
| "bundle_contract": BUNDLE_CONTRACT, | ||
| "source": { | ||
| "cadence": report["cadence"], | ||
| "as_of": report["as_of"], | ||
| "generated_at": report["generated_at"], | ||
| "schema_version": report["schema_version"], | ||
| }, | ||
| "provenance": { | ||
| "political_events": "examples/political_events.example.csv", | ||
| "political_watchlist": "examples/political_watchlist.example.csv", | ||
| }, | ||
| "files": sorted(path.name for path in output.iterdir()), | ||
| "sha256": {name: _sha256(output / name) for name in ("manifest.json", "report.html", "report.json")}, | ||
| "manifest_source": manifest["source"], | ||
| "html_links": ["report.json", "manifest.json"], | ||
| "checks": CHECKS, | ||
| "repeat_build_bytes": repeat_equal, | ||
| "deterministic_clock": {"mode": "frozen_harness", "generated_at": report["generated_at"]}, | ||
| } | ||
|
|
||
|
|
||
| def build(args: argparse.Namespace) -> None: | ||
| base_sha = _require_base_sha(args.base_sha) | ||
| output = Path(args.artifact_dir) | ||
| events = Path(args.political_events) | ||
| watchlist = Path(args.political_watchlist) | ||
| if not events.is_file() or not watchlist.is_file(): | ||
| raise PreviewBundleError("fixture_missing") | ||
| if not args.frozen_generated_at: | ||
| raise PreviewBundleError("frozen_generated_at_required") | ||
| repeat_parent = Path(tempfile.mkdtemp(prefix=f".{output.name}.repeat-", dir=output.parent)) | ||
| try: | ||
| repeat_output = repeat_parent / "preview" | ||
| with patch.object(advisory_report, "utc_now_iso", return_value=args.frozen_generated_at): | ||
| first_report = advisory_report.build_advisory_report( | ||
| as_of=args.as_of, | ||
| cadence=args.cadence, | ||
| political_events_path=events, | ||
| political_watchlist_path=watchlist, | ||
| ) | ||
| build_preview_bundle(first_report, output) | ||
| first_evidence = read_preview_bundle(output) | ||
| second_report = advisory_report.build_advisory_report( | ||
| as_of=args.as_of, | ||
| cadence=args.cadence, | ||
| political_events_path=events, | ||
| political_watchlist_path=watchlist, | ||
| ) | ||
| build_preview_bundle(second_report, repeat_output) | ||
| evidence = first_evidence | ||
| _require_contract(dict(evidence.report), dict(evidence.manifest)) | ||
| repeat_equal = all( | ||
| (output / name).read_bytes() == (repeat_output / name).read_bytes() | ||
| for name in ("manifest.json", "report.html", "report.json") | ||
| ) | ||
| finally: | ||
| shutil.rmtree(repeat_parent, ignore_errors=True) | ||
| if not repeat_equal: | ||
| raise PreviewBundleError("repeat_build_non_deterministic") | ||
| payload = _evidence( | ||
| output=output, | ||
| report=dict(evidence.report), | ||
| manifest=dict(evidence.manifest), | ||
| base_sha=base_sha, | ||
| repeat_equal=repeat_equal, | ||
| ) | ||
| evidence_path = Path(args.evidence_path) | ||
| if evidence_path.exists(): | ||
| raise PreviewBundleError("evidence_exists") | ||
| evidence_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") | ||
| print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) | ||
|
|
||
|
|
||
| def parser() -> argparse.ArgumentParser: | ||
| result = argparse.ArgumentParser() | ||
| result.add_argument("--as-of", required=True) | ||
| result.add_argument("--cadence", choices=("daily", "weekly", "monthly"), default="daily") | ||
| result.add_argument("--political-events", required=True) | ||
| result.add_argument("--political-watchlist", required=True) | ||
| result.add_argument("--artifact-dir", required=True) | ||
| result.add_argument("--evidence-path", required=True) | ||
| result.add_argument("--base-sha", required=True) | ||
| result.add_argument("--frozen-generated-at", required=True) | ||
| return result | ||
|
|
||
|
|
||
| def main() -> None: | ||
| try: | ||
| build(parser().parse_args()) | ||
| except PreviewBundleError as exc: | ||
| print(f"daily_preview_build_failed:{exc.code}", file=sys.stderr) | ||
| raise SystemExit(1) from None | ||
| except (OSError, TypeError, ValueError, UnicodeError): | ||
| print("daily_preview_build_failed", file=sys.stderr) | ||
| raise SystemExit(1) from None | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| #!/usr/bin/env python3 | ||
| """Read back a downloaded D3 preview artifact without production integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import hashlib | ||
| import json | ||
| import re | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | ||
|
|
||
| from quant_advisor_research.preview_bundle import PreviewBundleError, read_preview_bundle | ||
|
|
||
|
|
||
| BASE_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") | ||
| SOURCE_KIND = "downloaded_repository_representative_fixture" | ||
| SOURCE_SCHEMA_VERSION = "5" | ||
| SOURCE_CONTRACT_VERSION = "model_recommendations.v5" | ||
| BUNDLE_CONTRACT = "qar.preview_bundle.v1" | ||
|
|
||
|
|
||
| def verify(args: argparse.Namespace) -> None: | ||
| if BASE_SHA_PATTERN.fullmatch(args.base_sha) is None: | ||
| raise PreviewBundleError("base_sha_invalid") | ||
| output = Path(args.artifact_dir) | ||
| evidence = read_preview_bundle(output) | ||
| report = dict(evidence.report) | ||
| manifest = dict(evidence.manifest) | ||
| source = manifest.get("source") | ||
| expected_source = { | ||
| "schema_version": SOURCE_SCHEMA_VERSION, | ||
| "contract_version": SOURCE_CONTRACT_VERSION, | ||
| "cadence": "daily", | ||
| "as_of": report.get("as_of"), | ||
| "generated_at": report.get("generated_at"), | ||
| } | ||
| if ( | ||
| not isinstance(source, dict) | ||
| or report.get("schema_version") != SOURCE_SCHEMA_VERSION | ||
| or report.get("cadence") != "daily" | ||
| or manifest.get("bundle_contract") != BUNDLE_CONTRACT | ||
| or source != expected_source | ||
| ): | ||
| raise PreviewBundleError("contract_drift") | ||
| try: | ||
| build_evidence = json.loads(Path(args.build_evidence_path).read_text(encoding="utf-8")) | ||
| except (OSError, TypeError, ValueError, UnicodeError): | ||
| raise PreviewBundleError("build_evidence_invalid") from None | ||
| if not isinstance(build_evidence, dict): | ||
| raise PreviewBundleError("build_evidence_invalid") | ||
| if ( | ||
| build_evidence.get("source_kind") != "repository_representative_fixture" | ||
| or build_evidence.get("base_sha") != args.base_sha | ||
| or build_evidence.get("bundle_contract") != BUNDLE_CONTRACT | ||
| or build_evidence.get("manifest_source") != source | ||
| ): | ||
| raise PreviewBundleError("build_evidence_mismatch") | ||
| expected_hashes = build_evidence.get("sha256") | ||
| if not isinstance(expected_hashes, dict): | ||
| raise PreviewBundleError("build_evidence_invalid") | ||
| actual_hashes = { | ||
| name: hashlib.sha256((output / name).read_bytes()).hexdigest() | ||
| for name in ("manifest.json", "report.html", "report.json") | ||
| } | ||
| if ( | ||
| expected_hashes != actual_hashes | ||
| or build_evidence.get("files") != ["manifest.json", "report.html", "report.json"] | ||
| or build_evidence.get("source") != { | ||
| "cadence": "daily", | ||
| "as_of": report.get("as_of"), | ||
| "generated_at": report.get("generated_at"), | ||
| "schema_version": SOURCE_SCHEMA_VERSION, | ||
| } | ||
| ): | ||
| raise PreviewBundleError("build_evidence_mismatch") | ||
| payload = { | ||
| "source_kind": SOURCE_KIND, | ||
| "base_sha": args.base_sha, | ||
| "bundle_contract": BUNDLE_CONTRACT, | ||
| "source": source, | ||
| "files": sorted(path.name for path in output.iterdir()), | ||
| "sha256": { | ||
| name: hashlib.sha256((output / name).read_bytes()).hexdigest() | ||
| for name in ("manifest.json", "report.html", "report.json") | ||
| }, | ||
| "checks": [ | ||
| "exact_three_files", | ||
| "canonical_json_readback", | ||
| "manifest_hashes", | ||
| "manifest_source_pair", | ||
| "relative_html_links", | ||
| ], | ||
| "build_evidence_bound": True, | ||
| } | ||
| evidence_path = Path(args.evidence_path) | ||
| if evidence_path.exists(): | ||
| raise PreviewBundleError("evidence_exists") | ||
| evidence_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") | ||
| print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) | ||
|
|
||
|
|
||
| def parser() -> argparse.ArgumentParser: | ||
| result = argparse.ArgumentParser() | ||
| result.add_argument("--artifact-dir", required=True) | ||
| result.add_argument("--evidence-path", required=True) | ||
| result.add_argument("--build-evidence-path", required=True) | ||
| result.add_argument("--base-sha", required=True) | ||
| return result | ||
|
|
||
|
|
||
| def main() -> None: | ||
| try: | ||
| verify(parser().parse_args()) | ||
| except (PreviewBundleError, OSError, TypeError, ValueError, UnicodeError): | ||
| print("daily_preview_readback_failed", file=sys.stderr) | ||
| raise SystemExit(1) from None | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the
pull_requestworkflow path filter I checked, changes to transitive validation code such assrc/quant_advisor_research/contracts.pydo not trigger this job, even thoughpreview_bundle.pyimportsvalidate_advisory_reportfrom that module forbuild_preview_bundle()/read_preview_bundle()validation. A PR that only changes the contract/schema validation can therefore bypass the upload/download artifact acceptance; please include the transitive modules or broaden thesrc/quant_advisor_research/**filter.Useful? React with 👍 / 👎.