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