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.
- quantbom/__init__.py +497 -0
- quantbom/_bitorder.py +121 -0
- quantbom/_circuit.py +77 -0
- quantbom/_collect.py +661 -0
- quantbom/_drift.py +333 -0
- quantbom/_fidelity.py +280 -0
- quantbom/_merge.py +187 -0
- quantbom/_qir.py +224 -0
- quantbom/_scan.py +118 -0
- quantbom/_sign.py +284 -0
- quantbom/_stats.py +125 -0
- quantbom/_verify.py +530 -0
- quantbom/actions/__init__.py +27 -0
- quantbom/actions/base.py +68 -0
- quantbom/actions/benchmark.py +53 -0
- quantbom/actions/bind_backend.py +71 -0
- quantbom/actions/capture.py +106 -0
- quantbom/actions/check_qir.py +44 -0
- quantbom/actions/check_results.py +75 -0
- quantbom/actions/compare_native.py +88 -0
- quantbom/actions/disclose_error_handling.py +66 -0
- quantbom/actions/execute.py +85 -0
- quantbom/actions/inventory.py +24 -0
- quantbom/actions/layout.py +118 -0
- quantbom/actions/prove_equivalence.py +225 -0
- quantbom/actions/record_provenance.py +49 -0
- quantbom/actions/verify_conversion.py +53 -0
- quantbom/attest.py +363 -0
- quantbom/cli.py +378 -0
- quantbom/frameworks/__init__.py +67 -0
- quantbom/frameworks/base.py +155 -0
- quantbom/frameworks/braket.py +132 -0
- quantbom/frameworks/cirq.py +106 -0
- quantbom/frameworks/cudaq.py +223 -0
- quantbom/frameworks/pennylane.py +116 -0
- quantbom/frameworks/qiskit.py +67 -0
- quantbom/frameworks/qrisp.py +267 -0
- quantbom/harness.json +1730 -0
- quantbom/harness.py +88 -0
- quantbom-0.6.0.dist-info/METADATA +207 -0
- quantbom-0.6.0.dist-info/RECORD +45 -0
- quantbom-0.6.0.dist-info/WHEEL +5 -0
- quantbom-0.6.0.dist-info/entry_points.txt +2 -0
- quantbom-0.6.0.dist-info/licenses/LICENSE +202 -0
- quantbom-0.6.0.dist-info/top_level.txt +1 -0
quantbom/_collect.py
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
"""Bundled evidence collector for the QuantBOM SDK (self-contained — no server deps).
|
|
2
|
+
|
|
3
|
+
Runs an actual Qrisp/Qiskit/OpenQASM circuit through the open-source toolchain and
|
|
4
|
+
produces verifiable evidence (the QBOM):
|
|
5
|
+
|
|
6
|
+
Qrisp/Qiskit circuit ──.to_qiskit()──▶ qiskit.QuantumCircuit (normalized IR)
|
|
7
|
+
│
|
|
8
|
+
qiskit.qpy.dump() ──▶ circuit.qpy / transpiled.qpy (REAL bytes)
|
|
9
|
+
│
|
|
10
|
+
hashlib.sha256(bytes) ──▶ secqdevops:*-sha256 (REAL hash)
|
|
11
|
+
|
|
12
|
+
Everything runs offline on stock Qiskit (+ Qrisp for Qrisp circuits) — no IBM
|
|
13
|
+
account, no cloud. The hashes are of the actual file bytes, so the QuantBOM
|
|
14
|
+
service (and the user, offline) can independently re-verify them.
|
|
15
|
+
|
|
16
|
+
This is an internal module; use the public API in `quantbom` (certify/certified/wrap)
|
|
17
|
+
or the `quantbom` CLI.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import io
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Self-contained hashing helpers (kept identical to the server's so digests match).
|
|
29
|
+
def canonical_json(data: Any) -> str:
|
|
30
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha256_bytes(raw: bytes) -> str:
|
|
34
|
+
return f"sha256:{hashlib.sha256(raw).hexdigest()}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
BASIS_GATES = ["rz", "sx", "x", "cx", "measure"]
|
|
38
|
+
SEED_TRANSPILER = 42
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_demo_circuit():
|
|
42
|
+
"""A small, real Qrisp program: a 3-bit quantum adder (2 + 3)."""
|
|
43
|
+
from qrisp import QuantumFloat
|
|
44
|
+
|
|
45
|
+
a = QuantumFloat(3)
|
|
46
|
+
b = QuantumFloat(3)
|
|
47
|
+
a[:] = 2
|
|
48
|
+
b[:] = 3
|
|
49
|
+
c = a + b
|
|
50
|
+
return c.qs.compile()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_qrisp_version() -> str:
|
|
54
|
+
try:
|
|
55
|
+
from importlib.metadata import version
|
|
56
|
+
|
|
57
|
+
return version("qrisp")
|
|
58
|
+
except Exception:
|
|
59
|
+
return "unknown"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# QPY is not forward-readable: a Qiskit release writes its own newest QPY format
|
|
63
|
+
# by default, and an older Qiskit refuses it ("QPY format version 18 isn't
|
|
64
|
+
# supported"). Found 20 Oct 2026: a producer on qiskit 2.6 (QPY 18) uploaded to a
|
|
65
|
+
# service on qiskit 2.5 (reads up to 17, held below 2.6 by qrisp), and the service
|
|
66
|
+
# could not load the circuits to re-derive QNT-05 or re-execute them. Every Qiskit
|
|
67
|
+
# the SDK supports (>=2.0) writes and reads QPY 13 (its QPY_COMPATIBILITY_VERSION),
|
|
68
|
+
# so the bundle is written there; a circuit using a feature 13 cannot express
|
|
69
|
+
# falls back to the native format, and the service then says it cannot load it.
|
|
70
|
+
QPY_PORTABLE_VERSION = 13
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _qpy_bytes(qiskit_circuit) -> bytes:
|
|
74
|
+
from qiskit import qpy
|
|
75
|
+
|
|
76
|
+
version = max(QPY_PORTABLE_VERSION, getattr(qpy, "QPY_COMPATIBILITY_VERSION", QPY_PORTABLE_VERSION))
|
|
77
|
+
buf = io.BytesIO()
|
|
78
|
+
try:
|
|
79
|
+
qpy.dump(qiskit_circuit, buf, version=version)
|
|
80
|
+
except Exception: # a feature the portable format cannot express: the native format, readable by this Qiskit and newer
|
|
81
|
+
buf = io.BytesIO()
|
|
82
|
+
qpy.dump(qiskit_circuit, buf)
|
|
83
|
+
return buf.getvalue()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_circuit(source: str, value: str | None) -> tuple[Any, str, Any, str]:
|
|
87
|
+
"""Resolve any supported frontend down to the normalized IR.
|
|
88
|
+
|
|
89
|
+
Returns (qiskit_circuit, frontend_label, qrisp_circuit_or_None, source_format).
|
|
90
|
+
Every path lowers to a `qiskit.QuantumCircuit` — the QBOM normalized IR —
|
|
91
|
+
through the framework adapters.
|
|
92
|
+
"""
|
|
93
|
+
from .frameworks import get as _adapter
|
|
94
|
+
|
|
95
|
+
if source == "qrisp-demo":
|
|
96
|
+
qiskit_circuit, label, native = _adapter("qrisp").lower(build_demo_circuit())
|
|
97
|
+
return qiskit_circuit, label, native, "qrisp"
|
|
98
|
+
if source in ("qpy", "qasm3", "qasm2"):
|
|
99
|
+
circ, label, fmt = _adapter("qiskit").load(source, value)
|
|
100
|
+
return circ, label, None, fmt
|
|
101
|
+
raise ValueError(f"unknown circuit source: {source}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _circuit_metrics(qiskit_circuit, qrisp_circuit=None) -> dict[str, Any]:
|
|
105
|
+
"""Structural metrics computed from the normalized IR (works for any frontend)."""
|
|
106
|
+
def safe(fn, default=0):
|
|
107
|
+
try:
|
|
108
|
+
return fn()
|
|
109
|
+
except Exception:
|
|
110
|
+
return default
|
|
111
|
+
|
|
112
|
+
metrics = {
|
|
113
|
+
"qubits": qiskit_circuit.num_qubits,
|
|
114
|
+
"depth": safe(lambda: qiskit_circuit.depth()),
|
|
115
|
+
"size": safe(lambda: qiskit_circuit.size()),
|
|
116
|
+
"t_depth": safe(lambda: qiskit_circuit.depth(lambda i: i.operation.name in ("t", "tdg"))),
|
|
117
|
+
"two_qubit_depth": safe(lambda: qiskit_circuit.depth(lambda i: len(i.qubits) > 1)),
|
|
118
|
+
"gate_counts": dict(safe(lambda: qiskit_circuit.count_ops(), {})),
|
|
119
|
+
}
|
|
120
|
+
if qrisp_circuit is not None: # enrich with qrisp-native metrics when available
|
|
121
|
+
try:
|
|
122
|
+
cd = getattr(qrisp_circuit, "cnot_depth")
|
|
123
|
+
metrics["cnot_depth"] = cd() if callable(cd) else cd
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return metrics
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _calibration_snapshot(backend, provider: str, backend_name: str, simulator: bool) -> dict[str, Any]:
|
|
130
|
+
"""Best-effort device-calibration snapshot at execution time (F2.1).
|
|
131
|
+
|
|
132
|
+
Captures what the backend exposes — per-qubit T1/T2/frequency, readout
|
|
133
|
+
and gate errors, backend version, calibration timestamp — so hardware
|
|
134
|
+
evidence is tied to the actual device at the actual time (CEN/CENELEC
|
|
135
|
+
Roadmap §8.5, SI-traceable). Every field is optional: an ideal simulator
|
|
136
|
+
yields an honest minimal snapshot, never a fabricated one.
|
|
137
|
+
"""
|
|
138
|
+
from datetime import datetime, timezone
|
|
139
|
+
|
|
140
|
+
snap: dict[str, Any] = {
|
|
141
|
+
"schema": "secqdevops.backend-calibration/v1",
|
|
142
|
+
"provider": provider, "backend": backend_name, "simulator": simulator,
|
|
143
|
+
# when this snapshot was taken (the run time); with the provider's
|
|
144
|
+
# calibration_last_update it gives the calibration's age at the run (Q1.3)
|
|
145
|
+
"captured_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
146
|
+
"backend_version": None, "num_qubits": None, "qubits": [], "gates": {},
|
|
147
|
+
}
|
|
148
|
+
if backend is None:
|
|
149
|
+
snap["note"] = "no backend object available"
|
|
150
|
+
return snap
|
|
151
|
+
try:
|
|
152
|
+
version = str(getattr(backend, "backend_version", "") or "").strip()
|
|
153
|
+
snap["backend_version"] = version or None
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
target = getattr(backend, "target", None)
|
|
157
|
+
if target is None:
|
|
158
|
+
snap["note"] = "backend exposes no target"
|
|
159
|
+
return snap
|
|
160
|
+
snap["num_qubits"] = getattr(target, "num_qubits", None)
|
|
161
|
+
for i, qp in enumerate(getattr(target, "qubit_properties", None) or []):
|
|
162
|
+
if qp is None:
|
|
163
|
+
continue
|
|
164
|
+
entry: dict[str, Any] = {"qubit": i}
|
|
165
|
+
for attr, key, scale in (("t1", "t1_us", 1e6), ("t2", "t2_us", 1e6), ("frequency", "frequency_ghz", 1e-9)):
|
|
166
|
+
value = getattr(qp, attr, None)
|
|
167
|
+
entry[key] = round(value * scale, 3) if isinstance(value, (int, float)) else None
|
|
168
|
+
try:
|
|
169
|
+
entry["readout_error"] = round(target["measure"][(i,)].error, 6)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
snap["qubits"].append(entry)
|
|
173
|
+
try:
|
|
174
|
+
for name in getattr(target, "operation_names", []):
|
|
175
|
+
errors = [p.error for p in target[name].values() if p is not None and getattr(p, "error", None) is not None]
|
|
176
|
+
if errors:
|
|
177
|
+
snap["gates"][name] = {"avg_error": round(sum(errors) / len(errors), 6), "instances": len(errors)}
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
try: # classic properties (IBM) carry the calibration timestamp
|
|
181
|
+
props_fn = getattr(backend, "properties", None)
|
|
182
|
+
if callable(props_fn):
|
|
183
|
+
last_update = getattr(props_fn(), "last_update_date", None)
|
|
184
|
+
if last_update:
|
|
185
|
+
snap["calibration_last_update"] = str(last_update)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
if simulator and not snap["qubits"]:
|
|
189
|
+
snap["note"] = "ideal simulator — no device calibration data"
|
|
190
|
+
return snap
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
SEED_SIMULATOR = 1234
|
|
194
|
+
SEED_GENERIC = 1234
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_time(value: Any):
|
|
198
|
+
"""Provider timestamps come in several shapes; naive ones are taken as UTC."""
|
|
199
|
+
from datetime import datetime, timezone
|
|
200
|
+
|
|
201
|
+
if value is None:
|
|
202
|
+
return None
|
|
203
|
+
text = str(value).strip().replace("Z", "+00:00")
|
|
204
|
+
if " " in text and "T" not in text:
|
|
205
|
+
text = text.replace(" ", "T", 1)
|
|
206
|
+
try:
|
|
207
|
+
dt = datetime.fromisoformat(text)
|
|
208
|
+
except ValueError:
|
|
209
|
+
return None
|
|
210
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def calibration_age_hours(snapshot: dict[str, Any]) -> float | None:
|
|
214
|
+
"""Hours between the provider's last calibration and the run; None when the
|
|
215
|
+
device reports no calibration time (simulators, synthetic devices)."""
|
|
216
|
+
last, captured = _parse_time(snapshot.get("calibration_last_update")), _parse_time(snapshot.get("captured_at"))
|
|
217
|
+
if last is None or captured is None:
|
|
218
|
+
return None
|
|
219
|
+
return max(0.0, (captured - last).total_seconds() / 3600.0)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _with_measurements(qiskit_circuit):
|
|
223
|
+
circ = qiskit_circuit.copy()
|
|
224
|
+
if not any(inst.operation.name == "measure" for inst in circ.data):
|
|
225
|
+
circ.measure_all()
|
|
226
|
+
return circ
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _aer():
|
|
230
|
+
try:
|
|
231
|
+
from qiskit_aer import AerSimulator
|
|
232
|
+
|
|
233
|
+
return AerSimulator
|
|
234
|
+
except Exception:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
SIMULATOR_ERROR_HANDLING = { # local simulators return raw counts; nothing is corrected after the fact
|
|
239
|
+
"source": "local simulator: counts are raw samples of the simulated distribution",
|
|
240
|
+
"mitigation_status": "none", "mitigation": [], "suppression": [], "raw_counts": True,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ibm_error_handling(sampler) -> dict[str, Any]:
|
|
245
|
+
"""What qiskit-ibm-runtime's SamplerV2 reports about error handling, from its
|
|
246
|
+
options as resolved at submission. SamplerV2 returns raw counts (readout
|
|
247
|
+
mitigation lives in the Estimator), so mitigation is `none`; dynamical
|
|
248
|
+
decoupling and twirling are error *suppression* and are listed when enabled.
|
|
249
|
+
Anything unreadable is `unknown`, never guessed."""
|
|
250
|
+
try:
|
|
251
|
+
opts = sampler.options
|
|
252
|
+
suppression = []
|
|
253
|
+
dd = getattr(opts, "dynamical_decoupling", None)
|
|
254
|
+
if dd is not None and getattr(dd, "enable", False):
|
|
255
|
+
suppression.append({"method": "dynamical_decoupling", "sequence": str(getattr(dd, "sequence_type", "")) or None})
|
|
256
|
+
tw = getattr(opts, "twirling", None)
|
|
257
|
+
if tw is not None and (getattr(tw, "enable_gates", False) or getattr(tw, "enable_measure", False)):
|
|
258
|
+
suppression.append({"method": "twirling", "gates": bool(getattr(tw, "enable_gates", False)),
|
|
259
|
+
"measure": bool(getattr(tw, "enable_measure", False))})
|
|
260
|
+
return {"source": "qiskit-ibm-runtime SamplerV2 options as resolved at submission",
|
|
261
|
+
"mitigation_status": "none", "mitigation": [], "suppression": suppression, "raw_counts": True}
|
|
262
|
+
except Exception as exc: # the provider's options could not be read: say so
|
|
263
|
+
return {"source": f"provider options unreadable ({type(exc).__name__})", "mitigation_status": "unknown",
|
|
264
|
+
"mitigation": [], "suppression": [], "raw_counts": None}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _not_run(shots: int, reason: str, provider: str, backend_name: str, status: str = "unsupported") -> dict[str, Any]:
|
|
268
|
+
"""An honest record of a run that did not happen: no counts, and why."""
|
|
269
|
+
return {"shots": shots, "counts": {}, "status": status, "reason": reason, "provider": provider,
|
|
270
|
+
"backend_name": backend_name, "simulator": True, "execution_target": "simulator",
|
|
271
|
+
"noise": "n/a", "job_id": None, "session_id": None,
|
|
272
|
+
"error_handling": {**SIMULATOR_ERROR_HANDLING, "source": "no run took place", "raw_counts": None}}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _run_basic_simulator(qiskit_circuit, shots: int = 1024, dynamic: bool = False) -> dict[str, Any]:
|
|
276
|
+
"""Local, noiseless execution: Qiskit's BasicSimulator, or qiskit-aer for
|
|
277
|
+
dynamic circuits (BasicSimulator cannot run classical control flow)."""
|
|
278
|
+
from qiskit import transpile
|
|
279
|
+
|
|
280
|
+
if dynamic:
|
|
281
|
+
AerSimulator = _aer()
|
|
282
|
+
if AerSimulator is None:
|
|
283
|
+
return _not_run(shots, "dynamic circuits need qiskit-aer to execute locally "
|
|
284
|
+
"(pip install 'quantbom[sim]'); Qiskit's BasicSimulator cannot run classical control flow",
|
|
285
|
+
"qiskit-aer", "aer_simulator")
|
|
286
|
+
backend, provider, name = AerSimulator(), "qiskit-aer", "aer_simulator"
|
|
287
|
+
run_kw = {"seed_simulator": SEED_SIMULATOR}
|
|
288
|
+
else:
|
|
289
|
+
from qiskit.providers.basic_provider import BasicSimulator
|
|
290
|
+
|
|
291
|
+
backend, provider, name = BasicSimulator(), "qiskit", "basic_simulator"
|
|
292
|
+
run_kw = {"seed_simulator": SEED_SIMULATOR} # reproducible local evidence (Q2.4)
|
|
293
|
+
try:
|
|
294
|
+
tqc = transpile(_with_measurements(qiskit_circuit), backend, seed_transpiler=SEED_TRANSPILER)
|
|
295
|
+
counts = backend.run(tqc, shots=shots, **run_kw).result().get_counts()
|
|
296
|
+
except Exception as exc: # recorded, never hidden: the run did not happen
|
|
297
|
+
out = _not_run(shots, f"local simulation failed: {type(exc).__name__}: {exc}", provider, name, status="failed")
|
|
298
|
+
out["calibration"] = _calibration_snapshot(backend, provider, name, True)
|
|
299
|
+
return out
|
|
300
|
+
out = {"shots": shots, "counts": {str(k): int(v) for k, v in counts.items()}, "status": "completed",
|
|
301
|
+
"provider": provider, "backend_name": name, "simulator": True, "execution_target": "simulator",
|
|
302
|
+
"noise": "none (ideal simulator)", "job_id": None, "session_id": None,
|
|
303
|
+
"error_handling": dict(SIMULATOR_ERROR_HANDLING)}
|
|
304
|
+
out["calibration"] = _calibration_snapshot(backend, provider, name, True)
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _run_generic_backend(qiskit_circuit, shots: int = 1024, dynamic: bool = False) -> dict[str, Any]:
|
|
309
|
+
"""A synthetic, seeded device (Qiskit's GenericBackendV2): realistic coupling
|
|
310
|
+
map, basis gates and calibration-like error rates — but no real device.
|
|
311
|
+
With qiskit-aer its noise model is simulated; without it the run is ideal.
|
|
312
|
+
Every record says which, and that the calibration is synthetic."""
|
|
313
|
+
from qiskit import transpile
|
|
314
|
+
from qiskit.providers.fake_provider import GenericBackendV2
|
|
315
|
+
|
|
316
|
+
n = max(int(qiskit_circuit.num_qubits), 2)
|
|
317
|
+
device = GenericBackendV2(num_qubits=n, seed=SEED_GENERIC, control_flow=True)
|
|
318
|
+
AerSimulator = _aer()
|
|
319
|
+
if AerSimulator is not None:
|
|
320
|
+
executor, provider, name = AerSimulator.from_backend(device), "qiskit-aer", f"aer_simulator({device.name})"
|
|
321
|
+
noise = f"synthetic noise model of {device.name} (GenericBackendV2, seed {SEED_GENERIC})"
|
|
322
|
+
run_kw = {"seed_simulator": SEED_SIMULATOR}
|
|
323
|
+
elif dynamic:
|
|
324
|
+
return _not_run(shots, "dynamic circuits need qiskit-aer to execute locally (pip install 'quantbom[sim]')",
|
|
325
|
+
"qiskit", device.name)
|
|
326
|
+
else:
|
|
327
|
+
executor, provider, name = device, "qiskit", device.name
|
|
328
|
+
noise = "none — qiskit-aer not installed, so the synthetic device ran ideally"
|
|
329
|
+
run_kw = {}
|
|
330
|
+
calibration = _calibration_snapshot(device, "qiskit-generic", device.name, True)
|
|
331
|
+
calibration["synthetic"] = True
|
|
332
|
+
calibration["note"] = f"GenericBackendV2 synthetic calibration (seed {SEED_GENERIC}) — not a real device"
|
|
333
|
+
try:
|
|
334
|
+
tqc = transpile(_with_measurements(qiskit_circuit), device, seed_transpiler=SEED_TRANSPILER)
|
|
335
|
+
counts = executor.run(tqc, shots=shots, **run_kw).result().get_counts()
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
out = _not_run(shots, f"local simulation failed: {type(exc).__name__}: {exc}", provider, name, status="failed")
|
|
338
|
+
out["calibration"] = calibration
|
|
339
|
+
return out
|
|
340
|
+
return {"shots": shots, "counts": {str(k): int(v) for k, v in counts.items()}, "status": "completed",
|
|
341
|
+
"provider": provider, "backend_name": name, "simulator": True, "execution_target": "simulator",
|
|
342
|
+
"noise": noise, "job_id": None, "session_id": None, "calibration": calibration,
|
|
343
|
+
"error_handling": {**SIMULATOR_ERROR_HANDLING,
|
|
344
|
+
"source": "local simulator with a synthetic noise model: counts are raw samples, nothing corrected"}}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _run_ibm_runtime(qiskit_circuit, shots: int = 1024) -> dict[str, Any]:
|
|
348
|
+
"""Real IBM Quantum execution via qiskit-ibm-runtime (needs the [ibm] extra + saved creds)."""
|
|
349
|
+
from qiskit import transpile
|
|
350
|
+
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2
|
|
351
|
+
|
|
352
|
+
service = QiskitRuntimeService()
|
|
353
|
+
backend = service.least_busy(operational=True, simulator=False)
|
|
354
|
+
circ = qiskit_circuit.copy()
|
|
355
|
+
if not any(inst.operation.name == "measure" for inst in circ.data):
|
|
356
|
+
circ.measure_all()
|
|
357
|
+
tqc = transpile(circ, backend, seed_transpiler=SEED_TRANSPILER)
|
|
358
|
+
sampler = SamplerV2(mode=backend)
|
|
359
|
+
error_handling = _ibm_error_handling(sampler)
|
|
360
|
+
job = sampler.run([tqc], shots=shots)
|
|
361
|
+
result = job.result()
|
|
362
|
+
try:
|
|
363
|
+
counts = {str(k): int(v) for k, v in result[0].data.meas.get_counts().items()}
|
|
364
|
+
except Exception:
|
|
365
|
+
counts = {}
|
|
366
|
+
calibration = _calibration_snapshot(backend, "ibm", backend.name, False)
|
|
367
|
+
try: # best-effort job receipt (usage/timestamps) — never blocks collection
|
|
368
|
+
metrics = job.metrics()
|
|
369
|
+
calibration["job_receipt"] = {
|
|
370
|
+
"job_id": job.job_id(),
|
|
371
|
+
"timestamps": metrics.get("timestamps"),
|
|
372
|
+
"usage": metrics.get("usage"),
|
|
373
|
+
}
|
|
374
|
+
except Exception:
|
|
375
|
+
calibration["job_receipt"] = {"job_id": job.job_id()}
|
|
376
|
+
return {
|
|
377
|
+
"shots": shots, "counts": counts, "status": "completed", "provider": "ibm", "backend_name": backend.name,
|
|
378
|
+
"simulator": False, "execution_target": "hardware", "noise": "real device",
|
|
379
|
+
"job_id": job.job_id(), "session_id": getattr(job, "session_id", None),
|
|
380
|
+
"calibration": calibration, "error_handling": error_handling,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _run_backend(qiskit_circuit, backend_choice: str = "basic", shots: int = 1024, dynamic: bool = False) -> dict[str, Any]:
|
|
385
|
+
"""basic: local ideal simulator · generic: synthetic seeded device (GenericBackendV2)
|
|
386
|
+
· ibm: real IBM hardware when credentials allow, else the local simulator (recorded)."""
|
|
387
|
+
if backend_choice == "ibm":
|
|
388
|
+
try:
|
|
389
|
+
return _run_ibm_runtime(qiskit_circuit, shots)
|
|
390
|
+
except Exception as exc: # graceful fallback keeps the pipeline offline-runnable
|
|
391
|
+
out = _run_basic_simulator(qiskit_circuit, shots, dynamic)
|
|
392
|
+
out["note"] = f"IBM runtime unavailable, used the local simulator: {exc}"
|
|
393
|
+
return out
|
|
394
|
+
if backend_choice == "generic":
|
|
395
|
+
return _run_generic_backend(qiskit_circuit, shots, dynamic)
|
|
396
|
+
if backend_choice != "basic":
|
|
397
|
+
raise ValueError(f"unknown backend {backend_choice!r} (basic, generic, ibm)")
|
|
398
|
+
return _run_basic_simulator(qiskit_circuit, shots, dynamic)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def collect(
|
|
402
|
+
release_id: str,
|
|
403
|
+
out_dir: Path,
|
|
404
|
+
qiskit_circuit=None,
|
|
405
|
+
*,
|
|
406
|
+
frontend_label: str = "qrisp",
|
|
407
|
+
qrisp_circuit=None,
|
|
408
|
+
source_format: str = "qrisp",
|
|
409
|
+
backend_choice: str = "basic",
|
|
410
|
+
equivalence_check: bool = False,
|
|
411
|
+
expected_distribution: dict[str, float] | None = None,
|
|
412
|
+
expected_tolerance: float = 0.05,
|
|
413
|
+
conversion_fidelity: dict[str, Any] | None = None,
|
|
414
|
+
expected_declaration: dict[str, Any] | None = None,
|
|
415
|
+
native_export: tuple[str, str] | None = None,
|
|
416
|
+
native_result: dict[str, Any] | None = None,
|
|
417
|
+
) -> dict[str, Any]:
|
|
418
|
+
"""Run the harness actions over one circuit and write its QBOM.
|
|
419
|
+
|
|
420
|
+
Each regulation-mapped action (sdk/quantbom/actions/) writes its own
|
|
421
|
+
artifacts and returns the properties it emits; they are placed in the QBOM
|
|
422
|
+
by the declared layout, which also rejects two actions emitting one property.
|
|
423
|
+
"""
|
|
424
|
+
if qiskit_circuit is None: # default: the Qrisp demo, for backward compatibility
|
|
425
|
+
qiskit_circuit, frontend_label, qrisp_circuit, source_format = load_circuit("qrisp-demo", None)
|
|
426
|
+
from qiskit import __version__ as qiskit_version
|
|
427
|
+
|
|
428
|
+
from . import frameworks
|
|
429
|
+
from ._circuit import inspect_circuit, refuse_dynamic, require_closed
|
|
430
|
+
from .actions import assemble, run_all
|
|
431
|
+
from .actions.base import Context
|
|
432
|
+
|
|
433
|
+
# Preconditions, before any evidence is written (K0): an open circuit is
|
|
434
|
+
# refused; a dynamic one is accepted and recorded — for adapters that
|
|
435
|
+
# claim dynamic support (file inputs are Qiskit's own and always do).
|
|
436
|
+
circuit_info = inspect_circuit(qiskit_circuit)
|
|
437
|
+
require_closed(circuit_info)
|
|
438
|
+
adapter = frameworks.get(source_format)
|
|
439
|
+
if circuit_info["dynamic"] and adapter is not None and not adapter.supports_dynamic:
|
|
440
|
+
refuse_dynamic(circuit_info["dynamic_features"], source_format)
|
|
441
|
+
|
|
442
|
+
ctx = Context(
|
|
443
|
+
release_id=release_id, out_dir=out_dir, qiskit_circuit=qiskit_circuit, qrisp_circuit=qrisp_circuit,
|
|
444
|
+
frontend_label=frontend_label, source_format=source_format, backend_choice=backend_choice,
|
|
445
|
+
equivalence_check=equivalence_check, expected_distribution=expected_distribution,
|
|
446
|
+
expected_tolerance=expected_tolerance, expected_declaration=expected_declaration,
|
|
447
|
+
conversion_fidelity=conversion_fidelity, qiskit_version=qiskit_version, qrisp_version=get_qrisp_version(),
|
|
448
|
+
native_export=native_export, native_result=native_result, circuit_info=circuit_info,
|
|
449
|
+
)
|
|
450
|
+
results = run_all(ctx)
|
|
451
|
+
results.append(_record_outcomes(ctx, results))
|
|
452
|
+
s = ctx.state
|
|
453
|
+
sbom = _build_sbom(release_id, ctx.qrisp_version, qiskit_version, s["metrics"], assemble(results), source_format)
|
|
454
|
+
sbom_path = out_dir / "sbom.cdx.json"
|
|
455
|
+
_write_json(sbom_path, sbom)
|
|
456
|
+
s["action_results"] = results
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
"release_id": release_id,
|
|
460
|
+
"frontend": frontend_label,
|
|
461
|
+
"source_format": source_format,
|
|
462
|
+
"backend": s["runtime_result"]["backend_name"],
|
|
463
|
+
"qiskit_version": qiskit_version,
|
|
464
|
+
"circuit_metrics": s["metrics"],
|
|
465
|
+
"circuit_sha256": s["circuit_sha"],
|
|
466
|
+
"transpiled_sha256": s["transpiled_sha"],
|
|
467
|
+
"qasm_sha256": s["qasm_sha"],
|
|
468
|
+
"benchmark_sha256": s["benchmark_sha"],
|
|
469
|
+
"qir_sha256": s["qir_sha"],
|
|
470
|
+
"equivalence_verdict": s["equivalence_verdict"],
|
|
471
|
+
"conversion_fidelity_verdict": s["conversion_verdict"],
|
|
472
|
+
"result_fidelity": s["fidelity_summary"],
|
|
473
|
+
"calibration_sha256": s["calibration_sha"],
|
|
474
|
+
"circuit_qpy_bytes": len(s["circuit_qpy"]),
|
|
475
|
+
"transpiled_qpy_bytes": len(s["transpiled_qpy"]),
|
|
476
|
+
"sbom_path": str(sbom_path),
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _record_outcomes(ctx, results) -> Any:
|
|
481
|
+
"""record_outcomes — what every action did, and why, as hash-bound evidence.
|
|
482
|
+
|
|
483
|
+
Lets the gate explain each N-A ("not requested", "unsupported: mqt.qcec is
|
|
484
|
+
not installed", "no adapter-side translation") instead of a bare N-A. The
|
|
485
|
+
explanation is informational; verdicts never depend on it.
|
|
486
|
+
"""
|
|
487
|
+
from .actions import HARNESS_VERSION
|
|
488
|
+
from .actions.base import PRODUCED, ActionResult
|
|
489
|
+
|
|
490
|
+
log = {
|
|
491
|
+
"schema": "secqdevops.harness-log/v1",
|
|
492
|
+
"release_id": ctx.release_id,
|
|
493
|
+
"harness_version": HARNESS_VERSION,
|
|
494
|
+
"framework": ctx.source_format,
|
|
495
|
+
"actions": [{"id": r.action, "outcome": r.outcome, **({"reason": r.reason} if r.reason else {})}
|
|
496
|
+
for r in results],
|
|
497
|
+
}
|
|
498
|
+
ctx.quantum_dir.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
log_sha = _write_json(ctx.quantum_dir / "harness-log.json", log)
|
|
500
|
+
return ActionResult("record_outcomes", PRODUCED, [
|
|
501
|
+
{"name": "secqdevops:harness-version", "value": HARNESS_VERSION},
|
|
502
|
+
{"name": "secqdevops:harness-log-ref", "value": "quantum/harness-log.json"},
|
|
503
|
+
{"name": "secqdevops:harness-log-sha256", "value": log_sha},
|
|
504
|
+
])
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def sdk_version() -> str:
|
|
508
|
+
"""This SDK's version, for the QBOM's metadata.tools and the provenance builder.
|
|
509
|
+
|
|
510
|
+
In a source checkout (PYTHONPATH=sdk) sdk/pyproject.toml is the single source
|
|
511
|
+
of truth (RELEASING.md); installed package metadata can be a stale earlier
|
|
512
|
+
install there, so it is read only when no pyproject sits beside the package.
|
|
513
|
+
"""
|
|
514
|
+
import re
|
|
515
|
+
|
|
516
|
+
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
517
|
+
if pyproject.is_file():
|
|
518
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
519
|
+
if re.search(r'^name\s*=\s*"quantbom"', text, re.M) and (m := re.search(r'^version\s*=\s*"([^"]+)"', text, re.M)):
|
|
520
|
+
return m.group(1)
|
|
521
|
+
try:
|
|
522
|
+
from importlib.metadata import version as _dist_version
|
|
523
|
+
|
|
524
|
+
return _dist_version("quantbom")
|
|
525
|
+
except Exception:
|
|
526
|
+
return "unknown"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def split_release(release_id: str) -> tuple[str, str | None]:
|
|
530
|
+
"""`my-app@1.2.3` -> ("my-app", "1.2.3"); without a version part, (release_id, None).
|
|
531
|
+
|
|
532
|
+
Split at the last "@" so a scoped name (`@scope/app@1.0`) keeps its own.
|
|
533
|
+
"""
|
|
534
|
+
name, sep, version = release_id.rpartition("@")
|
|
535
|
+
if sep and name and version:
|
|
536
|
+
return name, version
|
|
537
|
+
return release_id, None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _release_purl(name: str, version: str | None) -> str:
|
|
541
|
+
"""pkg:generic purl for the release: each name segment and the version
|
|
542
|
+
percent-encoded (purl spec), so `owner/repo@sha` stays one valid purl."""
|
|
543
|
+
from urllib.parse import quote
|
|
544
|
+
|
|
545
|
+
path = "/".join(quote(seg, safe="") for seg in name.split("/") if seg) or quote(name, safe="")
|
|
546
|
+
return f"pkg:generic/{path}" + (f"@{quote(version, safe='')}" if version else "")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _collection_timestamp() -> str:
|
|
550
|
+
"""When the evidence was collected (UTC). SOURCE_DATE_EPOCH, the
|
|
551
|
+
reproducible-builds convention, pins it for byte-reproducible bundles."""
|
|
552
|
+
import os
|
|
553
|
+
from datetime import datetime, timezone
|
|
554
|
+
|
|
555
|
+
epoch = os.environ.get("SOURCE_DATE_EPOCH", "").strip()
|
|
556
|
+
moment = datetime.fromtimestamp(int(epoch), timezone.utc) if epoch.isdigit() else datetime.now(timezone.utc)
|
|
557
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _release_component(release_id: str) -> dict[str, Any]:
|
|
561
|
+
"""metadata.component: the release as the producer named it.
|
|
562
|
+
|
|
563
|
+
It once read `quantum-workload-<release_id>`, version 1.0.0 and an
|
|
564
|
+
Apache-2.0 licence for every release: a version the release does not have,
|
|
565
|
+
a malformed purl (`pkg:generic/app@1.2.3@1.0.0`) and a licence nothing
|
|
566
|
+
evidenced. No licence is declared now: the collector cannot know the
|
|
567
|
+
producer's, and asserting one would be an unevidenced claim.
|
|
568
|
+
"""
|
|
569
|
+
name, version = split_release(release_id)
|
|
570
|
+
return {"type": "application", "name": name, **({"version": version} if version else {}),
|
|
571
|
+
"purl": _release_purl(name, version)}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _frontend_component(source_format: str) -> dict[str, Any] | None:
|
|
575
|
+
"""The authoring framework as an SBOM component.
|
|
576
|
+
|
|
577
|
+
The component list must name the framework the release was actually written
|
|
578
|
+
in: hardcoding qrisp+qiskit once listed Qrisp for Cirq/Braket/PennyLane
|
|
579
|
+
releases that never used it, and omitted the framework they did use — an
|
|
580
|
+
inaccurate inventory under CRA Annex I Part II(1), and a framework no CVE
|
|
581
|
+
scan would see. The distribution and licence come from the framework's
|
|
582
|
+
adapter. None when the input was already Qiskit (in-memory, QPY or
|
|
583
|
+
OpenQASM), since qiskit is always listed as the IR.
|
|
584
|
+
"""
|
|
585
|
+
from .frameworks import get as _adapter
|
|
586
|
+
|
|
587
|
+
adapter = _adapter(source_format)
|
|
588
|
+
if adapter is None or adapter.name == "qiskit":
|
|
589
|
+
return None
|
|
590
|
+
dist, licence = adapter.component, adapter.license
|
|
591
|
+
try:
|
|
592
|
+
from importlib.metadata import version as _dist_version
|
|
593
|
+
|
|
594
|
+
ver = _dist_version(dist)
|
|
595
|
+
except Exception:
|
|
596
|
+
ver = "unknown"
|
|
597
|
+
return {
|
|
598
|
+
"type": "library",
|
|
599
|
+
"name": dist,
|
|
600
|
+
"version": ver,
|
|
601
|
+
"purl": f"pkg:pypi/{dist}@{ver}",
|
|
602
|
+
"licenses": [{"license": {"id": licence}}],
|
|
603
|
+
"properties": [{"name": "secqdevops:component-layer", "value": "programming"}],
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _build_sbom(
|
|
608
|
+
release_id: str,
|
|
609
|
+
qrisp_version: str,
|
|
610
|
+
qiskit_version: str,
|
|
611
|
+
metrics: dict[str, Any],
|
|
612
|
+
properties: list[dict[str, str]],
|
|
613
|
+
source_format: str = "qrisp",
|
|
614
|
+
) -> dict[str, Any]:
|
|
615
|
+
serial = "urn:uuid:" + sha256_bytes(release_id.encode("utf-8")).split(":", 1)[1][:32]
|
|
616
|
+
serial = f"urn:uuid:{serial[9:17]}-{serial[17:21]}-{serial[21:25]}-{serial[25:29]}-{serial[29:41]}"
|
|
617
|
+
return {
|
|
618
|
+
"bomFormat": "CycloneDX",
|
|
619
|
+
"specVersion": "1.7",
|
|
620
|
+
"serialNumber": serial,
|
|
621
|
+
"version": 1,
|
|
622
|
+
"metadata": {
|
|
623
|
+
"timestamp": _collection_timestamp(),
|
|
624
|
+
"tools": {
|
|
625
|
+
"components": [
|
|
626
|
+
# The generator is the quantbom SDK, whatever the framework (the
|
|
627
|
+
# label once read "secqdevops-qrisp-evidence 0.1.0" for every release).
|
|
628
|
+
{"type": "application", "name": "quantbom", "version": (v := sdk_version()),
|
|
629
|
+
"purl": f"pkg:pypi/quantbom@{v}",
|
|
630
|
+
"description": "QuantBOM SDK: SecQDevOps MOD-08 evidence collector"}
|
|
631
|
+
]
|
|
632
|
+
},
|
|
633
|
+
"component": _release_component(release_id),
|
|
634
|
+
"properties": properties,
|
|
635
|
+
},
|
|
636
|
+
"components": [
|
|
637
|
+
*([fc] if (fc := _frontend_component(source_format)) else []),
|
|
638
|
+
{
|
|
639
|
+
"type": "library",
|
|
640
|
+
"name": "qiskit",
|
|
641
|
+
"version": qiskit_version,
|
|
642
|
+
"purl": f"pkg:pypi/qiskit@{qiskit_version}",
|
|
643
|
+
"licenses": [{"license": {"id": "Apache-2.0"}}],
|
|
644
|
+
"properties": [{"name": "secqdevops:component-layer", "value": "programming"}],
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _write_json(path: Path, data: Any) -> str:
|
|
651
|
+
"""Write `data` as JSON and return the sha256 of the exact bytes written.
|
|
652
|
+
|
|
653
|
+
Returning the digest lets callers bind the artifact into the QBOM as
|
|
654
|
+
hash-verified evidence. `ensure_ascii=True` keeps the encoded bytes
|
|
655
|
+
identical to what `write_text` puts on disk, so the digest matches what
|
|
656
|
+
the server re-derives.
|
|
657
|
+
"""
|
|
658
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
659
|
+
payload = json.dumps(data, indent=2, ensure_ascii=True) + "\n"
|
|
660
|
+
path.write_text(payload, encoding="utf-8")
|
|
661
|
+
return sha256_bytes(payload.encode("utf-8"))
|