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.
Files changed (78) hide show
  1. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/CHANGELOG.md +32 -0
  2. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/PKG-INFO +1 -1
  3. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/security_model.md +12 -0
  4. pytest_gpu_proof-0.3.0/docs/sharding.md +109 -0
  5. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/mkdocs.yml +1 -0
  6. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/pyproject.toml +1 -1
  7. pytest_gpu_proof-0.3.0/src/pytest_gpu_proof/__init__.py +1 -0
  8. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/cli.py +56 -0
  9. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/config.py +26 -5
  10. pytest_gpu_proof-0.3.0/src/pytest_gpu_proof/merge.py +271 -0
  11. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/plugin.py +20 -5
  12. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/receipt.py +25 -2
  13. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/verify.py +74 -1
  14. pytest_gpu_proof-0.3.0/tests/test_merge.py +163 -0
  15. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_plugin_capture.py +31 -0
  16. pytest_gpu_proof-0.3.0/tests/test_sharding.py +282 -0
  17. pytest_gpu_proof-0.1.0/src/pytest_gpu_proof/__init__.py +0 -1
  18. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.github/workflows/ci.yml +0 -0
  19. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.github/workflows/docs.yml +0 -0
  20. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.github/workflows/publish-testpypi.yml +0 -0
  21. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.github/workflows/publish.yml +0 -0
  22. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/.gitignore +0 -0
  23. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/CLAUDE.md +0 -0
  24. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/LICENSE +0 -0
  25. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/README.md +0 -0
  26. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/RELEASING.md +0 -0
  27. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/ROADMAP.md +0 -0
  28. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/architecture.md +0 -0
  29. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/ci_gpu_mode.md +0 -0
  30. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/index.md +0 -0
  31. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/landscape.md +0 -0
  32. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/local_mode.md +0 -0
  33. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/docs/quickstart.md +0 -0
  34. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/Makefile +0 -0
  35. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/README.md +0 -0
  36. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/cuda_ctypes_matmul.py +0 -0
  37. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/matmul_kernel.cu +0 -0
  38. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_ctypes_matmul/test_cuda_ctypes_matmul.py +0 -0
  39. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/CMakeLists.txt +0 -0
  40. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/README.md +0 -0
  41. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/bindings.cpp +0 -0
  42. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_cuda.h +0 -0
  43. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/src/matmul_kernel.cu +0 -0
  44. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_nanobind_matmul/test_cuda_nanobind_matmul.py +0 -0
  45. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/CMakeLists.txt +0 -0
  46. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/README.md +0 -0
  47. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/bindings.cpp +0 -0
  48. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_cuda.h +0 -0
  49. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/src/matmul_kernel.cu +0 -0
  50. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/cuda_pybind11_matmul/test_cuda_pybind11_matmul.py +0 -0
  51. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/github_gpu_runner/.github/workflows/gpu-test.yml +0 -0
  52. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/CMakeLists.txt +0 -0
  53. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/README.md +0 -0
  54. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/jax_ffi_matmul.py +0 -0
  55. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/jax_ffi_cuda_matmul/src/matmul_ffi.cu +0 -0
  56. {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
  57. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/local_receipt_verify/.github/workflows/verify.yml +0 -0
  58. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/conftest.py +0 -0
  59. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/minimal_python_only/test_minimal.py +0 -0
  60. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/fake_cuda_lib.py +0 -0
  61. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/examples/wrapped_cuda_ctypes/test_cuda_wrapper.py +0 -0
  62. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/install.sh +0 -0
  63. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/requirements-dev.txt +0 -0
  64. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/requirements.txt +0 -0
  65. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/__main__.py +0 -0
  66. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/compare.py +0 -0
  67. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/fingerprint.py +0 -0
  68. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/gitutils.py +0 -0
  69. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/__init__.py +0 -0
  70. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/base.py +0 -0
  71. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/src/pytest_gpu_proof/signers/ed25519.py +0 -0
  72. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/conftest.py +0 -0
  73. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_compare.py +0 -0
  74. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_fingerprint.py +0 -0
  75. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_gitutils.py +0 -0
  76. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_receipt.py +0 -0
  77. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.3.0}/tests/test_signing.py +0 -0
  78. {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.1.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.
@@ -33,6 +33,7 @@ nav:
33
33
  - Quickstart: quickstart.md
34
34
  - Local Mode: local_mode.md
35
35
  - CI-GPU Mode: ci_gpu_mode.md
36
+ - Sharding & Merge: sharding.md
36
37
  - Architecture: architecture.md
37
38
  - Security Model: security_model.md
38
39
  - Landscape: landscape.md
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-gpu-proof"
7
- version = "0.1.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 it differs from its built-in default),
51
- then [tool.gpu_proof] in pyproject.toml, then the built-in default."""
52
- cli = opt(opt_name, default)
53
- if cli != default:
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
- fail_on_skip=bool(resolve("--gpu-proof-fail-on-skip", "fail_on_skip", False)),
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="local",
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="gpu-proof.json",
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="ed25519",
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="gpu_proof",
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="src,tests",
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,