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.
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/CHANGELOG.md +15 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/PKG-INFO +1 -1
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/security_model.md +12 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/sharding.md +47 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/pyproject.toml +1 -1
- pytest_gpu_proof-0.3.0/src/pytest_gpu_proof/__init__.py +1 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/cli.py +11 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/config.py +15 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/merge.py +113 -3
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/plugin.py +15 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/receipt.py +25 -2
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/verify.py +74 -1
- pytest_gpu_proof-0.3.0/tests/test_sharding.py +282 -0
- pytest_gpu_proof-0.2.0/src/pytest_gpu_proof/__init__.py +0 -1
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.github/workflows/ci.yml +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.github/workflows/docs.yml +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.github/workflows/publish-testpypi.yml +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.github/workflows/publish.yml +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/.gitignore +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/CLAUDE.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/LICENSE +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/README.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/RELEASING.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/ROADMAP.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/architecture.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/ci_gpu_mode.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/index.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/landscape.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/local_mode.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/docs/quickstart.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/Makefile +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/README.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/cuda_ctypes_matmul.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/matmul_kernel.cu +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/test_cuda_ctypes_matmul.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/CMakeLists.txt +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/README.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/bindings.cpp +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_cuda.h +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_kernel.cu +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/test_cuda_nanobind_matmul.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/CMakeLists.txt +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/README.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/bindings.cpp +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_cuda.h +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_kernel.cu +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/test_cuda_pybind11_matmul.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/github_gpu_runner/.github/workflows/gpu-test.yml +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/CMakeLists.txt +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/README.md +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/jax_ffi_matmul.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/src/matmul_ffi.cu +0 -0
- {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
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/local_receipt_verify/.github/workflows/verify.yml +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/conftest.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/test_minimal.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/fake_cuda_lib.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/test_cuda_wrapper.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/install.sh +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/mkdocs.yml +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/requirements-dev.txt +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/requirements.txt +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/__main__.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/compare.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/fingerprint.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/gitutils.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/__init__.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/base.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/ed25519.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/conftest.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_compare.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_fingerprint.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_gitutils.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_merge.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_plugin_capture.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_receipt.py +0 -0
- {pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/tests/test_signing.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
63
|
+
schema = receipts[0].get("schema_version")
|
|
64
|
+
if schema not in ("1", "2"):
|
|
64
65
|
raise MergeError(
|
|
65
|
-
f"unsupported schema_version {
|
|
66
|
-
f"
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/cuda_ctypes_matmul.py
RENAMED
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/matmul_kernel.cu
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/CMakeLists.txt
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/bindings.cpp
RENAMED
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_cuda.h
RENAMED
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_kernel.cu
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/CMakeLists.txt
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/bindings.cpp
RENAMED
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_cuda.h
RENAMED
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_kernel.cu
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/CMakeLists.txt
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/jax_ffi_matmul.py
RENAMED
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/src/matmul_ffi.cu
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/test_minimal.py
RENAMED
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/fake_cuda_lib.py
RENAMED
|
File without changes
|
{pytest_gpu_proof-0.2.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/test_cuda_wrapper.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|