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/__init__.py ADDED
@@ -0,0 +1,497 @@
1
+ """QuantBOM SDK — certify a quantum release from your running Qrisp/Qiskit code.
2
+
3
+ One env var + one line. Evidence is collected locally (your source never leaves
4
+ the machine); only the QBOM/evidence bundle is sent to the QuantBOM service, which
5
+ returns a signed CRA certificate that is also mirrored to ./.quantbom/<release>/.
6
+
7
+ export QUANTBOM_API_KEY=qb_live_...
8
+ import quantbom
9
+ result = quantbom.certify(my_circuit, release="portfolio-opt@1.2.3")
10
+ print(result["overall_status"], result["certificate"]["certificate_id"])
11
+
12
+ Integration modes:
13
+ - post-run call: quantbom.certify(circuit, release=...)
14
+ - decorator: @quantbom.certified(release="...") (the function returns a circuit)
15
+ - CLI: quantbom certify --qpy circuit.qpy --release ... (see quantbom.cli)
16
+ - two-step: quantbom.collect(...) -> add partner evidence -> quantbom.certify(bundle=...)
17
+
18
+ Fail-soft by default: if the service is unreachable or credits are exhausted, a
19
+ warning is logged and your quantum workflow continues. Pass mode="gate" to raise
20
+ / exit non-zero on a non-compliant or failed result (for CI).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import io
25
+ import json
26
+ import os
27
+ import sys
28
+ import urllib.error
29
+ import urllib.request
30
+ import zipfile
31
+ from pathlib import Path
32
+ from typing import Any, Callable
33
+
34
+ # QuantBOM was called ZentroQ until 0.6.0: ZENTROQ_* settings keep working
35
+ for _name, _value in list(os.environ.items()):
36
+ if _name.startswith("ZENTROQ_"):
37
+ os.environ.setdefault("QUANTBOM_" + _name[len("ZENTROQ_"):], _value)
38
+
39
+ __all__ = ["certify", "certified", "collect", "configure", "keygen", "pin_keys", "sign", "verify", "wrap"]
40
+
41
+ DEFAULT_API_URL = os.environ.get("QUANTBOM_API_URL", "http://127.0.0.1:8088")
42
+ MIRROR_FILES = ("csnap.json", "certificate.json", "certificate.json.att.json", "signing.pub", "verification.json")
43
+
44
+ _config: dict[str, Any] = {"api_url": DEFAULT_API_URL, "api_key": os.environ.get("QUANTBOM_API_KEY"), "mirror_dir": ".quantbom"}
45
+
46
+
47
+ def configure(api_url: str | None = None, api_key: str | None = None, mirror_dir: str | None = None) -> None:
48
+ if api_url:
49
+ _config["api_url"] = api_url
50
+ if api_key:
51
+ _config["api_key"] = api_key
52
+ if mirror_dir:
53
+ _config["mirror_dir"] = mirror_dir
54
+
55
+
56
+ def _warn(msg: str) -> None:
57
+ print(f"[quantbom] {msg}", file=sys.stderr)
58
+
59
+
60
+ def _detect_circuit(circuit: Any) -> tuple[Any, str, Any, str]:
61
+ """Return (qiskit_circuit, frontend_label, native_object_or_None, source_format).
62
+
63
+ Every supported frontend lowers to the normalized IR (qiskit.QuantumCircuit)
64
+ through its adapter in `frameworks/`; conversion libraries are lazy-imported
65
+ — if you built the object, you have its framework installed.
66
+ """
67
+ try:
68
+ import qiskit # noqa: F401
69
+ except Exception as exc: # pragma: no cover
70
+ raise RuntimeError("qiskit is required to collect evidence (pip install qiskit)") from exc
71
+ from .frameworks import detect
72
+
73
+ adapter = detect(circuit)
74
+ from ._circuit import refuse_dynamic, refuse_open
75
+
76
+ refuse_open(adapter.open_parameters(circuit), how="resolve them in your framework, or give each QNode argument a value")
77
+ if not adapter.supports_dynamic: # before lowering: one message, not the exporter's
78
+ refuse_dynamic(adapter.dynamic_features(circuit), adapter.name)
79
+ qiskit_circuit, label, native = adapter.lower(circuit)
80
+ return qiskit_circuit, label, native, adapter.name
81
+
82
+
83
+ def _collect_evidence(release: str, evidence_dir: Path, *, circuit=None, qpy=None, qasm3=None, backend: str = "basic", equivalence: bool = False, expected=None, tolerance: float = 0.05, expected_order: str = "native") -> Path:
84
+ """Build the local evidence bundle (the mirror) using the bundled collector."""
85
+ from . import _collect as qe
86
+ from ._bitorder import to_measured_order
87
+
88
+ fidelity = None
89
+ native_export = None
90
+ native_result = None
91
+ if circuit is not None:
92
+ qk, label, qrisp_circ, fmt = _detect_circuit(circuit)
93
+ # QNT-06: only here is the native Cirq/Braket/PennyLane object still in
94
+ # hand, so the conversion is checked before it is discarded — and its
95
+ # export kept, to be written hash-bound beside the report.
96
+ from ._fidelity import assess_conversion, sample_native
97
+ from .frameworks import get as _get
98
+
99
+ fidelity = assess_conversion(circuit, qk, fmt)
100
+ # QNT-09: the same native object, sampled on its own framework's simulator
101
+ native_result = sample_native(circuit, qk, fmt)
102
+ if fidelity is not None:
103
+ try:
104
+ native_export = _get(fmt).native_export(circuit)
105
+ except Exception: # the report already says what the export could not do
106
+ native_export = None
107
+ elif qpy is not None:
108
+ qk, label, qrisp_circ, fmt = qe.load_circuit("qpy", str(qpy))
109
+ elif qasm3 is not None:
110
+ qk, label, qrisp_circ, fmt = qe.load_circuit("qasm3", str(qasm3))
111
+ else:
112
+ raise ValueError("provide one of: circuit=, qpy=, qasm3=")
113
+
114
+ # Expected results are written in the framework's own bit order; results
115
+ # are measured in Qiskit's. Translate (and validate) before anything is
116
+ # written, so a malformed declaration fails here rather than as `exceeded`.
117
+ declaration = None
118
+ if expected:
119
+ from .frameworks import get as _adapter
120
+
121
+ adapter = _adapter(fmt) if circuit is not None else None # file inputs are Qiskit's own
122
+ expected, declaration = to_measured_order(
123
+ expected, fmt, qk, expected_order,
124
+ native_order=adapter.bit_order_for(circuit) if adapter else None,
125
+ measured_order=adapter.measured_order_for(circuit) if adapter else "qubit0_last",
126
+ )
127
+
128
+ evidence_dir.mkdir(parents=True, exist_ok=True)
129
+ qe.collect(release_id=release, out_dir=evidence_dir, qiskit_circuit=qk,
130
+ frontend_label=label, qrisp_circuit=qrisp_circ, source_format=fmt, backend_choice=backend,
131
+ equivalence_check=equivalence, expected_distribution=expected, expected_tolerance=tolerance,
132
+ expected_declaration=declaration, conversion_fidelity=fidelity, native_export=native_export,
133
+ native_result=native_result)
134
+ return evidence_dir / "sbom.cdx.json"
135
+
136
+
137
+ def collect(circuit=None, release: str = "release", out_dir=None, *, qpy=None, qasm3=None, backend: str = "basic",
138
+ equivalence: bool = False, expected=None, tolerance: float = 0.05, expected_order: str = "native",
139
+ environment: bool = False, syft_sbom=None, vuln_scan: bool = False, grype_output=None) -> Path:
140
+ """Collect the evidence bundle locally, without contacting the service.
141
+
142
+ The first half of `certify()`, for when evidence from elsewhere must join
143
+ the bundle before it is certified: partner modules' attestations (MOD-05
144
+ tests, MOD-07 vulnerability handling, MOD-03/09 crypto) and the release
145
+ signature are added to the directory by their own tools — see
146
+ `quantbom actions` for each one's command — and the result is certified
147
+ with `certify(bundle=out_dir)`. Returns the path of the QBOM.
148
+ """
149
+ out = Path(out_dir) if out_dir is not None else Path(_config["mirror_dir"]) / _safe(release)
150
+ sbom_path = _collect_evidence(release, out, circuit=circuit, qpy=qpy, qasm3=qasm3, backend=backend,
151
+ equivalence=equivalence, expected=expected, tolerance=tolerance,
152
+ expected_order=expected_order)
153
+ _merge_environment_sbom(sbom_path, syft_sbom=syft_sbom, environment=environment)
154
+ _scan_vulnerabilities(sbom_path, vuln_scan=vuln_scan, grype_output=grype_output)
155
+ return sbom_path
156
+
157
+
158
+ def _zip_dir(evidence_dir: Path, exclude: tuple[str, ...] = ()) -> bytes:
159
+ buf = io.BytesIO()
160
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
161
+ for path in sorted(evidence_dir.rglob("*")):
162
+ rel = str(path.relative_to(evidence_dir))
163
+ # mirror files live at the bundle ROOT; matching by basename once dropped
164
+ # the producer's evidence/signing.pub, so a producer signature could not
165
+ # verify on the service (masked while the service re-signed everything)
166
+ if path.is_file() and rel not in MIRROR_FILES and rel not in exclude:
167
+ zf.write(path, arcname=rel)
168
+ return buf.getvalue()
169
+
170
+
171
+ # privacy="strict": these circuit-IR files never leave the machine. The bytes
172
+ # stay in the local mirror (the producer's audit bundle); only their digests
173
+ # travel, marked client-attested on the certificate.
174
+ STRICT_WITHHELD = (
175
+ ("quantum/circuit.qpy", "secqdevops:circuit-sha256"),
176
+ ("quantum/transpiled.qpy", "secqdevops:transpiled-circuit-sha256"),
177
+ ("quantum/program.qasm", "secqdevops:qasm-program-sha256"),
178
+ )
179
+ # ... and so a producer signature made for a strict upload must not cover them:
180
+ # the service verifies every signed subject against the bytes it received.
181
+ STRICT_WITHHELD_REFS = (
182
+ "secqdevops:circuit-artifact-ref",
183
+ "secqdevops:transpiled-circuit-ref",
184
+ "secqdevops:qasm-program-ref",
185
+ )
186
+
187
+
188
+ def _apply_strict_privacy(sbom_path: Path, evidence_dir: Path) -> tuple[str, ...]:
189
+ """Re-verify the withheld digests locally, then mark the SBOM strict.
190
+
191
+ Attesting requires verifying: each withheld file's bytes are re-hashed
192
+ against the SBOM's claimed digest BEFORE it is withheld. A mismatch is a
193
+ hard error — we never attest digests we could not reproduce.
194
+ """
195
+ import hashlib
196
+
197
+ sbom = json.loads(sbom_path.read_text(encoding="utf-8"))
198
+ props = {p.get("name"): p.get("value") for p in sbom.get("metadata", {}).get("properties", [])}
199
+ withheld: list[str] = []
200
+ for rel, sha_prop in STRICT_WITHHELD:
201
+ path = evidence_dir / rel
202
+ claimed = props.get(sha_prop)
203
+ if not path.exists() or not claimed:
204
+ continue
205
+ actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
206
+ if actual != claimed:
207
+ raise RuntimeError(f"privacy=strict: local re-hash of {rel} does not match the QBOM ({claimed[:20]}… != {actual[:20]}…)")
208
+ withheld.append(rel)
209
+ properties = sbom.setdefault("metadata", {}).setdefault("properties", [])
210
+ properties[:] = [p for p in properties if p.get("name") != "secqdevops:privacy-mode"]
211
+ properties.append({"name": "secqdevops:privacy-mode", "value": "strict"})
212
+ sbom_path.write_text(json.dumps(sbom, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
213
+ return tuple(withheld)
214
+
215
+
216
+ def _post_certify(zip_bytes: bytes, release: str, retain: bool = False, assurance: str = "standard", scheme: str = "eu-cra") -> dict[str, Any]:
217
+ url = _config["api_url"].rstrip("/") + "/v1/certify"
218
+ key = _config["api_key"]
219
+ if not key:
220
+ raise RuntimeError("no API key: set QUANTBOM_API_KEY or call quantbom.configure(api_key=...)")
221
+ headers = {"X-API-Key": key, "X-Release-Id": release, "Content-Type": "application/zip"}
222
+ if retain: # opt-in server-side retention of the QBOM document + results (never artifact bytes)
223
+ headers["X-Retain"] = "1"
224
+ if assurance != "standard": # opt-in server-side re-execution (costs an extra credit)
225
+ headers["X-Assurance"] = assurance
226
+ if scheme != "eu-cra": # certify under an alternative compliance scheme (see /v1/schemes)
227
+ headers["X-Scheme"] = scheme
228
+ req = urllib.request.Request(url, data=zip_bytes, method="POST", headers=headers)
229
+ with urllib.request.urlopen(req, timeout=60) as resp:
230
+ return json.loads(resp.read().decode("utf-8"))
231
+
232
+
233
+ def _write_mirror(evidence_dir: Path, bundle: dict[str, Any]) -> None:
234
+ for name, content in (bundle or {}).items():
235
+ if content is None:
236
+ continue
237
+ path = evidence_dir / name
238
+ path.parent.mkdir(parents=True, exist_ok=True)
239
+ if isinstance(content, str):
240
+ path.write_text(content, encoding="utf-8")
241
+ else:
242
+ path.write_text(json.dumps(content, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
243
+
244
+
245
+ def _merge_environment_sbom(sbom_path: Path, *, syft_sbom=None, environment: bool = False) -> None:
246
+ """Optionally merge a full environment SBOM (syft) into the collected QBOM.
247
+
248
+ environment=True scans the running Python environment with a locally
249
+ installed syft (skipped with a warning when syft is absent); syft_sbom=
250
+ merges a pre-generated CycloneDX file. Both run client-side, before upload.
251
+ """
252
+ from . import _merge
253
+
254
+ if syft_sbom:
255
+ env = json.loads(Path(syft_sbom).read_text(encoding="utf-8"))
256
+ elif environment:
257
+ if not _merge.syft_available():
258
+ _warn("environment SBOM skipped — syft not found on PATH (quantum evidence still collected)")
259
+ return
260
+ env = _merge.run_syft(f"dir:{sys.prefix}") # the running Python environment
261
+ else:
262
+ return
263
+ sbom = json.loads(sbom_path.read_text(encoding="utf-8"))
264
+ stats = _merge.merge_environment(sbom, env)
265
+ sbom_path.write_text(json.dumps(sbom, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
266
+ _warn(f"environment SBOM merged: +{stats['added']} components "
267
+ f"({stats['licenses_enriched']} licenses enriched, {stats['missing_license']} still unlicensed)")
268
+
269
+
270
+ def _scan_vulnerabilities(sbom_path: Path, *, vuln_scan: bool = False, grype_output=None) -> None:
271
+ """Optionally populate the QBOM's vulnerabilities[] from a grype scan.
272
+
273
+ vuln_scan=True runs a locally installed grype over the collected (and
274
+ optionally syft-merged) QBOM — skipped with a warning when grype is
275
+ absent; grype_output= applies a pre-generated grype CycloneDX file.
276
+ """
277
+ from . import _scan
278
+
279
+ if grype_output:
280
+ scan_doc = json.loads(Path(grype_output).read_text(encoding="utf-8"))
281
+ elif vuln_scan:
282
+ if not _scan.grype_available():
283
+ _warn("vulnerability scan skipped — grype not found on PATH (gate will see an unscanned SBOM)")
284
+ return
285
+ scan_doc = _scan.run_grype(sbom_path)
286
+ else:
287
+ return
288
+ sbom = json.loads(sbom_path.read_text(encoding="utf-8"))
289
+ stats = _scan.apply_findings(sbom, scan_doc)
290
+ sbom_path.write_text(json.dumps(sbom, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
291
+ _warn(f"vulnerability scan: {stats['findings']} findings {stats.get('by_severity') or ''}")
292
+
293
+
294
+ def certify(circuit=None, release: str = "release", *, backend: str = "basic", qpy=None, qasm3=None,
295
+ mode: str = "report", api_key: str | None = None, api_url: str | None = None,
296
+ syft_sbom=None, environment: bool = False, privacy: str = "standard",
297
+ vuln_scan: bool = False, grype_output=None, retain: bool = False,
298
+ equivalence: bool = False, expected=None, tolerance: float = 0.05,
299
+ assurance: str = "standard", scheme: str = "eu-cra", expected_order: str = "native",
300
+ bundle=None, sign: bool = False, signing_key=None) -> dict[str, Any]:
301
+ """Collect evidence locally, certify via the QuantBOM service, mirror the result.
302
+
303
+ Returns a result dict. mode="report" (default) never raises on policy outcome;
304
+ mode="gate" raises RuntimeError on a non-compliant or failed/unreachable result.
305
+ environment=True merges a full syft scan of the running Python environment into
306
+ the QBOM; syft_sbom= merges a pre-generated CycloneDX environment SBOM.
307
+ vuln_scan=True runs grype over the QBOM so CRA-ESR-03 and the VEX carry real
308
+ findings; grype_output= applies a pre-generated grype CycloneDX file.
309
+ privacy="strict" keeps the circuit-IR bytes (QPY/OpenQASM) on this machine:
310
+ only their locally re-verified digests travel, and the certificate marks them
311
+ client-attested. The local mirror retains the full bundle for on-site audit.
312
+ retain=True opts into server-side retention of the QBOM document + result
313
+ JSONs (never artifact bytes) — list/download/delete via /v1/releases.
314
+ equivalence=True formally checks source-vs-transpiled semantic equivalence
315
+ (mqt.qcec — pip install "quantbom[verify]"); the verdict ships as
316
+ hash-verified evidence and the gate's QNT-05 judges it.
317
+ expected={"0101": 1.0} declares the distribution the circuit should produce
318
+ (with tolerance= as max total-variation distance); the measured deviation
319
+ ships as hash-verified evidence and the gate's QBOM-BENCH-02 judges it.
320
+ Outcomes are read in YOUR framework's bit order (expected_order="native"):
321
+ Cirq, Braket and PennyLane put qubit 0 first, Qiskit and Qrisp put it last,
322
+ and the SDK translates before comparing. Pass expected_order="qiskit" to
323
+ write outcomes in measured (Qiskit) order instead. Every outcome must span
324
+ the full measured register; a wrong width raises immediately.
325
+ assurance="re-executed" asks the SERVICE to independently re-run the circuit
326
+ and reproduce the claimed results (privacy=standard only; costs an extra
327
+ credit); in gate mode, not achieving the requested assurance raises.
328
+ bundle=DIR certifies a bundle made by collect(), after partner evidence or
329
+ a release signature has been added to it; nothing is re-collected.
330
+ sign=True signs the evidence as its producer before upload (your key from
331
+ `quantbom keygen`, signing_key= or $QUANTBOM_SIGNING_KEY; needs
332
+ "quantbom[signing]") — the authorship claim CRA-SIGN-01 rests on. Without
333
+ it the service only countersigns what it received, and the certificate
334
+ says the producer did not sign.
335
+ """
336
+ if assurance not in ("standard", "re-executed"):
337
+ raise ValueError("assurance must be 'standard' or 're-executed'")
338
+ if privacy not in ("standard", "strict"):
339
+ raise ValueError("privacy must be 'standard' or 'strict'")
340
+ if bundle is not None and (circuit is not None or qpy is not None or qasm3 is not None):
341
+ raise ValueError("bundle= certifies an already-collected bundle; do not also pass a circuit")
342
+ if bundle is not None and (equivalence or expected):
343
+ raise ValueError("equivalence= and expected= are collection options: pass them to collect(), not certify(bundle=)")
344
+ if api_key or api_url:
345
+ configure(api_url=api_url, api_key=api_key)
346
+ evidence_dir = Path(bundle) if bundle is not None else Path(_config["mirror_dir"]) / _safe(release)
347
+
348
+ try:
349
+ if bundle is not None:
350
+ sbom_path = evidence_dir / "sbom.cdx.json"
351
+ if not sbom_path.is_file():
352
+ raise FileNotFoundError(f"{evidence_dir} is not an evidence bundle (no sbom.cdx.json); create one with quantbom.collect()")
353
+ else:
354
+ sbom_path = _collect_evidence(release, evidence_dir, circuit=circuit, qpy=qpy, qasm3=qasm3, backend=backend, equivalence=equivalence, expected=expected, tolerance=tolerance, expected_order=expected_order)
355
+ if equivalence and "secqdevops:equivalence-ref" not in sbom_path.read_text(encoding="utf-8"):
356
+ _warn("equivalence check skipped — mqt.qcec not installed (pip install \"quantbom[verify]\")")
357
+ _merge_environment_sbom(sbom_path, syft_sbom=syft_sbom, environment=environment)
358
+ _scan_vulnerabilities(sbom_path, vuln_scan=vuln_scan, grype_output=grype_output)
359
+ if sign: # last, so the signature covers everything added above
360
+ from . import _sign
361
+
362
+ info = _sign.sign(evidence_dir, signing_key, omit_refs=STRICT_WITHHELD_REFS if privacy == "strict" else ())
363
+ _warn(f"evidence signed as producer, key {info['key_id'][:19]}… over {info['subjects']} artifacts")
364
+ withheld: tuple[str, ...] = ()
365
+ if privacy == "strict":
366
+ withheld = _apply_strict_privacy(sbom_path, evidence_dir)
367
+ _warn(f"privacy=strict — retained on this machine: {', '.join(withheld) or '(none)'}")
368
+ result = _post_certify(_zip_dir(evidence_dir, exclude=withheld), release, retain=retain, assurance=assurance, scheme=scheme)
369
+ except urllib.error.HTTPError as exc:
370
+ detail = _read_err(exc)
371
+ result = {"ok": False, "error": detail.get("error", f"HTTP {exc.code}"), "http_status": exc.code, "release_id": release}
372
+ if exc.code == 402:
373
+ _warn("out of credits — purchase more at the QuantBOM dashboard.")
374
+ except (urllib.error.URLError, Exception) as exc: # fail-soft: never break the user's quantum run
375
+ result = {"ok": False, "error": str(exc), "release_id": release}
376
+
377
+ if result.get("bundle"):
378
+ _write_mirror(evidence_dir, result.pop("bundle"))
379
+ result["mirror"] = str(evidence_dir)
380
+ result["ok"] = True
381
+
382
+ _summarize(result)
383
+
384
+ if mode == "gate":
385
+ if not result.get("ok"):
386
+ raise RuntimeError(f"QuantBOM: certification could not be completed: {result.get('error')}")
387
+ if result.get("overall_status") != "COMPLIANT":
388
+ raise RuntimeError(f"QuantBOM gate failed: release is {result.get('overall_status')}")
389
+ if assurance == "re-executed" and result.get("assurance") != "service-re-executed":
390
+ note = (result.get("reexecution") or {}).get("note") or (result.get("reexecution") or {}).get("verdict")
391
+ raise RuntimeError(f"QuantBOM: requested re-executed assurance not achieved ({result.get('assurance')}: {note})")
392
+ return result
393
+
394
+
395
+ def _extract_circuits(pubs) -> list:
396
+ """Pull qiskit circuits out of a primitive's run() argument (PUBs or a bare circuit)."""
397
+ from qiskit import QuantumCircuit
398
+
399
+ if isinstance(pubs, QuantumCircuit):
400
+ return [pubs]
401
+ seq = pubs if isinstance(pubs, (list, tuple)) else [pubs]
402
+ out = []
403
+ for pub in seq:
404
+ if isinstance(pub, QuantumCircuit):
405
+ out.append(pub)
406
+ elif isinstance(pub, (list, tuple)) and pub and isinstance(pub[0], QuantumCircuit):
407
+ out.append(pub[0])
408
+ return out
409
+
410
+
411
+ def wrap(primitive, release: str = "release", **kwargs):
412
+ """Wrap a Qiskit primitive (SamplerV2/EstimatorV2) so `.run(...)` certifies transparently.
413
+
414
+ from qiskit_ibm_runtime import SamplerV2
415
+ sampler = quantbom.wrap(SamplerV2(mode=backend), release="vqe@2.1")
416
+ job = sampler.run([circuit]) # certifies the circuit, then runs as normal
417
+
418
+ Certification is fire-and-forget and fail-soft — it never blocks or breaks the run.
419
+ """
420
+ original_run = primitive.run
421
+
422
+ def run(pubs, *a, **k):
423
+ try:
424
+ circuits = _extract_circuits(pubs)
425
+ for i, circ in enumerate(circuits):
426
+ rel = release if len(circuits) == 1 else release + "-" + str(i)
427
+ certify(circ, release=rel, **kwargs)
428
+ except Exception as exc: # never let certification break the user's run
429
+ _warn("wrap: certification skipped — " + str(exc))
430
+ return original_run(pubs, *a, **k)
431
+
432
+ primitive.run = run
433
+ return primitive
434
+
435
+
436
+ def certified(release: str = "release", **kwargs) -> Callable:
437
+ """Decorator: certify the qiskit/qrisp circuit returned by the wrapped function."""
438
+ def deco(fn: Callable) -> Callable:
439
+ def wrapper(*a, **k):
440
+ out = fn(*a, **k)
441
+ circuit = out[0] if isinstance(out, tuple) else out
442
+ certify(circuit, release=release, **kwargs)
443
+ return out
444
+ return wrapper
445
+ return deco
446
+
447
+
448
+ def keygen(key_dir=None, *, overwrite: bool = False) -> dict[str, str]:
449
+ """Generate your producer signing key (default ~/.quantbom/signing.key, 0600). See quantbom._sign."""
450
+ from . import _sign
451
+
452
+ return _sign.keygen(key_dir, overwrite=overwrite)
453
+
454
+
455
+ def sign(bundle, key=None, *, strict: bool = False) -> dict[str, Any]:
456
+ """Sign a collected bundle as its producer; strict=True when it will be uploaded with privacy="strict"."""
457
+ from . import _sign
458
+
459
+ return _sign.sign(bundle, key, omit_refs=STRICT_WITHHELD_REFS if strict else ())
460
+
461
+
462
+ def verify(path, *, certificate=None, keys_file=None, service_key=None) -> dict[str, Any]:
463
+ """Offline verification of a mirror directory, a collected bundle or a zip of either. See quantbom._verify."""
464
+ from . import _verify
465
+
466
+ return _verify.verify(path, certificate=certificate, keys_file=keys_file, service_key=service_key)
467
+
468
+
469
+ def pin_keys(api_url: str | None = None, out=None) -> dict[str, Any]:
470
+ """Fetch the service's signing keys once and pin them as the local trust anchor for verify()."""
471
+ from . import _verify
472
+
473
+ return _verify.pin_keys(api_url or _config["api_url"], out)
474
+
475
+
476
+ def _summarize(result: dict[str, Any]) -> None:
477
+ if result.get("ok"):
478
+ cert = result.get("certificate", {})
479
+ ver = result.get("verification", {})
480
+ _warn(
481
+ f"{result.get('release_id')}: {result.get('overall_status')} · "
482
+ f"cert {cert.get('certificate_id')} ({cert.get('overall_status')}) · "
483
+ f"verify {ver.get('verdict')} · credits left {result.get('credits_remaining')} · mirror {result.get('mirror')}"
484
+ )
485
+ else:
486
+ _warn(f"{result.get('release_id')}: not certified — {result.get('error')} (workflow continues)")
487
+
488
+
489
+ def _safe(name: str) -> str:
490
+ return "".join(c if c.isalnum() or c in "._-@" else "_" for c in name)
491
+
492
+
493
+ def _read_err(exc: urllib.error.HTTPError) -> dict[str, Any]:
494
+ try:
495
+ return json.loads(exc.read().decode("utf-8"))
496
+ except Exception:
497
+ return {}
quantbom/_bitorder.py ADDED
@@ -0,0 +1,121 @@
1
+ """Bit order for declared expected results (QBOM-BENCH-02).
2
+
3
+ Results are measured on the translated Qiskit circuit, so measured bitstrings
4
+ arrive in Qiskit's order: classical bit 0 is the RIGHTMOST character. Cirq,
5
+ Braket and PennyLane print qubit 0 as the LEFTMOST character. Measured with
6
+ each framework's own sampler, an X on qubit 0 of three qubits reads:
7
+
8
+ cirq, braket, pennylane '100'
9
+ qrisp, qiskit '001'
10
+
11
+ A declaration written in the wrong order is not just an inconvenience. It
12
+ fails correct releases — and it can pass wrong ones: '100' meant as "qubit 0
13
+ flipped" matches a buggy circuit that flips qubit 2 instead.
14
+
15
+ So expected results are read in the framework's own order by default and
16
+ translated to the measured order before comparison; the declaration as
17
+ written, and the order it was read in, are recorded in the hash-bound
18
+ expected-distribution document. Qiskit and Qrisp need no translation.
19
+
20
+ Declarations are also checked against the measured register width up front:
21
+ a wrong-width key can never match a measured outcome, so it always signals a
22
+ mistake and is better raised than recorded as a silent `exceeded`.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from .frameworks import ADAPTERS
29
+
30
+ # Frameworks whose own bitstrings put qubit 0 first — declared per adapter and
31
+ # verified against each framework's own sampler by the conformance suite.
32
+ QUBIT0_FIRST = {a.name for a in ADAPTERS if a.bit_order == "qubit0_first"}
33
+ ORDERS = ("native", "qiskit")
34
+
35
+
36
+ def measured_register(qiskit_circuit: Any) -> tuple[int, int]:
37
+ """(width, number of classical registers) exactly as the collector measures.
38
+
39
+ Mirrors the collector's runner: a circuit with no measurement gets
40
+ measure_all(), and the reported bitstring spans every classical bit.
41
+ """
42
+ circ = qiskit_circuit.copy()
43
+ if not any(inst.operation.name == "measure" for inst in circ.data):
44
+ circ.measure_all()
45
+ return circ.num_clbits, len(circ.cregs)
46
+
47
+
48
+ def to_measured_order(
49
+ expected: dict[str, float],
50
+ source_format: str,
51
+ qiskit_circuit: Any,
52
+ order: str = "native",
53
+ *,
54
+ native_order: str | None = None,
55
+ measured_order: str = "qubit0_last",
56
+ ) -> tuple[dict[str, float], dict[str, Any]]:
57
+ """Return (distribution in measured order, declaration record).
58
+
59
+ `native_order` is how the framework's own reading of THIS input is ordered
60
+ (per input kind for Qrisp; the adapter's `bit_order_for`), defaulting to the
61
+ adapter's class-level order. `measured_order` is how the lowered circuit's
62
+ measured string is ordered — Qiskit's, unless the lowering allocates
63
+ classical bits in reverse (Jasp). A declaration is translated exactly when
64
+ the two differ.
65
+
66
+ Raises ValueError for a declaration that cannot be interpreted safely:
67
+ unknown order, non-binary or wrong-width keys, keys that collide once
68
+ normalised, or a translation on a multi-register circuit (where "qubit 0
69
+ first" no longer describes a single unambiguous string).
70
+ """
71
+ if order not in ORDERS:
72
+ raise ValueError(f"expected_order must be one of {ORDERS}, not {order!r}")
73
+ if native_order is None:
74
+ native_order = "qubit0_first" if source_format in QUBIT0_FIRST else "qubit0_last"
75
+ width, registers = measured_register(qiskit_circuit)
76
+
77
+ declared: dict[str, float] = {}
78
+ for raw_key, prob in expected.items():
79
+ key = str(raw_key).replace(" ", "")
80
+ if not key or set(key) - {"0", "1"}:
81
+ raise ValueError(f"expected outcome {raw_key!r} is not a bitstring of 0s and 1s")
82
+ if len(key) != width:
83
+ raise ValueError(
84
+ f"expected outcome {raw_key!r} has {len(key)} bits, but this circuit is measured "
85
+ f"over {width} classical bits — every outcome must be exactly {width} characters"
86
+ )
87
+ if key in declared:
88
+ raise ValueError(f"expected outcome {raw_key!r} duplicates another key once spaces are removed")
89
+ declared[key] = float(prob)
90
+
91
+ translate = order == "native" and native_order != measured_order
92
+ if translate and registers > 1:
93
+ raise ValueError(
94
+ f"this {source_format} circuit measures into {registers} classical registers, so "
95
+ "'qubit 0 first' does not define one unambiguous bitstring. Declare the expected "
96
+ "outcomes in measured (Qiskit) order and pass expected_order='qiskit'."
97
+ )
98
+
99
+ first = "qubit 0 is the leftmost character"
100
+ last = "qubit 0 is the rightmost character"
101
+ if translate:
102
+ measured = {k[::-1]: v for k, v in declared.items()}
103
+ record = {
104
+ "declared_order": f"native ({source_format}): {first if native_order == 'qubit0_first' else last}",
105
+ "declared_distribution": declared,
106
+ "translated": True,
107
+ }
108
+ else:
109
+ convention = (
110
+ "qiskit: classical bit 0 is the rightmost character"
111
+ if order == "qiskit"
112
+ else f"native ({source_format}): same convention as the measured order"
113
+ )
114
+ measured = dict(declared)
115
+ record = {"declared_order": convention, "translated": False}
116
+ record["measured_order"] = (
117
+ "qiskit: classical bit 0 is the rightmost character" if measured_order == "qubit0_last"
118
+ else "lowered circuit: classical bits allocated in reverse, so qubit 0 is the leftmost character"
119
+ )
120
+ record["width"] = width
121
+ return measured, record