Skip to content

Registry (hardware detection)

Before running a big circuit, it's worth knowing how big "big" safely is on the actual machine running it -- QuantumHardwareRegistry reads the current machine's RAM and GPU availability and suggests a qubit ceiling from that, once, at construction time. Despite living in dense_evolution.circuits.registry historically, this has nothing to do with noise -- see Noise for NoiseModel/NoiseSpec instead.

Step 1. What does this machine look like?

import dense_evolution as de

reg = de.QuantumHardwareRegistry()
reg.ram_total, reg.has_jax, reg.has_gpu, reg.max_dense_qubits
(7.877658843994141, True, False, 20)

ram_total is total system RAM in GB (this machine's own, whatever it happens to be), has_jax/has_gpu are booleans, and max_dense_qubits is a suggested ceiling for a dense statevector simulation: 28 at ram_total >= 50, 24 at >= 12, 20 otherwise -- three fixed tiers, not a formula fit to this machine's exact number. 20 above reflects an 8GB machine landing in the lowest tier.

Step 2. The same numbers, printed

reg.print_diagnostics()
MAX_DENSE=20q | JAX=True | GPU=False

print_diagnostics() is the same four fields from Step 1, condensed to one line -- useful as a quick sanity check at the top of a script before committing to a large qubit count.


Details

max_dense_qubits is a suggestion, not an enforced limit: nothing in this class stops a caller from constructing a DenseSVSimulator above it -- pair it with Chunk's SafeMemoryGuard, which does actively refuse an allocation once available memory drops below its own threshold, for a real enforced ceiling instead of an advisory one.

Lazy x64: constructing QuantumHardwareRegistry is one of the entry points that enables jax_enable_x64 the first time it runs, same as DenseSVSimulator/ circuit_to_energy_fn -- see Autodiff's own precision note.

registry

NoiseModel

Stochastic single-qubit Kraus channels applied directly to a statevector. Each channel is a separate, importable module under dense_evolution. noise.kraus (dense_evolution.noise.kraus.depolarizing, etc.) -- this class is the shared dispatcher: RNG/key setup, the per-qubit loop, and final normalisation, common to every channel.

All channels are mathematically correct Kraus maps: - trace is preserved (normalisation enforced at the end) - phaseflip applies Z with probability p per qubit (non-deterministic) - amplitude_damping applies the correct K0/K1 Kraus operators - combined is a true worst-case NISQ mixture of all three Pauli errors plus amplitude damping

Supported models

'ideal' identity — no modification 'depolarizing' {√(1-p)I, √(p/3)X, √(p/3)Y, √(p/3)Z} 'bitflip' {√(1-p)I, √p·X} 'phaseflip' {√(1-p)I, √p·Z} ← was broken, now fixed 'amplitude_damping'{K0=diag(1,√(1-γ)), K1=[[0,√γ],[0,0]]} 'combined' depolarizing(p/2) + amplitude_damping(p/3), renormalised

Every channel draws one fire/no-fire decision per qubit per shot (plus one Pauli choice for depolarizing/combined's depolarizing sub-step), applied identically across the whole statevector -- the same single-Pauli-per-qubit-per-shot convention STIM's DEPOLARIZE1(p) uses. Prior to v8.1.57, every channel instead drew 2**(n-1) INDEPENDENT decisions per qubit per shot, one per amplitude pair (i.e. one per branch of the other n-1 qubits) -- inert on a product state, but on an entangled state it over-decohered any coherence-sensitive (off-diagonal) observable, up to hundreds of sigma vs the exact density-matrix Kraus-sum result on test cases (e.g. per-branch sampling dropped a measured value from 1.0 to 0.31 at p=0.15 on one such test -- see the v8.1.57 changelog entry for the full reproduction).

apply_to_sv staticmethod

apply_to_sv(
    sv: ndarray,
    n: int,
    model: str,
    p: float,
    rng: Optional[Generator] = None,
    qubits: Optional[List[int]] = None,
    jax_key: Optional[Any] = None,
) -> np.ndarray

Apply a stochastic Kraus channel to statevector sv in-place (numpy path) or via functional updates (JAX path).

Parameters:

Name Type Description Default
sv ndarray
required
n int
required
model str
required
p float
required
rng Optional[Generator]
  *sv* is a NumPy array. When *sv* is a JAX array, `rng`
  used to be silently ignored in favor of `jax_key` (or a
  non-reproducible OS-entropy key if that was also None
  -- issue #7); it is now used to *derive* a reproducible
  jax_key (`rng.integers(...)` seeds `jax.random.PRNGKey`)
  whenever `jax_key` isn't given explicitly, so seeding
  `rng` has the effect a caller expects on both array
  types instead of only on one of them.
None
qubits Optional[List[int]]
None
jax_key optional JAX PRNGKey, only meaningful when *sv* is a JAX
  array. Takes precedence over `rng` when both are given
  (explicit key beats a derived one). Created from OS
  entropy if neither `jax_key` nor `rng` is given.
None

Returns:

Type Description
Normalised statevector (same array type as input).

Examples:

A real quantum computer is never perfect -- every gate has some chance of error. Once you have a statevector from running your own QASM circuit (the same circuit as the getting-started example), this function is how you find out what a noisy device would have actually given you instead.

Start from the circuit and statevector you already have:

>>> import numpy as np
>>> import dense_evolution as de
>>> qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; creg c[2]; h q[0]; barrier q; cx q[0],q[1]; measure q -> c;'
>>> circuit = de.QASMParser().parse(qasm)
>>> sim = de.DenseSVSimulator(2)
>>> sim.run_circuit(circuit.to_tuples())
>>> sv = np.asarray(sim.get_statevector())

(the barrier is parsed and ignored -- it never becomes a gate tuple, so it has no effect on the statevector, only on how the circuit reads.)

Call NoiseModel.apply_to_sv on that same statevector, telling it the qubit count, which error model to simulate, and how strong it is:

>>> from dense_evolution.noise import NoiseModel
>>> rng = np.random.default_rng(0)
>>> sv_noisy = NoiseModel.apply_to_sv(sv.copy(), 2, 'depolarizing', 0.1, rng=rng)
>>> round(float(np.vdot(sv_noisy, sv_noisy).real), 4)  # still a valid, normalised state
1.0

'depolarizing' above is one of six models; pick any other one the same way, by name:

>>> NoiseModel.MODELS
['ideal', 'depolarizing', 'bitflip', 'phaseflip', 'amplitude_damping', 'combined']

p is that model's error probability (or damping rate for 'amplitude_damping') -- 0.1 above means each qubit has a 10% chance of a random Pauli error per call. Run it many times and average (see Density-matrix ZNE healing) to see what a real noisy device's typical output looks like, not just one random draw.

Source code in dense_evolution/noise/kraus_channels.py
@staticmethod
def apply_to_sv(
    sv:       np.ndarray,
    n:        int,
    model:    str,
    p:        float,
    rng:      Optional[np.random.Generator] = None,
    qubits:   Optional[List[int]] = None,
    jax_key:  Optional[Any] = None,
) -> np.ndarray:
    """
    Apply a stochastic Kraus channel to statevector *sv* in-place
    (numpy path) or via functional updates (JAX path).

    Parameters
    ----------
    sv      : complex statevector of length 2**n
    n       : number of qubits
    model   : one of NoiseModel.MODELS
    p       : error probability (or damping rate γ for amplitude_damping)
    rng     : optional pre-seeded numpy Generator. Used directly when
              *sv* is a NumPy array. When *sv* is a JAX array, `rng`
              used to be silently ignored in favor of `jax_key` (or a
              non-reproducible OS-entropy key if that was also None
              -- issue #7); it is now used to *derive* a reproducible
              jax_key (`rng.integers(...)` seeds `jax.random.PRNGKey`)
              whenever `jax_key` isn't given explicitly, so seeding
              `rng` has the effect a caller expects on both array
              types instead of only on one of them.
    qubits  : subset of qubits to apply the channel to; defaults to all
    jax_key : optional JAX PRNGKey, only meaningful when *sv* is a JAX
              array. Takes precedence over `rng` when both are given
              (explicit key beats a derived one). Created from OS
              entropy if neither `jax_key` nor `rng` is given.

    Returns
    -------
    Normalised statevector (same array type as input).

    Examples
    --------
    A real quantum computer is never perfect -- every gate has some chance of
    error. Once you have a statevector from running your own QASM circuit (the
    same circuit as the getting-started example), this function is how you find
    out what a noisy device would have actually given you instead.

    Start from the circuit and statevector you already have:

    >>> import numpy as np
    >>> import dense_evolution as de
    >>> qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; creg c[2]; h q[0]; barrier q; cx q[0],q[1]; measure q -> c;'
    >>> circuit = de.QASMParser().parse(qasm)
    >>> sim = de.DenseSVSimulator(2)
    >>> sim.run_circuit(circuit.to_tuples())
    >>> sv = np.asarray(sim.get_statevector())

    (the `barrier` is parsed and ignored -- it never becomes a gate tuple, so it
    has no effect on the statevector, only on how the circuit reads.)

    Call `NoiseModel.apply_to_sv` on that same statevector, telling it the
    qubit count, which error model to simulate, and how strong it is:

    >>> from dense_evolution.noise import NoiseModel
    >>> rng = np.random.default_rng(0)
    >>> sv_noisy = NoiseModel.apply_to_sv(sv.copy(), 2, 'depolarizing', 0.1, rng=rng)
    >>> round(float(np.vdot(sv_noisy, sv_noisy).real), 4)  # still a valid, normalised state
    1.0

    `'depolarizing'` above is one of six models; pick any other one the same way,
    by name:

    >>> NoiseModel.MODELS
    ['ideal', 'depolarizing', 'bitflip', 'phaseflip', 'amplitude_damping', 'combined']

    `p` is that model's error probability (or damping rate for
    `'amplitude_damping'`) -- 0.1 above means each qubit has a 10% chance of a
    random Pauli error per call. Run it many times and average (see
    [Density-matrix ZNE healing](../examples.md#density-matrix-zne-healing))
    to see what a real noisy device's *typical* output looks like, not just one
    random draw.
    """
    if model == 'ideal':
        return sv
    try:
        if p <= 0.0:
            return sv
    except jax.errors.TracerBoolConversionError:
        # p is a traced value (e.g. flowing through jax.jit/vmap/grad
        # as a NoiseSpec pytree leaf) -- can't early-exit on a Python
        # bool of it. Falling through is still correct: every channel
        # below already reduces to a no-op at p=0 (`fire = r < p` is
        # always False), this skips only the eager-mode optimization,
        # not correctness.
        pass

    channel = _CHANNELS[model]
    is_jax = HAS_JAX and isinstance(sv, jnp.ndarray)
    dim    = len(sv)

    # ── RNG initialisation ────────────────────────────────────────
    if is_jax:
        if jax_key is not None:
            key = jax_key
        elif rng is not None:
            # Derive a reproducible JAX key from the caller's seeded
            # NumPy generator instead of silently ignoring it -- each
            # call advances `rng`'s state, so a fresh, identically-
            # seeded `rng` reproduces the exact same sequence of keys
            # across separate runs (same guarantee the NumPy path
            # already gives).
            key = jax.random.PRNGKey(int(rng.integers(0, 2**32 - 1)))
        else:
            seed_bytes = os.urandom(4)
            jax_seed   = int.from_bytes(seed_bytes, byteorder='big')
            jax_seed  ^= time.perf_counter_ns() & 0xFFFF_FFFF
            key = jax.random.PRNGKey(jax_seed)
    else:
        key = None
        if rng is None:
            rng = _fresh_rng()

    target_qubits = qubits if qubits is not None else list(range(n))
    sv_out = sv  # JAX: functional; NumPy: will be modified in-place copy

    if not is_jax:
        sv_out = sv.copy()  # never mutate the caller's array

    for q in target_qubits:
        idx_0, idx_1 = _qubit_index_pairs(dim, q)
        sv_out, key = channel.apply(sv_out, idx_0, idx_1, p, rng, key, is_jax)

    # ── normalise ─────────────────────────────────────────────────
    if is_jax:
        norm = jnp.linalg.norm(sv_out)
        return sv_out / (norm + 1e-15)
    else:
        norm = np.linalg.norm(sv_out)
        return sv_out / (norm + 1e-15)

kraus_description staticmethod

kraus_description(model: str) -> Dict

Human-readable Kraus-operator formula and physical meaning for one of NoiseModel.MODELS.

Examples:

>>> from dense_evolution.noise import NoiseModel
>>> NoiseModel.kraus_description('bitflip')['physical']
'Bit flip σ_x with probability p'
Source code in dense_evolution/noise/kraus_channels.py
@staticmethod
def kraus_description(model: str) -> Dict:
    """Human-readable Kraus-operator formula and physical meaning for
    one of `NoiseModel.MODELS`.

    Examples
    --------
    >>> from dense_evolution.noise import NoiseModel
    >>> NoiseModel.kraus_description('bitflip')['physical']
    'Bit flip σ_x with probability p'
    """
    desc = {
        'ideal': {
            'kraus': 1,
            'formula': 'K₀ = I',
            'physical': 'No noise',
        },
        'depolarizing': {
            'kraus': 4,
            'formula': 'K₀=√(1-p)I  K₁=√(p/3)X  K₂=√(p/3)Y  K₃=√(p/3)Z',
            'physical': 'Isotropic Pauli error — equiprobable X, Y, Z',
        },
        'bitflip': {
            'kraus': 2,
            'formula': 'K₀=√(1-p)I  K₁=√p·X',
            'physical': 'Bit flip σ_x with probability p',
        },
        'phaseflip': {
            'kraus': 2,
            'formula': 'K₀=√(1-p)I  K₁=√p·Z',
            'physical': 'Pure dephasing σ_z with probability p',
        },
        'amplitude_damping': {
            'kraus': 2,
            'formula': 'K₀=diag(1,√(1-γ))  K₁=[[0,√γ],[0,0]]',
            'physical': 'T₁ energy relaxation |1⟩→|0⟩ with rate γ',
        },
        'combined': {
            'kraus': 6,
            'formula': 'Depolarizing(p/2) ∘ AmplitudeDamping(p/3)',
            'physical': 'Worst-case NISQ: dephasing + relaxation',
        },
    }
    return desc.get(model, desc['ideal'])

NoiseSpec

NoiseSpec(
    model: str,
    p,
    jax_key,
    qubits: Optional[List[int]] = None,
)

Native JAX-differentiable representation of a noise configuration -- a real JAX PyTree, so noise parameters thread through jax.jit/jax.grad/ jax.vmap natively -- e.g. as the noise= argument to circuit_to_energy_fn's energy_fn -- instead of being applied as an external, Python-side step around the already-traced circuit (the old way: build sv, exit the trace, call apply_to_sv separately).

model/qubits are static (aux_data): they select which code path runs, not values to differentiate or batch over -- the same role static_argnames plays for a plain jax.jit function, but automatic here because it's part of the pytree structure. p/jax_key are pytree leaves (children): p can be a traced/differentiable value (e.g. optimizing noise strength itself), and jax_key flows through jit/vmap/scan the way any other JAX array does -- no external Python-level key management, no OS-entropy fallback (unlike apply_to_sv called standalone with jax_key=None), so a NoiseSpec's result is always reproducible from the key it was built with.

jax_key is required (not Optional) -- the whole point of wiring noise into the traced computation this way is to remove the need for an external, ad-hoc key-management workaround; a caller who wants a fresh key per call should split one themselves (jax.random.split) and build a fresh NoiseSpec, the same as any other JAX-idiomatic stateless-key pattern.

Examples:

>>> import jax
>>> from dense_evolution.noise import NoiseSpec
>>> key = jax.random.PRNGKey(0)
>>> spec = NoiseSpec(model="depolarizing", p=0.05, jax_key=key, qubits=[0, 1])
>>> spec
NoiseSpec(model='depolarizing', p=0.05, qubits=(0, 1))
Source code in dense_evolution/noise/differentiable.py
def __init__(self, model: str, p, jax_key, qubits: Optional[List[int]] = None):
    self.model = model
    self.p = p
    self.jax_key = jax_key
    self.qubits = tuple(qubits) if qubits is not None else None

apply_dark_theme

apply_dark_theme()

Dashboard-only diagnostic-plot styling (dark background + GitHub- dark-ish palette). Used to run as a plt.style.use('dark_background') module-level side effect here, so it fired on ANY import dense_evolution and silently recolored every matplotlib figure a caller made afterward, dashboard or not (prog.txt point 2). Now opt-in: call this explicitly from the dashboard's own startup.

Source code in dense_evolution/circuits/registry.py
def apply_dark_theme():
    """Dashboard-only diagnostic-plot styling (dark background + GitHub-
    dark-ish palette). Used to run as a `plt.style.use('dark_background')`
    module-level side effect here, so it fired on ANY `import
    dense_evolution` and silently recolored every matplotlib figure a
    caller made afterward, dashboard or not (prog.txt point 2). Now
    opt-in: call this explicitly from the dashboard's own startup."""
    plt.style.use('dark_background')
    matplotlib.rcParams.update({
        'figure.facecolor': '#010409',
        'axes.facecolor': '#0d1117',
        'axes.edgecolor': '#21262d',
        'grid.color': '#21262d',
        'font.family': 'monospace',
        'font.size': 9,
    })