quantbom 0.6.0__py3-none-any.whl

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 (45) hide show
  1. quantbom/__init__.py +497 -0
  2. quantbom/_bitorder.py +121 -0
  3. quantbom/_circuit.py +77 -0
  4. quantbom/_collect.py +661 -0
  5. quantbom/_drift.py +333 -0
  6. quantbom/_fidelity.py +280 -0
  7. quantbom/_merge.py +187 -0
  8. quantbom/_qir.py +224 -0
  9. quantbom/_scan.py +118 -0
  10. quantbom/_sign.py +284 -0
  11. quantbom/_stats.py +125 -0
  12. quantbom/_verify.py +530 -0
  13. quantbom/actions/__init__.py +27 -0
  14. quantbom/actions/base.py +68 -0
  15. quantbom/actions/benchmark.py +53 -0
  16. quantbom/actions/bind_backend.py +71 -0
  17. quantbom/actions/capture.py +106 -0
  18. quantbom/actions/check_qir.py +44 -0
  19. quantbom/actions/check_results.py +75 -0
  20. quantbom/actions/compare_native.py +88 -0
  21. quantbom/actions/disclose_error_handling.py +66 -0
  22. quantbom/actions/execute.py +85 -0
  23. quantbom/actions/inventory.py +24 -0
  24. quantbom/actions/layout.py +118 -0
  25. quantbom/actions/prove_equivalence.py +225 -0
  26. quantbom/actions/record_provenance.py +49 -0
  27. quantbom/actions/verify_conversion.py +53 -0
  28. quantbom/attest.py +363 -0
  29. quantbom/cli.py +378 -0
  30. quantbom/frameworks/__init__.py +67 -0
  31. quantbom/frameworks/base.py +155 -0
  32. quantbom/frameworks/braket.py +132 -0
  33. quantbom/frameworks/cirq.py +106 -0
  34. quantbom/frameworks/cudaq.py +223 -0
  35. quantbom/frameworks/pennylane.py +116 -0
  36. quantbom/frameworks/qiskit.py +67 -0
  37. quantbom/frameworks/qrisp.py +267 -0
  38. quantbom/harness.json +1730 -0
  39. quantbom/harness.py +88 -0
  40. quantbom-0.6.0.dist-info/METADATA +207 -0
  41. quantbom-0.6.0.dist-info/RECORD +45 -0
  42. quantbom-0.6.0.dist-info/WHEEL +5 -0
  43. quantbom-0.6.0.dist-info/entry_points.txt +2 -0
  44. quantbom-0.6.0.dist-info/licenses/LICENSE +202 -0
  45. quantbom-0.6.0.dist-info/top_level.txt +1 -0
quantbom/_sign.py ADDED
@@ -0,0 +1,284 @@
1
+ """Producer signing from a pip install (hybrid signing model, step 2 — MVP_PLAN.md B1).
2
+
3
+ The producer's signature is the certificate's authorship claim: the releasing
4
+ organisation signed its own evidence with its own key. Until now that needed the
5
+ repository's `sign_evidence.py`; this module is the same construction in the
6
+ wheel — Ed25519 over an in-toto Statement in a DSSE envelope, the subjects being
7
+ the evidence artifacts the QBOM references, bound by SHA-256 — so a bundle
8
+ signed here verifies in the gate's `sign_evidence.verify`, byte for byte.
9
+
10
+ quantbom keygen # ~/.quantbom/signing.key (0600) + signing.pub
11
+ quantbom sign --bundle bundle/ # evidence/attestation.json, signing properties
12
+ quantbom certify ... --sign # collect, sign, certify in one go
13
+
14
+ Needs `cryptography` (pip install "quantbom[signing]"); the core wheel stays
15
+ stdlib-only, so the import is lazy and the error says what to install.
16
+
17
+ Kept deliberately identical to sign_evidence.py where the bytes matter
18
+ (PAYLOAD_TYPE, PREDICATE_TYPE, the PAE, the subject order, the key id). The
19
+ `builder.id` differs ("secqdevops/quantbom-sdk") so an attestation says which
20
+ tool made it; the verifier does not read it.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import stat
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ PAYLOAD_TYPE = "application/vnd.in-toto+json"
34
+ PREDICATE_TYPE = "https://secqdevops/qbom-evidence/v0.1"
35
+ BUILDER_ID = "secqdevops/quantbom-sdk"
36
+ ROLE = "producer"
37
+
38
+ # Artifact refs whose bytes become attestation subjects — the gate's list
39
+ # (sign_evidence.SUBJECT_REF_PROPS); tests/harness/test_sdk_sign.py pins the two equal.
40
+ SUBJECT_REF_PROPS = [
41
+ "secqdevops:circuit-artifact-ref",
42
+ "secqdevops:transpiled-circuit-ref",
43
+ "secqdevops:qasm-program-ref",
44
+ "secqdevops:native-program-ref",
45
+ "secqdevops:qir-program-ref",
46
+ "secqdevops:benchmark-ref",
47
+ "secqdevops:equivalence-ref",
48
+ "secqdevops:expected-distribution-ref",
49
+ "secqdevops:calibration-ref",
50
+ "secqdevops:provenance-artifact-ref",
51
+ "secqdevops:ct-proof-ref",
52
+ "secqdevops:vex-ref",
53
+ "secqdevops:cvd-policy-ref",
54
+ "secqdevops:test-report-ref",
55
+ ]
56
+
57
+ # The producer's properties and files; a service countersignature uses its own
58
+ # (service-*) and is never touched here.
59
+ SIGNING_PROPERTY_NAMES = {
60
+ "secqdevops:signature-ref",
61
+ "secqdevops:signing-pubkey-ref",
62
+ "secqdevops:signing-key-id",
63
+ "secqdevops:attestation-sha256",
64
+ }
65
+ ATTESTATION_REL = "evidence/attestation.json"
66
+ PUBKEY_REL = "evidence/signing.pub"
67
+
68
+ # Where `quantbom keygen` puts the key and where `--sign` looks for it when no
69
+ # path is given: the env var, then the user's home. Never the bundle, never
70
+ # the project directory — a private key must not travel with the evidence.
71
+ KEY_ENV = "QUANTBOM_SIGNING_KEY"
72
+ DEFAULT_KEY_DIR = Path.home() / ".quantbom"
73
+ LEGACY_KEY_DIR = Path.home() / ".zentroq" # before the rename (0.6.0); read, never written
74
+ PRIVATE_KEY_NAME = "signing.key"
75
+ PUBLIC_KEY_NAME = "signing.pub"
76
+
77
+
78
+ class SigningUnavailable(RuntimeError):
79
+ """`cryptography` is not installed."""
80
+
81
+
82
+ def _load_crypto():
83
+ try:
84
+ from cryptography.hazmat.primitives import serialization
85
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
86
+ except ImportError as exc: # the one dependency signing needs
87
+ raise SigningUnavailable('signing needs the cryptography package: pip install "quantbom[signing]"') from exc
88
+ return serialization, Ed25519PrivateKey
89
+
90
+
91
+ def _canonical(data: Any) -> bytes:
92
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
93
+
94
+
95
+ def _sha256(raw: bytes) -> str:
96
+ return hashlib.sha256(raw).hexdigest()
97
+
98
+
99
+ def _pae(payload: bytes) -> bytes:
100
+ """DSSE Pre-Authentication Encoding over the payload."""
101
+ pt = PAYLOAD_TYPE.encode("utf-8")
102
+ return b"DSSEv1 " + str(len(pt)).encode() + b" " + pt + b" " + str(len(payload)).encode() + b" " + payload
103
+
104
+
105
+ def key_id(public_key_raw: bytes) -> str:
106
+ """sha256 of the raw 32-byte public key, as /v1/keys and the gate compute it."""
107
+ return "sha256:" + _sha256(public_key_raw)
108
+
109
+
110
+ def _write_json(path: Path, data: Any) -> None:
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
113
+
114
+
115
+ def _now() -> str:
116
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
117
+
118
+
119
+ def release_identity(sbom: dict[str, Any]) -> str:
120
+ """The release id a QBOM describes: `name@version` from metadata.component.
121
+
122
+ QBOMs before SDK 0.5.3 named the component `quantum-workload-<release_id>`
123
+ with a placeholder version; for those the name alone is the identity, as it
124
+ always was.
125
+ """
126
+ component = (sbom.get("metadata") or {}).get("component") or {}
127
+ name = component.get("name") or "unknown"
128
+ version = component.get("version")
129
+ if name.startswith("quantum-workload-") or not version:
130
+ return name
131
+ return f"{name}@{version}"
132
+
133
+
134
+ def _properties(sbom: dict[str, Any]) -> dict[str, str]:
135
+ return {str(p["name"]): str(p["value"]) for p in sbom.get("metadata", {}).get("properties", [])
136
+ if p.get("name") and p.get("value") is not None}
137
+
138
+
139
+ def _public_raw(private) -> bytes:
140
+ serialization, _ = _load_crypto()
141
+ return private.public_key().public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)
142
+
143
+
144
+ def _public_pem(private) -> bytes:
145
+ serialization, _ = _load_crypto()
146
+ return private.public_key().public_bytes(encoding=serialization.Encoding.PEM,
147
+ format=serialization.PublicFormat.SubjectPublicKeyInfo)
148
+
149
+
150
+ def keygen(key_dir: str | os.PathLike | None = None, *, overwrite: bool = False) -> dict[str, str]:
151
+ """Generate a producer Ed25519 key pair.
152
+
153
+ Writes `signing.key` (PKCS8 PEM, mode 0600) and `signing.pub` (SPKI PEM)
154
+ into key_dir (default ~/.quantbom). Refuses to overwrite an existing key
155
+ unless asked: a lost private key cannot be recovered, and a replaced one
156
+ changes the key id every certificate so far names.
157
+ """
158
+ serialization, Ed25519PrivateKey = _load_crypto()
159
+ key_dir = Path(key_dir) if key_dir is not None else DEFAULT_KEY_DIR
160
+ priv_path, pub_path = key_dir / PRIVATE_KEY_NAME, key_dir / PUBLIC_KEY_NAME
161
+ if priv_path.exists() and not overwrite:
162
+ raise FileExistsError(f"{priv_path} exists; pass overwrite=True (quantbom keygen --force) to replace it — "
163
+ "certificates already issued name the old key id")
164
+ key_dir.mkdir(parents=True, exist_ok=True)
165
+ private = Ed25519PrivateKey.generate()
166
+ priv_pem = private.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8,
167
+ encryption_algorithm=serialization.NoEncryption())
168
+ # owner-only from the first byte: create with 0600, never write then chmod
169
+ fd = os.open(priv_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
170
+ with os.fdopen(fd, "wb") as fh:
171
+ fh.write(priv_pem)
172
+ os.chmod(priv_path, stat.S_IRUSR | stat.S_IWUSR) # O_TRUNC on an existing file keeps its old mode
173
+ pub_path.write_bytes(_public_pem(private))
174
+ return {"private_key": str(priv_path), "public_key": str(pub_path), "key_id": key_id(_public_raw(private))}
175
+
176
+
177
+ def resolve_key(key: str | os.PathLike | None = None) -> Path:
178
+ """The private key to sign with: the argument, else $QUANTBOM_SIGNING_KEY, else ~/.quantbom/signing.key."""
179
+ candidate = Path(key) if key else (Path(os.environ[KEY_ENV]) if os.environ.get(KEY_ENV) else DEFAULT_KEY_DIR / PRIVATE_KEY_NAME)
180
+ if not key and not os.environ.get(KEY_ENV) and not candidate.is_file() and (LEGACY_KEY_DIR / PRIVATE_KEY_NAME).is_file():
181
+ candidate = LEGACY_KEY_DIR / PRIVATE_KEY_NAME
182
+ if not candidate.is_file():
183
+ raise FileNotFoundError(f"no signing key at {candidate}: run `quantbom keygen`, or point --key / {KEY_ENV} at your key")
184
+ return candidate
185
+
186
+
187
+ def load_private_key(path: str | os.PathLike):
188
+ serialization, _ = _load_crypto()
189
+ return serialization.load_pem_private_key(Path(path).read_bytes(), password=None)
190
+
191
+
192
+ def public_key_id(path: str | os.PathLike) -> str:
193
+ """Key id of a public (SPKI PEM) or private key file."""
194
+ serialization, _ = _load_crypto()
195
+ raw = Path(path).read_bytes()
196
+ try:
197
+ key = serialization.load_pem_public_key(raw)
198
+ except ValueError:
199
+ key = serialization.load_pem_private_key(raw, password=None).public_key()
200
+ return key_id(key.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw))
201
+
202
+
203
+ def build_statement(sbom: dict[str, Any], base_dir: Path, *, created_at: str,
204
+ omit_refs: tuple[str, ...] = ()) -> dict[str, Any]:
205
+ """The in-toto Statement the producer signs.
206
+
207
+ Every referenced artifact present on disk becomes a subject, keyed by the
208
+ property NAME (not the path) so the signature survives ref rewriting when
209
+ the bundle is packed. `omit_refs` leaves out artifacts that will not travel
210
+ (privacy="strict" withholds the circuit IR): the gate verifies each subject
211
+ against the bytes it received, so a subject it cannot see would read as
212
+ tampering. Their digests still travel as claimed, hash-bound properties.
213
+ """
214
+ props = _properties(sbom)
215
+ subjects = []
216
+ for ref_prop in SUBJECT_REF_PROPS:
217
+ ref = props.get(ref_prop)
218
+ if not ref or ref_prop in omit_refs:
219
+ continue
220
+ path = (base_dir / ref).resolve()
221
+ if not path.exists():
222
+ continue
223
+ subjects.append({"name": ref_prop, "uri": ref, "digest": {"sha256": _sha256(path.read_bytes())}})
224
+ release_id = release_identity(sbom)
225
+ return {
226
+ "_type": "https://in-toto.io/Statement/v1",
227
+ "subject": subjects,
228
+ "predicateType": PREDICATE_TYPE,
229
+ "predicate": {
230
+ "release_id": release_id,
231
+ "qbom_profile_version": "0.3.0",
232
+ "builder": {"id": BUILDER_ID},
233
+ "signer_role": ROLE,
234
+ "created_at": created_at,
235
+ },
236
+ }
237
+
238
+
239
+ def sign(bundle: str | os.PathLike, key: str | os.PathLike | None = None, *,
240
+ created_at: str | None = None, omit_refs: tuple[str, ...] = ()) -> dict[str, Any]:
241
+ """Sign a collected bundle as its producer.
242
+
243
+ Writes evidence/attestation.json and evidence/signing.pub into the bundle
244
+ and (re)writes the four producer signing properties on sbom.cdx.json;
245
+ anything else in the QBOM, including a service countersignature, is left
246
+ as it was. Signing twice replaces the producer signature.
247
+ """
248
+ bundle_dir = Path(bundle)
249
+ sbom_path = bundle_dir / "sbom.cdx.json"
250
+ if not sbom_path.is_file():
251
+ raise FileNotFoundError(f"{bundle_dir} is not an evidence bundle (no sbom.cdx.json); make one with `quantbom collect`")
252
+ private = load_private_key(resolve_key(key))
253
+ kid = key_id(_public_raw(private))
254
+
255
+ sbom = json.loads(sbom_path.read_text(encoding="utf-8"))
256
+ statement = build_statement(sbom, bundle_dir, created_at=created_at or _now(), omit_refs=omit_refs)
257
+ payload = _canonical(statement)
258
+ envelope = {
259
+ "payloadType": PAYLOAD_TYPE,
260
+ "payload": base64.b64encode(payload).decode("ascii"),
261
+ "signatures": [{"keyid": kid, "sig": base64.b64encode(private.sign(_pae(payload))).decode("ascii")}],
262
+ }
263
+ attestation_path = bundle_dir / ATTESTATION_REL
264
+ _write_json(attestation_path, envelope)
265
+ (bundle_dir / PUBKEY_REL).write_bytes(_public_pem(private))
266
+ attestation_sha = "sha256:" + _sha256(attestation_path.read_bytes())
267
+
268
+ metadata = sbom.setdefault("metadata", {})
269
+ kept = [p for p in metadata.get("properties", []) if p.get("name") not in SIGNING_PROPERTY_NAMES]
270
+ metadata["properties"] = kept + [
271
+ {"name": "secqdevops:signature-ref", "value": ATTESTATION_REL},
272
+ {"name": "secqdevops:signing-pubkey-ref", "value": PUBKEY_REL},
273
+ {"name": "secqdevops:signing-key-id", "value": kid},
274
+ {"name": "secqdevops:attestation-sha256", "value": attestation_sha},
275
+ ]
276
+ _write_json(sbom_path, sbom)
277
+ return {
278
+ "release_id": statement["predicate"]["release_id"],
279
+ "role": ROLE,
280
+ "key_id": kid,
281
+ "subjects": len(statement["subject"]),
282
+ "attestation": str(attestation_path),
283
+ "attestation_sha256": attestation_sha,
284
+ }
quantbom/_stats.py ADDED
@@ -0,0 +1,125 @@
1
+ """Statistics behind QBOM-BENCH-02 — one definition for the SDK and the gate.
2
+
3
+ A measured distribution is a sample. Its total-variation distance (TVD) from a
4
+ declared expectation carries sampling error, so "within tolerance" at 1,024
5
+ shots on a 12-qubit circuit decides nothing. This module makes that explicit:
6
+
7
+ * the TVD, computed the one way the collector and the gate both use;
8
+ * a distribution-free bound on the sampling error of the empirical TVD
9
+ (Weissman et al. 2003, L1 deviation of the multinomial): with probability
10
+ at least 1 - alpha, TVD(empirical, true) <= sqrt(ln((2^k - 2)/alpha) / (2N)),
11
+ where k is the number of outcome classes. Outcomes the expectation gives
12
+ no mass are collapsed into one class, which leaves the TVD unchanged and
13
+ keeps k small: k = |declared support| + 1;
14
+ * the shots needed for that error to be at most half the tolerance;
15
+ * a three-way decision: `within` when even the worst-case sampling error
16
+ keeps the TVD inside the tolerance, `exceeded` when it keeps it outside,
17
+ and `undecided` otherwise — which the gate reports as N-A, never as PASS
18
+ or FAIL.
19
+
20
+ Standard library only. This file is `result_statistics.py` at the repository
21
+ root and, byte for byte, `sdk/quantbom/_stats.py`; the harness contract fails
22
+ when the two differ.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ from typing import Any
28
+
29
+ CONFIDENCE = 0.95
30
+
31
+
32
+ def _frequencies(counts: dict[str, Any], shots: int) -> dict[str, float]:
33
+ if not shots:
34
+ return {}
35
+ return {str(k).replace(" ", ""): float(v) / shots for k, v in (counts or {}).items()}
36
+
37
+
38
+ def _declared(expected: dict[str, Any]) -> dict[str, float]:
39
+ return {str(k).replace(" ", ""): float(v) for k, v in (expected or {}).items()}
40
+
41
+
42
+ def total_variation_distance(counts: dict[str, Any], shots: int, expected: dict[str, Any]) -> float:
43
+ observed, declared = _frequencies(counts, shots), _declared(expected)
44
+ return 0.5 * sum(abs(observed.get(k, 0.0) - declared.get(k, 0.0)) for k in set(observed) | set(declared))
45
+
46
+
47
+ def outcome_classes(expected: dict[str, Any]) -> int:
48
+ """|declared support| + 1: every undeclared outcome is one class ("other")."""
49
+ return len(_declared(expected)) + 1
50
+
51
+
52
+ def sampling_error(shots: int, k: int, confidence: float = CONFIDENCE) -> float:
53
+ """Bound on TVD(empirical, true) that holds with probability >= confidence."""
54
+ if shots <= 0:
55
+ return float("inf")
56
+ alpha = 1.0 - confidence
57
+ classes = max(int(k), 2)
58
+ return math.sqrt(math.log((2.0 ** classes - 2.0) / alpha) / (2.0 * shots))
59
+
60
+
61
+ def required_shots(k: int, tolerance: float, confidence: float = CONFIDENCE) -> int:
62
+ """Smallest N whose sampling error is at most half the tolerance."""
63
+ if tolerance <= 0:
64
+ raise ValueError("tolerance must be positive")
65
+ alpha = 1.0 - confidence
66
+ classes = max(int(k), 2)
67
+ half = tolerance / 2.0
68
+ return int(math.ceil(math.log((2.0 ** classes - 2.0) / alpha) / (2.0 * half * half)))
69
+
70
+
71
+ def decide(tvd: float, error: float, tolerance: float) -> str:
72
+ if tvd + error <= tolerance:
73
+ return "within"
74
+ if tvd - error > tolerance:
75
+ return "exceeded"
76
+ return "undecided"
77
+
78
+
79
+ NATIVE_VERDICTS = {"within": "agree", "exceeded": "disagree", "undecided": "undecided"}
80
+
81
+
82
+ def compare_samples(shared: dict[str, Any], shared_shots: int, native: dict[str, Any], native_shots: int,
83
+ tolerance: float, confidence: float = CONFIDENCE) -> dict[str, Any]:
84
+ """QNT-09: two independent samples of what should be one distribution — the
85
+ shared-format run and the framework's own simulator — compared by TVD.
86
+
87
+ Each empirical distribution is within its own sampling error of the truth,
88
+ so their TVD is within the sum of the two bounds of the true distance
89
+ (triangle inequality). The miss probability is split evenly between them,
90
+ so the bound holds jointly with probability >= confidence. Classes are the
91
+ outcomes seen in either sample plus one for everything unseen.
92
+ """
93
+ a, b = _frequencies(shared, shared_shots), _frequencies(native, native_shots)
94
+ tvd = 0.5 * sum(abs(a.get(k, 0.0) - b.get(k, 0.0)) for k in set(a) | set(b))
95
+ k = len(set(a) | set(b)) + 1
96
+ each = 1.0 - (1.0 - confidence) / 2.0
97
+ error = sampling_error(shared_shots, k, each) + sampling_error(native_shots, k, each)
98
+ return {
99
+ "tvd": round(tvd, 6),
100
+ "shared_shots": int(shared_shots),
101
+ "native_shots": int(native_shots),
102
+ "classes": k,
103
+ "confidence": confidence,
104
+ "sampling_error": round(error, 6) if math.isfinite(error) else None,
105
+ "tolerance": float(tolerance),
106
+ "verdict": NATIVE_VERDICTS[decide(tvd, error, tolerance)],
107
+ }
108
+
109
+
110
+ def assess(counts: dict[str, Any], shots: int, expected: dict[str, Any], tolerance: float,
111
+ confidence: float = CONFIDENCE) -> dict[str, Any]:
112
+ """Everything QBOM-BENCH-02 records, from the measured counts and the declaration."""
113
+ k = outcome_classes(expected)
114
+ tvd = total_variation_distance(counts, shots, expected)
115
+ error = sampling_error(shots, k, confidence)
116
+ return {
117
+ "tvd": round(tvd, 6),
118
+ "shots": int(shots),
119
+ "classes": k,
120
+ "confidence": confidence,
121
+ "sampling_error": round(error, 6) if math.isfinite(error) else None,
122
+ "required_shots": required_shots(k, tolerance, confidence),
123
+ "tolerance": float(tolerance),
124
+ "verdict": decide(tvd, error, tolerance),
125
+ }