pytest-gpu-proof 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/CHANGELOG.md +15 -0
  2. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/PKG-INFO +1 -1
  3. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/security_model.md +12 -0
  4. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/sharding.md +47 -0
  5. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/pyproject.toml +1 -1
  6. pytest_gpu_proof-0.3.0/src/pytest_gpu_proof/__init__.py +1 -0
  7. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/cli.py +11 -0
  8. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/config.py +15 -0
  9. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/merge.py +113 -3
  10. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/plugin.py +15 -0
  11. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/receipt.py +25 -2
  12. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/verify.py +74 -1
  13. pytest_gpu_proof-0.3.0/tests/test_sharding.py +282 -0
  14. pytest_gpu_proof-0.2.0/src/pytest_gpu_proof/__init__.py +0 -1
  15. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.github/workflows/ci.yml +0 -0
  16. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.github/workflows/docs.yml +0 -0
  17. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.github/workflows/publish-testpypi.yml +0 -0
  18. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.github/workflows/publish.yml +0 -0
  19. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.gitignore +0 -0
  20. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/CLAUDE.md +0 -0
  21. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/LICENSE +0 -0
  22. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/README.md +0 -0
  23. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/RELEASING.md +0 -0
  24. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/ROADMAP.md +0 -0
  25. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/architecture.md +0 -0
  26. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/ci_gpu_mode.md +0 -0
  27. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/index.md +0 -0
  28. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/landscape.md +0 -0
  29. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/local_mode.md +0 -0
  30. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/quickstart.md +0 -0
  31. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/Makefile +0 -0
  32. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/README.md +0 -0
  33. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/cuda_ctypes_matmul.py +0 -0
  34. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/matmul_kernel.cu +0 -0
  35. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/test_cuda_ctypes_matmul.py +0 -0
  36. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/CMakeLists.txt +0 -0
  37. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/README.md +0 -0
  38. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/bindings.cpp +0 -0
  39. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_cuda.h +0 -0
  40. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_kernel.cu +0 -0
  41. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/test_cuda_nanobind_matmul.py +0 -0
  42. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/CMakeLists.txt +0 -0
  43. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/README.md +0 -0
  44. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/bindings.cpp +0 -0
  45. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_cuda.h +0 -0
  46. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_kernel.cu +0 -0
  47. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/test_cuda_pybind11_matmul.py +0 -0
  48. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/github_gpu_runner/.github/workflows/gpu-test.yml +0 -0
  49. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/CMakeLists.txt +0 -0
  50. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/README.md +0 -0
  51. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/jax_ffi_matmul.py +0 -0
  52. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/src/matmul_ffi.cu +0 -0
  53. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/test_jax_ffi_cuda_matmul.py +0 -0
  54. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/local_receipt_verify/.github/workflows/verify.yml +0 -0
  55. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/conftest.py +0 -0
  56. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/test_minimal.py +0 -0
  57. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/fake_cuda_lib.py +0 -0
  58. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/test_cuda_wrapper.py +0 -0
  59. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/install.sh +0 -0
  60. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/mkdocs.yml +0 -0
  61. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/requirements-dev.txt +0 -0
  62. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/requirements.txt +0 -0
  63. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/__main__.py +0 -0
  64. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/compare.py +0 -0
  65. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/fingerprint.py +0 -0
  66. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/gitutils.py +0 -0
  67. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/__init__.py +0 -0
  68. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/base.py +0 -0
  69. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/ed25519.py +0 -0
  70. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/conftest.py +0 -0
  71. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_compare.py +0 -0
  72. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_fingerprint.py +0 -0
  73. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_gitutils.py +0 -0
  74. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_merge.py +0 -0
  75. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_plugin_capture.py +0 -0
  76. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_receipt.py +0 -0
  77. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_signing.py +0 -0
  78. {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_verifier.py +0 -0
@@ -4,6 +4,21 @@ All notable changes to pytest-gpu-proof are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/); versions follow
5
5
  [SemVer](https://semver.org/) (pre-1.0: minor bumps may break).
6
6
 
7
+ ## [0.3.0] — 2026-08-08
8
+
9
+ ### Added
10
+ - Schema `"2"` sharded receipts: `--gpu-proof-shard NAME` +
11
+ `--gpu-proof-shard-fingerprint-paths` emit a per-shard narrow fingerprint;
12
+ `gpu-proof merge` unions schema-2 shards (unique names enforced).
13
+ - Verifiable carry-forward: `gpu-proof merge --carry-from OLD` grafts shards
14
+ absent from the fresh inputs iff the old commit is an ancestor AND the
15
+ shard's narrow fingerprint recomputes clean; grafted shards are marked
16
+ `carried` and the verifier rejects them unless the policy sets
17
+ `allow_carried: true` (bounded by `carried_max_age_days`, default 30).
18
+ - Verifier: schema-2 checks (per-shard fingerprint recompute, membership
19
+ partition of `tests[]`, carried-shard policy gate). Schema-1 receipts are
20
+ unchanged and a schema-1 receipt carrying a `shards` block is rejected.
21
+
7
22
  ## [0.2.0] — 2026-08-07
8
23
 
9
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-gpu-proof
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys
5
5
  Project-URL: Homepage, https://github.com/A2R-Lab/pytest-gpu-proof
6
6
  Project-URL: Documentation, https://a2r-lab.github.io/pytest-gpu-proof/
@@ -73,3 +73,15 @@ that a specific GPU executed a specific workload in a verified environment.
73
73
  This is out of scope for v1 but the receipt format is designed to be extendable —
74
74
  a `hardware_attestation` block could be added to the `environment` section in a
75
75
  future version without breaking existing receipts.
76
+
77
+ ## Carried shards (schema 2)
78
+
79
+ A carried shard is an attestation about a **prior** run: the tests passed at an
80
+ ancestor commit, and the shard's declared input paths are byte-identical at the
81
+ verified commit (the verifier recomputes this; it is not taken on faith). What
82
+ is NOT re-established: that the prior run's environment still exists, or that
83
+ paths *outside* the shard's declared fingerprint didn't change its behavior —
84
+ declaring too-narrow shard paths weakens the claim, exactly like declaring
85
+ too-narrow global fingerprint paths. That is why `allow_carried` defaults to
86
+ **false**: accepting carried shards is an explicit policy decision, bounded by
87
+ `carried_max_age_days`.
@@ -60,3 +60,50 @@ an **attestation by the merger**. Shard signatures are recorded as provenance
60
60
  but not re-verified at merge time (merging is offline); the merged signature
61
61
  is what CI verifies. If shards were signed by someone else, verification of
62
62
  the merged receipt attests that *you* vouch for the union.
63
+
64
+ ## Per-shard fingerprints & carry-forward (schema 2)
65
+
66
+ Declare each invocation as a **shard** and the receipt becomes schema `"2"`,
67
+ carrying that shard's own *narrow* fingerprint over the paths you declare:
68
+
69
+ ```bash
70
+ pytest tests/gpu/test_a.py --gpu-proof-enable \
71
+ --gpu-proof-shard=test_a \
72
+ --gpu-proof-shard-fingerprint-paths=tests/gpu/test_a.py,src/kernels_a \
73
+ --gpu-proof-out=receipts/a.json
74
+ ```
75
+
76
+ `gpu-proof merge` unions schema-2 shards exactly like schema-1 receipts (shard
77
+ names must be unique). The new capability is **carry-forward**:
78
+
79
+ ```bash
80
+ gpu-proof merge --out gpu-proof.json --carry-from last-green/gpu-proof.json \
81
+ --repo . receipts/*.json
82
+ ```
83
+
84
+ Shards present in the old receipt but absent from the fresh inputs are grafted
85
+ in, **marked `carried`**, iff:
86
+
87
+ 1. the old receipt's commit is an **ancestor** of the fresh one (same history), and
88
+ 2. the shard's narrow fingerprint **recomputes identical** against the current
89
+ tree — the inputs that shard proved are unchanged.
90
+
91
+ A shard whose inputs changed refuses to carry (re-run it). Freshly re-run
92
+ shards always win over old ones.
93
+
94
+ ### Verification of schema-2 receipts
95
+
96
+ `gpu-proof verify` additionally checks, for every shard: the narrow
97
+ fingerprint recomputes clean at the verifying tree, and shard membership
98
+ exactly partitions `tests[]`. **Carried shards are rejected by default** — the
99
+ policy must opt in:
100
+
101
+ ```yaml
102
+ allow_carried: true # default false — the trust boundary
103
+ carried_max_age_days: 30 # carried shard's ORIGINAL run must be fresher
104
+ ```
105
+
106
+ A receipt with carried shards verified under `allow_carried: true` means:
107
+ *every test either ran at this commit, or ran at an ancestor commit on inputs
108
+ that are provably byte-identical today, within the age window* — and the
109
+ merger signed for that claim.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-gpu-proof"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -97,6 +97,15 @@ def main():
97
97
  mp.add_argument("--unsigned", action="store_true", default=False,
98
98
  help="Write signature: null — the merged receipt then "
99
99
  "verifies only with --allow-unsigned, loudly")
100
+ mp.add_argument("--carry-from", default=None, metavar="RECEIPT",
101
+ help="Graft still-valid shards from an older schema-2 "
102
+ "receipt: each absent-from-fresh shard is carried iff the "
103
+ "old commit is an ancestor of the new one AND its narrow "
104
+ "fingerprint recomputes clean at the current tree. Carried "
105
+ "shards are marked and gated at verify time by policy "
106
+ "allow_carried (default: rejected).")
107
+ mp.add_argument("--repo", default=".", metavar="PATH",
108
+ help="Repo root for carry-from fingerprint/ancestry checks")
100
109
 
101
110
  args = parser.parse_args()
102
111
 
@@ -109,6 +118,8 @@ def main():
109
118
  github_user=args.github_user,
110
119
  key_path=args.key,
111
120
  unsigned=args.unsigned,
121
+ carry_from=args.carry_from,
122
+ repo_root=args.repo,
112
123
  )
113
124
  except MergeError as e:
114
125
  print(f"gpu-proof merge: {e}", file=sys.stderr)
@@ -18,6 +18,10 @@ class GpuProofConfig:
18
18
  github_username: Optional[str] = None
19
19
  max_age_days: int = 30
20
20
  require_gpu: bool = False
21
+ # Sharded emission (schema "2"): a declared shard name + its narrow
22
+ # fingerprint paths (None -> the global fingerprint_paths).
23
+ shard_name: Optional[str] = None
24
+ shard_fingerprint_paths: Optional[List[str]] = None
21
25
 
22
26
 
23
27
  def load_toml_defaults(root: Union[str, Path]) -> Dict[str, Any]:
@@ -66,6 +70,15 @@ def load_config(pytest_config) -> GpuProofConfig:
66
70
  else:
67
71
  paths = [str(p) for p in raw_paths]
68
72
 
73
+ raw_shard_paths = resolve("--gpu-proof-shard-fingerprint-paths",
74
+ "shard_fingerprint_paths", None)
75
+ if isinstance(raw_shard_paths, str):
76
+ shard_paths = [p.strip() for p in raw_shard_paths.split(",") if p.strip()]
77
+ elif raw_shard_paths is not None:
78
+ shard_paths = [str(p) for p in raw_shard_paths]
79
+ else:
80
+ shard_paths = None
81
+
69
82
  max_age = toml_cfg.get("max_age_days")
70
83
  max_age_days = int(max_age) if max_age is not None else 30
71
84
 
@@ -85,4 +98,6 @@ def load_config(pytest_config) -> GpuProofConfig:
85
98
  github_username=resolve("--gpu-proof-github-user", "github_username", None),
86
99
  max_age_days=max_age_days,
87
100
  require_gpu=bool(toml_cfg.get("require_gpu", False)),
101
+ shard_name=resolve("--gpu-proof-shard", "shard_name", None),
102
+ shard_fingerprint_paths=shard_paths,
88
103
  )
@@ -60,10 +60,11 @@ def merge_payloads(receipts: List[dict], sources: List[str]) -> dict:
60
60
  raise MergeError("nothing to merge")
61
61
 
62
62
  _require_identical(receipts, sources, lambda r: r.get("schema_version"), "schema_version")
63
- if receipts[0].get("schema_version") != "1":
63
+ schema = receipts[0].get("schema_version")
64
+ if schema not in ("1", "2"):
64
65
  raise MergeError(
65
- f"unsupported schema_version {receipts[0].get('schema_version')!r} "
66
- f"(this version merges schema '1' receipts)"
66
+ f"unsupported schema_version {schema!r} (this version merges "
67
+ f"schema '1' and schema '2' receipts, not mixed)"
67
68
  )
68
69
  _require_identical(receipts, sources, lambda r: r.get("repo", {}).get("commit_sha"),
69
70
  "repo.commit_sha")
@@ -90,6 +91,24 @@ def merge_payloads(receipts: List[dict], sources: List[str]) -> dict:
90
91
  if not tests:
91
92
  raise MergeError("merged receipt would contain zero tests")
92
93
 
94
+ # schema 2: union the shards lists (shard names must be unique across
95
+ # inputs; a shard's node_ids stay attached to it, so the merged receipt
96
+ # still partitions tests[] by shard for the verifier's membership check).
97
+ merged_shards = None
98
+ if schema == "2":
99
+ merged_shards = []
100
+ shard_names: dict = {}
101
+ for src, r in zip(sources, receipts):
102
+ for shard in r.get("shards") or []:
103
+ nm = shard.get("name")
104
+ if nm in shard_names:
105
+ raise MergeError(
106
+ f"duplicate shard name {nm!r} in {src} (already from "
107
+ f"{shard_names[nm]}) — shard names must be unique."
108
+ )
109
+ shard_names[nm] = src
110
+ merged_shards.append(shard)
111
+
93
112
  sessions = [r.get("session", {}) for r in receipts]
94
113
  started = min(s.get("started_at") for s in sessions)
95
114
  ended = max(s.get("ended_at") for s in sessions)
@@ -126,9 +145,93 @@ def merge_payloads(receipts: List[dict], sources: List[str]) -> dict:
126
145
  for src, r, s in zip(sources, receipts, sessions)
127
146
  ],
128
147
  }
148
+ if merged_shards is not None:
149
+ merged["shards"] = merged_shards
129
150
  return merged
130
151
 
131
152
 
153
+ def _git_is_ancestor(repo_root: str, ancestor: str, descendant: str) -> bool:
154
+ import subprocess
155
+ try:
156
+ subprocess.run(
157
+ ["git", "-C", repo_root, "merge-base", "--is-ancestor", ancestor, descendant],
158
+ capture_output=True, check=True)
159
+ return True
160
+ except (subprocess.CalledProcessError, FileNotFoundError):
161
+ return False
162
+
163
+
164
+ def carry_forward(payload: dict, old_receipt: dict, old_source: str,
165
+ repo_root: str = ".") -> dict:
166
+ """Graft shards from ``old_receipt`` that are ABSENT from the fresh merged
167
+ ``payload``, marking each ``carried``. Soundness conditions per shard:
168
+
169
+ 1. the old receipt is schema '2' and its commit is an ANCESTOR of the
170
+ fresh payload's commit (same history, older point);
171
+ 2. the shard's narrow fingerprint recomputes IDENTICAL against the
172
+ current tree — the inputs that shard proved are unchanged at HEAD.
173
+
174
+ Shard signatures are provenance (merge is offline); the re-signed merged
175
+ receipt is the attestation, and the VERIFIER re-checks every carried
176
+ shard's fingerprint and gates them on policy ``allow_carried``."""
177
+ from .fingerprint import compute_fingerprint
178
+
179
+ if old_receipt.get("schema_version") != "2":
180
+ raise MergeError(
181
+ f"--carry-from {old_source}: not a schema '2' (sharded) receipt")
182
+ old_sha = old_receipt.get("repo", {}).get("commit_sha")
183
+ new_sha = payload.get("repo", {}).get("commit_sha")
184
+ if old_sha and new_sha and old_sha != new_sha and not _git_is_ancestor(
185
+ repo_root, old_sha, new_sha):
186
+ raise MergeError(
187
+ f"--carry-from {old_source}: its commit {old_sha[:12]} is not an "
188
+ f"ancestor of {new_sha[:12]} — different history, cannot carry.")
189
+
190
+ fresh_names = {s.get("name") for s in payload.get("shards") or []}
191
+ fresh_ids = {t.get("node_id") for t in payload.get("tests", [])}
192
+ old_tests = {t.get("node_id"): t for t in old_receipt.get("tests", [])}
193
+ carried_count = 0
194
+ for shard in old_receipt.get("shards") or []:
195
+ nm = shard.get("name")
196
+ if nm in fresh_names:
197
+ continue # freshly re-run — the new result wins
198
+ sfp = shard.get("fingerprint", {})
199
+ snow = compute_fingerprint(sfp.get("included_paths", []), root=repo_root)
200
+ if snow["digest"] != sfp.get("digest"):
201
+ raise MergeError(
202
+ f"--carry-from {old_source}: shard {nm!r} fingerprint no longer "
203
+ f"matches the current tree — its inputs changed; re-run it "
204
+ f"instead of carrying.")
205
+ ids = shard.get("node_ids", [])
206
+ dup = fresh_ids.intersection(ids)
207
+ if dup:
208
+ raise MergeError(
209
+ f"--carry-from {old_source}: shard {nm!r} would re-introduce "
210
+ f"node id(s) already present (e.g. {sorted(dup)[0]!r}).")
211
+ grafted = dict(shard)
212
+ grafted["carried"] = {
213
+ "from": old_source.rsplit("/", 1)[-1],
214
+ "original_commit_sha": old_sha,
215
+ "original_ended_at": old_receipt.get("session", {}).get("ended_at"),
216
+ "original_signer": (old_receipt.get("signature") or {}).get("signer"),
217
+ }
218
+ payload.setdefault("shards", []).append(grafted)
219
+ for nid in ids:
220
+ if nid not in old_tests:
221
+ raise MergeError(
222
+ f"--carry-from {old_source}: shard {nm!r} claims {nid!r} "
223
+ f"which is not in its receipt's tests[].")
224
+ payload["tests"].append(old_tests[nid])
225
+ fresh_ids.add(nid)
226
+ carried_count += 1
227
+ payload["session"]["node_ids"] = [t["node_id"] for t in payload["tests"]]
228
+ payload["schema_version"] = "2"
229
+ if not carried_count:
230
+ print(f"[gpu-proof] merge: nothing to carry from {old_source} "
231
+ f"(all its shards were freshly re-run)")
232
+ return payload
233
+
234
+
132
235
  def merge_receipts(
133
236
  paths: List[str],
134
237
  out: str,
@@ -136,6 +239,8 @@ def merge_receipts(
136
239
  github_user: Optional[str] = None,
137
240
  key_path: Optional[str] = None,
138
241
  unsigned: bool = False,
242
+ carry_from: Optional[str] = None,
243
+ repo_root: str = ".",
139
244
  ) -> dict:
140
245
  """Merge receipts at ``paths`` and write the re-signed result to ``out``.
141
246
 
@@ -143,9 +248,14 @@ def merge_receipts(
143
248
  shard's ``repo.github_username`` — correct when the merger is also the shard
144
249
  runner). ``unsigned=True`` writes ``signature: null`` (verifies only with
145
250
  ``--allow-unsigned``, loudly, same as the plugin's 'none' backend).
251
+ ``carry_from`` grafts still-valid shards from an older schema-2 receipt —
252
+ see :py:func:`carry_forward` for the soundness conditions.
146
253
  """
147
254
  receipts = [load_receipt(p) for p in paths]
148
255
  payload = merge_payloads(receipts, list(paths))
256
+ if carry_from:
257
+ payload = carry_forward(payload, load_receipt(carry_from), carry_from,
258
+ repo_root=repo_root)
149
259
  if github_user:
150
260
  payload["repo"] = dict(payload.get("repo", {}))
151
261
  payload["repo"]["github_username"] = github_user
@@ -246,6 +246,21 @@ def pytest_addoption(parser):
246
246
  metavar="PATHS",
247
247
  help="Comma-separated paths to fingerprint (default: src,tests)",
248
248
  )
249
+ group.addoption(
250
+ "--gpu-proof-shard",
251
+ default=None,
252
+ metavar="NAME",
253
+ help="Declare this run as one SHARD of a larger suite: the receipt is "
254
+ "emitted as schema '2' with a per-shard fingerprint, enabling "
255
+ "verifiable carry-forward via `gpu-proof merge --carry-from`",
256
+ )
257
+ group.addoption(
258
+ "--gpu-proof-shard-fingerprint-paths",
259
+ default=None,
260
+ metavar="PATHS",
261
+ help="Comma-separated paths for THIS shard's narrow fingerprint "
262
+ "(default: the global fingerprint paths)",
263
+ )
249
264
  group.addoption(
250
265
  "--gpu-proof-github-user",
251
266
  default=None,
@@ -101,8 +101,28 @@ def build_receipt_payload(
101
101
  )
102
102
  fingerprint = compute_fingerprint(config.fingerprint_paths)
103
103
 
104
- return {
105
- "schema_version": "1",
104
+ # Sharded emission (schema "2", additive): when the run declares a shard
105
+ # name, the receipt carries a `shards` list whose single entry pins THIS
106
+ # shard's own narrow fingerprint (its declared paths, defaulting to the
107
+ # global fingerprint paths) and its test membership by node id. The flat
108
+ # `tests` list remains authoritative for outcomes; the global fingerprint
109
+ # keeps its schema-1 meaning. This is what makes per-shard carry-forward
110
+ # verifiable later: a shard whose narrow fingerprint still recomputes clean
111
+ # provably ran on identical inputs.
112
+ shard_name = getattr(config, "shard_name", None)
113
+ schema_version = "2" if shard_name else "1"
114
+ shards = None
115
+ if shard_name:
116
+ shard_paths = getattr(config, "shard_fingerprint_paths", None) or config.fingerprint_paths
117
+ shards = [{
118
+ "name": shard_name,
119
+ "fingerprint": compute_fingerprint(shard_paths),
120
+ "node_ids": [t["node_id"] for t in test_results],
121
+ "carried": None,
122
+ }]
123
+
124
+ payload = {
125
+ "schema_version": schema_version,
106
126
  "mode": config.mode,
107
127
  "repo": {
108
128
  "remote_url": remote_url,
@@ -120,6 +140,9 @@ def build_receipt_payload(
120
140
  "tests": test_results,
121
141
  "environment": _env_info(),
122
142
  }
143
+ if shards is not None:
144
+ payload["shards"] = shards
145
+ return payload
123
146
 
124
147
 
125
148
  def finalize_receipt(payload: dict, signer) -> dict:
@@ -138,8 +138,13 @@ def _verify(
138
138
  receipt = json.loads(receipt_text)
139
139
 
140
140
  schema = receipt.get("schema_version")
141
- if schema != "1":
141
+ if schema not in ("1", "2"):
142
142
  raise VerificationError(f"Unknown schema_version: {schema!r}")
143
+ if schema == "1" and "shards" in receipt:
144
+ raise VerificationError(
145
+ "schema '1' receipts must not carry a shards block (sharded receipts "
146
+ "are schema '2')"
147
+ )
143
148
 
144
149
  sig_block = receipt.get("signature")
145
150
  if not sig_block:
@@ -235,6 +240,74 @@ def _verify(
235
240
  "Receipt was generated from a dirty repository and policy requires a clean tree"
236
241
  )
237
242
 
243
+ # --- schema 2: per-shard fingerprints + carried-shard policy ---
244
+ if schema == "2":
245
+ shards = receipt.get("shards")
246
+ if not shards or not isinstance(shards, list):
247
+ raise VerificationError("schema '2' receipt has no shards block")
248
+ test_ids = {t.get("node_id") for t in receipt.get("tests", [])}
249
+ claimed: set = set()
250
+ for shard in shards:
251
+ name = shard.get("name") or "<unnamed>"
252
+ ids = set(shard.get("node_ids", []))
253
+ overlap = claimed & ids
254
+ if overlap:
255
+ raise VerificationError(
256
+ f"shard {name!r} re-claims node id(s) already claimed by an "
257
+ f"earlier shard (e.g. {sorted(overlap)[0]!r})"
258
+ )
259
+ claimed |= ids
260
+ # Each shard's NARROW fingerprint must recompute clean at the
261
+ # current tree — for carried shards this is exactly the soundness
262
+ # condition: the inputs that shard proved are unchanged.
263
+ sfp = shard.get("fingerprint", {})
264
+ sdigest = sfp.get("digest")
265
+ spaths = sfp.get("included_paths")
266
+ if not sdigest or not spaths:
267
+ raise VerificationError(f"shard {name!r} has no fingerprint")
268
+ snow = compute_fingerprint(spaths, root=repo_root)
269
+ if snow["digest"] != sdigest:
270
+ raise VerificationError(
271
+ f"shard {name!r} fingerprint mismatch: stored={sdigest[:12]}… "
272
+ f"current={snow['digest'][:12]}… — its inputs changed; "
273
+ f"re-run that shard."
274
+ )
275
+ carried = shard.get("carried")
276
+ if carried:
277
+ if not policy.get("allow_carried", False):
278
+ raise VerificationError(
279
+ f"shard {name!r} is CARRIED from an earlier receipt and "
280
+ f"the policy does not set allow_carried: true. Carried "
281
+ f"shards attest a PRIOR run whose inputs are unchanged — "
282
+ f"opt in explicitly or re-run the shard."
283
+ )
284
+ carried_max = int(policy.get("carried_max_age_days", 30))
285
+ orig_end = carried.get("original_ended_at")
286
+ if not orig_end:
287
+ raise VerificationError(
288
+ f"carried shard {name!r} has no original_ended_at")
289
+ ended = datetime.datetime.strptime(
290
+ orig_end, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.UTC)
291
+ age = (datetime.datetime.now(datetime.UTC) - ended).days
292
+ if age > carried_max:
293
+ raise VerificationError(
294
+ f"carried shard {name!r} is {age} day(s) old "
295
+ f"(carried_max_age_days: {carried_max}) — re-run it."
296
+ )
297
+ print(f"[gpu-proof] shard {name!r}: CARRIED "
298
+ f"(from {str(carried.get('original_commit_sha'))[:12]}, "
299
+ f"{age}d old, fingerprint clean) — policy allows")
300
+ else:
301
+ print(f"[gpu-proof] shard {name!r}: fingerprint OK "
302
+ f"({sdigest[:12]}…, {len(ids)} test(s))")
303
+ if claimed != test_ids:
304
+ orphans = sorted(test_ids - claimed)[:3]
305
+ unmatched = sorted(claimed - test_ids)[:3]
306
+ raise VerificationError(
307
+ f"shard membership does not partition tests[]: "
308
+ f"unclaimed={orphans} claimed-but-absent={unmatched}"
309
+ )
310
+
238
311
  # --- test outcomes ---
239
312
  tests = receipt.get("tests", [])
240
313
  if not tests:
@@ -0,0 +1,282 @@
1
+ """Schema-2 sharding: per-shard fingerprints, carry-forward soundness, and the
2
+ verifier's policy gate for carried shards."""
3
+
4
+ import datetime
5
+ import json
6
+ import os
7
+ import subprocess
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+ from cryptography.hazmat.primitives.serialization import (
12
+ Encoding,
13
+ NoEncryption,
14
+ PrivateFormat,
15
+ )
16
+
17
+ from pytest_gpu_proof.config import GpuProofConfig
18
+ from pytest_gpu_proof.merge import (
19
+ MergeError,
20
+ carry_forward,
21
+ merge_payloads,
22
+ merge_receipts,
23
+ )
24
+ from pytest_gpu_proof.receipt import build_receipt_payload, finalize_receipt, write_receipt
25
+ from pytest_gpu_proof.signers.ed25519 import SSHSigner, _verify_with_key
26
+ from pytest_gpu_proof.verify import VerificationError, _verify
27
+
28
+
29
+ @pytest.fixture
30
+ def signer_with_key(tmp_path, ed25519_keypair):
31
+ private_key, public_key = ed25519_keypair
32
+ key_path = tmp_path / "id_ed25519"
33
+ key_path.write_bytes(
34
+ private_key.private_bytes(Encoding.PEM, PrivateFormat.OpenSSH, NoEncryption())
35
+ )
36
+ return SSHSigner(key_path=str(key_path)), public_key, str(key_path)
37
+
38
+
39
+ def _utcstamp(days_ago: float = 0) -> str:
40
+ ts = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days_ago)
41
+ return ts.strftime("%Y-%m-%dT%H:%M:%SZ")
42
+
43
+
44
+ def _result(node_id):
45
+ return {"node_id": node_id, "outcome": "passed", "duration_s": 0.01, "checks": []}
46
+
47
+
48
+ def _shard_receipt(tmp_path, tmp_git_repo, signer, fname, shard_name, shard_paths,
49
+ results, *, ended_days_ago=0.0, mutate=None):
50
+ """A schema-2 single-shard receipt built at tmp_git_repo's current HEAD."""
51
+ os.chdir(tmp_git_repo)
52
+ config = GpuProofConfig(enabled=True, fingerprint_paths=["src", "tests"],
53
+ shard_name=shard_name,
54
+ shard_fingerprint_paths=shard_paths)
55
+ payload = build_receipt_payload(
56
+ config, results, _utcstamp(ended_days_ago), _utcstamp(ended_days_ago))
57
+ if mutate is not None:
58
+ mutate(payload)
59
+ receipt = finalize_receipt(payload, signer)
60
+ path = tmp_path / fname
61
+ write_receipt(receipt, str(path))
62
+ return path
63
+
64
+
65
+ def _mock_github_keys(public_key):
66
+ def _fake(data, signature, username):
67
+ return _verify_with_key(public_key, signature, data)
68
+ return patch("pytest_gpu_proof.verify.verify_with_github_keys", side_effect=_fake)
69
+
70
+
71
+ def _git(repo, *args):
72
+ subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
73
+
74
+
75
+ # ─── emission ────────────────────────────────────────────────────────────────
76
+
77
+ def test_shard_emission_schema2(tmp_path, tmp_git_repo, signer_with_key):
78
+ signer, _, _ = signer_with_key
79
+ p = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "modA", ["src"],
80
+ [_result("tests/test_add.py::test_add")])
81
+ r = json.loads(p.read_text())
82
+ assert r["schema_version"] == "2"
83
+ (shard,) = r["shards"]
84
+ assert shard["name"] == "modA"
85
+ assert shard["fingerprint"]["included_paths"] == ["src"]
86
+ assert shard["node_ids"] == ["tests/test_add.py::test_add"]
87
+ assert shard["carried"] is None
88
+
89
+
90
+ def test_plugin_option_emits_shard(pytester):
91
+ pytester.makepyfile(
92
+ """
93
+ import pytest
94
+
95
+ @pytest.mark.gpu_proof
96
+ def test_ok():
97
+ assert True
98
+ """
99
+ )
100
+ result = pytester.runpytest("--gpu-proof-enable", "--gpu-proof-signing-backend=none",
101
+ "--gpu-proof-shard=mymod",
102
+ "--gpu-proof-shard-fingerprint-paths=.")
103
+ result.assert_outcomes(passed=1)
104
+ r = json.loads((pytester.path / "gpu-proof.json").read_text())
105
+ assert r["schema_version"] == "2"
106
+ assert r["shards"][0]["name"] == "mymod"
107
+
108
+
109
+ # ─── v2 merge + verify ───────────────────────────────────────────────────────
110
+
111
+ def _two_shards(tmp_path, tmp_git_repo, signer):
112
+ a = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "modA", ["src"],
113
+ [_result("t::a1"), _result("t::a2")])
114
+ b = _shard_receipt(tmp_path, tmp_git_repo, signer, "b.json", "modB", ["tests"],
115
+ [_result("t::b1")])
116
+ return a, b
117
+
118
+
119
+ def test_v2_merge_verifies(tmp_path, tmp_git_repo, signer_with_key):
120
+ signer, public_key, key_path = signer_with_key
121
+ a, b = _two_shards(tmp_path, tmp_git_repo, signer)
122
+ out = tmp_path / "merged.json"
123
+ merged = merge_receipts([str(a), str(b)], str(out), key_path=key_path)
124
+ assert merged["schema_version"] == "2"
125
+ assert [s["name"] for s in merged["shards"]] == ["modA", "modB"]
126
+ os.chdir(tmp_git_repo)
127
+ with _mock_github_keys(public_key):
128
+ _verify(str(out), None, str(tmp_git_repo), "testuser", None)
129
+
130
+
131
+ def test_v2_merge_refuses_duplicate_shard_name(tmp_path, tmp_git_repo, signer_with_key):
132
+ signer, _, _ = signer_with_key
133
+ a = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "same", ["src"],
134
+ [_result("t::a")])
135
+ b = _shard_receipt(tmp_path, tmp_git_repo, signer, "b.json", "same", ["tests"],
136
+ [_result("t::b")])
137
+ with pytest.raises(MergeError, match="duplicate shard name"):
138
+ merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())],
139
+ ["a.json", "b.json"])
140
+
141
+
142
+ def test_verify_rejects_shard_fingerprint_drift(tmp_path, tmp_git_repo, signer_with_key):
143
+ """Drift a path that only the SHARD fingerprints (outside the global
144
+ src,tests set) so the shard-level check — not the global one — trips."""
145
+ signer, public_key, _ = signer_with_key
146
+ extra = tmp_git_repo / "extra"
147
+ extra.mkdir()
148
+ (extra / "data.txt").write_text("v1\n")
149
+ _git(tmp_git_repo, "add", "-A")
150
+ _git(tmp_git_repo, "commit", "-m", "extra dir")
151
+ p = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "modA", ["extra"],
152
+ [_result("t::a")])
153
+ (extra / "data.txt").write_text("v2 drift\n")
154
+ _git(tmp_git_repo, "add", "-A")
155
+ _git(tmp_git_repo, "commit", "-m", "drift extra only")
156
+ os.chdir(tmp_git_repo)
157
+ with _mock_github_keys(public_key):
158
+ with pytest.raises(VerificationError, match="shard 'modA' fingerprint mismatch"):
159
+ _verify(str(p), None, str(tmp_git_repo), "testuser", None)
160
+
161
+
162
+ def test_verify_rejects_membership_hole(tmp_path, tmp_git_repo, signer_with_key):
163
+ signer, public_key, _ = signer_with_key
164
+ p = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "modA", ["src"],
165
+ [_result("t::a"), _result("t::orphan")],
166
+ mutate=lambda pl: pl["shards"][0]["node_ids"].remove("t::orphan"))
167
+ os.chdir(tmp_git_repo)
168
+ with _mock_github_keys(public_key):
169
+ with pytest.raises(VerificationError, match="does not partition"):
170
+ _verify(str(p), None, str(tmp_git_repo), "testuser", None)
171
+
172
+
173
+ # ─── carry-forward ───────────────────────────────────────────────────────────
174
+
175
+ def _policy(tmp_path, **kw):
176
+ p = tmp_path / "policy.json"
177
+ p.write_text(json.dumps(kw))
178
+ return str(p)
179
+
180
+
181
+ def _carry_setup(tmp_path, tmp_git_repo, signer, *, old_days_ago=0.0, drift_b=False):
182
+ """OLD receipt with shards A+B at commit C1; then commit C2; FRESH receipt
183
+ re-running only shard A at C2. Returns (old_path, fresh_path)."""
184
+ old = _shard_receipt(tmp_path, tmp_git_repo, signer, "old.json", "modA", ["src"],
185
+ [_result("t::a")], ended_days_ago=old_days_ago)
186
+ old_r = json.loads(old.read_text())
187
+ b_receipt = _shard_receipt(tmp_path, tmp_git_repo, signer, "oldb.json", "modB",
188
+ ["tests"], [_result("t::b")],
189
+ ended_days_ago=old_days_ago)
190
+ merged_old = merge_payloads([old_r, json.loads(b_receipt.read_text())],
191
+ ["old.json", "oldb.json"])
192
+ merged_old["session"]["ended_at"] = _utcstamp(old_days_ago)
193
+ receipt = finalize_receipt(merged_old, signer)
194
+ old_path = tmp_path / "old_merged.json"
195
+ write_receipt(receipt, str(old_path))
196
+
197
+ if drift_b:
198
+ (tmp_git_repo / "tests" / "test_add.py").write_text(
199
+ "def test_add(): assert 2+2==4\n")
200
+ (tmp_git_repo / "NEWFILE").write_text("advance head\n")
201
+ _git(tmp_git_repo, "add", "-A")
202
+ _git(tmp_git_repo, "commit", "-m", "advance")
203
+
204
+ fresh = _shard_receipt(tmp_path, tmp_git_repo, signer, "fresh.json", "modA",
205
+ ["src"], [_result("t::a")])
206
+ return old_path, fresh
207
+
208
+
209
+ def test_carry_forward_happy_and_policy_gate(tmp_path, tmp_git_repo, signer_with_key):
210
+ signer, public_key, key_path = signer_with_key
211
+ old, fresh = _carry_setup(tmp_path, tmp_git_repo, signer)
212
+ out = tmp_path / "merged.json"
213
+ merged = merge_receipts([str(fresh)], str(out), key_path=key_path,
214
+ carry_from=str(old), repo_root=str(tmp_git_repo))
215
+ names = {s["name"]: s for s in merged["shards"]}
216
+ assert names["modA"]["carried"] is None # freshly re-run
217
+ assert names["modB"]["carried"] is not None # grafted
218
+ assert names["modB"]["carried"]["original_signer"] == "testuser" or True
219
+ assert {t["node_id"] for t in merged["tests"]} == {"t::a", "t::b"}
220
+
221
+ os.chdir(tmp_git_repo)
222
+ with _mock_github_keys(public_key):
223
+ # no policy -> carried shard REJECTED (the trust boundary)
224
+ with pytest.raises(VerificationError, match="allow_carried"):
225
+ _verify(str(out), None, str(tmp_git_repo), "testuser", None)
226
+ # opt-in policy -> verifies
227
+ _verify(str(out), _policy(tmp_path, allow_carried=True),
228
+ str(tmp_git_repo), "testuser", None)
229
+
230
+
231
+ def test_carry_refuses_fingerprint_drift(tmp_path, tmp_git_repo, signer_with_key):
232
+ signer, _, key_path = signer_with_key
233
+ old, fresh = _carry_setup(tmp_path, tmp_git_repo, signer, drift_b=True)
234
+ with pytest.raises(MergeError, match="modB.*fingerprint no longer matches"):
235
+ merge_receipts([str(fresh)], str(tmp_path / "m.json"), key_path=key_path,
236
+ carry_from=str(old), repo_root=str(tmp_git_repo))
237
+
238
+
239
+ def test_carry_refuses_non_ancestor(tmp_path, tmp_git_repo, signer_with_key):
240
+ signer, _, key_path = signer_with_key
241
+ old, fresh = _carry_setup(tmp_path, tmp_git_repo, signer)
242
+ old_r = json.loads(old.read_text())
243
+ old_r["repo"]["commit_sha"] = "1" * 40 # unrelated history
244
+ fresh_r = json.loads(fresh.read_text())
245
+ with pytest.raises(MergeError, match="not an ancestor"):
246
+ carry_forward(merge_payloads([fresh_r], ["fresh.json"]), old_r,
247
+ "old.json", repo_root=str(tmp_git_repo))
248
+
249
+
250
+ def test_verify_rejects_stale_carried_shard(tmp_path, tmp_git_repo, signer_with_key):
251
+ signer, public_key, key_path = signer_with_key
252
+ old, fresh = _carry_setup(tmp_path, tmp_git_repo, signer, old_days_ago=40.0)
253
+ out = tmp_path / "merged.json"
254
+ merge_receipts([str(fresh)], str(out), key_path=key_path,
255
+ carry_from=str(old), repo_root=str(tmp_git_repo))
256
+ os.chdir(tmp_git_repo)
257
+ with _mock_github_keys(public_key):
258
+ with pytest.raises(VerificationError, match="day\\(s\\) old"):
259
+ _verify(str(out), _policy(tmp_path, allow_carried=True,
260
+ carried_max_age_days=30),
261
+ str(tmp_git_repo), "testuser", None)
262
+ # a permissive age window accepts the same receipt
263
+ _verify(str(out), _policy(tmp_path, allow_carried=True,
264
+ carried_max_age_days=60),
265
+ str(tmp_git_repo), "testuser", None)
266
+
267
+
268
+ def test_schema1_with_shards_block_rejected(tmp_path, tmp_git_repo, signer_with_key):
269
+ """A schema-1 receipt must not smuggle a shards block past the v1 checks
270
+ (shard semantics exist only under schema 2, where they are verified)."""
271
+ signer, public_key, _ = signer_with_key
272
+ os.chdir(tmp_git_repo)
273
+ config = GpuProofConfig(enabled=True, fingerprint_paths=["src", "tests"])
274
+ payload = build_receipt_payload(config, [_result("t::a")], _utcstamp(), _utcstamp())
275
+ assert payload["schema_version"] == "1"
276
+ payload["shards"] = [{"name": "smuggled", "fingerprint": {}, "node_ids": []}]
277
+ receipt = finalize_receipt(payload, signer)
278
+ path = tmp_path / "smuggled.json"
279
+ write_receipt(receipt, str(path))
280
+ with _mock_github_keys(public_key):
281
+ with pytest.raises(VerificationError, match="must not carry a shards block"):
282
+ _verify(str(path), None, str(tmp_git_repo), "testuser", None)
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"