Noisy Simulation
Goal: watch a GHZ state degrade under gate noise and readout error, first from rules you write and then from a device calibration table, and compare sampled and exact answers.
The figure of merit throughout is the GHZ fraction: the share of shots reading 000 or
111.
from prism_q import CircuitBuilder, simulate
circuit = CircuitBuilder(3, 3).h(0).cx(0, 1).cx(1, 2).measure_all().build()
def ghz_fraction(counts):
return (counts.get("000", 0) + counts.get("111", 0)) / sum(counts.values())
ideal = simulate(circuit).seed(42).sample_counts(10_000).counts()
print(ghz_fraction(ideal)) # 1.0
Noise from rules
NoiseBuilder attaches channels by rule. Each rule pairs a GateFilter, which picks the
gates, with a NoiseChannel. build(circuit) lowers the rules onto that circuit and
validates the result.
from prism_q import GateFilter, NoiseBuilder, NoiseChannel
noise = (
NoiseBuilder()
.after_gates(GateFilter.all().arity(1), NoiseChannel.depolarizing(0.001))
.after_gates_joint(GateFilter.all().named("cx"), NoiseChannel.two_qubit_depolarizing(0.02))
.uniform_readout_error(0.01, 0.02)
.build(circuit)
)
noisy = simulate(circuit).seed(42).noise(noise).sample_counts(10_000).counts()
print(round(ghz_fraction(noisy), 4)) # 0.9273
after_gates adds a one-qubit channel on each target of a matching gate.
after_gates_joint adds one channel across all of a gate's targets, here a two-qubit
depolarizing channel after every cx. uniform_readout_error(p01, p10) flips reported
bits after sampling: p01 is the chance a measured 0 reads as 1. The
Python guide lists the other rules: crosstalk,
over-rotation, idle noise, and noise after resets or before measurement.
A model is tied to the circuit it was built from. Build a new one for a different circuit.
Noise from a device calibration
DeviceCalibration reads what a device's calibration page reports: t1, t2 and
readout rates per qubit, a duration and error per gate family, and optional per-pair
overrides. to_noise_model turns it into thermal relaxation over each gate's duration,
depolarizing error per gate, and readout error per measured qubit.
from prism_q import DeviceCalibration
calibration = DeviceCalibration.parse(
"""
qubit 0 t1=120e-6 t2=80e-6 p01=0.02 p10=0.03
qubit 1 t1=95e-6 t2=110e-6 p01=0.01 p10=0.02
qubit 2 t1=60e-6 t2=50e-6 p01=0.02 p10=0.04
gate1q time=35e-9 error=3e-4
gate2q time=300e-9 error=8e-3
gate2q 1 2 time=450e-9 error=2e-2
"""
)
device = calibration.to_noise_model(circuit)
sampled = simulate(circuit).seed(42).noise(device).sample_counts(10_000).counts()
print(round(ghz_fraction(sampled), 4)) # 0.9028
Times are in seconds. The last line overrides the two-qubit family on the pair (1, 2),
in either order. DeviceCalibration.superconducting_transmon(n), trapped_ion(n) and
neutral_atom(n) are presets with illustrative magnitudes for a technology class, not a
measured device.
Exact answers from the density matrix
Sampling carries shot noise. The density-matrix backend evolves the mixed state itself,
so run() returns exact probabilities under the model. It never runs by default, since it
stores 4**n entries; select it by name. Measurements and readout error are left off here
to read the state before measurement:
from prism_q import BackendKind
unmeasured = CircuitBuilder(3).h(0).cx(0, 1).cx(1, 2).build()
exact = (
simulate(unmeasured)
.backend(BackendKind.density_matrix())
.noise(calibration.to_noise_model(unmeasured))
.seed(42)
.run()
.probabilities
)
print(round(exact[0] + exact[7], 4)) # 0.9712
The gap between this and the sampled 0.90 is mostly readout error, which acts only on the reported bits.
In Rust
#![allow(unused)] fn main() { use prism_q::{CircuitBuilder, GateFilter, NoiseBuilder, NoiseChannel, simulate}; let circuit = CircuitBuilder::new_with_classical(3, 3) .h(0) .cx(0, 1) .cx(1, 2) .measure_all() .build(); let noise = NoiseBuilder::new() .after_gates(GateFilter::all().arity(1), NoiseChannel::Depolarizing { p: 0.001 }) .after_gates_joint( GateFilter::all().named("cx"), NoiseChannel::TwoQubitDepolarizing { p: 0.02 }, ) .uniform_readout_error(0.01, 0.02) .build(&circuit)?; let counts = simulate(&circuit).noise(&noise).seed(42).sample_counts(10_000)?; let good = counts.counts.get(&vec![0b000]).unwrap_or(&0) + counts.counts.get(&vec![0b111]).unwrap_or(&0); assert!(good > 9_000); Ok::<(), prism_q::PrismError>(()) }
Rust count keys are packed words with classical bit 0 in the lowest bit, so 000 and
111 are vec![0b000] and vec![0b111].
examples/noise.rs
adds the calibration and density-matrix steps.
Next: A QEC Memory Experiment.