Tatopenn's picture
Sync mirror with GitHub main (v8.1.62): subpackage refactor, mitigation promotions, MCP server refactor
2a87ede verified
Raw History Blame Contribute Delete
11.9 kB
"""
Real simulation engine for the dashboard.
OpenQASM text -> dense_evolution's own QASMParser -> executed on
dense_evolution's actual DenseSVSimulator/MPSSimulator (not Qiskit's own
simulator, and not Qiskit's QASM parser either) -> statevector,
probabilities and shot counts, all reordered into Qiskit's little-endian
qubit convention so they line up with the Circuit tab's qubit labels and
with qiskit.visualization's functions (which assume that convention).
No Qiskit QuantumCircuit is ever constructed here, not even for the
Circuit-diagram panel: qiskit.circuit.QuantumCircuit.__init__ itself
segfaults (SIGSEGV, inside qiskit's own compiled extension) on macOS,
independent of *how* the circuit is built or what's done with it
afterwards -- see tests/integration/test_interop.py::TestQiskitInterop for the full
reproduction story (QuantumCircuit(3) alone, no QASM, no method calls,
still crashes there). SimulationResult/LargeScaleMPSResult below carry
the plain gate tuples instead, and dashboard_core.circuit_diagram draws
the diagram directly from those with plain matplotlib.
No synthetic/placeholder data anywhere here: every quantity returned is
computed from a real run of the real engine.
"""
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
import numpy as np
import dense_evolution as de
__all__ = ['SimulationResult', 'run_circuit_from_qasm', 'LargeScaleMPSResult', 'run_large_circuit_mps']
def _qasm_to_tuples(qasm_text: str):
"""Parses qasm_text with dense_evolution's own QASMParser (never
Qiskit's) and returns (n_qubits, ops) where ops is the same
(name, *qubits[, param]) tuple format every other dense_evolution
entry point (run_circuit, QuantumTranspiler.transpile, ...) accepts."""
parsed = de.QASMParser().parse(qasm_text)
return parsed.n_qubits, parsed.to_tuples()
# MPSSimulator.contract_to_statevector's own hard ceiling (RAM-independent
# -- it refuses above this regardless of available memory, since a dense
# 2**n array stops being the point of using MPS at all). Circuits at or
# below this go through the normal dense-statevector path (same
# Probabilities/Q-sphere/Statevector panels as the Dense backend); above
# it, run_large_circuit_mps is the only real path.
MPS_DENSE_CONTRACTION_LIMIT = 24
@lru_cache(maxsize=None)
def _qiskit_bit_order_perm(n_qubits: int) -> np.ndarray:
"""The bit-reverse index permutation itself, cached per n_qubits --
it's O(2**n_qubits) to build and depends only on n_qubits, so a
circuit run repeatedly at the same qubit count (the common case: a
fixed molecule/preset re-run with different parameters) was
rebuilding an identical array every single call. lru_cache keys on
the (hashable) n_qubits argument only -- the returned array itself
doesn't need to be hashable."""
perm = np.array([int(format(i, f'0{n_qubits}b')[::-1], 2) for i in range(2 ** n_qubits)])
perm.setflags(write=False) # cached and shared across calls -- never mutate in place
return perm
def _to_qiskit_bit_order(values: np.ndarray, n_qubits: int) -> np.ndarray:
"""Bit-reverse index order: Dense-Evolution is MSB-first (qubit 0 =
most significant bit of the index), Qiskit is little-endian (qubit 0
= least significant bit) -- the same remap dense_evolution.interop
applies to probabilities internally, used here for the raw complex
statevector too since it is the identical index permutation."""
return values[_qiskit_bit_order_perm(n_qubits)]
@dataclass
class SimulationResult:
ops: list # dense_evolution gate tuples -- feeds dashboard_core.circuit_diagram for the Circuit tab
n_qubits: int
statevector: np.ndarray # complex128, Qiskit bit order
probabilities: np.ndarray # Qiskit bit order
counts: dict # Qiskit-style bitstring -> shot count
noise_model: str = "ideal"
noise_p: float = 0.0
fidelity_vs_ideal: Optional[float] = None
backend: str = "dense"
mps_max_bond_used: Optional[int] = None
mps_memory_mb: Optional[float] = None
mps_avg_jsd: Optional[float] = None
def run_circuit_from_qasm(
qasm_text: str,
n_shots: int = 1000,
seed: Optional[int] = None,
noise_model: str = "ideal",
noise_p: float = 0.0,
backend: str = "dense",
) -> SimulationResult:
"""Parse `qasm_text` and run it on a real dense_evolution engine,
returning every quantity the dashboard's tabs need.
noise_model/noise_p: one of dense_evolution.NoiseModel.MODELS
('ideal', 'depolarizing', 'bitflip', 'phaseflip', 'amplitude_damping',
'combined') applied as a real stochastic Kraus channel to the
statevector via NoiseModel.apply_to_sv -- not a fabricated decay
curve, the actual channel math. 'ideal'/p<=0 skips it entirely (and
leaves SimulationResult.fidelity_vs_ideal as None -- comparing a run
against itself is not a real quantity). Otherwise fidelity_vs_ideal
is the real dense_evolution.statevector_fidelity(|<ideal|noisy>|^2)
between this run's one noisy trajectory and the same circuit's ideal
statevector, both computed in this same call (not re-simulated by the
caller).
backend: 'dense' (DenseSVSimulator, the default) or 'mps'
(MPSSimulator -- adaptive SVD-truncated matrix product state, scales
to far more qubits for low-entanglement circuits; contracted back to
a dense statevector afterwards so the same Probabilities/Q-sphere/
Statevector panels work unchanged regardless of which engine ran the
circuit). Verified to match 'dense' exactly (atol=1e-6) on every
preset in QASM_LIBRARY, and to run a 12-qubit GHZ state in ~2.5s
(max_bond=2) where 'dense' would need 2**12 complex amplitudes.
"""
n_qubits, ops = _qasm_to_tuples(qasm_text)
if n_qubits < 1:
raise ValueError("circuit must declare at least 1 qubit")
if backend not in ("dense", "mps"):
raise ValueError(f"unknown backend {backend!r}, must be 'dense' or 'mps'")
# Both backends end up holding a real 2**n_qubits complex128 dense
# statevector (Dense always; MPS after contract_to_statevector() for
# display) -- check against this machine's *actual* free RAM before
# allocating it, via the same real anti-OOM guard dense_evolution.chunk
# already uses (15% safety threshold, psutil-backed), rather than
# letting a manually-typed large QASM circuit (which bypasses the
# UI's qubit cap entirely) crash the process.
required_mb = (2 ** n_qubits) * 16 / 1e6
de.chunk.SafeMemoryGuard().check_allocation(required_mb, context=f"{n_qubits}-qubit statevector")
mps_max_bond_used = mps_memory_mb = mps_avg_jsd = None
if backend == "mps":
# MPSSimulator.run_circuit_jit does not auto-transpile (unlike
# DenseSVSimulator.run_circuit) -- swap/ccx must be decomposed
# first or it raises on 'swap' not being in its native GATE_IDS
# (verified directly: the QFT preset's trailing swap fails here
# without this step).
transpiled = de.QuantumTranspiler.transpile(ops)
mps = de.MPSSimulator(n_qubits)
mps.run_circuit_jit(transpiled)
sv_native = np.asarray(mps.contract_to_statevector())
mps_max_bond_used = mps.max_bond_used()
mps_memory_mb = mps.memory_mb()
mps_avg_jsd = mps.avg_jsd()
else:
sim = de.DenseSVSimulator(n_qubits, use_float32=False)
sim.run_circuit(ops)
sv_native = np.asarray(sim.sv)
rng = np.random.default_rng(seed)
fidelity_vs_ideal = None
if noise_model != "ideal" and noise_p > 0:
# apply_to_sv mutates its numpy input in-place, so the pre-noise
# amplitudes have to be copied out first -- this is one stochastic
# Kraus-channel trajectory (a real single noisy realization, not a
# density matrix), so the result is still a valid pure state and
# dense_evolution.statevector_fidelity (the pure-state counterpart
# to the density-matrix uhlmann_fidelity already used by the ZNE
# panels below) is the right comparison against the ideal state.
sv_ideal = sv_native.copy()
sv_native = de.NoiseModel.apply_to_sv(sv_native, n_qubits, noise_model, noise_p, rng=rng)
fidelity_vs_ideal = float(de.statevector_fidelity(sv_ideal, sv_native))
statevector = _to_qiskit_bit_order(sv_native, n_qubits)
probabilities = np.abs(statevector) ** 2
# sample_counts expects/returns dense_evolution's native MSB-first
# bitstring convention -- reverse each key to relabel into Qiskit's
# little-endian convention (same physical samples, just relabeled to
# match the Circuit/Probabilities tabs' qubit numbering).
counts_native = de.sample_counts(sv_native, n_shots, rng=rng)
counts = {key[::-1]: n for key, n in counts_native.items()}
return SimulationResult(
ops=ops,
n_qubits=n_qubits,
statevector=statevector,
probabilities=probabilities,
counts=counts,
noise_model=noise_model,
noise_p=noise_p,
fidelity_vs_ideal=fidelity_vs_ideal,
backend=backend,
mps_max_bond_used=mps_max_bond_used,
mps_memory_mb=mps_memory_mb,
mps_avg_jsd=mps_avg_jsd,
)
@dataclass
class LargeScaleMPSResult:
ops: list # dense_evolution gate tuples -- feeds dashboard_core.circuit_diagram for the Circuit tab
n_qubits: int
top_k_states: list # [(bitstring, probability), ...] Qiskit bit order, sorted descending
k_requested: int
mps_max_bond_used: int
mps_memory_mb: float
mps_avg_jsd: float
def run_large_circuit_mps(qasm_text: str, k: int = 32, seed: Optional[int] = None) -> LargeScaleMPSResult:
"""For circuits beyond MPS_DENSE_CONTRACTION_LIMIT qubits, where no
dense (2**n,) statevector/probabilities array can exist at all: runs
the real MPS circuit and finds the top-k most probable basis states
via MPSSimulator.get_top_k_probable_states -- a real greedy beam
search, EXACT probabilities for the states it finds (not sampled or
approximated). Verified directly against exact dense diagonalization
at 10 qubits: every state with non-negligible probability was found,
matching to 6 decimal places (recall isn't guaranteed complete for
highly-entangled circuits at fixed k -- see MPSSimulator's own
docstring -- but the probabilities reported are always exact, never
estimated).
Chosen over MPSSimulator.get_probabilities_sampled for this UI:
measured directly, sampling 2000 shots takes 130s/257s/582s at
30/50/100 qubits (grows with n -- roughly O(n_samples * n_qubits)),
while get_top_k_probable_states stays under 2s even at 100 qubits.
"""
n_qubits, raw_ops = _qasm_to_tuples(qasm_text)
if n_qubits < 1:
raise ValueError("circuit must declare at least 1 qubit")
ops = de.QuantumTranspiler.transpile(raw_ops)
mps = de.MPSSimulator(n_qubits)
mps.run_circuit_jit(ops)
idx, probs = mps.get_top_k_probable_states(k=k)
top_k_states = []
for i, p in zip(idx.tolist(), probs.tolist()):
native_bits = format(int(i), f'0{n_qubits}b')
qiskit_bits = native_bits[::-1] # same MSB-first -> little-endian flip as _to_qiskit_bit_order
top_k_states.append((qiskit_bits, float(p)))
top_k_states.sort(key=lambda t: t[1], reverse=True)
return LargeScaleMPSResult(
ops=raw_ops,
n_qubits=n_qubits,
top_k_states=top_k_states,
k_requested=k,
mps_max_bond_used=mps.max_bond_used(),
mps_memory_mb=mps.memory_mb(),
mps_avg_jsd=mps.avg_jsd(),
)