pytest-gpu-proof 0.1.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.1.0 → pytest_gpu_proof-0.3.0}/CHANGELOG.md +32 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/PKG-INFO +1 -1
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/security_model.md +12 -0
- pytest_gpu_proof-0.3.0/docs/sharding.md +109 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/mkdocs.yml +1 -0
- {pytest_gpu_proof-0.1.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.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/cli.py +56 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/config.py +26 -5
- pytest_gpu_proof-0.3.0/src/pytest_gpu_proof/merge.py +271 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/plugin.py +20 -5
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/receipt.py +25 -2
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/verify.py +74 -1
- pytest_gpu_proof-0.3.0/tests/test_merge.py +163 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_plugin_capture.py +31 -0
- pytest_gpu_proof-0.3.0/tests/test_sharding.py +282 -0
- pytest_gpu_proof-0.1.0/src/pytest_gpu_proof/__init__.py +0 -1
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.github/workflows/ci.yml +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.github/workflows/docs.yml +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.github/workflows/publish-testpypi.yml +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.github/workflows/publish.yml +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.gitignore +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/CLAUDE.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/LICENSE +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/README.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/RELEASING.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/ROADMAP.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/architecture.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/ci_gpu_mode.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/index.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/landscape.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/local_mode.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/quickstart.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/Makefile +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/README.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/cuda_ctypes_matmul.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/matmul_kernel.cu +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/test_cuda_ctypes_matmul.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/CMakeLists.txt +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/README.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/bindings.cpp +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_cuda.h +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_kernel.cu +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/test_cuda_nanobind_matmul.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/CMakeLists.txt +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/README.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/bindings.cpp +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_cuda.h +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_kernel.cu +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/test_cuda_pybind11_matmul.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/github_gpu_runner/.github/workflows/gpu-test.yml +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/CMakeLists.txt +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/README.md +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/jax_ffi_matmul.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/src/matmul_ffi.cu +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/test_jax_ffi_cuda_matmul.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/local_receipt_verify/.github/workflows/verify.yml +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/conftest.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/test_minimal.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/fake_cuda_lib.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/test_cuda_wrapper.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/install.sh +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/requirements-dev.txt +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/requirements.txt +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/__main__.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/compare.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/fingerprint.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/gitutils.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/__init__.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/base.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/ed25519.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/conftest.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_compare.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_fingerprint.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_gitutils.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_receipt.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_signing.py +0 -0
- {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_verifier.py +0 -0
|
@@ -4,6 +4,38 @@ 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
|
+
|
|
22
|
+
## [0.2.0] — 2026-08-07
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- `gpu-proof merge`: union N shard receipts from one commit into a single
|
|
26
|
+
re-signed receipt (per-module crash isolation / machine sharding). Refuses
|
|
27
|
+
shards that disagree on schema, commit SHA, fingerprint, mode, or
|
|
28
|
+
environment; duplicate node IDs across shards are a hard error. Records
|
|
29
|
+
per-shard provenance under `session.shards` (additive — the verifier is
|
|
30
|
+
unchanged). `repo.dirty` is OR-ed; `gpu_info` survives CPU-only shards.
|
|
31
|
+
Docs: `docs/sharding.md`.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- Explicit CLI values equal to their built-in defaults are no longer silently
|
|
35
|
+
ignored in favor of `[tool.gpu_proof]` (value-taking options now register a
|
|
36
|
+
`None` sentinel; `--gpu-proof-fail-on-skip` ORs with the toml value since a
|
|
37
|
+
store_true flag can only turn it on).
|
|
38
|
+
|
|
7
39
|
## [0.1.0] — 2026-07-07
|
|
8
40
|
|
|
9
41
|
First public release.
|
|
@@ -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`.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Sharded runs & merging receipts
|
|
2
|
+
|
|
3
|
+
Large suites often can't (or shouldn't) run as one pytest session: per-module
|
|
4
|
+
subprocesses give crash isolation (one CUDA abort no longer erases the whole
|
|
5
|
+
run's results), and big projects split GPU tests across invocations or
|
|
6
|
+
machines. Each invocation emits its own receipt; CI wants **one** artifact.
|
|
7
|
+
|
|
8
|
+
## Emit one receipt per shard
|
|
9
|
+
|
|
10
|
+
Point each invocation at its own output path:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pytest tests/gpu/test_a.py --gpu-proof-enable --gpu-proof-out=receipts/a.json
|
|
14
|
+
pytest tests/gpu/test_b.py --gpu-proof-enable --gpu-proof-out=receipts/b.json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Every shard receipt is a complete, individually verifiable receipt.
|
|
18
|
+
|
|
19
|
+
## Merge
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gpu-proof merge --out gpu-proof.json receipts/a.json receipts/b.json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`merge` unions the shards' `tests`, spans `session.started_at`/`ended_at`
|
|
26
|
+
across them, records per-shard provenance under `session.shards`
|
|
27
|
+
(`source`, `node_count`, timestamps, and each shard's recorded signer), and
|
|
28
|
+
**re-signs the merged payload with your local SSH key**. The result flows
|
|
29
|
+
through `gpu-proof verify` completely unchanged — same schema, same seven
|
|
30
|
+
checks.
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
|
|
34
|
+
- `--github-user USERNAME` — recorded signer identity for the merged receipt
|
|
35
|
+
(default: the first shard's `repo.github_username`).
|
|
36
|
+
- `--key PATH` — SSH private key (default: `git config user.signingKey`, then
|
|
37
|
+
`~/.ssh/id_ed25519` / `id_ecdsa` / `id_rsa`).
|
|
38
|
+
- `--unsigned` — write `signature: null`; verifies only with
|
|
39
|
+
`--allow-unsigned`, loudly.
|
|
40
|
+
|
|
41
|
+
## What merge refuses
|
|
42
|
+
|
|
43
|
+
A merged receipt must mean exactly what a single-session receipt means, so
|
|
44
|
+
`merge` hard-refuses shards that disagree on anything a receipt pins:
|
|
45
|
+
|
|
46
|
+
- `schema_version`, `repo.commit_sha`, `fingerprint` (digest + paths),
|
|
47
|
+
`mode`, or the `environment` the tests ran under (python/pytest/plugin
|
|
48
|
+
versions, platform);
|
|
49
|
+
- **duplicate node IDs across shards** — two shards attesting the same test is
|
|
50
|
+
a sharding bug in the runner, never something to dedupe silently.
|
|
51
|
+
|
|
52
|
+
`repo.dirty` is OR-ed: one dirty shard makes the merged attestation dirty, and
|
|
53
|
+
your verify-time dirty policy applies honestly. `gpu_info` is taken from the
|
|
54
|
+
first shard that has one, so a CPU-only shard doesn't erase the GPU record.
|
|
55
|
+
|
|
56
|
+
## Trust model
|
|
57
|
+
|
|
58
|
+
Consistent with the [security model](security_model.md): the merged receipt is
|
|
59
|
+
an **attestation by the merger**. Shard signatures are recorded as provenance
|
|
60
|
+
but not re-verified at merge time (merging is offline); the merged signature
|
|
61
|
+
is what CI verifies. If shards were signed by someone else, verification of
|
|
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"
|
|
@@ -72,8 +72,64 @@ def main():
|
|
|
72
72
|
"require_gpu = true in [tool.gpu_proof].",
|
|
73
73
|
)
|
|
74
74
|
|
|
75
|
+
# merge
|
|
76
|
+
mp = subparsers.add_parser(
|
|
77
|
+
"merge",
|
|
78
|
+
help="Merge shard receipts from ONE commit into a single re-signed receipt",
|
|
79
|
+
description=(
|
|
80
|
+
"Union the tests of N shard receipts (same commit SHA, fingerprint, "
|
|
81
|
+
"and environment) into one receipt, re-signed with your local SSH "
|
|
82
|
+
"key, that flows through `gpu-proof verify` unchanged. Shards that "
|
|
83
|
+
"disagree on anything a receipt pins are refused; duplicate node "
|
|
84
|
+
"IDs across shards are always an error."
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
mp.add_argument("shards", nargs="+", metavar="RECEIPT",
|
|
88
|
+
help="Shard receipt paths (two or more, typically)")
|
|
89
|
+
mp.add_argument("--out", required=True, metavar="PATH",
|
|
90
|
+
help="Path for the merged receipt")
|
|
91
|
+
mp.add_argument("--github-user", default=None, metavar="USERNAME",
|
|
92
|
+
help="Recorded signer identity for the merged receipt "
|
|
93
|
+
"(default: the first shard's repo.github_username)")
|
|
94
|
+
mp.add_argument("--key", default=None, metavar="PATH",
|
|
95
|
+
help="SSH private key to sign with (default: git "
|
|
96
|
+
"user.signingKey, then ~/.ssh/id_ed25519 etc.)")
|
|
97
|
+
mp.add_argument("--unsigned", action="store_true", default=False,
|
|
98
|
+
help="Write signature: null — the merged receipt then "
|
|
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")
|
|
109
|
+
|
|
75
110
|
args = parser.parse_args()
|
|
76
111
|
|
|
112
|
+
if args.command == "merge":
|
|
113
|
+
from .merge import MergeError, merge_receipts
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
receipt = merge_receipts(
|
|
117
|
+
args.shards, args.out,
|
|
118
|
+
github_user=args.github_user,
|
|
119
|
+
key_path=args.key,
|
|
120
|
+
unsigned=args.unsigned,
|
|
121
|
+
carry_from=args.carry_from,
|
|
122
|
+
repo_root=args.repo,
|
|
123
|
+
)
|
|
124
|
+
except MergeError as e:
|
|
125
|
+
print(f"gpu-proof merge: {e}", file=sys.stderr)
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
n = len(receipt.get("tests", []))
|
|
128
|
+
shards = len(receipt.get("session", {}).get("shards", []))
|
|
129
|
+
print(f"merged {shards} shard(s), {n} tests -> {args.out}"
|
|
130
|
+
+ (" (UNSIGNED)" if args.unsigned else ""))
|
|
131
|
+
sys.exit(0)
|
|
132
|
+
|
|
77
133
|
if args.command == "verify":
|
|
78
134
|
from .verify import verify_receipt
|
|
79
135
|
|
|
@@ -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]:
|
|
@@ -47,10 +51,13 @@ def load_config(pytest_config) -> GpuProofConfig:
|
|
|
47
51
|
return default
|
|
48
52
|
|
|
49
53
|
def resolve(opt_name, toml_key, default):
|
|
50
|
-
"""Precedence: CLI flag (when
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
"""Precedence: CLI flag (when explicitly passed), then [tool.gpu_proof]
|
|
55
|
+
in pyproject.toml, then the built-in default. Value-taking options
|
|
56
|
+
register with ``default=None`` so an explicit CLI value that happens to
|
|
57
|
+
equal the built-in default still wins — previously it was silently
|
|
58
|
+
ignored in favor of the toml value."""
|
|
59
|
+
cli = opt(opt_name, None)
|
|
60
|
+
if cli is not None:
|
|
54
61
|
return cli
|
|
55
62
|
toml_val = toml_cfg.get(toml_key)
|
|
56
63
|
if toml_val is not None:
|
|
@@ -63,6 +70,15 @@ def load_config(pytest_config) -> GpuProofConfig:
|
|
|
63
70
|
else:
|
|
64
71
|
paths = [str(p) for p in raw_paths]
|
|
65
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
|
+
|
|
66
82
|
max_age = toml_cfg.get("max_age_days")
|
|
67
83
|
max_age_days = int(max_age) if max_age is not None else 30
|
|
68
84
|
|
|
@@ -74,9 +90,14 @@ def load_config(pytest_config) -> GpuProofConfig:
|
|
|
74
90
|
signing_backend=resolve("--gpu-proof-signing-backend", "signing_backend", "ed25519"),
|
|
75
91
|
policy_path=resolve("--gpu-proof-policy", "policy_path", None),
|
|
76
92
|
required_marker=resolve("--gpu-proof-required-marker", "required_marker", "gpu_proof"),
|
|
77
|
-
|
|
93
|
+
# store_true flag: False just means "not passed", so OR with the toml
|
|
94
|
+
# value rather than sentinel-resolving (a CLI flag can only turn it ON).
|
|
95
|
+
fail_on_skip=bool(opt("--gpu-proof-fail-on-skip", False)
|
|
96
|
+
or toml_cfg.get("fail_on_skip", False)),
|
|
78
97
|
fingerprint_paths=paths,
|
|
79
98
|
github_username=resolve("--gpu-proof-github-user", "github_username", None),
|
|
80
99
|
max_age_days=max_age_days,
|
|
81
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,
|
|
82
103
|
)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Merge shard receipts from one commit into a single re-signed receipt.
|
|
3
|
+
|
|
4
|
+
Motivation: large suites run their GPU tests as several pytest invocations
|
|
5
|
+
(per-module crash isolation, machine sharding). Each invocation emits its own
|
|
6
|
+
receipt; CI wants ONE artifact to verify. ``gpu-proof merge`` unions the shard
|
|
7
|
+
receipts' ``tests`` and re-signs the result with the merger's local SSH key —
|
|
8
|
+
so the merged receipt flows through the existing ``gpu-proof verify`` path
|
|
9
|
+
completely unchanged (schema_version stays "1"; the only addition is the
|
|
10
|
+
OPTIONAL ``session.shards`` provenance list, which the verifier ignores).
|
|
11
|
+
|
|
12
|
+
Trust model (consistent with docs/security_model.md): the merged receipt is an
|
|
13
|
+
attestation by the MERGER — shard signatures are recorded as provenance but are
|
|
14
|
+
NOT verified here (merge is offline by design); the merged receipt's signature
|
|
15
|
+
is what CI verifies. Merging refuses to combine shards that disagree on
|
|
16
|
+
anything a receipt pins: schema, commit SHA, fingerprint, mode, or the
|
|
17
|
+
environment the tests ran under. Duplicate node IDs are always an error — two
|
|
18
|
+
shards attesting the same test is a sharding bug, not a merge input.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from typing import List, Optional
|
|
24
|
+
|
|
25
|
+
from .receipt import finalize_receipt, write_receipt
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MergeError(ValueError):
|
|
29
|
+
"""A refusal to merge, with an actionable message."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_receipt(path: str) -> dict:
|
|
33
|
+
try:
|
|
34
|
+
with open(path) as f:
|
|
35
|
+
data = json.load(f)
|
|
36
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
37
|
+
raise MergeError(f"{path}: not a readable receipt ({e})") from e
|
|
38
|
+
if not isinstance(data, dict) or "tests" not in data:
|
|
39
|
+
raise MergeError(f"{path}: not a gpu-proof receipt (no 'tests' key)")
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _require_identical(receipts: List[dict], sources: List[str], getter, what: str):
|
|
44
|
+
values = [getter(r) for r in receipts]
|
|
45
|
+
first = values[0]
|
|
46
|
+
for src, val in zip(sources[1:], values[1:]):
|
|
47
|
+
if val != first:
|
|
48
|
+
raise MergeError(
|
|
49
|
+
f"shards disagree on {what}: {sources[0]}={first!r} vs {src}={val!r} — "
|
|
50
|
+
f"a merged receipt must come from ONE commit/config; re-run the "
|
|
51
|
+
f"divergent shard."
|
|
52
|
+
)
|
|
53
|
+
return first
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def merge_payloads(receipts: List[dict], sources: List[str]) -> dict:
|
|
57
|
+
"""Union shard payloads into one UNSIGNED payload. Raises MergeError on any
|
|
58
|
+
disagreement over what a receipt pins."""
|
|
59
|
+
if len(receipts) < 1:
|
|
60
|
+
raise MergeError("nothing to merge")
|
|
61
|
+
|
|
62
|
+
_require_identical(receipts, sources, lambda r: r.get("schema_version"), "schema_version")
|
|
63
|
+
schema = receipts[0].get("schema_version")
|
|
64
|
+
if schema not in ("1", "2"):
|
|
65
|
+
raise MergeError(
|
|
66
|
+
f"unsupported schema_version {schema!r} (this version merges "
|
|
67
|
+
f"schema '1' and schema '2' receipts, not mixed)"
|
|
68
|
+
)
|
|
69
|
+
_require_identical(receipts, sources, lambda r: r.get("repo", {}).get("commit_sha"),
|
|
70
|
+
"repo.commit_sha")
|
|
71
|
+
_require_identical(receipts, sources, lambda r: r.get("fingerprint"), "fingerprint")
|
|
72
|
+
_require_identical(receipts, sources, lambda r: r.get("mode"), "mode")
|
|
73
|
+
for key in ("python_version", "platform", "pytest_version", "plugin_version"):
|
|
74
|
+
_require_identical(receipts, sources,
|
|
75
|
+
lambda r, k=key: r.get("environment", {}).get(k),
|
|
76
|
+
f"environment.{key}")
|
|
77
|
+
|
|
78
|
+
# tests: union; duplicate node ids are a sharding bug, never silently deduped.
|
|
79
|
+
tests: List[dict] = []
|
|
80
|
+
seen: dict = {}
|
|
81
|
+
for src, r in zip(sources, receipts):
|
|
82
|
+
for t in r.get("tests", []):
|
|
83
|
+
nid = t.get("node_id")
|
|
84
|
+
if nid in seen:
|
|
85
|
+
raise MergeError(
|
|
86
|
+
f"duplicate node_id {nid!r} in {src} (already attested by "
|
|
87
|
+
f"{seen[nid]}) — shards must partition the suite."
|
|
88
|
+
)
|
|
89
|
+
seen[nid] = src
|
|
90
|
+
tests.append(t)
|
|
91
|
+
if not tests:
|
|
92
|
+
raise MergeError("merged receipt would contain zero tests")
|
|
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
|
+
|
|
112
|
+
sessions = [r.get("session", {}) for r in receipts]
|
|
113
|
+
started = min(s.get("started_at") for s in sessions)
|
|
114
|
+
ended = max(s.get("ended_at") for s in sessions)
|
|
115
|
+
|
|
116
|
+
merged = dict(receipts[0])
|
|
117
|
+
merged.pop("signature", None)
|
|
118
|
+
# repo: the commit SHA is asserted identical above; branch/remote come from
|
|
119
|
+
# the first shard. `dirty` is OR-ed — one dirty shard makes the merged
|
|
120
|
+
# attestation dirty, and the verifier's dirty policy then applies honestly.
|
|
121
|
+
merged["repo"] = dict(receipts[0].get("repo", {}))
|
|
122
|
+
merged["repo"]["dirty"] = any(r.get("repo", {}).get("dirty") for r in receipts)
|
|
123
|
+
# environment: fields asserted identical above; gpu_info from the first
|
|
124
|
+
# shard that has one (a CPU-only shard shouldn't erase the GPU record).
|
|
125
|
+
merged["environment"] = dict(receipts[0].get("environment", {}))
|
|
126
|
+
merged["environment"]["gpu_info"] = next(
|
|
127
|
+
(r["environment"]["gpu_info"] for r in receipts
|
|
128
|
+
if r.get("environment", {}).get("gpu_info")), None)
|
|
129
|
+
merged["tests"] = tests
|
|
130
|
+
merged["session"] = {
|
|
131
|
+
"started_at": started,
|
|
132
|
+
"ended_at": ended,
|
|
133
|
+
"node_ids": [t["node_id"] for t in tests],
|
|
134
|
+
# Provenance (additive; the verifier ignores unknown session keys).
|
|
135
|
+
# Shard signers are recorded, not verified — the merged signature is
|
|
136
|
+
# the attestation CI checks.
|
|
137
|
+
"shards": [
|
|
138
|
+
{
|
|
139
|
+
"source": os.path.basename(src),
|
|
140
|
+
"node_count": len(r.get("tests", [])),
|
|
141
|
+
"started_at": s.get("started_at"),
|
|
142
|
+
"ended_at": s.get("ended_at"),
|
|
143
|
+
"signer": (r.get("signature") or {}).get("signer"),
|
|
144
|
+
}
|
|
145
|
+
for src, r, s in zip(sources, receipts, sessions)
|
|
146
|
+
],
|
|
147
|
+
}
|
|
148
|
+
if merged_shards is not None:
|
|
149
|
+
merged["shards"] = merged_shards
|
|
150
|
+
return merged
|
|
151
|
+
|
|
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
|
+
|
|
235
|
+
def merge_receipts(
|
|
236
|
+
paths: List[str],
|
|
237
|
+
out: str,
|
|
238
|
+
*,
|
|
239
|
+
github_user: Optional[str] = None,
|
|
240
|
+
key_path: Optional[str] = None,
|
|
241
|
+
unsigned: bool = False,
|
|
242
|
+
carry_from: Optional[str] = None,
|
|
243
|
+
repo_root: str = ".",
|
|
244
|
+
) -> dict:
|
|
245
|
+
"""Merge receipts at ``paths`` and write the re-signed result to ``out``.
|
|
246
|
+
|
|
247
|
+
``github_user`` overrides the recorded signer identity (default: the first
|
|
248
|
+
shard's ``repo.github_username`` — correct when the merger is also the shard
|
|
249
|
+
runner). ``unsigned=True`` writes ``signature: null`` (verifies only with
|
|
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.
|
|
253
|
+
"""
|
|
254
|
+
receipts = [load_receipt(p) for p in paths]
|
|
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)
|
|
259
|
+
if github_user:
|
|
260
|
+
payload["repo"] = dict(payload.get("repo", {}))
|
|
261
|
+
payload["repo"]["github_username"] = github_user
|
|
262
|
+
|
|
263
|
+
if unsigned:
|
|
264
|
+
receipt = dict(payload)
|
|
265
|
+
receipt["signature"] = None
|
|
266
|
+
else:
|
|
267
|
+
from .signers.ed25519 import SSHSigner
|
|
268
|
+
signer = SSHSigner(key_path=key_path)
|
|
269
|
+
receipt = finalize_receipt(payload, signer)
|
|
270
|
+
write_receipt(receipt, out)
|
|
271
|
+
return receipt
|
|
@@ -201,13 +201,13 @@ def pytest_addoption(parser):
|
|
|
201
201
|
)
|
|
202
202
|
group.addoption(
|
|
203
203
|
"--gpu-proof-mode",
|
|
204
|
-
default=
|
|
204
|
+
default=None,
|
|
205
205
|
choices=["local", "ci-gpu"],
|
|
206
206
|
help="Execution mode: local (default) or ci-gpu",
|
|
207
207
|
)
|
|
208
208
|
group.addoption(
|
|
209
209
|
"--gpu-proof-out",
|
|
210
|
-
default=
|
|
210
|
+
default=None,
|
|
211
211
|
metavar="PATH",
|
|
212
212
|
help="Output path for the receipt JSON (default: gpu-proof.json)",
|
|
213
213
|
)
|
|
@@ -219,7 +219,7 @@ def pytest_addoption(parser):
|
|
|
219
219
|
)
|
|
220
220
|
group.addoption(
|
|
221
221
|
"--gpu-proof-signing-backend",
|
|
222
|
-
default=
|
|
222
|
+
default=None,
|
|
223
223
|
choices=["ed25519", "none"],
|
|
224
224
|
help="Signing backend (default: ed25519 via SSH key)",
|
|
225
225
|
)
|
|
@@ -231,7 +231,7 @@ def pytest_addoption(parser):
|
|
|
231
231
|
)
|
|
232
232
|
group.addoption(
|
|
233
233
|
"--gpu-proof-required-marker",
|
|
234
|
-
default=
|
|
234
|
+
default=None,
|
|
235
235
|
help="Marker name that flags a test for the receipt (default: gpu_proof)",
|
|
236
236
|
)
|
|
237
237
|
group.addoption(
|
|
@@ -242,10 +242,25 @@ def pytest_addoption(parser):
|
|
|
242
242
|
)
|
|
243
243
|
group.addoption(
|
|
244
244
|
"--gpu-proof-fingerprint-paths",
|
|
245
|
-
default=
|
|
245
|
+
default=None,
|
|
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,
|