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/cli.py ADDED
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env python3
2
+ """QuantBOM CLI — certify a quantum release from your code, a file or the built-in demo.
3
+
4
+ export QUANTBOM_API_KEY=qb_live_...
5
+ quantbom certify --entry my_pkg.circuits:build --release my-app@1.0 # any supported framework
6
+ quantbom certify --qpy circuit.qpy --release my-app@1.0
7
+ quantbom certify --qasm3 program.qasm3 --release my-app@1.0
8
+ quantbom certify --demo --release smoke-test
9
+
10
+ # sign the evidence as its producer (the authorship claim CRA-SIGN-01 rests on)
11
+ quantbom keygen # once: ~/.quantbom/signing.key
12
+ quantbom certify --entry my_pkg.circuits:build --release my-app@1.0 --sign
13
+
14
+ # two-step, when partner evidence or a signature joins the bundle first
15
+ quantbom collect --entry my_pkg.circuits:build --release my-app@1.0 --out bundle/
16
+ python emit_test_evidence.py emit ... --inject-sbom bundle/sbom.cdx.json
17
+ quantbom attest tests --bundle bundle/ --suite quantum-security-suite --outcome passed --passed 42
18
+ quantbom attest vulns --bundle bundle/ --policy-url https://example.org/security --contact mailto:security@example.org
19
+ quantbom attest constant-time --bundle bundle/ --tool dudect --target kyber_decaps --measurements timings.csv
20
+ quantbom attest crypto --bundle bundle/ --cbom cbom.cdx.json
21
+ quantbom sign --bundle bundle/ # last: the signature covers what the attests added
22
+ quantbom certify --bundle bundle/ --release my-app@1.0
23
+
24
+ # an auditor, offline: re-derive digests, signatures and bindings; the service key must be anchored
25
+ quantbom keys --pin # fetch https://certify.zortenet.eu/v1/keys once, pin it
26
+ quantbom verify .quantbom/my-app@1.0 # or a zip of the mirror; exit 0 only on VERIFIED
27
+
28
+ # what can the harness do for my framework / for this requirement?
29
+ quantbom actions --framework cirq
30
+ quantbom actions --requirement CRA-II-3
31
+
32
+ # did a toolchain change alter what my programs mean? (semantic drift sentinel)
33
+ quantbom drift --corpus drift_corpus/programs.py # exit 1 on semantic drift
34
+ quantbom drift --corpus drift_corpus/programs.py --update-baseline
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import sys
41
+ from pathlib import Path
42
+
43
+ from . import STRICT_WITHHELD_REFS, certify, collect
44
+
45
+
46
+ def _add_source(p: argparse.ArgumentParser, *, bundle: bool) -> None:
47
+ src = p.add_mutually_exclusive_group(required=True)
48
+ src.add_argument("--entry", metavar="MODULE:ATTR",
49
+ help="circuit object from your code (module:attr or file.py:attr; a function is called) — "
50
+ "Qiskit, Qrisp, Cirq, Braket, PennyLane or CUDA-Q")
51
+ src.add_argument("--qpy", help="Qiskit-native circuit (QPY file)")
52
+ src.add_argument("--qasm3", help="OpenQASM3 file")
53
+ src.add_argument("--demo", action="store_true", help="use the built-in Qrisp demo circuit")
54
+ if bundle:
55
+ src.add_argument("--bundle", metavar="DIR", help="certify a bundle made by `quantbom collect` (nothing is re-collected)")
56
+
57
+
58
+ def _add_collection(p: argparse.ArgumentParser) -> None:
59
+ p.add_argument("--release", required=True)
60
+ p.add_argument("--backend", default="basic", choices=["basic", "generic", "ibm"],
61
+ help="basic: local ideal simulator · generic: synthetic seeded device (GenericBackendV2; "
62
+ "noise with quantbom[sim]) · ibm: real hardware (credentials needed)")
63
+ p.add_argument("--environment", "-E", action="store_true",
64
+ help="merge a syft scan of the running Python environment into the QBOM (needs syft on PATH)")
65
+ p.add_argument("--syft-sbom", help="merge a pre-generated CycloneDX environment SBOM (syft/trivy/cdxgen JSON)")
66
+ p.add_argument("--vuln-scan", "-V", action="store_true",
67
+ help="run grype over the QBOM so CRA-ESR-03 and the VEX carry real findings (needs grype on PATH)")
68
+ p.add_argument("--grype-output", help="apply a pre-generated grype CycloneDX JSON instead of running grype")
69
+ p.add_argument("--equivalence", "-Q", action="store_true",
70
+ help="formally verify source-vs-transpiled equivalence (needs mqt.qcec: pip install 'quantbom[verify]')")
71
+ p.add_argument("--expect", action="append", metavar="OUTCOME=PROB",
72
+ help="declare an expected outcome probability (repeatable), e.g. --expect 00101011010=1.0")
73
+ p.add_argument("--tolerance", type=float, default=0.05,
74
+ help="max total-variation distance for --expect (default 0.05)")
75
+ p.add_argument("--expected-order", default="native", choices=["native", "qiskit"],
76
+ help="bit order of --expect outcomes: your framework's own (default) or Qiskit's measured order")
77
+
78
+
79
+ def _source_kwargs(args) -> dict:
80
+ if args.entry:
81
+ from .harness import resolve_entry
82
+
83
+ return {"circuit": resolve_entry(args.entry)}
84
+ if args.qpy:
85
+ return {"qpy": args.qpy}
86
+ if args.qasm3:
87
+ return {"qasm3": args.qasm3}
88
+ if getattr(args, "bundle", None):
89
+ return {"bundle": args.bundle}
90
+ from . import _collect as qe
91
+
92
+ return {"circuit": qe.build_demo_circuit().to_qiskit()}
93
+
94
+
95
+ def _collection_kwargs(args) -> dict:
96
+ return dict(backend=args.backend, environment=args.environment, syft_sbom=args.syft_sbom,
97
+ vuln_scan=args.vuln_scan, grype_output=args.grype_output, equivalence=args.equivalence,
98
+ expected={k: float(v) for k, v in (e.split("=", 1) for e in args.expect)} if args.expect else None,
99
+ tolerance=args.tolerance, expected_order=args.expected_order)
100
+
101
+
102
+ def _print_actions(result: dict, framework: str | None) -> None:
103
+ mark = {"supported": "yes", "not_applicable": "n/a", "unsupported": "NO", "external": "external"}
104
+ for a in result["actions"]:
105
+ line = f"{a['id']:<30} {a['provider']:<5}"
106
+ if framework:
107
+ st = a["status"]
108
+ word = mark.get(st.get("status"), "?")
109
+ if st.get("conformance") == "pending" and word == "yes":
110
+ word = "yes?" # the adapter supports it; the conformance suite has not run on this framework
111
+ line += f" {word:<9}"
112
+ line += f" {'opt-in' if a.get('opt_in') else 'always':<7} {', '.join(a['controls']) or '—'}"
113
+ print(line)
114
+ if framework and a["status"].get("reason"):
115
+ print(f"{'':<32}why: {a['status']['reason']}")
116
+ reqs = "; ".join(f"{s}: {', '.join(ids)}" for s, ids in a["requirements"].items())
117
+ if reqs:
118
+ print(f"{'':<32}serves {reqs}")
119
+ how = a.get("how") or {}
120
+ for key in ("cli", "sdk", "install"):
121
+ if how.get(key):
122
+ print(f"{'':<32}{key}: {how[key]}")
123
+ for cmd in how.get("commands", []):
124
+ print(f"{'':<32}run: {cmd}")
125
+ if how.get("commands") and not how.get("cli"):
126
+ print(f"{'':<32} (MOD-08 repository tool — not part of the quantbom wheel)")
127
+ elif how.get("commands"):
128
+ print(f"{'':<32} (the same evidence, from the repository script)")
129
+
130
+
131
+ def main() -> None:
132
+ parser = argparse.ArgumentParser(prog="quantbom", description="QuantBOM — certify a quantum release")
133
+ sub = parser.add_subparsers(dest="cmd", required=True)
134
+
135
+ c = sub.add_parser("certify", help="collect evidence (or take a collected bundle) and certify a release")
136
+ _add_source(c, bundle=True)
137
+ _add_collection(c)
138
+ c.add_argument("--mode", default="report", choices=["report", "gate"])
139
+ c.add_argument("--privacy", default="standard", choices=["standard", "strict"],
140
+ help="strict: circuit-IR bytes (QPY/OpenQASM) never leave this machine; digests are client-attested")
141
+ c.add_argument("--assurance", default="standard", choices=["standard", "re-executed"],
142
+ help="re-executed: the service independently re-runs the circuit and must reproduce the claimed results (extra credit)")
143
+ c.add_argument("--scheme", default="eu-cra",
144
+ help="compliance scheme to certify under (default eu-cra; see GET /v1/schemes)")
145
+ c.add_argument("--retain", action="store_true",
146
+ help="opt into server-side retention of the QBOM document + results (never artifact bytes); manage via /v1/releases")
147
+ c.add_argument("--sign", action="store_true",
148
+ help="sign the evidence as its producer before upload (quantbom keygen first; needs quantbom[signing])")
149
+ c.add_argument("--key", metavar="FILE", help="producer signing key for --sign (default $QUANTBOM_SIGNING_KEY, then ~/.quantbom/signing.key)")
150
+ c.add_argument("--api-url")
151
+ c.add_argument("--api-key")
152
+
153
+ g = sub.add_parser("keygen", help="generate your producer signing key (Ed25519; private key written 0600)")
154
+ g.add_argument("--key-dir", metavar="DIR", help="where to write signing.key and signing.pub (default ~/.quantbom)")
155
+ g.add_argument("--force", action="store_true", help="replace an existing key (certificates already issued name the old key id)")
156
+
157
+ s_ = sub.add_parser("sign", help="sign a collected bundle as its producer (evidence/attestation.json + signing properties)")
158
+ s_.add_argument("--bundle", required=True, metavar="DIR", help="bundle from `quantbom collect`")
159
+ s_.add_argument("--key", metavar="FILE", help="producer signing key (default $QUANTBOM_SIGNING_KEY, then ~/.quantbom/signing.key)")
160
+ s_.add_argument("--strict", action="store_true",
161
+ help="the bundle will be uploaded with --privacy strict: leave the withheld circuit files out of the signature")
162
+
163
+ k = sub.add_parser("collect", help="collect the evidence bundle locally, without contacting the service")
164
+ _add_source(k, bundle=False)
165
+ _add_collection(k)
166
+ k.add_argument("--out", required=True, metavar="DIR", help="directory to write the bundle to")
167
+
168
+ v = sub.add_parser("verify", help="verify a certified release offline: digests, signatures and bindings re-derived from bytes "
169
+ "(the gate's controls are not re-run; that needs the gate)")
170
+ v.add_argument("path", help="the mirror directory (.quantbom/<release>/), a `quantbom collect` bundle, or a zip of either")
171
+ v.add_argument("--certificate", metavar="FILE", help="certificate.json, when not beside the QBOM")
172
+ anchor = v.add_mutually_exclusive_group()
173
+ anchor.add_argument("--keys", metavar="FILE", help="a /v1/keys document to trust (default: the one pinned by `quantbom keys --pin`)")
174
+ anchor.add_argument("--service-key", metavar="FILE", help="a single service public key (PEM) to trust")
175
+ v.add_argument("--json", action="store_true", help="print the full report as JSON")
176
+
177
+ at = sub.add_parser("attest", help="add partner / tool evidence to a collected bundle (MOD-05 tests, MOD-07 VEX + CVD policy, "
178
+ "MOD-09 constant-time proof, MOD-03/09 CBOM agility) — before `quantbom sign`")
179
+ ats = at.add_subparsers(dest="attest", required=True)
180
+ t_ = ats.add_parser("tests", help="MOD-05 security test report (control MOD05-TEST-01)")
181
+ t_.add_argument("--bundle", required=True, metavar="DIR")
182
+ t_.add_argument("--suite", required=True)
183
+ t_.add_argument("--outcome", required=True, choices=["passed", "failed"])
184
+ t_.add_argument("--passed", type=int, default=0)
185
+ t_.add_argument("--failed", type=int, default=0)
186
+ t_.add_argument("--skipped", type=int, default=0)
187
+ t_.add_argument("--tool", default="mod05", help="the test tool and version")
188
+ t_.add_argument("--source-module", default="MOD-05")
189
+ vu = ats.add_parser("vulns", help="VEX/VDR over the release plus a CVD-policy attestation (CRA-VULN-01, CRA-VDP-01)")
190
+ vu.add_argument("--bundle", required=True, metavar="DIR")
191
+ vu.add_argument("--policy-url", required=True, help="public coordinated-disclosure policy URL")
192
+ vu.add_argument("--contact", required=True, help="security contact (mailto: or URL)")
193
+ vu.add_argument("--sla-days", type=int, default=90)
194
+ vu.add_argument("--advisories", metavar="FILE", help='MOD-07 advisories ({"vulnerabilities": [...]}) merged into the release before the VEX is built')
195
+ ct = ats.add_parser("constant-time", help="constant-time proof from a dudect / ctgrind / Binsec-Rel result (CRA-ESR-06)")
196
+ ct.add_argument("--bundle", required=True, metavar="DIR")
197
+ ct.add_argument("--tool", required=True, choices=["binsec-rel", "ctgrind", "dudect"])
198
+ ct.add_argument("--target", required=True, help="analysed component (purl or symbol)")
199
+ src = ct.add_mutually_exclusive_group(required=True)
200
+ src.add_argument("--t-value", type=float, help="precomputed Welch t-statistic / tool t-value")
201
+ src.add_argument("--measurements", metavar="CSV", help="two-column CSV: class,nanoseconds (Welch's t is computed here)")
202
+ ct.add_argument("--threshold", type=float, default=10.0, help="|t| above this is leakage (dudect's 10)")
203
+ cr = ats.add_parser("crypto", help="CBOM ingestion and PQC migration readiness (CRA-CRYPTO-01)")
204
+ cr.add_argument("--bundle", required=True, metavar="DIR")
205
+ cr.add_argument("--cbom", metavar="FILE", help="CycloneDX CBOM; copied to crypto/cbom.cdx.json in the bundle (default: already there)")
206
+
207
+ ks = sub.add_parser("keys", help="show, or with --pin fetch, the service's certificate-signing keys (the trust anchor for `verify`)")
208
+ ks.add_argument("--pin", action="store_true", help="fetch /v1/keys from the service and pin it to ~/.quantbom/service-keys.json")
209
+ ks.add_argument("--api-url", help="service base URL (default $QUANTBOM_API_URL)")
210
+ ks.add_argument("--out", metavar="FILE", help="pin to this file instead")
211
+
212
+ a = sub.add_parser("actions", help="list harness actions: per framework, or those covering a requirement or control")
213
+ a.add_argument("--framework", help="qiskit, qrisp, cirq, braket, pennylane, cudaq — adds each action's status for it")
214
+ a.add_argument("--requirement", help="only actions whose evidence feeds this requirement (any scheme)")
215
+ a.add_argument("--control", help="only actions whose evidence feeds this control")
216
+ a.add_argument("--json", action="store_true", help="machine-readable output (same shape as GET /v1/harness)")
217
+
218
+ d = sub.add_parser("drift", help="semantic drift sentinel: compile a corpus in this toolchain and classify every change "
219
+ "(none / structural / observational / semantic) against the source and the baseline")
220
+ d.add_argument("--corpus", required=True, metavar="MODULE.py", help="a Python file defining PROGRAMS = {name: (framework, builder)}")
221
+ d.add_argument("--baseline", metavar="FILE", help="baseline JSON (default: <corpus>.baseline.json beside the corpus)")
222
+ d.add_argument("--update-baseline", action="store_true", help="record this run as the baseline (after reviewing the drift it reports)")
223
+ d.add_argument("--json", action="store_true", help="print the full report as JSON")
224
+ d.add_argument("--report", metavar="FILE", help="also write the report JSON here")
225
+ d.add_argument("--require-pins", metavar="FILE", help="refuse to run unless the installed toolchain equals these dist==version pins "
226
+ "(how a baseline is only ever recorded on the pinned toolchain)")
227
+ d.add_argument("--strict", action="store_true", help="on the pinned toolchain: fail on anything but none / nondeterministic "
228
+ "(the baseline no longer describes the toolchain it claims to)")
229
+ args = parser.parse_args()
230
+
231
+ if args.cmd in ("keygen", "sign"):
232
+ from . import _sign
233
+
234
+ try:
235
+ if args.cmd == "keygen":
236
+ out = _sign.keygen(args.key_dir, overwrite=args.force)
237
+ else:
238
+ out = _sign.sign(args.bundle, args.key, omit_refs=STRICT_WITHHELD_REFS if args.strict else ())
239
+ except (_sign.SigningUnavailable, FileNotFoundError, FileExistsError, ValueError) as err:
240
+ print(f"quantbom {args.cmd}: {err}", file=sys.stderr)
241
+ sys.exit(2)
242
+ print(json.dumps(out, indent=2))
243
+ sys.exit(0)
244
+
245
+ if args.cmd == "attest":
246
+ from . import attest
247
+
248
+ try:
249
+ if args.attest == "tests":
250
+ out = attest.tests(args.bundle, suite=args.suite, outcome=args.outcome, passed=args.passed, failed=args.failed,
251
+ skipped=args.skipped, tool=args.tool, source_module=args.source_module)
252
+ elif args.attest == "vulns":
253
+ out = attest.vulnerability_handling(args.bundle, policy_url=args.policy_url, contact=args.contact,
254
+ sla_days=args.sla_days, advisories=args.advisories)
255
+ elif args.attest == "constant-time":
256
+ out = attest.constant_time(args.bundle, tool=args.tool, target=args.target, t_value=args.t_value,
257
+ measurements=args.measurements, threshold=args.threshold)
258
+ else:
259
+ out = attest.crypto(args.bundle, cbom=args.cbom)
260
+ except (FileNotFoundError, ValueError, OSError) as err:
261
+ print(f"quantbom attest {args.attest}: {err}", file=sys.stderr)
262
+ sys.exit(2)
263
+ print(json.dumps(out, indent=2))
264
+ sys.exit(0)
265
+
266
+ if args.cmd == "verify":
267
+ from . import _verify
268
+
269
+ try:
270
+ report = _verify.verify(args.path, certificate=args.certificate, keys_file=args.keys, service_key=args.service_key)
271
+ except (_verify.SigningUnavailable, FileNotFoundError, ValueError) as err:
272
+ print(f"quantbom verify: {err}", file=sys.stderr)
273
+ sys.exit(2)
274
+ print(json.dumps(report, indent=2) if args.json else _verify.render(report))
275
+ sys.exit(0 if report["verdict"] == "VERIFIED" else 1)
276
+
277
+ if args.cmd == "keys":
278
+ from . import _verify
279
+
280
+ try:
281
+ if args.pin:
282
+ from . import _config
283
+
284
+ out = _verify.pin_keys(args.api_url or _config["api_url"], args.out)
285
+ else:
286
+ path = Path(args.out) if args.out else _verify.PINNED_KEYS
287
+ if not path.is_file():
288
+ print(f"quantbom keys: nothing pinned at {path} — run `quantbom keys --pin`", file=sys.stderr)
289
+ sys.exit(2)
290
+ out = json.loads(path.read_text())
291
+ except (_verify.SigningUnavailable, ValueError, OSError) as err:
292
+ print(f"quantbom keys: {err}", file=sys.stderr)
293
+ sys.exit(2)
294
+ print(json.dumps(out, indent=2))
295
+ sys.exit(0)
296
+
297
+ if args.cmd == "drift":
298
+ from . import _drift
299
+
300
+ corpus = Path(args.corpus)
301
+ baseline_path = Path(args.baseline) if args.baseline else corpus.with_name(corpus.stem + ".baseline.json")
302
+ baseline = json.loads(baseline_path.read_text()) if baseline_path.exists() else None
303
+ try:
304
+ programs = _drift.load_corpus(corpus)
305
+ except (OSError, ValueError) as err:
306
+ print(f"quantbom drift: {err}", file=sys.stderr)
307
+ sys.exit(2)
308
+ if args.require_pins:
309
+ try:
310
+ mismatches = _drift.pin_mismatches(_drift.read_pins(args.require_pins))
311
+ except (OSError, ValueError) as err:
312
+ print(f"quantbom drift: {err}", file=sys.stderr)
313
+ sys.exit(2)
314
+ if mismatches:
315
+ print("quantbom drift: the installed toolchain is not the pinned one:\n " + "\n ".join(mismatches), file=sys.stderr)
316
+ sys.exit(2)
317
+ report, code = _drift.run(programs, baseline)
318
+ if args.strict:
319
+ bad = _drift.strict_violations(report)
320
+ if bad:
321
+ print("quantbom drift --strict: the baseline does not match this toolchain:\n " + "\n ".join(bad), file=sys.stderr)
322
+ code = 1
323
+ if args.report:
324
+ Path(args.report).write_text(json.dumps(report, indent=2))
325
+ if args.update_baseline:
326
+ baseline_path.write_text(json.dumps(_drift.baseline_from(report, args.require_pins), indent=2) + "\n")
327
+ print(f"baseline written: {baseline_path}", file=sys.stderr)
328
+ print(json.dumps(report, indent=2) if args.json else _drift.render(report))
329
+ if baseline is None and not args.update_baseline:
330
+ print("note: no baseline — drift vs baseline not assessed (run with --update-baseline to record one)", file=sys.stderr)
331
+ sys.exit(code)
332
+
333
+ if args.cmd == "actions":
334
+ from .harness import load_index, query
335
+
336
+ try:
337
+ result = query(load_index(), framework=args.framework, requirement=args.requirement, control=args.control)
338
+ except KeyError as err:
339
+ print(f"quantbom actions: {err.args[0]}", file=sys.stderr)
340
+ sys.exit(2)
341
+ if args.json:
342
+ print(json.dumps(result, indent=2, ensure_ascii=False))
343
+ else:
344
+ print(f"harness {result['harness_version']}" + (f" — {json.dumps(result['query'])}" if result["query"] else ""))
345
+ record = args.framework and load_index()["frameworks"][args.framework].get("conformance")
346
+ if isinstance(record, dict):
347
+ if record.get("status") == "verified":
348
+ print(f"note: {args.framework} conformance verified — {record.get('versions')} · {record.get('host')} · {record.get('date')}")
349
+ else:
350
+ print(f"note: {args.framework} conformance PENDING — {record.get('note', '')}; "
351
+ "every 'yes' below is unverified until the suite has run")
352
+ elif record:
353
+ print(f"note: {args.framework} conformance {record}")
354
+ _print_actions(result, args.framework)
355
+ sys.exit(0)
356
+
357
+ if args.cmd == "collect":
358
+ try:
359
+ sbom = collect(release=args.release, out_dir=args.out, **_source_kwargs(args), **_collection_kwargs(args))
360
+ except ValueError as err: # a refused input (e.g. unbound parameters): say why, no traceback
361
+ print(f"quantbom collect: {err}", file=sys.stderr)
362
+ sys.exit(2)
363
+ print(json.dumps({"release_id": args.release, "bundle": str(sbom.parent), "qbom": str(sbom)}, indent=2))
364
+ sys.exit(0)
365
+
366
+ kw = dict(release=args.release, mode=args.mode, api_url=args.api_url, api_key=args.api_key,
367
+ privacy=args.privacy, retain=args.retain, assurance=args.assurance, scheme=args.scheme,
368
+ sign=args.sign, signing_key=args.key, **_collection_kwargs(args))
369
+ res = certify(**_source_kwargs(args), **kw)
370
+
371
+ keys = ("release_id", "overall_status", "assurance", "reexecution", "certificate", "verification", "credits_remaining", "retained", "mirror", "ok", "error")
372
+ print(json.dumps({k: res.get(k) for k in keys if k in res}, indent=2))
373
+ ok = res.get("ok") and (args.mode != "gate" or res.get("overall_status") == "COMPLIANT")
374
+ sys.exit(0 if ok else 1)
375
+
376
+
377
+ if __name__ == "__main__":
378
+ main()
@@ -0,0 +1,67 @@
1
+ """Framework adapter registry (HARNESS_PLAN.md §2.1).
2
+
3
+ Order matters for detection: Qiskit's isinstance check first, then Qrisp's
4
+ duck-typed `.to_qiskit()`, then frameworks recognised by their module.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from .base import FrameworkAdapter, Undecidable
11
+ from .braket import BraketAdapter
12
+ from .cirq import CirqAdapter
13
+ from .cudaq import CudaqAdapter
14
+ from .pennylane import PennyLaneAdapter
15
+ from .qiskit import QiskitAdapter
16
+ from .qrisp import QrispAdapter
17
+
18
+ ADAPTERS: tuple[FrameworkAdapter, ...] = (
19
+ QiskitAdapter(), QrispAdapter(), CirqAdapter(), BraketAdapter(), PennyLaneAdapter(), CudaqAdapter(),
20
+ )
21
+ _BY_NAME = {a.name: a for a in ADAPTERS}
22
+
23
+
24
+ def get(name: str) -> FrameworkAdapter | None:
25
+ return _BY_NAME.get(name)
26
+
27
+
28
+ # K7: computational models the harness does not certify, recognised by the
29
+ # module an object comes from, so the refusal names the model rather than
30
+ # listing what was expected. The gate (22 controls, QBOM v0.3) is defined for
31
+ # gate-model circuits; these need their own evidence model, not a translation.
32
+ NON_GATE_MODELS = {
33
+ "annealing (Ising / QUBO)": ("dimod", "dwave", "dwavebinarycsp", "neal", "dwave_networkx", "qubovert", "pyqubo"),
34
+ "photonic (continuous-variable / linear optics)": ("strawberryfields", "perceval", "piquasso", "thewalrus"),
35
+ "analog / pulse-level": ("qutip", "pulser", "bloqade", "qiskit_dynamics", "qiskit.pulse"),
36
+ "annealing-style schedule (Braket AHS)": ("braket.ahs",),
37
+ }
38
+ GATE_MODEL_INPUTS = ("qiskit.QuantumCircuit, a Qrisp circuit/session (.to_qiskit()), a cirq.Circuit, "
39
+ "a braket Circuit, a PennyLane QNode/tape, or a CUDA-Q kernel")
40
+
41
+
42
+ def model_of(obj: Any) -> str | None:
43
+ """The non-gate model an object belongs to, by its type's module; None when unknown."""
44
+ module = type(obj).__module__ or ""
45
+ for model, roots in NON_GATE_MODELS.items():
46
+ if any(module == r or module.startswith(r + ".") for r in roots):
47
+ return model
48
+ return None
49
+
50
+
51
+ def detect(obj: Any) -> FrameworkAdapter:
52
+ for adapter in ADAPTERS:
53
+ if adapter.detect(obj):
54
+ return adapter
55
+ model = model_of(obj)
56
+ if model:
57
+ raise TypeError(
58
+ f"{type(obj).__module__}.{type(obj).__name__} is an {model} program. QuantBOM certifies gate-model circuits "
59
+ f"({GATE_MODEL_INPUTS}): the 22 controls and the QBOM profile are defined for gates, measurements and "
60
+ "transpilation, so this program cannot be translated into that evidence; it is refused, not approximated"
61
+ )
62
+ raise TypeError(
63
+ f"certify() expects a gate-model circuit — {GATE_MODEL_INPUTS} — got {type(obj).__module__}.{type(obj).__name__}"
64
+ )
65
+
66
+
67
+ __all__ = ["ADAPTERS", "FrameworkAdapter", "NON_GATE_MODELS", "Undecidable", "detect", "get", "model_of"]
@@ -0,0 +1,155 @@
1
+ """Framework adapter contract (HARNESS_PLAN.md §2.1).
2
+
3
+ One adapter per quantum programming framework. Everything framework-specific
4
+ in MOD-08 lives behind this interface: lowering to the normalized IR, the
5
+ reference simulator QNT-06 compares against, the qubit/bit order, and the
6
+ component the framework contributes to the inventory. Adding a framework means
7
+ writing one adapter and passing tests/frameworks/test_conformance.py — nothing
8
+ else in MOD-08 changes.
9
+
10
+ Import rule: adapter modules import only the standard library at load time and
11
+ pull their framework in lazily, inside methods. The harness contract check
12
+ reads adapter facts on a bare Python with no quantum packages installed.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+
19
+ class Undecidable(Exception):
20
+ """A comparison that cannot be decided — reported as `inconclusive` (N-A)."""
21
+
22
+
23
+ def dist_version(dist: str) -> str:
24
+ try:
25
+ from importlib.metadata import version
26
+
27
+ return version(dist)
28
+ except Exception:
29
+ return "unknown"
30
+
31
+
32
+ def qiskit_version() -> str:
33
+ from qiskit import __version__
34
+
35
+ return __version__
36
+
37
+
38
+ class FrameworkAdapter:
39
+ #: source-format value recorded in the QBOM, and the manifest key
40
+ name: str = ""
41
+ #: PyPI distribution recorded as the framework's SBOM component
42
+ component: str = ""
43
+ #: SPDX licence of that component
44
+ license: str = ""
45
+ #: "qubit0_first": qubit 0 is the leftmost character of the framework's own
46
+ #: bitstrings; "qubit0_last": the rightmost (Qiskit's measured order);
47
+ #: "by-input": the reading differs by input kind — see `bit_orders`
48
+ bit_order: str = "qubit0_last"
49
+ #: input kind -> bit order, for a framework whose own readings differ by
50
+ #: input (Qrisp: QuantumCircuit.run() vs QuantumVariable.get_measurement()).
51
+ #: None when `bit_order` applies to every input.
52
+ bit_orders: dict[str, str] | None = None
53
+ #: None (input already is the IR), "qrisp-compiler" (the framework's own
54
+ #: compiler lowers it), or the OpenQASM dialect an adapter translates through
55
+ translation: str | None = None
56
+ #: True when the framework's dynamic constructs (mid-circuit measurement,
57
+ #: classical control) survive lowering and are conformance-tested (C7).
58
+ #: Enforced at collection: a dynamic program from an adapter that does not
59
+ #: claim it is refused, never certified on an unverified lowering.
60
+ supports_dynamic: bool = False
61
+ #: What QNT-06 compares the lowered circuit against: "dense" (the
62
+ #: framework's own simulator yields a dense unitary, capped by
63
+ #: _fidelity.max_qubits()), "stabilizer" (planned, K3) or "none" (no
64
+ #: adapter-side translation to check, or no reference available)
65
+ reference: str = "none"
66
+
67
+ @property
68
+ def translates(self) -> bool:
69
+ """True when an adapter-side translation exists that QNT-06 must check."""
70
+ return self.translation not in (None, "qrisp-compiler")
71
+
72
+ def detect(self, obj: Any) -> bool:
73
+ raise NotImplementedError
74
+
75
+ # ---- conventions, pinned per input (never "whichever matches") ----
76
+ def input_kind(self, obj: Any) -> str | None:
77
+ """The kind of input this object is, for frameworks with `bit_orders`."""
78
+ return None
79
+
80
+ def bit_order_for(self, obj: Any) -> str:
81
+ """How the framework's OWN reading of this object's outcomes is ordered."""
82
+ if self.bit_orders:
83
+ kind = self.input_kind(obj)
84
+ if kind not in self.bit_orders:
85
+ raise Undecidable(f"{self.name}: no declared bit order for input kind {kind!r}")
86
+ return self.bit_orders[kind]
87
+ return self.bit_order
88
+
89
+ def measured_order_for(self, obj: Any) -> str:
90
+ """How the LOWERED circuit's measured string is ordered: Qiskit's
91
+ (`qubit0_last`) unless the lowering allocates classical bits in reverse."""
92
+ return "qubit0_last"
93
+
94
+ def open_parameters(self, obj: Any) -> list[str]:
95
+ """Names of parameters the object leaves unbound — checked BEFORE lowering,
96
+ so an open circuit is refused with one clear message rather than whatever
97
+ the framework's exporter raises. Default: none known at this level (the
98
+ lowered Qiskit circuit is checked again in any case)."""
99
+ return []
100
+
101
+ def dynamic_features(self, obj: Any) -> list[str]:
102
+ """Dynamic constructs visible BEFORE lowering (mid-circuit measurement,
103
+ classical control), so an adapter that does not support them refuses
104
+ with one message instead of a library error. Default: none known at
105
+ this level; the lowered circuit is inspected again in any case."""
106
+ return []
107
+
108
+ def lower(self, obj: Any) -> tuple[Any, str, Any]:
109
+ """(qiskit_circuit, frontend_label, native_object_kept_for_evidence_or_None)."""
110
+ raise NotImplementedError
111
+
112
+ # ---- QNT-06 hooks — required only when `translates` ----
113
+ def native_export(self, obj: Any) -> tuple[str, str]:
114
+ """The exact text the translation consumed, and its format."""
115
+ raise Undecidable(f"no native export for {self.name}")
116
+
117
+ def reference_unitary(self, obj: Any) -> tuple[Any, str]:
118
+ """(unitary from the framework's OWN simulator, reference description)."""
119
+ raise Undecidable(f"no reference simulator for {self.name}")
120
+
121
+ def reference_state(self, obj: Any) -> tuple[Any, str]:
122
+ """(final state on |0...0> from the framework's OWN simulator, description) —
123
+ the scalable reference beyond the dense cap (K3); qubit 0 most significant."""
124
+ raise Undecidable(f"no reference state simulator for {self.name}")
125
+
126
+ def export_for_equivalence(self, obj: Any) -> tuple[str, str, str | None]:
127
+ """(text, format, note): the native export as an independent equivalence
128
+ checker can parse it. Default: the native export itself; an adapter whose
129
+ export uses non-standard gate names rewrites them and says so."""
130
+ text, fmt = self.native_export(obj)
131
+ return text, fmt, None
132
+
133
+ # ---- conformance hook: the framework's own reading of measurements ----
134
+ def native_sample(self, obj: Any, shots: int = 8) -> dict[str, int]:
135
+ raise NotImplementedError(f"{self.name} adapter has no native sampler")
136
+
137
+ # ---- QNT-09 hook: the program run on the framework's own simulator ----
138
+ def native_run(self, obj: Any, shots: int) -> dict[str, Any]:
139
+ """Sample the NATIVE program on the framework's own simulator.
140
+
141
+ Returns {"simulator": description, "reading": counts as the framework
142
+ reads them, "reading_order": "qubit0_first" | "measured",
143
+ "measured": the same counts keyed as the shared-format run's measured
144
+ string (spaces removed)}; Qrisp sessions add "by_variable". A program
145
+ without measurements is measured on every qubit, as the collector
146
+ measures the lowered circuit. Undecidable when the framework cannot
147
+ say, or the reading cannot be mapped onto the measured string."""
148
+ raise Undecidable(f"no native simulator run for {self.name}")
149
+
150
+ @staticmethod
151
+ def _reversed_reading(reading: dict[str, int], simulator: str) -> dict[str, Any]:
152
+ """For frameworks that print qubit 0 first: the measured string is the
153
+ reading reversed (registers concatenated in declaration order)."""
154
+ return {"simulator": simulator, "reading": reading, "reading_order": "qubit0_first",
155
+ "measured": {k[::-1]: int(v) for k, v in reading.items()}}