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/_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"))