diff --git a/docs/sphinx/api/qec/cpp_api.rst b/docs/sphinx/api/qec/cpp_api.rst index a2f702c7b..572310ccd 100644 --- a/docs/sphinx/api/qec/cpp_api.rst +++ b/docs/sphinx/api/qec/cpp_api.rst @@ -44,6 +44,70 @@ Detector Error Model .. doxygenfunction:: cudaq::qec::decoder_context_from_memory_circuit(const code &, operation, std::size_t, cudaq::noise_model &, bool) .. doxygenfunction:: cudaq::qec::dem_from_stim_text(const std::string &, bool) +.. _dyn_dem_cpp_api: + +Dynamic DEM Construction +======================== + +Build a code-capacity or phenomenological DEM from CSS generator matrices +(no Stim circuit required), or compose per-round DEM chunks that can be +stitched and closed into a flat :cpp:struct:`cudaq::qec::detector_error_model`. +See :doc:`/examples_rst/qec/dyn_dem` for a walkthrough. + +CSS matrices and noise +---------------------- + +.. doxygenstruct:: cudaq::qec::css_code_matrices + :members: + +.. doxygenstruct:: cudaq::qec::css_noise_params + :members: + +.. doxygenfunction:: cudaq::qec::css_matrices_from_code +.. doxygenfunction:: cudaq::qec::dem_from_css_matrices(const css_code_matrices &, const css_noise_params &, std::size_t) +.. doxygenfunction:: cudaq::qec::dem_from_css_matrices(const code &, const css_noise_params &, std::size_t) + +Extended DEM chunks +------------------- + +.. doxygenstruct:: cudaq::qec::extended_dem + :members: + +.. doxygenfunction:: cudaq::qec::extended_dem_from_css_matrices + +.. doxygenstruct:: cudaq::qec::dem_chunk_spec + :members: + +.. doxygenstruct:: cudaq::qec::dem_chunks_spec + :members: + +.. doxygenfunction:: cudaq::qec::dem_chunk_from_spec +.. doxygenfunction:: cudaq::qec::dem_chunks_from_spec + +Stitch, close, and merge +------------------------ + +.. doxygenenum:: cudaq::qec::prior_combine_mode + +.. doxygenfunction:: cudaq::qec::dem_stitch +.. doxygenfunction:: cudaq::qec::dem_stitch_all +.. doxygenfunction:: cudaq::qec::dem_stitch_merged +.. doxygenfunction:: cudaq::qec::dem_close +.. doxygenfunction:: cudaq::qec::dem_close_all +.. doxygenfunction:: cudaq::qec::dem_merge_duplicate_columns +.. doxygenfunction:: cudaq::qec::are_dem_columns_unique +.. doxygenfunction:: cudaq::qec::assert_dem_columns_unique + +Streaming decoder maps +---------------------- + +.. doxygenfunction:: cudaq::qec::dem_chunk_rounds +.. doxygenfunction:: cudaq::qec::dem_chunks_to_rounds +.. doxygenfunction:: cudaq::qec::dem_chunks_to_detector_round +.. doxygenfunction:: cudaq::qec::dem_chunks_to_d_sparse +.. doxygenfunction:: cudaq::qec::dem_chunks_to_o_sparse +.. doxygenfunction:: cudaq::qec::dem_chunks_to_pcm + .. _dem_sampling_cpp_api: Detector Error Model (DEM) Sampling diff --git a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst index c62458a6a..709f9e433 100644 --- a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst +++ b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst @@ -43,6 +43,7 @@ The configuration API enables setting up decoders before circuit execution. Deco .. doxygenfunction:: cudaq::qec::decoding::config::configure_decoders_from_file .. doxygenfunction:: cudaq::qec::decoding::config::configure_decoders_from_str .. doxygenfunction:: cudaq::qec::decoding::config::finalize_decoders +.. doxygenfunction:: cudaq::qec::decoding::config::expand_dem_chunks Helper Functions ---------------- diff --git a/docs/sphinx/api/qec/python_api.rst b/docs/sphinx/api/qec/python_api.rst index 0840cbb88..71cfe0ba7 100644 --- a/docs/sphinx/api/qec/python_api.rst +++ b/docs/sphinx/api/qec/python_api.rst @@ -51,6 +51,69 @@ Detector Error Model .. autofunction:: cudaq_qec.dem_from_stim_text .. autofunction:: cudaq_qec.d_sparse +.. _dyn_dem_python_api: + +Dynamic DEM Construction +======================== + +Build a code-capacity or phenomenological DEM from CSS generator matrices +(no Stim circuit required), or compose per-round DEM *chunks* that can be +stitched and closed into a flat :class:`~cudaq_qec.DetectorErrorModel`. +See :doc:`/examples_rst/qec/dyn_dem` for a walkthrough. + +CSS matrices and noise +---------------------- + +.. autoclass:: cudaq_qec.CssCodes + :members: + +.. autoclass:: cudaq_qec.CssNoise + :members: + +.. autofunction:: cudaq_qec.css_matrices_from_code +.. autofunction:: cudaq_qec.dem_from_css_matrices + +Extended DEM chunks +------------------- + +.. autoclass:: cudaq_qec.ExtendedDem + :members: + +.. autofunction:: cudaq_qec.extended_dem_from_css_matrices + +.. autoclass:: cudaq_qec.DemChunkSpec + :members: + +.. autoclass:: cudaq_qec.DemChunksSpec + :members: + +.. autofunction:: cudaq_qec.dem_chunk_from_spec +.. autofunction:: cudaq_qec.dem_chunks_from_spec + +Stitch, close, and merge +------------------------ + +.. autoclass:: cudaq_qec.PriorCombineMode + :members: + +.. autofunction:: cudaq_qec.dem_stitch +.. autofunction:: cudaq_qec.dem_stitch_all +.. autofunction:: cudaq_qec.dem_stitch_merged +.. autofunction:: cudaq_qec.dem_close +.. autofunction:: cudaq_qec.dem_close_all +.. autofunction:: cudaq_qec.dem_merge_duplicate_columns +.. autofunction:: cudaq_qec.are_dem_columns_unique +.. autofunction:: cudaq_qec.assert_dem_columns_unique + +Streaming decoder maps +---------------------- + +.. autofunction:: cudaq_qec.dem_chunk_rounds +.. autofunction:: cudaq_qec.dem_chunks_to_rounds +.. autofunction:: cudaq_qec.dem_chunks_to_detector_round +.. autofunction:: cudaq_qec.dem_chunks_to_d_sparse +.. autofunction:: cudaq_qec.dem_chunks_to_o_sparse + Decoder Interfaces ================== diff --git a/docs/sphinx/components/qec/introduction.rst b/docs/sphinx/components/qec/introduction.rst index 4c761f21f..52c3475f0 100644 --- a/docs/sphinx/components/qec/introduction.rst +++ b/docs/sphinx/components/qec/introduction.rst @@ -859,6 +859,46 @@ backend selection details, see the :doc:`DEM Sampling example `. +Dynamic DEM Construction +^^^^^^^^^^^^^^^^^^^^^^^^ + +When a Stim circuit is not available — or when the round count must stay +flexible until decoder construction — build DEMs from CSS generator matrices +and compose them as per-round chunks: + +* ``dem_from_css_matrices`` — :math:`T`-round code-capacity / phenomenological + DEM from ``CssCodes`` / ``css_code_matrices`` and ``CssNoise`` / + ``css_noise_params``. +* ``extended_dem_from_css_matrices``, ``dem_stitch`` / ``dem_close_all`` — + one-round chunks that stitch and close to the same flat DEM. +* YAML ``dem_chunks`` + ``num_rounds`` — declarative init / bulk / final + phases for real-time decoder configs; expanded by ``expand_dem_chunks``. + +.. tab:: Python + + .. literalinclude:: ../../examples/qec/python/dyn_dem.py + :language: python + :start-after: [Begin Documentation] + :end-before: [End Documentation] + +.. tab:: C++ + + .. literalinclude:: ../../examples/qec/cpp/dyn_dem.cpp + :language: cpp + :start-after: [Begin Documentation] + :end-before: [End Documentation] + + Compile and run with + + .. code-block:: bash + + nvq++ -lcudaq-qec -lcudaq-qec-decoders dyn_dem.cpp + ./a.out + +See the :doc:`Dynamic DEM Construction example ` +for phase specs, YAML ``dem_chunks``, merge semantics, and closing rules. + + Pre-built QEC Decoders ---------------------- diff --git a/docs/sphinx/examples/qec/cpp/dyn_dem.cpp b/docs/sphinx/examples/qec/cpp/dyn_dem.cpp new file mode 100644 index 000000000..cf68248e0 --- /dev/null +++ b/docs/sphinx/examples/qec/cpp/dyn_dem.cpp @@ -0,0 +1,60 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ +// [Begin Documentation] +// Dynamic DEM construction from CSS matrices and composable DEM chunks. +// +// Compile and run with: +// nvq++ -lcudaq-qec -lcudaq-qec-decoders dyn_dem.cpp +// ./a.out + +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/dem_construction.h" +#include "cudaq/qec/extended_dem.h" + +#include +#include + +int main() { + // d=3 repetition code (Z-basis): H_Z = [[1,1,0],[0,1,1]], L_Z = [[1,0,0]]. + cudaq::qec::css_code_matrices code; + code.hz = cudaq::qec::sparse_binary_matrix::from_nested_csc( + 2, 3, {{0}, {0, 1}, {1}}); + code.lz = + cudaq::qec::sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {}, {}}); + + cudaq::qec::css_noise_params noise; + noise.px = 0.01; + noise.pm = 0.005; + + constexpr std::size_t num_rounds = 5; + + // Monolithic T-round DEM (code-capacity + measurement errors). + auto flat = cudaq::qec::dem_from_css_matrices(code, noise, num_rounds); + std::cout << "flat DEM: " << flat.num_detectors() << " detectors, " + << flat.num_error_mechanisms() << " faults, " + << flat.num_observables() << " observables\n"; + + // Same experiment as T one-round chunks closed in one O(T) pass. + auto chunk = cudaq::qec::extended_dem_from_css_matrices(code, noise); + std::vector chunks(num_rounds, chunk); + auto closed = cudaq::qec::dem_close_all(chunks); + + if (closed.num_detectors() != flat.num_detectors() || + closed.num_error_mechanisms() != flat.num_error_mechanisms()) { + std::cerr << "dem_close_all does not match dem_from_css_matrices\n"; + return 1; + } + std::cout << "dem_close_all matches dem_from_css_matrices\n"; + + auto detector_round = cudaq::qec::dem_chunks_to_detector_round(chunks); + auto d_sparse = cudaq::qec::dem_chunks_to_d_sparse(chunks); + std::cout << "detector_round length " << detector_round.size() + << ", D_sparse rows " << d_sparse.size() << "\n"; + return 0; +} +// [End Documentation] diff --git a/docs/sphinx/examples/qec/python/dyn_dem.py b/docs/sphinx/examples/qec/python/dyn_dem.py new file mode 100644 index 000000000..4aec4a513 --- /dev/null +++ b/docs/sphinx/examples/qec/python/dyn_dem.py @@ -0,0 +1,42 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +# [Begin Documentation] +import cudaq_qec as qec + +# Build a T-round DEM from a CSS code and phenomenological noise — no Stim +# circuit required. +code = qec.get_code("repetition", distance=3) +noise = qec.CssNoise() +noise.px = 0.01 +noise.pm = 0.005 + +num_rounds = 5 +flat = qec.dem_from_css_matrices(code, noise, num_rounds) +print(f"flat DEM: {flat.num_detectors()} detectors, " + f"{flat.num_error_mechanisms()} faults, " + f"{flat.num_observables()} observables") + +# The same experiment as composable one-round chunks. Stitch-and-close (or +# dem_close_all) recovers the flat DEM, which is useful when rounds are +# streamed or when init/bulk/final phases differ. +matrices = qec.css_matrices_from_code(code) +chunk = qec.extended_dem_from_css_matrices(matrices, noise) +chunks = [chunk] * num_rounds +closed = qec.dem_close_all(chunks) + +assert closed.num_detectors() == flat.num_detectors() +assert closed.num_error_mechanisms() == flat.num_error_mechanisms() +print("dem_close_all matches dem_from_css_matrices") + +# Streaming helpers: detector→round map and D_sparse for realtime configs. +detector_round = qec.dem_chunks_to_detector_round(chunks) +d_sparse = qec.dem_chunks_to_d_sparse(chunks) +print(f"detector_round length {len(detector_round)}, " + f"D_sparse rows {len(d_sparse)}") +# [End Documentation] diff --git a/docs/sphinx/examples_rst/qec/dyn_dem.rst b/docs/sphinx/examples_rst/qec/dyn_dem.rst new file mode 100644 index 000000000..54a937fe1 --- /dev/null +++ b/docs/sphinx/examples_rst/qec/dyn_dem.rst @@ -0,0 +1,142 @@ +.. _dyn_dem_example: + +Dynamic DEM Construction +------------------------ + +A **detector error model** (DEM) relates independent fault mechanisms to the +detectors and logical observables they flip. CUDA-Q QEC already builds DEMs +from Stim circuits (``dem_from_memory_circuit``, ``dem_from_stim_text``). The +*dynamic DEM* interfaces add two complementary paths that do not require a +circuit: + +1. **Matrix construction** — ``dem_from_css_matrices`` builds a + :math:`T`-round code-capacity (or phenomenological) DEM directly from CSS + generator matrices and noise rates. +2. **Composable chunks** — an :class:`~cudaq_qec.ExtendedDem` is one round (or + phase) of that model. Stitch chunks across round boundaries and + ``dem_close`` / ``dem_close_all`` to recover a flat + :class:`~cudaq_qec.DetectorErrorModel`. + +The chunk form is what streaming and real-time decoders need when the round +count is chosen at run time: the same init / bulk / final phase description +expands to any :math:`T \ge 2`, and helpers such as +``dem_chunks_to_d_sparse`` fill the ``D_sparse`` field of a decoder config. + +Matrix construction ++++++++++++++++++++ + +``css_code_matrices`` (Python: ``CssCodes``) holds the four sparse CSS blocks +``hz``, ``hx``, ``lz``, ``lx``. ``css_noise_params`` (Python: ``CssNoise``) +supplies uniform or per-qubit / per-check rates ``px``, ``py``, ``pz``, and +optional measurement-error rate ``pm``. + +With ``num_rounds = T``, each data-qubit fault in round :math:`r` contributes +to detector bands :math:`r` and :math:`r+1` (syndrome differences), except the +final round which touches only band :math:`T-1`. Measurement errors flip no +logical observable. The invariant + +.. math:: + + \mathrm{dem\_close}(\mathrm{dem\_stitch\_all}( + \underbrace{c,\ldots,c}_{T})) + \;=\; + \mathrm{dem\_from\_css\_matrices}(\mathrm{code},\mathrm{noise},T) + +holds for one-round chunks :math:`c` built by +``extended_dem_from_css_matrices``. Prefer ``dem_close_all`` when only the +closed DEM is needed: it is :math:`O(T)` rather than the left-fold +:math:`O(T^2)` of ``dem_stitch_all``. + +Example ++++++++ + +.. tab:: Python + + .. literalinclude:: ../../examples/qec/python/dyn_dem.py + :language: python + :start-after: [Begin Documentation] + :end-before: [End Documentation] + +.. tab:: C++ + + .. literalinclude:: ../../examples/qec/cpp/dyn_dem.cpp + :language: cpp + :start-after: [Begin Documentation] + :end-before: [End Documentation] + + Compile and run with + + .. code-block:: bash + + nvq++ -lcudaq-qec -lcudaq-qec-decoders dyn_dem.cpp + ./a.out + +Phase specs and YAML ``dem_chunks`` ++++++++++++++++++++++++++++++++++++ + +For asymmetric init / bulk / final rounds (for example dropping measurement +errors on a destructive final readout), describe each phase as a +:class:`~cudaq_qec.DemChunkSpec` — the same ``-1``-terminated sparse lists used +by ``H_sparse`` / ``O_sparse`` — and group them in a +:class:`~cudaq_qec.DemChunksSpec`. ``dem_chunks_from_spec(spec, num_rounds)`` +expands to ``init``, ``num_rounds - 2`` copies of ``bulk``, then ``final``. + +Real-time decoder YAML accepts the same structure under ``dem_chunks`` with +``num_rounds``. When ``H_sparse`` is empty, the configuration is *chunk form*: +``expand_dem_chunks`` (called during decoder construction) derives +``block_size``, ``syndrome_size``, ``H_sparse``, ``O_sparse``, and +``D_sparse``, and supplies ``error_rate_vec`` from the closed DEM priors. A +nonempty ``H_sparse`` keeps the config in flat form (the matrix wins). + +.. code-block:: yaml + + decoders: + - id: 0 + type: single_error_lut + num_rounds: 5 + dem_chunks: + init: + num_faults: 9 + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + bulk: + num_faults: 9 + H_in_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] + +Duplicate fault columns ++++++++++++++++++++++++ + +After stitching, identical row-support columns can appear when the same +physical fault is modelled on both sides of a seam. ``dem_merge_duplicate_columns`` +(or ``dem_stitch_merged``) collapses them. The default +:class:`~cudaq_qec.PriorCombineMode` ``or_combine`` uses the GF(2) / XOR rule +:math:`p = \tfrac12\bigl(1 - \prod_i (1 - 2 p_i)\bigr)`, matching DEM +canonicalization elsewhere in CUDA-Q QEC. ``sum_combine`` is a small-:math:`p` +linear approximation, clamped to :math:`[0, 1]`. + +Closing drops ``out_syndrome`` +++++++++++++++++++++++++++++++ + +``dem_close`` and ``dem_close_all`` intentionally discard the last chunk's +outgoing seam: there is no later round for it to differ against. Put any +final-boundary detector in ``in_syndrome`` or ``interior`` (for a phase +spec, ``H_in_sparse`` / ``H_mid_sparse``). ``dem_chunks_spec`` validation +rejects a nonempty ``final.H_out_sparse`` for this reason. + +See also +++++++++ + +- :ref:`dyn_dem_python_api` — Python API reference +- :ref:`dyn_dem_cpp_api` — C++ API reference +- :doc:`/examples_rst/qec/realtime_decoding` — decoder configuration YAML +- :doc:`/examples_rst/qec/dem_sampling` — sampling from a flat DEM diff --git a/docs/sphinx/examples_rst/qec/examples.rst b/docs/sphinx/examples_rst/qec/examples.rst index 8bd215188..234ca3b9b 100644 --- a/docs/sphinx/examples_rst/qec/examples.rst +++ b/docs/sphinx/examples_rst/qec/examples.rst @@ -13,6 +13,7 @@ Examples that illustrate how to use CUDA-QX for application development are avai Decoders Improving Relay BP Decoding With Gamma Ensembles DEM Sampling + Dynamic DEM Construction Real-Time Decoding AI Predecoder with CUDA-Q Realtime AI Predecoder with CUDA-Q Realtime (with FPGA Data Injection) diff --git a/docs/sphinx/examples_rst/qec/realtime_decoding.rst b/docs/sphinx/examples_rst/qec/realtime_decoding.rst index aba22e275..55664e027 100644 --- a/docs/sphinx/examples_rst/qec/realtime_decoding.rst +++ b/docs/sphinx/examples_rst/qec/realtime_decoding.rst @@ -168,6 +168,12 @@ arguments: error_rate_vec: [ 0.1, 0.1, 0.1 ] merge_strategy: smallest_weight +When the round count is not fixed until run time, describe the DEM as +``dem_chunks`` (init / optional bulk / final phases) plus ``num_rounds`` +instead of spelling out ``H_sparse``, ``O_sparse``, and ``D_sparse``. Decoder +construction expands the phases through ``expand_dem_chunks``. See +:doc:`/examples_rst/qec/dyn_dem` for the chunk layout and a YAML example. + The ``decoder_custom_args`` section is converted between YAML and the parameter map a decoder's constructor receives using a *parameter schema* registered under the decoder's name. All built-in decoders ship with a diff --git a/libs/qec/include/cudaq/qec/code_matrices.h b/libs/qec/include/cudaq/qec/code_matrices.h new file mode 100644 index 000000000..65d288d8c --- /dev/null +++ b/libs/qec/include/cudaq/qec/code_matrices.h @@ -0,0 +1,231 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// This file defines the css_code_matrices and css_noise_params types used +// to describe a Calderbank-Shor-Steane (CSS) quantum error-correcting code +// in terms of its sparse binary generator matrices and a depolarizing noise +// model. These types serve as the primary input interface for +// dem_from_css_matrices() and are intended to be derived from a code object +// via css_matrices_from_code(), or constructed directly from raw matrix data. + +#pragma once + +#include "cudaq/qec/sparse_binary_matrix.h" +#include +#include +#include +#include + +namespace cudaq::qec { + +/// @brief CSS code generator matrices: parity-check and logical-operator +/// matrices for a Calderbank-Shor-Steane (CSS) quantum error-correcting code. +/// +/// All matrices are sparse binary (GF(2)). Each matrix column corresponds to +/// one data qubit, so every non-empty matrix must share the same num_cols() +/// value (the number of data qubits, n). A matrix with zero rows is a valid +/// empty block; its num_cols() may be 0 (default-constructed) or n. +/// +/// Row semantics: +/// hz — Z-type stabilizer generators [n_z_checks x n_qubits]. +/// Row i is the support of the i-th Z-stabilizer generator. +/// A Z-stabilizer anticommutes with X errors, so hz[:,q] gives +/// the set of Z-checks triggered by an X error on qubit q. +/// hx — X-type stabilizer generators [n_x_checks x n_qubits]. +/// Detects Z errors symmetrically; hx[:,q] gives the X-checks +/// triggered by a Z error on qubit q. +/// lz — Z-type logical operators [k x n_qubits]. +/// lz[r, q] = 1 iff the r-th Z logical observable is flipped +/// by an X error on qubit q. +/// lx — X-type logical operators [k x n_qubits]. +/// lx[r, q] = 1 iff the r-th X logical observable is flipped +/// by a Z error on qubit q. +struct css_code_matrices { + sparse_binary_matrix hz; ///< Z stabilizers [n_z_checks x n_qubits] + sparse_binary_matrix hx; ///< X stabilizers [n_x_checks x n_qubits] + sparse_binary_matrix lz; ///< Z logical operators [k x n_qubits] + sparse_binary_matrix lx; ///< X logical operators [k x n_qubits] +}; + +/// @brief Noise parameters for the phenomenological noise model. +/// +/// Extends the code-capacity (data-qubit-only) model with syndrome +/// measurement errors, which are the most important circuit-level effect +/// for CSS codes: each stabilizer measurement can produce the wrong outcome +/// with probability pm, creating a time-local detector event that spans +/// two consecutive rounds without flipping any logical observable. +/// +/// Scalar rates (px, py, pz, pm) apply uniformly. Per-element override +/// vectors take priority when non-empty; their length must equal n_qubits +/// (for data-qubit rates) or n_checks = hz.num_rows() + hx.num_rows() +/// (for pm_per_check, Z-checks first then X-checks). Elements with +/// effective rate zero produce no DEM column. +struct css_noise_params { + double px = 0.0; ///< Uniform X data-qubit rate; overridden by px_per_qubit + double py = 0.0; ///< Uniform Y data-qubit rate; overridden by py_per_qubit + double pz = 0.0; ///< Uniform Z data-qubit rate; overridden by pz_per_qubit + double pm = 0.0; ///< Uniform syndrome measurement error rate per check per + ///< round; overridden by pm_per_check if set + + /// Per-qubit X rates. If non-empty, length must equal n_qubits. + std::vector px_per_qubit; + /// Per-qubit Y rates. If non-empty, length must equal n_qubits. + std::vector py_per_qubit; + /// Per-qubit Z rates. If non-empty, length must equal n_qubits. + std::vector pz_per_qubit; + /// Per-check measurement error rates. If non-empty, length must equal + /// n_checks (= hz.num_rows() + hx.num_rows()); Z-checks first. + std::vector pm_per_check; +}; + +// --------------------------------------------------------------------------- +// Implementation details used by dem_construction.cpp and extended_dem.cpp. +// Not part of the public API. +// --------------------------------------------------------------------------- + +namespace detail { + +using col_list = std::vector>; + +/// Determine n_qubits from the first matrix that has a nonzero column count. +/// Returns 0 when all matrices are default-constructed. +inline std::size_t resolve_num_qubits(const css_code_matrices &code) { + if (code.hz.num_cols() > 0) + return static_cast(code.hz.num_cols()); + if (code.hx.num_cols() > 0) + return static_cast(code.hx.num_cols()); + if (code.lz.num_cols() > 0) + return static_cast(code.lz.num_cols()); + if (code.lx.num_cols() > 0) + return static_cast(code.lx.num_cols()); + return 0; +} + +/// Throw if m has nonzero rows but a column count that disagrees with n. +inline void check_num_cols(const sparse_binary_matrix &m, std::size_t n, + const char *label) { + if (m.num_rows() != 0 && static_cast(m.num_cols()) != n) + throw std::invalid_argument( + std::string(label) + " num_cols (" + std::to_string(m.num_cols()) + + ") does not match n_qubits (" + std::to_string(n) + ")"); +} + +/// Throw if rates is non-empty with a length that does not match n. +/// size_label names the dimension n counts, so a per-check vector does not +/// report a per-qubit mismatch. +inline void check_rate_vector_size(const std::vector &rates, + std::size_t n, const char *label, + const char *size_label) { + if (!rates.empty() && rates.size() != n) + throw std::invalid_argument(std::string(label) + " has " + + std::to_string(rates.size()) + " entries but " + + size_label + "=" + std::to_string(n)); +} + +/// Throw if per_qubit is non-empty with a length that does not match n_qubits. +inline void check_per_qubit_size(const std::vector &per_qubit, + std::size_t n, const char *label) { + check_rate_vector_size(per_qubit, n, label, "n_qubits"); +} + +/// Throw unless p is a probability. Written as a negated range test so that +/// NaN, which compares false against everything, is rejected too. +inline void check_probability(double p, const std::string &label) { + if (!(p >= 0.0 && p <= 1.0)) + throw std::invalid_argument(label + " must be a probability in [0, 1]," + + " got " + std::to_string(p)); +} + +/// Throw unless every scalar and per-element rate is a probability. Zero rates +/// are legal and simply produce no DEM column; the point of this check is that +/// a negative or NaN rate is silently inactive under that same rule, so a +/// mistyped configuration would otherwise build a smaller DEM instead of +/// failing. +inline void validate_noise_rates(const css_noise_params &noise) { + check_probability(noise.px, "px"); + check_probability(noise.py, "py"); + check_probability(noise.pz, "pz"); + check_probability(noise.pm, "pm"); + + const auto check_each = [](const std::vector &rates, + const char *label) { + for (std::size_t i = 0; i < rates.size(); ++i) + check_probability(rates[i], + std::string(label) + "[" + std::to_string(i) + "]"); + }; + check_each(noise.px_per_qubit, "px_per_qubit"); + check_each(noise.py_per_qubit, "py_per_qubit"); + check_each(noise.pz_per_qubit, "pz_per_qubit"); + check_each(noise.pm_per_check, "pm_per_check"); +} + +/// True when any noise rate is nonzero, i.e. the model asks for at least one +/// fault mechanism. Call only after validate_noise_rates(), so that a nonzero +/// rate here really is a positive probability. +inline bool has_any_noise(const css_noise_params &noise) { + const auto any_nonzero = [](const std::vector &rates) { + for (const double p : rates) + if (p != 0.0) + return true; + return false; + }; + return noise.px != 0.0 || noise.py != 0.0 || noise.pz != 0.0 || + noise.pm != 0.0 || any_nonzero(noise.px_per_qubit) || + any_nonzero(noise.py_per_qubit) || any_nonzero(noise.pz_per_qubit) || + any_nonzero(noise.pm_per_check); +} + +/// Nested CSC for m padded to outer size n; new entries are empty vectors. +inline col_list padded_nested_csc(const sparse_binary_matrix &m, + std::size_t n) { + auto cols = m.to_nested_csc(); + cols.resize(n); + return cols; +} + +/// Effective rate for qubit q: per_qubit[q] when set, else uniform. +inline double qubit_rate(double uniform, const std::vector &per_qubit, + std::size_t q) { + return per_qubit.empty() ? uniform : per_qubit[q]; +} + +/// Qubit indices (ascending) whose effective rate is nonzero. +inline std::vector +active_qubits(double uniform, const std::vector &per_qubit, + std::size_t n) { + std::vector out; + out.reserve(n); + for (std::size_t q = 0; q < n; ++q) + if (qubit_rate(uniform, per_qubit, q) > 0.0) + out.push_back(q); + return out; +} + +/// Effective measurement error rate for check k (k indexes all checks, +/// Z-type first then X-type): pm_per_check[k] when set, else uniform pm. +inline double check_rate(double uniform_pm, + const std::vector &pm_per_check, + std::size_t k) { + return pm_per_check.empty() ? uniform_pm : pm_per_check[k]; +} + +/// Check indices (ascending, 0..n_checks-1) with a nonzero effective rate. +inline std::vector +active_checks(double uniform_pm, const std::vector &pm_per_check, + std::size_t n_checks) { + std::vector out; + out.reserve(n_checks); + for (std::size_t k = 0; k < n_checks; ++k) + if (check_rate(uniform_pm, pm_per_check, k) > 0.0) + out.push_back(k); + return out; +} + +} // namespace detail + +} // namespace cudaq::qec diff --git a/libs/qec/include/cudaq/qec/dem_construction.h b/libs/qec/include/cudaq/qec/dem_construction.h new file mode 100644 index 000000000..bb47a9817 --- /dev/null +++ b/libs/qec/include/cudaq/qec/dem_construction.h @@ -0,0 +1,76 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// This file declares dem_from_css_matrices(), which builds a T-round +// detector_error_model directly from CSS generator matrices and a noise +// model, without requiring a stabilizer circuit or Stim round-trip. +// num_rounds defaults to 1 for the single-round case. +// +// The model is code-capacity when only data-qubit rates are set, and +// phenomenological when a measurement error rate (pm / pm_per_check) is set +// as well, which adds one fault column per active check per round. + +#pragma once + +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/detector_error_model.h" + +namespace cudaq::qec { + +/// @brief Build a T-round detector_error_model from CSS generator matrices +/// and a depolarizing (optionally phenomenological) noise model. +/// +/// Models T independent syndrome measurement rounds. Detectors are syndrome +/// *differences* between consecutive rounds: detector[r] fires when the +/// syndrome in round r differs from the syndrome in round r-1 (round 0 is +/// compared to the zero initial state). A data-qubit fault in round r +/// therefore spans two adjacent detector bands — round r and round r+1 — +/// because it changes the syndrome in round r but not in round r-1 or r+1. +/// Faults in the final round (r = T-1) span only that round's band since +/// there is no round T. +/// +/// With num_rounds = 1 (the default), the model reduces to a flat +/// single-round code-capacity DEM: each fault spans only one detector band. +/// +/// @param code CSS code matrices. All non-empty matrices must share the +/// same num_cols() value (n_qubits). Default-constructed +/// (zero-row, zero-column) matrices are treated as empty. +/// @param noise Noise rates applied identically to every round. +/// Per-element vectors override the scalar rates; elements +/// with effective rate 0 produce no column. +/// @param num_rounds Number of syndrome measurement rounds T (default 1, +/// must be >= 1). +/// @return detector_error_model with: +/// - detector_error_matrix: [T*d x e] where +/// d = hz.num_rows() + hx.num_rows(), +/// e = T * (|active_X| + |active_Z| + |active_Y| + |active_checks|). +/// |active_checks| is the number of checks with a nonzero measurement +/// error rate, and is 0 unless pm or pm_per_check is set. +/// Round r occupies rows r*d .. (r+1)*d-1. Within each round: +/// rows 0 .. hz.num_rows()-1 are Z-type detectors (X and Y faults); +/// rows hz.num_rows() .. d-1 are X-type detectors (Z and Y faults). +/// A measurement-error column touches the one detector row of its own +/// check in rounds r and r+1, whichever type that check is. +/// - observables_flips_matrix: [k x e], +/// k = lz.num_rows() + lx.num_rows(). +/// Faults in any round flip the same observable rows (the logical +/// measurement is taken once at the end of the experiment). Measurement +/// errors flip no observable. +/// - error_rates: column layout is +/// [round 0 faults | ... | round T-1 faults]; within each round: +/// [active X qubits | active Z qubits | active Y qubits | +/// active checks], each in ascending index order. +/// @throws std::invalid_argument if num_rounds is 0, if two non-empty +/// matrices have inconsistent num_cols() values, if a per-qubit rate +/// vector length does not equal n_qubits, if pm_per_check's length +/// does not equal d, or if any rate is not a probability in [0, 1]. +detector_error_model dem_from_css_matrices(const css_code_matrices &code, + const css_noise_params &noise, + std::size_t num_rounds = 1); + +} // namespace cudaq::qec diff --git a/libs/qec/include/cudaq/qec/dem_construction_code.h b/libs/qec/include/cudaq/qec/dem_construction_code.h new file mode 100644 index 000000000..b0117c8b3 --- /dev/null +++ b/libs/qec/include/cudaq/qec/dem_construction_code.h @@ -0,0 +1,50 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// This file declares convenience functions that bridge the code base class +// to css_code_matrices and dem_from_css_matrices(). They live in cudaq-qec +// (not cudaq-qec-decoders) because they depend on the code base class which +// requires the CUDA-Q framework. Callers working purely from raw matrix data +// should use dem_construction.h directly. + +#pragma once + +#include "cudaq/qec/code.h" +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/dem_construction.h" + +namespace cudaq::qec { + +/// @brief Extract CSS generator matrices from a code object. +/// +/// Converts the four parity/observable tensors returned by the code object +/// into a css_code_matrices suitable for direct use with +/// dem_from_css_matrices(). The conversion uses the implicit +/// sparse_binary_matrix(tensor) constructor (CSC layout). +/// +/// @param qec_code A constructed CSS code object (repetition, surface, etc.). +/// @return css_code_matrices populated from the code's parity and logical +/// operator matrices. +css_code_matrices css_matrices_from_code(const code &qec_code); + +/// @brief Build a T-round code-capacity DEM directly from a code object. +/// +/// Convenience wrapper equivalent to: +/// dem_from_css_matrices(css_matrices_from_code(qec_code), noise, num_rounds) +/// +/// @param qec_code A constructed CSS code object. +/// @param noise Per-qubit depolarizing noise parameters. +/// @param num_rounds Number of syndrome measurement rounds T (default 1). +/// @return A T-round code-capacity DEM. See +/// dem_from_css_matrices(css_code_matrices, css_noise_params, +/// std::size_t) for full semantics. +detector_error_model dem_from_css_matrices(const code &qec_code, + const css_noise_params &noise, + std::size_t num_rounds = 1); + +} // namespace cudaq::qec diff --git a/libs/qec/include/cudaq/qec/extended_dem.h b/libs/qec/include/cudaq/qec/extended_dem.h new file mode 100644 index 000000000..efe2294b0 --- /dev/null +++ b/libs/qec/include/cudaq/qec/extended_dem.h @@ -0,0 +1,505 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// This file defines extended_dem — one chunk of a detector error model, which +// partitions the PCM into four row groups (interior detectors, observable +// flips, incoming-seam syndrome, and outgoing-seam syndrome) — together with +// the three operations that compose chunks into a flat detector_error_model: +// +// extended_dem_from_css_matrices() — build a one-round chunk +// dem_stitch(a, b) — compose two adjacent chunks +// dem_close(dem) — collapse seam rows into a flat DEM +// +// Stitching contracts a.out_syndrome and b.in_syndrome onto the same output +// rows (the seam), so each fault in A and each fault in B independently +// contribute to the seam detector. Stitching T one-round chunks and closing +// produces output identical to dem_from_css_matrices(code, noise, T). + +#pragma once + +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/detector_error_model.h" +#include "cudaq/qec/sparse_binary_matrix.h" +#include +#include +#include + +namespace cudaq::qec { + +/// @brief One DEM chunk, partitioned into interior, observable, and seam rows. +/// +/// All four sparse matrices share the same num_cols() — the number of fault +/// mechanisms in this chunk. Row counts: +/// interior: n_interior detectors fully inside this chunk. +/// observables: k = lz.num_rows() + lx.num_rows(). +/// in_syndrome: d = hz.num_rows() + hx.num_rows() (left seam, incoming). +/// out_syndrome: d (right seam, outgoing). +/// +/// For a one-round chunk, in_syndrome == out_syndrome (the raw syndrome of +/// that round participates in both the left and right seam detectors). Tags +/// identify each seam row: a.out_tags[k] must equal b.in_tags[k] for dem_stitch +/// to be valid. +/// +/// The two seams need not be the same width. A phase chunk in the +/// init / bulk / final decomposition is deliberately asymmetric: +/// - init has no incoming seam (nothing precedes it), so in_syndrome has +/// zero rows while out_syndrome carries the syndrome forward. Its own +/// detectors live in interior, because round 0 is compared against the +/// zero initial state rather than against a previous round. +/// - bulk has both seams and usually no interior rows: its detectors are +/// the seam differences formed when it is stitched to its neighbours. +/// - final has no outgoing seam, so out_syndrome has zero rows. +/// Use num_in_seam_rows() / num_out_seam_rows() when the distinction matters; +/// num_seam_rows() reports the incoming width and is the right choice only for +/// the uniform per-round chunks the streaming helpers below expect. +struct extended_dem { + sparse_binary_matrix interior; ///< [n_int x n_faults] interior detectors + sparse_binary_matrix observables; ///< [k x n_faults] observable flips + sparse_binary_matrix in_syndrome; ///< [d x n_faults] left seam syndrome + sparse_binary_matrix out_syndrome; ///< [d x n_faults] right seam syndrome + std::vector fault_priors; ///< length n_faults + std::vector in_tags; ///< check identity per in_syndrome row + std::vector out_tags; ///< check identity per out_syndrome row + + /// Number of fault columns (num_cols shared by all four matrices). + uint32_t num_faults() const; + + /// Number of interior detector rows. + uint32_t num_interior() const; + + /// Number of observable rows. + uint32_t num_observables() const; + + /// Number of incoming-seam rows, i.e. rows of in_syndrome. + /// + /// Equals num_out_seam_rows() for the uniform per-round chunks produced by + /// extended_dem_from_css_matrices(); differs for phase chunks. + uint32_t num_seam_rows() const; + + /// Number of incoming-seam rows. Spelled-out alias for num_seam_rows(), + /// for call sites where the asymmetry is the point. + uint32_t num_in_seam_rows() const; + + /// Number of outgoing-seam rows, i.e. rows of out_syndrome. + uint32_t num_out_seam_rows() const; + + /// @brief Throw std::invalid_argument unless this chunk is internally + /// consistent, i.e. safe to stitch, close, or merge. + /// + /// num_faults() reports in_syndrome's width alone, so nothing else about a + /// chunk is self-describing: a block that disagrees with it, or a prior list + /// that does not have one entry per fault, would otherwise be read at the + /// wrong width and silently misalign columns (or walk past the end of a + /// nested column list). Checks that + /// - every block is num_faults() columns wide, including zero-row blocks + /// (use a width-n empty matrix, not a default-constructed one, when the + /// chunk has faults), + /// - fault_priors has one entry per fault, and + /// - each tag vector has one entry per row of the seam it names. + /// + /// @param context Prefix for the error message, naming the caller. + void validate(const char *context) const; +}; + +/// @brief Build a one-round extended_dem from CSS matrices and noise. +/// +/// For each active fault (qubit with nonzero rate), emits one fault column: +/// - in_syndrome and out_syndrome rows: the raw syndrome of that fault +/// (hz[:,q] for X faults, hx[:,q] for Z faults; both for Y). +/// in_syndrome == out_syndrome for a one-round chunk because the same +/// syndrome[r] appears on both sides of the seam detectors. +/// - observables rows: lz[:,q] for X/Y faults, lx[:,q] for Z/Y faults. +/// - interior rows: empty (no syndrome differences within one round). +/// +/// Tags are sequential: in_tags[k] = out_tags[k] = k. +/// +/// @param code CSS code matrices. Same constraints as dem_from_css_matrices. +/// @param noise Depolarizing noise rates. Per-qubit vectors override scalars. +/// @return One-round extended_dem with n_seam_rows = hz.num_rows() + +/// hx.num_rows() and n_faults = |active_X|+|active_Z|+|active_Y|. +/// @throws std::invalid_argument on dimension or per-qubit size mismatch. +extended_dem extended_dem_from_css_matrices(const css_code_matrices &code, + const css_noise_params &noise); + +// --------------------------------------------------------------------------- +// Declarative chunk specs (the form carried in decoder configuration YAML) +// --------------------------------------------------------------------------- + +/// @brief One DEM chunk written as flat row-sparse index lists. +/// +/// Each vector holds the fault-column indices of a row followed by a -1 +/// terminator, so `[0, 1, 5, -1, 1, 2, 6, -1]` is two rows and the row count of +/// a well-formed vector is its number of -1 entries. This is the same encoding +/// the decoder configuration already uses for H_sparse / O_sparse / D_sparse, +/// which is why the phases can be written directly in YAML. +/// +/// The four matrices map onto extended_dem's row groups: +/// H_in_sparse -> in_syndrome (incoming seam; empty for an init phase) +/// H_mid_sparse -> interior (detectors wholly inside this chunk) +/// H_out_sparse -> out_syndrome (outgoing seam; empty for a final phase) +/// O_sparse -> observables +struct dem_chunk_spec { + /// Number of fault columns, i.e. num_cols() of all four matrices. + uint64_t num_faults = 0; + std::vector H_in_sparse; + std::vector H_mid_sparse; + std::vector H_out_sparse; + std::vector O_sparse; + /// Prior for each fault column; must have num_faults entries. + std::vector error_rates; + + bool operator==(const dem_chunk_spec &) const = default; + + /// True when nothing has been set, used to detect an omitted phase. + bool is_empty() const; + + /// @brief Check internal consistency: a positive fault count that fits in + /// uint32_t (sparse matrix column index width), one error rate per fault with + /// each in [0, 1], and index lists that are -1 terminated with every index + /// in [0, num_faults). + /// @param context Prefix for error messages, e.g. "dem_chunks.init". + /// @throws std::invalid_argument on the first violation. + void validate(const std::string &context) const; +}; + +/// @brief The init / bulk / final phases of a repeated-round decomposition. +/// +/// A run of T rounds is `init`, then `bulk` repeated T-2 times, then `final`. +/// Because `bulk` is what repeats, its incoming and outgoing seams must be the +/// same width; init has no incoming seam and final no outgoing seam. +struct dem_chunks_spec { + dem_chunk_spec init; + /// Optional: omit for a decomposition with no repeated middle. + dem_chunk_spec bulk; + dem_chunk_spec final; + + bool operator==(const dem_chunks_spec &) const = default; + + /// True when no phase has been set. + bool is_empty() const; + + /// True when a repeated middle phase was supplied. + bool has_bulk() const; + + /// @brief Validate each phase, then the relationships between them: init + /// carries no incoming seam, final no outgoing seam, bulk's two seams are + /// equally wide, the contracted seams line up, and every phase reports the + /// same number of observables. + /// @throws std::invalid_argument on the first violation. + void validate() const; +}; + +/// @brief Build an extended_dem from a dem_chunk_spec. +/// +/// Seam tags are assigned sequentially (in_tags[k] = out_tags[k] = k), which is +/// what makes adjacent phases stitchable: the k-th check keeps its identity +/// across every phase boundary. +/// +/// @param spec Chunk to materialize. +/// @param context Prefix for error messages raised by spec.validate(). +/// @return The chunk as an extended_dem. +/// @throws std::invalid_argument if the spec is inconsistent. +extended_dem dem_chunk_from_spec(const dem_chunk_spec &spec, + const std::string &context = "dem_chunk"); + +/// @brief Expand a phase spec into the chunk sequence for a given round count. +/// +/// Produces init, `num_rounds - 2` copies of bulk, then final, which is the +/// sequence dem_stitch_all() consumes to build the whole experiment. When the +/// spec has no bulk phase, only num_rounds == 2 is representable. +/// +/// @param spec Validated phase specs. +/// @param num_rounds Total rounds, counting init and final. Must be >= 2. +/// @return num_rounds chunks, ready to stitch left to right. +/// @throws std::invalid_argument if num_rounds < 2, if bulk repeats are needed +/// but no bulk phase was supplied, or if the spec is inconsistent. +std::vector dem_chunks_from_spec(const dem_chunks_spec &spec, + std::size_t num_rounds); + +/// @brief Stitch two adjacent DEM chunks: contract a.out_syndrome with +/// b.in_syndrome. +/// +/// The seam = [a.out_syndrome | b.in_syndrome] (fault columns from A then B) +/// becomes new interior rows in the result. Each fault in A and each fault in +/// B independently contribute to the same seam-detector row, which fires when +/// the syndrome changes across the chunk boundary. The stitched chunk spans +/// the fault mechanisms of both inputs. +/// +/// Interior rows come out in ascending round order -- a's interior, then the +/// seam between them, then b's interior -- for any association of stitches, so +/// dem_close_all() and dem_chunks_to_detector_round() read the same round +/// layout out of a stitched chunk as they would out of the rounds pushed +/// separately. +/// +/// Row counts after stitching: +/// interior: a.num_interior() + b.num_interior() + a.num_out_seam_rows() +/// observables: a.num_observables() (must equal b.num_observables()) +/// in_syndrome: a.num_in_seam_rows() +/// out_syndrome: b.num_out_seam_rows() +/// +/// The contracted seam is a's outgoing side against b's incoming side, so +/// those two widths must agree; a's incoming and b's outgoing widths are +/// carried through untouched and may be anything, including zero. That is what +/// lets an init chunk (no incoming seam) stitch to a bulk chunk, and a bulk +/// chunk to a final one (no outgoing seam). +/// +/// @param a Left DEM chunk. a.out_tags must equal b.in_tags element-wise. +/// @param b Right DEM chunk. +/// @return Stitched extended_dem. +/// @throws std::runtime_error if a's outgoing seam width differs from b's +/// incoming seam width, on tag mismatch, or on observable-count +/// mismatch. +extended_dem dem_stitch(const extended_dem &a, const extended_dem &b); + +/// @brief Stitch a span of adjacent DEM chunks left-to-right. +/// +/// Equivalent to dem_stitch(dem_stitch(...stitch(dem_chunks[0], +/// dem_chunks[1])...), dem_chunks[n-1]). dem_chunks must be non-empty and each +/// adjacent pair must be tag-compatible. +/// +/// @param dem_chunks Non-empty sequence of one-round (or pre-stitched) chunks. +/// @return Fully-stitched extended_dem. +/// @throws std::invalid_argument if dem_chunks is empty. +/// @throws std::runtime_error on any pairwise tag or shape mismatch. +extended_dem dem_stitch_all(const std::vector &dem_chunks); + +// --------------------------------------------------------------------------- +// Duplicate fault columns +// --------------------------------------------------------------------------- + +/// @brief Prior-combining strategy for dem_merge_duplicate_columns(). +/// +/// When two or more fault columns share identical row support they are merged +/// into one. The merged prior is computed from the individual priors using +/// one of these two rules: +/// +/// - or_combine (default): p_merged = 1/2 * (1 - prod_i(1 - 2 p_i)) +/// Exact probability that an odd number of independent events fire, which +/// is the net GF(2) effect of identical DEM columns (even counts cancel). +/// Pairwise this is P(A xor B) = p + q - 2 p q, matching +/// detector_error_model canonicalization. Prefer this for physical fault +/// mechanisms. +/// +/// - sum_combine: p_merged = min(1, sum_i(p_i)) +/// Linear approximation valid when all p_i are small. The sum of several +/// larger priors can exceed 1, which is not a probability any decoder can +/// use, so the result is clamped; prefer or_combine when the priors are +/// not small. +enum class prior_combine_mode { or_combine, sum_combine }; + +/// @brief Merge fault columns with identical row support into single columns. +/// +/// After stitching, duplicate-support columns arise when seam-time noise is +/// modelled on both sides of a measurement boundary. This function collapses +/// them: all columns whose nonzero row sets are identical (across the full +/// combined PCM: interior + observables + in_syndrome + out_syndrome) are +/// replaced by one column whose prior is the combination of the merged priors +/// under the chosen rule. +/// +/// Output columns are sorted lexicographically by their row-support tuples, +/// matching the Python reference implementation. Row counts and seam tags are +/// copied unchanged; only the column space is modified. +/// +/// Columns are compared over GF(2), so a column that lists a row twice matches +/// one that omits that row. This operates across columns and spans all four +/// blocks at once; sparse_binary_matrix::canonicalize() is the unrelated +/// within-column normalization of a single block. +/// +/// @param dem Input extended_dem (not modified). +/// @param mode How to combine priors of merged columns. +/// @return New extended_dem with unique-support columns. +extended_dem dem_merge_duplicate_columns( + const extended_dem &dem, + prior_combine_mode mode = prior_combine_mode::or_combine); + +/// @brief Return true iff every fault column has a unique row-support set. +bool are_dem_columns_unique(const extended_dem &dem); + +/// @brief Throw std::invalid_argument if any two columns share row support. +/// +/// The error message names the duplicate support and the affected column +/// indices, and suggests calling dem_merge_duplicate_columns() to fix the +/// issue. +void assert_dem_columns_unique(const extended_dem &dem); + +/// @brief Stitch DEM chunks left-to-right then merge duplicate columns. +/// +/// Equivalent to dem_merge_duplicate_columns(dem_stitch_all(dem_chunks), mode). +/// Convenience wrapper for the common pattern of assembling a window and +/// immediately deduplicating its fault columns. +/// +/// @param dem_chunks Non-empty sequence of chunks in round order. +/// @param mode Prior-combining strategy (default: or_combine). +extended_dem +dem_stitch_merged(const std::vector &dem_chunks, + prior_combine_mode mode = prior_combine_mode::or_combine); + +// --------------------------------------------------------------------------- +// Utilities for streaming decoder integration +// --------------------------------------------------------------------------- + +/// @brief How many rounds one DEM chunk spans. +/// +/// A chunk built by extended_dem_from_css_matrices() spans one round and has +/// no interior rows. Stitching R of them contracts R-1 seams into interior +/// rows, so a chunk's interior row count counts its rounds after the leading +/// one: `1 + num_interior() / d`. +/// +/// The leading round is the incoming seam band. An init phase chunk has none -- +/// nothing precedes the first round for it to compare against -- and carries +/// round 0 in its interior instead, so it spans `num_interior() / d` rounds. +/// `d` is whichever seam the chunk has, incoming for preference. +/// +/// @param dem_chunk Chunk to measure. +/// @throws std::invalid_argument if the chunk has no seam rows on either side, +/// or if its interior rows are not a whole number of rounds. +uint32_t dem_chunk_rounds(const extended_dem &dem_chunk); + +/// @brief Total rounds a sequence of DEM chunks describes. +/// +/// The sum of dem_chunk_rounds() over the sequence, which is the round count +/// dem_close_all(dem_chunks) produces detectors for. Only equal to +/// dem_chunks.size() when every chunk spans a single round. +/// +/// @param dem_chunks Non-empty sequence of chunks in round order. +/// @throws std::invalid_argument on an empty sequence, a seam that does not +/// contract against its neighbour, a seam whose width differs from the +/// rest of the sequence, or a chunk whose interior rows are not a whole +/// number of rounds. +std::size_t dem_chunks_to_rounds(const std::vector &dem_chunks); + +/// @brief Extract the detector→round mapping from a sequence of DEM chunks. +/// +/// Returns a vector of length T*d (where d is the sequence's seam width and +/// T is dem_chunks_to_rounds(dem_chunks)) where entry i gives the round index +/// (0..T-1) of detector i in the flat DEM produced by +/// dem_close_all(dem_chunks). Detector r*d+k belongs to round r. +/// +/// This vector is the "detector_round" parameter expected by streaming +/// decoders that need to know when each detector is available so they can +/// stream inputs round-by-round. +/// +/// A chunk may span several rounds: dem_stitch() keeps its interior rows in +/// ascending round order, so a stitched chunk maps to exactly the rounds its +/// pieces would have mapped to on their own. +/// +/// @param dem_chunks Non-empty sequence of chunks in round order. +/// @throws std::invalid_argument on an empty sequence, a seam that does not +/// contract against its neighbour, a seam whose width differs from the +/// rest of the sequence, or a chunk whose interior rows are not a whole +/// number of rounds. +std::vector +dem_chunks_to_detector_round(const std::vector &dem_chunks); + +/// @brief Extract the D_sparse measurement-to-detector map from T DEM chunks. +/// +/// D_sparse[det_id] lists the raw per-round measurement bit positions (within +/// a flat T*d buffer laid out as round-0 bits 0..d-1, round-1 bits d..2d-1, +/// ...) that XOR-combine to produce detector det_id: +/// - Detector k (r=0): bits {k} (vs zero initial state) +/// - Detector r*d+k (r>0): bits {(r-1)*d+k, r*d+k} (syndrome difference) +/// +/// The return type matches the nested overload of decoder::set_D_sparse(). +/// +/// T is dem_chunks_to_rounds(dem_chunks), so a chunk spanning several rounds +/// contributes a measurement slot per round it carries. +/// +/// @param dem_chunks Non-empty sequence of chunks in round order. +/// @throws std::invalid_argument on an empty sequence, a seam that does not +/// contract against its neighbour, a seam whose width differs from the +/// rest of the sequence, or a chunk whose interior rows are not a whole +/// number of rounds. +std::vector> +dem_chunks_to_d_sparse(const std::vector &dem_chunks); + +/// @brief Extract the O_sparse observable-flip map from T DEM chunks. +/// +/// O_sparse[obs_id] lists the global fault column indices (across all T +/// chunks, concatenated in chunk order) that flip observable obs_id. This +/// matches the nested overload of decoder::set_O_sparse() and is compatible +/// with the observables_flips_matrix produced by dem_close_all(dem_chunks). +/// +/// Keyed on fault columns rather than rounds, so how many rounds each chunk +/// spans makes no difference here. +/// +/// @param dem_chunks Non-empty sequence of chunks in round order. +/// @throws std::invalid_argument if dem_chunks is empty. +std::vector> +dem_chunks_to_o_sparse(const std::vector &dem_chunks); + +/// @brief Build a flat detector_error_model from T DEM chunks in O(T) time. +/// +/// Equivalent to dem_close(dem_stitch_all(dem_chunks)) but avoids the O(T²) +/// cost of the left-fold accumulation in dem_stitch_all. Detector rows are +/// always emitted in round order: band r*d..(r+1)*d-1 corresponds to +/// detector[r] regardless of chunk granularity. Error rates are concatenated in +/// chunk order. +/// +/// Prefer this over dem_close(dem_stitch_all(dem_chunks)) whenever all T chunks +/// are available upfront and only the closed DEM is needed. +/// dem_stitch/dem_stitch_all remain the right choice when the intermediate +/// extended_dem must be inspected (e.g. seam rows before closing, or partial +/// stitching). +/// +/// Seams only have to contract pairwise, so a phase decomposition works: the +/// first chunk may have no incoming seam (its interior carries round 0) and the +/// last may have no outgoing one. As with dem_close(), the last chunk's +/// out_syndrome is discarded: any detector that should appear in the closed +/// DEM must already live in some chunk's in_syndrome or interior (for a +/// dem_chunks_spec final phase, that means H_in_sparse / H_mid_sparse). +/// +/// @param dem_chunks Non-empty sequence of chunks in round order. Each chunk's +/// out_syndrome must match the next one's in_syndrome, and all +/// must share num_observables(). +/// @return detector_error_model ready for any decoder. +/// @throws std::invalid_argument if dem_chunks is empty or dimensions differ. +detector_error_model dem_close_all(const std::vector &dem_chunks); + +/// @brief Build a canonicalized, CSC-format parity-check matrix from chunks. +/// +/// Equivalent to +/// sparse_binary_matrix(dem_close_all(dem_chunks).detector_error_matrix) +/// .canonicalize() +/// .to_csc() +/// expressed as a single named utility so decoders that need an H matrix +/// directly (not the full detector_error_model) don't have to repeat the +/// idiom. +/// +/// @param dem_chunks Non-empty sequence of chunks in round order. +/// @return Canonicalized CSC sparse_binary_matrix (rows = detectors, +/// cols = fault mechanisms). +/// @throws std::invalid_argument if dem_chunks is empty. +sparse_binary_matrix +dem_chunks_to_pcm(const std::vector &dem_chunks); + +/// @brief Collapse an extended_dem into a flat detector_error_model. +/// +/// Places in_syndrome rows first, then interior rows, to match the detector +/// row ordering produced by dem_from_css_matrices(): +/// - detector_error_matrix: [in_syndrome stacked above interior] +/// - observables_flips_matrix: observables +/// - error_rates: fault_priors +/// +/// out_syndrome is intentionally dropped. Closing models a terminated +/// experiment: there is no later round for the outgoing seam to differ against, +/// matching dem_from_css_matrices (final-round faults touch only the last +/// detector band). Put any detector that must survive closing into in_syndrome +/// or interior instead — for example a final data-readout boundary belongs in +/// the last chunk's in_syndrome / interior (dem_chunks.final.H_in_sparse / +/// H_mid_sparse), never only in out_syndrome. dem_chunks_spec::validate() +/// already rejects a nonempty final.H_out_sparse for this reason. +/// +/// Invariant (up to canonicalization): +/// dem_close(dem_stitch_all(T one-round chunks)) +/// == dem_from_css_matrices(code, noise, T) +/// +/// @param dem Fully-stitched (or single-chunk) extended_dem. +/// @return detector_error_model ready for use with any decoder. +detector_error_model dem_close(const extended_dem &dem); + +} // namespace cudaq::qec diff --git a/libs/qec/include/cudaq/qec/realtime/decoding_config.h b/libs/qec/include/cudaq/qec/realtime/decoding_config.h index 868e2cf67..d991728cd 100644 --- a/libs/qec/include/cudaq/qec/realtime/decoding_config.h +++ b/libs/qec/include/cudaq/qec/realtime/decoding_config.h @@ -9,6 +9,7 @@ #pragma once #include "cuda-qx/core/heterogeneous_map.h" +#include "cudaq/qec/extended_dem.h" #include #include #include @@ -70,11 +71,45 @@ struct decoder_config { /// GPU-accelerated decoder, hence at this level rather than inside the /// per-decoder custom args. Unset = unpinned. std::optional cuda_device_id; + /// The five fields below describe the DEM two alternative ways, and exactly + /// one of them applies: + /// + /// - Flat form: H_sparse plus block_size, syndrome_size, O_sparse and + /// D_sparse, all sized for the whole experiment. + /// - Chunk form: dem_chunks plus num_rounds. The other five are derived by + /// expanding the phases num_rounds times, and must be omitted. + /// + /// See expand_dem_chunks() for the derivation, which runs at decoder + /// construction so the rest of the pipeline only ever sees the flat form. uint64_t block_size = 0; uint64_t syndrome_size = 0; std::vector H_sparse; std::vector O_sparse; std::vector D_sparse; + /// Optional per-phase DEM for a streaming, repeated-round decomposition. + /// H_sparse above describes the whole experiment as one flat matrix, which + /// requires knowing the round count up front; these phases describe one + /// round each so the round count can be chosen (or grown) at run time. See + /// cudaq::qec::dem_chunks_from_spec() for expansion to a chunk sequence. + /// + /// A configuration that also has a nonempty H_sparse is flat, and that + /// matrix is the one decoders are built from -- the phases are then only a + /// record of where it came from. Form selection keys off H_sparse.empty(), + /// so an omitted H_sparse and an explicit empty list both count as chunk + /// form. Nonempty H_sparse is exactly the state expand_dem_chunks() leaves + /// behind, which is what lets an expanded configuration round-trip through + /// YAML; it is not a way to override individual rounds. + std::optional dem_chunks; + /// How many rounds to expand `dem_chunks` into. Required with dem_chunks and + /// rejected without it. This is the round count the flat form would otherwise + /// have baked into its matrix dimensions; naming it here is what lets the + /// same phase description serve experiments of different lengths. + /// + /// This is the *total* round count, so the expansion is init, `num_rounds-2` + /// bulk copies, then final, and the minimum is 2. Note that the + /// decoder-tasking spec writes the same construction as `S_R` where `R` + /// counts bulk copies only: this field is that `R` plus 2. + std::optional num_rounds; decoder_custom_args_t decoder_custom_args; bool operator==(const decoder_config &) const = default; @@ -157,6 +192,25 @@ class multi_decoder_config { from_yaml_str(const std::string_view yaml_str); }; +/// @brief Rewrite a chunk-form configuration into the equivalent flat form, +/// filling block_size, syndrome_size, H_sparse, O_sparse and D_sparse from +/// `dem_chunks` expanded `num_rounds` times. Everything downstream of this +/// therefore only has to understand the flat form. +/// +/// Does nothing to a configuration that is already flat (one whose `H_sparse` +/// is nonempty, or which carries no `dem_chunks` at all), so it is safe to +/// call unconditionally. An empty H_sparse with dem_chunks present is still +/// treated as chunk form. +/// +/// @return The closed DEM the flat fields were derived from, so a caller that +/// also wants its per-fault priors does not have to expand a second +/// time. Empty when the configuration was already flat. +/// @throws std::runtime_error if `num_rounds` is missing, or if the phases +/// cannot be expanded to that many rounds. +__attribute__((visibility("default"))) +std::optional +expand_dem_chunks(decoder_config &config); + /// @brief Generate a JSON Schema (draft 2020-12) document describing valid /// `multi_decoder_config` YAML files, so third-party tools (check-jsonschema, /// python jsonschema, yaml-language-server, ...) can validate user-provided diff --git a/libs/qec/lib/CMakeLists.txt b/libs/qec/lib/CMakeLists.txt index 37842f4be..c19e014d8 100644 --- a/libs/qec/lib/CMakeLists.txt +++ b/libs/qec/lib/CMakeLists.txt @@ -43,7 +43,9 @@ set(DECODERS_SOURCES decoder.cpp decoder_config_payload.cpp decoder_config_schema.cpp + dem_construction.cpp detector_error_model.cpp + extended_dem.cpp logger.cpp logger_forwarder.cpp pcm_utils.cpp @@ -57,6 +59,7 @@ set(DECODERS_SOURCES set(QEC_SOURCES code.cpp decoder_config_payload_provider.cpp + dem_construction_code.cpp experiments.cpp stabilizer_utils.cpp ) diff --git a/libs/qec/lib/dem_construction.cpp b/libs/qec/lib/dem_construction.cpp new file mode 100644 index 000000000..badf77fce --- /dev/null +++ b/libs/qec/lib/dem_construction.cpp @@ -0,0 +1,171 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// Constructs a T-round code-capacity detector_error_model from CSS generator +// matrices and a depolarizing noise model. Each data-qubit fault maps to a +// DEM column whose detector rows span two consecutive round bands (the round +// of the fault and the next), except for faults in the final round which +// span only that round's band. Observable rows are the same for every round +// since the logical measurement is taken once at the end of the experiment. + +#include "cudaq/qec/dem_construction.h" +#include "cudaq/qec/code_matrices.h" + +#include +#include + +namespace cudaq::qec { + +using namespace cudaq::qec::detail; + +detector_error_model dem_from_css_matrices(const css_code_matrices &code, + const css_noise_params &noise, + std::size_t num_rounds) { + if (num_rounds == 0) + throw std::invalid_argument("num_rounds must be >= 1"); + + // Checked before the n == 0 early-out below so that a malformed rate is + // reported even when the code matrices describe no qubits to apply it to. + validate_noise_rates(noise); + + detector_error_model result; + const std::size_t n = resolve_num_qubits(code); + + if (n != 0) { + // Validate hz even though n may have been resolved from hz itself: + // if n came from a different matrix while hz has rows but zero columns, + // this catches the inconsistency that would otherwise produce an all-empty + // Z-type detector block with no error or exception. + check_num_cols(code.hz, n, "hz"); + check_num_cols(code.hx, n, "hx"); + check_num_cols(code.lz, n, "lz"); + check_num_cols(code.lx, n, "lx"); + check_per_qubit_size(noise.px_per_qubit, n, "px_per_qubit"); + check_per_qubit_size(noise.py_per_qubit, n, "py_per_qubit"); + check_per_qubit_size(noise.pz_per_qubit, n, "pz_per_qubit"); + + // Per-qubit CSC column lists, padded so hz_csc[q] etc. are always valid. + // hz_csc[q] = Z-check rows triggered by an X fault on qubit q. + // hx_csc[q] = X-check rows triggered by a Z fault on qubit q. + // lz_csc[q] = Z-obs rows flipped by an X fault on qubit q. + // lx_csc[q] = X-obs rows flipped by a Z fault on qubit q. + const auto hz_csc = padded_nested_csc(code.hz, n); + const auto hx_csc = padded_nested_csc(code.hx, n); + const auto lz_csc = padded_nested_csc(code.lz, n); + const auto lx_csc = padded_nested_csc(code.lx, n); + + const std::size_t nz = static_cast(code.hz.num_rows()); + const std::size_t nx = static_cast(code.hx.num_rows()); + const std::size_t kz = static_cast(code.lz.num_rows()); + const std::size_t kx = static_cast(code.lx.num_rows()); + const std::size_t d = nz + nx; // detector rows per round = total checks + + // pm_per_check must have length d (= nz + nx) when non-empty. + check_rate_vector_size(noise.pm_per_check, d, "pm_per_check", "n_checks"); + + const std::size_t n_detectors = num_rounds * d; + const std::size_t n_observables = kz + kx; + + const auto x_qubits = active_qubits(noise.px, noise.px_per_qubit, n); + const auto z_qubits = active_qubits(noise.pz, noise.pz_per_qubit, n); + const auto y_qubits = active_qubits(noise.py, noise.py_per_qubit, n); + // Measurement errors: one column per active check (0..d-1) per round. + // Check index k < nz is a Z-type check; k >= nz is an X-type check. + const auto m_checks = active_checks(noise.pm, noise.pm_per_check, d); + + const std::size_t per_round = + x_qubits.size() + z_qubits.size() + y_qubits.size() + m_checks.size(); + const std::size_t n_errors = num_rounds * per_round; + + if (n_errors != 0) { + result.detector_error_matrix = + cudaqx::tensor({n_detectors, n_errors}); + result.observables_flips_matrix = + cudaqx::tensor({n_observables, n_errors}); + result.error_rates.reserve(n_errors); + + std::size_t col = 0; + + for (std::size_t r = 0; r < num_rounds; ++r) { + const std::size_t r_off = r * d; // detector row base for round r + const std::size_t r1_off = + (r + 1) * d; // detector row base for round r+1 + const bool has_next = (r + 1 < num_rounds); + + // X faults: Z-type detectors at round r (and r+1). Traverse hz_csc[q] + // once, writing to both row bands in the same pass. + for (const std::size_t q : x_qubits) { + for (auto row : hz_csc[q]) { + result.detector_error_matrix.at({r_off + row, col}) ^= 1; + if (has_next) + result.detector_error_matrix.at({r1_off + row, col}) ^= 1; + } + for (auto row : lz_csc[q]) + result.observables_flips_matrix.at({row, col}) ^= 1; + result.error_rates.push_back( + qubit_rate(noise.px, noise.px_per_qubit, q)); + ++col; + } + + // Z faults: X-type detectors (offset nz) at rounds r and r+1. + for (const std::size_t q : z_qubits) { + for (auto row : hx_csc[q]) { + result.detector_error_matrix.at({r_off + nz + row, col}) ^= 1; + if (has_next) + result.detector_error_matrix.at({r1_off + nz + row, col}) ^= 1; + } + for (auto row : lx_csc[q]) + result.observables_flips_matrix.at({kz + row, col}) ^= 1; + result.error_rates.push_back( + qubit_rate(noise.pz, noise.pz_per_qubit, q)); + ++col; + } + + // Y faults: both detector bands at rounds r and r+1. + for (const std::size_t q : y_qubits) { + for (auto row : hz_csc[q]) { + result.detector_error_matrix.at({r_off + row, col}) ^= 1; + if (has_next) + result.detector_error_matrix.at({r1_off + row, col}) ^= 1; + } + for (auto row : hx_csc[q]) { + result.detector_error_matrix.at({r_off + nz + row, col}) ^= 1; + if (has_next) + result.detector_error_matrix.at({r1_off + nz + row, col}) ^= 1; + } + for (auto row : lz_csc[q]) + result.observables_flips_matrix.at({row, col}) ^= 1; + for (auto row : lx_csc[q]) + result.observables_flips_matrix.at({kz + row, col}) ^= 1; + result.error_rates.push_back( + qubit_rate(noise.py, noise.py_per_qubit, q)); + ++col; + } + + // Measurement error faults: one column per active check per round. + // A measurement error on check k flips syndrome[r][k] without any data + // error. detector[r][k] = syndrome[r][k] XOR syndrome[r-1][k] fires, + // and so does detector[r+1][k] = syndrome[r+1][k] XOR syndrome[r][k] + // (if the next round measures syndrome[r+1][k] correctly and sees + // syndrome[r][k] was wrong). No logical observable is flipped. + for (const std::size_t k : m_checks) { + result.detector_error_matrix.at({r_off + k, col}) ^= 1; + if (has_next) + result.detector_error_matrix.at({r1_off + k, col}) ^= 1; + result.error_rates.push_back( + check_rate(noise.pm, noise.pm_per_check, k)); + ++col; + } + } // end - for(r) + } // end - if (n_errors != 0) + } // end - if (n != 0) + + return result; +} // end - dem_from_css_matrices() + +} // namespace cudaq::qec diff --git a/libs/qec/lib/dem_construction_code.cpp b/libs/qec/lib/dem_construction_code.cpp new file mode 100644 index 000000000..878c7b75b --- /dev/null +++ b/libs/qec/lib/dem_construction_code.cpp @@ -0,0 +1,46 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// Bridge between code objects and dem_from_css_matrices(). Extracts the +// four CSS generator matrices from a code object by calling get_parity_z(), +// get_parity_x(), get_observables_z(), and get_observables_x(), then +// delegates to the matrix-based construction path. The dense tensors +// returned by those methods are implicitly converted to sparse_binary_matrix +// via the non-explicit constructor (CSC layout, non-zeros treated as 1). + +#include "cudaq/qec/dem_construction_code.h" + +namespace cudaq::qec { + +// Convert a dense tensor to sparse_binary_matrix, returning an empty +// (default-constructed) matrix when the tensor is not rank-2. Code methods +// such as get_parity_x() return a rank-0 tensor when the code has no +// stabilizers of that type (e.g. a Z-basis repetition code has no X checks). +static sparse_binary_matrix tensor_to_sparse(const cudaqx::tensor &t) { + if (t.rank() != 2) + return sparse_binary_matrix{}; + return sparse_binary_matrix(t); +} + +css_code_matrices css_matrices_from_code(const code &qec_code) { + css_code_matrices m; + m.hz = tensor_to_sparse(qec_code.get_parity_z()); + m.hx = tensor_to_sparse(qec_code.get_parity_x()); + m.lz = tensor_to_sparse(qec_code.get_observables_z()); + m.lx = tensor_to_sparse(qec_code.get_observables_x()); + return m; +} + +detector_error_model dem_from_css_matrices(const code &qec_code, + const css_noise_params &noise, + std::size_t num_rounds) { + return dem_from_css_matrices(css_matrices_from_code(qec_code), noise, + num_rounds); +} + +} // namespace cudaq::qec diff --git a/libs/qec/lib/extended_dem.cpp b/libs/qec/lib/extended_dem.cpp new file mode 100644 index 000000000..1d032ab83 --- /dev/null +++ b/libs/qec/lib/extended_dem.cpp @@ -0,0 +1,1212 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// Implements extended_dem construction and composition using +// sparse_binary_matrix throughout. No dense tensor intermediates appear until +// dem_close(), where the output detector_error_model requires tensor. +// +// Each fault column of a one-round chunk has in_syndrome == out_syndrome +// (the raw syndrome of that round), because syndrome[r] participates in +// both seam detectors it borders: detector[r] = syndrome[r] XOR syndrome[r-1] +// (via in_syndrome) and detector[r+1] = syndrome[r+1] XOR syndrome[r] +// (via out_syndrome). +// +// Stitching is horizontal block concatenation: A's fault columns occupy the +// left half and B's the right. The seam rows are formed by placing both +// a.out_syndrome and b.in_syndrome at the same row indices so each fault +// independently contributes to the seam detector. +// +// dem_close() places in_syndrome rows first (they become detector[0] = +// syndrome[0] vs. zero initial state), then interior rows, matching the row +// order of dem_from_css_matrices(). out_syndrome is dropped: closing ends the +// experiment, so there is no later round for that seam to become a detector. + +#include "cudaq/qec/extended_dem.h" +#include "cudaq/qec/code_matrices.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cudaq::qec { + +using namespace detail; // resolve_num_qubits, check_num_cols, etc. + +// --------------------------------------------------------------------------- +// extended_dem accessors +// --------------------------------------------------------------------------- + +uint32_t extended_dem::num_faults() const { return in_syndrome.num_cols(); } + +uint32_t extended_dem::num_interior() const { return interior.num_rows(); } + +uint32_t extended_dem::num_observables() const { + return observables.num_rows(); +} + +uint32_t extended_dem::num_seam_rows() const { return in_syndrome.num_rows(); } + +uint32_t extended_dem::num_in_seam_rows() const { + return in_syndrome.num_rows(); +} + +// Everything downstream indexes fault columns by the chunk's own fault index, +// so a block that is not that wide would be scattered into the wrong columns +// (or past the end of a nested column list) with no other symptom. Zero-row +// blocks must still report num_faults() columns: dem_merge_duplicate_columns +// and dem_chunks_to_o_sparse walk every fault index through every block. +void extended_dem::validate(const char *context) const { + const uint32_t n = num_faults(); + + const auto check_width = [&](const sparse_binary_matrix &block, + const char *label) { + if (block.num_cols() != n) + throw std::invalid_argument( + std::string(context) + ": " + label + " has " + + std::to_string(block.num_cols()) + " columns but the chunk has " + + std::to_string(n) + + " faults; every block must be as wide as the chunk"); + }; + // in_syndrome is what num_faults() reports, so it defines n rather than + // being checked against it. + check_width(interior, "interior"); + check_width(observables, "observables"); + check_width(out_syndrome, "out_syndrome"); + + if (fault_priors.size() != n) + throw std::invalid_argument(std::string(context) + ": fault_priors has " + + std::to_string(fault_priors.size()) + + " entries but the chunk has " + + std::to_string(n) + " faults"); + + const auto check_tags = [&context](const std::vector &tags, + uint32_t rows, const char *label) { + if (tags.size() != rows) + throw std::invalid_argument(std::string(context) + ": " + label + + " has " + std::to_string(tags.size()) + + " entries but the seam it names has " + + std::to_string(rows) + " rows"); + }; + check_tags(in_tags, num_in_seam_rows(), "in_tags"); + check_tags(out_tags, num_out_seam_rows(), "out_tags"); +} // end - extended_dem::validate() + +uint32_t extended_dem::num_out_seam_rows() const { + return out_syndrome.num_rows(); +} + +// --------------------------------------------------------------------------- +// Internal sparse-matrix helpers (extended_dem-specific) +// --------------------------------------------------------------------------- +// col_list, resolve_num_qubits, check_num_cols, check_per_qubit_size, +// padded_nested_csc, qubit_rate, and active_qubits come from +// cudaq::qec::detail (code_matrices.h) via `using namespace detail` above. + +// [A | B]: same num_rows, concatenate column lists. +static sparse_binary_matrix hcat(const sparse_binary_matrix &a, + const sparse_binary_matrix &b) { + auto cols = a.to_nested_csc(); + const auto b_cols = b.to_nested_csc(); + cols.insert(cols.end(), b_cols.begin(), b_cols.end()); + const auto n_total = static_cast(a.num_cols()) + b.num_cols(); + if (n_total > std::numeric_limits::max()) + throw std::overflow_error("hcat: combined column count exceeds uint32_t"); + return sparse_binary_matrix::from_nested_csc( + a.num_rows(), static_cast(n_total), cols); +} + +// [A ; B]: same num_cols, B row indices offset by a.num_rows(). +// Precondition: b.num_cols() == a.num_cols(). All internal callers satisfy +// this by padding both operands to the same column count before calling. +static sparse_binary_matrix vstack(const sparse_binary_matrix &a, + const sparse_binary_matrix &b) { + if (b.num_cols() != a.num_cols()) + throw std::invalid_argument("vstack: column count mismatch (" + + std::to_string(a.num_cols()) + " vs " + + std::to_string(b.num_cols()) + ")"); + const uint32_t n_cols = a.num_cols(); + const uint32_t a_rows = a.num_rows(); + const auto total_rows = static_cast(a_rows) + b.num_rows(); + if (total_rows > std::numeric_limits::max()) + throw std::overflow_error("vstack: combined row count exceeds uint32_t"); + auto a_cols = a.to_nested_csc(); + const auto b_cols = b.to_nested_csc(); + col_list combined(n_cols); + for (uint32_t c = 0; c < n_cols; ++c) { + combined[c] = std::move(a_cols[c]); + for (auto r : b_cols[c]) + combined[c].push_back(a_rows + + r); // safe: a_rows+r < total_rows ≤ UINT32_MAX + } + return sparse_binary_matrix::from_nested_csc( + static_cast(total_rows), n_cols, combined); +} + +// Append empty columns on the right until total column count = n_total. +static sparse_binary_matrix pad_right(const sparse_binary_matrix &m, + uint32_t n_total) { + auto cols = m.to_nested_csc(); + cols.resize(n_total); + return sparse_binary_matrix::from_nested_csc(m.num_rows(), n_total, cols); +} + +// Prepend n_left empty columns, shifting existing columns to the right. +static sparse_binary_matrix pad_left(const sparse_binary_matrix &m, + uint32_t n_left, uint32_t n_total) { + const auto src = m.to_nested_csc(); + col_list cols(n_total); + for (uint32_t c = 0; c < m.num_cols(); ++c) + cols[n_left + c] = src[c]; + return sparse_binary_matrix::from_nested_csc(m.num_rows(), n_total, cols); +} + +// All-zero sparse matrix. +static sparse_binary_matrix zero_matrix(uint32_t nrows, uint32_t ncols) { + return sparse_binary_matrix::from_nested_csc(nrows, ncols, col_list(ncols)); +} + +// --------------------------------------------------------------------------- +// extended_dem_from_css_matrices +// --------------------------------------------------------------------------- + +// Builds the one-round extended_dem directly from sparse column lists. +// No dense tensor intermediate: the per-qubit CSC index lists from +// hz/hx/lz/lx are assembled into syndrome and observable column lists +// in one pass, then wrapped into sparse_binary_matrix objects. +extended_dem extended_dem_from_css_matrices(const css_code_matrices &code, + const css_noise_params &noise) { + // Checked before the n == 0 early-out below so that a malformed rate is + // reported even when the code matrices describe no qubits to apply it to. + validate_noise_rates(noise); + + extended_dem result; + const std::size_t n = resolve_num_qubits(code); + if (n != 0) { // all code below depends on at least one qubit + + check_num_cols(code.hz, n, "hz"); + check_num_cols(code.hx, n, "hx"); + check_num_cols(code.lz, n, "lz"); + check_num_cols(code.lx, n, "lx"); + check_per_qubit_size(noise.px_per_qubit, n, "px_per_qubit"); + check_per_qubit_size(noise.py_per_qubit, n, "py_per_qubit"); + check_per_qubit_size(noise.pz_per_qubit, n, "pz_per_qubit"); + + // Per-qubit CSC column lists: syn_csc[q] gives the syndrome rows + // triggered by a fault on qubit q (Z-type rows first, then X-type). + const auto hz_csc = padded_nested_csc(code.hz, n); + const auto hx_csc = padded_nested_csc(code.hx, n); + const auto lz_csc = padded_nested_csc(code.lz, n); + const auto lx_csc = padded_nested_csc(code.lx, n); + + const uint32_t nz = static_cast(code.hz.num_rows()); + const uint32_t nx = static_cast(code.hx.num_rows()); + const uint32_t kz = static_cast(code.lz.num_rows()); + const uint32_t kx = static_cast(code.lx.num_rows()); + // Seam/observable widths are uint32_t matrix dimensions; reject sums that + // would wrap before they become wrong sparse shapes. + if (static_cast(nz) + nx > std::numeric_limits::max()) + throw std::invalid_argument( + "extended_dem_from_css_matrices: hz.num_rows() + hx.num_rows() " + "exceeds uint32_t max"); + if (static_cast(kz) + kx > std::numeric_limits::max()) + throw std::invalid_argument( + "extended_dem_from_css_matrices: lz.num_rows() + lx.num_rows() " + "exceeds uint32_t max"); + const uint32_t d = nz + nx; // seam rows (total checks per round) + const uint32_t k = kz + kx; // observable rows + + // pm_per_check must have length d when non-empty. + check_rate_vector_size(noise.pm_per_check, static_cast(d), + "pm_per_check", "n_checks"); + + const auto x_qubits = active_qubits(noise.px, noise.px_per_qubit, n); + const auto z_qubits = active_qubits(noise.pz, noise.pz_per_qubit, n); + const auto y_qubits = active_qubits(noise.py, noise.py_per_qubit, n); + const auto m_checks = active_checks(noise.pm, noise.pm_per_check, + static_cast(d)); + const std::size_t n_faults_sz = + x_qubits.size() + z_qubits.size() + y_qubits.size() + m_checks.size(); + if (n_faults_sz > std::numeric_limits::max()) + throw std::invalid_argument( + "extended_dem_from_css_matrices: active fault count exceeds " + "uint32_t max"); + const uint32_t n_faults = static_cast(n_faults_sz); + + if (n_faults != 0) { // no columns to build when all rates are zero + + // Build syndrome and observable column lists directly. For a one-round + // chunk, syndrome rows are the raw check activations (not differenced): + // Z-type rows (0..nz-1): from hz[:,q] + // X-type rows (nz..d-1): from hx[:,q] (offset by nz) + col_list syn_cols(n_faults); + col_list obs_cols(n_faults); + std::vector priors; + priors.reserve(n_faults); + + uint32_t col = 0; + + // X faults trigger Z-type syndrome rows and Z-type observable rows. + for (const std::size_t q : x_qubits) { + for (auto r : hz_csc[q]) + syn_cols[col].push_back(r); + for (auto r : lz_csc[q]) + obs_cols[col].push_back(r); + priors.push_back(qubit_rate(noise.px, noise.px_per_qubit, q)); + ++col; + } + + // Z faults trigger X-type syndrome rows (offset nz) and X-type obs. + for (const std::size_t q : z_qubits) { + for (auto r : hx_csc[q]) + syn_cols[col].push_back(nz + r); + for (auto r : lx_csc[q]) + obs_cols[col].push_back(kz + r); + priors.push_back(qubit_rate(noise.pz, noise.pz_per_qubit, q)); + ++col; + } + + // Y faults trigger both syndrome bands and both observable bands. + for (const std::size_t q : y_qubits) { + for (auto r : hz_csc[q]) + syn_cols[col].push_back(r); + for (auto r : hx_csc[q]) + syn_cols[col].push_back(nz + r); + for (auto r : lz_csc[q]) + obs_cols[col].push_back(r); + for (auto r : lx_csc[q]) + obs_cols[col].push_back(kz + r); + priors.push_back(qubit_rate(noise.py, noise.py_per_qubit, q)); + ++col; + } + + // Measurement error faults: check k fires only row k in the syndrome + // (one check misfires), no logical effect. in_syndrome == out_syndrome as + // always for a one-round chunk: syndrome[r][k] participates in both + // detector[r] (via in_syndrome) and detector[r+1] (via out_syndrome). + for (const std::size_t ck : m_checks) { + syn_cols[col].push_back( + static_cast(ck)); + // obs_cols[col] stays empty — measurement errors don't flip + // observables. + priors.push_back(check_rate(noise.pm, noise.pm_per_check, ck)); + ++col; + } + + // in_syndrome == out_syndrome: the same syndrome participates in both + // the left seam (detector[r]) and the right seam (detector[r+1]). + result.in_syndrome = + sparse_binary_matrix::from_nested_csc(d, n_faults, syn_cols); + result.out_syndrome = result.in_syndrome; + + result.observables = + sparse_binary_matrix::from_nested_csc(k, n_faults, obs_cols); + + result.interior = zero_matrix(0, n_faults); + result.fault_priors = std::move(priors); + + // Sequential check IDs: tag k == k for all checks. + result.in_tags.resize(d); + std::iota(result.in_tags.begin(), result.in_tags.end(), uint64_t{0}); + result.out_tags = result.in_tags; + + } // end - if (n_faults != 0) + } // end - if (n != 0) + + return result; +} // end - extended_dem_from_css_matrices() + +// --------------------------------------------------------------------------- +// dem_chunk_spec / dem_chunks_spec +// --------------------------------------------------------------------------- + +// Number of rows a -1-terminated index list describes. validate() has already +// established that the list ends with a terminator, so this is exact. +static uint32_t sparse_row_count(const std::vector &rows) { + return static_cast(std::count(rows.begin(), rows.end(), -1)); +} + +// Split a -1-terminated index list into a sparse matrix with num_faults +// columns. Empty input yields a 0-row matrix that still carries the column +// count, which is what keeps extended_dem::num_faults() meaningful for an init +// phase whose in_syndrome has no rows at all. +static sparse_binary_matrix +sparse_from_terminated_rows(const std::vector &rows, + uint64_t num_faults) { + std::vector> nested; + std::vector current; + for (auto value : rows) { + if (value == -1) { + nested.push_back(current); + current.clear(); + continue; + } + current.push_back(static_cast(value)); + } + return sparse_binary_matrix::from_nested_csr( + static_cast(nested.size()), + static_cast(num_faults), nested); +} + +static void validate_index_list(const std::vector &rows, + uint64_t num_faults, const std::string &context, + const std::string &field) { + if (rows.empty()) + return; + if (rows.back() != -1) + throw std::invalid_argument(context + "." + field + + " must end with a -1 row terminator"); + for (auto value : rows) { + if (value == -1) + continue; + if (value < 0 || static_cast(value) >= num_faults) + throw std::invalid_argument( + context + "." + field + " index " + std::to_string(value) + + " is out of range for num_faults " + std::to_string(num_faults)); + } +} + +bool dem_chunk_spec::is_empty() const { + return num_faults == 0 && H_in_sparse.empty() && H_mid_sparse.empty() && + H_out_sparse.empty() && O_sparse.empty() && error_rates.empty(); +} + +void dem_chunk_spec::validate(const std::string &context) const { + if (num_faults == 0) + throw std::invalid_argument(context + ".num_faults must be positive"); + // sparse_binary_matrix columns are indexed with uint32_t; a wider count + // would wrap in sparse_from_terminated_rows and silently mis-size the chunk. + if (num_faults > std::numeric_limits::max()) + throw std::invalid_argument( + context + ".num_faults (" + std::to_string(num_faults) + + ") exceeds uint32_t max (" + + std::to_string( + std::numeric_limits::max()) + + ")"); + if (error_rates.size() != num_faults) + throw std::invalid_argument( + context + ".error_rates has " + std::to_string(error_rates.size()) + + " entries but num_faults is " + std::to_string(num_faults)); + for (std::size_t i = 0; i < error_rates.size(); ++i) + if (!(error_rates[i] >= 0.0 && error_rates[i] <= 1.0)) + throw std::invalid_argument(context + ".error_rates[" + + std::to_string(i) + + "] = " + std::to_string(error_rates[i]) + + " is not a probability in [0, 1]"); + + validate_index_list(H_in_sparse, num_faults, context, "H_in_sparse"); + validate_index_list(H_mid_sparse, num_faults, context, "H_mid_sparse"); + validate_index_list(H_out_sparse, num_faults, context, "H_out_sparse"); + validate_index_list(O_sparse, num_faults, context, "O_sparse"); +} + +bool dem_chunks_spec::is_empty() const { + return init.is_empty() && bulk.is_empty() && final.is_empty(); +} + +bool dem_chunks_spec::has_bulk() const { return !bulk.is_empty(); } + +void dem_chunks_spec::validate() const { + if (is_empty()) + throw std::invalid_argument("dem_chunks: no phases were supplied"); + if (init.is_empty()) + throw std::invalid_argument("dem_chunks: init phase is required"); + if (final.is_empty()) + throw std::invalid_argument("dem_chunks: final phase is required"); + + init.validate("dem_chunks.init"); + if (has_bulk()) + bulk.validate("dem_chunks.bulk"); + final.validate("dem_chunks.final"); + + // init starts the stream, so nothing feeds its incoming seam; final ends it, + // so nothing consumes its outgoing seam. init's own round-0 detectors belong + // in H_mid_sparse, compared against the zero initial state. + if (!init.H_in_sparse.empty()) + throw std::invalid_argument( + "dem_chunks.init.H_in_sparse must be empty: the init phase has no " + "incoming seam. Put the first round's detectors in H_mid_sparse."); + if (!final.H_out_sparse.empty()) + throw std::invalid_argument( + "dem_chunks.final.H_out_sparse must be empty: the final phase has no " + "outgoing seam."); + + const uint32_t init_out = sparse_row_count(init.H_out_sparse); + const uint32_t final_in = sparse_row_count(final.H_in_sparse); + + if (has_bulk()) { + const uint32_t bulk_in = sparse_row_count(bulk.H_in_sparse); + const uint32_t bulk_out = sparse_row_count(bulk.H_out_sparse); + // bulk is the phase that repeats, so it has to stitch to itself. + if (bulk_in != bulk_out) + throw std::invalid_argument( + "dem_chunks.bulk seams are unequal (H_in_sparse has " + + std::to_string(bulk_in) + " rows, H_out_sparse has " + + std::to_string(bulk_out) + + " rows); the repeated phase must stitch to itself"); + if (init_out != bulk_in) + throw std::invalid_argument( + "dem_chunks seam width mismatch: init.H_out_sparse has " + + std::to_string(init_out) + " rows but bulk.H_in_sparse has " + + std::to_string(bulk_in)); + if (bulk_out != final_in) + throw std::invalid_argument( + "dem_chunks seam width mismatch: bulk.H_out_sparse has " + + std::to_string(bulk_out) + " rows but final.H_in_sparse has " + + std::to_string(final_in)); + } else if (init_out != final_in) { + throw std::invalid_argument( + "dem_chunks seam width mismatch: init.H_out_sparse has " + + std::to_string(init_out) + " rows but final.H_in_sparse has " + + std::to_string(final_in)); + } + + const uint32_t init_obs = sparse_row_count(init.O_sparse); + if (has_bulk() && sparse_row_count(bulk.O_sparse) != init_obs) + throw std::invalid_argument( + "dem_chunks observable count mismatch: init has " + + std::to_string(init_obs) + " but bulk has " + + std::to_string(sparse_row_count(bulk.O_sparse))); + if (sparse_row_count(final.O_sparse) != init_obs) + throw std::invalid_argument( + "dem_chunks observable count mismatch: init has " + + std::to_string(init_obs) + " but final has " + + std::to_string(sparse_row_count(final.O_sparse))); +} // end - dem_chunks_spec::validate() + +// Build an extended_dem from a spec without validating. The caller must have +// already called spec.validate() or dem_chunks_spec::validate(). +static extended_dem dem_chunk_from_spec_impl(const dem_chunk_spec &spec) { + extended_dem dem; + dem.interior = + sparse_from_terminated_rows(spec.H_mid_sparse, spec.num_faults); + dem.observables = sparse_from_terminated_rows(spec.O_sparse, spec.num_faults); + dem.in_syndrome = + sparse_from_terminated_rows(spec.H_in_sparse, spec.num_faults); + dem.out_syndrome = + sparse_from_terminated_rows(spec.H_out_sparse, spec.num_faults); + dem.fault_priors = spec.error_rates; + + // Sequential tags give the k-th check the same identity in every phase, so + // adjacent phases satisfy dem_stitch's a.out_tags == b.in_tags requirement. + dem.in_tags.resize(dem.num_in_seam_rows()); + std::iota(dem.in_tags.begin(), dem.in_tags.end(), uint64_t{0}); + dem.out_tags.resize(dem.num_out_seam_rows()); + std::iota(dem.out_tags.begin(), dem.out_tags.end(), uint64_t{0}); + + return dem; +} + +extended_dem dem_chunk_from_spec(const dem_chunk_spec &spec, + const std::string &context) { + spec.validate(context); + return dem_chunk_from_spec_impl(spec); +} // end - dem_chunk_from_spec() + +std::vector dem_chunks_from_spec(const dem_chunks_spec &spec, + std::size_t num_rounds) { + if (num_rounds < 2) + throw std::invalid_argument( + "dem_chunks_from_spec: num_rounds must be at least 2 (init and final), " + "got " + + std::to_string(num_rounds)); + spec.validate(); + + const std::size_t bulk_repeats = num_rounds - 2; + if (bulk_repeats > 0 && !spec.has_bulk()) + throw std::invalid_argument("dem_chunks_from_spec: num_rounds " + + std::to_string(num_rounds) + " needs " + + std::to_string(bulk_repeats) + + " bulk rounds but no bulk phase was supplied"); + + // spec.validate() already checked each phase; use the impl variant to avoid + // re-running per-phase validation for every call below. + std::vector chunks; + chunks.reserve(num_rounds); + chunks.push_back(dem_chunk_from_spec_impl(spec.init)); + if (bulk_repeats > 0) { + const auto bulk = dem_chunk_from_spec_impl(spec.bulk); + chunks.insert(chunks.end(), bulk_repeats, bulk); + } + chunks.push_back(dem_chunk_from_spec_impl(spec.final)); + return chunks; +} // end - dem_chunks_from_spec() + +// --------------------------------------------------------------------------- +// dem_stitch +// --------------------------------------------------------------------------- + +extended_dem dem_stitch(const extended_dem &a, const extended_dem &b) { + a.validate("dem_stitch: left chunk"); + b.validate("dem_stitch: right chunk"); + if (a.num_observables() != b.num_observables()) + throw std::invalid_argument("dem_stitch: observable count mismatch (" + + std::to_string(a.num_observables()) + " vs " + + std::to_string(b.num_observables()) + ")"); + // Only the contracted seam has to line up: a's outgoing side against b's + // incoming side. Comparing a.num_seam_rows() to b.num_seam_rows() instead + // would test a's *incoming* width, which is not part of this seam at all and + // is legitimately zero for an init phase chunk. + if (a.num_out_seam_rows() != b.num_in_seam_rows()) + throw std::invalid_argument( + "dem_stitch: seam row count mismatch (a out_syndrome " + + std::to_string(a.num_out_seam_rows()) + " vs b in_syndrome " + + std::to_string(b.num_in_seam_rows()) + ")"); + if (a.out_tags != b.in_tags) + throw std::invalid_argument( + "dem_stitch: a.out_tags != b.in_tags — check ordering is incompatible"); + + const auto n_A_sz = static_cast(a.num_faults()); + const auto n_total_sz = n_A_sz + b.num_faults(); + if (n_total_sz > std::numeric_limits::max()) + throw std::overflow_error("dem_stitch: combined fault count (" + + std::to_string(n_total_sz) + + ") exceeds uint32_t max"); + const uint32_t n_A = static_cast(n_A_sz); + const uint32_t n_total = static_cast(n_total_sz); + + extended_dem r; + + // interior = [ a.interior | 0 ] (a's existing interior) + // [ a.out_syndrome | b.in_syndrome ] (new seam row) + // [ 0 | b.interior ] (b's existing interior) + // Faults from A and B are disjoint half-columns; each contributes + // independently to the same seam-detector row via its own half. + // + // The new seam sits between the two interiors, which is what keeps interior + // rows in ascending round order however the caller associates its stitches: + // a's rounds precede the seam they end at, which precedes b's rounds. Append + // it after b.interior instead and a tree fold -- dem_stitch(dem_stitch(a, b), + // dem_stitch(c, d)) -- would file the b|c seam after c's own rounds, leaving + // dem_close_all() emitting detector rows out of round order. + r.interior = vstack(vstack(pad_right(a.interior, n_total), + hcat(a.out_syndrome, b.in_syndrome)), + pad_left(b.interior, n_A, n_total)); + + // Observables and seam boundaries: simple horizontal concat / padding. + r.observables = hcat(a.observables, b.observables); + r.in_syndrome = pad_right(a.in_syndrome, n_total); + r.out_syndrome = pad_left(b.out_syndrome, n_A, n_total); + + r.fault_priors = a.fault_priors; + r.fault_priors.insert(r.fault_priors.end(), b.fault_priors.begin(), + b.fault_priors.end()); + r.in_tags = a.in_tags; + r.out_tags = b.out_tags; + + return r; +} // end - dem_stitch() + +// --------------------------------------------------------------------------- +// dem_stitch_all +// --------------------------------------------------------------------------- + +extended_dem dem_stitch_all(const std::vector &dem_chunks) { + if (dem_chunks.empty()) + throw std::invalid_argument("dem_stitch_all: dem_chunks must be non-empty"); + // dem_stitch validates both of its operands, which covers every chunk + // except when there is only one and no stitching happens at all. + if (dem_chunks.size() == 1) + dem_chunks[0].validate("dem_stitch_all: chunk 0"); + extended_dem acc = dem_chunks[0]; + for (std::size_t i = 1; i < dem_chunks.size(); ++i) + acc = dem_stitch(acc, dem_chunks[i]); + return acc; +} + +// --------------------------------------------------------------------------- +// dem_close +// --------------------------------------------------------------------------- + +// Scatter the non-zero entries of src into dst at (row_offset, col_offset). +// write_sparse_rows is the col_offset=0 special case. +static void write_sparse_block(cudaqx::tensor &dst, + const sparse_binary_matrix &src, + std::size_t row_offset, std::size_t col_offset) { + const auto cols = src.to_nested_csc(); + const uint32_t nc = src.num_cols(); + for (uint32_t c = 0; c < nc; ++c) + for (auto r : cols[c]) + dst.at({row_offset + r, col_offset + c}) ^= 1; +} + +static void write_sparse_rows(cudaqx::tensor &dst, + const sparse_binary_matrix &src, + uint32_t row_offset) { + write_sparse_block(dst, src, row_offset, 0); +} + +// dem_close() must emit a detector_error_model whose matrix fields are +// tensor — that is the existing public API for all decoders. +// The sparse → dense conversion here is the only use of dense tensors +// in the extended_dem layer. +// +// out_syndrome is not written: closing ends the experiment, so there is no +// next round for that seam to become a detector against. Callers that need a +// final-boundary detector must put it in in_syndrome or interior first. +detector_error_model dem_close(const extended_dem &dem) { + dem.validate("dem_close"); + const uint32_t n_faults = dem.num_faults(); + const uint32_t n_seam = dem.num_in_seam_rows(); + const uint32_t n_interior = dem.num_interior(); + const uint32_t n_obs = dem.num_observables(); + // Row order: in_syndrome first (detector[0] = syndrome[0] vs. zero), + // then interior seam rows (detectors for subsequent round differences). + const uint32_t n_det = n_seam + n_interior; + + detector_error_model result; + result.detector_error_matrix = cudaqx::tensor({n_det, n_faults}); + result.observables_flips_matrix = cudaqx::tensor({n_obs, n_faults}); + result.error_rates = dem.fault_priors; + + write_sparse_rows(result.detector_error_matrix, dem.in_syndrome, 0); + write_sparse_rows(result.detector_error_matrix, dem.interior, n_seam); + write_sparse_rows(result.observables_flips_matrix, dem.observables, 0); + + return result; +} // end - dem_close() + +// --------------------------------------------------------------------------- +// Streaming decoder integration utilities +// --------------------------------------------------------------------------- + +// The width of a chunk's seam, taken from whichever side it has. A phase +// decomposition leaves the first chunk's incoming seam empty (nothing precedes +// the first round) and the last chunk's outgoing seam empty (nothing follows +// the last), so neither side can be read blindly. +static uint32_t chunk_seam_width(const extended_dem &dem_chunk) { + return dem_chunk.num_in_seam_rows() != 0 ? dem_chunk.num_in_seam_rows() + : dem_chunk.num_out_seam_rows(); +} + +// Adjacent chunks contract a's outgoing seam against b's incoming one, the same +// rule dem_stitch applies. Comparing num_seam_rows() across chunks instead +// would compare two incoming widths, neither of which is the seam being +// contracted, and so would reject a phase decomposition. +// +// Tags are checked alongside widths because matching row counts alone do not +// make a seam contractible: out_tags[i] and in_tags[i] have to name the same +// physical check, or the contraction pairs up unrelated rows. Skipping this +// here would leave dem_close_all() accepting sequences dem_stitch() rejects. +static void +require_contractible_seams(const std::vector &dem_chunks, + const char *fn) { + for (std::size_t i = 0; i + 1 < dem_chunks.size(); ++i) { + if (dem_chunks[i].num_out_seam_rows() != + dem_chunks[i + 1].num_in_seam_rows()) + throw std::invalid_argument( + std::string(fn) + ": chunk " + std::to_string(i) + + " out_syndrome has " + + std::to_string(dem_chunks[i].num_out_seam_rows()) + + " rows but chunk " + std::to_string(i + 1) + " in_syndrome has " + + std::to_string(dem_chunks[i + 1].num_in_seam_rows())); + if (dem_chunks[i].out_tags != dem_chunks[i + 1].in_tags) + throw std::invalid_argument( + std::string(fn) + ": chunk " + std::to_string(i) + + " out_tags do not match chunk " + std::to_string(i + 1) + + " in_tags, so their seam rows describe different checks"); + } // end - for(i) +} + +// Shared validation for the round-indexed maps: non-empty, seams that contract, +// one seam width shared by every round, and interior rows that come in whole +// rounds. These maps place detector r*d+k, so a chunk carrying a different +// width would silently shift every later round's detectors. +static void +validate_dem_chunk_sequence(const std::vector &dem_chunks, + const char *fn) { + if (dem_chunks.empty()) + throw std::invalid_argument(std::string(fn) + + ": dem_chunks must be non-empty"); + require_contractible_seams(dem_chunks, fn); + + const uint32_t d = chunk_seam_width(dem_chunks[0]); + if (d == 0) + throw std::invalid_argument(std::string(fn) + + ": chunk 0 has no seam rows on either side, so " + "its rounds cannot be counted"); + for (std::size_t i = 0; i < dem_chunks.size(); ++i) { + // An absent seam is the open end of the sequence; a present one has to + // agree with the rest, otherwise there is no single round width. + for (const auto [width, side] : + {std::pair{dem_chunks[i].num_in_seam_rows(), "in_syndrome"}, + std::pair{dem_chunks[i].num_out_seam_rows(), "out_syndrome"}}) + if (width != 0 && width != d) + throw std::invalid_argument( + std::string(fn) + ": chunk " + std::to_string(i) + " " + side + + " has " + std::to_string(width) + + " rows, not the sequence's round width " + std::to_string(d)); + if (dem_chunks[i].num_interior() % d != 0) + throw std::invalid_argument( + std::string(fn) + ": chunk " + std::to_string(i) + " has " + + std::to_string(dem_chunks[i].num_interior()) + + " interior rows, which is not a whole number of rounds of " + + std::to_string(d) + " checks"); + } +} + +// dem_chunk_rounds: how many rounds one chunk spans. Stitching R one-round +// chunks contracts R-1 seams into interior rows, so the interior row count +// gives the rounds after the leading one. +// +// The leading round is the incoming seam band, which an init phase chunk does +// not have -- it carries round 0 in its interior instead, having nothing before +// it to compare against. So the band counts as a round only when it is there, +// and a chunk with interior rows and no incoming seam (interior == d) is one +// round, not two. +uint32_t dem_chunk_rounds(const extended_dem &dem_chunk) { + const uint32_t d = chunk_seam_width(dem_chunk); + if (d == 0) + throw std::invalid_argument("dem_chunk_rounds: chunk has no seam rows on " + "either side, so its rounds cannot be counted"); + if (dem_chunk.num_interior() % d != 0) + throw std::invalid_argument( + "dem_chunk_rounds: chunk has " + + std::to_string(dem_chunk.num_interior()) + + " interior rows, which is not a whole number of rounds of " + + std::to_string(d) + " checks"); + const uint32_t leading = dem_chunk.num_in_seam_rows() != 0 ? 1 : 0; + return leading + dem_chunk.num_interior() / d; +} + +// dem_chunks_to_rounds: total rounds a sequence of chunks describes. +std::size_t dem_chunks_to_rounds(const std::vector &dem_chunks) { + validate_dem_chunk_sequence(dem_chunks, "dem_chunks_to_rounds"); + std::size_t rounds = 0; + for (const auto &dem_chunk : dem_chunks) + rounds += dem_chunk_rounds(dem_chunk); + return rounds; +} + +// dem_chunks_to_detector_round: detector r*d+k belongs to round r. +// Purely dimension-based — no CSC traversal needed. +std::vector +dem_chunks_to_detector_round(const std::vector &dem_chunks) { + validate_dem_chunk_sequence(dem_chunks, "dem_chunks_to_detector_round"); + + const uint32_t d = chunk_seam_width(dem_chunks[0]); + std::vector result; + std::int32_t round = 0; + + result.reserve(dem_chunks.size() * static_cast(d)); + for (const auto &dem_chunk : dem_chunks) { + // A multi-round chunk carries its own rounds in ascending order, so rounds + // simply keep counting up across chunk boundaries. + const uint32_t rounds = dem_chunk_rounds(dem_chunk); + for (uint32_t r = 0; r < rounds; ++r, ++round) + result.insert(result.end(), d, round); + } + return result; +} + +// dem_chunks_to_d_sparse: D_sparse[det_id] = measurement bit positions that +// XOR-combine to form that detector. +// det k (r=0): {k} — compared to zero initial state +// det r*d+k (r>0): {(r-1)*d+k, r*d+k} — consecutive syndrome XOR +std::vector> +dem_chunks_to_d_sparse(const std::vector &dem_chunks) { + validate_dem_chunk_sequence(dem_chunks, "dem_chunks_to_d_sparse"); + + const uint32_t d = chunk_seam_width(dem_chunks[0]); + // Rounds, not chunks: a chunk may carry several of them. + const std::size_t T = dem_chunks_to_rounds(dem_chunks); + const std::size_t n_det = T * static_cast(d); + + std::vector> d_sparse(n_det); + + // Largest measurement bit index is (T-1)*d + (d-1); check it fits uint32_t. + if (T > 0) { + const auto max_bit = static_cast(T - 1) * d + (d - 1); + if (max_bit > std::numeric_limits::max()) + throw std::overflow_error( + "dem_chunks_to_d_sparse: measurement bit index exceeds uint32_t max"); + } + + for (std::size_t r = 0; r < T; ++r) { + for (uint32_t k = 0; k < d; ++k) { + const std::size_t det = r * d + k; + if (r == 0) { + // Round-0 detector: single measurement at position k. + d_sparse[det] = {k}; + } else { + // Later detector: XOR of previous and current round's measurement. + d_sparse[det] = {static_cast((r - 1) * d + k), + static_cast(r * d + k)}; + } + } + } + return d_sparse; +} + +// dem_chunks_to_o_sparse: O_sparse[obs_id] = global fault column indices that +// flip observable obs_id. Built by transposing each chunk's observables +// sparse matrix with the appropriate column offset applied. +std::vector> +dem_chunks_to_o_sparse(const std::vector &dem_chunks) { + if (dem_chunks.empty()) + throw std::invalid_argument( + "dem_chunks_to_o_sparse: dem_chunks must be non-empty"); + + for (std::size_t i = 0; i < dem_chunks.size(); ++i) + dem_chunks[i].validate( + ("dem_chunks_to_o_sparse: chunk " + std::to_string(i)).c_str()); + + const uint32_t k_obs = dem_chunks[0].num_observables(); + + // Validate consistent observable count across chunks. + for (std::size_t i = 1; i < dem_chunks.size(); ++i) + if (dem_chunks[i].num_observables() != k_obs) + throw std::invalid_argument( + "dem_chunks_to_o_sparse: chunk " + std::to_string(i) + " has " + + std::to_string(dem_chunks[i].num_observables()) + + " observables but chunk 0 has " + std::to_string(k_obs)); + + std::vector> o_sparse(k_obs); + + std::size_t col_off = 0; + for (const auto &c : dem_chunks) { + const uint32_t nc = c.num_faults(); + // Check that the highest global column index in this chunk fits uint32_t. + if (nc > 0 && col_off + nc - 1 > std::numeric_limits::max()) + throw std::overflow_error("dem_chunks_to_o_sparse: global fault column " + "index exceeds uint32_t max"); + // obs is stored as [k_obs × n_faults] in CSC. to_nested_csc() gives + // outer index = fault column, inner = observable rows that flip. + // We need the transpose: observable → fault columns. validate() above + // already required observables.num_cols() == nc. + const auto obs_csc = c.observables.to_nested_csc(); + for (uint32_t fc = 0; fc < nc; ++fc) + for (auto obs_row : obs_csc[fc]) + o_sparse[obs_row].push_back(static_cast(col_off + fc)); + col_off += nc; + } + return o_sparse; +} + +// --------------------------------------------------------------------------- +// dem_close_all +// --------------------------------------------------------------------------- + +// dem_close_all builds the closed DEM in a single forward pass over the chunk +// list. Each fault column is written at most twice: once into the detector +// band for the seam on its left (via in_syndrome) and once into the seam on +// its right (via out_syndrome). The last chunk's out_syndrome is dropped on +// purpose — same terminal-boundary rule as dem_close() — so a detector that +// should appear in the closed DEM must already be in some in_syndrome or +// interior. For single-round chunks (num_interior==0), the output is +// byte-identical to dem_close(dem_stitch_all(dem_chunks)). +// +// Detector row layout (round order, regardless of chunk granularity): +// rows 0..d-1: dem_chunks[0].in_syndrome (detector[0]) +// rows d..d+n_int[0]-1: dem_chunks[0].interior +// rows d+n_int[0]...: seam between chunk 0 and chunk 1 +// rows ...: dem_chunks[1].interior +// ... +// where d = num_seam_rows() and n_int[i] = dem_chunks[i].num_interior(). +detector_error_model +dem_close_all(const std::vector &dem_chunks) { + if (dem_chunks.empty()) + throw std::invalid_argument("dem_close_all: dem_chunks must be non-empty"); + + for (std::size_t i = 0; i < dem_chunks.size(); ++i) + dem_chunks[i].validate( + ("dem_close_all: chunk " + std::to_string(i)).c_str()); + + const uint32_t k = dem_chunks[0].num_observables(); + const std::size_t T = dem_chunks.size(); + + require_contractible_seams(dem_chunks, "dem_close_all"); + for (std::size_t i = 1; i < T; ++i) + if (dem_chunks[i].num_observables() != k) + throw std::invalid_argument( + "dem_close_all: chunk " + std::to_string(i) + " has " + + std::to_string(dem_chunks[i].num_observables()) + + " observables but chunk 0 has " + std::to_string(k)); + + // Detector rows open with chunk 0's incoming seam, which is round 0 compared + // against the initial state. An init phase chunk has no such band and carries + // round 0 in its interior instead, so this is zero for a phase sequence. + const std::size_t lead = dem_chunks[0].num_in_seam_rows(); + + // Cumulative fault column offsets. + std::vector col_off(T + 1, 0); + for (std::size_t i = 0; i < T; ++i) + col_off[i + 1] = + col_off[i] + static_cast(dem_chunks[i].num_faults()); + + const std::size_t n_faults = col_off.back(); + detector_error_model result; + + if (n_faults != 0) { + std::size_t total_interior = 0; + for (const auto &c : dem_chunks) + total_interior += c.num_interior(); + // One seam per adjacent pair, each as wide as the sides it contracts. A + // uniform sequence makes every term d, recovering lead + interior + (T-1)d. + std::size_t total_seam = 0; + for (std::size_t i = 0; i + 1 < T; ++i) + total_seam += dem_chunks[i].num_out_seam_rows(); + const std::size_t n_detectors = lead // in_syndrome of chunk 0 + + total_interior // across all chunks + + total_seam; + + result.detector_error_matrix = + cudaqx::tensor({n_detectors, n_faults}); + result.observables_flips_matrix = + cudaqx::tensor({static_cast(k), n_faults}); + result.error_rates.reserve(n_faults); + + // detector[0]: in_syndrome of chunk 0, at its own column range. + write_sparse_block(result.detector_error_matrix, dem_chunks[0].in_syndrome, + 0, col_off[0]); + + std::size_t row_cursor = lead; + + for (std::size_t i = 0; i < T; ++i) { + // Interior rows already closed within chunk i (seams inside this chunk). + if (dem_chunks[i].num_interior() > 0) { + write_sparse_block(result.detector_error_matrix, dem_chunks[i].interior, + row_cursor, col_off[i]); + row_cursor += dem_chunks[i].num_interior(); + } + + // Seam between chunk i and chunk i+1: left side from i's out_syndrome, + // right side from (i+1)'s in_syndrome. Dropped for the last chunk. + if (i + 1 < T) { + write_sparse_block(result.detector_error_matrix, + dem_chunks[i].out_syndrome, row_cursor, col_off[i]); + write_sparse_block(result.detector_error_matrix, + dem_chunks[i + 1].in_syndrome, row_cursor, + col_off[i + 1]); + row_cursor += dem_chunks[i].num_out_seam_rows(); + } + + // Observables: chunk i's faults go into its own column range. + write_sparse_block(result.observables_flips_matrix, + dem_chunks[i].observables, 0, col_off[i]); + } // end - for(i) + + for (const auto &c : dem_chunks) + result.error_rates.insert(result.error_rates.end(), + c.fault_priors.begin(), c.fault_priors.end()); + + } // end - if (n_faults != 0) + + return result; +} // end - dem_close_all() + +sparse_binary_matrix +dem_chunks_to_pcm(const std::vector &dem_chunks) { + return sparse_binary_matrix(dem_close_all(dem_chunks).detector_error_matrix) + .canonicalize() + .to_csc(); +} // end - dem_chunks_to_pcm() + +// --------------------------------------------------------------------------- +// dem_merge_duplicate_columns — merge fault columns with identical row support +// --------------------------------------------------------------------------- + +namespace { + +// Per-column row lists, sorted ascending with duplicate rows GF(2)-collapsed. +// sparse_binary_matrix stores index lists as handed to it, so a source may list +// a row twice in one column; over GF(2) that is the same column as one omitting +// the row, and the two must produce the same support key. canonicalize() +// collapses per compressed group, so the matrix has to be CSC for those groups +// to be columns. +col_list canonical_columns(const sparse_binary_matrix &mat) { + if (mat.layout() == sparse_binary_matrix_layout::csc) + return mat.canonicalize().to_nested_csc(); + return mat.to_csc().canonicalize().to_nested_csc(); +} + +// The four blocks of a DEM as per-column row lists over one shared row space: +// [0, n_int) interior +// [n_int, n_int+k) observables +// [..., +d) in_syndrome +// [..., +d) out_syndrome +struct dem_column_view { + col_list interior, observables, in_syndrome, out_syndrome; + uint32_t obs_base, in_base, out_base; + + explicit dem_column_view(const extended_dem &dem) + : interior(canonical_columns(dem.interior)), + observables(canonical_columns(dem.observables)), + in_syndrome(canonical_columns(dem.in_syndrome)), + out_syndrome(canonical_columns(dem.out_syndrome)), + obs_base(dem.num_interior()), in_base(obs_base + dem.num_observables()), + out_base(in_base + dem.num_seam_rows()) {} + + // Row support of fault column j in the shared row space. Already ascending: + // every block's list is sorted and the bases increase block by block. + std::vector support(std::size_t j) const { + std::vector sup; + sup.reserve(interior[j].size() + observables[j].size() + + in_syndrome[j].size() + out_syndrome[j].size()); + for (auto r : interior[j]) + sup.push_back(static_cast(r)); + for (auto r : observables[j]) + sup.push_back(obs_base + static_cast(r)); + for (auto r : in_syndrome[j]) + sup.push_back(in_base + static_cast(r)); + for (auto r : out_syndrome[j]) + sup.push_back(out_base + static_cast(r)); + return sup; + } +}; + +} // namespace + +extended_dem dem_merge_duplicate_columns(const extended_dem &dem, + prior_combine_mode mode) { + dem.validate("dem_merge_duplicate_columns"); + const std::size_t n = dem.num_faults(); + if (n == 0) + return dem; + + const dem_column_view cols(dem); + + // Map support → the group of columns sharing it. std::map gives free lex + // ordering so the output matches the Python reference. + struct column_group { + std::size_t representative; + // prod(1-2p_i) for XOR (or_combine) mode, sum(p_i) for SUM mode. + double accumulator; + std::size_t members; + }; + std::map, column_group> sup_map; + + for (std::size_t j = 0; j < n; ++j) { + auto sup = cols.support(j); + const double p = dem.fault_priors[j]; + auto it = sup_map.find(sup); + if (it == sup_map.end()) { + // XOR: track prod(1-2p). SUM: track sum(p). + const double init = + (mode == prior_combine_mode::or_combine) ? (1.0 - 2.0 * p) : p; + sup_map.emplace(std::move(sup), column_group{j, init, 1}); + } else { + if (mode == prior_combine_mode::or_combine) + it->second.accumulator *= (1.0 - 2.0 * p); + else + it->second.accumulator += p; + ++it->second.members; + } + } + + // Build new column lists and priors from the merged unique supports. + const auto new_n = static_cast(sup_map.size()); + col_list new_int(new_n), new_obs(new_n), new_ins(new_n), new_out(new_n); + std::vector new_priors; + new_priors.reserve(new_n); + + std::size_t k = 0; + for (const auto &[sup, group] : sup_map) { + const std::size_t rep = group.representative; + new_int[k] = cols.interior[rep]; + new_obs[k] = cols.observables[rep]; + new_ins[k] = cols.in_syndrome[rep]; + new_out[k] = cols.out_syndrome[rep]; + // A column with nothing to merge keeps its prior bit for bit: + // 0.5*(1-(1-2p)) is not always p in floating point, and merging a DEM + // whose columns are already unique must not perturb its weights. + // + // sum_combine is a small-p linear approximation, so its sum can leave the + // unit interval where or_combine (XOR) cannot. Clamp it: a prior above 1 + // is not a probability, and every consumer of fault_priors treats it as + // one. + const double fp = group.members == 1 + ? dem.fault_priors[rep] + : (mode == prior_combine_mode::or_combine + ? 0.5 * (1.0 - group.accumulator) + : std::min(1.0, group.accumulator)); + new_priors.push_back(fp); + ++k; + } + + extended_dem result; + result.interior = + sparse_binary_matrix::from_nested_csc(dem.num_interior(), new_n, new_int); + result.observables = sparse_binary_matrix::from_nested_csc( + dem.num_observables(), new_n, new_obs); + result.in_syndrome = sparse_binary_matrix::from_nested_csc( + dem.num_seam_rows(), new_n, new_ins); + result.out_syndrome = sparse_binary_matrix::from_nested_csc( + dem.num_out_seam_rows(), new_n, new_out); + result.fault_priors = std::move(new_priors); + result.in_tags = dem.in_tags; + result.out_tags = dem.out_tags; + return result; +} // end - dem_merge_duplicate_columns() + +bool are_dem_columns_unique(const extended_dem &dem) { + dem.validate("are_dem_columns_unique"); + const std::size_t n = dem.num_faults(); + const dem_column_view cols(dem); + + std::map, std::size_t> seen; + for (std::size_t j = 0; j < n; ++j) { + if (!seen.emplace(cols.support(j), j).second) + return false; + } + return true; +} // end - are_dem_columns_unique() + +void assert_dem_columns_unique(const extended_dem &dem) { + dem.validate("assert_dem_columns_unique"); + const std::size_t n = dem.num_faults(); + const dem_column_view cols(dem); + + std::map, std::vector> groups; + for (std::size_t j = 0; j < n; ++j) + groups[cols.support(j)].push_back(j); + + std::size_t n_dup = 0; + const std::vector *first_sup = nullptr; + const std::vector *first_cols = nullptr; + for (const auto &[sup, cols] : groups) { + if (cols.size() > 1) { + ++n_dup; + if (!first_sup) { + first_sup = ⊃ + first_cols = &cols; + } + } + } + + if (n_dup == 0) + return; + + std::string msg = + "extended_dem has duplicate fault columns: " + std::to_string(n_dup) + + " duplicate support set(s); for example, columns {"; + for (std::size_t i = 0; i < first_cols->size(); ++i) { + if (i) + msg += ", "; + msg += std::to_string((*first_cols)[i]); + } + msg += "} all share support {"; + for (std::size_t i = 0; i < first_sup->size(); ++i) { + if (i) + msg += ", "; + msg += std::to_string((*first_sup)[i]); + } + msg += "}. Call dem_merge_duplicate_columns() to merge them."; + throw std::invalid_argument(msg); +} // end - assert_dem_columns_unique() + +extended_dem dem_stitch_merged(const std::vector &dem_chunks, + prior_combine_mode mode) { + return dem_merge_duplicate_columns(dem_stitch_all(dem_chunks), mode); +} // end - dem_stitch_merged() + +} // namespace cudaq::qec diff --git a/libs/qec/lib/realtime/config.cpp b/libs/qec/lib/realtime/config.cpp index 3cfaddf07..a4c98cf82 100644 --- a/libs/qec/lib/realtime/config.cpp +++ b/libs/qec/lib/realtime/config.cpp @@ -14,12 +14,16 @@ #include "cudaq/qec/decoder_config_payload.h" #include "cudaq/qec/decoder_config_schema.h" #include "cudaq/qec/logger.h" +#include "cudaq/qec/pcm_utils.h" #include "cudaq/qec/realtime/decoding_config.h" +#include #include #include #include #include #include +#include +#include namespace cudaq::qec::decoding::config { @@ -32,6 +36,62 @@ void decoder_config::validate_custom_args() const { config::validate_custom_args(type, decoder_custom_args.map()); } +/// Flatten the nested sparse form the decoder API uses into the -1-terminated +/// row form the configuration uses. +static std::vector +flatten_sparse_rows(const std::vector> &rows) { + std::vector flat; + std::size_t total = rows.size(); + for (const auto &row : rows) + total += row.size(); + flat.reserve(total); + for (const auto &row : rows) { + for (const auto column : row) + flat.push_back(static_cast(column)); + flat.push_back(-1); + } + return flat; +} + +std::optional +expand_dem_chunks(decoder_config &config) { + if (!config.dem_chunks.has_value() || !config.H_sparse.empty()) + return std::nullopt; + if (!config.num_rounds.has_value()) + throw std::runtime_error( + "num_rounds is required to expand dem_chunks for decoder " + + std::to_string(config.id)); + const auto rounds = *config.num_rounds; + + std::vector chunks; + try { + chunks = cudaq::qec::dem_chunks_from_spec(*config.dem_chunks, rounds); + } catch (const std::invalid_argument &error) { + throw std::runtime_error( + "Cannot expand dem_chunks for decoder " + std::to_string(config.id) + + " to " + std::to_string(rounds) + " rounds: " + error.what()); + } + + cudaq::qec::detector_error_model closed; + std::vector> d_sparse; + try { + closed = cudaq::qec::dem_close_all(chunks); + d_sparse = cudaq::qec::dem_chunks_to_d_sparse(chunks); + } catch (const std::exception &error) { + throw std::runtime_error( + "Cannot close dem_chunks for decoder " + std::to_string(config.id) + + " into " + std::to_string(rounds) + " rounds: " + error.what()); + } + + config.block_size = closed.num_error_mechanisms(); + config.syndrome_size = closed.num_detectors(); + config.H_sparse = cudaq::qec::pcm_to_sparse_vec(closed.detector_error_matrix); + config.O_sparse = + cudaq::qec::pcm_to_sparse_vec(closed.observables_flips_matrix); + config.D_sparse = flatten_sparse_rows(d_sparse); + return closed; +} + cudaqx::heterogeneous_map decoder_config::decoder_custom_args_to_heterogeneous_map() const { auto args = decoder_custom_args.map(); @@ -272,30 +332,192 @@ struct ScalarEnumerationTraits { } }; +// One phase of a repeated-round DEM. The four matrices reuse the flat +// -1-terminated row encoding of H_sparse / O_sparse / D_sparse; only +// num_faults, O_sparse and error_rates are required, because whether a phase +// has an incoming or outgoing seam is exactly what distinguishes init, bulk and +// final from one another. +template <> +struct MappingTraits { + static void mapping(IO &io, cudaq::qec::dem_chunk_spec &spec) { + io.mapRequired("num_faults", spec.num_faults); + io.mapOptional("H_in_sparse", spec.H_in_sparse); + io.mapOptional("H_mid_sparse", spec.H_mid_sparse); + io.mapOptional("H_out_sparse", spec.H_out_sparse); + io.mapRequired("O_sparse", spec.O_sparse); + io.mapRequired("error_rates", spec.error_rates); + } +}; + +template <> +struct MappingTraits { + static void mapping(IO &io, cudaq::qec::dem_chunks_spec &spec) { + io.mapRequired("init", spec.init); + // Omitted for a two-round decomposition with no repeated middle. + io.mapOptional("bulk", spec.bulk, cudaq::qec::dem_chunk_spec{}); + io.mapRequired("final", spec.final); + // Cross-phase validation deliberately does not happen here. LLVM's YAML + // parser records a diagnostic and keeps going, so a malformed document + // reaches this point with phases half-populated; throwing now would report + // a bogus "final phase is required" instead of the parser's own "unknown + // key" message. validate_parsed_dem_chunks() below runs once the parse has + // been checked for errors. + } +}; + template <> struct MappingTraits { static void mapping(IO &io, cudaq::qec::decoding::config::decoder_config &config) { + // io.keys() reports every key the traits below ask for as well as the ones + // the document actually carries, so it can only tell absent from present + // before any mapping call has registered a name. The flat-form check at + // the bottom needs that distinction, because a field that is missing and + // one that is present but empty parse to the same value. + std::vector document_keys; + if (!io.outputting()) + for (const auto key : io.keys()) + document_keys.emplace_back(key.str()); + const auto in_document = [&document_keys](const char *name) { + return std::find(document_keys.begin(), document_keys.end(), name) != + document_keys.end(); + }; + io.mapRequired("id", config.id); io.mapRequired("type", config.type); io.mapOptional("dispatch", config.dispatch, cudaq::qec::decoding::config::DecoderDispatch::host); io.mapOptional("cuda_device_id", config.cuda_device_id); - io.mapRequired("block_size", config.block_size); - io.mapRequired("syndrome_size", config.syndrome_size); - io.mapRequired("H_sparse", config.H_sparse); - io.mapRequired("O_sparse", config.O_sparse); - io.mapRequired("D_sparse", config.D_sparse); - - // Validate that the number of rows in the H_sparse vector is equal to - // syndrome_size. - auto num_H_rows = - std::count(config.H_sparse.begin(), config.H_sparse.end(), -1); - if (num_H_rows != config.syndrome_size) { + // The DEM arrives in one of two forms (see decoder_config). Everything + // below the mapping calls enforces that exactly one of them is described, + // because the chunk form derives the flat fields and a config that spelled + // out both could disagree with itself. + // A flat configuration names all five on the way out as well as the way + // in. Emitting only the ones that differ from their defaults would drop a + // legitimately empty O_sparse (a DEM with no observables) and leave behind + // a document that no longer parses. A chunk-form configuration takes the + // optional path so its derived fields stay absent, which is what makes the + // emitted document re-parse as chunk form. + const bool emitting_flat_form = + io.outputting() && + !(config.dem_chunks.has_value() && config.H_sparse.empty()); + if (emitting_flat_form) { + io.mapRequired("block_size", config.block_size); + io.mapRequired("syndrome_size", config.syndrome_size); + io.mapRequired("H_sparse", config.H_sparse); + io.mapRequired("O_sparse", config.O_sparse); + io.mapRequired("D_sparse", config.D_sparse); + } else { + io.mapOptional("block_size", config.block_size, std::uint64_t{0}); + io.mapOptional("syndrome_size", config.syndrome_size, std::uint64_t{0}); + io.mapOptional("H_sparse", config.H_sparse, std::vector{}); + io.mapOptional("O_sparse", config.O_sparse, std::vector{}); + io.mapOptional("D_sparse", config.D_sparse, std::vector{}); + } + io.mapOptional("dem_chunks", config.dem_chunks); + io.mapOptional("num_rounds", config.num_rounds); + + // LLVM's YAML parser records a diagnostic and keeps going, so a malformed + // document arrives here half-populated: keys the document never had look + // absent, and optionals it never set have been materialized to their + // defaults. Validating that state reports a problem the document does not + // have, in place of the parser's own message about the one it does. + if (io.error()) + return; + + // Chunk form when dem_chunks is set and H_sparse is empty. Emptiness is + // what the code keys off: an omitted H_sparse and an explicit H_sparse: [] + // both parse to the same empty vector, so both are treated as chunk form. + // A nonempty H_sparse makes the config flat -- the matrix wins -- which is + // also what expand_dem_chunks() leaves behind after it runs. + const bool chunk_form = + config.dem_chunks.has_value() && config.H_sparse.empty(); + + // num_rounds says how many times to repeat the phases, so it needs phases + // to repeat. It is checked against dem_chunks rather than against + // chunk_form because an expanded config still carries the phases its + // matrices came from, and that count still describes them. + if (config.num_rounds.has_value() && !config.dem_chunks.has_value()) + throw std::runtime_error( + "num_rounds is set for decoder " + std::to_string(config.id) + + " but there are no dem_chunks for it to repeat. Either remove " + "num_rounds or add dem_chunks to describe the DEM."); + + // The expansion is init, num_rounds-2 bulk copies, then final, so a count + // below 2 describes no experiment dem_chunks_from_spec() can build. Reject + // it while the document is being read rather than leaving it to fail at + // decoder construction, which is where expansion actually runs. + if (config.num_rounds.has_value() && *config.num_rounds < 2) throw std::runtime_error( - "Number of rows in H_sparse vector is not equal to syndrome_size: " + - std::to_string(num_H_rows) + - " != " + std::to_string(config.syndrome_size)); + "num_rounds must be at least 2 for decoder " + + std::to_string(config.id) + + " because it counts the init and final rounds as well as the bulk " + "copies between them, but it is " + + std::to_string(*config.num_rounds) + "."); + + if (chunk_form) { + if (!config.num_rounds.has_value()) + throw std::runtime_error( + "num_rounds is required for decoder " + std::to_string(config.id) + + " because its DEM comes from dem_chunks, which describes one round " + "at a time and so does not itself fix the experiment length."); + // These are all derived from the phases; accepting them here would let a + // config disagree with its own dem_chunks. + const auto reject_derived = [&](const char *key, bool present) { + if (present) + throw std::runtime_error( + std::string(key) + " must not be set for decoder " + + std::to_string(config.id) + + " because it is derived from dem_chunks. Remove it, or describe " + "the whole experiment with H_sparse instead of dem_chunks."); + }; + reject_derived("block_size", config.block_size != 0); + reject_derived("syndrome_size", config.syndrome_size != 0); + reject_derived("O_sparse", !config.O_sparse.empty()); + reject_derived("D_sparse", !config.D_sparse.empty()); + } else { + // A flat document spells out its whole DEM, so every one of these fields + // has to be there. They are mapOptional only because the chunk form + // derives them; without this check a document that omits one parses into + // a zero-sized DEM and fails much later, at decoder construction. + if (!io.outputting()) { + std::string missing; + for (const char *name : {"block_size", "syndrome_size", "H_sparse", + "O_sparse", "D_sparse"}) { + if (!in_document(name)) { + if (!missing.empty()) + missing += ", "; + missing += name; + } + } // end - for(name) + if (!missing.empty()) + throw std::runtime_error( + "decoder " + std::to_string(config.id) + + " is missing required field(s): " + missing + + ". A flat DEM names block_size, syndrome_size, H_sparse, " + "O_sparse and D_sparse together; use dem_chunks with num_rounds " + "to describe the same DEM one round at a time instead."); + } // end - if(!io.outputting()) + + if (config.H_sparse.empty() && config.syndrome_size > 0) + throw std::runtime_error( + "H_sparse is required for decoder " + std::to_string(config.id) + + " unless dem_chunks is present, which describes the same DEM one " + "round at a time instead."); + if (config.block_size == 0 && !config.H_sparse.empty()) + throw std::runtime_error("block_size is required for decoder " + + std::to_string(config.id)); + + // Validate that the number of rows in the H_sparse vector is equal to + // syndrome_size. + auto num_H_rows = + std::count(config.H_sparse.begin(), config.H_sparse.end(), -1); + if (num_H_rows != config.syndrome_size) { + throw std::runtime_error("Number of rows in H_sparse vector is not " + "equal to syndrome_size: " + + std::to_string(num_H_rows) + + " != " + std::to_string(config.syndrome_size)); + } } // Validate that no values in the H_sparse vector are out of range. @@ -420,6 +642,25 @@ struct MappingTraits { } // namespace llvm::yaml +namespace { + +// Run dem_chunks_spec's cross-phase checks after a successful parse, and +// present failures as std::runtime_error so every configuration error out of +// from_yaml_str() has the one type callers already catch. +void validate_parsed_dem_chunks( + const cudaq::qec::decoding::config::decoder_config &config) { + if (!config.dem_chunks) + return; + try { + config.dem_chunks->validate(); + } catch (const std::invalid_argument &e) { + throw std::runtime_error("Invalid dem_chunks for decoder " + + std::to_string(config.id) + ": " + e.what()); + } +} + +} // namespace + // Static method to convert a YAML string to a multi_decoder_config. cudaq::qec::decoding::config::multi_decoder_config cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( @@ -430,6 +671,8 @@ cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( if (const auto error = yaml_in.error()) throw std::runtime_error("Invalid decoder configuration YAML: " + error.message()); + for (const auto &decoder : config.decoders) + validate_parsed_dem_chunks(decoder); return config; } @@ -451,6 +694,7 @@ cudaq::qec::decoding::config::decoder_config::from_yaml_str( if (const auto error = yaml_in.error()) throw std::runtime_error("Invalid decoder configuration YAML: " + error.message()); + validate_parsed_dem_chunks(config); return config; } @@ -611,6 +855,10 @@ std::string decoder_config_json_schema() { {"H_sparse", llvm::json::Object{{"$ref", "#/$defs/sparse_matrix"}}}, {"O_sparse", llvm::json::Object{{"$ref", "#/$defs/sparse_matrix"}}}, {"D_sparse", llvm::json::Object{{"$ref", "#/$defs/sparse_matrix"}}}, + {"dem_chunks", llvm::json::Object{{"$ref", "#/$defs/dem_chunks"}}}, + // At least 2: num_rounds counts the init and final rounds as well as the + // bulk copies between them, which is what dem_chunks_from_spec() builds. + {"num_rounds", llvm::json::Object{{"type", "integer"}, {"minimum", 2}}}, {"decoder_custom_args", llvm::json::Object{{"type", "object"}}}, }; @@ -646,6 +894,23 @@ std::string decoder_config_json_schema() { llvm::json::Object{{"decoder_custom_args", llvm::json::Object{{"maxProperties", 0}}}}}}}}); + // The cross-phase rules dem_chunks_spec::validate() enforces (empty init + // incoming seam, empty final outgoing seam, equal seam widths, one error rate + // per fault) are not expressible here, so a document that passes this schema + // may still be rejected when parsed. + llvm::json::Object dem_chunk_properties{ + {"num_faults", llvm::json::Object{{"type", "integer"}, {"minimum", 1}}}, + {"H_in_sparse", llvm::json::Object{{"$ref", "#/$defs/sparse_matrix"}}}, + {"H_mid_sparse", llvm::json::Object{{"$ref", "#/$defs/sparse_matrix"}}}, + {"H_out_sparse", llvm::json::Object{{"$ref", "#/$defs/sparse_matrix"}}}, + {"O_sparse", llvm::json::Object{{"$ref", "#/$defs/sparse_matrix"}}}, + {"error_rates", + llvm::json::Object{{"type", "array"}, + {"items", llvm::json::Object{{"type", "number"}, + {"minimum", 0}, + {"maximum", 1}}}}}, + }; + llvm::json::Object defs{ {"sparse_matrix", llvm::json::Object{{"type", "array"}, @@ -679,13 +944,40 @@ std::string decoder_config_json_schema() { llvm::json::Object{ {"$ref", "#/$defs/transport_shape_override"}}}}}, {"additionalProperties", false}}}, + {"dem_chunk", + llvm::json::Object{ + {"type", "object"}, + {"properties", std::move(dem_chunk_properties)}, + {"required", + llvm::json::Array{"num_faults", "O_sparse", "error_rates"}}, + {"additionalProperties", false}}}, + {"dem_chunks", + llvm::json::Object{ + {"type", "object"}, + {"properties", + llvm::json::Object{ + {"init", llvm::json::Object{{"$ref", "#/$defs/dem_chunk"}}}, + {"bulk", llvm::json::Object{{"$ref", "#/$defs/dem_chunk"}}}, + {"final", llvm::json::Object{{"$ref", "#/$defs/dem_chunk"}}}}}, + {"required", llvm::json::Array{"init", "final"}}, + {"additionalProperties", false}}}, {"decoder_config", llvm::json::Object{ {"type", "object"}, {"properties", std::move(config_properties)}, - {"required", - llvm::json::Array{"id", "type", "block_size", "syndrome_size", - "H_sparse", "O_sparse", "D_sparse"}}, + {"required", llvm::json::Array{"id", "type"}}, + // The DEM is described either flat or as repeated phases. The + // parser additionally rejects a chunk-form document that also sets + // the derived fields, which is not expressible here. + {"anyOf", + llvm::json::Array{ + llvm::json::Object{ + {"required", llvm::json::Array{"H_sparse", "block_size", + "syndrome_size", "O_sparse", + "D_sparse"}}}, + llvm::json::Object{ + {"required", + llvm::json::Array{"dem_chunks", "num_rounds"}}}}}, {"additionalProperties", false}, {"allOf", std::move(dispatch)}}}, {"decoder_params", std::move(decoder_params)}, diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index 63baa2774..b22dc70f6 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -218,19 +218,42 @@ cudaqx::heterogeneous_map prepare_decoder_params( } std::unique_ptr create_realtime_decoder( - const cudaq::qec::decoding::config::decoder_config &decoder_config) { - if (decoder_config.id < 0 || static_cast(decoder_config.id) > - std::numeric_limits::max()) + const cudaq::qec::decoding::config::decoder_config &config_in) { + if (config_in.id < 0 || static_cast(config_in.id) > + std::numeric_limits::max()) throw std::invalid_argument("Decoder ID is outside the uint32_t range: " + - std::to_string(decoder_config.id)); + std::to_string(config_in.id)); + + // A chunk-form configuration names its DEM one round at a time. Expand it + // here so everything below is written against the flat form only. + auto expanded_config = config_in; + const auto closed_dem = + cudaq::qec::decoding::config::expand_dem_chunks(expanded_config); + const auto &decoder_config = expanded_config; + if (decoder_config.D_sparse.empty()) throw std::runtime_error( "D_sparse must be provided in decoder configuration"); + // pcm_from_sparse_vec() turns an empty H_sparse into an all-zero matrix + // rather than failing, so without this check a config that described no DEM + // at all would build a decoder whose parity-check matrix decodes nothing. + if (decoder_config.H_sparse.empty()) + throw std::runtime_error( + "H_sparse must be provided to build decoder " + + std::to_string(decoder_config.id) + + ", either directly or by way of dem_chunks and num_rounds."); auto t0 = std::chrono::high_resolution_clock::now(); CUDA_QEC_INFO("Creating decoder {} of type {}", decoder_config.id, decoder_config.type); + auto params = prepare_decoder_params(decoder_config); + // The phases carry a prior per fault, which the derived matrices do not. A + // hand-written flat configuration would have had to pass these through + // decoder_custom_args, so supply them here for the ones that did not. + if (closed_dem && !params.contains("error_rate_vec")) + params.insert("error_rate_vec", closed_dem->error_rates); + auto pcm = cudaq::qec::pcm_from_sparse_vec(decoder_config.H_sparse, decoder_config.syndrome_size, decoder_config.block_size); @@ -238,11 +261,10 @@ std::unique_ptr create_realtime_decoder( decoder_config.O_sparse.end(), -1); // Materialize O before decoder construction to validate its sparse shape and // column indices for every decoder type. TRT also receives this matrix in its - // constructor parameters through prepare_decoder_params() below. + // constructor parameters through prepare_decoder_params() above. (void)cudaq::qec::pcm_from_sparse_vec( decoder_config.O_sparse, num_observables, decoder_config.block_size); - auto decoder = cudaq::qec::get_decoder( - decoder_config.type, pcm, prepare_decoder_params(decoder_config)); + auto decoder = cudaq::qec::get_decoder(decoder_config.type, pcm, params); decoder->set_decoder_id(decoder_config.id); decoder->set_O_sparse(decoder_config.O_sparse); decoder->set_D_sparse(decoder_config.D_sparse); diff --git a/libs/qec/python/CMakeLists.txt b/libs/qec/python/CMakeLists.txt index 4a3a1ced9..c958bcf67 100644 --- a/libs/qec/python/CMakeLists.txt +++ b/libs/qec/python/CMakeLists.txt @@ -33,7 +33,9 @@ cudaqx_add_pymodule(${MODULE_NAME} bindings/py_decoder.cpp bindings/py_decoding_config.cpp bindings/py_decoding.cpp + bindings/py_dem_construction.cpp bindings/py_dem_sampling.cpp + bindings/py_extended_dem.cpp bindings/py_surface_code.cpp bindings/py_patch.cpp ) diff --git a/libs/qec/python/bindings/cudaqx_qec.cpp b/libs/qec/python/bindings/cudaqx_qec.cpp index fb1d25576..4b4dde95a 100644 --- a/libs/qec/python/bindings/cudaqx_qec.cpp +++ b/libs/qec/python/bindings/cudaqx_qec.cpp @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2022 - 2023 NVIDIA Corporation & Affiliates. * + * Copyright (c) 2022 - 2026 NVIDIA Corporation & Affiliates. * * All rights reserved. * * * * This source code and the accompanying materials are made available under * @@ -10,7 +10,9 @@ #include "py_decoder.h" #include "py_decoding.h" #include "py_decoding_config.h" +#include "py_dem_construction.h" #include "py_dem_sampling.h" +#include "py_extended_dem.h" #include "py_surface_code.h" #include @@ -28,6 +30,8 @@ NB_MODULE(_pycudaqx_qec_the_suffix_matters_cudaq_qec, mod) { cudaq::qec::decoding::bindDecoding(mod); cudaq::qec::dem_sampler::bindDemSampling(mod); cudaq::qec::surface_code::bindSurfaceCode(mod); + cudaq::qec::bindDemConstruction(mod); + cudaq::qec::bindExtendedDem(mod); // Suppress nanobind's reference-leak warnings. // // Background: nanobind runs leak detection when its internal state capsule is diff --git a/libs/qec/python/bindings/py_decoding_config.cpp b/libs/qec/python/bindings/py_decoding_config.cpp index fb66c0832..e8e38b4b2 100644 --- a/libs/qec/python/bindings/py_decoding_config.cpp +++ b/libs/qec/python/bindings/py_decoding_config.cpp @@ -192,6 +192,15 @@ void bindDecodingConfig(nb::module_ &mod) { .def_rw("H_sparse", &decoder_config::H_sparse) .def_rw("O_sparse", &decoder_config::O_sparse) .def_rw("D_sparse", &decoder_config::D_sparse) + .def_rw("dem_chunks", &decoder_config::dem_chunks, + "Optional DemChunksSpec describing the DEM as init / bulk / " + "final phases instead of flat matrices. Set it together with " + "num_rounds and leave block_size, syndrome_size, H_sparse, " + "O_sparse and D_sparse unset; expand_dem_chunks() derives those.") + .def_rw("num_rounds", &decoder_config::num_rounds, + "Total rounds to expand dem_chunks into, counting init and " + "final, so the expansion is init, num_rounds - 2 bulk copies, " + "then final. Required with dem_chunks and rejected without it.") .def_prop_rw( "decoder_custom_args", [](const decoder_config &self) -> nb::object { @@ -246,6 +255,22 @@ void bindDecodingConfig(nb::module_ &mod) { const multi_decoder_config &b) { return a == b; }); // Library helpers + mod_cfg.def( + "expand_dem_chunks", &config::expand_dem_chunks, nb::arg("config"), + "Rewrite a chunk-form configuration into the equivalent flat form.\n\n" + "Fills block_size, syndrome_size, H_sparse, O_sparse and D_sparse on\n" + "`config` in place from dem_chunks expanded num_rounds times, so a\n" + "chunk-form configuration can be handed to anything that understands\n" + "only the flat form. Creating a decoder does this for you.\n\n" + "Does nothing to a configuration that is already flat, so it is safe to\n" + "call unconditionally.\n\n" + "Args:\n" + " config: decoder_config to rewrite in place.\n" + "Returns:\n" + " The closed detector_error_model the flat fields came from, or None\n" + " if the configuration was already flat. Its error_rates are the\n" + " per-fault priors the phases declared."); + mod_cfg.def( "configure_decoders", &configure_decoders, nb::arg("config"), "Configure decoders in a multi_decoder_config list; returns int status."); diff --git a/libs/qec/python/bindings/py_dem_construction.cpp b/libs/qec/python/bindings/py_dem_construction.cpp new file mode 100644 index 000000000..d939fb4df --- /dev/null +++ b/libs/qec/python/bindings/py_dem_construction.cpp @@ -0,0 +1,124 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// Python bindings for CSS code matrix types and DEM construction functions. +// css_code_matrices and css_noise_params are exposed as simple structs whose +// sparse_binary_matrix fields can be set from numpy arrays or scipy sparse +// matrices. dem_from_css_matrices accepts either a css_code_matrices or a +// code object directly. + +#include "py_dem_construction.h" +#include "type_casters.h" +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/dem_construction.h" +#include "cudaq/qec/dem_construction_code.h" +#include "cudaq/qec/sparse_binary_matrix.h" + +#include +#include +#include + +namespace nb = nanobind; + +namespace cudaq::qec { + +void bindDemConstruction(nb::module_ &mod) { + + // ------------------------------------------------------------------------- + // css_code_matrices + // ------------------------------------------------------------------------- + nb::class_( + mod, "CssCodes", + "CSS code generator matrices: parity-check and logical operators.\n\n" + "Each matrix column corresponds to one data qubit. Construct from\n" + "sparse_binary_matrix fields or use css_matrices_from_code().") + .def(nb::init<>()) + .def_rw("hz", &css_code_matrices::hz, + "Z stabilizers [n_z_checks x n_qubits]") + .def_rw("hx", &css_code_matrices::hx, + "X stabilizers [n_x_checks x n_qubits]") + .def_rw("lz", &css_code_matrices::lz, + "Z logical operators [k x n_qubits]") + .def_rw("lx", &css_code_matrices::lx, + "X logical operators [k x n_qubits]"); + + // ------------------------------------------------------------------------- + // css_noise_params + // ------------------------------------------------------------------------- + nb::class_( + mod, "CssNoise", + "Phenomenological noise parameters for CSS code-capacity DEM\n" + "construction. Scalar rates apply uniformly; per-element vectors\n" + "override when non-empty (length must equal n_qubits or n_checks).") + .def(nb::init<>()) + .def_rw("px", &css_noise_params::px, "Uniform X data-qubit error rate") + .def_rw("py", &css_noise_params::py, "Uniform Y data-qubit error rate") + .def_rw("pz", &css_noise_params::pz, "Uniform Z data-qubit error rate") + .def_rw("pm", &css_noise_params::pm, + "Uniform syndrome measurement error rate per check per round") + .def_rw("px_per_qubit", &css_noise_params::px_per_qubit, + "Per-qubit X rates (overrides px when non-empty)") + .def_rw("py_per_qubit", &css_noise_params::py_per_qubit, + "Per-qubit Y rates") + .def_rw("pz_per_qubit", &css_noise_params::pz_per_qubit, + "Per-qubit Z rates") + .def_rw("pm_per_check", &css_noise_params::pm_per_check, + "Per-check measurement error rates (Z-checks first)"); + + // ------------------------------------------------------------------------- + // dem_from_css_matrices — matrix-based overload + // ------------------------------------------------------------------------- + mod.def( + "dem_from_css_matrices", + [](const css_code_matrices &code, const css_noise_params &noise, + std::size_t num_rounds) { + return dem_from_css_matrices(code, noise, num_rounds); + }, + nb::arg("code"), nb::arg("noise"), nb::arg("num_rounds") = 1, + "Build a T-round code-capacity DEM from CSS generator matrices.\n\n" + "Args:\n" + " code: CssCodes instance with hz/hx/lz/lx matrices.\n" + " noise: CssNoise instance with error rates.\n" + " num_rounds: Number of syndrome measurement rounds (default 1).\n" + "Returns:\n" + " detector_error_model ready for any decoder."); + + // ------------------------------------------------------------------------- + // css_matrices_from_code — bridge from code objects + // ------------------------------------------------------------------------- + mod.def("css_matrices_from_code", &css_matrices_from_code, nb::arg("code"), + "Extract CSS generator matrices from a QEC code object.\n\n" + "Calls code.get_parity_z/x() and code.get_observables_z/x() and\n" + "wraps the results as CssCodes.\n\n" + "Args:\n" + " code: A cudaq_qec.Code instance (repetition, surface, etc.).\n" + "Returns:\n" + " CssCodes instance."); + + // ------------------------------------------------------------------------- + // dem_from_css_matrices — code-object overload + // ------------------------------------------------------------------------- + mod.def( + "dem_from_css_matrices", + [](const code &qec_code, const css_noise_params &noise, + std::size_t num_rounds) { + return dem_from_css_matrices(qec_code, noise, num_rounds); + }, + nb::arg("code"), nb::arg("noise"), nb::arg("num_rounds") = 1, + "Build a T-round code-capacity DEM directly from a code object.\n\n" + "Equivalent to dem_from_css_matrices(css_matrices_from_code(code),\n" + "noise, num_rounds).\n\n" + "Args:\n" + " code: A cudaq_qec.Code instance.\n" + " noise: CssNoise instance.\n" + " num_rounds: Number of syndrome rounds (default 1).\n" + "Returns:\n" + " detector_error_model."); +} + +} // namespace cudaq::qec diff --git a/libs/qec/python/bindings/py_dem_construction.h b/libs/qec/python/bindings/py_dem_construction.h new file mode 100644 index 000000000..316f3d66c --- /dev/null +++ b/libs/qec/python/bindings/py_dem_construction.h @@ -0,0 +1,13 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ +#pragma once +#include +namespace nb = nanobind; +namespace cudaq::qec { +void bindDemConstruction(nb::module_ &mod); +} // namespace cudaq::qec diff --git a/libs/qec/python/bindings/py_extended_dem.cpp b/libs/qec/python/bindings/py_extended_dem.cpp new file mode 100644 index 000000000..e379629c9 --- /dev/null +++ b/libs/qec/python/bindings/py_extended_dem.cpp @@ -0,0 +1,281 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// Python bindings for extended_dem, dem_stitch/dem_close operations, and the +// DEM chunk-to-streaming-decoder utility functions. + +#include "py_extended_dem.h" +#include "type_casters.h" +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/extended_dem.h" + +#include +#include +#include +#include + +namespace nb = nanobind; + +namespace cudaq::qec { + +void bindExtendedDem(nb::module_ &mod) { + + // ------------------------------------------------------------------------- + // extended_dem + // ------------------------------------------------------------------------- + nb::class_( + mod, "ExtendedDem", + "One chunk of a DEM, partitioned into interior, observable, and seam " + "rows.\n\n" + "A single extended_dem represents one round (or a pre-stitched group\n" + "of rounds). Stitch adjacent DEM chunks and close to produce a flat\n" + "detector_error_model for a decoder.") + .def(nb::init<>()) + .def("num_faults", &extended_dem::num_faults, + "Number of fault columns (shared by all four matrices).") + .def("num_interior", &extended_dem::num_interior, + "Number of interior detector rows (seams within this DEM chunk).") + .def("num_observables", &extended_dem::num_observables, + "Number of observable rows.") + .def("num_seam_rows", &extended_dem::num_seam_rows, + "Number of incoming-seam rows (= total checks per round for a\n" + "uniform per-round chunk).") + .def("num_in_seam_rows", &extended_dem::num_in_seam_rows, + "Number of incoming-seam rows. Zero for an init phase chunk.") + .def("num_out_seam_rows", &extended_dem::num_out_seam_rows, + "Number of outgoing-seam rows. Zero for a final phase chunk.") + .def_rw("fault_priors", &extended_dem::fault_priors, + "Error rates (one per fault column, same order as matrices).") + .def_rw( + "in_tags", &extended_dem::in_tags, + "Check identity tags for the left seam (for dem_stitch validation).") + .def_rw("out_tags", &extended_dem::out_tags, + "Check identity tags for the right seam."); + + // ------------------------------------------------------------------------- + // extended_dem_from_css_matrices + // ------------------------------------------------------------------------- + mod.def("extended_dem_from_css_matrices", &extended_dem_from_css_matrices, + nb::arg("code"), nb::arg("noise"), + "Build a one-round ExtendedDem from CSS generator matrices.\n\n" + "Args:\n" + " code: CssCodes instance.\n" + " noise: CssNoise instance.\n" + "Returns:\n" + " ExtendedDem with in_syndrome == out_syndrome (one round)."); + + // ------------------------------------------------------------------------- + // dem_chunk_spec / dem_chunks_spec + // ------------------------------------------------------------------------- + nb::class_( + mod, "DemChunkSpec", + "One DEM phase, written the way decoder configuration YAML writes it.\n\n" + "The three H lists are the phase's parity blocks against the same\n" + "num_faults columns: H_in_sparse is the incoming seam, H_mid_sparse the\n" + "interior, H_out_sparse the outgoing seam. Each is a flat list of\n" + "column indices with -1 ending every row, so [0, 1, -1, 2, -1] is two\n" + "rows. An init phase leaves H_in_sparse empty and a final phase leaves\n" + "H_out_sparse empty, since nothing precedes the first round or follows\n" + "the last.") + .def(nb::init<>()) + .def_rw("num_faults", &dem_chunk_spec::num_faults, + "Fault columns in this phase; every matrix has this width.") + .def_rw("H_in_sparse", &dem_chunk_spec::H_in_sparse, + "Incoming seam rows. Empty for an init phase.") + .def_rw("H_mid_sparse", &dem_chunk_spec::H_mid_sparse, + "Interior detector rows owned by this phase.") + .def_rw("H_out_sparse", &dem_chunk_spec::H_out_sparse, + "Outgoing seam rows. Empty for a final phase.") + .def_rw("O_sparse", &dem_chunk_spec::O_sparse, + "Which fault columns flip each observable (row-sparse, -1 " + "terminated).") + .def_rw("error_rates", &dem_chunk_spec::error_rates, + "One prior per fault column, in column order.") + .def("is_empty", &dem_chunk_spec::is_empty, + "True iff no field has been set, meaning the phase is absent.") + .def("validate", &dem_chunk_spec::validate, nb::arg("context"), + "Raise ValueError on the first inconsistency, naming `context`.") + .def("__eq__", [](const dem_chunk_spec &a, const dem_chunk_spec &b) { + return a == b; + }); + + nb::class_( + mod, "DemChunksSpec", + "An experiment's DEM as init / bulk / final phases.\n\n" + "Expanding these for a round count gives the chunk sequence for a whole\n" + "experiment, so one description serves runs of any length. bulk is\n" + "optional and only needed when more than two rounds are wanted.") + .def(nb::init<>()) + .def_rw("init", &dem_chunks_spec::init, + "First round. Has no incoming seam.") + .def_rw("bulk", &dem_chunks_spec::bulk, + "Repeated middle round. Leave empty for a two-round experiment.") + .def_rw("final", &dem_chunks_spec::final, + "Last round. Has no outgoing seam.") + .def("is_empty", &dem_chunks_spec::is_empty, + "True iff no phase has been set.") + .def("has_bulk", &dem_chunks_spec::has_bulk, + "True iff a bulk phase is present and so rounds can be repeated.") + .def( + "validate", &dem_chunks_spec::validate, + "Raise ValueError if the phases cannot stitch into an experiment:\n" + "a missing init or final, seams that do not line up, priors that do\n" + "not match the fault count, or indices out of range.") + .def("__eq__", [](const dem_chunks_spec &a, const dem_chunks_spec &b) { + return a == b; + }); + + mod.def("dem_chunk_from_spec", &dem_chunk_from_spec, nb::arg("spec"), + nb::arg("context") = "dem_chunk", + "Build one ExtendedDem from a DemChunkSpec.\n\n" + "Args:\n" + " spec: DemChunkSpec to realize.\n" + " context: Prefix for any error message.\n" + "Returns:\n" + " ExtendedDem with seam tags assigned so adjacent phases stitch."); + + mod.def( + "dem_chunks_from_spec", &dem_chunks_from_spec, nb::arg("spec"), + nb::arg("num_rounds"), + "Expand phase specs into the chunk sequence for a round count.\n\n" + "Produces init, num_rounds - 2 copies of bulk, then final -- the list\n" + "dem_close_all() and dem_stitch_all() consume.\n\n" + "Args:\n" + " spec: DemChunksSpec describing the phases.\n" + " num_rounds: Total rounds counting init and final; at least 2.\n" + "Returns:\n" + " A list of num_rounds ExtendedDem in round order."); + + // ------------------------------------------------------------------------- + // prior_combine_mode + // ------------------------------------------------------------------------- + nb::enum_( + mod, "PriorCombineMode", + "Strategy for merging fault priors when dem_merge_duplicate_columns()\n" + "collapses columns with identical row support.") + .value("or_combine", prior_combine_mode::or_combine, + "p = 1/2 * (1 - prod(1 - 2 p_i)): exact probability that an odd\n" + "number of independent events fire (GF(2) / XOR merge). Use for\n" + "physical fault mechanisms.") + .value("sum_combine", prior_combine_mode::sum_combine, + "p = min(1, sum(p_i)): linear approximation valid for small\n" + "priors."); + + // ------------------------------------------------------------------------- + // dem_merge_duplicate_columns / are_dem_columns_unique / + // assert_dem_columns_unique / dem_stitch_merged + // ------------------------------------------------------------------------- + mod.def( + "dem_merge_duplicate_columns", &dem_merge_duplicate_columns, + nb::arg("dem"), nb::arg("mode") = prior_combine_mode::or_combine, + "Merge fault columns with identical row support into single columns.\n\n" + "Args:\n" + " dem: ExtendedDem whose duplicate columns should be collapsed.\n" + " mode: PriorCombineMode controlling how priors are merged.\n" + "Returns:\n" + " New ExtendedDem with unique-support columns."); + + mod.def("are_dem_columns_unique", &are_dem_columns_unique, nb::arg("dem"), + "Return True iff every fault column has a unique row-support set."); + + mod.def( + "assert_dem_columns_unique", &assert_dem_columns_unique, nb::arg("dem"), + "Raise std::invalid_argument if any two columns share row support.\n\n" + "The error message names the duplicate support and column indices,\n" + "and suggests calling dem_merge_duplicate_columns() to fix the issue."); + + mod.def( + "dem_stitch_merged", &dem_stitch_merged, nb::arg("dem_chunks"), + nb::arg("mode") = prior_combine_mode::or_combine, + "Stitch DEM chunks left-to-right then merge duplicate columns.\n\n" + "Equivalent to dem_merge_duplicate_columns(dem_stitch_all(dem_chunks)).\n" + "Args:\n" + " dem_chunks: Non-empty list of ExtendedDem in round order.\n" + " mode: PriorCombineMode for the merge step.\n" + "Returns:\n" + " Stitched ExtendedDem with unique-support columns."); + + // ------------------------------------------------------------------------- + // dem_stitch / dem_stitch_all + // ------------------------------------------------------------------------- + mod.def("dem_stitch", &dem_stitch, nb::arg("a"), nb::arg("b"), + "Stitch two adjacent DEM chunks: contract a.out_syndrome with\n" + "b.in_syndrome. The seam becomes new interior rows in the result."); + + mod.def("dem_stitch_all", &dem_stitch_all, nb::arg("dem_chunks"), + "Stitch a list of adjacent DEM chunks left-to-right.\n\n" + "Args:\n" + " dem_chunks: Non-empty list of ExtendedDem in round order.\n" + "Returns:\n" + " Fully-stitched ExtendedDem."); + + // ------------------------------------------------------------------------- + // dem_close / dem_close_all + // ------------------------------------------------------------------------- + mod.def("dem_close", &dem_close, nb::arg("dem"), + "Collapse an ExtendedDem into a flat detector_error_model.\n\n" + "Places in_syndrome rows first, then interior rows. out_syndrome is\n" + "dropped: closing ends the experiment, so put any final-boundary\n" + "detector in in_syndrome or interior instead."); + + mod.def( + "dem_close_all", &dem_close_all, nb::arg("dem_chunks"), + "Build a flat detector_error_model from T DEM chunks in O(T) time.\n\n" + "Equivalent to dem_close(dem_stitch_all(dem_chunks)) but avoids O(T^2) " + "cost. The last chunk's out_syndrome is dropped, same as dem_close().\n\n" + "Args:\n" + " dem_chunks: Non-empty list of ExtendedDem in round order.\n" + "Returns:\n" + " detector_error_model ready for any decoder."); + + // ------------------------------------------------------------------------- + // Streaming decoder utilities + // ------------------------------------------------------------------------- + mod.def("dem_chunk_rounds", &dem_chunk_rounds, nb::arg("dem_chunk"), + "Return how many measurement rounds a DEM chunk spans.\n\n" + "One for a DEM chunk from extended_dem_from_css_matrices(); for a\n" + "stitched DEM chunk, one more than its interior rows divided by its\n" + "seam rows."); + + mod.def("dem_chunks_to_rounds", &dem_chunks_to_rounds, nb::arg("dem_chunks"), + "Return the total rounds a list of DEM chunks describes.\n\n" + "The sum of dem_chunk_rounds() over the list, which equals " + "len(dem_chunks)\n" + "only when every DEM chunk spans a single round."); + + mod.def( + "dem_chunks_to_detector_round", &dem_chunks_to_detector_round, + nb::arg("dem_chunks"), + "Return the round index for each detector in " + "dem_close_all(dem_chunks).\n\n" + "Returns a list of length T*d where entry i gives the round (0..T-1)\n" + "of detector i, with T = dem_chunks_to_rounds(dem_chunks). Pass as the\n" + "'detector_round' parameter to streaming decoders that need to know\n" + "when each detector becomes available."); + + mod.def( + "dem_chunks_to_d_sparse", &dem_chunks_to_d_sparse, nb::arg("dem_chunks"), + "Return the D_sparse measurement-to-detector map from T DEM chunks.\n\n" + "d_sparse[det_id] lists the raw measurement bit positions that\n" + "XOR-combine to fire that detector. Compatible with\n" + "decoder.set_D_sparse()."); + + mod.def("dem_chunks_to_o_sparse", &dem_chunks_to_o_sparse, + nb::arg("dem_chunks"), + "Return the O_sparse observable-flip map from T DEM chunks.\n\n" + "o_sparse[obs_id] lists the global fault column indices that flip\n" + "observable obs_id. Compatible with decoder.set_O_sparse() and\n" + "to_logical_outcome()."); + + mod.def("dem_chunks_to_pcm", &dem_chunks_to_pcm, nb::arg("dem_chunks"), + "Build a canonicalized CSC parity-check matrix from T DEM chunks.\n\n" + "Equivalent to closing the chunks, then canonicalizing the detector\n" + "error matrix and converting it to CSC layout."); +} + +} // namespace cudaq::qec diff --git a/libs/qec/python/bindings/py_extended_dem.h b/libs/qec/python/bindings/py_extended_dem.h new file mode 100644 index 000000000..95aabf0a1 --- /dev/null +++ b/libs/qec/python/bindings/py_extended_dem.h @@ -0,0 +1,13 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ +#pragma once +#include +namespace nb = nanobind; +namespace cudaq::qec { +void bindExtendedDem(nb::module_ &mod); +} // namespace cudaq::qec diff --git a/libs/qec/python/cudaq_qec/__init__.py b/libs/qec/python/cudaq_qec/__init__.py index 079dac80c..0283f8ee9 100644 --- a/libs/qec/python/cudaq_qec/__init__.py +++ b/libs/qec/python/cudaq_qec/__init__.py @@ -125,6 +125,7 @@ def checked_decode_batch(self, *args, **kwargs): configure_decoders_from_str = qecrt.config.configure_decoders_from_str finalize_decoders = qecrt.config.finalize_decoders configure_decoders = qecrt.config.configure_decoders +expand_dem_chunks = qecrt.config.expand_dem_chunks decoder_param_schema = qecrt.config.decoder_param_schema registered_decoder_schemas = qecrt.config.registered_decoder_schemas decoder_config_json_schema = qecrt.config.decoder_config_json_schema diff --git a/libs/qec/python/tests/test_decoders_yaml.py b/libs/qec/python/tests/test_decoders_yaml.py index 474c8483f..35b1d5a9e 100644 --- a/libs/qec/python/tests/test_decoders_yaml.py +++ b/libs/qec/python/tests/test_decoders_yaml.py @@ -277,5 +277,196 @@ def test_sliding_window_boundary_syndromes_roundtrip(): check_decoder_yaml_roundtrip(multi_config) +# --------------------------------------------------------------------------- +# Chunk-form configurations +# --------------------------------------------------------------------------- + +REP5_CHECKS = 4 + + +def chunk_form_yaml(num_rounds): + """A d=5 repetition code written as phases rather than flat matrices.""" + return f""" +id: 0 +type: single_error_lut +num_rounds: {num_rounds} +dem_chunks: + init: + num_faults: 9 + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + bulk: + num_faults: 9 + H_in_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] +""" + + +def test_chunk_form_parses_with_no_flat_matrices(): + config = qec.decoder_config.from_yaml_str(chunk_form_yaml(5)) + + assert config.num_rounds == 5 + assert config.dem_chunks is not None + assert config.dem_chunks.init.num_faults == 9 + # The flat fields are derived, so they stay unset until expansion. + assert config.block_size == 0 + assert config.syndrome_size == 0 + assert len(config.H_sparse) == 0 + + +def test_expand_dem_chunks_fills_the_flat_fields(): + config = qec.decoder_config.from_yaml_str(chunk_form_yaml(5)) + closed = qec.qecrt.config.expand_dem_chunks(config) + + assert closed is not None + assert config.syndrome_size == 5 * REP5_CHECKS + # 4 init/bulk phases of 9 faults, plus a 5-fault final round. + assert config.block_size == 41 + assert len(closed.error_rates) == config.block_size + # One -1 terminator per row, so H and D are as tall as the DEM. + assert config.H_sparse.count(-1) == config.syndrome_size + assert config.D_sparse.count(-1) == config.syndrome_size + + +def test_expand_dem_chunks_is_a_no_op_on_a_flat_config(): + config = qec.decoder_config.from_yaml_str(chunk_form_yaml(3)) + qec.qecrt.config.expand_dem_chunks(config) + flat_H = list(config.H_sparse) + + # Already flat now, so a second call must leave it alone. + assert qec.qecrt.config.expand_dem_chunks(config) is None + assert list(config.H_sparse) == flat_H + + +def test_same_chunks_serve_different_round_counts(): + sizes = {} + for rounds in (2, 3, 5, 8): + config = qec.decoder_config.from_yaml_str(chunk_form_yaml(rounds)) + qec.qecrt.config.expand_dem_chunks(config) + sizes[rounds] = (config.syndrome_size, config.block_size) + + for rounds, (syndrome_size, block_size) in sizes.items(): + assert syndrome_size == rounds * REP5_CHECKS + assert block_size == 9 * (rounds - 1) + 5 + + +def test_chunk_form_builds_a_working_decoder(): + config = qec.decoder_config.from_yaml_str(chunk_form_yaml(5)) + multi_config = qec.multi_decoder_config() + multi_config.decoders = [config] + + assert qec.qecrt.config.configure_decoders(multi_config) == 0 + qec.qecrt.config.finalize_decoders() + + +def test_num_rounds_is_required_with_dem_chunks(): + without = chunk_form_yaml(5).replace("num_rounds: 5\n", "") + with pytest.raises(Exception): + qec.decoder_config.from_yaml_str(without) + + +def test_num_rounds_is_rejected_without_dem_chunks(): + yaml_str = """ +id: 0 +type: single_error_lut +num_rounds: 5 +block_size: 3 +syndrome_size: 2 +H_sparse: [ 0, -1, 1, -1 ] +O_sparse: [ 0, -1 ] +D_sparse: [ 0, -1, 0, 1, -1 ] +""" + with pytest.raises(Exception): + qec.decoder_config.from_yaml_str(yaml_str) + + +def test_num_rounds_below_two_is_rejected(): + # num_rounds counts init and final as well as the bulk copies between + # them, so 2 is the smallest expansion there is. + for rounds in (0, 1): + with pytest.raises(Exception): + qec.decoder_config.from_yaml_str(chunk_form_yaml(rounds)) + + assert qec.decoder_config.from_yaml_str(chunk_form_yaml(2)).num_rounds == 2 + + +def test_incomplete_flat_config_is_rejected(): + # dem_chunks made the flat fields optional in the mapping; a flat config + # still has to name all of them, or it parses into an empty DEM that only + # fails when a decoder is built from it. + complete = { + "block_size": "block_size: 3", + "syndrome_size": "syndrome_size: 2", + "H_sparse": "H_sparse: [ 0, -1, 1, -1 ]", + "O_sparse": "O_sparse: [ 0, -1 ]", + "D_sparse": "D_sparse: [ 0, -1, 0, 1, -1 ]", + } + header = "id: 0\ntype: single_error_lut\n" + assert qec.decoder_config.from_yaml_str(header + + "\n".join(complete.values())) + + for omitted in complete: + yaml_str = header + "\n".join( + line for key, line in complete.items() if key != omitted) + with pytest.raises(Exception): + qec.decoder_config.from_yaml_str(yaml_str) + + +def test_config_describing_no_dem_at_all_is_rejected(): + with pytest.raises(Exception): + qec.decoder_config.from_yaml_str("id: 0\ntype: single_error_lut\n") + + +def test_expand_dem_chunks_is_exported_at_top_level(): + config = qec.decoder_config.from_yaml_str(chunk_form_yaml(5)) + assert qec.expand_dem_chunks(config) is not None + assert config.block_size == 41 + + +def test_derived_fields_are_rejected_in_chunk_form(): + with_block_size = chunk_form_yaml(5).replace( + "num_rounds: 5", "num_rounds: 5\nblock_size: 41") + with pytest.raises(Exception): + qec.decoder_config.from_yaml_str(with_block_size) + + +def test_chunk_form_can_be_built_programmatically(): + config = qec.decoder_config() + config.id = 0 + config.type = "single_error_lut" + config.num_rounds = 4 + + spec = qec.DemChunksSpec() + spec.init.num_faults = 9 + spec.init.H_mid_sparse = [ + 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 + ] + spec.init.H_out_sparse = [5, -1, 6, -1, 7, -1, 8, -1] + spec.init.O_sparse = [0, -1] + spec.init.error_rates = [0.02] * 9 + spec.bulk.num_faults = 9 + spec.bulk.H_in_sparse = [0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1] + spec.bulk.H_out_sparse = [5, -1, 6, -1, 7, -1, 8, -1] + spec.bulk.O_sparse = [0, -1] + spec.bulk.error_rates = [0.02] * 9 + spec.final.num_faults = 5 + spec.final.H_in_sparse = [0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1] + spec.final.O_sparse = [0, -1] + spec.final.error_rates = [0.02] * 5 + config.dem_chunks = spec + + qec.qecrt.config.expand_dem_chunks(config) + assert config.syndrome_size == 4 * REP5_CHECKS + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/libs/qec/python/tests/test_dem.py b/libs/qec/python/tests/test_dem.py index b158c1ecb..4946a5648 100644 --- a/libs/qec/python/tests/test_dem.py +++ b/libs/qec/python/tests/test_dem.py @@ -841,5 +841,246 @@ def test_decoder_context_single_type_code_empty_component(): assert len(zc_m2d) == zc_dem.num_detectors() +# --------------------------------------------------------------------------- +# Tests for dem_stitch / dem_stitch_all / dem_close / dem_close_all and the +# dem_chunks_to_* utility functions exposed via py_extended_dem.cpp. +# All tests use a rep-3 single-round chunk (d=2 seam rows, 3 faults). +# --------------------------------------------------------------------------- + + +def _rep3_dem_chunk(px=0.01): + code = qec.css_matrices_from_code(qec.get_code("repetition", distance=3)) + noise = qec.CssNoise() + noise.px_per_qubit = [px, px, px] + return qec.extended_dem_from_css_matrices(code, noise) + + +def test_dem_stitch_interior_grows(): + # After one stitch the interior must contain one seam's worth of rows (d=2). + c = _rep3_dem_chunk() + ab = qec.dem_stitch(c, c) + assert ab.num_interior() == 2 + assert ab.num_seam_rows() == 2 + assert ab.num_faults() == 6 # 2 rounds × 3 qubits + + +def test_dem_stitch_fault_priors_concatenated(): + px = 0.01 + c = _rep3_dem_chunk(px) + ab = qec.dem_stitch(c, c) + assert len(ab.fault_priors) == 6 + assert all(abs(p - px) < 1e-12 for p in ab.fault_priors) + + +def test_dem_stitch_all_empty_raises(): + with pytest.raises(Exception): + qec.dem_stitch_all([]) + + +def test_dem_stitch_all_single_chunk_identity(): + c = _rep3_dem_chunk() + result = qec.dem_stitch_all([c]) + assert result.num_interior() == c.num_interior() + assert result.num_faults() == c.num_faults() + + +def test_dem_stitch_all_three_chunks_interior(): + c = _rep3_dem_chunk() + abc = qec.dem_stitch_all([c, c, c]) + # 2 seams × 2 rows each = 4 interior rows + assert abc.num_interior() == 4 + assert abc.num_faults() == 9 # 3 rounds × 3 qubits + + +def test_dem_close_single_chunk_shape(): + # A single rep-3 chunk has d=2 seam rows and 3 fault columns. + # dem_close collapses in_syndrome rows (the seam) into the flat DEM, so + # the result must have exactly d=2 detectors and 3 error mechanisms. + px = 0.01 + chunk = _rep3_dem_chunk(px) + closed = qec.dem_close(chunk) + d = chunk.num_seam_rows() + assert closed.num_detectors() == d + assert closed.num_error_mechanisms() == chunk.num_faults() + assert np.allclose(closed.error_rates, chunk.fault_priors) + + +def test_dem_close_all_matches_close_stitch_all(): + # dem_close_all must produce the same DEM as dem_close(dem_stitch_all(...)). + T = 4 + chunks = [_rep3_dem_chunk() for _ in range(T)] + via_stitch = qec.dem_close(qec.dem_stitch_all(chunks)) + via_close_all = qec.dem_close_all(chunks) + assert via_close_all.num_detectors() == via_stitch.num_detectors() + assert via_close_all.num_error_mechanisms() == \ + via_stitch.num_error_mechanisms() + assert np.array_equal(np.asarray(via_close_all.detector_error_matrix), + np.asarray(via_stitch.detector_error_matrix)) + assert np.array_equal(np.asarray(via_close_all.observables_flips_matrix), + np.asarray(via_stitch.observables_flips_matrix)) + assert np.allclose(via_close_all.error_rates, via_stitch.error_rates) + + +def test_dem_close_all_empty_raises(): + with pytest.raises(Exception): + qec.dem_close_all([]) + + +def test_dem_chunk_rounds_single(): + c = _rep3_dem_chunk() + assert qec.dem_chunk_rounds(c) == 1 + + +def test_dem_chunk_rounds_stitched(): + c = _rep3_dem_chunk() + ab = qec.dem_stitch(c, c) + assert qec.dem_chunk_rounds(ab) == 2 + + +def test_dem_chunks_to_rounds(): + T = 5 + chunks = [_rep3_dem_chunk() for _ in range(T)] + assert qec.dem_chunks_to_rounds(chunks) == T + + +def test_dem_chunks_to_detector_round_length_and_values(): + # T chunks of d=2 seam rows → T*d detector entries; entry i belongs to + # round i//d. + T = 3 + d = 2 + chunks = [_rep3_dem_chunk() for _ in range(T)] + rounds = qec.dem_chunks_to_detector_round(chunks) + assert len(rounds) == T * d + for i, r in enumerate(rounds): + assert r == i // d, f"detector {i}: expected round {i // d}, got {r}" + + +def test_dem_chunks_to_d_sparse_length(): + T = 3 + d = 2 + chunks = [_rep3_dem_chunk() for _ in range(T)] + d_sparse = qec.dem_chunks_to_d_sparse(chunks) + # One entry per detector + assert len(d_sparse) == T * d + + +def test_dem_chunks_to_o_sparse_length(): + T = 3 + chunks = [_rep3_dem_chunk() for _ in range(T)] + o_sparse = qec.dem_chunks_to_o_sparse(chunks) + # One entry per observable (rep-3 has 1) + assert len(o_sparse) == chunks[0].num_observables() + + +# --------------------------------------------------------------------------- +# Declarative phase specs +# --------------------------------------------------------------------------- + +REP5_CHECKS = 4 + + +def _rep5_spec(): + """A d=5 repetition code as init / bulk / final phases. + + Fault columns 0..4 are data qubits and 5..8 measurement errors; the final + phase measures destructively so it has only the five data columns. + """ + spec = qec.DemChunksSpec() + + spec.init.num_faults = 9 + spec.init.H_mid_sparse = [ + 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 + ] + spec.init.H_out_sparse = [5, -1, 6, -1, 7, -1, 8, -1] + spec.init.O_sparse = [0, -1] + spec.init.error_rates = [0.02] * 9 + + spec.bulk.num_faults = 9 + spec.bulk.H_in_sparse = [0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1] + spec.bulk.H_out_sparse = [5, -1, 6, -1, 7, -1, 8, -1] + spec.bulk.O_sparse = [0, -1] + spec.bulk.error_rates = [0.02] * 9 + + spec.final.num_faults = 5 + spec.final.H_in_sparse = [0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1] + spec.final.O_sparse = [0, -1] + spec.final.error_rates = [0.02] * 5 + + return spec + + +def test_dem_chunks_spec_validates(): + spec = _rep5_spec() + spec.validate() + assert spec.has_bulk() + assert not spec.is_empty() + assert qec.DemChunksSpec().is_empty() + + +def test_dem_chunk_spec_round_trips_through_attributes(): + spec = _rep5_spec() + assert spec.init.num_faults == 9 + assert spec.final.num_faults == 5 + # H_in_sparse is what makes a phase an init phase, so it has to stay empty. + assert spec.init.H_in_sparse == [] + assert spec.final.H_out_sparse == [] + + +def test_dem_chunks_from_spec_expands_to_round_count(): + spec = _rep5_spec() + for rounds in range(2, 7): + chunks = qec.dem_chunks_from_spec(spec, rounds) + assert len(chunks) == rounds + # The ends are open: nothing precedes the first round or follows the + # last, so those seams are empty. + assert chunks[0].num_in_seam_rows() == 0 + assert chunks[-1].num_out_seam_rows() == 0 + assert qec.dem_chunks_to_rounds(chunks) == rounds + + +def test_dem_chunks_from_spec_closes_to_one_detector_band_per_round(): + spec = _rep5_spec() + for rounds in range(2, 7): + chunks = qec.dem_chunks_from_spec(spec, rounds) + dem = qec.dem_close_all(chunks) + assert dem.detector_error_matrix.shape[0] == rounds * REP5_CHECKS + assert len(qec.dem_chunks_to_d_sparse(chunks)) == rounds * REP5_CHECKS + + +def test_dem_chunk_from_spec_matches_the_phase_it_describes(): + spec = _rep5_spec() + init = qec.dem_chunk_from_spec(spec.init, "init") + assert init.num_faults() == 9 + assert init.num_in_seam_rows() == 0 + assert init.num_out_seam_rows() == REP5_CHECKS + # init carries round 0 in its interior, having nothing to compare against. + assert init.num_interior() == REP5_CHECKS + assert qec.dem_chunk_rounds(init) == 1 + + +def test_dem_chunks_spec_rejects_an_init_with_an_incoming_seam(): + spec = _rep5_spec() + spec.init.H_in_sparse = [0, -1, 1, -1, 2, -1, 3, -1] + with pytest.raises(Exception): + spec.validate() + + +def test_dem_chunks_spec_rejects_mismatched_error_rate_count(): + spec = _rep5_spec() + spec.init.error_rates = [0.02] * 8 + with pytest.raises(Exception): + spec.validate() + + +def test_dem_chunks_from_spec_needs_a_bulk_phase_to_repeat(): + spec = _rep5_spec() + spec.bulk = qec.DemChunkSpec() + assert not spec.has_bulk() + # Two rounds are just init and final, so no bulk is needed. + assert len(qec.dem_chunks_from_spec(spec, 2)) == 2 + with pytest.raises(Exception): + qec.dem_chunks_from_spec(spec, 3) + + if __name__ == "__main__": pytest.main() diff --git a/libs/qec/unittests/CMakeLists.txt b/libs/qec/unittests/CMakeLists.txt index 407996e35..e30e1ee30 100644 --- a/libs/qec/unittests/CMakeLists.txt +++ b/libs/qec/unittests/CMakeLists.txt @@ -87,6 +87,25 @@ target_link_libraries(test_sparse_binary_matrix PRIVATE GTest::gtest_main add_dependencies(CUDAQXQECUnitTests test_sparse_binary_matrix) gtest_discover_tests(test_sparse_binary_matrix) +add_executable(test_extended_dem test_extended_dem.cpp) +target_link_libraries(test_extended_dem PRIVATE GTest::gtest_main + cudaq-qec-decoders) +add_dependencies(CUDAQXQECUnitTests test_extended_dem) +gtest_discover_tests(test_extended_dem) + +add_executable(test_dem_construction test_dem_construction.cpp) +target_link_libraries(test_dem_construction PRIVATE GTest::gtest_main + cudaq-qec-decoders) +add_dependencies(CUDAQXQECUnitTests test_dem_construction) +gtest_discover_tests(test_dem_construction) + +add_executable(test_dem_construction_code test_dem_construction_code.cpp) +target_link_libraries(test_dem_construction_code PRIVATE GTest::gtest_main + cudaq-qec + cudaq::cudaq-stim-target) +add_dependencies(CUDAQXQECUnitTests test_dem_construction_code) +gtest_discover_tests(test_dem_construction_code) + add_executable(test_logger test_logger.cpp) target_link_libraries(test_logger PRIVATE GTest::gtest_main cudaq-qec-decoders) add_dependencies(CUDAQXQECUnitTests test_logger) diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index cc099a859..ddb16cbfa 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -1310,3 +1310,648 @@ TEST(DecoderYAMLTest, NonSchemaKeysDroppedFromDecoderParamsAndEmission) { // The stored args are untouched -- only the derived views are filtered. EXPECT_TRUE(config.decoder_custom_args.map().contains("not_a_real_param")); } + +// --------------------------------------------------------------------------- +// dem_chunks: per-phase DEM for a repeated-round decomposition +// --------------------------------------------------------------------------- + +namespace { + +// A d=5 repetition code split into init / bulk / final phases. Fault columns +// 0..4 are the data qubits and 5..8 the measurement errors, so the final phase +// has only five: its destructive data readout has no measurement error. +std::string dem_chunks_yaml(const std::string &init_h_in = "[ ]", + const std::string &final_h_out = "[ ]") { + return R"( +decoders: + - id: 0 + type: multi_error_lut + block_size: 3 + syndrome_size: 3 + H_sparse: [0, -1, 1, -1, 2, -1] + O_sparse: [0, -1, 1, -1, 2, -1] + D_sparse: [0, -1, 1, -1, 2, -1] + dem_chunks: + init: + num_faults: 9 + H_in_sparse: )" + + init_h_in + R"( + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + bulk: + num_faults: 9 + H_in_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_mid_sparse: [ ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + H_mid_sparse: [ ] + H_out_sparse: )" + + final_h_out + R"( + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] +)"; +} + +} // namespace + +TEST(DecoderDemChunksYAMLTest, ParsesAllThreePhases) { + const auto config = + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + dem_chunks_yaml()); + ASSERT_EQ(config.decoders.size(), 1u); + const auto &chunks = config.decoders[0].dem_chunks; + ASSERT_TRUE(chunks.has_value()); + EXPECT_TRUE(chunks->has_bulk()); + + EXPECT_EQ(chunks->init.num_faults, 9u); + EXPECT_TRUE(chunks->init.H_in_sparse.empty()); + EXPECT_EQ(chunks->init.H_mid_sparse.size(), 16u); + EXPECT_EQ(chunks->init.error_rates.size(), 9u); + + EXPECT_EQ(chunks->bulk.num_faults, 9u); + EXPECT_TRUE(chunks->bulk.H_mid_sparse.empty()); + + EXPECT_EQ(chunks->final.num_faults, 5u); + EXPECT_TRUE(chunks->final.H_out_sparse.empty()); + EXPECT_EQ(chunks->final.error_rates.size(), 5u); +} + +// The parsed phases expand into the chunk sequence a streaming decoder wants, +// and closing that sequence yields the flat DEM of the whole experiment. +TEST(DecoderDemChunksYAMLTest, ParsedPhasesExpandAndClose) { + const auto config = + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + dem_chunks_yaml()); + const auto &chunks = *config.decoders[0].dem_chunks; + + const auto expanded = cudaq::qec::dem_chunks_from_spec(chunks, 5); + ASSERT_EQ(expanded.size(), 5u); + EXPECT_EQ(expanded.front().num_in_seam_rows(), 0u); + EXPECT_EQ(expanded.back().num_out_seam_rows(), 0u); + + const auto flat = cudaq::qec::dem_close(cudaq::qec::dem_stitch_all(expanded)); + // Four checks per round over five rounds; 9 faults per round except the + // five-fault final readout. + EXPECT_EQ(flat.detector_error_matrix.shape()[0], 20u); + EXPECT_EQ(flat.detector_error_matrix.shape()[1], 4u * 9u + 5u); + EXPECT_EQ(flat.observables_flips_matrix.shape()[0], 1u); +} + +TEST(DecoderDemChunksYAMLTest, RoundTripsThroughEmission) { + auto config = + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + dem_chunks_yaml()); + const auto emitted = config.to_yaml_str(200); + EXPECT_NE(emitted.find("dem_chunks"), std::string::npos); + EXPECT_NE(emitted.find("H_mid_sparse"), std::string::npos); + + auto round_tripped = + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + emitted); + EXPECT_EQ(round_tripped.decoders[0].dem_chunks, + config.decoders[0].dem_chunks); + EXPECT_EQ(round_tripped.to_yaml_str(200), emitted); +} + +// The section is optional: every existing configuration still parses and emits +// nothing extra. +TEST(DecoderDemChunksYAMLTest, SectionIsOptional) { + const std::string yaml = R"( +decoders: + - id: 0 + type: multi_error_lut + block_size: 3 + syndrome_size: 3 + H_sparse: [0, -1, 1, -1, 2, -1] + O_sparse: [0, -1, 1, -1, 2, -1] + D_sparse: [0, -1, 1, -1, 2, -1] +)"; + auto config = + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml); + EXPECT_FALSE(config.decoders[0].dem_chunks.has_value()); + EXPECT_EQ(config.to_yaml_str(200).find("dem_chunks"), std::string::npos); +} + +// A two-round decomposition omits bulk entirely. +TEST(DecoderDemChunksYAMLTest, BulkPhaseIsOptional) { + const std::string yaml = R"( +decoders: + - id: 0 + type: multi_error_lut + block_size: 3 + syndrome_size: 3 + H_sparse: [0, -1, 1, -1, 2, -1] + O_sparse: [0, -1, 1, -1, 2, -1] + D_sparse: [0, -1, 1, -1, 2, -1] + dem_chunks: + init: + num_faults: 9 + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] +)"; + auto config = + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml); + const auto &chunks = *config.decoders[0].dem_chunks; + EXPECT_FALSE(chunks.has_bulk()); + EXPECT_EQ(cudaq::qec::dem_chunks_from_spec(chunks, 2).size(), 2u); +} + +// Cross-phase validation runs during the parse, so a malformed decomposition is +// rejected at configuration time rather than at the first decode. +TEST(DecoderDemChunksYAMLTest, RejectsInitWithIncomingSeam) { + EXPECT_THROW( + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + dem_chunks_yaml("[ 0, -1, 1, -1, 2, -1, 3, -1 ]")), + std::runtime_error); +} + +TEST(DecoderDemChunksYAMLTest, RejectsFinalWithOutgoingSeam) { + EXPECT_THROW( + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + dem_chunks_yaml("[ ]", "[ 0, -1, 1, -1, 2, -1, 3, -1 ]")), + std::runtime_error); +} + +TEST(DecoderDemChunksYAMLTest, RejectsUnknownPhaseKey) { + const std::string yaml = R"( +decoders: + - id: 0 + type: multi_error_lut + block_size: 3 + syndrome_size: 3 + H_sparse: [0, -1, 1, -1, 2, -1] + O_sparse: [0, -1, 1, -1, 2, -1] + D_sparse: [0, -1, 1, -1, 2, -1] + dem_chunks: + init: + num_faults: 9 + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + H_side_sparse: [ 0, -1 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] +)"; + EXPECT_THROW( + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml), + std::runtime_error); +} + +// --------------------------------------------------------------------------- +// Chunk form: dem_chunks + num_rounds in place of the flat matrices +// --------------------------------------------------------------------------- + +namespace { + +// The same d=5 repetition code, written only as phases. Everything the flat +// form would spell out -- block_size, syndrome_size, H/O/D_sparse -- follows +// from expanding these num_rounds times. +std::string chunk_form_yaml(unsigned num_rounds, + const std::string &decoder_type = "sample_decoder", + const std::string &extra_keys = "") { + return R"( +decoders: + - id: 3 + type: )" + + decoder_type + + R"( + num_rounds: )" + + std::to_string(num_rounds) + R"( +)" + extra_keys + + R"( dem_chunks: + init: + num_faults: 9 + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + bulk: + num_faults: 9 + H_in_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] +)"; +} + +cudaq::qec::decoding::config::decoder_config +parse_one(const std::string &yaml) { + auto config = + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml); + EXPECT_EQ(config.decoders.size(), 1u); + return config.decoders.at(0); +} + +} // namespace + +TEST(DecoderChunkFormTest, ParsesWithNoFlatMatrices) { + const auto config = parse_one(chunk_form_yaml(5)); + EXPECT_TRUE(config.H_sparse.empty()); + EXPECT_TRUE(config.O_sparse.empty()); + EXPECT_TRUE(config.D_sparse.empty()); + EXPECT_EQ(config.block_size, 0u); + EXPECT_EQ(config.syndrome_size, 0u); + ASSERT_TRUE(config.dem_chunks.has_value()); + ASSERT_TRUE(config.num_rounds.has_value()); + EXPECT_EQ(*config.num_rounds, 5u); +} + +// Expansion produces a configuration that is internally consistent by the same +// rules a hand-written flat config has to satisfy. +TEST(DecoderChunkFormTest, ExpandsToASelfConsistentFlatConfig) { + auto config = parse_one(chunk_form_yaml(5)); + const auto closed = cudaq::qec::decoding::config::expand_dem_chunks(config); + + ASSERT_TRUE(closed.has_value()); + EXPECT_EQ(closed->error_rates.size(), config.block_size) + << "one prior per fault"; + EXPECT_GT(config.block_size, 0u); + EXPECT_GT(config.syndrome_size, 0u); + + const auto count_rows = [](const std::vector &sparse) { + return static_cast( + std::count(sparse.begin(), sparse.end(), -1)); + }; + EXPECT_EQ(count_rows(config.H_sparse), config.syndrome_size); + EXPECT_EQ(count_rows(config.D_sparse), config.syndrome_size); + EXPECT_EQ(count_rows(config.O_sparse), 1u) << "one logical observable"; + + // 41 faults: 9 for init, 9 per bulk round, 5 for the final destructive + // readout, which has no measurement error. + EXPECT_EQ(config.block_size, 41u); + + // The expanded config is flat, so re-expanding is a no-op. + EXPECT_FALSE( + cudaq::qec::decoding::config::expand_dem_chunks(config).has_value()); +} + +// The whole point of the chunk form: one description, many experiment lengths. +TEST(DecoderChunkFormTest, SameChunksServeDifferentRoundCounts) { + auto three = parse_one(chunk_form_yaml(3)); + auto five = parse_one(chunk_form_yaml(5)); + EXPECT_EQ(three.dem_chunks, five.dem_chunks); + + cudaq::qec::decoding::config::expand_dem_chunks(three); + cudaq::qec::decoding::config::expand_dem_chunks(five); + EXPECT_LT(three.syndrome_size, five.syndrome_size); + EXPECT_EQ(three.block_size, 23u) << "9 + 9 + 5"; + EXPECT_EQ(five.block_size, 41u) << "9 + 9 + 9 + 9 + 5"; +} + +// End to end: a config with no flat matrices at all yields a live decoder whose +// measurement capacity matches the rounds it was expanded to (4 syndromes per +// round for this d=5 code). +TEST(DecoderChunkFormTest, BuildsARealtimeDecoder) { + const auto config = parse_one(chunk_form_yaml(5)); + auto decoder = cudaq::qec::decoding::host::create_realtime_decoder(config); + + ASSERT_NE(decoder, nullptr); + EXPECT_EQ(decoder->get_decoder_id(), 3u); + EXPECT_EQ(decoder->get_num_observables(), 1u); + EXPECT_EQ(decoder->get_num_msyn_per_decode(), 20u) << "5 rounds x 4"; + EXPECT_EQ(decoder->get_block_size(), 41u); +} + +TEST(DecoderChunkFormTest, RoundCountFlowsThroughToTheDecoder) { + const auto three = parse_one(chunk_form_yaml(3)); + auto decoder = cudaq::qec::decoding::host::create_realtime_decoder(three); + EXPECT_EQ(decoder->get_num_msyn_per_decode(), 12u) << "3 rounds x 4"; +} + +// The session registry is the server's own entry point, so exercise it rather +// than only the decoder factory underneath it. +TEST(DecoderChunkFormTest, LoadsThroughTheSessionRegistry) { + cudaq::qec::decoding::config::multi_decoder_config config; + config.decoders.push_back(parse_one(chunk_form_yaml(5))); + + cudaq::qec::decoding_server::SessionRegistry registry; + registry.load_from_config(config, "unit test"); + + const auto &decoder = registry.get(3).dec; + ASSERT_NE(decoder, nullptr); + EXPECT_EQ(decoder->get_num_msyn_per_decode(), 20u); +} + +TEST(DecoderChunkFormTest, NumRoundsIsRequired) { + const std::string yaml = R"( +decoders: + - id: 3 + type: sample_decoder + dem_chunks: + init: + num_faults: 9 + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] +)"; + try { + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml); + FAIL() << "expected a missing-num_rounds failure"; + } catch (const std::runtime_error &error) { + EXPECT_NE(std::string(error.what()).find("num_rounds"), std::string::npos) + << error.what(); + } +} + +TEST(DecoderChunkFormTest, NumRoundsIsRejectedWithoutChunks) { + const std::string yaml = R"( +decoders: + - id: 0 + type: sample_decoder + block_size: 3 + syndrome_size: 3 + num_rounds: 4 + H_sparse: [0, -1, 1, -1, 2, -1] + O_sparse: [0, -1, 1, -1, 2, -1] + D_sparse: [0, -1, 1, -1, 2, -1] +)"; + try { + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml); + FAIL() << "expected num_rounds to be rejected without dem_chunks"; + } catch (const std::runtime_error &error) { + EXPECT_NE(std::string(error.what()).find("num_rounds"), std::string::npos) + << error.what(); + } +} + +// Spelling out a derived field alongside dem_chunks is rejected rather than +// silently overwritten, so a config can never disagree with its own phases. +TEST(DecoderChunkFormTest, DerivedFieldsAreRejected) { + for (const std::string derived : + {" syndrome_size: 3\n", " block_size: 41\n", + " O_sparse: [0, -1]\n", " D_sparse: [0, -1]\n"}) { + try { + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + chunk_form_yaml(5, "sample_decoder", derived)); + ADD_FAILURE() << "expected rejection of " << derived; + } catch (const std::runtime_error &error) { + const std::string message = error.what(); + EXPECT_NE(message.find("derived"), std::string::npos) << message; + } + } +} + +TEST(DecoderChunkFormTest, RejectsARoundCountThePhasesCannotServe) { + // Only init and final are defined below, so there is no bulk phase to repeat + // for the three interior rounds a five-round expansion needs. + const std::string yaml = R"( +decoders: + - id: 3 + type: sample_decoder + num_rounds: 5 + dem_chunks: + init: + num_faults: 9 + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] +)"; + auto config = parse_one(yaml); + EXPECT_THROW(cudaq::qec::decoding::config::expand_dem_chunks(config), + std::runtime_error); +} + +// A config that carries both forms is flat -- the explicit matrix wins -- which +// is also the state expand_dem_chunks() leaves behind, so it must re-parse. +TEST(DecoderChunkFormTest, ExpandedConfigIsItselfAValidFlatConfig) { + auto config = parse_one(chunk_form_yaml(5)); + cudaq::qec::decoding::config::expand_dem_chunks(config); + + cudaq::qec::decoding::config::multi_decoder_config wrapper; + wrapper.decoders.push_back(config); + const auto emitted = wrapper.to_yaml_str(200); + EXPECT_NE(emitted.find("H_sparse"), std::string::npos); + EXPECT_NE(emitted.find("dem_chunks"), std::string::npos); + + const auto reparsed = parse_one(emitted); + EXPECT_EQ(reparsed.H_sparse, config.H_sparse); + EXPECT_EQ(reparsed.syndrome_size, config.syndrome_size); +} + +// Without dem_chunks there is nothing else describing the DEM, so the omission +// is reported as a missing key rather than as a row-count mismatch. +TEST(DecoderChunkFormTest, HSparseStillRequiredWithoutChunks) { + const std::string yaml = R"( +decoders: + - id: 7 + type: multi_error_lut + block_size: 3 + syndrome_size: 3 + O_sparse: [0, -1, 1, -1, 2, -1] + D_sparse: [0, -1, 1, -1, 2, -1] +)"; + try { + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml); + FAIL() << "expected a missing-H_sparse failure"; + } catch (const std::runtime_error &error) { + const std::string message = error.what(); + EXPECT_NE(message.find("H_sparse"), std::string::npos) << message; + EXPECT_NE(message.find("dem_chunks"), std::string::npos) << message; + } +} + +// dem_chunks made the flat fields optional in the mapping, which must not turn +// into accepting a flat config that only names some of them: the omission has +// to be reported while the document is read, not when a decoder is eventually +// built from a zero-sized DEM. +TEST(DecoderChunkFormTest, IncompleteFlatConfigIsRejectedAtParse) { + // Each entry drops exactly one field from an otherwise complete flat config. + const std::vector> omissions{ + {"block_size", " syndrome_size: 3\n H_sparse: [0, -1, 1, -1, 2, " + "-1]\n O_sparse: [0, -1]\n D_sparse: [0, -1, 1, -1, " + "2, -1]\n"}, + {"syndrome_size", " block_size: 3\n H_sparse: [0, -1, 1, -1, 2, " + "-1]\n O_sparse: [0, -1]\n D_sparse: [0, -1, 1, " + "-1, 2, -1]\n"}, + {"O_sparse", " block_size: 3\n syndrome_size: 3\n H_sparse: [0, " + "-1, 1, -1, 2, -1]\n D_sparse: [0, -1, 1, -1, 2, -1]\n"}, + {"D_sparse", " block_size: 3\n syndrome_size: 3\n H_sparse: [0, " + "-1, 1, -1, 2, -1]\n O_sparse: [0, -1]\n"}, + }; + + for (const auto &[omitted, body] : omissions) { + const std::string yaml = + "decoders:\n - id: 0\n type: multi_error_lut\n" + body; + try { + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml); + ADD_FAILURE() << "expected rejection when " << omitted << " is omitted"; + } catch (const std::runtime_error &error) { + const std::string message = error.what(); + EXPECT_NE(message.find(omitted), std::string::npos) << message; + } + } // end - for(omissions) +} + +// The degenerate document that describes no DEM at all: before dem_chunks the +// required-key check rejected it, and it must still be rejected rather than +// parsing into an empty decoder. +TEST(DecoderChunkFormTest, ConfigWithNeitherFormIsRejectedAtParse) { + const std::string yaml = R"( +decoders: + - id: 0 + type: multi_error_lut +)"; + EXPECT_THROW( + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str(yaml), + std::runtime_error); +} + +// num_rounds counts init and final too, so 2 is the smallest expansion there +// is. Rejecting 1 at parse keeps the failure next to the mistake instead of +// deferring it to expand_dem_chunks() at decoder construction. +TEST(DecoderChunkFormTest, RejectsNumRoundsBelowTwoAtParse) { + for (const unsigned rounds : {0u, 1u}) { + try { + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + chunk_form_yaml(rounds)); + ADD_FAILURE() << "expected rejection of num_rounds: " << rounds; + } catch (const std::runtime_error &error) { + const std::string message = error.what(); + EXPECT_NE(message.find("num_rounds"), std::string::npos) << message; + } + } + // Two rounds is init + final with no bulk copies, and is accepted. + EXPECT_EQ(*parse_one(chunk_form_yaml(2)).num_rounds, 2u); +} + +// The schema has to agree with the parser about the minimum, or a document +// that passes offline validation still fails to load. +TEST(DecoderChunkFormTest, JsonSchemaNumRoundsMinimumMatchesTheParser) { + const auto schema = + cudaq::qec::decoding::config::decoder_config_json_schema(); + const auto at = schema.find("\"num_rounds\""); + ASSERT_NE(at, std::string::npos); + EXPECT_NE(schema.find("\"minimum\": 2", at), std::string::npos) + << schema.substr(at, 200); +} + +// Chunk form with no flat fields is the form users actually write, so it has to +// survive a trip through emission and back unchanged. +TEST(DecoderChunkFormTest, PureChunkFormRoundTripsThroughEmission) { + const auto config = parse_one(chunk_form_yaml(5)); + + cudaq::qec::decoding::config::multi_decoder_config wrapper; + wrapper.decoders.push_back(config); + const auto emitted = wrapper.to_yaml_str(200); + EXPECT_NE(emitted.find("dem_chunks"), std::string::npos) << emitted; + + // Still chunk form after the trip: emission must not have materialized the + // derived fields, which re-parsing would reject as a config disagreeing + // with its own phases. + const auto reparsed = parse_one(emitted); + EXPECT_EQ(reparsed.dem_chunks, config.dem_chunks); + EXPECT_EQ(reparsed.num_rounds, config.num_rounds); + EXPECT_TRUE(reparsed.H_sparse.empty()); +} + +// A config carrying both forms is flat, and "flat wins" has to mean the +// decoder is built from the explicit matrix -- not from an expansion that +// silently disagrees with it. +TEST(DecoderChunkFormTest, FlatMatrixWinsOverChunksThatDisagree) { + auto config = parse_one(chunk_form_yaml(5)); + // A flat DEM deliberately unlike anything the phases expand to. + config.block_size = 3; + config.syndrome_size = 2; + config.H_sparse = {0, -1, 1, -1}; + config.O_sparse = {0, -1}; + config.D_sparse = {0, -1, 1, -1}; + const auto flat_H = config.H_sparse; + + const auto closed = cudaq::qec::decoding::config::expand_dem_chunks(config); + EXPECT_FALSE(closed.has_value()) << "an already-flat config must not expand"; + EXPECT_EQ(config.H_sparse, flat_H); + EXPECT_EQ(config.syndrome_size, 2u); +} + +// A config carrying both keeps validating H_sparse against syndrome_size: the +// relaxation applies only when H_sparse is genuinely absent. +TEST(DecoderChunkFormTest, PresentHSparseIsStillValidatedAlongsideChunks) { + const auto good = + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + dem_chunks_yaml()); + EXPECT_EQ(good.decoders[0].H_sparse.size(), 6u); + EXPECT_TRUE(good.decoders[0].dem_chunks.has_value()); + + // Two H rows against syndrome_size 3 is still an error, dem_chunks or not. + const std::string wrong_rows = R"( +decoders: + - id: 0 + type: multi_error_lut + block_size: 3 + syndrome_size: 3 + H_sparse: [0, -1, 1, -1] + O_sparse: [0, -1, 1, -1, 2, -1] + D_sparse: [0, -1, 1, -1, 2, -1] + dem_chunks: + init: + num_faults: 9 + H_mid_sparse: [ 0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1 ] + H_out_sparse: [ 5, -1, 6, -1, 7, -1, 8, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02 ] + final: + num_faults: 5 + H_in_sparse: [ 0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1 ] + O_sparse: [ 0, -1 ] + error_rates: [ 0.02, 0.02, 0.02, 0.02, 0.02 ] +)"; + EXPECT_THROW( + cudaq::qec::decoding::config::multi_decoder_config::from_yaml_str( + wrong_rows), + std::runtime_error); +} + +// The generated schema accepts either form and requires num_rounds with the +// chunk one. +TEST(DecoderChunkFormTest, JsonSchemaDescribesBothForms) { + const auto schema = + cudaq::qec::decoding::config::decoder_config_json_schema(); + EXPECT_NE(schema.find("anyOf"), std::string::npos); + EXPECT_NE(schema.find("\"num_rounds\""), std::string::npos); + EXPECT_NE(schema.find("\"H_sparse\""), std::string::npos); + EXPECT_NE(schema.find("\"dem_chunks\""), std::string::npos); +} + +// The generated JSON Schema advertises the section so offline validators accept +// configurations that use it. +TEST(DecoderDemChunksYAMLTest, AppearsInGeneratedJsonSchema) { + const auto schema = + cudaq::qec::decoding::config::decoder_config_json_schema(); + EXPECT_NE(schema.find("dem_chunks"), std::string::npos); + EXPECT_NE(schema.find("H_mid_sparse"), std::string::npos); + EXPECT_NE(schema.find("error_rates"), std::string::npos); +} diff --git a/libs/qec/unittests/test_dem_construction.cpp b/libs/qec/unittests/test_dem_construction.cpp new file mode 100644 index 000000000..8f52bbcf3 --- /dev/null +++ b/libs/qec/unittests/test_dem_construction.cpp @@ -0,0 +1,648 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// Unit tests for dem_from_css_matrices(). Tests cover: +// - num_rounds=1 (default): repetition code X errors against manually +// computed expected values and against dem_from_stim_text(). +// - CSS code with both X and Z errors (two detector bands, two obs bands). +// - Y errors triggering both detector bands. +// - Per-qubit noise rates including zero-rate qubit suppression. +// - Edge cases: all-zero rates, default-constructed matrices, mismatches. +// - Multi-round (num_rounds>1): detector-difference structure, bulk vs +// last-round boundary, observable propagation across rounds, Stim +// cross-check for T=2. + +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/dem_construction.h" +#include "cudaq/qec/detector_error_model.h" +#include +#include +#include +#include + +namespace cudaq::qec { +namespace { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Return true iff the two dense uint8 tensors have identical shape and +// values. Assumes rank-2 tensors. +bool tensors_equal(const cudaqx::tensor &a, + const cudaqx::tensor &b) { + if (a.rank() != 2 || b.rank() != 2) + return false; + if (a.shape()[0] != b.shape()[0] || a.shape()[1] != b.shape()[1]) + return false; + for (std::size_t r = 0; r < a.shape()[0]; ++r) { + for (std::size_t c = 0; c < a.shape()[1]; ++c) { + if (a.at({r, c}) != b.at({r, c})) + return false; + } + } + return true; +} + +// Build css_code_matrices for the d=3 repetition code (Z-basis only). +// H_Z = [[1,1,0],[0,1,1]] (Z0Z1, Z1Z2 stabilizers) +// H_X = empty +// L_Z = [[1,0,0]] (Z0 logical) +// L_X = empty +css_code_matrices rep3_z_basis_matrices() { + css_code_matrices m; + m.hz = sparse_binary_matrix::from_nested_csc(2, 3, {{0}, {0, 1}, {1}}); + m.lz = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {}, {}}); + return m; +} + +// --------------------------------------------------------------------------- +// Repetition code d=3, X errors only +// --------------------------------------------------------------------------- + +// Expected detector_error_matrix for d=3, px=0.01 (no Z/Y errors): +// +// col 0 1 2 (X fault on qubit 0, 1, 2) +// D0 [ 1 1 0 ] (Z0Z1 stabilizer) +// D1 [ 0 1 1 ] (Z1Z2 stabilizer) +TEST(DemConstruction, RepetitionCode3_XOnly_DetectorMatrix) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise); + + ASSERT_EQ(dem.num_detectors(), 2u); + ASSERT_EQ(dem.num_error_mechanisms(), 3u); + + // Column 0: X on qubit 0 → D0 + EXPECT_EQ(dem.detector_error_matrix.at({0, 0}), 1u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 0}), 0u); + + // Column 1: X on qubit 1 → D0, D1 + EXPECT_EQ(dem.detector_error_matrix.at({0, 1}), 1u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 1}), 1u); + + // Column 2: X on qubit 2 → D1 + EXPECT_EQ(dem.detector_error_matrix.at({0, 2}), 0u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 2}), 1u); +} + +// Expected observables_flips_matrix for d=3, px=0.01: +// +// col 0 1 2 +// L0 [ 1 0 0 ] (Z0 logical flipped by X on qubit 0 only) +TEST(DemConstruction, RepetitionCode3_XOnly_ObservableMatrix) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise); + + ASSERT_EQ(dem.num_observables(), 1u); + EXPECT_EQ(dem.observables_flips_matrix.at({0, 0}), 1u); + EXPECT_EQ(dem.observables_flips_matrix.at({0, 1}), 0u); + EXPECT_EQ(dem.observables_flips_matrix.at({0, 2}), 0u); +} + +TEST(DemConstruction, RepetitionCode3_XOnly_ErrorRates) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise); + + ASSERT_EQ(dem.error_rates.size(), 3u); + EXPECT_DOUBLE_EQ(dem.error_rates[0], 0.01); + EXPECT_DOUBLE_EQ(dem.error_rates[1], 0.01); + EXPECT_DOUBLE_EQ(dem.error_rates[2], 0.01); +} + +// Cross-check: the DEM produced by dem_from_css_matrices() must match +// the one produced by dem_from_stim_text() for the same scenario. +// Stim DEM (X errors, d=3 repetition code, p=0.01): +// X on q0: flips D0 and L0 +// X on q1: flips D0 and D1 +// X on q2: flips D1 +TEST(DemConstruction, RepetitionCode3_XOnly_MatchesStimText) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto from_matrices = dem_from_css_matrices(code, noise); + + const std::string stim_dem = "error(0.01) D0 L0\n" + "error(0.01) D0 D1\n" + "error(0.01) D1\n"; + auto from_stim = dem_from_stim_text(stim_dem); + + EXPECT_TRUE(tensors_equal(from_matrices.detector_error_matrix, + from_stim.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(from_matrices.observables_flips_matrix, + from_stim.observables_flips_matrix)); + ASSERT_EQ(from_matrices.error_rates.size(), from_stim.error_rates.size()); + for (std::size_t i = 0; i < from_matrices.error_rates.size(); ++i) + EXPECT_DOUBLE_EQ(from_matrices.error_rates[i], from_stim.error_rates[i]); +} + +// --------------------------------------------------------------------------- +// X and Z errors (repetition code with explicit Z-error columns) +// --------------------------------------------------------------------------- + +// Add a Z observable (X-type logical) to the repetition code so that Z +// errors also have an observable effect. Z errors have no syndrome (hx is +// empty) but qubit-0 Z errors flip the X logical L_X[0,0]=1. +// +// With px = pz = 0.01: +// Columns 0..2: X faults on q0,q1,q2 (same as X-only test) +// Columns 3..5: Z faults on q0,q1,q2 +// col 3: obs row 1 (X logical) flipped (Z on q0, L_X[0,0]=1) +// col 4: no effect (Z on q1, L_X[0,1]=0) +// col 5: no effect (Z on q2, L_X[0,2]=0) +TEST(DemConstruction, XAndZErrors_Dimensions) { + css_code_matrices code = rep3_z_basis_matrices(); + code.lx = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {}, {}}); + css_noise_params noise; + noise.px = 0.01; + noise.pz = 0.01; + + auto dem = dem_from_css_matrices(code, noise); + + // 2 Z detectors + 0 X detectors = 2 total detectors. + // 1 Z observable + 1 X observable = 2 total observables. + // 3 X columns + 3 Z columns = 6 total columns. + EXPECT_EQ(dem.num_detectors(), 2u); + EXPECT_EQ(dem.num_observables(), 2u); + EXPECT_EQ(dem.num_error_mechanisms(), 6u); +} + +TEST(DemConstruction, XAndZErrors_ZFaultColumns) { + css_code_matrices code = rep3_z_basis_matrices(); + code.lx = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {}, {}}); + css_noise_params noise; + noise.px = 0.01; + noise.pz = 0.01; + + auto dem = dem_from_css_matrices(code, noise); + + // Z fault columns start at index 3. + // col 3 (Z on q0): no detectors, obs row 1 (X logical) flipped. + EXPECT_EQ(dem.detector_error_matrix.at({0, 3}), 0u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 3}), 0u); + EXPECT_EQ(dem.observables_flips_matrix.at({0, 3}), 0u); // Z-obs unaffected + EXPECT_EQ(dem.observables_flips_matrix.at({1, 3}), 1u); // X-obs flipped + + // col 4 (Z on q1): no effect. + EXPECT_EQ(dem.observables_flips_matrix.at({1, 4}), 0u); + + // col 5 (Z on q2): no effect. + EXPECT_EQ(dem.observables_flips_matrix.at({1, 5}), 0u); +} + +// --------------------------------------------------------------------------- +// Y errors +// --------------------------------------------------------------------------- + +// Minimal CSS pair: 2 qubits, 1 Z-stabilizer (Z0Z1), 1 X-stabilizer (X0X1). +// hz = [[1,1]] → Z0Z1 detects X errors +// hx = [[1,1]] → X0X1 detects Z errors +// lz = [[1,0]] → Z logical = Z0 +// lx = [[1,0]] → X logical = X0 +// +// Y fault on qubit 0: X component flips D0 (Z-check) and L0 (Z-obs); +// Z component flips D1 (X-check) and L1 (X-obs). +TEST(DemConstruction, YErrors_BothBandsTriggered) { + css_code_matrices code; + code.hz = sparse_binary_matrix::from_nested_csc(1, 2, {{0}, {0}}); + code.hx = sparse_binary_matrix::from_nested_csc(1, 2, {{0}, {0}}); + code.lz = sparse_binary_matrix::from_nested_csc(1, 2, {{0}, {}}); + code.lx = sparse_binary_matrix::from_nested_csc(1, 2, {{0}, {}}); + css_noise_params noise; + noise.py = 0.005; + + auto dem = dem_from_css_matrices(code, noise); + + // 1 Z-det + 1 X-det = 2 detectors, 2 observables, 2 Y-fault columns. + EXPECT_EQ(dem.num_detectors(), 2u); + EXPECT_EQ(dem.num_observables(), 2u); + EXPECT_EQ(dem.num_error_mechanisms(), 2u); + EXPECT_DOUBLE_EQ(dem.error_rates[0], 0.005); + EXPECT_DOUBLE_EQ(dem.error_rates[1], 0.005); + + // Y on qubit 0 (col 0): det row 0 (Z-check) and det row 1 (X-check). + EXPECT_EQ(dem.detector_error_matrix.at({0, 0}), 1u); // Z-det from X comp + EXPECT_EQ(dem.detector_error_matrix.at({1, 0}), 1u); // X-det from Z comp + EXPECT_EQ(dem.observables_flips_matrix.at({0, 0}), 1u); // Z-obs + EXPECT_EQ(dem.observables_flips_matrix.at({1, 0}), 1u); // X-obs + + // Y on qubit 1 (col 1): both detectors triggered, no observable flip. + EXPECT_EQ(dem.detector_error_matrix.at({0, 1}), 1u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 1}), 1u); + EXPECT_EQ(dem.observables_flips_matrix.at({0, 1}), 0u); + EXPECT_EQ(dem.observables_flips_matrix.at({1, 1}), 0u); +} + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +TEST(DemConstruction, AllRatesZero_ReturnsEmptyDem) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; // px = py = pz = 0 + + auto dem = dem_from_css_matrices(code, noise); + + EXPECT_EQ(dem.num_error_mechanisms(), 0u); + EXPECT_TRUE(dem.error_rates.empty()); +} + +TEST(DemConstruction, AllMatricesDefault_ReturnsEmptyDem) { + css_code_matrices code; // all default-constructed (0x0) + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise); + + EXPECT_EQ(dem.num_error_mechanisms(), 0u); +} + +// A default-constructed hx (0 columns) must not cause bounds errors even +// when pz > 0, because the Z-fault columns will have no detector entries. +TEST(DemConstruction, DefaultHxLx_ZFaultColumnsAreEmpty) { + css_code_matrices code = rep3_z_basis_matrices(); + // hx and lx are default-constructed (0x0); Z errors are undetectable. + css_noise_params noise; + noise.pz = 0.01; + + auto dem = dem_from_css_matrices(code, noise); + + // 2 Z-detectors, 1 Z-observable, 3 Z-fault columns. + EXPECT_EQ(dem.num_detectors(), 2u); + EXPECT_EQ(dem.num_observables(), 1u); + EXPECT_EQ(dem.num_error_mechanisms(), 3u); + + // All Z-fault columns should be zero in detector matrix (no X stabilizers). + for (std::size_t col = 0; col < 3u; ++col) { + EXPECT_EQ(dem.detector_error_matrix.at({0, col}), 0u) << "col=" << col; + EXPECT_EQ(dem.detector_error_matrix.at({1, col}), 0u) << "col=" << col; + } +} + +TEST(DemConstruction, DimensionMismatch_Throws) { + css_code_matrices code; + // hz has 3 columns, hx has 4 columns → should throw. + code.hz = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {0}, {0}}); + code.hx = sparse_binary_matrix::from_nested_csc(1, 4, {{0}, {0}, {0}, {0}}); + css_noise_params noise; + noise.px = 0.01; + noise.pz = 0.01; + + EXPECT_THROW(dem_from_css_matrices(code, noise), std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// Per-qubit noise rates +// --------------------------------------------------------------------------- + +// d=3 repetition code, non-uniform X error rates: only qubit 0 and qubit 2 +// have nonzero rates. Qubit 1 (px=0) should emit no column. +TEST(DemConstruction, PerQubitRates_SkipsZeroRateQubits) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px_per_qubit = {0.01, 0.0, 0.02}; + + auto dem = dem_from_css_matrices(code, noise); + + // Only 2 columns: X on q0 and X on q2. + ASSERT_EQ(dem.num_error_mechanisms(), 2u); + EXPECT_DOUBLE_EQ(dem.error_rates[0], 0.01); + EXPECT_DOUBLE_EQ(dem.error_rates[1], 0.02); + + // col 0: X on q0 → D0, L0 + EXPECT_EQ(dem.detector_error_matrix.at({0, 0}), 1u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 0}), 0u); + EXPECT_EQ(dem.observables_flips_matrix.at({0, 0}), 1u); + + // col 1: X on q2 → D1 only (no observable flip) + EXPECT_EQ(dem.detector_error_matrix.at({0, 1}), 0u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 1}), 1u); + EXPECT_EQ(dem.observables_flips_matrix.at({0, 1}), 0u); +} + +// Verify that per-qubit rates override the uniform scalar. +TEST(DemConstruction, PerQubitRates_OverrideUniform) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; // uniform; ignored when per-qubit set + noise.px_per_qubit = {0.05, 0.10, 0.0}; // q2 gets rate 0 → no column + + auto dem = dem_from_css_matrices(code, noise); + + ASSERT_EQ(dem.num_error_mechanisms(), 2u); + EXPECT_DOUBLE_EQ(dem.error_rates[0], 0.05); // q0 + EXPECT_DOUBLE_EQ(dem.error_rates[1], 0.10); // q1 +} + +// A per-qubit vector with wrong length must throw. +TEST(DemConstruction, PerQubitRates_WrongLength_Throws) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px_per_qubit = {0.01, 0.02}; // only 2 entries for 3-qubit code + + EXPECT_THROW(dem_from_css_matrices(code, noise), std::invalid_argument); +} + +// All per-qubit rates zero → empty DEM even with nonzero uniform scalar. +TEST(DemConstruction, PerQubitRates_AllZero_EmptyDem) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; // non-zero scalar + noise.px_per_qubit = {0.0, 0.0, 0.0}; // all qubits suppressed + + auto dem = dem_from_css_matrices(code, noise); + + EXPECT_EQ(dem.num_error_mechanisms(), 0u); +} + +// --------------------------------------------------------------------------- +// Multi-round DEM construction +// --------------------------------------------------------------------------- + +// d=3 repetition code, T=2 rounds, X errors only. +// +// Detector layout (d=2 checks per round): +// D0 = round 0, Z0Z1 D1 = round 0, Z1Z2 +// D2 = round 1, Z0Z1 D3 = round 1, Z1Z2 +// +// Column layout (round 0 first): +// col 0 – round 0, q0: {D0,D2}, L0 +// col 1 – round 0, q1: {D0,D1,D2,D3} +// col 2 – round 0, q2: {D1,D3} +// col 3 – round 1, q0: {D2}, L0 (last round → single band) +// col 4 – round 1, q1: {D2,D3} +// col 5 – round 1, q2: {D3} +TEST(DemConstruction, MultiRound_T2_Dimensions) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise, 2); + + EXPECT_EQ(dem.num_detectors(), 4u); // 2 rounds × 2 checks + EXPECT_EQ(dem.num_observables(), 1u); + EXPECT_EQ(dem.num_error_mechanisms(), 6u); // 2 rounds × 3 qubits +} + +// A fault in a bulk round (not the last) must span two consecutive +// detector bands. Round 0, qubit 0: D0 and D2 both set; D1 and D3 clear. +TEST(DemConstruction, MultiRound_BulkFault_SpansTwoRounds) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise, 2); + + // col 0 = round 0, q0 + EXPECT_EQ(dem.detector_error_matrix.at({0, 0}), 1u); // D0: round 0, Z0Z1 + EXPECT_EQ(dem.detector_error_matrix.at({1, 0}), 0u); // D1: round 0, Z1Z2 + EXPECT_EQ(dem.detector_error_matrix.at({2, 0}), 1u); // D2: round 1, Z0Z1 + EXPECT_EQ(dem.detector_error_matrix.at({3, 0}), 0u); // D3: round 1, Z1Z2 + EXPECT_EQ(dem.observables_flips_matrix.at({0, 0}), 1u); // L0 flipped +} + +// A fault in the last round must span only that round's detector band. +// Round 1 (T-1), qubit 0: D2 set; D0 clear (no back-propagation). +TEST(DemConstruction, MultiRound_LastRoundFault_SpansOneRound) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise, 2); + + // col 3 = round 1, q0 + EXPECT_EQ(dem.detector_error_matrix.at({0, 3}), 0u); // D0: unaffected + EXPECT_EQ(dem.detector_error_matrix.at({1, 3}), 0u); // D1: unaffected + EXPECT_EQ(dem.detector_error_matrix.at({2, 3}), 1u); // D2: round 1, Z0Z1 + EXPECT_EQ(dem.detector_error_matrix.at({3, 3}), 0u); // D3: unaffected + EXPECT_EQ(dem.observables_flips_matrix.at({0, 3}), 1u); // L0 flipped +} + +// Faults in every round must flip the observable (the logical measurement +// is taken once at the end; any uncorrected X on the logical qubit matters). +TEST(DemConstruction, MultiRound_ObservableFlipped_AllRounds) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise, 3); + + // col 0 = round 0, q0 → L0 flipped + EXPECT_EQ(dem.observables_flips_matrix.at({0, 0}), 1u); + // col 3 = round 1, q0 → L0 flipped + EXPECT_EQ(dem.observables_flips_matrix.at({0, 3}), 1u); + // col 6 = round 2, q0 → L0 flipped + EXPECT_EQ(dem.observables_flips_matrix.at({0, 6}), 1u); + + // q1 never flips L0 regardless of round + EXPECT_EQ(dem.observables_flips_matrix.at({0, 1}), 0u); // round 0, q1 + EXPECT_EQ(dem.observables_flips_matrix.at({0, 4}), 0u); // round 1, q1 + EXPECT_EQ(dem.observables_flips_matrix.at({0, 7}), 0u); // round 2, q1 +} + +// Cross-check against dem_from_stim_text() for d=3, T=2, px=0.01. +// Detector numbering matches our layout: D0/D1 = round 0, D2/D3 = round 1. +// +// error(0.01) D0 D2 L0 ← round 0, q0: bulk span + observable +// error(0.01) D0 D1 D2 D3 ← round 0, q1: bulk span, both checks +// error(0.01) D1 D3 ← round 0, q2: bulk span +// error(0.01) D2 L0 ← round 1, q0: single band (last round) +// error(0.01) D2 D3 ← round 1, q1: single band +// error(0.01) D3 ← round 1, q2: single band +TEST(DemConstruction, MultiRound_T2_MatchesStimText) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto from_matrices = dem_from_css_matrices(code, noise, 2); + + const std::string stim_dem = "error(0.01) D0 D2 L0\n" + "error(0.01) D0 D1 D2 D3\n" + "error(0.01) D1 D3\n" + "error(0.01) D2 L0\n" + "error(0.01) D2 D3\n" + "error(0.01) D3\n"; + auto from_stim = dem_from_stim_text(stim_dem); + + EXPECT_TRUE(tensors_equal(from_matrices.detector_error_matrix, + from_stim.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(from_matrices.observables_flips_matrix, + from_stim.observables_flips_matrix)); + ASSERT_EQ(from_matrices.error_rates.size(), from_stim.error_rates.size()); + for (std::size_t i = 0; i < from_matrices.error_rates.size(); ++i) + EXPECT_DOUBLE_EQ(from_matrices.error_rates[i], from_stim.error_rates[i]); +} + +// T=3 rounds produces the right dimensions and a correctly placed +// interior fault. Round 1 (bulk), q0: spans rounds 1 and 2 only. +// +// Detector rows: D0..D1 = round 0, D2..D3 = round 1, D4..D5 = round 2. +// Column 3 = round 1, q0. +TEST(DemConstruction, MultiRound_T3_InteriorFaultSpan) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + auto dem = dem_from_css_matrices(code, noise, 3); + + EXPECT_EQ(dem.num_detectors(), 6u); // 3 rounds × 2 checks + EXPECT_EQ(dem.num_error_mechanisms(), 9u); // 3 rounds × 3 qubits + + // col 3 = round 1, q0: D2 (round 1 Z0Z1) and D4 (round 2 Z0Z1). + EXPECT_EQ(dem.detector_error_matrix.at({0, 3}), 0u); // D0: unaffected + EXPECT_EQ(dem.detector_error_matrix.at({2, 3}), 1u); // D2: round 1 + EXPECT_EQ(dem.detector_error_matrix.at({4, 3}), 1u); // D4: round 2 span + EXPECT_EQ(dem.observables_flips_matrix.at({0, 3}), 1u); // L0 flipped +} + +TEST(DemConstruction, MultiRound_ZeroRounds_Throws) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + + EXPECT_THROW(dem_from_css_matrices(code, noise, 0), std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// Measurement errors (phenomenological noise model) +// --------------------------------------------------------------------------- + +// T=1, pm only: each check has its own column that fires ONE detector +// (last round → no next round to span). +// rep3 has 2 Z-checks → 2 meas-error columns beyond the 3 data columns. +// col 3: meas error on Z0Z1 → D0 only +// col 4: meas error on Z1Z2 → D1 only +TEST(DemConstruction, MeasurementErrors_T1_SingleDetector) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.pm = 0.005; + + auto dem = dem_from_css_matrices(code, noise); // T=1 default + + // 2 detectors (single round), 2 fault columns (pm>0, px=0) + ASSERT_EQ(dem.num_detectors(), 2u); + ASSERT_EQ(dem.num_error_mechanisms(), 2u); + EXPECT_DOUBLE_EQ(dem.error_rates[0], 0.005); + EXPECT_DOUBLE_EQ(dem.error_rates[1], 0.005); + + // col 0 = meas error on Z0Z1: D0 fires, D1 does not + EXPECT_EQ(dem.detector_error_matrix.at({0, 0}), 1u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 0}), 0u); + // No observable flip for measurement errors + EXPECT_EQ(dem.observables_flips_matrix.at({0, 0}), 0u); + + // col 1 = meas error on Z1Z2: D1 fires, D0 does not + EXPECT_EQ(dem.detector_error_matrix.at({0, 1}), 0u); + EXPECT_EQ(dem.detector_error_matrix.at({1, 1}), 1u); + EXPECT_EQ(dem.observables_flips_matrix.at({0, 1}), 0u); +} + +// T=2, pm only: bulk-round meas errors span two rounds; last-round errors +// span one. Column layout per round: Z-check 0, Z-check 1. +// col 0: meas Z0Z1 round 0 → D0 and D2 +// col 1: meas Z1Z2 round 0 → D1 and D3 +// col 2: meas Z0Z1 round 1 → D2 only (last round) +// col 3: meas Z1Z2 round 1 → D3 only (last round) +TEST(DemConstruction, MeasurementErrors_T2_BulkAndLastRound) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.pm = 0.005; + + auto dem = dem_from_css_matrices(code, noise, 2); + + ASSERT_EQ(dem.num_detectors(), 4u); // 2 rounds × 2 checks + ASSERT_EQ(dem.num_error_mechanisms(), 4u); // 2 rounds × 2 checks + + // Round 0, check 0: spans D0 and D2 + EXPECT_EQ(dem.detector_error_matrix.at({0, 0}), 1u); // D0 + EXPECT_EQ(dem.detector_error_matrix.at({1, 0}), 0u); // D1 unaffected + EXPECT_EQ(dem.detector_error_matrix.at({2, 0}), 1u); // D2 (next round) + EXPECT_EQ(dem.detector_error_matrix.at({3, 0}), 0u); // D3 unaffected + EXPECT_EQ(dem.observables_flips_matrix.at({0, 0}), 0u); // no logical flip + + // Round 1 (last), check 0: spans D2 only + EXPECT_EQ(dem.detector_error_matrix.at({0, 2}), 0u); // D0 unaffected + EXPECT_EQ(dem.detector_error_matrix.at({2, 2}), 1u); // D2 only + EXPECT_EQ(dem.detector_error_matrix.at({3, 2}), 0u); // D3 unaffected +} + +// Combined px + pm: data errors and measurement errors in the same DEM. +// T=2, rep3: 3 data cols + 2 meas cols per round = 10 total columns. +// Cross-check against manually derived Stim DEM. +// +// Column order per round: [X_q0, X_q1, X_q2, meas_check0, meas_check1] +// +// Stim DEM (T=2, px=0.01, pm=0.005): +// error(0.01) D0 D2 L0 ← X q0 round 0 +// error(0.01) D0 D1 D2 D3 ← X q1 round 0 +// error(0.01) D1 D3 ← X q2 round 0 +// error(0.005) D0 D2 ← meas Z0Z1 round 0 +// error(0.005) D1 D3 ← meas Z1Z2 round 0 +// error(0.01) D2 L0 ← X q0 round 1 +// error(0.01) D2 D3 ← X q1 round 1 +// error(0.01) D3 ← X q2 round 1 +// error(0.005) D2 ← meas Z0Z1 round 1 +// error(0.005) D3 ← meas Z1Z2 round 1 +TEST(DemConstruction, MeasurementErrors_PxPm_T2_MatchesStimText) { + css_code_matrices code = rep3_z_basis_matrices(); + css_noise_params noise; + noise.px = 0.01; + noise.pm = 0.005; + + auto from_matrices = dem_from_css_matrices(code, noise, 2); + + const std::string stim_dem = "error(0.01) D0 D2 L0\n" + "error(0.01) D0 D1 D2 D3\n" + "error(0.01) D1 D3\n" + "error(0.005) D0 D2\n" + "error(0.005) D1 D3\n" + "error(0.01) D2 L0\n" + "error(0.01) D2 D3\n" + "error(0.01) D3\n" + "error(0.005) D2\n" + "error(0.005) D3\n"; + auto from_stim = dem_from_stim_text(stim_dem); + + EXPECT_EQ(from_matrices.num_detectors(), from_stim.num_detectors()); + EXPECT_EQ(from_matrices.num_error_mechanisms(), + from_stim.num_error_mechanisms()); + EXPECT_TRUE(tensors_equal(from_matrices.detector_error_matrix, + from_stim.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(from_matrices.observables_flips_matrix, + from_stim.observables_flips_matrix)); + ASSERT_EQ(from_matrices.error_rates.size(), from_stim.error_rates.size()); + for (std::size_t i = 0; i < from_matrices.error_rates.size(); ++i) + EXPECT_DOUBLE_EQ(from_matrices.error_rates[i], from_stim.error_rates[i]) + << "col=" << i; +} + +// hz with nonzero rows but zero columns (while n_qubits > 0 from hx) must +// throw rather than silently producing a DEM with empty Z-type detectors. +TEST(DemConstruction, MalformedHz_RowsWithZeroCols_Throws) { + css_code_matrices code; + // hz: 2 rows, 0 columns — nonzero rows, zero columns → inconsistent + code.hz = sparse_binary_matrix::from_nested_csc(2, 0, {}); + // hx defines n=3; hz should be validated against that + code.hx = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {0}, {0}}); + css_noise_params noise; + noise.pz = 0.01; + + EXPECT_THROW(dem_from_css_matrices(code, noise), std::invalid_argument); +} + +} // namespace +} // namespace cudaq::qec diff --git a/libs/qec/unittests/test_dem_construction_code.cpp b/libs/qec/unittests/test_dem_construction_code.cpp new file mode 100644 index 000000000..369c9aab6 --- /dev/null +++ b/libs/qec/unittests/test_dem_construction_code.cpp @@ -0,0 +1,92 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// Tests for css_matrices_from_code() and the dem_from_css_matrices(code&,...) +// overload. These require the full cudaq-qec library (code objects depend on +// the CUDA-Q framework), so they live in a separate executable from the +// matrix-level tests in test_dem_construction.cpp. + +#include "cuda-qx/core/heterogeneous_map.h" +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/dem_construction.h" +#include "cudaq/qec/dem_construction_code.h" +#include "cudaq/qec/detector_error_model.h" +#include +#include + +namespace cudaq::qec { +namespace { + +// css_matrices_from_code() on the d=3 repetition code must return: +// hz: 2x3 sparse matrix matching H_Z = [[1,1,0],[0,1,1]] +// hx: 0x3 (no X stabilizers) +// lz: 1x3 matching L_Z = [[1,0,0]] +// lx: 1x3 (all zeros for this pure-Z code) +TEST(DemConstructionCode, CssMatricesFromCode_Repetition3_Dimensions) { + cudaqx::heterogeneous_map opts; + opts.insert("distance", std::size_t{3}); + auto rep = code::get("repetition", opts); + + auto m = css_matrices_from_code(*rep); + + EXPECT_EQ(m.hz.num_rows(), 2u); + EXPECT_EQ(m.hz.num_cols(), 3u); + EXPECT_EQ(m.hx.num_rows(), 0u); + EXPECT_EQ(m.lz.num_rows(), 1u); + EXPECT_EQ(m.lz.num_cols(), 3u); +} + +// The hz matrix from the repetition code must encode Z0Z1 and Z1Z2. +// hz[:,0] = {0}, hz[:,1] = {0,1}, hz[:,2] = {1}. +TEST(DemConstructionCode, CssMatricesFromCode_Repetition3_HzEntries) { + cudaqx::heterogeneous_map opts; + opts.insert("distance", std::size_t{3}); + auto rep = code::get("repetition", opts); + + auto m = css_matrices_from_code(*rep); + auto hz_dense = m.hz.to_dense(); + + EXPECT_EQ(hz_dense.at({0, 0}), 1u); // Z0Z1: qubit 0 + EXPECT_EQ(hz_dense.at({0, 1}), 1u); // Z0Z1: qubit 1 + EXPECT_EQ(hz_dense.at({0, 2}), 0u); + EXPECT_EQ(hz_dense.at({1, 0}), 0u); + EXPECT_EQ(hz_dense.at({1, 1}), 1u); // Z1Z2: qubit 1 + EXPECT_EQ(hz_dense.at({1, 2}), 1u); // Z1Z2: qubit 2 +} + +// The full dem_from_css_matrices(code, noise) overload must produce the same +// DEM as dem_from_css_matrices(css_matrices_from_code(code), noise). +TEST(DemConstructionCode, CodeOverload_MatchesMatrixOverload) { + cudaqx::heterogeneous_map opts; + opts.insert("distance", std::size_t{3}); + auto rep = code::get("repetition", opts); + + css_noise_params noise; + noise.px = 0.01; + + auto from_code = dem_from_css_matrices(*rep, noise); + auto from_matrices = + dem_from_css_matrices(css_matrices_from_code(*rep), noise); + + ASSERT_EQ(from_code.num_detectors(), from_matrices.num_detectors()); + ASSERT_EQ(from_code.num_observables(), from_matrices.num_observables()); + ASSERT_EQ(from_code.num_error_mechanisms(), + from_matrices.num_error_mechanisms()); + + const auto &d1 = from_code.detector_error_matrix; + const auto &d2 = from_matrices.detector_error_matrix; + for (std::size_t r = 0; r < from_code.num_detectors(); ++r) + for (std::size_t c = 0; c < from_code.num_error_mechanisms(); ++c) + EXPECT_EQ(d1.at({r, c}), d2.at({r, c})) << "r=" << r << " c=" << c; + + for (std::size_t i = 0; i < from_code.num_error_mechanisms(); ++i) + EXPECT_DOUBLE_EQ(from_code.error_rates[i], from_matrices.error_rates[i]); +} // end - CodeOverload_MatchesMatrixOverload + +} // namespace +} // namespace cudaq::qec diff --git a/libs/qec/unittests/test_extended_dem.cpp b/libs/qec/unittests/test_extended_dem.cpp new file mode 100644 index 000000000..aaf99e45e --- /dev/null +++ b/libs/qec/unittests/test_extended_dem.cpp @@ -0,0 +1,1622 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +// Unit tests for extended_dem, dem_stitch(), dem_stitch_all(), and dem_close(). +// +// The central invariant under test: +// dem_close(dem_stitch_all(T one-round chunks)) == +// dem_from_css_matrices(code, noise, T) +// +// Additional cases: +// - One-round chunk structure (in_syndrome == out_syndrome, no interior) +// - Stitch dimensions (interior grows by one seam per stitch) +// - Tag validation (mismatched tags throw) +// - dem_close() row ordering matches monolithic model +// - Per-qubit noise propagates correctly through stitch + +#include "cudaq/qec/code_matrices.h" +#include "cudaq/qec/dem_construction.h" +#include "cudaq/qec/detector_error_model.h" +#include "cudaq/qec/extended_dem.h" +#include +#include +#include +#include +#include +#include + +namespace cudaq::qec { +namespace { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +bool tensors_equal(const cudaqx::tensor &a, + const cudaqx::tensor &b) { + if (a.rank() != 2 || b.rank() != 2) + return false; + if (a.shape()[0] != b.shape()[0] || a.shape()[1] != b.shape()[1]) + return false; + for (std::size_t r = 0; r < a.shape()[0]; ++r) + for (std::size_t c = 0; c < a.shape()[1]; ++c) + if (a.at({r, c}) != b.at({r, c})) + return false; + return true; +} + +// d=3 repetition code (Z-basis): H_Z = [[1,1,0],[0,1,1]], L_Z = [[1,0,0]]. +css_code_matrices rep3() { + css_code_matrices m; + m.hz = sparse_binary_matrix::from_nested_csc(2, 3, {{0}, {0, 1}, {1}}); + m.lz = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {}, {}}); + return m; +} + +css_noise_params px_only(double p) { + css_noise_params n; + n.px = p; + return n; +} + +// --------------------------------------------------------------------------- +// One-round chunk structure +// --------------------------------------------------------------------------- + +TEST(ExtendedDem, OneRound_NoInterior) { + auto dem_chunk = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + EXPECT_EQ(dem_chunk.num_interior(), 0u); +} + +// For a one-round chunk, in_syndrome == out_syndrome (the same raw syndrome). +TEST(ExtendedDem, OneRound_InSyndromeEqualsOutSyndrome) { + auto dem_chunk = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + auto in_d = dem_chunk.in_syndrome.to_dense(); + auto out_d = dem_chunk.out_syndrome.to_dense(); + EXPECT_TRUE(tensors_equal(in_d, out_d)); +} + +// The in/out_syndrome matrix must equal the single-round detector_error_matrix. +TEST(ExtendedDem, OneRound_SyndromeMatchesFlatDem) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto dem_chunk = extended_dem_from_css_matrices(code, noise); + auto flat = dem_from_css_matrices(code, noise); + + EXPECT_TRUE(tensors_equal(dem_chunk.in_syndrome.to_dense(), + flat.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(dem_chunk.observables.to_dense(), + flat.observables_flips_matrix)); + EXPECT_EQ(dem_chunk.fault_priors, flat.error_rates); +} + +// Tags are sequential for a same-code chunk. +TEST(ExtendedDem, OneRound_Tags) { + auto dem_chunk = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + // d=2 checks for d=3 rep code + ASSERT_EQ(dem_chunk.in_tags.size(), 2u); + ASSERT_EQ(dem_chunk.out_tags.size(), 2u); + EXPECT_EQ(dem_chunk.in_tags, dem_chunk.out_tags); + EXPECT_EQ(dem_chunk.in_tags[0], 0u); + EXPECT_EQ(dem_chunk.in_tags[1], 1u); +} + +// --------------------------------------------------------------------------- +// Stitch dimensions +// --------------------------------------------------------------------------- + +// After one stitch, interior grows by one seam worth of rows (d=2 here). +TEST(ExtendedDem, Stitch_InteriorGrowsBySeamRows) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto c0 = extended_dem_from_css_matrices(code, noise); + auto c1 = extended_dem_from_css_matrices(code, noise); + auto ab = dem_stitch(c0, c1); + + // seam_rows = 2 (nz=2, nx=0) + EXPECT_EQ(ab.num_interior(), 2u); + EXPECT_EQ(ab.num_seam_rows(), 2u); + EXPECT_EQ(ab.num_observables(), 1u); + EXPECT_EQ(ab.num_faults(), 6u); // 2 rounds × 3 qubits +} + +// Fault columns from A come first, then B. +TEST(ExtendedDem, Stitch_FaultColumnOrder) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto c0 = extended_dem_from_css_matrices(code, noise); + auto c1 = extended_dem_from_css_matrices(code, noise); + auto ab = dem_stitch(c0, c1); + + // A's priors (3 entries at 0.01) then B's (3 entries at 0.01) + ASSERT_EQ(ab.fault_priors.size(), 6u); + for (std::size_t i = 0; i < 6u; ++i) + EXPECT_DOUBLE_EQ(ab.fault_priors[i], 0.01) << "i=" << i; +} + +// Stitch 3 chunks: interior = 2 seams × 2 rows each = 4. +TEST(ExtendedDem, Stitch_ThreeDemChunks_Interior) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto c0 = extended_dem_from_css_matrices(code, noise); + auto c1 = extended_dem_from_css_matrices(code, noise); + auto c2 = extended_dem_from_css_matrices(code, noise); + auto abc = dem_stitch(dem_stitch(c0, c1), c2); + + EXPECT_EQ(abc.num_interior(), 4u); // 2 seams × 2 rows + EXPECT_EQ(abc.num_faults(), 9u); // 3 rounds × 3 qubits +} + +// Interior rows must come out in round order for any association of stitches, +// or a chunk stitched as a tree would file one of its seams under the wrong +// round and dem_close() would emit detectors out of order. +TEST(ExtendedDem, Stitch_TreeFoldMatchesLeftFold) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + auto c = extended_dem_from_css_matrices(code, noise); + + auto left = dem_stitch(dem_stitch(dem_stitch(c, c), c), c); + auto tree = dem_stitch(dem_stitch(c, c), dem_stitch(c, c)); + auto right = dem_stitch(c, dem_stitch(c, dem_stitch(c, c))); + auto flat = dem_from_css_matrices(code, noise, 4); + + for (const auto *shape : {&left, &tree, &right}) { + auto closed = dem_close(*shape); + EXPECT_EQ(dem_chunk_rounds(*shape), 4u); + EXPECT_TRUE(tensors_equal(closed.detector_error_matrix, + flat.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(closed.observables_flips_matrix, + flat.observables_flips_matrix)); + } +} + +// --------------------------------------------------------------------------- +// dem_close() — invariant: dem_close(dem_stitch_all(T)) == +// dem_from_css_matrices(T) +// --------------------------------------------------------------------------- + +// T=1: close of a single chunk == single-round monolithic DEM. +TEST(ExtendedDem, Close_T1_MatchesMonolithic) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto closed = dem_close(extended_dem_from_css_matrices(code, noise)); + auto flat = dem_from_css_matrices(code, noise); + + EXPECT_EQ(closed.num_detectors(), flat.num_detectors()); + EXPECT_EQ(closed.num_observables(), flat.num_observables()); + EXPECT_EQ(closed.num_error_mechanisms(), flat.num_error_mechanisms()); + EXPECT_TRUE( + tensors_equal(closed.detector_error_matrix, flat.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(closed.observables_flips_matrix, + flat.observables_flips_matrix)); + EXPECT_EQ(closed.error_rates, flat.error_rates); +} + +// T=2: dem_close(dem_stitch(c0, c1)) == dem_from_css_matrices(code, noise, 2). +TEST(ExtendedDem, Close_T2_MatchesMonolithic) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto c0 = extended_dem_from_css_matrices(code, noise); + auto c1 = extended_dem_from_css_matrices(code, noise); + auto stitched = dem_stitch(c0, c1); + auto closed = dem_close(stitched); + auto flat = dem_from_css_matrices(code, noise, 2); + + EXPECT_EQ(closed.num_detectors(), flat.num_detectors()); + EXPECT_EQ(closed.num_error_mechanisms(), flat.num_error_mechanisms()); + EXPECT_TRUE( + tensors_equal(closed.detector_error_matrix, flat.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(closed.observables_flips_matrix, + flat.observables_flips_matrix)); + EXPECT_EQ(closed.error_rates, flat.error_rates); +} + +// T=3 using dem_stitch_all. +TEST(ExtendedDem, Close_T3_MatchesMonolithic) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + std::vector dem_chunks( + 3, extended_dem_from_css_matrices(code, noise)); + auto closed = dem_close(dem_stitch_all(dem_chunks)); + auto flat = dem_from_css_matrices(code, noise, 3); + + EXPECT_EQ(closed.num_detectors(), flat.num_detectors()); + EXPECT_EQ(closed.num_error_mechanisms(), flat.num_error_mechanisms()); + EXPECT_TRUE( + tensors_equal(closed.detector_error_matrix, flat.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(closed.observables_flips_matrix, + flat.observables_flips_matrix)); + EXPECT_EQ(closed.error_rates, flat.error_rates); +} + +// T=5 stress test. +TEST(ExtendedDem, Close_T5_MatchesMonolithic) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + std::vector dem_chunks( + 5, extended_dem_from_css_matrices(code, noise)); + auto closed = dem_close(dem_stitch_all(dem_chunks)); + auto flat = dem_from_css_matrices(code, noise, 5); + + EXPECT_EQ(closed.num_detectors(), flat.num_detectors()); + EXPECT_EQ(closed.num_observables(), flat.num_observables()); + EXPECT_EQ(closed.num_error_mechanisms(), flat.num_error_mechanisms()); + EXPECT_TRUE( + tensors_equal(closed.detector_error_matrix, flat.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(closed.observables_flips_matrix, + flat.observables_flips_matrix)); + EXPECT_EQ(closed.error_rates, flat.error_rates); +} + +// --------------------------------------------------------------------------- +// Tag validation +// --------------------------------------------------------------------------- + +// Mismatched out/in tags must throw. +TEST(ExtendedDem, Stitch_TagMismatch_Throws) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto c0 = extended_dem_from_css_matrices(code, noise); + auto c1 = extended_dem_from_css_matrices(code, noise); + c1.in_tags[0] = 999u; // corrupt a tag + + EXPECT_THROW(dem_stitch(c0, c1), std::invalid_argument); +} + +// Observable count mismatch must throw. +TEST(ExtendedDem, Stitch_ObservableMismatch_Throws) { + css_code_matrices code = rep3(); + + css_noise_params nx_noise; // no observables from lx + nx_noise.px = 0.01; + + css_code_matrices code_with_lx = rep3(); + code_with_lx.lx = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {}, {}}); + css_noise_params pxpz; + pxpz.px = 0.01; + pxpz.pz = 0.01; + + auto c0 = extended_dem_from_css_matrices(code, nx_noise); + auto c1 = extended_dem_from_css_matrices(code_with_lx, pxpz); + + EXPECT_THROW(dem_stitch(c0, c1), std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// Per-qubit noise propagates through stitch +// --------------------------------------------------------------------------- + +// Qubit 1 has zero rate in round 0 only — its column is absent from c0 but +// present in c1. The stitched + closed DEM should have 5 error mechanisms. +TEST(ExtendedDem, PerQubitNoise_SparseDemChunk) { + css_code_matrices code = rep3(); + + css_noise_params noise_sparse; + noise_sparse.px_per_qubit = {0.01, 0.0, 0.01}; // skip qubit 1 + + css_noise_params noise_full; + noise_full.px = 0.01; + + auto c0 = extended_dem_from_css_matrices(code, noise_sparse); // 2 faults + auto c1 = extended_dem_from_css_matrices(code, noise_full); // 3 faults + auto closed = dem_close(dem_stitch(c0, c1)); + + EXPECT_EQ(closed.num_error_mechanisms(), 5u); + EXPECT_EQ(closed.num_detectors(), 4u); // 2 rounds × 2 checks +} + +// --------------------------------------------------------------------------- +// dem_stitch_all edge cases +// --------------------------------------------------------------------------- + +TEST(ExtendedDem, StitchAll_Empty_Throws) { + EXPECT_THROW(dem_stitch_all({}), std::invalid_argument); +} + +TEST(ExtendedDem, StitchAll_OneDemChunk_IsIdentity) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto c0 = extended_dem_from_css_matrices(code, noise); + auto result = dem_stitch_all({c0}); + + EXPECT_EQ(result.num_interior(), c0.num_interior()); + EXPECT_EQ(result.num_seam_rows(), c0.num_seam_rows()); + EXPECT_EQ(result.num_observables(), c0.num_observables()); + EXPECT_EQ(result.num_faults(), c0.num_faults()); +} + +// --------------------------------------------------------------------------- +// dem_close_all +// --------------------------------------------------------------------------- + +// dem_close_all on a single chunk must equal dem_close() on that chunk. +TEST(ExtendedDem, CloseAll_T1_MatchesClose) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + auto dem_chunk = extended_dem_from_css_matrices(code, noise); + auto via_close = dem_close(dem_chunk); + auto via_close_all = dem_close_all({dem_chunk}); + + EXPECT_EQ(via_close_all.num_detectors(), via_close.num_detectors()); + EXPECT_TRUE(tensors_equal(via_close_all.detector_error_matrix, + via_close.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(via_close_all.observables_flips_matrix, + via_close.observables_flips_matrix)); + EXPECT_EQ(via_close_all.error_rates, via_close.error_rates); +} + +// dem_close_all(T chunks) == dem_close(dem_stitch_all(T chunks)) for +// single-round chunks. +TEST(ExtendedDem, CloseAll_T4_MatchesCloseStitchAll) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + std::vector dem_chunks( + 4, extended_dem_from_css_matrices(code, noise)); + + auto via_stitch = dem_close(dem_stitch_all(dem_chunks)); + auto via_close_all = dem_close_all(dem_chunks); + + EXPECT_EQ(via_close_all.num_detectors(), via_stitch.num_detectors()); + EXPECT_EQ(via_close_all.num_error_mechanisms(), + via_stitch.num_error_mechanisms()); + EXPECT_TRUE(tensors_equal(via_close_all.detector_error_matrix, + via_stitch.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(via_close_all.observables_flips_matrix, + via_stitch.observables_flips_matrix)); + EXPECT_EQ(via_close_all.error_rates, via_stitch.error_rates); +} + +// dem_close_all output matches dem_from_css_matrices for the same T rounds. +TEST(ExtendedDem, CloseAll_T5_MatchesMonolithic) { + css_code_matrices code = rep3(); + css_noise_params noise = px_only(0.01); + + std::vector dem_chunks( + 5, extended_dem_from_css_matrices(code, noise)); + auto via_close_all = dem_close_all(dem_chunks); + auto monolithic = dem_from_css_matrices(code, noise, 5); + + EXPECT_EQ(via_close_all.num_detectors(), monolithic.num_detectors()); + EXPECT_EQ(via_close_all.num_error_mechanisms(), + monolithic.num_error_mechanisms()); + EXPECT_TRUE(tensors_equal(via_close_all.detector_error_matrix, + monolithic.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(via_close_all.observables_flips_matrix, + monolithic.observables_flips_matrix)); + EXPECT_EQ(via_close_all.error_rates, monolithic.error_rates); +} + +// dem_close_all on empty chunks must throw. +TEST(ExtendedDem, CloseAll_EmptyDemChunks_Throws) { + EXPECT_THROW(dem_close_all({}), std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// Measurement errors through the extended_dem pipeline +// --------------------------------------------------------------------------- + +// A one-round chunk with pm>0 must have 2 extra fault columns (one per +// Z-check for rep3) whose syndrome column contains only that check's row +// and whose observable column is empty. +TEST(ExtendedDem, MeasurementErrors_OneRoundDemChunk_ExtraColumns) { + css_noise_params noise; + noise.pm = 0.005; + + auto dem_chunk = extended_dem_from_css_matrices(rep3(), noise); + + // 2 checks → 2 meas-error columns; no data errors (px=pz=py=0) + EXPECT_EQ(dem_chunk.num_faults(), 2u); + EXPECT_EQ(dem_chunk.num_interior(), 0u); + EXPECT_DOUBLE_EQ(dem_chunk.fault_priors[0], 0.005); + EXPECT_DOUBLE_EQ(dem_chunk.fault_priors[1], 0.005); + + // in_syndrome col 0: only row 0 (Z0Z1 check) is set + auto syn_dense = dem_chunk.in_syndrome.to_dense(); + EXPECT_EQ(syn_dense.at({0, 0}), 1u); // Z0Z1 fires + EXPECT_EQ(syn_dense.at({1, 0}), 0u); // Z1Z2 unaffected + + // observables are all zero — measurement errors don't flip logicals + auto obs_dense = dem_chunk.observables.to_dense(); + EXPECT_EQ(obs_dense.at({0, 0}), 0u); + EXPECT_EQ(obs_dense.at({0, 1}), 0u); +} + +// dem_close_all on T pm-only chunks must match dem_from_css_matrices(T, pm). +TEST(ExtendedDem, MeasurementErrors_CloseAll_MatchesMonolithic) { + css_noise_params noise; + noise.pm = 0.005; + + const std::size_t T = 3; + std::vector dem_chunks( + T, extended_dem_from_css_matrices(rep3(), noise)); + auto via_close_all = dem_close_all(dem_chunks); + auto monolithic = dem_from_css_matrices(rep3(), noise, T); + + EXPECT_EQ(via_close_all.num_detectors(), monolithic.num_detectors()); + EXPECT_EQ(via_close_all.num_error_mechanisms(), + monolithic.num_error_mechanisms()); + EXPECT_TRUE(tensors_equal(via_close_all.detector_error_matrix, + monolithic.detector_error_matrix)); + EXPECT_TRUE(tensors_equal(via_close_all.observables_flips_matrix, + monolithic.observables_flips_matrix)); + EXPECT_EQ(via_close_all.error_rates, monolithic.error_rates); +} + +// --------------------------------------------------------------------------- +// Streaming decoder integration utilities +// --------------------------------------------------------------------------- + +// d=3 rep code, T=2 rounds, d=2 checks. +// detector_round must be [0, 0, 1, 1] — both round-0 detectors first. +TEST(ExtendedDem, DemChunksToDetectorRound_T2) { + std::vector dem_chunks( + 2, extended_dem_from_css_matrices(rep3(), px_only(0.01))); + + auto dr = dem_chunks_to_detector_round(dem_chunks); + + ASSERT_EQ(dr.size(), 4u); // T=2 rounds × d=2 checks + EXPECT_EQ(dr[0], 0); // det 0: round 0, check 0 + EXPECT_EQ(dr[1], 0); // det 1: round 0, check 1 + EXPECT_EQ(dr[2], 1); // det 2: round 1, check 0 + EXPECT_EQ(dr[3], 1); // det 3: round 1, check 1 +} + +// T=3: three rounds, d=2 checks — verify all entries. +TEST(ExtendedDem, DemChunksToDetectorRound_T3) { + std::vector dem_chunks( + 3, extended_dem_from_css_matrices(rep3(), px_only(0.01))); + + auto dr = dem_chunks_to_detector_round(dem_chunks); + + ASSERT_EQ(dr.size(), 6u); + for (int r = 0; r < 3; ++r) + for (int k = 0; k < 2; ++k) + EXPECT_EQ(dr[r * 2 + k], r) << "r=" << r << " k=" << k; +} + +// A chunk's interior rows count its rounds after the first. +TEST(ExtendedDem, DemChunkRounds_CountsInteriorRows) { + auto one = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + + EXPECT_EQ(dem_chunk_rounds(one), 1u); + EXPECT_EQ(dem_chunk_rounds(dem_stitch(one, one)), 2u); + EXPECT_EQ(dem_chunk_rounds(dem_stitch_all({one, one, one})), 3u); + EXPECT_EQ(dem_chunks_to_rounds({one, dem_stitch(one, one), one}), 4u); +} + +// A pre-stitched chunk maps to exactly the rounds its pieces would have mapped +// to on their own, however the caller grouped them. +TEST(ExtendedDem, DemChunksToDetectorRound_MultiRoundChunksMatchSingles) { + auto one = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + const std::vector singles(4, one); + const auto want = dem_chunks_to_detector_round(singles); + + ASSERT_EQ(want.size(), 8u); // 4 rounds x 2 checks + EXPECT_EQ(dem_chunks_to_detector_round({dem_stitch_all(singles)}), want); + EXPECT_EQ(dem_chunks_to_detector_round( + {dem_stitch(one, one), dem_stitch(one, one)}), + want); + EXPECT_EQ( + dem_chunks_to_detector_round({one, dem_stitch_all({one, one, one})}), + want); + EXPECT_EQ(dem_chunks_to_d_sparse({dem_stitch(one, one), one, one}), + dem_chunks_to_d_sparse(singles)); +} + +// Interior rows that are not a whole number of rounds cannot be attributed to +// rounds at all, so every round-indexed map has to refuse them rather than +// truncate. +TEST(ExtendedDem, DemChunkUtils_PartialRoundInterior_Throws) { + auto dem_chunk = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + // One interior row where a round of this code carries two. + dem_chunk.interior = sparse_binary_matrix::from_nested_csc( + 1, dem_chunk.num_faults(), + std::vector>(dem_chunk.num_faults(), + std::vector{})); + + EXPECT_THROW(dem_chunk_rounds(dem_chunk), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_rounds({dem_chunk}), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_detector_round({dem_chunk}), + std::invalid_argument); + EXPECT_THROW(dem_chunks_to_d_sparse({dem_chunk}), std::invalid_argument); +} + +// d=3 rep code, T=2 rounds, d=2 checks. +// D_sparse layout: +// det 0 (r=0, check 0): only meas bit 0 → {0} +// det 1 (r=0, check 1): only meas bit 1 → {1} +// det 2 (r=1, check 0): meas bits 0 and 2 (XOR) → {0, 2} +// det 3 (r=1, check 1): meas bits 1 and 3 (XOR) → {1, 3} +TEST(ExtendedDem, DemChunksToDSparse_T2_Layout) { + std::vector dem_chunks( + 2, extended_dem_from_css_matrices(rep3(), px_only(0.01))); + + auto ds = dem_chunks_to_d_sparse(dem_chunks); + + ASSERT_EQ(ds.size(), 4u); + + // Round-0 detectors: single measurement each. + ASSERT_EQ(ds[0].size(), 1u); + EXPECT_EQ(ds[0][0], 0u); + ASSERT_EQ(ds[1].size(), 1u); + EXPECT_EQ(ds[1][0], 1u); + + // Round-1 detectors: XOR of consecutive round measurements. + ASSERT_EQ(ds[2].size(), 2u); + EXPECT_EQ(ds[2][0], 0u); // meas bit (r-1)*d+k = 0*2+0 = 0 + EXPECT_EQ(ds[2][1], 2u); // meas bit r*d+k = 1*2+0 = 2 + ASSERT_EQ(ds[3].size(), 2u); + EXPECT_EQ(ds[3][0], 1u); // meas bit (r-1)*d+k = 0*2+1 = 1 + EXPECT_EQ(ds[3][1], 3u); // meas bit r*d+k = 1*2+1 = 3 +} + +// O_sparse for rep3 px-only T=2: 1 observable, 6 fault columns. +// Only faults on qubit 0 (columns 0 and 3) flip the Z0 logical. +TEST(ExtendedDem, DemChunksToOSparse_T2_Rep3_XOnly) { + css_noise_params noise; + noise.px = 0.01; + std::vector dem_chunks( + 2, extended_dem_from_css_matrices(rep3(), noise)); + + auto os = dem_chunks_to_o_sparse(dem_chunks); + + // 1 observable for rep3 + ASSERT_EQ(os.size(), 1u); + + // 6 total fault columns: 3 per round. + // Column 0 = round 0, q0 (X): flips L0 + // Column 1 = round 0, q1 (X): does not flip L0 + // Column 2 = round 0, q2 (X): does not flip L0 + // Column 3 = round 1, q0 (X): flips L0 + // Column 4 = round 1, q1 (X): does not flip L0 + // Column 5 = round 1, q2 (X): does not flip L0 + const auto &obs0 = os[0]; + ASSERT_EQ(obs0.size(), 2u); + EXPECT_EQ(obs0[0], 0u); // round 0, qubit 0 + EXPECT_EQ(obs0[1], 3u); // round 1, qubit 0 +} + +// Empty chunks must throw for all three utilities. +TEST(ExtendedDem, DemChunkUtils_EmptyChunks_Throw) { + EXPECT_THROW(dem_chunks_to_detector_round({}), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_d_sparse({}), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_o_sparse({}), std::invalid_argument); +} + +// hz with nonzero rows but zero columns triggers the same check inside +// extended_dem_from_css_matrices(). +TEST(ExtendedDem, MalformedHz_RowsWithZeroCols_Throws) { + css_code_matrices code; + code.hz = sparse_binary_matrix::from_nested_csc(2, 0, {}); + code.hx = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {0}, {0}}); + css_noise_params noise; + noise.pz = 0.01; + + EXPECT_THROW(extended_dem_from_css_matrices(code, noise), + std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// dem_merge_duplicate_columns +// --------------------------------------------------------------------------- + +// Helper: rep-3 single-round chunk (px=0.01, only X faults → 3 columns). +static extended_dem rep3_dem_chunk_x() { + css_code_matrices m; + m.hz = sparse_binary_matrix::from_nested_csc(2, 3, {{0}, {0, 1}, {1}}); + m.lz = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {}, {}}); + css_noise_params n; + n.px = 0.01; + return extended_dem_from_css_matrices(m, n); +} + +// A fresh single-round chunk already has unique columns (all are distinct). +TEST(ExtendedDem, MergeDuplicateColumns_AlreadyUnique_Noop) { + auto dem_chunk = rep3_dem_chunk_x(); + EXPECT_TRUE(are_dem_columns_unique(dem_chunk)); + ASSERT_NO_THROW(assert_dem_columns_unique(dem_chunk)); + + const auto canon = dem_merge_duplicate_columns(dem_chunk); + EXPECT_EQ(canon.num_faults(), dem_chunk.num_faults()); + EXPECT_EQ(canon.fault_priors, dem_chunk.fault_priors); +} + +// Manually build an extended_dem with two identical columns (same row support) +// and verify they are merged by dem_merge_duplicate_columns. +TEST(ExtendedDem, MergeDuplicateColumns_MergesDuplicates_OrMode) { + // One interior row, no observables, one seam row; two identical columns. + extended_dem dem; + // Column 0 and column 1 both fire interior row 0. + dem.interior = sparse_binary_matrix::from_nested_csc(1, 2, {{0}, {0}}); + dem.observables = sparse_binary_matrix::from_nested_csc(0, 2, {{}, {}}); + dem.in_syndrome = sparse_binary_matrix::from_nested_csc(1, 2, {{}, {}}); + dem.out_syndrome = sparse_binary_matrix::from_nested_csc(1, 2, {{}, {}}); + dem.fault_priors = {0.1, 0.2}; + dem.in_tags = {0}; + dem.out_tags = {0}; + + EXPECT_FALSE(are_dem_columns_unique(dem)); + EXPECT_THROW(assert_dem_columns_unique(dem), std::invalid_argument); + + const auto canon = + dem_merge_duplicate_columns(dem, prior_combine_mode::or_combine); + EXPECT_EQ(canon.num_faults(), 1u); + // XOR / GF(2) merge: P(A xor B) = 0.1 + 0.2 - 2*0.1*0.2 = 0.26 + EXPECT_NEAR(canon.fault_priors[0], 0.26, 1e-12); + EXPECT_TRUE(are_dem_columns_unique(canon)); +} + +TEST(ExtendedDem, MergeDuplicateColumns_MergesDuplicates_SumMode) { + extended_dem dem; + dem.interior = sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {0}, {0}}); + dem.observables = sparse_binary_matrix::from_nested_csc(0, 3, {{}, {}, {}}); + dem.in_syndrome = sparse_binary_matrix::from_nested_csc(1, 3, {{}, {}, {}}); + dem.out_syndrome = sparse_binary_matrix::from_nested_csc(1, 3, {{}, {}, {}}); + dem.fault_priors = {0.05, 0.03, 0.02}; + dem.in_tags = {0}; + dem.out_tags = {0}; + + const auto canon = + dem_merge_duplicate_columns(dem, prior_combine_mode::sum_combine); + EXPECT_EQ(canon.num_faults(), 1u); + EXPECT_NEAR(canon.fault_priors[0], 0.10, 1e-12); +} + +// Two columns with DIFFERENT supports must stay separate. +TEST(ExtendedDem, MergeDuplicateColumns_DistinctSupports_Unchanged) { + extended_dem dem; + dem.interior = sparse_binary_matrix::from_nested_csc(2, 2, {{0}, {1}}); + dem.observables = sparse_binary_matrix::from_nested_csc(0, 2, {{}, {}}); + dem.in_syndrome = sparse_binary_matrix::from_nested_csc(1, 2, {{}, {}}); + dem.out_syndrome = sparse_binary_matrix::from_nested_csc(1, 2, {{}, {}}); + dem.fault_priors = {0.1, 0.2}; + dem.in_tags = {0}; + dem.out_tags = {0}; + + EXPECT_TRUE(are_dem_columns_unique(dem)); + const auto canon = dem_merge_duplicate_columns(dem); + EXPECT_EQ(canon.num_faults(), 2u); + EXPECT_EQ(canon.fault_priors, dem.fault_priors); +} + +// Columns are compared over GF(2): sparse_binary_matrix stores index lists as +// given, so a source may list a row twice in one column. Such a column denotes +// the same vector as one omitting that row and must merge with it. +TEST(ExtendedDem, MergeDuplicateColumns_ComparesColumnsOverGf2) { + extended_dem dem; + // Column 0 fires interior row 1 only. Column 1 lists row 0 twice (which + // cancels) plus row 1, so it denotes the same column as column 0. Column 2 + // fires row 0 once and must stay separate. + dem.interior = + sparse_binary_matrix::from_nested_csc(2, 3, {{1}, {0, 1, 0}, {0}}); + dem.observables = sparse_binary_matrix::from_nested_csc(0, 3, {{}, {}, {}}); + dem.in_syndrome = sparse_binary_matrix::from_nested_csc(1, 3, {{}, {}, {}}); + dem.out_syndrome = sparse_binary_matrix::from_nested_csc(1, 3, {{}, {}, {}}); + dem.fault_priors = {0.1, 0.2, 0.3}; + dem.in_tags = {0}; + dem.out_tags = {0}; + + EXPECT_FALSE(are_dem_columns_unique(dem)); + EXPECT_THROW(assert_dem_columns_unique(dem), std::invalid_argument); + + const auto canon = + dem_merge_duplicate_columns(dem, prior_combine_mode::or_combine); + ASSERT_EQ(canon.num_faults(), 2u); + EXPECT_TRUE(are_dem_columns_unique(canon)); + + // Lexicographic output order puts {0} before {1}, and the cancelled row is + // gone from the stored column. + const auto cols = canon.interior.to_nested_csc(); + EXPECT_EQ(cols[0], std::vector{0}); + EXPECT_EQ(cols[1], std::vector{1}); + EXPECT_NEAR(canon.fault_priors[0], 0.3, 1e-12); + // XOR merge of the two GF(2)-equal columns: 0.1 + 0.2 - 2*0.1*0.2 = 0.26. + EXPECT_NEAR(canon.fault_priors[1], 0.26, 1e-12); +} + +// Tags and row-block sizes pass through unchanged after merging. +TEST(ExtendedDem, MergeDuplicateColumns_TagsPreserved) { + auto dem_chunk = rep3_dem_chunk_x(); + dem_chunk.in_tags = {42, 99}; + dem_chunk.out_tags = {42, 99}; + const auto canon = dem_merge_duplicate_columns(dem_chunk); + EXPECT_EQ(canon.in_tags, dem_chunk.in_tags); + EXPECT_EQ(canon.out_tags, dem_chunk.out_tags); + EXPECT_EQ(canon.num_interior(), dem_chunk.num_interior()); + EXPECT_EQ(canon.num_observables(), dem_chunk.num_observables()); + EXPECT_EQ(canon.num_seam_rows(), dem_chunk.num_seam_rows()); +} + +// dem_stitch_merged == dem_merge_duplicate_columns(dem_stitch_all(...)). +TEST(ExtendedDem, StitchMerged_MatchesStitchThenMergeDuplicates) { + const std::vector dem_chunks(4, rep3_dem_chunk_x()); + const auto via_stitch_then_merge = + dem_merge_duplicate_columns(dem_stitch_all(dem_chunks)); + const auto via_stitch_merged = dem_stitch_merged(dem_chunks); + + EXPECT_EQ(via_stitch_merged.num_faults(), via_stitch_then_merge.num_faults()); + EXPECT_EQ(via_stitch_merged.fault_priors, via_stitch_then_merge.fault_priors); +} + +// --------------------------------------------------------------------------- +// Asymmetric (phase) chunks +// --------------------------------------------------------------------------- + +// The init / bulk / final phase chunks of a d=5 repetition code. Fault columns +// 0..4 are the five data qubits, 5..8 the four measurement errors; the final +// phase drops the measurement errors because its destructive data readout has +// none, leaving five columns. +// +// These are the chunks a repeated-round decomposition streams: init owns the +// first round's detectors outright (round 0 is compared against the zero +// initial state), bulk owns none of its own and produces detectors only at the +// seams, and final closes the last seam. + +constexpr uint32_t kRep5Checks = 4; + +extended_dem rep5_phase_init() { + extended_dem dem; + dem.interior = sparse_binary_matrix::from_nested_csr( + kRep5Checks, 9, {{0, 1, 5}, {1, 2, 6}, {2, 3, 7}, {3, 4, 8}}); + dem.observables = sparse_binary_matrix::from_nested_csr(1, 9, {{0}}); + dem.in_syndrome = sparse_binary_matrix::from_nested_csr(0, 9, {}); + dem.out_syndrome = sparse_binary_matrix::from_nested_csr( + kRep5Checks, 9, {{5}, {6}, {7}, {8}}); + dem.fault_priors.assign(9, 0.02); + dem.out_tags = {0, 1, 2, 3}; + return dem; +} + +extended_dem rep5_phase_bulk() { + extended_dem dem; + dem.interior = sparse_binary_matrix::from_nested_csr(0, 9, {}); + dem.observables = sparse_binary_matrix::from_nested_csr(1, 9, {{0}}); + dem.in_syndrome = sparse_binary_matrix::from_nested_csr( + kRep5Checks, 9, {{0, 1, 5}, {1, 2, 6}, {2, 3, 7}, {3, 4, 8}}); + dem.out_syndrome = sparse_binary_matrix::from_nested_csr( + kRep5Checks, 9, {{5}, {6}, {7}, {8}}); + dem.fault_priors.assign(9, 0.02); + dem.in_tags = {0, 1, 2, 3}; + dem.out_tags = {0, 1, 2, 3}; + return dem; +} + +extended_dem rep5_phase_final() { + extended_dem dem; + dem.interior = sparse_binary_matrix::from_nested_csr(0, 5, {}); + dem.observables = sparse_binary_matrix::from_nested_csr(1, 5, {{0}}); + dem.in_syndrome = sparse_binary_matrix::from_nested_csr( + kRep5Checks, 5, {{0, 1}, {1, 2}, {2, 3}, {3, 4}}); + dem.out_syndrome = sparse_binary_matrix::from_nested_csr(0, 5, {}); + dem.fault_priors.assign(5, 0.02); + dem.in_tags = {0, 1, 2, 3}; + return dem; +} + +TEST(ExtendedDemPhases, InitHasNoIncomingSeam) { + const auto init = rep5_phase_init(); + EXPECT_EQ(init.num_in_seam_rows(), 0u); + EXPECT_EQ(init.num_out_seam_rows(), kRep5Checks); + EXPECT_EQ(init.num_interior(), kRep5Checks); +} + +TEST(ExtendedDemPhases, FinalHasNoOutgoingSeam) { + const auto fin = rep5_phase_final(); + EXPECT_EQ(fin.num_in_seam_rows(), kRep5Checks); + EXPECT_EQ(fin.num_out_seam_rows(), 0u); + EXPECT_EQ(fin.num_interior(), 0u); +} + +TEST(ExtendedDemPhases, BulkOwnsNoInteriorRows) { + const auto bulk = rep5_phase_bulk(); + EXPECT_EQ(bulk.num_in_seam_rows(), kRep5Checks); + EXPECT_EQ(bulk.num_out_seam_rows(), kRep5Checks); + EXPECT_EQ(bulk.num_interior(), 0u); +} + +// num_seam_rows() keeps reporting the incoming width, so every existing +// uniform-chunk call site is unaffected by the in/out split. +TEST(ExtendedDemPhases, SeamRowsAliasesIncomingWidth) { + const auto uniform = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + EXPECT_EQ(uniform.num_seam_rows(), uniform.num_in_seam_rows()); + EXPECT_EQ(uniform.num_in_seam_rows(), uniform.num_out_seam_rows()); + + const auto init = rep5_phase_init(); + EXPECT_EQ(init.num_seam_rows(), init.num_in_seam_rows()); +} + +// Stitching init to bulk contracts init's outgoing seam against bulk's +// incoming one even though init's own incoming seam is empty. +TEST(ExtendedDemPhases, StitchInitToBulk) { + const auto ab = dem_stitch(rep5_phase_init(), rep5_phase_bulk()); + + // init's own 4 detectors, then the 4 new seam detectors; bulk adds none. + EXPECT_EQ(ab.num_interior(), 2u * kRep5Checks); + EXPECT_EQ(ab.num_in_seam_rows(), 0u); + EXPECT_EQ(ab.num_out_seam_rows(), kRep5Checks); + EXPECT_EQ(ab.num_faults(), 18u); + EXPECT_EQ(ab.num_observables(), 1u); +} + +// The seam detector row is the XOR of init's outgoing syndrome and bulk's +// incoming one, with bulk's faults offset into the second half-column block. +TEST(ExtendedDemPhases, SeamRowXorsAdjacentSyndromes) { + const auto ab = dem_stitch(rep5_phase_init(), rep5_phase_bulk()); + const auto rows = ab.interior.to_nested_csr(); + ASSERT_EQ(rows.size(), 2u * kRep5Checks); + + // Seam rows follow init's own interior rows. Row 0 of the seam block: + // init measurement error 0 (column 5) plus bulk's data qubits 0, 1 and + // measurement error 0 (columns 9+0, 9+1, 9+5). + const std::vector expected{5, 9, 10, 14}; + EXPECT_EQ(rows[kRep5Checks], expected); +} + +// The full init -> bulk -> final chain closes into a flat DEM whose detector +// count is one round of init detectors plus one per seam. +TEST(ExtendedDemPhases, StitchAllAndCloseChain) { + const std::vector phases{rep5_phase_init(), rep5_phase_bulk(), + rep5_phase_final()}; + const auto stitched = dem_stitch_all(phases); + EXPECT_EQ(stitched.num_in_seam_rows(), 0u); + EXPECT_EQ(stitched.num_out_seam_rows(), 0u); + EXPECT_EQ(stitched.num_faults(), 23u); + + const auto flat = dem_close(stitched); + ASSERT_EQ(flat.detector_error_matrix.rank(), 2u); + // 4 init detectors + 4 (init|bulk) seam + 4 (bulk|final) seam. + EXPECT_EQ(flat.detector_error_matrix.shape()[0], 3u * kRep5Checks); + EXPECT_EQ(flat.detector_error_matrix.shape()[1], 23u); + EXPECT_EQ(flat.observables_flips_matrix.shape()[0], 1u); + EXPECT_EQ(flat.error_rates.size(), 23u); +} + +// Repeating the bulk phase adds one seam's worth of detectors per repeat. +TEST(ExtendedDemPhases, RepeatingBulkAddsOneSeamPerRound) { + for (std::size_t repeats = 1; repeats <= 4; ++repeats) { + std::vector phases{rep5_phase_init()}; + phases.insert(phases.end(), repeats, rep5_phase_bulk()); + phases.push_back(rep5_phase_final()); + + const auto flat = dem_close(dem_stitch_all(phases)); + // init detectors + one seam per adjacency (repeats + 1 of them). + EXPECT_EQ(flat.detector_error_matrix.shape()[0], + (repeats + 2) * kRep5Checks); + EXPECT_EQ(flat.detector_error_matrix.shape()[1], 9u * (repeats + 1) + 5u); + } +} + +// A final chunk cannot be followed by anything: its outgoing seam is empty. +TEST(ExtendedDemPhases, StitchAfterFinalThrows) { + EXPECT_THROW(dem_stitch(rep5_phase_final(), rep5_phase_bulk()), + std::invalid_argument); +} + +// Nothing can precede init: its incoming seam is empty. +TEST(ExtendedDemPhases, StitchBeforeInitThrows) { + EXPECT_THROW(dem_stitch(rep5_phase_bulk(), rep5_phase_init()), + std::invalid_argument); +} + +// init, `rounds - 2` bulks, final: one chunk per round. +std::vector rep5_phases(std::size_t rounds) { + std::vector phases{rep5_phase_init()}; + phases.insert(phases.end(), rounds - 2, rep5_phase_bulk()); + phases.push_back(rep5_phase_final()); + return phases; +} + +// dem_close_all() takes a single O(T) pass and the left fold takes the general +// route. They have to agree on phase chunks exactly as they do on uniform ones: +// every round-indexed map below is built on that equivalence holding. +TEST(ExtendedDemPhases, CloseAllMatchesStitchAndClose) { + for (std::size_t rounds = 2; rounds <= 6; ++rounds) { + const auto phases = rep5_phases(rounds); + const auto via_stitch = dem_close(dem_stitch_all(phases)); + const auto via_close_all = dem_close_all(phases); + + EXPECT_EQ(via_close_all.num_detectors(), via_stitch.num_detectors()) + << "rounds=" << rounds; + EXPECT_EQ(via_close_all.num_error_mechanisms(), + via_stitch.num_error_mechanisms()); + EXPECT_TRUE(tensors_equal(via_close_all.detector_error_matrix, + via_stitch.detector_error_matrix)) + << "rounds=" << rounds; + EXPECT_TRUE(tensors_equal(via_close_all.observables_flips_matrix, + via_stitch.observables_flips_matrix)); + EXPECT_EQ(via_close_all.error_rates, via_stitch.error_rates); + } +} + +// init carries round 0 in its interior instead of an incoming seam band, so it +// still spans exactly one round rather than the two a naive 1 + interior/d +// would report. +TEST(ExtendedDemPhases, EachPhaseSpansOneRound) { + EXPECT_EQ(dem_chunk_rounds(rep5_phase_init()), 1u); + EXPECT_EQ(dem_chunk_rounds(rep5_phase_bulk()), 1u); + EXPECT_EQ(dem_chunk_rounds(rep5_phase_final()), 1u); + + for (std::size_t rounds = 2; rounds <= 6; ++rounds) + EXPECT_EQ(dem_chunks_to_rounds(rep5_phases(rounds)), rounds); +} + +// Pre-stitching part of the chain does not change the rounds it reports, so a +// caller may group phases however it likes. +TEST(ExtendedDemPhases, PreStitchedPhasesReportTheSameRounds) { + const auto phases = rep5_phases(4); + const auto head = dem_stitch(phases[0], phases[1]); + EXPECT_EQ(head.num_in_seam_rows(), 0u) << "still an open init end"; + EXPECT_EQ(dem_chunk_rounds(head), 2u); + EXPECT_EQ(dem_chunks_to_rounds({head, phases[2], phases[3]}), 4u); +} + +// The detector count dem_close_all() emits is the round count the maps assume, +// which is what keeps the derived H and D the same height. +TEST(ExtendedDemPhases, DetectorCountAgreesWithRoundMaps) { + for (std::size_t rounds = 2; rounds <= 6; ++rounds) { + const auto phases = rep5_phases(rounds); + const std::size_t want = rounds * kRep5Checks; + EXPECT_EQ(dem_close_all(phases).num_detectors(), want); + EXPECT_EQ(dem_chunks_to_d_sparse(phases).size(), want); + EXPECT_EQ(dem_chunks_to_detector_round(phases).size(), want); + } +} + +// Round 0 is read directly; later rounds are xor'd against their predecessor. +TEST(ExtendedDemPhases, DSparseIsTheTimelikeLayout) { + const uint32_t rounds = 3; + const auto d_sparse = dem_chunks_to_d_sparse(rep5_phases(rounds)); + ASSERT_EQ(d_sparse.size(), rounds * kRep5Checks); + + for (uint32_t k = 0; k < kRep5Checks; ++k) { + const std::vector want{k}; + EXPECT_EQ(d_sparse[k], want); + } + for (uint32_t r = 1; r < rounds; ++r) + for (uint32_t k = 0; k < kRep5Checks; ++k) { + const std::vector want{(r - 1) * kRep5Checks + k, + r * kRep5Checks + k}; + EXPECT_EQ(d_sparse[r * kRep5Checks + k], want); + } +} + +TEST(ExtendedDemPhases, DetectorRoundLabelsOneBandPerRound) { + const int32_t rounds = 3; + std::vector want; + for (int32_t r = 0; r < rounds; ++r) + want.insert(want.end(), kRep5Checks, r); + EXPECT_EQ(dem_chunks_to_detector_round(rep5_phases(rounds)), want); +} + +// O_sparse is keyed on fault columns, so the phases' differing fault counts are +// all it has to track: column 0 of each phase flips the observable. +TEST(ExtendedDemPhases, OSparseSpansEveryPhase) { + const auto o_sparse = dem_chunks_to_o_sparse(rep5_phases(3)); + ASSERT_EQ(o_sparse.size(), 1u); + const std::vector want{0, 9, 18}; + EXPECT_EQ(o_sparse[0], want); +} + +// Only the two open ends are exempt. A seam that goes missing mid-chain is +// still an error rather than a shorter experiment. +TEST(ExtendedDemPhases, NonContractingSeamThrows) { + const std::vector broken{rep5_phase_init(), rep5_phase_final(), + rep5_phase_bulk()}; + EXPECT_THROW(dem_close_all(broken), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_rounds(broken), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_d_sparse(broken), std::invalid_argument); +} + +// Equal seam widths are not enough: the two sides have to name the same checks +// in the same order, or the contraction pairs up unrelated rows. dem_stitch +// enforces this, so the single-pass builder has to enforce it too -- otherwise +// dem_close_all() would quietly accept sequences the fold rejects. +TEST(ExtendedDemPhases, MismatchedSeamTagsThrow) { + auto bulk = rep5_phase_bulk(); + ASSERT_EQ(bulk.in_tags.size(), kRep5Checks); + std::swap(bulk.in_tags[0], bulk.in_tags[1]); + + const std::vector permuted{rep5_phase_init(), bulk, + rep5_phase_final()}; + EXPECT_THROW(dem_stitch_all(permuted), std::invalid_argument); + EXPECT_THROW(dem_close_all(permuted), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_rounds(permuted), std::invalid_argument); +} + +// A chunk closed on both sides carries no width to count rounds against. +TEST(ExtendedDemPhases, FullyClosedChunkCannotBeCounted) { + const auto closed = dem_stitch_all(rep5_phases(3)); + ASSERT_EQ(closed.num_in_seam_rows(), 0u); + ASSERT_EQ(closed.num_out_seam_rows(), 0u); + EXPECT_THROW(dem_chunk_rounds(closed), std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// Declarative phase specs +// --------------------------------------------------------------------------- + +// The same d=5 repetition code as the hand-built chunks above, written the way +// it appears in decoder configuration YAML. +dem_chunks_spec rep5_spec() { + dem_chunks_spec spec; + + spec.init.num_faults = 9; + spec.init.H_mid_sparse = {0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1}; + spec.init.H_out_sparse = {5, -1, 6, -1, 7, -1, 8, -1}; + spec.init.O_sparse = {0, -1}; + spec.init.error_rates.assign(9, 0.02); + + spec.bulk.num_faults = 9; + spec.bulk.H_in_sparse = {0, 1, 5, -1, 1, 2, 6, -1, 2, 3, 7, -1, 3, 4, 8, -1}; + spec.bulk.H_out_sparse = {5, -1, 6, -1, 7, -1, 8, -1}; + spec.bulk.O_sparse = {0, -1}; + spec.bulk.error_rates.assign(9, 0.02); + + spec.final.num_faults = 5; + spec.final.H_in_sparse = {0, 1, -1, 1, 2, -1, 2, 3, -1, 3, 4, -1}; + spec.final.O_sparse = {0, -1}; + spec.final.error_rates.assign(5, 0.02); + + return spec; +} + +void expect_chunks_equal(const extended_dem &actual, + const extended_dem &expected) { + EXPECT_EQ(actual.num_faults(), expected.num_faults()); + EXPECT_TRUE( + tensors_equal(actual.interior.to_dense(), expected.interior.to_dense())); + EXPECT_TRUE(tensors_equal(actual.observables.to_dense(), + expected.observables.to_dense())); + EXPECT_TRUE(tensors_equal(actual.in_syndrome.to_dense(), + expected.in_syndrome.to_dense())); + EXPECT_TRUE(tensors_equal(actual.out_syndrome.to_dense(), + expected.out_syndrome.to_dense())); + EXPECT_EQ(actual.fault_priors, expected.fault_priors); + EXPECT_EQ(actual.in_tags, expected.in_tags); + EXPECT_EQ(actual.out_tags, expected.out_tags); +} + +// The declarative form and the hand-built chunks describe the same DEM. +TEST(DemChunkSpec, MatchesHandBuiltChunks) { + const auto spec = rep5_spec(); + expect_chunks_equal(dem_chunk_from_spec(spec.init), rep5_phase_init()); + expect_chunks_equal(dem_chunk_from_spec(spec.bulk), rep5_phase_bulk()); + expect_chunks_equal(dem_chunk_from_spec(spec.final), rep5_phase_final()); +} + +TEST(DemChunkSpec, ValidSpecValidates) { + EXPECT_NO_THROW(rep5_spec().validate()); + EXPECT_TRUE(rep5_spec().has_bulk()); + EXPECT_FALSE(rep5_spec().is_empty()); + EXPECT_TRUE(dem_chunks_spec{}.is_empty()); +} + +// sparse_binary_matrix columns are uint32_t-indexed; reject a wider count +// before error_rates.size() is compared (so the test need not allocate +// UINT32_MAX+1 priors). +TEST(DemChunkSpec, NumFaultsMustFitUint32) { + dem_chunk_spec spec; + spec.num_faults = + static_cast(std::numeric_limits::max()) + 1ull; + EXPECT_THROW(spec.validate("test"), std::invalid_argument); +} + +// Expansion produces init, one bulk per middle round, then final. +TEST(DemChunkSpec, ExpandsToRequestedRoundCount) { + const auto spec = rep5_spec(); + for (std::size_t rounds = 2; rounds <= 6; ++rounds) { + const auto chunks = dem_chunks_from_spec(spec, rounds); + ASSERT_EQ(chunks.size(), rounds); + EXPECT_EQ(chunks.front().num_in_seam_rows(), 0u); + EXPECT_EQ(chunks.back().num_out_seam_rows(), 0u); + + const auto flat = dem_close(dem_stitch_all(chunks)); + // init's own detectors plus one seam per adjacency. + EXPECT_EQ(flat.detector_error_matrix.shape()[0], rounds * kRep5Checks); + } +} + +// A two-round experiment needs no bulk phase at all. +TEST(DemChunkSpec, TwoRoundsNeedsNoBulk) { + auto spec = rep5_spec(); + spec.bulk = dem_chunk_spec{}; + ASSERT_FALSE(spec.has_bulk()); + EXPECT_NO_THROW(spec.validate()); + + const auto chunks = dem_chunks_from_spec(spec, 2); + EXPECT_EQ(chunks.size(), 2u); + EXPECT_THROW(dem_chunks_from_spec(spec, 3), std::invalid_argument); +} + +TEST(DemChunkSpec, TooFewRoundsThrows) { + const auto spec = rep5_spec(); + EXPECT_THROW(dem_chunks_from_spec(spec, 0), std::invalid_argument); + EXPECT_THROW(dem_chunks_from_spec(spec, 1), std::invalid_argument); +} + +TEST(DemChunkSpec, ZeroFaultsThrows) { + dem_chunk_spec spec; + EXPECT_THROW(spec.validate("chunk"), std::invalid_argument); +} + +TEST(DemChunkSpec, ErrorRateCountMustMatchNumFaults) { + auto spec = rep5_spec(); + spec.init.error_rates.pop_back(); + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +TEST(DemChunkSpec, ErrorRateOutsideUnitIntervalThrows) { + auto spec = rep5_spec(); + spec.bulk.error_rates[3] = 1.5; + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +TEST(DemChunkSpec, IndexBeyondNumFaultsThrows) { + auto spec = rep5_spec(); + spec.final.H_in_sparse = {0, 5, -1}; + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +// A list whose last row is not terminated would silently lose that row, so it +// is rejected rather than truncated. +TEST(DemChunkSpec, MissingRowTerminatorThrows) { + auto spec = rep5_spec(); + spec.init.H_out_sparse = {5, -1, 6}; + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +TEST(DemChunkSpec, InitWithIncomingSeamThrows) { + auto spec = rep5_spec(); + spec.init.H_in_sparse = {0, -1, 1, -1, 2, -1, 3, -1}; + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +TEST(DemChunkSpec, FinalWithOutgoingSeamThrows) { + auto spec = rep5_spec(); + spec.final.H_out_sparse = {0, -1, 1, -1, 2, -1, 3, -1}; + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +// bulk repeats, so a bulk whose seams differ in width cannot stitch to itself. +TEST(DemChunkSpec, AsymmetricBulkThrows) { + auto spec = rep5_spec(); + spec.bulk.H_out_sparse = {5, -1, 6, -1}; + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +TEST(DemChunkSpec, SeamWidthMismatchAcrossPhasesThrows) { + auto spec = rep5_spec(); + spec.final.H_in_sparse = {0, 1, -1, 1, 2, -1}; + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +TEST(DemChunkSpec, ObservableCountMismatchThrows) { + auto spec = rep5_spec(); + spec.final.O_sparse = {0, -1, 1, -1}; + EXPECT_THROW(spec.validate(), std::invalid_argument); +} + +TEST(DemChunkSpec, MissingRequiredPhaseThrows) { + auto no_init = rep5_spec(); + no_init.init = dem_chunk_spec{}; + EXPECT_THROW(no_init.validate(), std::invalid_argument); + + auto no_final = rep5_spec(); + no_final.final = dem_chunk_spec{}; + EXPECT_THROW(no_final.validate(), std::invalid_argument); + + EXPECT_THROW(dem_chunks_spec{}.validate(), std::invalid_argument); +} + +// A genuine width disagreement across the contracted seam still throws. +TEST(ExtendedDemPhases, MismatchedSeamWidthThrows) { + auto narrow = rep5_phase_bulk(); + narrow.in_syndrome = + sparse_binary_matrix::from_nested_csr(2, 9, {{0, 1, 5}, {1, 2, 6}}); + narrow.in_tags = {0, 1}; + EXPECT_THROW(dem_stitch(rep5_phase_init(), narrow), std::invalid_argument); +} + +// --------------------------------------------------------------------------- +// Streaming decomposition +// +// These pin the structural facts a streaming decoder relies on: that a round's +// decode problem can be built once, at configuration time, without knowing how +// many rounds the experiment will run. +// +// They say nothing about decode *quality*. Decoding round by round is a +// windowed approximation of a global decode and the two need not agree on +// corrections; that tradeoff is the decoder's, not the model's. What is exact, +// and what is checked here, is the shape of the problem each round poses. +// --------------------------------------------------------------------------- + +// Fault-column offsets of the phase chunks within the closed model, plus a +// trailing total: chunk i owns columns [offsets[i], offsets[i + 1]). +std::vector +rep5_fault_offsets(const std::vector &phases) { + std::vector offsets{0}; + for (const auto &chunk : phases) + offsets.push_back(offsets.back() + chunk.num_faults()); + return offsets; +} + +using dense_block = std::vector>; + +dense_block block_of(const cudaqx::tensor &m, std::size_t row_begin, + std::size_t row_end, std::size_t col_begin, + std::size_t col_end) { + dense_block block; + for (std::size_t r = row_begin; r < row_end; ++r) { + std::vector row; + for (std::size_t c = col_begin; c < col_end; ++c) + row.push_back(m.at({r, c})); + block.push_back(std::move(row)); + } + return block; +} + +// Round r's detector band together with the fault columns of the chunks it can +// reach: its own, and its predecessor's. +dense_block round_band(const std::vector &phases, + std::size_t round) { + const auto offsets = rep5_fault_offsets(phases); + const auto closed = dem_close_all(phases); + return block_of(closed.detector_error_matrix, round * kRep5Checks, + (round + 1) * kRep5Checks, + offsets[round == 0 ? 0 : round - 1], offsets[round + 1]); +} + +// The locality claim. A round's detectors are supported entirely on two +// adjacent chunks' fault columns, so a decoder handling round r never needs +// columns from rounds it has already retired or has yet to see. +TEST(ExtendedDemStreaming, BandsTouchOnlyTwoAdjacentChunks) { + for (std::size_t rounds = 2; rounds <= 7; ++rounds) { + const auto phases = rep5_phases(rounds); + const auto offsets = rep5_fault_offsets(phases); + const auto closed = dem_close_all(phases); + const auto &H = closed.detector_error_matrix; + ASSERT_EQ(H.shape()[0], rounds * kRep5Checks); + + for (std::size_t round = 0; round < rounds; ++round) { + const std::size_t lo = offsets[round == 0 ? 0 : round - 1]; + const std::size_t hi = offsets[round + 1]; + for (std::size_t r = round * kRep5Checks; r < (round + 1) * kRep5Checks; + ++r) + for (std::size_t c = 0; c < H.shape()[1]; ++c) + if (H.at({r, c}) != 0u) + EXPECT_TRUE(c >= lo && c < hi) + << "rounds=" << rounds << " round=" << round << " row=" << r + << " column " << c << " escapes the window [" << lo << ", " + << hi << ")"; + } + } +} + +// The reuse claim, and the reason a streaming decoder needs no per-round setup: +// every interior bulk round poses the *same* problem. Not merely the same +// dimensions -- the identical matrix, for every round and every round count. +TEST(ExtendedDemStreaming, EveryBulkRoundPosesOneFixedProblem) { + const auto reference = round_band(rep5_phases(5), 2); + ASSERT_EQ(reference.size(), kRep5Checks); + ASSERT_EQ(reference.front().size(), 18u) << "two bulk chunks of 9 faults"; + + for (std::size_t rounds = 4; rounds <= 9; ++rounds) { + const auto phases = rep5_phases(rounds); + // Rounds 1 and rounds-1 abut init and final; the rest are interior bulk. + for (std::size_t round = 2; round + 1 < rounds; ++round) + EXPECT_EQ(round_band(phases, round), reference) + << "rounds=" << rounds << " round=" << round; + } +} + +// The boundary rounds are likewise fixed, so the whole experiment reduces to +// three matrices built once: init, bulk, final. Nothing here scales with the +// round count, which is what lets the count stay unknown until the shot ends. +TEST(ExtendedDemStreaming, BoundaryRoundsPoseFixedProblemsToo) { + const auto first = round_band(rep5_phases(4), 0); + const auto entering_bulk = round_band(rep5_phases(4), 1); + const auto last = round_band(rep5_phases(4), 3); + + for (std::size_t rounds = 4; rounds <= 9; ++rounds) { + const auto phases = rep5_phases(rounds); + EXPECT_EQ(round_band(phases, 0), first) << "rounds=" << rounds; + EXPECT_EQ(round_band(phases, 1), entering_bulk) << "rounds=" << rounds; + EXPECT_EQ(round_band(phases, rounds - 1), last) << "rounds=" << rounds; + } +} + +// The detector side of the same claim: forming round r's detectors reads only +// rounds r-1 and r of the measurement record, so the streaming front-end needs +// one round of lookback and no more. +TEST(ExtendedDemStreaming, DetectorsNeedOneRoundOfLookback) { + for (std::size_t rounds = 2; rounds <= 7; ++rounds) { + const auto d_sparse = dem_chunks_to_d_sparse(rep5_phases(rounds)); + ASSERT_EQ(d_sparse.size(), rounds * kRep5Checks); + + for (std::size_t det = 0; det < d_sparse.size(); ++det) { + const std::size_t round = det / kRep5Checks; + const std::size_t earliest = (round == 0 ? 0 : round - 1) * kRep5Checks; + const std::size_t past_end = (round + 1) * kRep5Checks; + for (const auto bit : d_sparse[det]) + EXPECT_TRUE(bit >= earliest && bit < past_end) + << "rounds=" << rounds << " detector=" << det << " reads bit " + << bit << " outside [" << earliest << ", " << past_end << ")"; + } + } +} + +// The chunks a streaming step spans: the one retiring and the one that just +// arrived. The opening has nothing to retire yet and the flush nothing more to +// wait for, so those two steps span a single chunk. +std::vector step_chunks(const std::vector &phases, + std::size_t step) { + const std::size_t retiring = step == 0 ? 0 : step - 1; + const std::size_t arriving = std::min(step, phases.size() - 1); + return {phases.begin() + retiring, phases.begin() + arriving + 1}; +} + +// What the previous tests read out of the whole experiment's DEM, built instead +// from the step's own chunks: stitch them, close the seam they arrive on, and +// drop the open one they hand forward. That construction never mentions the +// round count, which is what lets a round be posed as it arrives, and what it +// produces is exactly the corresponding block of the whole experiment. +// +// The one thing the window does not carry is the retiring band's dependence on +// the chunk before it, which is why the comparison stops at the window's own +// columns. That chunk was committed a round earlier, and backing its +// corrections out of the syndrome is what keeps the truncated band consistent. +TEST(ExtendedDemStreaming, AWindowIsTheExperimentRestrictedToItsOwnChunks) { + for (std::size_t rounds = 3; rounds <= 8; ++rounds) { + const auto phases = rep5_phases(rounds); + const auto offsets = rep5_fault_offsets(phases); + const auto whole = dem_close_all(phases); + + // Steps run 0..R inclusive, the opening and the flush included. + for (std::size_t step = 0; step <= rounds; ++step) { + const auto chunks = step_chunks(phases, step); + const auto window = dem_close(dem_stitch_all(chunks)); + + const std::size_t first_chunk = step == 0 ? 0 : step - 1; + const std::size_t past_chunk = std::min(step, rounds - 1) + 1; + const std::size_t row_begin = first_chunk * kRep5Checks; + const std::size_t row_end = past_chunk * kRep5Checks; + + ASSERT_EQ(window.num_detectors(), row_end - row_begin) + << "rounds=" << rounds << " step=" << step; + ASSERT_EQ(window.num_error_mechanisms(), + offsets[past_chunk] - offsets[first_chunk]); + + EXPECT_EQ(block_of(window.detector_error_matrix, 0, + window.num_detectors(), 0, + window.num_error_mechanisms()), + block_of(whole.detector_error_matrix, row_begin, row_end, + offsets[first_chunk], offsets[past_chunk])) + << "rounds=" << rounds << " step=" << step; + + // The priors have to come across too, or the window would be weighted + // differently from the experiment it is a piece of. + const std::decay_t want( + whole.error_rates.begin() + offsets[first_chunk], + whole.error_rates.begin() + offsets[past_chunk]); + EXPECT_EQ(window.error_rates, want) + << "rounds=" << rounds << " step=" << step; + } // end - for(step) + } // end - for(rounds) +} + +// The observables travel with the window, so a round can say what its committed +// faults do to the logical operators without consulting the whole experiment. +TEST(ExtendedDemStreaming, AWindowCarriesItsChunksObservables) { + const std::size_t rounds = 5; + const auto phases = rep5_phases(rounds); + const auto offsets = rep5_fault_offsets(phases); + const auto whole = dem_close_all(phases); + + for (std::size_t step = 0; step <= rounds; ++step) { + const auto window = dem_close(dem_stitch_all(step_chunks(phases, step))); + const std::size_t first_chunk = step == 0 ? 0 : step - 1; + const std::size_t past_chunk = std::min(step, rounds - 1) + 1; + + EXPECT_EQ( + block_of(window.observables_flips_matrix, 0, window.num_observables(), + 0, window.num_error_mechanisms()), + block_of(whole.observables_flips_matrix, 0, whole.num_observables(), + offsets[first_chunk], offsets[past_chunk])) + << "step=" << step; + } // end - for(step) +} + +// --------------------------------------------------------------------------- +// Internal consistency of a chunk +// --------------------------------------------------------------------------- + +// num_faults() reports in_syndrome's width alone, so a block that disagrees +// with it would be scattered into the wrong columns (or past the end of the +// closed matrix) with nothing else to signal the mistake. +TEST(ExtendedDemValidate, BlockWiderThanTheChunkThrows) { + auto dem_chunk = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + ASSERT_EQ(dem_chunk.num_faults(), 3u); + // One interior row that claims four fault columns instead of three. + dem_chunk.interior = sparse_binary_matrix::from_nested_csr(1, 4, {{3}}); + + EXPECT_THROW(dem_chunk.validate("test"), std::invalid_argument); + EXPECT_THROW(dem_close(dem_chunk), std::invalid_argument); + EXPECT_THROW(dem_stitch(dem_chunk, dem_chunk), std::invalid_argument); + EXPECT_THROW(dem_close_all({dem_chunk}), std::invalid_argument); +} + +// A prior per fault is what dem_close copies into error_rates, so a short or +// long list would silently reweight the DEM. +TEST(ExtendedDemValidate, PriorCountMustMatchFaultCount) { + auto dem_chunk = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + dem_chunk.fault_priors.pop_back(); + + EXPECT_THROW(dem_chunk.validate("test"), std::invalid_argument); + EXPECT_THROW(dem_close(dem_chunk), std::invalid_argument); + EXPECT_THROW(dem_stitch_all({dem_chunk}), std::invalid_argument); +} + +// Tags name seam rows one for one; a mismatched count means the seam cannot be +// checked for contractibility at all. +TEST(ExtendedDemValidate, TagCountMustMatchSeamRows) { + auto dem_chunk = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + dem_chunk.out_tags.push_back(99u); + + EXPECT_THROW(dem_chunk.validate("test"), std::invalid_argument); +} + +// A well-formed chunk, and every chunk these builders produce, must pass. +TEST(ExtendedDemValidate, WellFormedChunksPass) { + EXPECT_NO_THROW( + extended_dem_from_css_matrices(rep3(), px_only(0.01)).validate("test")); + // All-zero rates leave a default-constructed chunk, which is consistent. + EXPECT_NO_THROW(extended_dem_from_css_matrices(rep3(), css_noise_params{}) + .validate("test")); + for (const auto &phase : rep5_phases(4)) + EXPECT_NO_THROW(phase.validate("test")); +} + +// --------------------------------------------------------------------------- +// Noise rate validation +// --------------------------------------------------------------------------- + +// A negative or NaN rate is inactive under the "rate > 0" rule that selects +// fault columns, so without this check a mistyped rate quietly builds a +// smaller DEM instead of failing. +TEST(ExtendedDemNoiseValidation, RejectsRatesOutsideTheUnitInterval) { + for (const double bad : {-0.01, 1.5, std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}) { + css_noise_params noise; + noise.px = bad; + EXPECT_THROW(extended_dem_from_css_matrices(rep3(), noise), + std::invalid_argument) + << "px=" << bad; + EXPECT_THROW(dem_from_css_matrices(rep3(), noise), std::invalid_argument) + << "px=" << bad; + } +} + +TEST(ExtendedDemNoiseValidation, RejectsBadPerElementRates) { + css_noise_params per_qubit; + per_qubit.px_per_qubit = {0.01, -0.01, 0.01}; + EXPECT_THROW(extended_dem_from_css_matrices(rep3(), per_qubit), + std::invalid_argument); + + css_noise_params per_check; + per_check.pm_per_check = {0.01, 2.0}; + EXPECT_THROW(extended_dem_from_css_matrices(rep3(), per_check), + std::invalid_argument); +} + +// The bound is inclusive: 0 means "no such fault" and 1 means "always". +TEST(ExtendedDemNoiseValidation, AcceptsTheEndpointsOfTheUnitInterval) { + css_noise_params certain; + certain.px = 1.0; + EXPECT_NO_THROW(extended_dem_from_css_matrices(rep3(), certain)); + EXPECT_NO_THROW(extended_dem_from_css_matrices(rep3(), css_noise_params{})); +} + +// pm_per_check is sized by checks, not qubits, so its message must say so -- +// the two dimensions differ and a wrong one sends the reader to the wrong +// field. +TEST(ExtendedDemNoiseValidation, PerCheckLengthErrorNamesTheCheckCount) { + css_noise_params noise; + noise.pm_per_check = {0.01}; // rep3 has 2 checks + try { + extended_dem_from_css_matrices(rep3(), noise); + ADD_FAILURE() << "expected a length mismatch"; + } catch (const std::invalid_argument &error) { + const std::string message = error.what(); + EXPECT_NE(message.find("n_checks"), std::string::npos) << message; + EXPECT_EQ(message.find("n_qubits"), std::string::npos) << message; + } +} + +// --------------------------------------------------------------------------- +// Prior combination +// --------------------------------------------------------------------------- + +// sum_combine is a small-p approximation whose sum can leave the unit +// interval. It is clamped, because every consumer reads fault_priors as a +// probability. +TEST(ExtendedDemMerge, SumCombineClampsToOne) { + // Three columns of identical support, so all three merge into one whose + // linear sum of priors is 1.2. + extended_dem dem_chunk; + dem_chunk.in_syndrome = + sparse_binary_matrix::from_nested_csc(1, 3, {{0}, {0}, {0}}); + dem_chunk.out_syndrome = dem_chunk.in_syndrome; + dem_chunk.interior = + sparse_binary_matrix::from_nested_csc(0, 3, {{}, {}, {}}); + dem_chunk.observables = + sparse_binary_matrix::from_nested_csc(0, 3, {{}, {}, {}}); + dem_chunk.fault_priors = {0.4, 0.4, 0.4}; + dem_chunk.in_tags = {0}; + dem_chunk.out_tags = {0}; + ASSERT_NO_THROW(dem_chunk.validate("test")); + + const auto merged = + dem_merge_duplicate_columns(dem_chunk, prior_combine_mode::sum_combine); + ASSERT_EQ(merged.num_faults(), 1u); + EXPECT_DOUBLE_EQ(merged.fault_priors[0], 1.0); + + // or_combine is the GF(2) / XOR rule, which also stays in [0, 1]: + // 1/2 * (1 - (1-2*0.4)^3) = 1/2 * (1 - 0.2^3) = 0.496. + const auto ored = + dem_merge_duplicate_columns(dem_chunk, prior_combine_mode::or_combine); + ASSERT_EQ(ored.num_faults(), 1u); + EXPECT_DOUBLE_EQ(ored.fault_priors[0], 0.5 * (1.0 - 0.2 * 0.2 * 0.2)); +} + +// A zero-row block still has to report the chunk's fault width. A +// default-constructed (0-column) empty interior used to pass validate() and +// then OOB inside dem_merge_duplicate_columns / dem_chunks_to_o_sparse. +TEST(ExtendedDemValidate, EmptyBlockMustMatchFaultWidth) { + extended_dem dem_chunk; + dem_chunk.in_syndrome = + sparse_binary_matrix::from_nested_csc(1, 2, {{0}, {0}}); + dem_chunk.out_syndrome = dem_chunk.in_syndrome; + dem_chunk.observables = sparse_binary_matrix::from_nested_csc(0, 2, {{}, {}}); + dem_chunk.fault_priors = {0.1, 0.2}; + dem_chunk.in_tags = {0}; + dem_chunk.out_tags = {0}; + // interior left default-constructed: 0 rows, 0 columns. + + EXPECT_THROW(dem_chunk.validate("test"), std::invalid_argument); + EXPECT_THROW(dem_merge_duplicate_columns(dem_chunk), std::invalid_argument); + EXPECT_THROW(are_dem_columns_unique(dem_chunk), std::invalid_argument); + EXPECT_THROW(assert_dem_columns_unique(dem_chunk), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_o_sparse({dem_chunk}), std::invalid_argument); +} + +// Short prior lists used to be read past the end by merge helpers that skipped +// validate(). +TEST(ExtendedDemValidate, MergeRejectsShortPriorList) { + auto dem_chunk = extended_dem_from_css_matrices(rep3(), px_only(0.01)); + dem_chunk.fault_priors.pop_back(); + + EXPECT_THROW(dem_merge_duplicate_columns(dem_chunk), std::invalid_argument); + EXPECT_THROW(are_dem_columns_unique(dem_chunk), std::invalid_argument); + EXPECT_THROW(assert_dem_columns_unique(dem_chunk), std::invalid_argument); + EXPECT_THROW(dem_chunks_to_o_sparse({dem_chunk}), std::invalid_argument); +} + +} // namespace +} // namespace cudaq::qec