Detector Error Models

A detector error model (DEM) is a detector error matrix (capturing which detectors each error mechanism flips) together with a noise model that assigns a likelihood to each error mechanism. It is the input a decoder needs to infer which errors occurred from a circuit’s measurement record.

In CUDA-Q you declare the parity checks (detectors) and logical observables directly inside a kernel, then extract the DEM with cudaq::dem_from_kernel (C++) or cudaq.dem_from_kernel (Python) as text in Stim’s standard .dem file format, which stim.DetectorErrorModel parses back into a decoder-ready model. The measurements that feed the declarations are measurement handles (Measuring Kernels).

Three kernel-side declarations are available in both C++ and Python : detector(m0, m1, ...) declares one detector as a parity constraint over the given measurements; detectors(prev, curr) declares N detectors by pairing two equal-length handle vectors element-wise (the standard form for cross-round detectors); and logical_observable(m0, m1, ...) declares a logical observable.

The example below is a three-qubit bit-flip memory experiment: each round measures the data qubits and pairs them with the previous round via detectors, with a final logical_observable reading out the register. In-kernel apply_noise seeds the error mechanisms. Each call applies a single-qubit bit-flip channel (cudaq::x_error in C++, cudaq.XError in Python) that applies a Pauli X with the given probability, so a flipped data qubit shows up as a parity change in the next detectors pair. See the C++ and Python API references for apply_noise and the other predefined noise channels.

# A 3-qubit bit-flip memory experiment. Each round measures the data qubits;
# cross-round detectors pair each measurement with its value in the previous
# round, and a final logical observable reads out the register. In-kernel
# `apply_noise` seeds the error mechanisms the detector error model reports.
@cudaq.kernel
def memory_experiment(rounds: int):
    data = cudaq.qvector(3)
    prev = mz(data)

    for r in range(rounds):
        cudaq.apply_noise(cudaq.XError, 0.01, data[0])
        cudaq.apply_noise(cudaq.XError, 0.01, data[1])
        cudaq.apply_noise(cudaq.XError, 0.01, data[2])

        curr = mz(data)
        # One detector per qubit, pairing this round with the previous one.
        cudaq.detectors(prev, curr)
        prev = curr

    cudaq.logical_observable(prev[0], prev[1], prev[2])


// A 3-qubit bit-flip memory experiment. Each round measures the data qubits;
// cross-round detectors pair each measurement with its value in the previous
// round, and a final logical observable reads out the register. In-kernel
// `apply_noise` seeds the error mechanisms the detector error model reports.
__qpu__ void memory_experiment(int rounds) {
  cudaq::qvector data(3);
  auto prev = mz(data);

  for (int r = 0; r < rounds; ++r) {
    cudaq::apply_noise<cudaq::x_error>(0.01, data[0]);
    cudaq::apply_noise<cudaq::x_error>(0.01, data[1]);
    cudaq::apply_noise<cudaq::x_error>(0.01, data[2]);

    auto curr = mz(data);
    // One detector per qubit, pairing this round with the previous one.
    cudaq::detectors(prev, curr);
    prev = curr;
  }
  cudaq::logical_observable(prev[0], prev[1], prev[2]);
}

Pass the kernel (and a noise model) to dem_from_kernel to extract the DEM.

# Generate the detector error model as Stim `.dem` text. A noise model must be
# supplied for the in-kernel `apply_noise` mechanisms to take effect. Parse the
# text with `stim.DetectorErrorModel(dem)` to drive a decoder.
noise = cudaq.NoiseModel()
dem = cudaq.dem_from_kernel(memory_experiment, 2, noise_model=noise)
print(f"Memory experiment DEM:\n{dem}")
  // Generate the detector error model as Stim `.dem` text. A noise model must
  // be supplied for the in-kernel `apply_noise` mechanisms to take effect.
  // Parse the text with Stim (`stim::DetectorErrorModel{dem}`) to drive a
  // decoder.
  cudaq::noise_model noise;
  std::string dem =
      cudaq::dem_from_kernel(memory_experiment, &noise, /*rounds=*/2);
  std::printf("Memory experiment DEM:\n%s\n", dem.c_str());

The .dem text is a list of independent error mechanisms. Each error(p) D... L... line gives one mechanism: its probability p, the detectors it flips (its symptoms, D), and the logical observables it flips (its frame changes, L).

Output DEM: With two rounds and three data qubits, there are six independent error mechanisms: one bit-flip per qubit per round at the in-kernel probability 0.01 (printed at full floating-point precision). Each error flips one detector together with the logical observable L0:

error(0.01000000000000000021) D0 L0
error(0.01000000000000000021) D1 L0
error(0.01000000000000000021) D2 L0
error(0.01000000000000000021) D3 L0
error(0.01000000000000000021) D4 L0
error(0.01000000000000000021) D5 L0

DEM Options

dem_from_kernel accepts optional parameters that are forwarded to the Stim error analyzer (C++: cudaq::dem_options struct; Python: keyword arguments). All options default to False / 0.

Option

Description

decompose_errors

Decompose hyper-edge error mechanisms into pairs of two-detector edges. Required when feeding the DEM to minimum-weight perfect matching decoders.

fold_loops

Fold loop bodies in the circuit for a more compact DEM. CUDA-Q kernels are compiled to a flat (loop-free) Stim circuit, so this option has no effect in practice.

allow_gauge_detectors

Allow detectors whose parity is not determined by the circuit.

approximate_disjoint_errors_threshold

Threshold in [0, 1] for approximating disjoint-error products. Set to 0.0 (the default) to disable approximation.

ignore_decomposition_failures

When decomposition fails for an error mechanism, insert it into the DEM undecomposed (as a hyper-edge) instead of raising an exception. Only relevant when decompose_errors is True.

block_decomposition_from_introducing_remnant_edges

Prevent the decomposer from introducing remnant edges that would otherwise be needed to satisfy the decomposition.

Hyper-edges appear when a single fault trips both an X-type and a Z-type parity check. The circuit below prepares a Bell pair (the +1 eigenstate of both XX and ZZ) and measures each stabilizer with its own ancilla. A Y error on a data qubit anti-commutes with both checks and flips the data readout, so one mechanism flips three detectors at once. The accompanying single-qubit X and Z errors seed the graph-like edges that the hyper-edge decomposes into (since Y = X · Z):

# A hyper-edge arises naturally when one fault trips both an
# X-type and a Z-type parity check. This circuit prepares a Bell pair |Phi+>,
# the +1 eigenstate of both XX and ZZ, and measures each stabilizer with its
# own ancilla. A Y error anti-commutes with both checks and flips the data
# readout, lighting up three detectors at once. Because Y = X * Z, that
# hyper-edge decomposes into the separate X and Z edges seeded by the
# accompanying single-qubit errors.
@cudaq.kernel
def correlated_checks():
    data = cudaq.qvector(2)
    z_anc = cudaq.qubit()
    x_anc = cudaq.qubit()

    # Prepare |00> + |11>
    h(data[0])
    x.ctrl(data[0], data[1])

    cudaq.apply_noise(cudaq.XError, 0.01, data[0])
    cudaq.apply_noise(cudaq.ZError, 0.01, data[0])
    cudaq.apply_noise(cudaq.YError, 0.02, data[0])

    # ZZ parity check: the data qubits control the ancilla.
    x.ctrl(data[0], z_anc)
    x.ctrl(data[1], z_anc)
    z_syndrome = mz(z_anc)

    # XX parity check: the ancilla controls the data, read out in the X basis.
    h(x_anc)
    x.ctrl(x_anc, data[0])
    x.ctrl(x_anc, data[1])
    h(x_anc)
    x_syndrome = mz(x_anc)

    final = mz(data)
    cudaq.detector(z_syndrome)
    cudaq.detector(x_syndrome)
    cudaq.detector(final[0], final[1])


// A hyperedge arises naturally when one fault trips both an
// X-type and a Z-type parity check. This circuit prepares a Bell pair |Phi+>,
// the +1 eigenstate of both XX and ZZ, and measures each stabilizer with its
// own ancilla. A Y error anticommutes with both checks and flips the data
// readout, lighting up three detectors at once. Because Y = X * Z, that
// hyperedge decomposes into the separate X and Z edges seeded by the
// accompanying single-qubit errors.
__qpu__ void correlated_checks() {
  cudaq::qvector data(2);
  cudaq::qubit z_anc;
  cudaq::qubit x_anc;

  // Prepare |00> + |11>
  h(data[0]);
  x<cudaq::ctrl>(data[0], data[1]);

  cudaq::apply_noise<cudaq::x_error>(0.01, data[0]);
  cudaq::apply_noise<cudaq::z_error>(0.01, data[0]);
  cudaq::apply_noise<cudaq::y_error>(0.02, data[0]);

  // ZZ parity check: the data qubits control the ancilla.
  x<cudaq::ctrl>(data[0], z_anc);
  x<cudaq::ctrl>(data[1], z_anc);
  auto z_syndrome = mz(z_anc);

  // XX parity check: the ancilla controls the data, read out in the X basis.
  h(x_anc);
  x<cudaq::ctrl>(x_anc, data[0]);
  x<cudaq::ctrl>(x_anc, data[1]);
  h(x_anc);
  auto x_syndrome = mz(x_anc);

  auto final = mz(data);
  cudaq::detector(z_syndrome);
  cudaq::detector(x_syndrome);
  cudaq::detector(final[0], final[1]);
}

Generate the DEM with and without decompose_errors:

Pass any option as a keyword argument after the kernel arguments:

# Pass DEM options as keyword arguments to control the Stim error analyzer.
# decompose_errors=True splits hyper-edge mechanisms (three or more detectors)
# into pairs of graph-like edges, which is required by most MWPM decoders.
dem_raw = cudaq.dem_from_kernel(correlated_checks, noise_model=noise)
dem_decomposed = cudaq.dem_from_kernel(
    correlated_checks,
    noise_model=noise,
    decompose_errors=True,
)
print(f"Raw DEM:\n{dem_raw}")
print(f"Decomposed DEM:\n{dem_decomposed}")

Construct a cudaq::dem_options value and pass it as the third argument (after the noise model):

  // Pass a cudaq::dem_options struct to control the Stim error analyzer.
  // decompose_errors=true splits hyperedge mechanisms (three or more detectors)
  // into pairs of graphlike edges, which is required by most MWPM decoders.
  std::string dem_raw = cudaq::dem_from_kernel(correlated_checks, &noise);
  cudaq::dem_options opts;
  opts.decompose_errors = true;
  std::string dem_decomposed =
      cudaq::dem_from_kernel(correlated_checks, &noise, opts);
  std::printf("Raw DEM:\n%s\n", dem_raw.c_str());
  std::printf("Decomposed DEM:\n%s\n", dem_decomposed.c_str());

Without decomposition the Y error is a single three-detector hyper-edge (D0 D1 D2), printed alongside the graph-like X edge (D0 D2) and Z edge (D1):

error(0.02000000000000000042) D0 D1 D2
error(0.01000000000000000021) D0 D2
error(0.01000000000000000021) D1

With decompose_errors=True the hyper-edge is written as the product of two graph-like components, separated by ^:

error(0.01000000000000000021) D0 D2
error(0.02000000000000000042) D0 D2 ^ D1
error(0.01000000000000000021) D1

Measurement Matrices

The DEM describes how error mechanisms flip detectors, but decoders often also need to know how the raw measurement record maps onto the detectors and logical observables. dem_from_kernel can return that mapping alongside the DEM text as two sparse binary measurement matrices, m2d and m2o:

  • m2d has shape (num_detectors, num_measurements). Entry m2d[d, m] == 1 means measurement m contributes to detector d.

  • m2o has shape (num_observables, num_measurements). Entry m2o[k, m] == 1 means measurement m contributes to observable k.

In both matrices the columns are indexed by measurement in chronological order.

Pass return_measurement_matrices=True. The function then returns a 3-tuple (dem_text, m2d, m2o) instead of a plain string, where m2d and m2o are scipy.sparse.csr_matrix objects with binary entries:

# Set return_measurement_matrices=True to also obtain the sparse
# measurements-to-detectors (m2d) and measurements-to-observables (m2o)
# matrices. The function then returns a 3-tuple instead of a plain string.
# Both matrices are `scipy.sparse.csr_matrix` with binary entries, and their
# columns are indexed by measurement in chronological order.
dem_text, m2d, m2o = cudaq.dem_from_kernel(
    memory_experiment,
    2,
    noise_model=noise,
    return_measurement_matrices=True,
)
# m2d has shape `(num_detectors, num_measurements)`: m2d[d, m] == 1 means
# measurement m contributes to detector d. m2o has shape
# `(num_observables, num_measurements)` with the same convention for observables.
print(f"m2d shape: {m2d.shape}")
print(f"m2o shape: {m2o.shape}")
print(f"m2d:\n{m2d.toarray()}")
print(f"m2o:\n{m2o.toarray()}")

Use the overloads that accept cudaq::M2DSparseMatrix and cudaq::M2OSparseMatrix output references. Each rows[i] lists the chronological measurement indices contributing to that detector or observable, and num_measurements gives the column count:

  // Overloads taking cudaq::M2DSparseMatrix and cudaq::M2OSparseMatrix output
  // references also populate the measurements-to-detectors (m2d) and
  // measurements-to-observables (m2o) matrices. Both are filled in the same
  // circuit pass that produces the DEM text. Each row lists the chronological
  // measurement indices contributing to that detector / observable.
  cudaq::M2DSparseMatrix m2d;
  cudaq::M2OSparseMatrix m2o;
  std::string dem_mm =
      cudaq::dem_from_kernel(memory_experiment, &noise, m2d, m2o, /*rounds=*/2);
  std::printf("m2d: %zu detectors x %zu measurements\n", m2d.rows.size(),
              m2d.num_measurements);
  std::printf("m2o: %zu observables x %zu measurements\n", m2o.rows.size(),
              m2o.num_measurements);

  // The measurement matrices can be combined with any DEM options by passing a
  // cudaq::dem_options struct (as above) before the m2d/m2o output references.
  cudaq::dem_options mm_opts;
  mm_opts.decompose_errors = true;
  cudaq::M2DSparseMatrix m2d_dec;
  cudaq::M2OSparseMatrix m2o_dec;
  std::string dem_mm_decomposed = cudaq::dem_from_kernel(
      memory_experiment, &noise, mm_opts, m2d_dec, m2o_dec,
      /*rounds=*/2);

Both matrices are computed in the same pass as the DEM, so requesting them adds no additional circuit execution. They can be combined with any of the DEM options above (for example decompose_errors=True).

For the memory_experiment kernel above (with rounds=2) there are nine measurements and six detectors, so m2d is a 6 x 9 matrix and m2o is a 1 x 9 matrix. Each detector pairs a qubit’s measurement in one round with its value in the previous round, which shows up as the two ones per row of m2d; the single logical observable reads the three final-round measurements, giving the three ones in m2o:

m2d shape: (6, 9)
m2o shape: (1, 9)
m2d:
[[1 0 0 1 0 0 0 0 0]
 [0 1 0 0 1 0 0 0 0]
 [0 0 1 0 0 1 0 0 0]
 [0 0 0 1 0 0 1 0 0]
 [0 0 0 0 1 0 0 1 0]
 [0 0 0 0 0 1 0 0 1]]
m2o:
[[0 0 0 0 0 0 1 1 1]]

Limitations

  • Stabilizer (Clifford) circuits only. The DEM formalism requires detectors to be deterministic under noise-free execution, which is only well defined for Clifford circuits; a non-Clifford gate raises a diagnostic.

  • No measurement-conditional control flow. Branching on a measurement result changes the measurement count shot-to-shot and breaks the detector matrix model; such kernels are rejected.

  • Independent Pauli noise. Each error mechanism is assumed independent.

  • Pre-decomposition. The DEM reflects the abstract kernel circuit, not the hardware-decomposed circuit.