diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2e8f4b6..f15f6b2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,8 +3,7 @@ updates: - package-ecosystem: pip directory: / schedule: - interval: weekly - day: monday + interval: monthly time: "09:00" timezone: America/Los_Angeles open-pull-requests-limit: 5 diff --git a/CLAUDE.md b/CLAUDE.md index efdf42c..81a7659 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,11 @@ the development lock with `pip install -r requirements/dev.lock`, then run - Lint: `.venv/bin/ruff check .` (CI enforces this; must stay clean). Auto-fix: `ruff check . --fix`. - Dependency changes: edit `pyproject.toml`, then run `scripts/update_dependency_locks.sh`; commit `poetry.lock` and every changed - export under `requirements/`. + export under `requirements/`. Because `pyproject.toml` and `poetry.lock` are + release-critical fingerprinted inputs, a dependency change also requires + regenerating both artifact releases from the new clean source commit + (expansion wrapper first, then the paper wrapper); Dependabot lock-only PRs + therefore cannot pass CI and are handled through this coordinated flow. - Operational scripts import `quantcortex.*`, so run them with the root on the path: `PYTHONPATH=. .venv/bin/python scripts/.py` (validate_performance, generate_report, survivorship_demo, verify_brokers, diff --git a/docs/img/performance_manifest.json b/docs/img/performance_manifest.json index 60f9f1f..d494aa7 100644 --- a/docs/img/performance_manifest.json +++ b/docs/img/performance_manifest.json @@ -1,11 +1,11 @@ { "schema_version": 4, - "generated_at": "2026-07-02T05:47:22Z", + "generated_at": "2026-07-02T06:57:57Z", "generator": { "path": "scripts/generate_report.py", "script_sha256": "b536aa7fc5e4fe7df6c7ff28c0992629a489869eaec46486db7aff1cb946099b", "git": { - "source_commit": "dddead6351d956223e2c7aecf959dd2d93388be9", + "source_commit": "34096ad11c1ae33531186a86961a9e2e883d60a9", "worktree_clean_at_start": true }, "source_tree": { diff --git a/paper/build_manifest.json b/paper/build_manifest.json index 4c6bcb4..288bfd0 100644 --- a/paper/build_manifest.json +++ b/paper/build_manifest.json @@ -1,18 +1,18 @@ { "anonymous_pdf": { "path": "quantcortex_audit_anonymous.pdf", - "sha256": "7a7e5283b098957602eea34e38dde3ffe5b2968a622f4789e0ad8bdbe0c3bbb6" + "sha256": "74bd8f67606e4bad177b81c49f3818fd2682abceef3b39cb279bb497d5b8d062" }, "pdf": { "path": "quantcortex_audit_neurips2026.pdf", - "sha256": "cf50fea34c2020061d86815c586106de87f26c9e29af46336524b899d1365cc2" + "sha256": "c2a16206747a6f9205396cb040d7cd01d0f5306319570286a218d9072388e7af" }, "schema_version": 1, - "source_commit": "dddead6351d956223e2c7aecf959dd2d93388be9", - "source_date_epoch": 1782970812, + "source_commit": "34096ad11c1ae33531186a86961a9e2e883d60a9", + "source_date_epoch": 1782975290, "source_manifest": { "path": "quantcortex_audit_neurips2026.sources.sha256", - "sha256": "39e28d3967612a086eaab5d81bbb69023dbb939900c119f42d28cab3ec48a631" + "sha256": "e550f507515e06a0ee005ec23b828d0ed346230841a99c718063589249c3d443" }, "tectonic_bundle": { "name": "default_bundle_v33.tar", diff --git a/paper/expansion/results/manifest.json b/paper/expansion/results/manifest.json index 34f753a..26eff98 100644 --- a/paper/expansion/results/manifest.json +++ b/paper/expansion/results/manifest.json @@ -163,9 +163,9 @@ } ] }, - "generated_at": "2026-07-02T05:40:12Z", + "generated_at": "2026-07-02T06:54:51Z", "git": { - "source_commit": "dddead6351d956223e2c7aecf959dd2d93388be9", + "source_commit": "34096ad11c1ae33531186a86961a9e2e883d60a9", "tracked_worktree_clean_at_start": true }, "protocol": { @@ -290,9 +290,9 @@ "quantcortex/timing/vix_scaler.py": "a3667424e5573fb289e63c26c69da6a68d6c943742359f0466d29b25c56e3686", "schemas/canonical_target_tape.schema.json": "4f1c0bf6d5360305d2982adea78de3f61c4bc1ebae9207cb2ba2bd4379b43d44", "scripts/fetch_expansion_data.py": "678b5c7fcc1b89e333fc5298b1fdaeb8994de713bc7b2b5ed461e1ec1eb94403", - "scripts/release_expansion_artifacts.sh": "c66de150012bc2ad4ea06f65e2f8993a564c003c717e3b220cf6c2f665d363e0", + "scripts/release_expansion_artifacts.sh": "727e7b9c023df48550481f38887624e5c6154217e279af2d4e77da11b93c7400", "scripts/run_expansion_experiments.py": "df9932dc67a1e1151faebc5dfd742f0aa622dfcf8093ce5ef9c55d9be4fbaf59" }, - "sha256": "3aeaab12c2c63f8022700be3a45978b4ca4f429703fde4566877eb4f2c830f0f" + "sha256": "3cea145651a58cdbb70509df5f53c56ff22d6ddf07c3bbe282280940d918777f" } } diff --git a/paper/quantcortex_audit_anonymous.pdf b/paper/quantcortex_audit_anonymous.pdf index cfd8337..a19d2bc 100644 Binary files a/paper/quantcortex_audit_anonymous.pdf and b/paper/quantcortex_audit_anonymous.pdf differ diff --git a/paper/quantcortex_audit_anonymous.sha256 b/paper/quantcortex_audit_anonymous.sha256 index 42b9e73..8f5a4b6 100644 --- a/paper/quantcortex_audit_anonymous.sha256 +++ b/paper/quantcortex_audit_anonymous.sha256 @@ -1 +1 @@ -7a7e5283b098957602eea34e38dde3ffe5b2968a622f4789e0ad8bdbe0c3bbb6 quantcortex_audit_anonymous.pdf +74bd8f67606e4bad177b81c49f3818fd2682abceef3b39cb279bb497d5b8d062 quantcortex_audit_anonymous.pdf diff --git a/paper/quantcortex_audit_neurips2026.pdf b/paper/quantcortex_audit_neurips2026.pdf index baf7f02..3ff054a 100644 Binary files a/paper/quantcortex_audit_neurips2026.pdf and b/paper/quantcortex_audit_neurips2026.pdf differ diff --git a/paper/quantcortex_audit_neurips2026.sha256 b/paper/quantcortex_audit_neurips2026.sha256 index 3281168..311f488 100644 --- a/paper/quantcortex_audit_neurips2026.sha256 +++ b/paper/quantcortex_audit_neurips2026.sha256 @@ -1 +1 @@ -cf50fea34c2020061d86815c586106de87f26c9e29af46336524b899d1365cc2 quantcortex_audit_neurips2026.pdf +c2a16206747a6f9205396cb040d7cd01d0f5306319570286a218d9072388e7af quantcortex_audit_neurips2026.pdf diff --git a/paper/quantcortex_audit_neurips2026.sources.sha256 b/paper/quantcortex_audit_neurips2026.sources.sha256 index 0282f6a..58d039c 100644 --- a/paper/quantcortex_audit_neurips2026.sources.sha256 +++ b/paper/quantcortex_audit_neurips2026.sources.sha256 @@ -4,11 +4,11 @@ c2b36aafee0ad2e3ac631e05a5fc1b20e1acce10dd0b4758667f0a809cafff51 checklist.tex 62609e68cbc90516cf19a46d52f80bf07ab0a4751880e4c078cc9be4ba842a5c references.bib 0c1ad36961fcd9198dcc2558cf2793e1df39973bde8264fd701f5e7970672757 neurips_2026.sty 06f4407daed7bcd594e00bbf2751e7bd32c5d00eae530b2a6b2f66625b864162 preregistration.md -32e1a703329d6767d4184a940009c158a723738ba53c7de0ecc57bfac861da9b results/generated_values.tex -105d350319bac9a92bfb746d10da7414ad7de43140b69f6987ec5b54eca4c713 results/manifest.json +4cee7de114227526d83abaae47a0c647451a864056955e91d099f846dfc4dfc4 results/generated_values.tex +046e5cc9146f4344c144b9fa947e8b18d6f8c2b7a2bce26c5cc96f03c4d5576c results/manifest.json e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c expansion/protocol.json 2dbaa11bfdd9a1936b45114f61bd96c53e3d57eefe103a75c488352486c0e2f9 expansion/results/generated_values.tex -1db304ba19a96ac6890c3b6bec2bb7dc13793bf1298af990712b03ce617c4066 expansion/results/manifest.json +8c9cfa381e4382bd7fe45af82e9f9d6a1a4c0503fd35db824053381e5c8d16a8 expansion/results/manifest.json 18608aa1250c4554b2e27b507211f764fdf1ec1fb8e4b9f09e080601505e2e3a figures/accounting_summary.pdf 6765f7d1f827577ba648af7545d006ef7f005f0091194605c6130100215ac18c figures/audit_protocol.pdf 19df4c9eabe88add863ed4be4e9315aa0084b833be9deb4d5bb7633e769ced07 figures/bootstrap_robustness.pdf diff --git a/paper/results/generated_values.tex b/paper/results/generated_values.tex index d83ba96..d8ea7e8 100644 --- a/paper/results/generated_values.tex +++ b/paper/results/generated_values.tex @@ -4,7 +4,7 @@ \newcommand{\PaperRequiredWarmupSessions}{274} \newcommand{\PaperBootstrapReplications}{5,000} \newcommand{\PaperInputDigest}{efb384a62157e56a0cd8065abf45c1ed07d90ec26c681e5d54d74fe4cb9c55e1} -\newcommand{\PaperSourceTreeDigest}{5bf7019e1a5f23262701fa5d8e02780c9f274a9b12821a4323818fd282efd295} +\newcommand{\PaperSourceTreeDigest}{1c05d00cea1d0e9acc5118c3e9302fc50111a327ed15c48e25019a40cecec5b8} \newcommand{\PaperNetCAGR}{1.40\%} \newcommand{\PaperGrossCAGR}{3.17\%} \newcommand{\PaperCashCAGR}{2.50\%} diff --git a/paper/results/manifest.json b/paper/results/manifest.json index 05f5a98..bb2a5dd 100644 --- a/paper/results/manifest.json +++ b/paper/results/manifest.json @@ -20,7 +20,7 @@ "results/cost_sensitivity.csv": "25b9969cf8ecdabd19e7761ad2973252c9f5b9f994b6dc147595a2a112c24d88", "results/engine_comparison.csv": "ea0322dcd2aa4a1eb3b5996045c3a4b2ed25a85684a578ee788b013b99b643cd", "results/evaluation_contract.json": "77ee05ce64622ef9ba1bfbd7dae85c4f6fd44f07db0b21feaf6b3e0e418673e7", - "results/generated_values.tex": "32e1a703329d6767d4184a940009c158a723738ba53c7de0ecc57bfac861da9b", + "results/generated_values.tex": "4cee7de114227526d83abaae47a0c647451a864056955e91d099f846dfc4dfc4", "results/protocol_switches.csv": "20d5a0dc37e07a20c1f2772c8e9940ff6464ef1da98692edd5fdb06531bc2393", "results/return_decomposition.csv": "53a84aa9b91c92f0037e48dc09154807cbf003ccf0038f39da8406108bf709cc", "results/sharpe_uncertainty.csv": "aead873ced9b25c76c6944aa6e3ba0901ade65c784fa5cb8cc10ad1c6c01f136", @@ -240,14 +240,14 @@ "path": "results/evaluation_contract.json", "schema_version": 1 }, - "generated_at": "2026-07-02T05:47:22Z", + "generated_at": "2026-07-02T06:57:57Z", "generator": { "dependency_lock": { "path": "poetry.lock", "sha256": "d4e2e756f8ba3ca67ca0e7592c56d1e3c42303fd8b5af06dd52f25658d9e6ceb" }, "git": { - "source_commit": "dddead6351d956223e2c7aecf959dd2d93388be9", + "source_commit": "34096ad11c1ae33531186a86961a9e2e883d60a9", "worktree_clean_at_start": true }, "packages": { @@ -261,7 +261,7 @@ "path": "scripts/run_paper_experiments.py", "platform": "macOS-26.4-arm64-arm-64bit-Mach-O", "python": "3.14.4", - "script_sha256": "922bf3c414e0eadca5c13ee831528347dd83a0cd174efbdf15a987b511845de5", + "script_sha256": "25010ce7302d1113e48fb7095dabf1e0bb5b283ba9270455cdbf55382718530e", "source_tree": { "file_count": 112, "files": { @@ -375,10 +375,10 @@ "quantcortex/timing/vix_scaler.py": "a3667424e5573fb289e63c26c69da6a68d6c943742359f0466d29b25c56e3686", "schemas/canonical_target_tape.schema.json": "4f1c0bf6d5360305d2982adea78de3f61c4bc1ebae9207cb2ba2bd4379b43d44", "schemas/evaluation_contract.schema.json": "970f24f587e669925306625d12c5a84dffd03ff5b222a59905849b2fa222784f", - "scripts/release_paper_artifacts.sh": "410b1f444ce242b94fa9ba7bcf5868fcc74ad664f2c24f9503d4f3a230e8b38c", - "scripts/run_paper_experiments.py": "922bf3c414e0eadca5c13ee831528347dd83a0cd174efbdf15a987b511845de5" + "scripts/release_paper_artifacts.sh": "fbf68e79be2479e089e638d88923de3aa675dacbfc1e180ffe75b2a05eaecf39", + "scripts/run_paper_experiments.py": "25010ce7302d1113e48fb7095dabf1e0bb5b283ba9270455cdbf55382718530e" }, - "sha256": "5bf7019e1a5f23262701fa5d8e02780c9f274a9b12821a4323818fd282efd295" + "sha256": "1c05d00cea1d0e9acc5118c3e9302fc50111a327ed15c48e25019a40cecec5b8" }, "threadpools": [ { diff --git a/scripts/release_expansion_artifacts.sh b/scripts/release_expansion_artifacts.sh index 273ecdc..afba8d3 100755 --- a/scripts/release_expansion_artifacts.sh +++ b/scripts/release_expansion_artifacts.sh @@ -9,10 +9,12 @@ if [[ ! -x "${python_bin}" ]]; then printf '%s\n' "Python environment not found: ${python_bin}" >&2 exit 1 fi -# Generation runs from a detached worktree at the source commit, so only -# uncommitted changes to release-critical source can corrupt a release. -# Scoping the cleanliness check to those paths keeps the wrapper rerunnable -# while regenerated artifacts sit uncommitted in the working tree. +# Generation runs from a detached worktree at the source commit, so +# uncommitted changes outside release-critical source cannot alter what is +# generated here. Scoping the cleanliness check to those paths keeps the +# wrapper rerunnable while regenerated artifacts sit uncommitted in the +# working tree; the paper wrapper independently verifies those artifacts +# against this release's manifest before republishing them. release_source_paths=( quantcortex schemas/canonical_target_tape.schema.json diff --git a/scripts/release_paper_artifacts.sh b/scripts/release_paper_artifacts.sh index 2470a77..390f67b 100755 --- a/scripts/release_paper_artifacts.sh +++ b/scripts/release_paper_artifacts.sh @@ -10,11 +10,13 @@ if [[ ! -x "${python_bin}" ]]; then exit 1 fi -# Generation runs from a detached worktree at the source commit, so only -# uncommitted changes to release-critical source can corrupt a release. -# Scoping the cleanliness check to those paths lets the paper release run -# after the expansion wrapper has deposited regenerated artifacts in the -# working tree, which the documented dual-release flow requires. +# Generation runs from a detached worktree at the source commit, so +# uncommitted changes outside release-critical source cannot alter what is +# generated. Scoping the cleanliness check to those paths lets the paper +# release run after the expansion wrapper has deposited regenerated artifacts +# in the working tree, which the documented dual-release flow requires. The +# expansion artifacts that this release republishes are separately verified +# against the expansion manifest below before they are copied. release_source_paths=( quantcortex schemas @@ -250,6 +252,16 @@ if [[ "${expansion_source_commit}" != "${source_commit}" ]]; then "expansion source: ${expansion_source_commit}" >&2 exit 1 fi +# The copied expansion artifacts are republished inside the built PDFs, so +# verify every file against the expansion manifest before copying. A tampered +# or unexpected expansion output must fail the release, not flow into print. +PYTHONPATH="${repo_root}" "${python_bin}" - "${repo_root}/paper/expansion" <<'PY' +import sys + +from scripts.run_paper_experiments import verify_expansion_artifacts + +verify_expansion_artifacts(sys.argv[1]) +PY rm -rf "${source_worktree}/paper/expansion/results" \ "${source_worktree}/paper/expansion/figures" cp -R "${repo_root}/paper/expansion/results" \ diff --git a/scripts/run_paper_experiments.py b/scripts/run_paper_experiments.py index 9ef0870..e04da9b 100644 --- a/scripts/run_paper_experiments.py +++ b/scripts/run_paper_experiments.py @@ -203,6 +203,61 @@ def _json_sha256(value: object) -> str: return hashlib.sha256(encoded).hexdigest() +def verify_expansion_artifacts(expansion_root: Path) -> None: + """Fail closed unless the expansion artifact tree matches its manifest. + + The paper release republishes ``paper/expansion/results`` and + ``paper/expansion/figures`` inside the built PDFs, so every file it copies + must be exactly the file the expansion manifest recorded. Rejects absolute + or parent-traversing manifest paths, symlinks, missing files, digest + mismatches, and files present on disk that the manifest does not list + (``results/manifest.json`` itself is the only exception, because it cannot + record its own hash). + """ + expansion_root = Path(expansion_root) + manifest_path = expansion_root / "results" / "manifest.json" + if manifest_path.is_symlink() or not manifest_path.is_file(): + raise SystemExit(f"expansion manifest missing: {manifest_path}") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict) or not artifacts: + raise SystemExit("expansion manifest lists no artifacts") + + resolved_root = expansion_root.resolve() + for relative, expected in artifacts.items(): + candidate = Path(relative) + if candidate.is_absolute() or ".." in candidate.parts: + raise SystemExit(f"unsafe expansion artifact path: {relative}") + artifact = expansion_root / candidate + if artifact.is_symlink(): + raise SystemExit(f"expansion artifact is a symlink: {relative}") + if not artifact.is_file(): + raise SystemExit(f"expansion artifact missing: {relative}") + if not artifact.resolve().is_relative_to(resolved_root): + raise SystemExit(f"unsafe expansion artifact path: {relative}") + if _sha256(artifact) != expected: + raise SystemExit(f"expansion artifact digest mismatch: {relative}") + + allowed = {Path(relative) for relative in artifacts} + allowed.add(Path("results/manifest.json")) + for subdirectory in ("results", "figures"): + directory = expansion_root / subdirectory + if not directory.is_dir(): + raise SystemExit(f"expansion directory missing: {subdirectory}") + for found in sorted(directory.rglob("*")): + if found.is_symlink(): + raise SystemExit( + "unexpected symlink under expansion artifacts: " + f"{found.relative_to(expansion_root)}" + ) + if found.is_file(): + relative_found = found.relative_to(expansion_root) + if relative_found not in allowed: + raise SystemExit( + f"unexpected expansion artifact: {relative_found}" + ) + + def _threadpool_environment() -> list[dict[str, object]]: """Return stable BLAS/OpenMP metadata without machine-specific paths.""" try: diff --git a/tests/test_paper_artifacts.py b/tests/test_paper_artifacts.py index 1eb73bb..8f83cac 100644 --- a/tests/test_paper_artifacts.py +++ b/tests/test_paper_artifacts.py @@ -8,7 +8,13 @@ import subprocess from pathlib import Path -from scripts.run_paper_experiments import _json_sha256, source_tree_manifest +import pytest + +from scripts.run_paper_experiments import ( + _json_sha256, + source_tree_manifest, + verify_expansion_artifacts, +) REPO_ROOT = Path(__file__).resolve().parent.parent PAPER_ROOT = REPO_ROOT / "paper" @@ -446,3 +452,101 @@ def test_paper_citations_are_defined_used_and_unique(): for eprint in eprints: assert f"https://arxiv.org/abs/{eprint}" in bibliography + + +def _build_expansion_fixture(root: Path) -> Path: + """Create a minimal valid expansion artifact tree with a correct manifest.""" + expansion = root / "expansion" + (expansion / "results").mkdir(parents=True) + (expansion / "figures").mkdir() + (expansion / "results" / "summary.csv").write_text("a,b\n1,2\n", encoding="ascii") + (expansion / "figures" / "plot.pdf").write_bytes(b"%PDF-1.4 fixture") + artifacts = { + "results/summary.csv": _sha256(expansion / "results" / "summary.csv"), + "figures/plot.pdf": _sha256(expansion / "figures" / "plot.pdf"), + } + manifest = {"git": {"source_commit": "0" * 40}, "artifacts": artifacts} + (expansion / "results" / "manifest.json").write_text( + json.dumps(manifest), encoding="ascii" + ) + return expansion + + +def test_verify_expansion_artifacts_accepts_valid_tree(tmp_path): + expansion = _build_expansion_fixture(tmp_path) + verify_expansion_artifacts(expansion) + + +def test_verify_expansion_artifacts_rejects_tampered_artifact(tmp_path): + # Regression: the paper release once copied expansion artifacts into the + # built PDFs after checking only the manifest's source commit, so a + # tampered aggregate (for example an edited generated value) was published + # without any digest failure. + expansion = _build_expansion_fixture(tmp_path) + (expansion / "results" / "summary.csv").write_text("a,b\n9,9\n", encoding="ascii") + with pytest.raises(SystemExit, match="digest mismatch"): + verify_expansion_artifacts(expansion) + + +def test_verify_expansion_artifacts_rejects_missing_artifact(tmp_path): + expansion = _build_expansion_fixture(tmp_path) + (expansion / "figures" / "plot.pdf").unlink() + with pytest.raises(SystemExit, match="missing"): + verify_expansion_artifacts(expansion) + + +def test_verify_expansion_artifacts_rejects_unexpected_file(tmp_path): + expansion = _build_expansion_fixture(tmp_path) + (expansion / "results" / "extra.csv").write_text("x\n", encoding="ascii") + with pytest.raises(SystemExit, match="unexpected expansion artifact"): + verify_expansion_artifacts(expansion) + + +def test_verify_expansion_artifacts_rejects_symlink(tmp_path): + expansion = _build_expansion_fixture(tmp_path) + target = expansion / "results" / "summary.csv" + link = expansion / "figures" / "plot.pdf" + link.unlink() + link.symlink_to(target) + with pytest.raises(SystemExit, match="symlink"): + verify_expansion_artifacts(expansion) + + +def test_verify_expansion_artifacts_rejects_unsafe_manifest_paths(tmp_path): + expansion = _build_expansion_fixture(tmp_path) + manifest_path = expansion / "results" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="ascii")) + + outside = tmp_path / "outside.txt" + outside.write_text("leak\n", encoding="ascii") + for unsafe in ("../outside.txt", str(outside)): + tampered = dict(manifest) + tampered["artifacts"] = dict(manifest["artifacts"]) + tampered["artifacts"][unsafe] = _sha256(outside) + manifest_path.write_text(json.dumps(tampered), encoding="ascii") + with pytest.raises(SystemExit, match="unsafe"): + verify_expansion_artifacts(expansion) + + +def test_verify_expansion_artifacts_rejects_empty_manifest(tmp_path): + expansion = _build_expansion_fixture(tmp_path) + manifest_path = expansion / "results" / "manifest.json" + manifest_path.write_text(json.dumps({"artifacts": {}}), encoding="ascii") + with pytest.raises(SystemExit, match="lists no artifacts"): + verify_expansion_artifacts(expansion) + + +def test_release_wrapper_verifies_expansion_artifacts_before_copying(): + release_script = (REPO_ROOT / "scripts" / "release_paper_artifacts.sh").read_text( + encoding="utf-8" + ) + assert "verify_expansion_artifacts" in release_script + # The verification must run before the expansion artifacts are copied into + # the detached build worktree. + assert release_script.index("verify_expansion_artifacts") < release_script.index( + 'cp -R "${repo_root}/paper/expansion/results"' + ) + + +def test_committed_expansion_artifacts_validate(): + verify_expansion_artifacts(PAPER_ROOT / "expansion")