From 425dcde1bffc3e057f194d96f6adabd9c1ca8e68 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Fri, 31 Jul 2026 12:40:03 -0700 Subject: [PATCH 01/24] Unify decoder construction inputs Signed-off-by: Melody Ren --- docs/sphinx/api/qec/cpp_api.rst | 5 +- .../api/qec/cpp_realtime_decoding_api.rst | 9 +- docs/sphinx/components/qec/introduction.rst | 25 +-- .../examples/qec/cpp/real_time_complete.cpp | 4 +- libs/qec/include/cudaq/qec/decoder.h | 95 ++++----- libs/qec/include/cudaq/qec/decoder_inputs.h | 129 ++++++++++++ libs/qec/include/cudaq/qec/experiments.h | 27 ++- libs/qec/lib/CMakeLists.txt | 1 + libs/qec/lib/decoder.cpp | 96 +++++---- libs/qec/lib/decoder_inputs.cpp | 186 ++++++++++++++++++ libs/qec/lib/decoders/lut.cpp | 19 +- .../plugins/chromobius/chromobius.cpp | 41 +--- .../example/single_error_lut_example.cpp | 11 +- .../plugins/pymatching/pymatching.cpp | 10 +- .../plugins/trt_decoder/trt_decoder.cpp | 24 ++- libs/qec/lib/decoders/sliding_window.cpp | 24 ++- libs/qec/lib/decoders/sliding_window.h | 10 +- libs/qec/lib/experiments.cpp | 87 ++++++-- libs/qec/python/bindings/py_code.cpp | 15 +- libs/qec/python/bindings/py_decoder.cpp | 9 +- libs/qec/python/tests/test_dem.py | 4 + .../backend-specific/stim/test_qec_stim.cpp | 43 ++-- .../qec/unittests/decoders/sample_decoder.cpp | 9 +- .../app_examples/concurrency_test_decoder.cpp | 9 +- .../realtime/app_examples/surface_code-1.cpp | 9 +- .../app_examples/surface_code-4-yaml.cpp | 40 ++-- libs/qec/unittests/test_decoders.cpp | 136 +++++++++++-- .../unittests/test_decoding_server_core.cpp | 14 +- libs/qec/unittests/test_qec.cpp | 8 + 29 files changed, 835 insertions(+), 264 deletions(-) create mode 100644 libs/qec/include/cudaq/qec/decoder_inputs.h create mode 100644 libs/qec/lib/decoder_inputs.cpp diff --git a/docs/sphinx/api/qec/cpp_api.rst b/docs/sphinx/api/qec/cpp_api.rst index a2f702c7b..bca8b9bf2 100644 --- a/docs/sphinx/api/qec/cpp_api.rst +++ b/docs/sphinx/api/qec/cpp_api.rst @@ -67,12 +67,11 @@ Legacy convenience wrappers (delegate to ``cpu::sample_dem``; prefer the Decoder Interfaces ================== -.. doxygenstruct:: cudaq::qec::decoder_inputs +.. doxygenclass:: cudaq::qec::decoder_inputs :members: .. doxygenfunction:: cudaq::qec::d_sparse(const cudaq::M2DSparseMatrix &) - -.. doxygentypedef:: cudaq::qec::decoder_init +.. doxygenfunction:: cudaq::qec::d_sparse(const cudaq::qec::sparse_binary_matrix &) .. doxygenclass:: cudaq::qec::decoder :members: diff --git a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst index c62458a6a..14fb3b622 100644 --- a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst +++ b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst @@ -52,7 +52,7 @@ Real-time decoding requires converting matrices to sparse format for efficient d - :cpp:func:`cudaq::qec::pcm_to_sparse_vec` for converting a dense PCM to a sparse PCM. - :cpp:func:`cudaq::qec::pcm_from_sparse_vec` for converting a sparse PCM to a dense PCM. - :cpp:func:`cudaq::qec::d_sparse` for converting an ``M2DSparseMatrix`` (obtained from - a :cpp:struct:`cudaq::qec::decoder_inputs` component) into the ``-1``-terminated sparse + a :cpp:class:`cudaq::qec::decoder_inputs` component) into the ``-1``-terminated sparse vector a decoder config expects for ``D_sparse``. **Usage in real-time decoding:** @@ -62,8 +62,9 @@ Real-time decoding requires converting matrices to sparse format for efficient d auto ctx = cudaq::qec::decoder_context_from_memory_circuit( code, statePrep, numRounds, noise); auto inputs = ctx.z_component(); // or x_component() / full_component() - config.H_sparse = cudaq::qec::pcm_to_sparse_vec(inputs.dem.detector_error_matrix); - config.O_sparse = cudaq::qec::pcm_to_sparse_vec(inputs.dem.observables_flips_matrix); - config.D_sparse = cudaq::qec::d_sparse(inputs.m2d); + const auto dem = inputs.materialize_detector_error_model(); + config.H_sparse = cudaq::qec::pcm_to_sparse_vec(dem.detector_error_matrix); + config.O_sparse = cudaq::qec::pcm_to_sparse_vec(dem.observables_flips_matrix); + config.D_sparse = cudaq::qec::d_sparse(*inputs.measurement_to_detectors()); See also :ref:`parity_check_matrix_utilities` for additional PCM manipulation functions. diff --git a/docs/sphinx/components/qec/introduction.rst b/docs/sphinx/components/qec/introduction.rst index 4c761f21f..5b9a46e01 100644 --- a/docs/sphinx/components/qec/introduction.rst +++ b/docs/sphinx/components/qec/introduction.rst @@ -632,9 +632,9 @@ To implement a new decoder: // Decoder-specific members public: - my_decoder(const qec::sparse_binary_matrix& H, - const heterogeneous_map& params) - : decoder(H) { + my_decoder(qec::decoder_inputs inputs, + const heterogeneous_map& params) + : decoder(std::move(inputs)) { // Initialize decoder } @@ -651,18 +651,19 @@ To implement a new decoder: CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( my_decoder, static std::unique_ptr create( - const qec::decoder_init& init, + qec::decoder_inputs inputs, const heterogeneous_map& params) { - return qec::make_pcm_decoder(init, params); + return qec::make_pcm_decoder(std::move(inputs), params); } ) CUDAQ_EXT_PT_REGISTER_TYPE(my_decoder) -The :code:`make_pcm_decoder` helper dispatches :code:`decoder_init`. It -passes a stored sparse PCM directly to the decoder constructor; when the -variant contains Stim DEM text, it parses the DEM and constructs the sparse -detector matrix before invoking the same constructor. +The :code:`make_pcm_decoder` helper is a transitional adapter for matrix-family +decoders. The factory always receives :code:`decoder_inputs`; the helper passes +that stable input handle to the decoder and supplies legacy constructor +defaults while decoder implementations migrate to reading model data directly +from the owned inputs. Example: Lookup Table Decoder ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -676,11 +677,12 @@ Here's a simple lookup table decoder for the Steane code: std::map single_qubit_err_signatures; public: - single_error_lut(const qec::sparse_binary_matrix& H, + single_error_lut(qec::decoder_inputs inputs, const heterogeneous_map& params) - : decoder(H) { + : decoder(std::move(inputs)) { // Canonicalize before using each sparse column as an error // signature so duplicate row indices cancel over GF(2). + const auto& H = get_inputs().detector_error_matrix(); auto H_e2d = H.canonicalize().to_nested_csc(); for (std::size_t qErr = 0; qErr < block_size; qErr++) { @@ -1501,4 +1503,3 @@ Additional Noise Models noise.add_all_qubit_channel( "x", cudaq::depolarization2(/*probability*/ 0.01), /*numControls*/ 1); - diff --git a/docs/sphinx/examples/qec/cpp/real_time_complete.cpp b/docs/sphinx/examples/qec/cpp/real_time_complete.cpp index 595e1e21d..9c59a4418 100644 --- a/docs/sphinx/examples/qec/cpp/real_time_complete.cpp +++ b/docs/sphinx/examples/qec/cpp/real_time_complete.cpp @@ -30,7 +30,7 @@ // Save decoder configuration to YAML file void save_dem(const cudaq::qec::decoder_inputs &inputs, const std::string &filename) { - const auto &dem = inputs.dem; + const auto dem = inputs.materialize_detector_error_model(); // Create decoder config cudaq::qec::decoding::config::decoder_config config; config.id = 0; @@ -39,7 +39,7 @@ void save_dem(const cudaq::qec::decoder_inputs &inputs, config.syndrome_size = dem.num_detectors(); config.H_sparse = cudaq::qec::pcm_to_sparse_vec(dem.detector_error_matrix); config.O_sparse = cudaq::qec::pcm_to_sparse_vec(dem.observables_flips_matrix); - config.D_sparse = cudaq::qec::d_sparse(inputs.m2d); + config.D_sparse = cudaq::qec::d_sparse(*inputs.measurement_to_detectors()); // Decoder parameters are a plain heterogeneous_map; keys are governed by // the parameter schema the decoder registered. diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index 226e22adf..e7da9e392 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -11,8 +11,7 @@ #include "cuda-qx/core/extension_point.h" #include "cuda-qx/core/heterogeneous_map.h" #include "cuda-qx/core/tensor.h" -#include "sparse_binary_matrix.h" -#include "cudaq/qec/detector_error_model.h" +#include "cudaq/qec/decoder_inputs.h" #include #include #include @@ -21,7 +20,6 @@ #include #include #include -#include #include namespace cudaq::qec { @@ -32,10 +30,6 @@ using float_t = CUDAQX_QEC_FLOAT_TYPE; using float_t = double; #endif -/// Decoder construction input: either a parity-check matrix or raw Stim DEM -/// text. -using decoder_init = std::variant; - /// @brief Validates that all keys in a heterogeneous map are found in a list of /// acceptable types /// @param config The heterogeneous map to validate @@ -134,7 +128,7 @@ class async_decoder_result { /// arbitrary constructor parameters that can be unique to each specific /// decoder. class decoder - : public cudaqx::extension_point { private: struct rt_impl; @@ -150,22 +144,22 @@ class decoder /// constructor params should call set_result_type(decode_to_obs); all others /// default to decode_to_errs. /// - /// Note: even in decode_to_obs mode, set_O_sparse() must still be called so - /// that enqueue_syndrome() knows num_observables and can size the corrections - /// buffer correctly. + /// Note: legacy H-only construction must still call set_O_sparse() so that + /// enqueue_syndrome() knows num_observables. Construction with decoder_inputs + /// obtains the count from input metadata. enum decode_result_type { decode_to_errs, ///< result.size() == block_size; enqueue_syndrome projects ///< via O_sparse decode_to_obs, ///< result.size() == num_observables; enqueue_syndrome uses - ///< result directly; set_O_sparse() still required + ///< result directly }; decoder() = delete; /// @brief Constructor - /// @param H Decoder's parity check matrix. Taken by value so rvalue - /// arguments are moved into the base member. - decoder(cudaq::qec::sparse_binary_matrix H); + /// @param inputs Stable model and measurement inputs. Taken by value so the + /// factory can move its immutable handle into the decoder. + decoder(decoder_inputs inputs); /// @brief Decode a single syndrome /// @param syndrome A vector of syndrome measurements where the floating point @@ -201,17 +195,17 @@ class decoder /// @brief Construct a registered decoder by name. /// @param name The registered decoder name. - /// @param init A parity-check matrix or raw Stim DEM string. + /// @param inputs Stable decoder inputs. /// @param param_map Optional decoder-specific parameters. static std::unique_ptr - get(const std::string &name, const decoder_init &init, + get(const std::string &name, decoder_inputs inputs, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()); static std::unique_ptr get(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()) { - return get(name, decoder_init{H}, param_map); + return get(name, decoder_inputs{H}, param_map); } static std::unique_ptr @@ -225,21 +219,22 @@ class decoder get(const std::string &name, const std::string &stim_dem_text, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()) { - return get(name, decoder_init{stim_dem_text}, param_map); + return get(name, decoder_inputs::from_stim_dem(stim_dem_text), param_map); } static std::unique_ptr get(const std::string &name, const char *stim_dem_text, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()) { - return get(name, decoder_init{std::string{stim_dem_text}}, param_map); + return get(name, decoder_inputs::from_stim_dem(stim_dem_text), param_map); } static std::unique_ptr get(const std::string &name, std::string_view stim_dem_text, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()) { - return get(name, decoder_init{std::string{stim_dem_text}}, param_map); + return get(name, decoder_inputs::from_stim_dem(std::string{stim_dem_text}), + param_map); } std::size_t get_block_size() { return block_size; } @@ -273,13 +268,21 @@ class decoder /// row terminators. void set_O_sparse(const std::vector &O_sparse); - /// @brief Set the D_sparse matrix. + /// @brief Set D from nested rows. The measurement count is inferred as the + /// largest referenced column plus one, so trailing unused columns cannot be + /// represented. void set_D_sparse(const std::vector> &D_sparse); /// @brief Set the D_sparse matrix, using a single long vector with -1 as row - /// terminators. + /// terminators. Vector encodings infer the measurement count as the largest + /// referenced column plus one and therefore cannot represent trailing unused + /// measurement columns. void set_D_sparse(const std::vector &D_sparse); + /// @brief Set D from a shaped sparse matrix, preserving its exact measurement + /// column count, including trailing unused columns. + void set_D_sparse(const sparse_binary_matrix &D_sparse); + /// @brief Set the decoder id. void set_decoder_id(uint32_t decoder_id); @@ -344,6 +347,9 @@ class decoder virtual std::string get_version() const; protected: + /// @brief The immutable construction inputs owned by this decoder. + const decoder_inputs &get_inputs() const noexcept { return inputs_; } + /// @brief Sets the result type. Call in the constructor when an "O" /// observable matrix is detected in the decoder params. Must be called /// before the first enqueue_syndrome(). @@ -367,9 +373,6 @@ class decoder /// @brief For a classical `[n,k]` code, this is `n-k` std::size_t syndrome_size = 0; - /// @brief The decoder's parity check matrix - sparse_binary_matrix H; - /// @brief The decoder's observable matrix in sparse format std::vector> O_sparse; @@ -381,6 +384,9 @@ class decoder int cuda_device_id_ = -1; private: + /// @brief The decoder's immutable construction inputs. + const decoder_inputs inputs_; + decode_result_type result_type_ = decode_result_type::decode_to_errs; }; @@ -537,13 +543,13 @@ inline void convert_vec_hard_to_soft(const std::vector> &in, } std::unique_ptr -get_decoder(const std::string &name, const decoder_init &init, +get_decoder(const std::string &name, decoder_inputs inputs, const cudaqx::heterogeneous_map options = {}); inline std::unique_ptr get_decoder(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_init{H}, options); + return get_decoder(name, decoder_inputs{H}, options); } inline std::unique_ptr @@ -555,19 +561,22 @@ get_decoder(const std::string &name, const cudaqx::tensor &H, inline std::unique_ptr get_decoder(const std::string &name, const std::string &stim_dem_text, const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_init{stim_dem_text}, options); + return get_decoder(name, decoder_inputs::from_stim_dem(stim_dem_text), + options); } inline std::unique_ptr get_decoder(const std::string &name, const char *stim_dem_text, const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_init{std::string{stim_dem_text}}, options); + return get_decoder(name, decoder_inputs::from_stim_dem(stim_dem_text), + options); } inline std::unique_ptr get_decoder(const std::string &name, std::string_view stim_dem_text, const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_init{std::string{stim_dem_text}}, options); + return get_decoder( + name, decoder_inputs::from_stim_dem(std::string{stim_dem_text}), options); } namespace details { @@ -584,25 +593,19 @@ dem_default_values dem_defaults_for_missing_keys( const detector_error_model &dem); } // namespace details -/// If `init` holds DEM text, parse it and inject `"O"` / `"error_rate_vec"` -/// defaults when absent. +/// Transitional adapter for matrix-family decoders. Model data comes from +/// `inputs`; until those constructors consume it directly, this helper injects +/// O and error-rate defaults through their legacy parameter interface. template std::unique_ptr -make_pcm_decoder(const decoder_init &init, +make_pcm_decoder(decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - if (const auto *H = std::get_if(&init)) - return std::make_unique(*H, params); - - const auto dem = dem_from_stim_text(std::get(init)); cudaqx::heterogeneous_map merged = params; - const auto defaults = details::dem_defaults_for_missing_keys( - [&](const std::string &key) { return merged.contains(key); }, dem); - if (defaults.O) - merged.insert("O", *defaults.O); - if (defaults.error_rate_vec) - merged.insert("error_rate_vec", *defaults.error_rate_vec); - return std::make_unique( - cudaq::qec::sparse_binary_matrix(dem.detector_error_matrix), merged); + if (!merged.contains("O") && inputs.num_observables() > 0) + merged.insert("O", inputs.observable_flips_matrix().to_dense()); + if (!merged.contains("error_rate_vec") && !inputs.error_rates().empty()) + merged.insert("error_rate_vec", inputs.error_rates()); + return std::make_unique(std::move(inputs), merged); } } // namespace cudaq::qec diff --git a/libs/qec/include/cudaq/qec/decoder_inputs.h b/libs/qec/include/cudaq/qec/decoder_inputs.h new file mode 100644 index 000000000..328aecd4f --- /dev/null +++ b/libs/qec/include/cudaq/qec/decoder_inputs.h @@ -0,0 +1,129 @@ +/****************************************************************-*- 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 "cudaq/qec/detector_error_model.h" +#include "cudaq/qec/sparse_binary_matrix.h" +#include +#include +#include +#include +#include +#include + +namespace cudaq::qec { + +/// @brief Authoritative representation from which a decoder model originates. +/// +/// Matrix and Stim sources are supported now. `dem_chunks` names the compact +/// repeated-round representation being introduced by the dynamic DEM APIs. +/// Adding its typed constructor and accessor does not change the +/// `decoder_inputs` object layout or decoder factory signature. +enum class decoder_model_source : std::uint8_t { + matrices, + stim_dem, + dem_chunks, +}; + +/// @brief Stable, owning input contract shared by offline and server decoders. +/// +/// This is a small immutable value handle. Copies share the same model state; +/// the decoder factory takes the handle by value and the decoder base retains +/// it. Source-specific data is authoritative and the common matrix accessors +/// expose the projection stored when the handle is constructed. Model matrices +/// are stored sparsely instead of composing detector_error_model, whose matrix +/// fields are dense tensors. +class decoder_inputs { +public: + /// @brief Construct an H-only matrix model. + explicit decoder_inputs(sparse_binary_matrix detector_error_matrix); + + /// @brief Construct a materialized matrix model. + /// @param detector_error_matrix H, with shape detectors x error mechanisms. + /// @param observable_flips_matrix O, with shape observables x error + /// mechanisms. Its row count is retained even when a row has no nonzeros. + /// @param error_rates Optional rate per error mechanism. + /// @param measurement_to_detectors Optional D, with shape detectors x raw + /// measurements. + /// @param error_ids Optional correlation ID per error mechanism. + decoder_inputs( + sparse_binary_matrix detector_error_matrix, + sparse_binary_matrix observable_flips_matrix, + std::vector error_rates = {}, + std::optional measurement_to_detectors = + std::nullopt, + std::optional> error_ids = std::nullopt); + + /// @brief Construct from the existing materialized detector-error model. + explicit decoder_inputs(detector_error_model model, + std::optional + measurement_to_detectors = std::nullopt); + + /// @brief Construct from authoritative raw Stim DEM text. + /// + /// Matrix accessors expose the common lossy projection produced by + /// `dem_from_stim_text`; DEM-native decoders should consume `stim_dem()`. + static decoder_inputs + from_stim_dem(std::string stim_dem_text, + std::optional measurement_to_detectors = + std::nullopt); + + decoder_inputs(const decoder_inputs &) noexcept; + /// @brief Move construction leaves the source valid only for destruction or + /// assignment. + decoder_inputs(decoder_inputs &&) noexcept; + decoder_inputs &operator=(const decoder_inputs &) noexcept; + /// @brief Move assignment leaves the source valid only for destruction or + /// assignment. + decoder_inputs &operator=(decoder_inputs &&) noexcept; + ~decoder_inputs(); + + decoder_model_source source() const noexcept; + + /// @brief Return the stored common H projection. + const sparse_binary_matrix &detector_error_matrix() const; + + /// @brief Return the stored common O projection. + const sparse_binary_matrix &observable_flips_matrix() const; + + const std::vector &error_rates() const; + const std::optional> &error_ids() const; + + /// @brief Return D, or nullptr when input syndromes are already detectors. + const sparse_binary_matrix *measurement_to_detectors() const noexcept; + + bool has_stim_dem() const noexcept; + + /// @throws std::logic_error if the authoritative source is not a Stim DEM. + const std::string &stim_dem() const; + + /// @brief Materialize the common detector-error-model view. + detector_error_model materialize_detector_error_model() const; + + /// Dimensions are stored as source metadata so these accessors never need to + /// request H or O. For matrix sources they intentionally duplicate the O(1) + /// matrix shape values in preparation for compact source alternatives. + std::size_t num_detectors() const noexcept; + std::size_t num_error_mechanisms() const noexcept; + std::size_t num_observables() const noexcept; + +private: + struct impl; + static std::shared_ptr make_matrix_state( + decoder_model_source source, sparse_binary_matrix detector_error_matrix, + sparse_binary_matrix observable_flips_matrix, + std::vector error_rates, + std::optional> error_ids, + std::optional measurement_to_detectors, + std::optional raw_stim_dem = std::nullopt); + explicit decoder_inputs(std::shared_ptr state); + std::shared_ptr state_; +}; + +} // namespace cudaq::qec diff --git a/libs/qec/include/cudaq/qec/experiments.h b/libs/qec/include/cudaq/qec/experiments.h index c580438eb..5b25dfe1b 100644 --- a/libs/qec/include/cudaq/qec/experiments.h +++ b/libs/qec/include/cudaq/qec/experiments.h @@ -9,6 +9,7 @@ #include "cudaq/algorithms/dem.h" #include "cudaq/qec/code.h" +#include "cudaq/qec/decoder_inputs.h" #include "cudaq/qec/detector_error_model.h" #include #include @@ -160,18 +161,12 @@ std::tuple, cudaqx::tensor> sample_memory_circuit(const code &code, std::size_t numShots, std::size_t numRounds, cudaq::noise_model &noise); -/// @brief Finalized decoder inputs: a canonicalized DEM and measurement maps. -struct decoder_inputs { - detector_error_model dem; - cudaq::M2DSparseMatrix m2d; - cudaq::M2OSparseMatrix m2o; -}; - /// @brief Lazy handle returned by `decoder_context_from_memory_circuit`. /// /// Stores the raw (uncanonicalized) circuit analysis. Call a component method -/// to canonicalize exactly the stabilizer type needed and obtain a -/// `decoder_inputs`: +/// to canonicalize exactly the stabilizer type needed and obtain stable +/// `decoder_inputs`. The circuit-only measurement-to-observable map is not +/// part of the decoder plugin input contract. /// - `x_component()` — X-stabilizer detectors only /// - `z_component()` — Z-stabilizer detectors only /// - `full_component()` — both stabilizer types, boundary-aware @@ -179,6 +174,12 @@ struct decoder_context { /// @brief Total number of measurements per shot (column count of m2d/m2o). std::size_t num_measurements() const; + /// @brief Circuit-analysis measurement-to-observable metadata. + /// + /// This remains on the experiment handle and is not passed through the + /// decoder plugin factory in the first contract iteration. + const cudaq::M2OSparseMatrix &measurement_to_observables() const; + /// @brief Canonicalize X-stabilizer detectors; return decoder_inputs. decoder_inputs x_component() const; @@ -207,6 +208,14 @@ struct decoder_context { /// realtime decoder config expects for its `D_sparse`. std::vector d_sparse(const cudaq::M2DSparseMatrix &m2d); +/// @brief Convert CUDA-Q circuit-analysis M2D output to the QEC-owned sparse +/// matrix used by decoder_inputs. +sparse_binary_matrix m2d_to_sparse(const cudaq::M2DSparseMatrix &m2d); + +/// @brief Flatten a QEC-owned detector-by-measurement matrix into the legacy +/// `-1`-terminated server encoding. +std::vector d_sparse(const sparse_binary_matrix &m2d); + /// @brief Given a memory circuit setup, generate a DEM /// @param code QEC Code to sample /// @param statePrep Initial state preparation operation diff --git a/libs/qec/lib/CMakeLists.txt b/libs/qec/lib/CMakeLists.txt index 37842f4be..02c993d65 100644 --- a/libs/qec/lib/CMakeLists.txt +++ b/libs/qec/lib/CMakeLists.txt @@ -41,6 +41,7 @@ endif() set(DECODERS_SOURCES decoder.cpp + decoder_inputs.cpp decoder_config_payload.cpp decoder_config_schema.cpp detector_error_model.cpp diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index 6e9cf2388..e159550fc 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -19,7 +19,7 @@ #include #include -INSTANTIATE_REGISTRY(cudaq::qec::decoder, const cudaq::qec::decoder_init &, +INSTANTIATE_REGISTRY(cudaq::qec::decoder, cudaq::qec::decoder_inputs, const cudaqx::heterogeneous_map &) // Include decoder implementations AFTER registry instantiation @@ -78,11 +78,11 @@ struct decoder::rt_impl { void decoder::rt_impl_deleter::operator()(rt_impl *p) const { delete p; } -decoder::decoder(cudaq::qec::sparse_binary_matrix H) - : H(std::move(H)), - pimpl(std::unique_ptr(new rt_impl())) { - syndrome_size = this->H.num_rows(); - block_size = this->H.num_cols(); +decoder::decoder(decoder_inputs inputs) + : pimpl(std::unique_ptr(new rt_impl())), + inputs_(std::move(inputs)) { + syndrome_size = inputs_.num_detectors(); + block_size = inputs_.num_error_mechanisms(); reset_decoder(); pimpl->persistent_detector_buffer.resize(this->syndrome_size); pimpl->persistent_soft_detector_buffer.resize(this->syndrome_size); @@ -203,7 +203,7 @@ class ConstructionDevicePin { }; std::unique_ptr -decoder::get(const std::string &name, const decoder_init &init, +decoder::get(const std::string &name, decoder_inputs inputs, const cudaqx::heterogeneous_map ¶m_map) { auto [mutex, registry] = get_registry(); std::lock_guard lock(mutex); @@ -215,7 +215,7 @@ decoder::get(const std::string &name, const decoder_init &init, "additional plugin diagnostics at startup."); const int cuda_device_id = read_cuda_device_id(param_map); if (cuda_device_id < 0) - return iter->second(init, param_map); + return iter->second(std::move(inputs), param_map); ConstructionDevicePin device_pin(cuda_device_id); // The key is consumed here; strip it so plugins that strictly validate // their parameter keys do not reject it. @@ -223,7 +223,7 @@ decoder::get(const std::string &name, const decoder_init &init, for (const auto &kv : param_map) if (kv.first != "cuda_device_id") plugin_params.insert(kv.first, kv.second); - auto d = iter->second(init, plugin_params); + auto d = iter->second(std::move(inputs), plugin_params); d->cuda_device_id_ = cuda_device_id; device_pin.commit(); return d; @@ -247,10 +247,13 @@ dem_default_values dem_defaults_for_missing_keys( static uint32_t calculate_num_msyn_per_decode( const std::vector> &D_sparse) { uint32_t max_col = 0; + bool found_column = false; for (const auto &row : D_sparse) - for (const auto col : row) + for (const auto col : row) { max_col = std::max(max_col, col); - return max_col + 1; + found_column = true; + } + return found_column ? max_col + 1 : 0; } static void @@ -271,33 +274,42 @@ static void set_sparse_from_vec(const std::vector &vec_in, std::vector> &sparse_out) { sparse_out.clear(); - bool first_of_row = true; + std::vector row; for (auto elem : vec_in) { if (elem < 0) { - first_of_row = true; + sparse_out.push_back(std::move(row)); + row.clear(); } else { - if (first_of_row) { - sparse_out.emplace_back(); - first_of_row = false; - } - sparse_out.back().push_back(static_cast(elem)); + row.push_back(static_cast(elem)); } } + if (!row.empty()) + sparse_out.push_back(std::move(row)); } void decoder::set_O_sparse(const std::vector> &O_sparse) { + if (inputs_.num_observables() > 0 && + O_sparse.size() != inputs_.num_observables()) + throw std::invalid_argument( + "O_sparse row count must match decoder_inputs observable count"); + validate_sparse_column_indices(O_sparse, block_size, "O_sparse"); this->O_sparse = O_sparse; - validate_sparse_column_indices(this->O_sparse, block_size, "O_sparse"); this->pimpl->corrections.clear(); - this->pimpl->corrections.resize(O_sparse.size()); + this->pimpl->corrections.resize(get_num_observables()); on_o_sparse_configured(); } void decoder::set_O_sparse(const std::vector &O_sparse_vec_in) { - set_sparse_from_vec(O_sparse_vec_in, this->O_sparse); - validate_sparse_column_indices(this->O_sparse, block_size, "O_sparse"); + std::vector> parsed; + set_sparse_from_vec(O_sparse_vec_in, parsed); + if (inputs_.num_observables() > 0 && + parsed.size() != inputs_.num_observables()) + throw std::invalid_argument( + "O_sparse row count must match decoder_inputs observable count"); + validate_sparse_column_indices(parsed, block_size, "O_sparse"); + this->O_sparse = std::move(parsed); this->pimpl->corrections.clear(); - this->pimpl->corrections.resize(O_sparse.size()); + this->pimpl->corrections.resize(get_num_observables()); on_o_sparse_configured(); } @@ -314,7 +326,7 @@ uint32_t decoder::get_decoder_id() const { return pimpl->decoder_id; } template void set_D_sparse_common(decoder *decoder, const std::vector> &D_sparse, - PimplType *pimpl) { + uint32_t num_measurements, PimplType *pimpl) { auto *sw_decoder = dynamic_cast(decoder); if (sw_decoder != nullptr) { @@ -345,7 +357,7 @@ void set_D_sparse_common(decoder *decoder, } } - pimpl->num_msyn_per_decode = calculate_num_msyn_per_decode(D_sparse); + pimpl->num_msyn_per_decode = num_measurements; pimpl->msyn_buffer.clear(); pimpl->msyn_buffer.resize(pimpl->num_msyn_per_decode); pimpl->msyn_buffer_index = 0; @@ -353,13 +365,22 @@ void set_D_sparse_common(decoder *decoder, void decoder::set_D_sparse(const std::vector> &D_sparse) { this->D_sparse = D_sparse; - set_D_sparse_common(this, D_sparse, pimpl.get()); + set_D_sparse_common(this, D_sparse, calculate_num_msyn_per_decode(D_sparse), + pimpl.get()); on_d_sparse_configured(); } void decoder::set_D_sparse(const std::vector &D_sparse_vec_in) { set_sparse_from_vec(D_sparse_vec_in, this->D_sparse); - set_D_sparse_common(this, this->D_sparse, pimpl.get()); + set_D_sparse_common(this, this->D_sparse, + calculate_num_msyn_per_decode(this->D_sparse), + pimpl.get()); + on_d_sparse_configured(); +} + +void decoder::set_D_sparse(const sparse_binary_matrix &D_sparse) { + this->D_sparse = D_sparse.to_nested_csr(); + set_D_sparse_common(this, this->D_sparse, D_sparse.num_cols(), pimpl.get()); on_d_sparse_configured(); } @@ -406,8 +427,8 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, if (should_log) { log_t0 = std::chrono::high_resolution_clock::now(); log_errors.reserve(syndrome_length); - log_observables.reserve(O_sparse.size()); - log_observable_corrections.resize(O_sparse.size()); + log_observables.reserve(get_num_observables()); + log_observable_corrections.resize(get_num_observables()); } // Decode now. @@ -514,6 +535,10 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, case decode_result_type::decode_to_errs: // Error-frame path: decoder returns a block-sized error vector; project // to observables via O_sparse. + if (O_sparse.size() != num_observables) + throw std::runtime_error(fmt::format( + "Observable matrix is not configured: expected {} rows, got {}", + num_observables, O_sparse.size())); if (should_log) for (std::size_t e = 0, E = decoded_result.result.size(); e < E; e++) if (decoded_result.result[e]) @@ -569,7 +594,7 @@ bool decoder::enqueue_syndrome(const std::vector &syndrome) { void decoder::clear_corrections() { pimpl->corrections.clear(); - pimpl->corrections.resize(O_sparse.size()); + pimpl->corrections.resize(get_num_observables()); const bool log_due_to_log_level = cudaq::qec::detail::should_log(cudaq::qec::detail::log_level::info); const bool should_log = pimpl->should_log || log_due_to_log_level; @@ -602,7 +627,10 @@ const uint8_t *decoder::get_obs_corrections() const { return pimpl->corrections.data(); } -std::size_t decoder::get_num_observables() const { return O_sparse.size(); } +std::size_t decoder::get_num_observables() const { + return inputs_.num_observables() > 0 ? inputs_.num_observables() + : O_sparse.size(); +} void decoder::reset_decoder() { // Zero out all data that is considered "per-shot" memory. @@ -612,7 +640,7 @@ void decoder::reset_decoder() { pimpl->msyn_buffer.clear(); pimpl->msyn_buffer.resize(pimpl->num_msyn_per_decode); pimpl->corrections.clear(); - pimpl->corrections.resize(O_sparse.size()); + pimpl->corrections.resize(get_num_observables()); const bool log_due_to_log_level = cudaq::qec::detail::should_log(cudaq::qec::detail::log_level::info); const bool should_log = pimpl->should_log || log_due_to_log_level; @@ -629,9 +657,9 @@ void decoder::reset_decoder() { } std::unique_ptr get_decoder(const std::string &name, - const decoder_init &init, + decoder_inputs inputs, const cudaqx::heterogeneous_map options) { - return decoder::get(name, init, options); + return decoder::get(name, std::move(inputs), options); } // Constructor function for auto-loading plugins diff --git a/libs/qec/lib/decoder_inputs.cpp b/libs/qec/lib/decoder_inputs.cpp new file mode 100644 index 000000000..7b1d367c3 --- /dev/null +++ b/libs/qec/lib/decoder_inputs.cpp @@ -0,0 +1,186 @@ +/******************************************************************************* + * 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. * + ******************************************************************************/ + +#include "cudaq/qec/decoder_inputs.h" +#include +#include + +namespace cudaq::qec { + +struct decoder_inputs::impl { + decoder_model_source source = decoder_model_source::matrices; + std::size_t num_detectors = 0; + std::size_t num_error_mechanisms = 0; + std::size_t num_observables = 0; + sparse_binary_matrix H; + sparse_binary_matrix O; + std::vector rates; + std::optional> ids; + std::optional D; + std::optional raw_stim_dem; +}; + +namespace { + +sparse_binary_matrix empty_observable_matrix(std::uint32_t num_columns) { + return sparse_binary_matrix::from_csr(0, num_columns, {0}, {}); +} + +void validate_model(const sparse_binary_matrix &H, + const sparse_binary_matrix &O, + const std::vector &rates, + const std::optional> &ids, + const std::optional &D) { + if (O.num_cols() != H.num_cols()) + throw std::invalid_argument( + "decoder_inputs: O column count must match H column count"); + if (!rates.empty() && rates.size() != H.num_cols()) + throw std::invalid_argument( + "decoder_inputs: error_rates size must match H column count"); + if (ids && ids->size() != H.num_cols()) + throw std::invalid_argument( + "decoder_inputs: error_ids size must match H column count"); + if (D && D->num_rows() != H.num_rows()) + throw std::invalid_argument( + "decoder_inputs: D row count must match H row count"); +} + +} // namespace + +std::shared_ptr decoder_inputs::make_matrix_state( + decoder_model_source source, sparse_binary_matrix H, sparse_binary_matrix O, + std::vector rates, std::optional> ids, + std::optional D, + std::optional raw_stim_dem) { + H = H.to_csc(); + O = O.to_csr(); + if (D) + *D = D->to_csr(); + validate_model(H, O, rates, ids, D); + + auto state = std::make_shared(); + state->source = source; + state->num_detectors = H.num_rows(); + state->num_error_mechanisms = H.num_cols(); + state->num_observables = O.num_rows(); + state->H = std::move(H); + state->O = std::move(O); + state->rates = std::move(rates); + state->ids = std::move(ids); + state->D = std::move(D); + state->raw_stim_dem = std::move(raw_stim_dem); + return state; +} + +decoder_inputs::decoder_inputs(sparse_binary_matrix H) : state_(nullptr) { + auto O = empty_observable_matrix(H.num_cols()); + state_ = make_matrix_state(decoder_model_source::matrices, std::move(H), + std::move(O), {}, std::nullopt, std::nullopt); +} + +decoder_inputs::decoder_inputs( + sparse_binary_matrix H, sparse_binary_matrix O, + std::vector error_rates, + std::optional measurement_to_detectors, + std::optional> error_ids) + : decoder_inputs(make_matrix_state( + decoder_model_source::matrices, std::move(H), std::move(O), + std::move(error_rates), std::move(error_ids), + std::move(measurement_to_detectors))) {} + +decoder_inputs::decoder_inputs( + detector_error_model model, + std::optional measurement_to_detectors) + : decoder_inputs(make_matrix_state( + decoder_model_source::matrices, + sparse_binary_matrix(model.detector_error_matrix), + sparse_binary_matrix(model.observables_flips_matrix), + std::move(model.error_rates), std::move(model.error_ids), + std::move(measurement_to_detectors))) {} + +decoder_inputs decoder_inputs::from_stim_dem( + std::string stim_dem_text, + std::optional measurement_to_detectors) { + auto model = dem_from_stim_text(stim_dem_text); + return decoder_inputs(make_matrix_state( + decoder_model_source::stim_dem, + sparse_binary_matrix(model.detector_error_matrix), + sparse_binary_matrix(model.observables_flips_matrix), + std::move(model.error_rates), std::move(model.error_ids), + std::move(measurement_to_detectors), std::move(stim_dem_text))); +} + +decoder_inputs::decoder_inputs(std::shared_ptr state) + : state_(std::move(state)) {} + +decoder_inputs::decoder_inputs(const decoder_inputs &) noexcept = default; +decoder_inputs::decoder_inputs(decoder_inputs &&) noexcept = default; +decoder_inputs & +decoder_inputs::operator=(const decoder_inputs &) noexcept = default; +decoder_inputs &decoder_inputs::operator=(decoder_inputs &&) noexcept = default; +decoder_inputs::~decoder_inputs() = default; + +decoder_model_source decoder_inputs::source() const noexcept { + return state_->source; +} + +const sparse_binary_matrix &decoder_inputs::detector_error_matrix() const { + return state_->H; +} + +const sparse_binary_matrix &decoder_inputs::observable_flips_matrix() const { + return state_->O; +} + +const std::vector &decoder_inputs::error_rates() const { + return state_->rates; +} + +const std::optional> & +decoder_inputs::error_ids() const { + return state_->ids; +} + +const sparse_binary_matrix * +decoder_inputs::measurement_to_detectors() const noexcept { + return state_->D ? &*state_->D : nullptr; +} + +bool decoder_inputs::has_stim_dem() const noexcept { + return state_->raw_stim_dem.has_value(); +} + +const std::string &decoder_inputs::stim_dem() const { + if (!state_->raw_stim_dem) + throw std::logic_error( + "decoder_inputs: authoritative source is not a Stim DEM"); + return *state_->raw_stim_dem; +} + +detector_error_model decoder_inputs::materialize_detector_error_model() const { + detector_error_model model; + model.detector_error_matrix = state_->H.to_dense(); + model.observables_flips_matrix = state_->O.to_dense(); + model.error_rates = state_->rates; + model.error_ids = state_->ids; + return model; +} + +std::size_t decoder_inputs::num_detectors() const noexcept { + return state_->num_detectors; +} + +std::size_t decoder_inputs::num_error_mechanisms() const noexcept { + return state_->num_error_mechanisms; +} + +std::size_t decoder_inputs::num_observables() const noexcept { + return state_->num_observables; +} + +} // namespace cudaq::qec diff --git a/libs/qec/lib/decoders/lut.cpp b/libs/qec/lib/decoders/lut.cpp index a25f4bdff..3af7d855a 100644 --- a/libs/qec/lib/decoders/lut.cpp +++ b/libs/qec/lib/decoders/lut.cpp @@ -49,9 +49,10 @@ class multi_error_lut : public decoder { bool decoding_time = false; public: - multi_error_lut(const cudaq::qec::sparse_binary_matrix &H, + multi_error_lut(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : decoder(std::move(inputs)) { + const auto &H = get_inputs().detector_error_matrix(); if (params.contains("lut_error_depth")) { lut_error_depth = params.get("lut_error_depth"); if (lut_error_depth < 1) { @@ -230,9 +231,10 @@ class multi_error_lut : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( multi_error_lut, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return cudaq::qec::make_pcm_decoder(std::move(inputs), + params); }) }; @@ -240,17 +242,18 @@ CUDAQ_EXT_PT_REGISTER_TYPE(multi_error_lut) class single_error_lut : public multi_error_lut { public: - single_error_lut(const cudaq::qec::sparse_binary_matrix &H, + single_error_lut(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) - : multi_error_lut(H, params) {} + : multi_error_lut(std::move(inputs), params) {} virtual ~single_error_lut() {} CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( single_error_lut, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return cudaq::qec::make_pcm_decoder(std::move(inputs), + params); }) }; diff --git a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp index 26877e1d6..f16ab0585 100644 --- a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp +++ b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp @@ -13,7 +13,6 @@ #include #include -#include #include #include #include @@ -26,29 +25,10 @@ namespace { struct chromobius_init_data { stim::DetectorErrorModel dem; - cudaq::qec::sparse_binary_matrix base_H; }; -cudaq::qec::sparse_binary_matrix -make_empty_base_H(std::size_t num_detectors, std::size_t num_observables) { - using index_type = cudaq::qec::sparse_binary_matrix::index_type; - constexpr auto max_index = std::numeric_limits::max(); - if (num_detectors > max_index) - throw std::runtime_error( - "Chromobius DEM has too many detectors for CUDA-Q QEC"); - if (num_observables > max_index) - throw std::runtime_error( - "Chromobius DEM has too many observables for CUDA-Q QEC"); - - return cudaq::qec::sparse_binary_matrix::from_csc( - static_cast(num_detectors), - static_cast(num_observables), - std::vector(num_observables + 1, 0), {}); -} - -chromobius_init_data make_chromobius_init_data(const decoder_init &init) { - const auto *dem_text = std::get_if(&init); - if (!dem_text) { +chromobius_init_data make_chromobius_init_data(const decoder_inputs &inputs) { + if (!inputs.has_stim_dem()) { throw std::runtime_error( "Chromobius decoder requires a Stim detector error model string as " "decoder input. Use get_decoder(\"chromobius\", dem_text, params)."); @@ -56,13 +36,12 @@ chromobius_init_data make_chromobius_init_data(const decoder_init &init) { stim::DetectorErrorModel dem; try { - dem = stim::DetectorErrorModel(*dem_text); + dem = stim::DetectorErrorModel(inputs.stim_dem()); } catch (const std::exception &e) { throw std::runtime_error(std::string("Chromobius Stim DEM parse failed: ") + e.what()); } - const auto num_detectors = static_cast(dem.count_detectors()); const auto num_observables = static_cast(dem.count_observables()); if (num_observables > 64) { @@ -71,8 +50,7 @@ chromobius_init_data make_chromobius_init_data(const decoder_init &init) { "CUDA-Q QEC wrapper supports at most 64 observables."); } - return chromobius_init_data{ - std::move(dem), make_empty_base_H(num_detectors, num_observables)}; + return chromobius_init_data{std::move(dem)}; } std::vector> identity_sparse(std::size_t size) { @@ -103,9 +81,9 @@ class chromobius : public decoder { std::vector packed_detection_events; public: - chromobius(chromobius_init_data init_data, + chromobius(decoder_inputs inputs, chromobius_init_data init_data, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(init_data.base_H)), dem(std::move(init_data.dem)) { + : decoder(std::move(inputs)), dem(std::move(init_data.dem)) { ::chromobius::DecoderConfigOptions options; options.drop_mobius_errors_involving_remnant_errors = get_bool_param(params, "drop_mobius_errors_involving_remnant_errors", @@ -181,10 +159,11 @@ class chromobius : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( chromobius, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return std::make_unique(make_chromobius_init_data(init), - params); + auto init_data = make_chromobius_init_data(inputs); + return std::make_unique(std::move(inputs), + std::move(init_data), params); }) }; diff --git a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp index 661306d80..fcbb785e4 100644 --- a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp +++ b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp @@ -22,9 +22,10 @@ class single_error_lut_example : public decoder { std::map single_qubit_err_signatures; public: - single_error_lut_example(const cudaq::qec::sparse_binary_matrix &H, + single_error_lut_example(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : decoder(std::move(inputs)) { + const auto &H = get_inputs().detector_error_matrix(); // Decoder-specific constructor arguments can be placed in `params`. // The loop below sets err_sig[r] = '1' (not XOR-toggle), so canonicalize @@ -77,10 +78,10 @@ class single_error_lut_example : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( single_error_lut_example, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, - params); + return cudaq::qec::make_pcm_decoder( + std::move(inputs), params); }) }; diff --git a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp index f1e26d8e2..c28564a1b 100644 --- a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp +++ b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp @@ -56,9 +56,10 @@ class pymatching : public decoder { #endif public: - pymatching(const cudaq::qec::sparse_binary_matrix &H, + pymatching(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : decoder(std::move(inputs)) { + const auto &H = get_inputs().detector_error_matrix(); if (params.contains("error_rate_vec")) { error_rate_vec = params.get>("error_rate_vec"); @@ -259,9 +260,10 @@ class pymatching : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( pymatching, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return cudaq::qec::make_pcm_decoder(std::move(inputs), + params); }) }; diff --git a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp index 675b25fd9..213f20c59 100644 --- a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp +++ b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp @@ -129,7 +129,8 @@ static Logger gLogger; /// (b) when "O" is also provided, the concatenation [pre_L, /// residual_dets] as the only output. /// - "global_decoder_params": Optional parameters for the global decoder. The -/// decoder is created with the same H passed to the trt_decoder constructor. +/// decoder receives the same model inputs passed to the trt_decoder +/// constructor, including authoritative raw DEM provenance when present. /// - "O": Observables matrix (num_observables x block_size). Calls to /// decode() and decode_batch() will return the logical frame of the /// observables. Requires that the TRT model emits the concatenation @@ -420,7 +421,7 @@ class trt_decoder : public decoder { size_t num_observables_ = 0; public: - trt_decoder(const cudaq::qec::sparse_binary_matrix &H, + trt_decoder(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms); virtual decoder_result decode(const std::vector &syndrome) override; @@ -432,9 +433,10 @@ class trt_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( trt_decoder, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return cudaq::qec::make_pcm_decoder(std::move(inputs), + params); }) private: @@ -535,9 +537,10 @@ struct trt_decoder::Impl { // trt_decoder method implementations // ============================================================================ -trt_decoder::trt_decoder(const cudaq::qec::sparse_binary_matrix &H, +trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : decoder(std::move(inputs)) { + const auto &H = get_inputs().detector_error_matrix(); impl_ = std::make_unique(); @@ -762,8 +765,11 @@ trt_decoder::trt_decoder(const cudaq::qec::sparse_binary_matrix &H, global_decoder_params_ = params.get("global_decoder_params"); if (!global_decoder_name.empty()) { - global_decoder_ = - decoder::get(global_decoder_name, H, global_decoder_params_); + // Preserve authoritative model provenance for DEM-native children. The + // parent's D is inert on this decode_batch path; the shared child-input + // derivation utility will omit it when that phase lands. + global_decoder_ = decoder::get(global_decoder_name, get_inputs(), + global_decoder_params_); CUDA_QEC_INFO("TensorRT decoder: global_decoder '{}' attached", global_decoder_name); } @@ -790,7 +796,7 @@ trt_decoder::trt_decoder(const cudaq::qec::sparse_binary_matrix &H, // global decoders still carry their own O copies. This duplicate plumbing // is intentional for now; a follow-up can make O ownership less // redundant. - set_O_sparse(cudaq::qec::pcm_to_sparse_vec(O)); + set_O_sparse(cudaq::qec::sparse_binary_matrix(O).to_nested_csr()); set_result_type(decode_result_type::decode_to_obs); // The TRT model output must encode [pre_L (num_observables_ entries), diff --git a/libs/qec/lib/decoders/sliding_window.cpp b/libs/qec/lib/decoders/sliding_window.cpp index 8795c1bc4..c6f6fb5d4 100644 --- a/libs/qec/lib/decoders/sliding_window.cpp +++ b/libs/qec/lib/decoders/sliding_window.cpp @@ -16,6 +16,25 @@ namespace cudaq::qec { +namespace { + +decoder_inputs canonicalize_sliding_window_inputs(decoder_inputs inputs) { + // Stim-derived sparse matrices are canonical at construction. Retain the + // authoritative raw source instead of rebuilding a matrix-authoritative + // handle solely to canonicalize its storage. + if (inputs.source() == decoder_model_source::stim_dem) + return inputs; + + std::optional D; + if (const auto *measurement_map = inputs.measurement_to_detectors()) + D = *measurement_map; + return decoder_inputs(inputs.detector_error_matrix().canonicalize().to_csc(), + inputs.observable_flips_matrix(), inputs.error_rates(), + std::move(D), inputs.error_ids()); +} + +} // namespace + void sliding_window::validate_inputs() { uint32_t num_rows = H.num_rows(); if (num_boundary_syndromes > num_syndromes_per_round) @@ -108,11 +127,12 @@ void sliding_window::initialize_window(std::size_t batch_size) { std::chrono::duration(t1 - t0).count() * 1000; } -sliding_window::sliding_window(const cudaq::qec::sparse_binary_matrix &H, +sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) // Canonical CSC is the steady-state contract for decode_window's column // slices and for validate_inputs's per-column .front()/.back() reads. - : decoder(H.canonicalize().to_csc()) { + : decoder(canonicalize_sliding_window_inputs(std::move(inputs))), + H(get_inputs().detector_error_matrix()) { // Fetch parameters from the params map. window_size = params.get("window_size", window_size); step_size = params.get("step_size", step_size); diff --git a/libs/qec/lib/decoders/sliding_window.h b/libs/qec/lib/decoders/sliding_window.h index 49c1af7e7..cfa073e48 100644 --- a/libs/qec/lib/decoders/sliding_window.h +++ b/libs/qec/lib/decoders/sliding_window.h @@ -22,6 +22,9 @@ namespace cudaq::qec { /// low-latency decoding of streaming syndrome data. class sliding_window : public decoder { private: + /// Canonical materialized matrix view retained by this matrix-family decoder. + const sparse_binary_matrix &H; + // --- Input parameters --- /// The number of rounds of syndrome data in each window. @@ -104,7 +107,7 @@ class sliding_window : public decoder { /// - num_boundary_syndromes: Boundary-layer width (0 if uniform) /// - inner_decoder_name: Name of the inner decoder to use /// - inner_decoder_params: Parameters for the inner decoder (optional) - sliding_window(const cudaq::qec::sparse_binary_matrix &H, + sliding_window(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms); /// @brief Decode a syndrome vector @@ -141,9 +144,10 @@ class sliding_window : public decoder { // Plugin registration macros CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( sliding_window, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return cudaq::qec::make_pcm_decoder(std::move(inputs), + params); }) }; diff --git a/libs/qec/lib/experiments.cpp b/libs/qec/lib/experiments.cpp index 35a762d0a..29f841d69 100644 --- a/libs/qec/lib/experiments.cpp +++ b/libs/qec/lib/experiments.cpp @@ -14,8 +14,10 @@ #include #include #include +#include #include #include +#include using namespace cudaqx; @@ -442,9 +444,9 @@ namespace details { /// so the simpler overload suffices. static decoder_inputs make_component(detector_error_model dem, cudaq::M2DSparseMatrix m2d, - cudaq::M2OSparseMatrix m2o, std::size_t num_rounds, - std::size_t num_x_stabilizers, std::size_t num_z_stabilizers, - bool fixed_basis_is_z, bool keep_x, bool keep_z) { + std::size_t num_rounds, std::size_t num_x_stabilizers, + std::size_t num_z_stabilizers, bool fixed_basis_is_z, + bool keep_x, bool keep_z) { if (keep_x && keep_z) { const uint32_t numBoundary = fixed_basis_is_z ? static_cast(num_z_stabilizers) @@ -452,7 +454,7 @@ make_component(detector_error_model dem, cudaq::M2DSparseMatrix m2d, dem.canonicalize_for_rounds_with_boundary( static_cast(num_x_stabilizers + num_z_stabilizers), numBoundary, /*remove_zero_syndrome_errors=*/true); - return {std::move(dem), std::move(m2d), std::move(m2o)}; + return decoder_inputs(std::move(dem), m2d_to_sparse(m2d)); } const std::size_t numDetectors = dem.detector_error_matrix.shape()[0]; @@ -471,7 +473,7 @@ make_component(detector_error_model dem, cudaq::M2DSparseMatrix m2d, detector_error_model empty_dem; empty_dem.detector_error_matrix = cudaqx::tensor({0, 0}); empty_dem.observables_flips_matrix = cudaqx::tensor({numObs, 0}); - return {std::move(empty_dem), std::move(empty_m2d), std::move(m2o)}; + return decoder_inputs(std::move(empty_dem), m2d_to_sparse(empty_m2d)); } // Select the detector rows. @@ -497,17 +499,55 @@ make_component(detector_error_model dem, cudaq::M2DSparseMatrix m2d, (keep_z ? num_z_stabilizers : 0) + (keep_x ? num_x_stabilizers : 0); dem.canonicalize_for_rounds(static_cast(numReturnSynPerRound), /*remove_zero_syndrome_errors=*/true); - return {std::move(dem), std::move(out_m2d), std::move(m2o)}; + return decoder_inputs(std::move(dem), m2d_to_sparse(out_m2d)); } } // namespace details std::vector d_sparse(const cudaq::M2DSparseMatrix &m2d) { std::vector out; - out.reserve(m2d.rows.size() * 2); // rough estimate for (const auto &row : m2d.rows) { - for (auto meas : row) - out.push_back(static_cast(meas)); + for (const auto measurement : row) { + if (measurement > + static_cast(std::numeric_limits::max())) + throw std::overflow_error( + "measurement-to-detector index exceeds int64_t range"); + out.push_back(static_cast(measurement)); + } + out.push_back(-1); + } + return out; +} + +sparse_binary_matrix m2d_to_sparse(const cudaq::M2DSparseMatrix &m2d) { + using index_type = sparse_binary_matrix::index_type; + if (m2d.rows.size() > std::numeric_limits::max() || + m2d.num_measurements > std::numeric_limits::max()) + throw std::overflow_error( + "measurement-to-detector map exceeds uint32_t dimensions"); + + std::vector> rows; + rows.reserve(m2d.rows.size()); + for (const auto &source_row : m2d.rows) { + auto &row = rows.emplace_back(); + row.reserve(source_row.size()); + for (const auto measurement : source_row) { + if (measurement > std::numeric_limits::max()) + throw std::overflow_error( + "measurement-to-detector index exceeds uint32_t range"); + row.push_back(static_cast(measurement)); + } + } + return sparse_binary_matrix::from_nested_csr( + static_cast(rows.size()), + static_cast(m2d.num_measurements), rows); +} + +std::vector d_sparse(const sparse_binary_matrix &m2d) { + std::vector out; + for (const auto &row : m2d.to_nested_csr()) { + for (const auto measurement : row) + out.push_back(static_cast(measurement)); out.push_back(-1); } return out; @@ -575,24 +615,29 @@ std::size_t decoder_context::num_measurements() const { return m2d_.num_measurements; } +const cudaq::M2OSparseMatrix & +decoder_context::measurement_to_observables() const { + return m2o_; +} + decoder_inputs decoder_context::x_component() const { - return details::make_component(dem_, m2d_, m2o_, num_rounds_, - num_x_stabilizers_, num_z_stabilizers_, - fixed_basis_is_z_, /*keep_x=*/true, + return details::make_component(dem_, m2d_, num_rounds_, num_x_stabilizers_, + num_z_stabilizers_, fixed_basis_is_z_, + /*keep_x=*/true, /*keep_z=*/false); } decoder_inputs decoder_context::z_component() const { - return details::make_component(dem_, m2d_, m2o_, num_rounds_, - num_x_stabilizers_, num_z_stabilizers_, - fixed_basis_is_z_, /*keep_x=*/false, + return details::make_component(dem_, m2d_, num_rounds_, num_x_stabilizers_, + num_z_stabilizers_, fixed_basis_is_z_, + /*keep_x=*/false, /*keep_z=*/true); } decoder_inputs decoder_context::full_component() const { - return details::make_component(dem_, m2d_, m2o_, num_rounds_, - num_x_stabilizers_, num_z_stabilizers_, - fixed_basis_is_z_, /*keep_x=*/true, + return details::make_component(dem_, m2d_, num_rounds_, num_x_stabilizers_, + num_z_stabilizers_, fixed_basis_is_z_, + /*keep_x=*/true, /*keep_z=*/true); } @@ -605,7 +650,7 @@ detector_error_model dem_from_memory_circuit(const code &code, return decoder_context_from_memory_circuit(code, statePrep, numRounds, noise, decompose_errors) .full_component() - .dem; + .materialize_detector_error_model(); } // For CSS codes, may want to partition x vs z decoding @@ -617,7 +662,7 @@ detector_error_model x_dem_from_memory_circuit(const code &code, return decoder_context_from_memory_circuit(code, statePrep, numRounds, noise, decompose_errors) .x_component() - .dem; + .materialize_detector_error_model(); } detector_error_model z_dem_from_memory_circuit(const code &code, @@ -628,7 +673,7 @@ detector_error_model z_dem_from_memory_circuit(const code &code, return decoder_context_from_memory_circuit(code, statePrep, numRounds, noise, decompose_errors) .z_component() - .dem; + .materialize_detector_error_model(); } } // namespace cudaq::qec diff --git a/libs/qec/python/bindings/py_code.cpp b/libs/qec/python/bindings/py_code.cpp index 9eb8faf93..bd3ada138 100644 --- a/libs/qec/python/bindings/py_code.cpp +++ b/libs/qec/python/bindings/py_code.cpp @@ -711,7 +711,10 @@ void bindCode(nb::module_ &mod) { "x_component", [](const decoder_context &h) { auto ctx = h.x_component(); - return nb::make_tuple(ctx.dem, ctx.m2d.rows, ctx.m2o.rows); + return nb::make_tuple( + ctx.materialize_detector_error_model(), + ctx.measurement_to_detectors()->to_nested_csr(), + h.measurement_to_observables().rows); }, R"pbdoc( Canonicalize X-stabilizer detectors; return (dem, m2d, m2o). @@ -723,7 +726,10 @@ void bindCode(nb::module_ &mod) { "z_component", [](const decoder_context &h) { auto ctx = h.z_component(); - return nb::make_tuple(ctx.dem, ctx.m2d.rows, ctx.m2o.rows); + return nb::make_tuple( + ctx.materialize_detector_error_model(), + ctx.measurement_to_detectors()->to_nested_csr(), + h.measurement_to_observables().rows); }, R"pbdoc( Canonicalize Z-stabilizer detectors; return (dem, m2d, m2o). @@ -735,7 +741,10 @@ void bindCode(nb::module_ &mod) { "full_component", [](const decoder_context &h) { auto ctx = h.full_component(); - return nb::make_tuple(ctx.dem, ctx.m2d.rows, ctx.m2o.rows); + return nb::make_tuple( + ctx.materialize_detector_error_model(), + ctx.measurement_to_detectors()->to_nested_csr(), + h.measurement_to_observables().rows); }, R"pbdoc( Canonicalize both stabilizer types with boundary awareness; diff --git a/libs/qec/python/bindings/py_decoder.cpp b/libs/qec/python/bindings/py_decoder.cpp index b646645b8..4bb42defd 100644 --- a/libs/qec/python/bindings/py_decoder.cpp +++ b/libs/qec/python/bindings/py_decoder.cpp @@ -195,7 +195,7 @@ class PyDecoder : public decoder { /// @brief Construct from a scipy sparse matrix (CSR, CSC, COO, ...) or a /// dense numpy array of any numeric dtype. PyDecoder(nb::object mat) - : decoder([&mat]() -> cudaq::qec::sparse_binary_matrix { + : decoder(decoder_inputs([&mat]() -> cudaq::qec::sparse_binary_matrix { // Any scipy sparse format exposes tocsr(); detect via that rather // than indptr/indices, which COO and some other formats lack. if (nb::hasattr(mat, "tocsr")) @@ -209,7 +209,7 @@ class PyDecoder : public decoder { return make_sparse_from_dense( nb::cast>( mat.attr("astype")("uint8", nb::arg("copy") = false))); - }()) {} + }())) {} decoder_result decode(const std::vector &syndrome) override { NB_OVERRIDE_PURE(decode, syndrome); @@ -896,7 +896,8 @@ void bindDecoder(nb::module_ &mod) { return PyDecoderRegistry::get_decoder(name, H_obj, options); } - return get_decoder(name, decoder_init{dem_text}, hetMapFromKwargs(options)); + return get_decoder(name, decoder_inputs::from_stim_dem(dem_text), + hetMapFromKwargs(options)); }; qecmod.def( @@ -944,7 +945,7 @@ void bindDecoder(nb::module_ &mod) { ``cudaqx::tensor`` is built first, then converted to CSC sparse storage. For large PCMs this can allocate as much memory as ``rows * cols``. - A Stim detector error model string: native C++ decoders receive the - raw DEM text via ``decoder_init``; Python-registered decoders receive + raw DEM text via ``decoder_inputs``; Python-registered decoders receive the DEM-derived PCM plus ``O`` and ``error_rate_vec`` defaults. For Python-registered decoders (``cudaq.qec.decoder`` decorator), ``H`` diff --git a/libs/qec/python/tests/test_dem.py b/libs/qec/python/tests/test_dem.py index b158c1ecb..e8decf19d 100644 --- a/libs/qec/python/tests/test_dem.py +++ b/libs/qec/python/tests/test_dem.py @@ -820,6 +820,10 @@ def test_decoder_context_d_sparse_layout(): assert rebuilt == [list(r) for r in fc_m2d] +def test_d_sparse_does_not_require_explicit_measurement_width(): + assert qec.d_sparse([[2], [], [0, 4]]) == [2, -1, -1, 0, 4, -1] + + def test_decoder_context_single_type_code_empty_component(): # The repetition code has only Z stabilizers, so its X context has no # detectors. Verify the empty context is self-consistent (no detectors, diff --git a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp index ede7baa39..8fc4c2647 100644 --- a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp +++ b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp @@ -612,29 +612,33 @@ TEST(QECCodeTester, checkRealtimeDecodeFromMemoryCircuit) { auto ctx = cudaq::qec::decoder_context_from_memory_circuit( *steane, cudaq::qec::operation::prep0, nRounds, noise); - auto [dem, m2d, m2o] = ctx.full_component(); - - ASSERT_FALSE(m2d.rows.empty()); - EXPECT_EQ(m2d.rows.size(), dem.num_detectors()); + auto inputs = ctx.full_component(); + auto dem = inputs.materialize_detector_error_model(); + const auto *D = inputs.measurement_to_detectors(); + ASSERT_NE(D, nullptr); + const auto m2d_rows = D->to_nested_csr(); + + ASSERT_FALSE(m2d_rows.empty()); + EXPECT_EQ(m2d_rows.size(), dem.num_detectors()); ASSERT_EQ(ctx.num_measurements(), nRounds * numCols + numData); // Inhomogeneous boundary: single-measurement boundary detectors coexist with // multi-measurement interior/final ones, so m2d rows are not all one shape. - std::size_t minRow = m2d.rows[0].size(), maxRow = m2d.rows[0].size(); - for (const auto &row : m2d.rows) { + std::size_t minRow = m2d_rows[0].size(), maxRow = m2d_rows[0].size(); + for (const auto &row : m2d_rows) { minRow = std::min(minRow, row.size()); maxRow = std::max(maxRow, row.size()); } EXPECT_LT(minRow, maxRow); // Configure the realtime decoder from the decoder_inputs returned by - // full_component(). - auto decoder = - cudaq::qec::get_decoder("single_error_lut", dem.detector_error_matrix); - decoder->set_O_sparse( - cudaq::qec::pcm_to_sparse_vec(dem.observables_flips_matrix)); - decoder->set_D_sparse(cudaq::qec::d_sparse(m2d)); - ASSERT_EQ(decoder->get_num_msyn_per_decode(), m2d.num_measurements); + // full_component(). The server adapter explicitly requests and installs O + // and D; decoder base construction consumes metadata only. + auto decoder = cudaq::qec::get_decoder("single_error_lut", inputs); + EXPECT_EQ(decoder->get_num_msyn_per_decode(), 0); + decoder->set_O_sparse(inputs.observable_flips_matrix().to_nested_csr()); + decoder->set_D_sparse(*D); + ASSERT_EQ(decoder->get_num_msyn_per_decode(), D->num_cols()); // Stream numCols ancilla per round, then the final data readout. The window // must not decode until that last chunk completes it. @@ -680,21 +684,26 @@ TEST(QECCodeTester, checkDecoderContextAndComponents) { auto x = cudaq::qec::x_dem_from_memory_circuit(*steane, prep, nRounds, noise); // full_component() matches the plain entry point. - auto [fc_dem, fc_m2d, fc_m2o] = ctx.full_component(); + auto fc_inputs = ctx.full_component(); + auto fc_dem = fc_inputs.materialize_detector_error_model(); EXPECT_TRUE( tensors_equal(fc_dem.detector_error_matrix, dem.detector_error_matrix)); // x_component() / z_component() reproduce the per-type DEMs and partition // the detectors without re-running dem_from_kernel. - auto [zc_dem, zc_m2d, zc_m2o] = ctx.z_component(); - auto [xc_dem, xc_m2d, xc_m2o] = ctx.x_component(); + auto zc_inputs = ctx.z_component(); + auto xc_inputs = ctx.x_component(); + auto zc_dem = zc_inputs.materialize_detector_error_model(); + auto xc_dem = xc_inputs.materialize_detector_error_model(); EXPECT_TRUE( tensors_equal(zc_dem.detector_error_matrix, z.detector_error_matrix)); EXPECT_TRUE( tensors_equal(xc_dem.detector_error_matrix, x.detector_error_matrix)); EXPECT_EQ(zc_dem.num_detectors() + xc_dem.num_detectors(), fc_dem.num_detectors()); - EXPECT_EQ(zc_m2d.rows.size(), zc_dem.num_detectors()); + ASSERT_NE(zc_inputs.measurement_to_detectors(), nullptr); + EXPECT_EQ(zc_inputs.measurement_to_detectors()->num_rows(), + zc_dem.num_detectors()); } TEST(QECCodeTester, checkDemFromMemoryCircuit) { diff --git a/libs/qec/unittests/decoders/sample_decoder.cpp b/libs/qec/unittests/decoders/sample_decoder.cpp index 0d357b5d7..d3513d2f5 100644 --- a/libs/qec/unittests/decoders/sample_decoder.cpp +++ b/libs/qec/unittests/decoders/sample_decoder.cpp @@ -20,9 +20,9 @@ class sample_decoder : public decoder { bool decode_to_obs = false; public: - sample_decoder(const cudaq::qec::sparse_binary_matrix &H, + sample_decoder(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : decoder(std::move(inputs)) { // Decoder-specific constructor arguments can be placed in `params`. decode_to_obs = params.get("decode_to_obs", decode_to_obs); if (decode_to_obs) @@ -42,9 +42,10 @@ class sample_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( sample_decoder, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return cudaq::qec::make_pcm_decoder(std::move(inputs), + params); }) }; diff --git a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp index d084a8e8a..09d8c44ee 100644 --- a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp +++ b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp @@ -86,9 +86,9 @@ reusable_decode_barrier &decode_barrier() { /// subsequent decode rendezvous with all configured instances before returning. class concurrency_test_decoder : public decoder { public: - concurrency_test_decoder(const sparse_binary_matrix &H, + concurrency_test_decoder(decoder_inputs inputs, const cudaqx::heterogeneous_map &) - : decoder(H) { + : decoder(std::move(inputs)) { std::cout << "QEC_CONCURRENCY_TEST_DECODER_CONSTRUCTED" << std::endl; set_result_type(decode_result_type::decode_to_obs); } @@ -114,8 +114,9 @@ class concurrency_test_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( concurrency_test_decoder, static std::unique_ptr create( - const decoder_init &init, const cudaqx::heterogeneous_map ¶ms) { - return make_pcm_decoder(init, params); + decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { + return make_pcm_decoder(std::move(inputs), + params); }) private: diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp index a5717f5c3..71671c8f5 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp @@ -313,8 +313,9 @@ build_multi_decoder_config(const cudaq::qec::decoder_inputs &inputs, std::size_t num_boundary_syndromes, const run_options &opts) { namespace config = cudaq::qec::decoding::config; - const auto &dem = inputs.dem; - const auto d_sparse = cudaq::qec::d_sparse(inputs.m2d); + const auto dem = inputs.materialize_detector_error_model(); + const auto d_sparse = + cudaq::qec::d_sparse(*inputs.measurement_to_detectors()); config::multi_decoder_config multi_config; for (int i = 0; i < opts.num_logical; i++) { @@ -544,8 +545,8 @@ bool setup_decoders(const cudaq::qec::code &code, auto ctx = cudaq::qec::decoder_context_from_memory_circuit( code, state_prep, opts.num_rounds, noise, decompose_errors); const auto inputs = ctx.full_component(); - printf("DEM: %ld detectors x %ld error mechanisms\n", - inputs.dem.num_detectors(), inputs.dem.num_error_mechanisms()); + printf("DEM: %ld detectors x %ld error mechanisms\n", inputs.num_detectors(), + inputs.num_error_mechanisms()); const bool is_z_prep = state_prep == cudaq::qec::operation::prep0 || state_prep == cudaq::qec::operation::prep1; diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp index 1b29e1025..d1ca7bfe8 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp @@ -327,7 +327,7 @@ void save_dem_to_file( const auto &inputs = (decoder_type == "nv-qldpc-decoder") ? bp_inputs[i] : matching_inputs[i]; - const auto &edem = inputs.dem; + const auto edem = inputs.materialize_detector_error_model(); cudaq::qec::decoding::config::decoder_config config; config.id = i; config.type = decoder_type; @@ -337,7 +337,10 @@ void save_dem_to_file( config.O_sparse = cudaq::qec::pcm_to_sparse_vec(edem.observables_flips_matrix); // Ising replaces this native mapping with its detector ordering below. - config.D_sparse = cudaq::qec::d_sparse(inputs.m2d); + const auto *D = inputs.measurement_to_detectors(); + if (!D) + throw std::runtime_error("decoder inputs are missing D"); + config.D_sparse = cudaq::qec::d_sparse(*D); if (decoder_type == "nv-qldpc-decoder") { cudaqx::heterogeneous_map nv_args; @@ -394,20 +397,18 @@ void save_dem_to_file( ising_artifacts_dir + "/O_csr.bin", oRows, oCols); auto priors = read_priors_bin(ising_artifacts_dir + "/priors.bin"); std::size_t dRows = 0; - config.D_sparse = - read_D_sparse_txt(ising_artifacts_dir + "/D_sparse.txt", - inputs.m2d.num_measurements, dRows); + config.D_sparse = read_D_sparse_txt( + ising_artifacts_dir + "/D_sparse.txt", D->num_cols(), dRows); - if (hRows != inputs.m2d.rows.size()) + if (hRows != D->num_rows()) throw std::runtime_error("Ising H rows (" + std::to_string(hRows) + ") != cudaqx m2d detectors (" + - std::to_string(inputs.m2d.rows.size()) + - ")"); - if (dRows != inputs.m2d.rows.size()) - throw std::runtime_error( - "D_sparse.txt rows (" + std::to_string(dRows) + - ") != cudaqx m2d detectors (" + - std::to_string(inputs.m2d.rows.size()) + ")"); + std::to_string(D->num_rows()) + ")"); + if (dRows != D->num_rows()) + throw std::runtime_error("D_sparse.txt rows (" + + std::to_string(dRows) + + ") != cudaqx m2d detectors (" + + std::to_string(D->num_rows()) + ")"); if (hCols != oCols || hCols != priors.size()) throw std::runtime_error("Ising H/O/priors column counts disagree"); if (oRows != 1) @@ -989,7 +990,11 @@ void demo_circuit_host(const cudaq::qec::code &code, int distance, patch_m2o, prep, numData, numAncx, numAncz, pairedRounds, cnot_schedX_flat, cnot_schedZ_flat, p_spam_per_patch[patch], z_logical_indices, z_supports_flat, z_supports_offsets); - if (patch > 0 && patch_m2d.rows != matching_inputs.front().m2d.rows) + const auto patch_D = cudaq::qec::m2d_to_sparse(patch_m2d); + if (patch > 0 && + patch_D.to_nested_csr() != matching_inputs.front() + .measurement_to_detectors() + ->to_nested_csr()) throw std::runtime_error( "per-patch DEMs produced different measurement mappings"); @@ -999,11 +1004,10 @@ void demo_circuit_host(const cudaq::qec::code &code, int distance, dual_parse ? cudaq::qec::dem_from_stim_text( dem_text, /*use_decomp_suggestions=*/false) : patch_dem; - matching_inputs.push_back({std::move(patch_dem), patch_m2d, patch_m2o}); - bp_inputs.push_back({std::move(patch_dem_undecomposed), - std::move(patch_m2d), std::move(patch_m2o)}); + matching_inputs.emplace_back(std::move(patch_dem), patch_D); + bp_inputs.emplace_back(std::move(patch_dem_undecomposed), patch_D); } - dem = matching_inputs.front().dem; + dem = matching_inputs.front().materialize_detector_error_model(); numSyndromesPerRound = numAncx + numAncz; printf("numSyndromesPerRound: %ld\n", numSyndromesPerRound); diff --git a/libs/qec/unittests/test_decoders.cpp b/libs/qec/unittests/test_decoders.cpp index 7efd9e218..c840fc464 100644 --- a/libs/qec/unittests/test_decoders.cpp +++ b/libs/qec/unittests/test_decoders.cpp @@ -8,6 +8,7 @@ #include "stim.h" #include "cudaq/qec/decoder.h" +#include "cudaq/qec/decoder_inputs.h" #include "cudaq/qec/detector_error_model.h" #include "cudaq/qec/pcm_utils.h" #include @@ -21,6 +22,20 @@ #include namespace { +class decoder_inputs_probe final : public cudaq::qec::decoder { +public: + explicit decoder_inputs_probe(cudaq::qec::decoder_inputs inputs) + : decoder(std::move(inputs)) {} + + cudaq::qec::decoder_result + decode(const std::vector &) override { + return {true, std::vector(block_size, 0.0)}; + } + + std::size_t configured_observable_rows() const { return O_sparse.size(); } + std::size_t configured_measurement_rows() const { return D_sparse.size(); } +}; + class ScopedEnv { public: ScopedEnv(const char *name, const char *value) : name(name) { @@ -42,6 +57,104 @@ class ScopedEnv { }; } // namespace +TEST(DecoderInputs, PreservesMatrixShapesAndMeasurementMap) { + using matrix = cudaq::qec::sparse_binary_matrix; + auto H = matrix::from_nested_csc(2, 3, {{0}, {0, 1}, {1}}); + auto O = matrix::from_nested_csr(3, 3, {{0}, {}, {2}}); + auto D = matrix::from_nested_csr(2, 5, {{0, 1}, {2, 3}}); + + cudaq::qec::decoder_inputs inputs(std::move(H), std::move(O), {0.1, 0.2, 0.3}, + std::move(D)); + + EXPECT_EQ(inputs.source(), cudaq::qec::decoder_model_source::matrices); + EXPECT_EQ(inputs.num_detectors(), 2); + EXPECT_EQ(inputs.num_error_mechanisms(), 3); + EXPECT_EQ(inputs.num_observables(), 3); + EXPECT_EQ(inputs.observable_flips_matrix().to_nested_csr(), + (std::vector>{{0}, {}, {2}})); + ASSERT_NE(inputs.measurement_to_detectors(), nullptr); + EXPECT_EQ(inputs.measurement_to_detectors()->num_rows(), 2); + EXPECT_EQ(inputs.measurement_to_detectors()->num_cols(), 5); + EXPECT_EQ(inputs.error_rates(), (std::vector{0.1, 0.2, 0.3})); + + const auto materialized = inputs.materialize_detector_error_model(); + EXPECT_EQ(materialized.observables_flips_matrix.shape()[0], 3); + EXPECT_EQ(materialized.observables_flips_matrix.shape()[1], 3); + EXPECT_EQ(materialized.observables_flips_matrix.at({1, 0}), 0); + EXPECT_EQ(materialized.observables_flips_matrix.at({1, 1}), 0); + EXPECT_EQ(materialized.observables_flips_matrix.at({1, 2}), 0); + + auto decoder = cudaq::qec::get_decoder("sample_decoder", inputs); + EXPECT_EQ(decoder->get_num_observables(), 3); + decoder->set_D_sparse(*inputs.measurement_to_detectors()); + EXPECT_EQ(decoder->get_num_msyn_per_decode(), 5); + EXPECT_THROW( + decoder->set_O_sparse(std::vector>{{0}, {2}}), + std::invalid_argument); +} + +TEST(DecoderInputs, BaseConstructionUsesMetadataWithoutMaterializingMatrices) { + using matrix = cudaq::qec::sparse_binary_matrix; + auto H = matrix::from_nested_csc(2, 3, {{0}, {0, 1}, {1}}); + auto O = matrix::from_nested_csr(3, 3, {{0}, {}, {2}}); + auto D = matrix::from_nested_csr(2, 5, {{0, 1}, {2, 3}}); + + decoder_inputs_probe decoder( + cudaq::qec::decoder_inputs(std::move(H), std::move(O), {}, std::move(D))); + + EXPECT_EQ(decoder.configured_observable_rows(), 0); + EXPECT_EQ(decoder.configured_measurement_rows(), 0); + EXPECT_EQ(decoder.get_num_observables(), 3); + EXPECT_EQ(decoder.get_num_msyn_per_decode(), 0); + decoder.reset_decoder(); + const auto *corrections = decoder.get_obs_corrections(); + ASSERT_NE(corrections, nullptr); + EXPECT_EQ(corrections[0], 0); + EXPECT_EQ(corrections[1], 0); + EXPECT_EQ(corrections[2], 0); + + decoder.set_D_sparse(matrix::from_nested_csr(2, 5, {{0, 1}, {2, 3}})); + EXPECT_THROW(decoder.enqueue_syndrome(std::vector(5, 0)), + std::runtime_error); +} + +TEST(DecoderInputs, RawStimRemainsAuthoritative) { + const std::string dem_text = "error(0.1) D0 L0\n" + "error(0.2) D1\n"; + + auto inputs = cudaq::qec::decoder_inputs::from_stim_dem(dem_text); + + EXPECT_EQ(inputs.source(), cudaq::qec::decoder_model_source::stim_dem); + ASSERT_TRUE(inputs.has_stim_dem()); + EXPECT_EQ(inputs.stim_dem(), dem_text); + EXPECT_EQ(inputs.num_detectors(), 2); + EXPECT_EQ(inputs.num_error_mechanisms(), 2); + EXPECT_EQ(inputs.num_observables(), 1); + EXPECT_EQ(inputs.error_rates(), (std::vector{0.1, 0.2})); + EXPECT_EQ(inputs.observable_flips_matrix().to_nested_csr(), + (std::vector>{{0}})); +} + +TEST(DecoderInputs, RejectsInconsistentDimensions) { + using matrix = cudaq::qec::sparse_binary_matrix; + auto H = matrix::from_nested_csc(2, 3, {{0}, {0, 1}, {1}}); + + EXPECT_THROW(cudaq::qec::decoder_inputs( + H, matrix::from_nested_csr(1, 2, {{0}}), {0.1, 0.2, 0.3}), + std::invalid_argument); + EXPECT_THROW(cudaq::qec::decoder_inputs( + H, matrix::from_nested_csr(1, 3, {{0}}), {0.1, 0.2}), + std::invalid_argument); + EXPECT_THROW(cudaq::qec::decoder_inputs( + H, matrix::from_nested_csr(1, 3, {{0}}), {0.1, 0.2, 0.3}, + matrix::from_nested_csr(1, 4, {{0}})), + std::invalid_argument); + EXPECT_THROW(cudaq::qec::decoder_inputs( + H, matrix::from_nested_csr(1, 3, {{0}}), {0.1, 0.2, 0.3}, + std::nullopt, std::vector{0, 1}), + std::invalid_argument); +} + TEST(DecoderUtils, CovertHardToSoft) { std::vector in = {1, 0, 1, 1}; std::vector out; @@ -211,8 +324,8 @@ TEST(SampleDecoder, RealtimeApiAndDefaultGraphHooks) { // Reapply D and O through the flattened YAML-style representation to exercise // the -1 row separators used by realtime configs. decoder->set_D_sparse(std::vector{0, 1, -1, 2, -1}); - decoder->set_O_sparse(std::vector{0, -1}); - EXPECT_EQ(decoder->get_num_observables(), 1u); + decoder->set_O_sparse(std::vector{0, -1, -1, 2, -1}); + EXPECT_EQ(decoder->get_num_observables(), 3u); // Three measurement bits fill the D buffer and trigger a decode. std::vector msyn = {1, 0, 1}; @@ -1264,9 +1377,9 @@ class ScopedDeviceRestore { /// proving decoder::get() strips cuda_device_id before the plugin ctor. class strict_keys_decoder : public cudaq::qec::decoder { public: - strict_keys_decoder(const cudaq::qec::sparse_binary_matrix &H, + strict_keys_decoder(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : decoder(std::move(inputs)) { auto invalid = cudaq::qec::validate_config_parameters(params, {"decode_to_obs"}); if (!invalid.empty()) @@ -1282,9 +1395,10 @@ class strict_keys_decoder : public cudaq::qec::decoder { } CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( strict_keys_decoder, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return cudaq::qec::make_pcm_decoder( + std::move(inputs), params); }) }; CUDAQ_EXT_PT_REGISTER_TYPE(strict_keys_decoder) @@ -1299,9 +1413,9 @@ cudaq::qec::sparse_binary_matrix make_test_H() { class device_recording_decoder : public cudaq::qec::decoder { public: std::atomic last_decode_device{-2}; - device_recording_decoder(const cudaq::qec::sparse_binary_matrix &H, + device_recording_decoder(cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map &) - : decoder(H) {} + : decoder(std::move(inputs)) {} cudaq::qec::decoder_result decode(const std::vector &) override { int dev = -1; @@ -1316,10 +1430,10 @@ class device_recording_decoder : public cudaq::qec::decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( device_recording_decoder, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, - params); + return cudaq::qec::make_pcm_decoder( + std::move(inputs), params); }) }; CUDAQ_EXT_PT_REGISTER_TYPE(device_recording_decoder) diff --git a/libs/qec/unittests/test_decoding_server_core.cpp b/libs/qec/unittests/test_decoding_server_core.cpp index fc56ff599..41b7acf0d 100644 --- a/libs/qec/unittests/test_decoding_server_core.cpp +++ b/libs/qec/unittests/test_decoding_server_core.cpp @@ -41,9 +41,10 @@ using cudaq::realtime::RPCResponse; class ControlledDecoder final : public cudaq::qec::decoder { public: ControlledDecoder() - : decoder(cudaq::qec::sparse_binary_matrix::from_csr( - /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, - /*col_indices=*/{0})) { + : decoder(cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix::from_csr( + /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, + /*col_indices=*/{0}))) { set_O_sparse(std::vector>{{0}}); // One detector is the parity of two incoming measurement bits, so a decode // completes only after two one-bit enqueue calls. @@ -300,9 +301,10 @@ TEST(SetCudaDeviceForDecode, ImpossibleDeviceThrows) { class MispinnedDecoder final : public cudaq::qec::decoder { public: MispinnedDecoder() - : decoder(cudaq::qec::sparse_binary_matrix::from_csr( - /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, - /*col_indices=*/{0})) { + : decoder(cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix::from_csr( + /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, + /*col_indices=*/{0}))) { set_O_sparse(std::vector>{{0}}); set_D_sparse(std::vector>{{0, 1}}); cuda_device_id_ = 1 << 20; diff --git a/libs/qec/unittests/test_qec.cpp b/libs/qec/unittests/test_qec.cpp index 382ae97c6..a562a1f0f 100644 --- a/libs/qec/unittests/test_qec.cpp +++ b/libs/qec/unittests/test_qec.cpp @@ -22,6 +22,14 @@ namespace { +TEST(DecoderInputsUtilities, DSparseDoesNotRequireDeclaredMeasurementWidth) { + cudaq::M2DSparseMatrix m2d; + m2d.rows = {{2}, {}, {0, 4}}; + + EXPECT_EQ(cudaq::qec::d_sparse(m2d), + (std::vector{2, -1, -1, 0, 4, -1})); +} + using cudaq::qec::surface_code::sc_orientation; using cudaq::qec::surface_code::stabilizer_grid; using cudaq::qec::surface_code::surface_role; From 7213c2d6dcbd871e0099233dbfc09ad61563fcde Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Fri, 31 Jul 2026 23:46:41 -0700 Subject: [PATCH 02/24] Fix decoder result form at construction Selecting a decoder's result form by the presence of an observable matrix conflated model data with a behavior switch. The form is now chosen once when the decoder is constructed and is immutable thereafter, and O is model data only. decode() returns to being the single virtual a plugin implements. The per-call output selection, the native/derived dispatch, the capability declarations and the caller-buffer mechanism built around them are removed; a decoder that cannot produce its requested form rejects construction rather than returning the wrong shape on the first decode. Projection from an error frame to observables lives in one shared base helper, so no decoder writes its own. decoder_inputs now distinguishes a supplied observable mapping from an absent one, so a zero-row O is a model rather than a missing one, and it can canonicalize itself while retaining authoritative source and provenance. prepare_decoder_params no longer branches on decoder names, and error rates reach decoders as model data through decoder_config instead of plugin parameters. TensorRT declares its engine output format explicitly, validates a global decoder's results before indexing them, and carries that decoder's optional metadata through the combined result. Signed-off-by: Melody Ren --- libs/qec/include/cudaq/qec/decoder.h | 173 ++++++++---- libs/qec/include/cudaq/qec/decoder_inputs.h | 57 +++- .../cudaq/qec/realtime/decoding_config.h | 3 + libs/qec/lib/decoder.cpp | 129 +++++++-- libs/qec/lib/decoder_inputs.cpp | 91 ++++-- libs/qec/lib/decoders/lut.cpp | 47 +++- .../plugins/chromobius/chromobius.cpp | 35 ++- .../example/single_error_lut_example.cpp | 19 +- .../plugins/pymatching/pymatching.cpp | 142 +++++----- .../plugins/trt_decoder/trt_decoder.cpp | 262 +++++++++++++----- libs/qec/lib/decoders/sliding_window.cpp | 72 +++-- libs/qec/lib/decoders/sliding_window.h | 8 +- libs/qec/lib/realtime/config.cpp | 12 + libs/qec/lib/realtime/realtime_decoding.cpp | 68 +---- libs/qec/python/bindings/py_decoder.cpp | 101 +++++-- .../python/bindings/py_decoding_config.cpp | 95 +++++-- libs/qec/python/cudaq_qec/_compat.py | 3 +- libs/qec/python/tests/test_decoders_yaml.py | 6 +- libs/qec/python/tests/test_decoding_config.py | 54 ++-- .../tests/test_decoding_config_deprecated.py | 79 +++++- libs/qec/python/tests/test_dem.py | 6 +- libs/qec/python/tests/test_sliding_window.py | 5 +- libs/qec/python/tests/test_trt_decoder.py | 39 ++- .../decoding_server_config.yaml | 2 +- .../backend-specific/stim/test_qec_stim.cpp | 33 +-- .../decoders/chromobius/test_chromobius.cpp | 10 +- .../decoders/pymatching/test_pymatching.cpp | 58 +++- .../test_pymatching_device_call_realtime.cpp | 4 +- .../pymatching/test_pymatching_realtime.cpp | 2 +- .../qec/unittests/decoders/sample_decoder.cpp | 39 ++- .../decoders/trt_decoder/test_trt_decoder.cpp | 74 +++-- .../app_examples/concurrency_test_decoder.cpp | 17 +- .../realtime/app_examples/surface_code-1.cpp | 11 +- ...surface_code-4-yaml-mixed-dispatch-test.sh | 2 +- .../app_examples/surface_code-4-yaml.cpp | 9 +- .../surface_code-5-per-decoder-rings.cpp | 3 +- .../data/config_nv_qldpc_relay.yml | 6 +- .../realtime/test_decoding_server.cpp | 16 +- .../test_realtime_predecoder_w_pymatching.cpp | 17 +- .../realtime/test_trt_decoder_composite.cpp | 197 ++----------- libs/qec/unittests/test_decoders.cpp | 199 ++++++++++--- libs/qec/unittests/test_decoders_yaml.cpp | 45 ++- .../unittests/test_decoding_server_core.cpp | 14 +- 43 files changed, 1469 insertions(+), 795 deletions(-) diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index e7da9e392..fce060d91 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -30,6 +30,12 @@ using float_t = CUDAQX_QEC_FLOAT_TYPE; using float_t = double; #endif +/// @brief The basis of a decoder result. +enum class decoder_output : std::uint8_t { + errors, + observables, +}; + /// @brief Validates that all keys in a heterogeneous map are found in a list of /// acceptable types /// @param config The heterogeneous map to validate @@ -53,8 +59,9 @@ struct decoder_result { /// @brief Whether or not the decoder converged. bool converged = false; - /// @brief Vector of length `block_size` with soft probabilities of errors in - /// each index. + /// @brief Decoder values in the instance's construction-time output basis. + /// Error results have length `block_size`; observable results have length + /// `get_num_observables()`. std::vector result; /// @brief Optional additional results from the decoder stored in a @@ -129,6 +136,7 @@ class async_decoder_result { /// decoder. class decoder : public cudaqx::extension_point, const cudaqx::heterogeneous_map &> { private: struct rt_impl; @@ -138,49 +146,34 @@ class decoder std::unique_ptr pimpl; public: - /// @brief Indicates whether decode() returns a full error frame (length - /// block_size) or an already-projected observable frame (length - /// num_observables). Decoders that accept an "O" observable matrix in their - /// constructor params should call set_result_type(decode_to_obs); all others - /// default to decode_to_errs. - /// - /// Note: legacy H-only construction must still call set_O_sparse() so that - /// enqueue_syndrome() knows num_observables. Construction with decoder_inputs - /// obtains the count from input metadata. - enum decode_result_type { - decode_to_errs, ///< result.size() == block_size; enqueue_syndrome projects - ///< via O_sparse - decode_to_obs, ///< result.size() == num_observables; enqueue_syndrome uses - ///< result directly - }; - decoder() = delete; /// @brief Constructor /// @param inputs Stable model and measurement inputs. Taken by value so the /// factory can move its immutable handle into the decoder. - decoder(decoder_inputs inputs); + decoder(decoder_inputs inputs, decoder_output default_output); /// @brief Decode a single syndrome /// @param syndrome A vector of syndrome measurements where the floating point /// value is the probability that the syndrome measurement is a |1>. The /// length of the syndrome vector should be equal to `syndrome_size`. - /// @returns Vector of length `block_size` with soft probabilities of errors - /// in each index. + /// @returns A result in the form this instance was constructed for. A + /// decoder that cannot produce that form rejects construction, so this never + /// negotiates the form per call. virtual decoder_result decode(const std::vector &syndrome) = 0; /// @brief Decode a single syndrome /// @param syndrome An order-1 tensor of syndrome measurements where a 1 bit /// represents that the syndrome measurement is a |1>. The /// length of the syndrome vector should be equal to `syndrome_size`. - /// @returns Vector of length `block_size` of errors in each index. + /// @returns A result in the instance's constructed output form. virtual decoder_result decode(const cudaqx::tensor &syndrome); /// @brief Decode a single syndrome /// @param syndrome A vector of syndrome measurements where the floating point /// value is the probability that the syndrome measurement is a |1>. - /// @returns std::future of a vector of length `block_size` with soft - /// probabilities of errors in each index. + /// @returns A future containing a result in the instance's constructed + /// output form. virtual std::future decode_async(const std::vector &syndrome); @@ -188,8 +181,7 @@ class decoder /// parallel depending on the specific implementation) /// @param syndrome A vector of `N` syndrome measurements where the floating /// point value is the probability that the syndrome measurement is a |1>. - /// @returns 2-D vector of size `N` x `block_size` with soft probabilities of - /// errors in each index. + /// @returns One result per input in the instance's constructed output form. virtual std::vector decode_batch(const std::vector> &syndrome); @@ -201,6 +193,12 @@ class decoder get(const std::string &name, decoder_inputs inputs, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()); + /// @brief Construct a registered decoder with an explicit instance-default + /// result form. + static std::unique_ptr + get(const std::string &name, decoder_inputs inputs, decoder_output output, + const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()); + static std::unique_ptr get(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, const cudaqx::heterogeneous_map ¶m_map = @@ -208,6 +206,14 @@ class decoder return get(name, decoder_inputs{H}, param_map); } + static std::unique_ptr + get(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, + decoder_output output, + const cudaqx::heterogeneous_map ¶m_map = + cudaqx::heterogeneous_map()) { + return get(name, decoder_inputs{H}, output, param_map); + } + static std::unique_ptr get(const std::string &name, const cudaqx::tensor &H, const cudaqx::heterogeneous_map ¶m_map = @@ -222,6 +228,35 @@ class decoder return get(name, decoder_inputs::from_stim_dem(stim_dem_text), param_map); } + /// Each raw-DEM spelling needs its own explicit-output overload: string_view + /// does not convert to const std::string&, and with both present a string + /// literal would otherwise be ambiguous between them. + static std::unique_ptr + get(const std::string &name, const std::string &stim_dem_text, + decoder_output output, + const cudaqx::heterogeneous_map ¶m_map = + cudaqx::heterogeneous_map()) { + return get(name, decoder_inputs::from_stim_dem(stim_dem_text), output, + param_map); + } + + static std::unique_ptr + get(const std::string &name, const char *stim_dem_text, decoder_output output, + const cudaqx::heterogeneous_map ¶m_map = + cudaqx::heterogeneous_map()) { + return get(name, decoder_inputs::from_stim_dem(stim_dem_text), output, + param_map); + } + + static std::unique_ptr + get(const std::string &name, std::string_view stim_dem_text, + decoder_output output, + const cudaqx::heterogeneous_map ¶m_map = + cudaqx::heterogeneous_map()) { + return get(name, decoder_inputs::from_stim_dem(std::string{stim_dem_text}), + output, param_map); + } + static std::unique_ptr get(const std::string &name, const char *stim_dem_text, const cudaqx::heterogeneous_map ¶m_map = @@ -240,17 +275,15 @@ class decoder std::size_t get_block_size() { return block_size; } std::size_t get_syndrome_size() { return syndrome_size; } + /// @brief The result form this instance was constructed to produce. Fixed at + /// construction; every decode operation returns this form. + decoder_output get_default_output() const noexcept { return default_output_; } + // -- Begin realtime decoding API -- // Note: all of the current realtime decoding API is designed to be used with // hard syndromes. - /// @brief Returns the type of result produced by decode(). - /// Defaults to decode_to_errs. Decoders that project to observables - /// internally (i.e., constructed with an "O" param) should call - /// set_result_type(decode_to_obs) in their constructor. - decode_result_type get_result_type() const { return result_type_; } - /// @brief Get the number of measurement syndromes per decode call. This /// depends on D_sparse, so you must have called set_D_sparse() first. uint32_t get_num_msyn_per_decode() const; @@ -350,10 +383,15 @@ class decoder /// @brief The immutable construction inputs owned by this decoder. const decoder_inputs &get_inputs() const noexcept { return inputs_; } - /// @brief Sets the result type. Call in the constructor when an "O" - /// observable matrix is detected in the decoder params. Must be called - /// before the first enqueue_syndrome(). - void set_result_type(decode_result_type type) { result_type_ = type; } + /// @brief Project an error frame onto observables through the model's O. + /// + /// A decoder that internally computes an error frame but was constructed for + /// observable output calls this before returning. The projection lives here + /// so it is implemented once rather than per plugin. + /// @throws std::runtime_error if no observable mapping is available. + void project_errors_to_observables(const float_t *errors, + float_t *observables, + std::size_t observables_size) const; /// @brief Hook called by both set_D_sparse overloads after base-class buffer /// setup is complete. Override to react to a new D_sparse without having to @@ -384,10 +422,13 @@ class decoder int cuda_device_id_ = -1; private: + static std::unique_ptr + get_impl(const std::string &name, decoder_inputs inputs, + std::optional output, + const cudaqx::heterogeneous_map ¶m_map); /// @brief The decoder's immutable construction inputs. const decoder_inputs inputs_; - - decode_result_type result_type_ = decode_result_type::decode_to_errs; + const decoder_output default_output_; }; /// @brief Convert a single soft probability to a hard 0/1 decision. @@ -546,12 +587,24 @@ std::unique_ptr get_decoder(const std::string &name, decoder_inputs inputs, const cudaqx::heterogeneous_map options = {}); +std::unique_ptr +get_decoder(const std::string &name, decoder_inputs inputs, + decoder_output output, + const cudaqx::heterogeneous_map options = {}); + inline std::unique_ptr get_decoder(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, const cudaqx::heterogeneous_map options = {}) { return get_decoder(name, decoder_inputs{H}, options); } +inline std::unique_ptr +get_decoder(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, + decoder_output output, + const cudaqx::heterogeneous_map options = {}) { + return get_decoder(name, decoder_inputs{H}, output, options); +} + inline std::unique_ptr get_decoder(const std::string &name, const cudaqx::tensor &H, const cudaqx::heterogeneous_map options = {}) { @@ -565,6 +618,34 @@ get_decoder(const std::string &name, const std::string &stim_dem_text, options); } +/// Each raw-DEM spelling needs its own explicit-output overload: string_view +/// does not convert to const std::string&, and with both present a string +/// literal would otherwise be ambiguous between them. +inline std::unique_ptr +get_decoder(const std::string &name, const std::string &stim_dem_text, + decoder_output output, + const cudaqx::heterogeneous_map options = {}) { + return get_decoder(name, decoder_inputs::from_stim_dem(stim_dem_text), output, + options); +} + +inline std::unique_ptr +get_decoder(const std::string &name, const char *stim_dem_text, + decoder_output output, + const cudaqx::heterogeneous_map options = {}) { + return get_decoder(name, decoder_inputs::from_stim_dem(stim_dem_text), output, + options); +} + +inline std::unique_ptr +get_decoder(const std::string &name, std::string_view stim_dem_text, + decoder_output output, + const cudaqx::heterogeneous_map options = {}) { + return get_decoder(name, + decoder_inputs::from_stim_dem(std::string{stim_dem_text}), + output, options); +} + inline std::unique_ptr get_decoder(const std::string &name, const char *stim_dem_text, const cudaqx::heterogeneous_map options = {}) { @@ -580,7 +661,6 @@ get_decoder(const std::string &name, std::string_view stim_dem_text, } namespace details { -// Declared here because `make_pcm_decoder` is a header-defined template. /// DEM-derived defaults; pointers alias into the source `dem`. struct dem_default_values { const cudaqx::tensor *O = nullptr; @@ -593,19 +673,4 @@ dem_default_values dem_defaults_for_missing_keys( const detector_error_model &dem); } // namespace details -/// Transitional adapter for matrix-family decoders. Model data comes from -/// `inputs`; until those constructors consume it directly, this helper injects -/// O and error-rate defaults through their legacy parameter interface. -template -std::unique_ptr -make_pcm_decoder(decoder_inputs inputs, - const cudaqx::heterogeneous_map ¶ms) { - cudaqx::heterogeneous_map merged = params; - if (!merged.contains("O") && inputs.num_observables() > 0) - merged.insert("O", inputs.observable_flips_matrix().to_dense()); - if (!merged.contains("error_rate_vec") && !inputs.error_rates().empty()) - merged.insert("error_rate_vec", inputs.error_rates()); - return std::make_unique(std::move(inputs), merged); -} - } // namespace cudaq::qec diff --git a/libs/qec/include/cudaq/qec/decoder_inputs.h b/libs/qec/include/cudaq/qec/decoder_inputs.h index 328aecd4f..76af067f4 100644 --- a/libs/qec/include/cudaq/qec/decoder_inputs.h +++ b/libs/qec/include/cudaq/qec/decoder_inputs.h @@ -15,20 +15,20 @@ #include #include #include +#include #include namespace cudaq::qec { /// @brief Authoritative representation from which a decoder model originates. /// -/// Matrix and Stim sources are supported now. `dem_chunks` names the compact -/// repeated-round representation being introduced by the dynamic DEM APIs. -/// Adding its typed constructor and accessor does not change the -/// `decoder_inputs` object layout or decoder factory signature. +/// Matrix and Stim sources are supported. A compact repeated-round source is +/// expected once the dynamic DEM APIs settle; adding it here needs only a new +/// enumerator plus its typed constructor and accessor, and changes neither the +/// `decoder_inputs` object layout nor the decoder factory signature. enum class decoder_model_source : std::uint8_t { matrices, stim_dem, - dem_chunks, }; /// @brief Stable, owning input contract shared by offline and server decoders. @@ -47,14 +47,16 @@ class decoder_inputs { /// @brief Construct a materialized matrix model. /// @param detector_error_matrix H, with shape detectors x error mechanisms. /// @param observable_flips_matrix O, with shape observables x error - /// mechanisms. Its row count is retained even when a row has no nonzeros. + /// mechanisms. Supplying it establishes an observable model; its row count is + /// retained even when a row has no nonzeros, so a zero-row O is a supplied + /// model rather than an absent one. /// @param error_rates Optional rate per error mechanism. /// @param measurement_to_detectors Optional D, with shape detectors x raw /// measurements. /// @param error_ids Optional correlation ID per error mechanism. decoder_inputs( sparse_binary_matrix detector_error_matrix, - sparse_binary_matrix observable_flips_matrix, + std::optional observable_flips_matrix, std::vector error_rates = {}, std::optional measurement_to_detectors = std::nullopt, @@ -89,7 +91,15 @@ class decoder_inputs { /// @brief Return the stored common H projection. const sparse_binary_matrix &detector_error_matrix() const; + /// @brief Whether this model supplies an observable mapping at all. + /// + /// Distinct from `num_observables() == 0`: a supplied O with zero rows is an + /// observable model, an H-only input is not. Construction-time validation of + /// an observable-output request depends on this distinction. + bool has_observable_model() const noexcept; + /// @brief Return the stored common O projection. + /// @throws std::logic_error if this model supplies no observable mapping. const sparse_binary_matrix &observable_flips_matrix() const; const std::vector &error_rates() const; @@ -98,6 +108,34 @@ class decoder_inputs { /// @brief Return D, or nullptr when input syndromes are already detectors. const sparse_binary_matrix *measurement_to_detectors() const noexcept; + /// @brief Make a basis-preserving child input while independently removing + /// the raw-measurement map. Authoritative compact model provenance is kept. + decoder_inputs without_measurement_to_detectors() const; + + /// @brief Return the same model with H in GF(2)-canonical CSC form. + /// + /// Basis-preserving: `canonicalize()` sorts indices within each compressed + /// group and XOR-merges duplicates, leaving column identity, ordering and + /// dimensions unchanged. Authoritative source, raw DEM provenance and any + /// existing provenance-loss reason are therefore all retained, whatever the + /// source kind. Consumers that need a canonical H should ask for it here + /// rather than rebuilding a matrix-authoritative handle by hand. + decoder_inputs canonicalized() const; + + /// @brief Make child inputs after a detector/error-basis transformation. + /// Compact provenance is intentionally dropped because it no longer + /// describes the supplied matrices; `provenance_loss_reason` records why. + decoder_inputs derive_with_changed_basis( + sparse_binary_matrix detector_error_matrix, + std::optional observable_flips_matrix, + std::vector error_rates, + std::optional> error_ids, + std::string provenance_loss_reason, + std::optional measurement_to_detectors = + std::nullopt) const; + + std::optional provenance_loss_reason() const noexcept; + bool has_stim_dem() const noexcept; /// @throws std::logic_error if the authoritative source is not a Stim DEM. @@ -117,11 +155,12 @@ class decoder_inputs { struct impl; static std::shared_ptr make_matrix_state( decoder_model_source source, sparse_binary_matrix detector_error_matrix, - sparse_binary_matrix observable_flips_matrix, + std::optional observable_flips_matrix, std::vector error_rates, std::optional> error_ids, std::optional measurement_to_detectors, - std::optional raw_stim_dem = std::nullopt); + std::optional raw_stim_dem = std::nullopt, + std::optional provenance_loss_reason = std::nullopt); explicit decoder_inputs(std::shared_ptr state); std::shared_ptr state_; }; diff --git a/libs/qec/include/cudaq/qec/realtime/decoding_config.h b/libs/qec/include/cudaq/qec/realtime/decoding_config.h index 868e2cf67..76df1c282 100644 --- a/libs/qec/include/cudaq/qec/realtime/decoding_config.h +++ b/libs/qec/include/cudaq/qec/realtime/decoding_config.h @@ -75,6 +75,9 @@ struct decoder_config { std::vector H_sparse; std::vector O_sparse; std::vector D_sparse; + /// Error probability per H column. This is framework model data and is + /// normalized into decoder_inputs rather than passed to plugin parameters. + std::vector error_rate_vec; decoder_custom_args_t decoder_custom_args; bool operator==(const decoder_config &) const = default; diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index e159550fc..d1a4b5a4b 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -17,9 +17,11 @@ #include #include #include +#include #include INSTANTIATE_REGISTRY(cudaq::qec::decoder, cudaq::qec::decoder_inputs, + std::optional, const cudaqx::heterogeneous_map &) // Include decoder implementations AFTER registry instantiation @@ -78,9 +80,9 @@ struct decoder::rt_impl { void decoder::rt_impl_deleter::operator()(rt_impl *p) const { delete p; } -decoder::decoder(decoder_inputs inputs) +decoder::decoder(decoder_inputs inputs, decoder_output default_output) : pimpl(std::unique_ptr(new rt_impl())), - inputs_(std::move(inputs)) { + inputs_(std::move(inputs)), default_output_(default_output) { syndrome_size = inputs_.num_detectors(); block_size = inputs_.num_error_mechanisms(); reset_decoder(); @@ -96,6 +98,38 @@ decoder::decoder(decoder_inputs inputs) pimpl->should_log = ch[0] == '1' || ch[0] == 'y' || ch[0] == 'Y'; } +void decoder::project_errors_to_observables( + const float_t *errors, float_t *observables, + std::size_t observables_size) const { + // Hot path: one call per shot on the realtime path. Sizes and O-row counts + // are fixed by construction (and by set_O_sparse for the legacy late-bound + // path), so they are not re-checked here. + if (observables_size > 0) + std::fill(observables, observables + observables_size, float_t{0}); + // Presence, not row count: a supplied zero-row O is a model that projects to + // no observables, which is different from having no observable model at all. + if (inputs_.has_observable_model()) { + const auto &O = inputs_.observable_flips_matrix(); + assert(O.layout() == sparse_binary_matrix_layout::csr); + const auto &ptr = O.ptr(); + const auto &indices = O.indices(); + for (std::size_t row = 0; row < O.num_rows(); ++row) { + bool parity = false; + for (auto pos = ptr[row]; pos < ptr[row + 1]; ++pos) + parity ^= convert_soft_to_hard(errors[indices[pos]]); + observables[row] = static_cast(parity); + } + return; + } + + for (std::size_t row = 0; row < O_sparse.size(); ++row) { + bool parity = false; + for (auto col : O_sparse[row]) + parity ^= convert_soft_to_hard(errors[col]); + observables[row] = static_cast(parity); + } +} + // Provide a trivial implementation of for tensor decode call. Child // classes should override this if they never want to pass through floats. decoder_result decoder::decode(const cudaqx::tensor &syndrome) { @@ -205,6 +239,26 @@ class ConstructionDevicePin { std::unique_ptr decoder::get(const std::string &name, decoder_inputs inputs, const cudaqx::heterogeneous_map ¶m_map) { + return get_impl(name, std::move(inputs), std::nullopt, param_map); +} + +std::unique_ptr +decoder::get(const std::string &name, decoder_inputs inputs, + decoder_output output, + const cudaqx::heterogeneous_map ¶m_map) { + return get_impl(name, std::move(inputs), output, param_map); +} + +std::unique_ptr +decoder::get_impl(const std::string &name, decoder_inputs inputs, + std::optional output, + const cudaqx::heterogeneous_map ¶m_map) { + for (const char *reserved : {"H", "O", "D", "error_rate_vec"}) + if (param_map.contains(reserved)) + throw std::runtime_error( + fmt::format("'{}' is framework model data; provide it through " + "decoder_inputs instead of decoder custom parameters", + reserved)); auto [mutex, registry] = get_registry(); std::lock_guard lock(mutex); auto iter = registry.find(name); @@ -214,8 +268,11 @@ decoder::get(const std::string &name, decoder_inputs inputs, ". Run with CUDAQ_LOG_LEVEL=info (environment variable) to see " "additional plugin diagnostics at startup."); const int cuda_device_id = read_cuda_device_id(param_map); - if (cuda_device_id < 0) - return iter->second(std::move(inputs), param_map); + if (cuda_device_id < 0) { + // The plugin validates the requested form against its model during + // construction; there is nothing left for the factory to re-check. + return iter->second(std::move(inputs), output, param_map); + } ConstructionDevicePin device_pin(cuda_device_id); // The key is consumed here; strip it so plugins that strictly validate // their parameter keys do not reject it. @@ -223,7 +280,7 @@ decoder::get(const std::string &name, decoder_inputs inputs, for (const auto &kv : param_map) if (kv.first != "cuda_device_id") plugin_params.insert(kv.first, kv.second); - auto d = iter->second(std::move(inputs), plugin_params); + auto d = iter->second(std::move(inputs), output, plugin_params); d->cuda_device_id_ = cuda_device_id; device_pin.commit(); return d; @@ -288,7 +345,10 @@ set_sparse_from_vec(const std::vector &vec_in, } void decoder::set_O_sparse(const std::vector> &O_sparse) { - if (inputs_.num_observables() > 0 && + // Presence, not row count: an explicitly supplied zero-row O is a model, and + // the late setter must not be able to silently replace it with a different + // row count. + if (inputs_.has_observable_model() && O_sparse.size() != inputs_.num_observables()) throw std::invalid_argument( "O_sparse row count must match decoder_inputs observable count"); @@ -302,7 +362,10 @@ void decoder::set_O_sparse(const std::vector> &O_sparse) { void decoder::set_O_sparse(const std::vector &O_sparse_vec_in) { std::vector> parsed; set_sparse_from_vec(O_sparse_vec_in, parsed); - if (inputs_.num_observables() > 0 && + // Presence, not row count: an explicitly supplied zero-row O is a model, and + // the late setter must not be able to silently replace it with a different + // row count. + if (inputs_.has_observable_model() && parsed.size() != inputs_.num_observables()) throw std::invalid_argument( "O_sparse row count must match decoder_inputs observable count"); @@ -469,45 +532,44 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, convert_vec_hard_to_soft(pimpl->persistent_detector_buffer, pimpl->persistent_soft_detector_buffer); auto decoded_result = decode(pimpl->persistent_soft_detector_buffer); + std::span decoded_values = decoded_result.result; // If we didn't get a decoded result, just return if (pimpl->is_sliding_window) { - if (decoded_result.result.size() == 0) { + if (decoded_values.empty()) { return false; } } // Process the results. // TODO - should this interrogate the decoded_result.converged flag? - const auto result_type = get_result_type(); const auto num_observables = get_num_observables(); const char *result_type_str = nullptr; const char *result_type_name = nullptr; std::size_t expected_result_size = 0; - switch (result_type) { - case decode_result_type::decode_to_errs: + switch (default_output_) { + case decoder_output::errors: result_type_str = "errs"; - result_type_name = "decode_to_errs"; + result_type_name = "errors"; expected_result_size = block_size; break; - case decode_result_type::decode_to_obs: + case decoder_output::observables: result_type_str = "obs"; - result_type_name = "decode_to_obs"; + result_type_name = "observables"; expected_result_size = num_observables; break; } if (!result_type_name) throw std::runtime_error( fmt::format("Unsupported decoder result type ({})", - static_cast(result_type))); + static_cast(default_output_))); if ((!pimpl->is_sliding_window && - decoded_result.result.size() != expected_result_size) || - (pimpl->is_sliding_window && !decoded_result.result.empty() && - decoded_result.result.size() != expected_result_size)) { + decoded_values.size() != expected_result_size) || + (pimpl->is_sliding_window && !decoded_values.empty() && + decoded_values.size() != expected_result_size)) { throw std::runtime_error(fmt::format( "Decoder result size ({}) does not match expected size ({}) for " "result type {}", - decoded_result.result.size(), expected_result_size, - result_type_name)); + decoded_values.size(), expected_result_size, result_type_name)); } // Flip an observable correction and mirror it into the per-call log so the @@ -521,18 +583,18 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, if (should_log) log_t2 = std::chrono::high_resolution_clock::now(); - switch (result_type) { - case decode_result_type::decode_to_obs: + switch (default_output_) { + case decoder_output::observables: // Observable-frame path: decoder already projected to observables via its // internal "O" matrix; use the result directly. for (std::size_t i = 0; i < num_observables; i++) - if (decoded_result.result[i]) { + if (decoded_values[i]) { if (should_log) log_observables.push_back(i); flip_correction(i); } break; - case decode_result_type::decode_to_errs: + case decoder_output::errors: // Error-frame path: decoder returns a block-sized error vector; project // to observables via O_sparse. if (O_sparse.size() != num_observables) @@ -540,14 +602,14 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, "Observable matrix is not configured: expected {} rows, got {}", num_observables, O_sparse.size())); if (should_log) - for (std::size_t e = 0, E = decoded_result.result.size(); e < E; e++) - if (decoded_result.result[e]) + for (std::size_t e = 0, E = decoded_values.size(); e < E; e++) + if (decoded_values[e]) log_errors.push_back(e); // For each observable, flip its correction once for each predicted error // that flips it (net parity over O_sparse[i]). for (std::size_t i = 0; i < num_observables; i++) for (auto col : O_sparse[i]) - if (decoded_result.result[col]) + if (decoded_values[col]) flip_correction(i); break; } @@ -628,8 +690,10 @@ const uint8_t *decoder::get_obs_corrections() const { } std::size_t decoder::get_num_observables() const { - return inputs_.num_observables() > 0 ? inputs_.num_observables() - : O_sparse.size(); + // The model owns the count whenever it supplies an observable mapping, even + // a zero-row one. The late-setter fallback serves only H-only inputs. + return inputs_.has_observable_model() ? inputs_.num_observables() + : O_sparse.size(); } void decoder::reset_decoder() { @@ -662,6 +726,13 @@ std::unique_ptr get_decoder(const std::string &name, return decoder::get(name, std::move(inputs), options); } +std::unique_ptr get_decoder(const std::string &name, + decoder_inputs inputs, + decoder_output output, + const cudaqx::heterogeneous_map options) { + return decoder::get(name, std::move(inputs), output, options); +} + // Constructor function for auto-loading plugins __attribute__((constructor)) void load_decoder_plugins() { // Load plugins from the decoder-specific plugin directory diff --git a/libs/qec/lib/decoder_inputs.cpp b/libs/qec/lib/decoder_inputs.cpp index 7b1d367c3..8ef2b7532 100644 --- a/libs/qec/lib/decoder_inputs.cpp +++ b/libs/qec/lib/decoder_inputs.cpp @@ -18,25 +18,24 @@ struct decoder_inputs::impl { std::size_t num_error_mechanisms = 0; std::size_t num_observables = 0; sparse_binary_matrix H; - sparse_binary_matrix O; + /// Absent when the model supplies no observable mapping. A present but + /// zero-row O is a supplied model, not an absent one. + std::optional O; std::vector rates; std::optional> ids; std::optional D; std::optional raw_stim_dem; + std::optional provenance_loss_reason; }; namespace { -sparse_binary_matrix empty_observable_matrix(std::uint32_t num_columns) { - return sparse_binary_matrix::from_csr(0, num_columns, {0}, {}); -} - void validate_model(const sparse_binary_matrix &H, - const sparse_binary_matrix &O, + const std::optional &O, const std::vector &rates, const std::optional> &ids, const std::optional &D) { - if (O.num_cols() != H.num_cols()) + if (O && O->num_cols() != H.num_cols()) throw std::invalid_argument( "decoder_inputs: O column count must match H column count"); if (!rates.empty() && rates.size() != H.num_cols()) @@ -53,12 +52,15 @@ void validate_model(const sparse_binary_matrix &H, } // namespace std::shared_ptr decoder_inputs::make_matrix_state( - decoder_model_source source, sparse_binary_matrix H, sparse_binary_matrix O, - std::vector rates, std::optional> ids, + decoder_model_source source, sparse_binary_matrix H, + std::optional O, std::vector rates, + std::optional> ids, std::optional D, - std::optional raw_stim_dem) { + std::optional raw_stim_dem, + std::optional provenance_loss_reason) { H = H.to_csc(); - O = O.to_csr(); + if (O) + *O = O->to_csr(); if (D) *D = D->to_csr(); validate_model(H, O, rates, ids, D); @@ -67,24 +69,24 @@ std::shared_ptr decoder_inputs::make_matrix_state( state->source = source; state->num_detectors = H.num_rows(); state->num_error_mechanisms = H.num_cols(); - state->num_observables = O.num_rows(); + state->num_observables = O ? O->num_rows() : 0; state->H = std::move(H); state->O = std::move(O); state->rates = std::move(rates); state->ids = std::move(ids); state->D = std::move(D); state->raw_stim_dem = std::move(raw_stim_dem); + state->provenance_loss_reason = std::move(provenance_loss_reason); return state; } -decoder_inputs::decoder_inputs(sparse_binary_matrix H) : state_(nullptr) { - auto O = empty_observable_matrix(H.num_cols()); - state_ = make_matrix_state(decoder_model_source::matrices, std::move(H), - std::move(O), {}, std::nullopt, std::nullopt); -} +decoder_inputs::decoder_inputs(sparse_binary_matrix H) + : decoder_inputs(make_matrix_state(decoder_model_source::matrices, + std::move(H), std::nullopt, {}, + std::nullopt, std::nullopt)) {} decoder_inputs::decoder_inputs( - sparse_binary_matrix H, sparse_binary_matrix O, + sparse_binary_matrix H, std::optional O, std::vector error_rates, std::optional measurement_to_detectors, std::optional> error_ids) @@ -133,8 +135,15 @@ const sparse_binary_matrix &decoder_inputs::detector_error_matrix() const { return state_->H; } +bool decoder_inputs::has_observable_model() const noexcept { + return state_->O.has_value(); +} + const sparse_binary_matrix &decoder_inputs::observable_flips_matrix() const { - return state_->O; + if (!state_->O) + throw std::logic_error( + "decoder_inputs: no observable mapping was supplied"); + return *state_->O; } const std::vector &decoder_inputs::error_rates() const { @@ -151,6 +160,43 @@ decoder_inputs::measurement_to_detectors() const noexcept { return state_->D ? &*state_->D : nullptr; } +decoder_inputs decoder_inputs::canonicalized() const { + auto H = state_->H.canonicalize().to_csc(); + return decoder_inputs(make_matrix_state( + state_->source, std::move(H), state_->O, state_->rates, state_->ids, + state_->D, state_->raw_stim_dem, state_->provenance_loss_reason)); +} + +decoder_inputs decoder_inputs::without_measurement_to_detectors() const { + auto state = std::make_shared(*state_); + state->D.reset(); + return decoder_inputs(std::move(state)); +} + +decoder_inputs decoder_inputs::derive_with_changed_basis( + sparse_binary_matrix H, std::optional O, + std::vector error_rates, + std::optional> error_ids, + std::string provenance_loss_reason, + std::optional measurement_to_detectors) const { + if (provenance_loss_reason.empty()) + throw std::invalid_argument( + "decoder_inputs: a basis-changing derivation requires a provenance " + "loss reason"); + return decoder_inputs(make_matrix_state( + decoder_model_source::matrices, std::move(H), std::move(O), + std::move(error_rates), std::move(error_ids), + std::move(measurement_to_detectors), std::nullopt, + std::move(provenance_loss_reason))); +} + +std::optional +decoder_inputs::provenance_loss_reason() const noexcept { + if (!state_->provenance_loss_reason) + return std::nullopt; + return *state_->provenance_loss_reason; +} + bool decoder_inputs::has_stim_dem() const noexcept { return state_->raw_stim_dem.has_value(); } @@ -165,7 +211,12 @@ const std::string &decoder_inputs::stim_dem() const { detector_error_model decoder_inputs::materialize_detector_error_model() const { detector_error_model model; model.detector_error_matrix = state_->H.to_dense(); - model.observables_flips_matrix = state_->O.to_dense(); + // A model with no observable mapping materializes as zero observable rows, + // matching a DEM that declares no observables. + model.observables_flips_matrix = + state_->O ? state_->O->to_dense() + : cudaqx::tensor( + {std::size_t{0}, state_->num_error_mechanisms}); model.error_rates = state_->rates; model.error_ids = state_->ids; return model; diff --git a/libs/qec/lib/decoders/lut.cpp b/libs/qec/lib/decoders/lut.cpp index 3af7d855a..5f35bfd51 100644 --- a/libs/qec/lib/decoders/lut.cpp +++ b/libs/qec/lib/decoders/lut.cpp @@ -50,9 +50,19 @@ class multi_error_lut : public decoder { public: multi_error_lut(cudaq::qec::decoder_inputs inputs, + decoder_output default_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs)) { + : decoder(std::move(inputs), default_output) { + // This decoder computes an error frame. Producing observables requires an + // observable mapping to project through; reject at construction rather + // than on the first decode. + if (default_output == decoder_output::observables && + !get_inputs().has_observable_model()) + throw std::invalid_argument( + "lut decoder was constructed for observable output but its model " + "supplies no observable mapping"); const auto &H = get_inputs().detector_error_matrix(); + error_rate_vec = get_inputs().error_rates(); if (params.contains("lut_error_depth")) { lut_error_depth = params.get("lut_error_depth"); if (lut_error_depth < 1) { @@ -62,8 +72,7 @@ class multi_error_lut : public decoder { throw std::runtime_error("lut_error_depth must be <= block_size"); } } - if (params.contains("error_rate_vec")) { - error_rate_vec = params.get>("error_rate_vec"); + if (!error_rate_vec.empty()) { if (error_rate_vec.size() != block_size) { throw std::runtime_error("error_rate_vec must be of size block_size"); } @@ -164,10 +173,23 @@ class multi_error_lut : public decoder { } } - virtual decoder_result decode(const std::vector &syndrome) { + decoder_result decode(const std::vector &syndrome) override { // This is a simple decoder with trivial results auto t0 = std::chrono::high_resolution_clock::now(); - decoder_result result{false, std::vector(block_size, 0.0)}; + decoder_result result{.result = std::vector(block_size, 0.0)}; + + // This decoder computes an error frame. Whether that frame is projected is + // fixed at construction, so the decision is read from immutable instance + // state rather than negotiated per call. + const bool project = get_default_output() == decoder_output::observables; + auto finish = [&](decoder_result &r) { + if (project) { + std::vector observables(get_num_observables(), 0.0); + project_errors_to_observables(r.result.data(), observables.data(), + observables.size()); + r.result = std::move(observables); + } + }; // Convert syndrome to a string std::string syndrome_str(syndrome.size(), '0'); @@ -184,6 +206,7 @@ class multi_error_lut : public decoder { if (!anyErrors) { result.converged = true; + finish(result); return result; } @@ -224,6 +247,7 @@ class multi_error_lut : public decoder { } } + finish(result); return result; } @@ -232,9 +256,10 @@ class multi_error_lut : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( multi_error_lut, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(std::move(inputs), - params); + return std::make_unique( + std::move(inputs), output.value_or(decoder_output::errors), params); }) }; @@ -243,17 +268,19 @@ CUDAQ_EXT_PT_REGISTER_TYPE(multi_error_lut) class single_error_lut : public multi_error_lut { public: single_error_lut(cudaq::qec::decoder_inputs inputs, + decoder_output default_output, const cudaqx::heterogeneous_map ¶ms) - : multi_error_lut(std::move(inputs), params) {} + : multi_error_lut(std::move(inputs), default_output, params) {} virtual ~single_error_lut() {} CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( single_error_lut, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(std::move(inputs), - params); + return std::make_unique( + std::move(inputs), output.value_or(decoder_output::errors), params); }) }; diff --git a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp index f16ab0585..ef96812f8 100644 --- a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp +++ b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp @@ -53,13 +53,6 @@ chromobius_init_data make_chromobius_init_data(const decoder_inputs &inputs) { return chromobius_init_data{std::move(dem)}; } -std::vector> identity_sparse(std::size_t size) { - std::vector> result(size); - for (std::size_t i = 0; i < size; ++i) - result[i].push_back(static_cast(i)); - return result; -} - bool get_bool_param(const cudaqx::heterogeneous_map ¶ms, const std::string &key, bool default_value) { return params.contains(key) ? params.get(key) : default_value; @@ -82,8 +75,17 @@ class chromobius : public decoder { public: chromobius(decoder_inputs inputs, chromobius_init_data init_data, + decoder_output default_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs)), dem(std::move(init_data.dem)) { + : decoder(std::move(inputs), default_output), + dem(std::move(init_data.dem)) { + // Chromobius predicts observable flips directly and cannot be inverted to + // an error frame. Reject the request at construction rather than on the + // first live shot. + if (default_output != decoder_output::observables) + throw std::invalid_argument( + "Chromobius cannot return an error frame; construct it for " + "observable output"); ::chromobius::DecoderConfigOptions options; options.drop_mobius_errors_involving_remnant_errors = get_bool_param(params, "drop_mobius_errors_involving_remnant_errors", @@ -110,15 +112,9 @@ class chromobius : public decoder { "CUDA-Q QEC wrapper supports at most 64 observables."); } - block_size = num_observables; num_detector_bytes = (syndrome_size + 7) / 8; hard_syndrome.resize(syndrome_size); packed_detection_events.resize(num_detector_bytes); - - // Chromobius directly predicts observables. Make the base realtime - // observable-reduction logic treat each predicted bit as its own observable - // correction. - this->set_O_sparse(identity_sparse(num_observables)); } decoder_result decode(const std::vector &syndrome) override { @@ -141,14 +137,15 @@ class chromobius : public decoder { packed_detection_events.size()), return_weight ? &weight : nullptr); - decoder_result result{true, std::vector(num_observables, 0.0)}; + decoder_result result{.converged = true}; + result.result.resize(num_observables); for (std::size_t i = 0; i < num_observables; ++i) result.result[i] = static_cast((prediction >> i) & 1); if (return_weight) { cudaqx::heterogeneous_map opt_results; opt_results.insert("weight", static_cast(weight)); - result.opt_results = opt_results; + result.opt_results = std::move(opt_results); } return result; } @@ -160,10 +157,12 @@ class chromobius : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( chromobius, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { auto init_data = make_chromobius_init_data(inputs); - return std::make_unique(std::move(inputs), - std::move(init_data), params); + return std::make_unique( + std::move(inputs), std::move(init_data), + output.value_or(decoder_output::observables), params); }) }; diff --git a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp index fcbb785e4..90d81743f 100644 --- a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp +++ b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp @@ -23,8 +23,18 @@ class single_error_lut_example : public decoder { public: single_error_lut_example(cudaq::qec::decoder_inputs inputs, + decoder_output default_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs)) { + : decoder(std::move(inputs), default_output) { + // The requested result form is validated here, at construction, so an + // unsupported request fails at setup rather than on the first decode. This + // example produces an error frame only; a decoder that can also project to + // observables would instead call project_errors_to_observables() before + // returning. + if (default_output != decoder_output::errors) + throw std::invalid_argument( + "single_error_lut_example produces an error frame only; construct it " + "for error output"); const auto &H = get_inputs().detector_error_matrix(); // Decoder-specific constructor arguments can be placed in `params`. @@ -42,7 +52,7 @@ class single_error_lut_example : public decoder { } } - virtual decoder_result decode(const std::vector &syndrome) { + decoder_result decode(const std::vector &syndrome) override { // This is a simple decoder that simply results decoder_result result{false, std::vector(block_size, 0.0)}; @@ -79,9 +89,10 @@ class single_error_lut_example : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( single_error_lut_example, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder( - std::move(inputs), params); + return std::make_unique( + std::move(inputs), output.value_or(decoder_output::errors), params); }) }; diff --git a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp index c28564a1b..a346895d1 100644 --- a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp +++ b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp @@ -29,20 +29,21 @@ class pymatching : public decoder { // Input parameters std::vector error_rate_vec; - // Default to DISALLOW for the H-only path so that decode() returns an error - // vector cleanly indexed by the original H columns. When an O matrix is - // provided (decode_to_observables), we switch to INDEPENDENT to match - // upstream PyMatching's from_detector_error_model, which always merges - // parallel edges under the independence assumption. The user can override - // either default via merge_strategy="..." in the params. + // Error-output instances default to DISALLOW. Observable-output instances + // default to INDEPENDENT to match upstream PyMatching's + // from_detector_error_model behavior. pm::MERGE_STRATEGY merge_strategy_enum = pm::MERGE_STRATEGY::DISALLOW; bool merge_strategy_explicit = false; // Map of edge pairs to column indices. This does not seem particularly // efficient. std::map, size_t> edge2col_idx; + std::map, double> edge2weight; bool decode_to_observables = false; + std::vector detection_events; + std::vector matched_edges; + std::vector observable_bits; // Helper function to make a canonical edge from two nodes. std::pair make_canonical_edge(int64_t node1, @@ -50,19 +51,38 @@ class pymatching : public decoder { return std::make_pair(std::min(node1, node2), std::max(node1, node2)); } + void record_error_column(const std::pair &edge, + std::size_t column, double weight) { + auto [column_it, inserted] = edge2col_idx.try_emplace(edge, column); + if (inserted) { + edge2weight.emplace(edge, weight); + return; + } + + const bool replace = merge_strategy_enum == pm::MERGE_STRATEGY::REPLACE; + const bool smaller = + merge_strategy_enum == pm::MERGE_STRATEGY::SMALLEST_WEIGHT && + weight < edge2weight.at(edge); + if (replace || smaller) { + column_it->second = column; + edge2weight.at(edge) = weight; + } + } + #if PERFORM_TIMING static constexpr size_t NUM_TIMING_STEPS = 4; std::array decode_times; #endif public: - pymatching(cudaq::qec::decoder_inputs inputs, + pymatching(cudaq::qec::decoder_inputs inputs, decoder_output default_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs)) { + : decoder(std::move(inputs), default_output) { const auto &H = get_inputs().detector_error_matrix(); + error_rate_vec = get_inputs().error_rates(); + decode_to_observables = default_output == decoder_output::observables; - if (params.contains("error_rate_vec")) { - error_rate_vec = params.get>("error_rate_vec"); + if (!error_rate_vec.empty()) { if (error_rate_vec.size() != block_size) { throw std::runtime_error("error_rate_vec must be of size block_size"); } @@ -98,39 +118,24 @@ class pymatching : public decoder { } std::vector> errs2observables(block_size); - if (params.contains("O")) { - auto O = params.get>("O"); - if (O.rank() != 2) { - throw std::runtime_error( - "O must be a 2-dimensional tensor (num_observables x block_size)"); - } - const size_t num_observables = O.shape()[0]; - if (O.shape()[1] != block_size) { + if (decode_to_observables) { + const auto &O = get_inputs().observable_flips_matrix(); + if (O.num_cols() != block_size) throw std::runtime_error( - "O must be of shape (num_observables, block_size); got second " - "dimension " + - std::to_string(O.shape()[1]) + ", block_size " + - std::to_string(block_size)); - } - std::vector> O_sparse; - for (size_t i = 0; i < num_observables; i++) { - O_sparse.emplace_back(); - auto *row = &O.at({i, 0}); - for (size_t j = 0; j < block_size; j++) { - if (row[j] > 0) { - O_sparse.back().push_back(static_cast(j)); - errs2observables[j].push_back(static_cast(i)); - } - } - } - this->set_O_sparse(O_sparse); - this->set_result_type(decode_result_type::decode_to_obs); - decode_to_observables = true; + "Observable matrix column count must equal block_size"); + const auto O_sparse = O.to_nested_csr(); + for (std::size_t observable = 0; observable < O_sparse.size(); + ++observable) + for (auto error : O_sparse[observable]) + errs2observables[error].push_back(observable); if (!merge_strategy_explicit) merge_strategy_enum = pm::MERGE_STRATEGY::INDEPENDENT; } - user_graph = pm::UserGraph(H.num_rows()); + user_graph = + decode_to_observables + ? pm::UserGraph(H.num_rows(), get_inputs().num_observables()) + : pm::UserGraph(H.num_rows()); H.validate_sorted_unique_indices("pymatching"); @@ -149,12 +154,16 @@ class pymatching : public decoder { const auto &col_rows = H_e2d[col]; if (col_rows.size() == 2) { - edge2col_idx[make_canonical_edge(col_rows[0], col_rows[1])] = col; + if (!decode_to_observables) + record_error_column(make_canonical_edge(col_rows[0], col_rows[1]), + col, weight); user_graph.add_or_merge_edge(col_rows[0], col_rows[1], errs2observables.at(col), weight, 0.0, merge_strategy_enum); } else if (col_rows.size() == 1) { - edge2col_idx[make_canonical_edge(col_rows[0], -1)] = col; + if (!decode_to_observables) + record_error_column(make_canonical_edge(col_rows[0], -1), col, + weight); user_graph.add_or_merge_boundary_edge(col_rows[0], errs2observables.at(col), weight, 0.0, merge_strategy_enum); @@ -167,6 +176,9 @@ class pymatching : public decoder { this->mwpm = decode_to_observables ? &user_graph.get_mwpm() : &user_graph.get_mwpm_with_search_graph(); + detection_events.reserve(syndrome_size); + matched_edges.reserve(block_size * 2); + observable_bits.resize(get_inputs().num_observables()); #if PERFORM_TIMING std::fill(decode_times.begin(), decode_times.end(), 0.0); #endif @@ -177,17 +189,20 @@ class pymatching : public decoder { /// @return The decoder result. /// @throws std::runtime_error if no matching solution is found, or /// std::out_of_range if an edge is not found in the edge2col_idx map. - virtual decoder_result decode(const std::vector &syndrome) { + decoder_result decode(const std::vector &syndrome) override { + decoder_result result; + const auto result_size = + decode_to_observables ? get_inputs().num_observables() : block_size; + result.result.resize(result_size, float_t{0}); + auto *output = result.result.data(); #if PERFORM_TIMING auto t0 = std::chrono::high_resolution_clock::now(); #endif - decoder_result result{false, std::vector()}; #if PERFORM_TIMING auto t1 = std::chrono::high_resolution_clock::now(); #endif - std::vector detection_events; - detection_events.reserve(syndrome.size()); + detection_events.clear(); for (size_t i = 0; i < syndrome.size(); i++) if (cudaq::qec::convert_soft_to_hard(syndrome[i])) detection_events.push_back(i); @@ -196,41 +211,35 @@ class pymatching : public decoder { #endif if (decode_to_observables) { if (mwpm->flooder.graph.num_observables < 64) { - result.result.resize(mwpm->flooder.graph.num_observables); auto res = pm::decode_detection_events_for_up_to_64_observables( *mwpm, detection_events, /*edge_correlations=*/false); for (size_t i = 0; i < mwpm->flooder.graph.num_observables; i++) { - result.result[i] = + output[i] = static_cast(res.obs_mask & (1ULL << i) ? 1.0 : 0.0); } } else { - result.result.resize(mwpm->flooder.graph.num_observables); - assert(O_sparse.size() == mwpm->flooder.graph.num_observables); pm::total_weight_int weight = 0; - std::vector obs(mwpm->flooder.graph.num_observables, 0); - obs.resize(mwpm->flooder.graph.num_observables); - pm::decode_detection_events(*mwpm, detection_events, obs.data(), weight, + std::fill(observable_bits.begin(), observable_bits.end(), uint8_t{0}); + pm::decode_detection_events(*mwpm, detection_events, + observable_bits.data(), weight, /*edge_correlations=*/false); - result.result.resize(mwpm->flooder.graph.num_observables); for (size_t i = 0; i < mwpm->flooder.graph.num_observables; i++) { - result.result[i] = static_cast(obs[i]); + output[i] = static_cast(observable_bits[i]); } } } else { - std::vector edges; - result.result.resize(block_size); - pm::decode_detection_events_to_edges(*mwpm, detection_events, edges); + matched_edges.clear(); + pm::decode_detection_events_to_edges(*mwpm, detection_events, + matched_edges); // Loop over the edge pairs to reconstruct errors. - assert(edges.size() % 2 == 0); - for (size_t i = 0; i < edges.size(); i += 2) { - auto edge = make_canonical_edge(edges.at(i), edges.at(i + 1)); + assert(matched_edges.size() % 2 == 0); + for (size_t i = 0; i < matched_edges.size(); i += 2) { + auto edge = + make_canonical_edge(matched_edges.at(i), matched_edges.at(i + 1)); auto col_idx = edge2col_idx.at(edge); - result.result[col_idx] = 1.0; + output[col_idx] = 1.0; } } - // An exception is thrown if no matching solution is found, so we can just - // set converged to true. - result.converged = true; #if PERFORM_TIMING auto t3 = std::chrono::high_resolution_clock::now(); decode_times[0] += @@ -246,6 +255,7 @@ class pymatching : public decoder { std::chrono::duration_cast(t3 - t0).count() / 1e6; #endif + result.converged = true; return result; } @@ -261,9 +271,10 @@ class pymatching : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( pymatching, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(std::move(inputs), - params); + return std::make_unique( + std::move(inputs), output.value_or(decoder_output::errors), params); }) }; @@ -280,7 +291,6 @@ struct pymatching_schema_registrar { cudaq::qec::decoding::config::register_decoder_schema( {"pymatching", { - {"error_rate_vec", k::f64_vec}, {"merge_strategy", k::string}, }}); } diff --git a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp index 213f20c59..11198b6d8 100644 --- a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp +++ b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp @@ -123,21 +123,29 @@ static Logger gLogger; /// 1GB) /// - "batch_size": Required when the ONNX model has a dynamic batch dim /// (-1). Used to size the optimization profile and I/O buffers. -/// - "global_decoder": Optional name of a decoder to run after TRT -/// (e.g. DEM decoder). The TRT model is assumed to have detectors as -/// inputs and either (a) residual detectors as the only output, or -/// (b) when "O" is also provided, the concatenation [pre_L, -/// residual_dets] as the only output. +/// - "engine_output_format": Required declaration of the engine output: +/// "errors", "residual_detectors", "observables", or +/// "observables_and_residual_detectors". +/// For the two residual forms this declares, and the caller guarantees, that +/// the engine emits residual detectors *in exactly the H-row basis and order +/// supplied at construction*. The width check below establishes size only, +/// not identity or ordering: a reordered engine would silently feed the +/// global decoder a permuted syndrome, and a raw-DEM child would decode it +/// against the wrong detector identities. Supporting reordered residuals +/// would require an explicit detector mapping, which this contract does not +/// provide. +/// - "global_decoder": Optional name of a decoder to run after TRT when the +/// declared engine output includes residual detectors. /// - "global_decoder_params": Optional parameters for the global decoder. The /// decoder receives the same model inputs passed to the trt_decoder /// constructor, including authoritative raw DEM provenance when present. -/// - "O": Observables matrix (num_observables x block_size). Calls to -/// decode() and decode_batch() will return the logical frame of the -/// observables. Requires that the TRT model emits the concatenation -/// [pre_L (num_observables entries), residual_dets (rest)] as a single -/// output. When a global_decoder is also set, the final result is -/// pre_L XOR global_decoder(residual_dets); otherwise only the pre_L -/// prefix is returned. +/// When the engine output includes an observable prefix, the child's result +/// is XOR-combined with that prefix and the child's opt_results are carried +/// through onto the combined result, so child options that surface only +/// through opt_results (for example Chromobius's return_weight) remain +/// externally visible. +/// O is read from decoder_inputs only for model dimensions and observable +/// combination. Its presence never selects an engine-output interpretation. /// /// Note: Only one of onnx_load_path or engine_load_path should be specified, /// not both. @@ -149,6 +157,47 @@ namespace cudaq::qec { namespace { +enum class trt_engine_output_format { + errors, + residual_detectors, + observables, + observables_and_residual_detectors, +}; + +trt_engine_output_format +parse_engine_output_format(const cudaqx::heterogeneous_map ¶ms) { + if (!params.contains("engine_output_format")) + throw std::runtime_error( + "TensorRT decoder requires 'engine_output_format'"); + const auto value = params.get("engine_output_format"); + if (value == "errors") + return trt_engine_output_format::errors; + if (value == "residual_detectors") + return trt_engine_output_format::residual_detectors; + if (value == "observables") + return trt_engine_output_format::observables; + if (value == "observables_and_residual_detectors") + return trt_engine_output_format::observables_and_residual_detectors; + throw std::runtime_error( + "engine_output_format must be one of: errors, residual_detectors, " + "observables, observables_and_residual_detectors"); +} + +decoder_output natural_trt_output(trt_engine_output_format format) { + return format == trt_engine_output_format::errors + ? decoder_output::errors + : decoder_output::observables; +} + +decoder_output trt_emitted_output(trt_engine_output_format format, + decoder_output default_output) { + if (format == trt_engine_output_format::errors) + return decoder_output::errors; + if (format == trt_engine_output_format::residual_detectors) + return default_output; + return decoder_output::observables; +} + // Helpers for templated I/O: binarize TRT output (float or uint8) to 0/1 // for counting and for the decoder API (float_t). inline bool trt_io_nonzero(float val) { return val >= 0.5f; } @@ -413,20 +462,18 @@ class trt_decoder : public decoder { std::unique_ptr global_decoder_; cudaqx::heterogeneous_map global_decoder_params_; - // When true, decode()/decode_batch() return the predicted logical-frame - // observables. The TRT model must emit the concatenation - // [pre_L (num_observables_ entries), residual_dets (rest)] as its single - // output. Enabled by passing the "O" (observables) parameter. - bool decode_to_observables_ = false; + trt_engine_output_format engine_output_format_; + decoder_output emitted_output_; size_t num_observables_ = 0; public: - trt_decoder(cudaq::qec::decoder_inputs inputs, + trt_decoder(cudaq::qec::decoder_inputs inputs, decoder_output default_output, + trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms); - virtual decoder_result decode(const std::vector &syndrome) override; + decoder_result decode(const std::vector &syndrome) override; - virtual std::vector + std::vector decode_batch(const std::vector> &syndromes) override; virtual ~trt_decoder(); @@ -434,9 +481,12 @@ class trt_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( trt_decoder, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(std::move(inputs), - params); + const auto format = parse_engine_output_format(params); + return std::make_unique( + std::move(inputs), output.value_or(natural_trt_output(format)), + format, params); }) private: @@ -538,9 +588,30 @@ struct trt_decoder::Impl { // ============================================================================ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, + decoder_output default_output, + trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs)) { - const auto &H = get_inputs().detector_error_matrix(); + : decoder(std::move(inputs), default_output), + engine_output_format_(engine_output_format), + emitted_output_( + trt_emitted_output(engine_output_format, default_output)) { + if ((engine_output_format_ == trt_engine_output_format::observables || + engine_output_format_ == + trt_engine_output_format::observables_and_residual_detectors) && + default_output != decoder_output::observables) + throw std::runtime_error( + "This TensorRT engine_output_format only supports observable output"); + + // An engine that emits an error frame can still serve an observable-output + // instance, but only by projecting through the model's O. Without an + // observable mapping there is nothing to project through, so reject here + // rather than returning an unprojected error frame at decode time. + if (emitted_output_ == decoder_output::errors && + default_output == decoder_output::observables && + !get_inputs().has_observable_model()) + throw std::runtime_error( + "This TensorRT engine emits an error frame and was constructed for " + "observable output, but its model supplies no observable mapping"); impl_ = std::make_unique(); @@ -758,49 +829,39 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, // Optional global decoder (e.g. DEM decoder), similar to sliding_window's // inner_decoder. When set, decode_batch will run: syndrome->trainX->TRT // ->postprocess->global_decoder->results. - if (params.contains("global_decoder") && - params.contains("global_decoder_params")) { + if (params.contains("global_decoder")) { std::string global_decoder_name = params.get("global_decoder"); global_decoder_params_ = - params.get("global_decoder_params"); + params.get("global_decoder_params", {}); if (!global_decoder_name.empty()) { - // Preserve authoritative model provenance for DEM-native children. The - // parent's D is inert on this decode_batch path; the shared child-input - // derivation utility will omit it when that phase lands. - global_decoder_ = decoder::get(global_decoder_name, get_inputs(), - global_decoder_params_); + if (engine_output_format_ != + trt_engine_output_format::residual_detectors && + engine_output_format_ != + trt_engine_output_format::observables_and_residual_detectors) + throw std::runtime_error( + "global_decoder requires an engine_output_format containing " + "residual detectors"); + const auto child_output = + engine_output_format_ == + trt_engine_output_format::observables_and_residual_detectors + ? decoder_output::observables + : default_output; + global_decoder_ = + decoder::get(global_decoder_name, + get_inputs().without_measurement_to_detectors(), + child_output, global_decoder_params_); CUDA_QEC_INFO("TensorRT decoder: global_decoder '{}' attached", global_decoder_name); } } - if (params.contains("O")) { - auto O = params.get>("O"); - if (O.rank() != 2) { - throw std::runtime_error( - "trt_decoder: O must be a 2-dimensional tensor (num_observables x " - "block_size)"); - } - if (O.shape()[1] != block_size) { - throw std::runtime_error( - "trt_decoder: O second dimension must equal H block_size (got " + - std::to_string(O.shape()[1]) + ", block_size " + - std::to_string(block_size) + ")"); - } - decode_to_observables_ = true; - num_observables_ = O.shape()[0]; - // Keep the base decoder's observable matrix in sync with constructor O. - // TRT only needs num_observables_ locally because the model emits the - // observable prefix directly, while realtime enqueue state and nested - // global decoders still carry their own O copies. This duplicate plumbing - // is intentional for now; a follow-up can make O ownership less - // redundant. - set_O_sparse(cudaq::qec::sparse_binary_matrix(O).to_nested_csr()); - set_result_type(decode_result_type::decode_to_obs); - - // The TRT model output must encode [pre_L (num_observables_ entries), - // residual_dets (rest)]. Validate sizing where we can. + num_observables_ = get_inputs().num_observables(); + const bool has_observable_prefix = + engine_output_format_ == trt_engine_output_format::observables || + engine_output_format_ == + trt_engine_output_format::observables_and_residual_detectors; + if (has_observable_prefix) { if (output_size_per_sample_ < num_observables_) { throw std::runtime_error( "trt_decoder: TRT output_size_per_sample (" + @@ -822,11 +883,43 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, ") for the [pre_L, residual_dets] split."); } } - CUDA_QEC_INFO("TensorRT decoder: decode_to_observables enabled " + CUDA_QEC_INFO("TensorRT decoder: observable engine prefix enabled " "(num_observables={})", num_observables_); } + if (engine_output_format_ == trt_engine_output_format::errors) { + if (global_decoder_) + throw std::runtime_error( + "engine_output_format='errors' cannot use global_decoder"); + if (output_size_per_sample_ != block_size) + throw std::runtime_error( + "TensorRT error output width must equal the input H column count"); + } else if (engine_output_format_ == + trt_engine_output_format::residual_detectors) { + if (!global_decoder_) + throw std::runtime_error( + "engine_output_format='residual_detectors' requires " + "global_decoder"); + if (output_size_per_sample_ != global_decoder_->get_syndrome_size()) + throw std::runtime_error( + "TensorRT residual detector width must equal the global decoder " + "syndrome size"); + // Width alone does not establish detector identity or ordering; the + // basis contract above is what makes this composition sound. + } else if (engine_output_format_ == trt_engine_output_format::observables) { + if (global_decoder_) + throw std::runtime_error( + "engine_output_format='observables' cannot use global_decoder"); + if (output_size_per_sample_ != num_observables_) + throw std::runtime_error( + "TensorRT observable output width must equal num_observables"); + } else if (!global_decoder_) { + throw std::runtime_error( + "engine_output_format='observables_and_residual_detectors' requires " + "global_decoder"); + } + } catch (const std::exception &e) { // Fail fast: a decoder that cannot infer must not exist. Callers // (decoder::get, configure_decoders, the decoding server) all propagate @@ -908,6 +1001,17 @@ trt_decoder::decode_batch(const std::vector> &syndromes) { throw std::runtime_error("TensorRT decode_batch produced " + std::to_string(results.size()) + " results for " + std::to_string(syndromes.size()) + " syndromes"); + // The engine's output form and the instance's form are both fixed at + // construction; only errors -> observables is reachable here. + if (emitted_output_ != get_default_output()) + for (auto &r : results) { + if (r.result.empty()) + continue; + std::vector observables(get_num_observables(), 0.0); + project_errors_to_observables(r.result.data(), observables.data(), + observables.size()); + r.result = std::move(observables); + } return results; } @@ -917,9 +1021,11 @@ std::vector trt_decoder::decode_batch_impl( std::vector results; results.reserve(syndromes.size()); - // Output split for the predecoder pattern: when decode_to_observables_ is - // on the TRT output is [pre_L (num_observables_), residual_dets (rest)]. - const size_t pre_L_size = decode_to_observables_ ? num_observables_ : 0; + const bool has_observable_prefix = + engine_output_format_ == trt_engine_output_format::observables || + engine_output_format_ == + trt_engine_output_format::observables_and_residual_detectors; + const size_t pre_L_size = has_observable_prefix ? num_observables_ : 0; const size_t residual_size = output_size_per_sample_ - pre_L_size; try { @@ -1005,19 +1111,42 @@ std::vector trt_decoder::decode_batch_impl( std::vector global_results = global_decoder_->decode_batch(residual_soft); - if (decode_to_observables_) { + // The child is an arbitrary registered decoder: validate its output + // before indexing it. This is composition safety at a trust boundary, + // checked once per batch, not per-decode contract re-validation. + if (global_results.size() != residual_soft.size()) + throw std::runtime_error( + "TensorRT global decoder returned " + + std::to_string(global_results.size()) + " results for " + + std::to_string(residual_soft.size()) + " residual syndromes"); + if (has_observable_prefix) + for (const auto &g : global_results) + if (g.result.size() != num_observables_) + throw std::runtime_error( + "TensorRT global decoder returned a " + + std::to_string(g.result.size()) + + "-value result; the observable prefix requires exactly " + + std::to_string(num_observables_)); + + if (has_observable_prefix) { // Combine pre_L (the prefix of the TRT output) with the global // decoder's logical-frame prediction via XOR. for (size_t batch_idx = 0; batch_idx < actual_batch; ++batch_idx) { decoder_result combined; combined.converged = global_results[batch_idx].converged; + // Carry the child's optional metadata through; the combination + // changes the result values, not the child's diagnostics. + combined.opt_results = + std::move(global_results[batch_idx].opt_results); combined.result.resize(num_observables_, 0.0f); const OutputType *pre_L_row = output_host.data() + batch_idx * output_size_per_sample_; const std::vector &g = global_results[batch_idx].result; + // Width was validated above, so no bounds guard here: a short child + // result must fail loudly rather than be silently zero-filled. for (size_t k = 0; k < num_observables_; ++k) { const uint8_t a = trt_io_nonzero(pre_L_row[k]) ? 1u : 0u; - const uint8_t b = (k < g.size() && g[k] >= 0.5f) ? 1u : 0u; + const uint8_t b = g[k] >= 0.5f ? 1u : 0u; combined.result[k] = static_cast(a ^ b); } results.push_back(std::move(combined)); @@ -1027,10 +1156,10 @@ std::vector trt_decoder::decode_batch_impl( results.push_back(std::move(r)); } } else { - // No global decoder. If decode_to_observables_ is set, return only + // No global decoder. If an observable prefix is declared, return only // the pre_L prefix; otherwise return the full TRT output. const size_t out_per_sample = - decode_to_observables_ ? num_observables_ : output_size_per_sample_; + has_observable_prefix ? num_observables_ : output_size_per_sample_; for (size_t batch_idx = 0; batch_idx < actual_batch; ++batch_idx) { decoder_result result; result.converged = true; @@ -1115,6 +1244,7 @@ struct trt_decoder_schema_registrar { {"memory_workspace", k::uint64}, {"batch_size", k::uint64}, {"use_cuda_graph", k::boolean}, + {"engine_output_format", k::string, /*required=*/true}, {"global_decoder", k::string}, {"global_decoder_params", k::discriminated, false, "", "global_decoder", /*materialize_empty=*/true}, diff --git a/libs/qec/lib/decoders/sliding_window.cpp b/libs/qec/lib/decoders/sliding_window.cpp index c6f6fb5d4..3a4f8ac76 100644 --- a/libs/qec/lib/decoders/sliding_window.cpp +++ b/libs/qec/lib/decoders/sliding_window.cpp @@ -19,18 +19,11 @@ namespace cudaq::qec { namespace { decoder_inputs canonicalize_sliding_window_inputs(decoder_inputs inputs) { - // Stim-derived sparse matrices are canonical at construction. Retain the - // authoritative raw source instead of rebuilding a matrix-authoritative - // handle solely to canonicalize its storage. - if (inputs.source() == decoder_model_source::stim_dem) - return inputs; - - std::optional D; - if (const auto *measurement_map = inputs.measurement_to_detectors()) - D = *measurement_map; - return decoder_inputs(inputs.detector_error_matrix().canonicalize().to_csc(), - inputs.observable_flips_matrix(), inputs.error_rates(), - std::move(D), inputs.error_ids()); + // Canonical CSC is the steady-state contract for decode_window's column + // slices and validate_inputs's per-column reads. canonicalized() is + // basis-preserving and retains the authoritative source, raw DEM provenance + // and any provenance-loss reason, so no source needs special-casing here. + return inputs.canonicalized(); } } // namespace @@ -128,11 +121,21 @@ void sliding_window::initialize_window(std::size_t batch_size) { } sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, + decoder_output default_output, const cudaqx::heterogeneous_map ¶ms) // Canonical CSC is the steady-state contract for decode_window's column // slices and for validate_inputs's per-column .front()/.back() reads. - : decoder(canonicalize_sliding_window_inputs(std::move(inputs))), + : decoder(canonicalize_sliding_window_inputs(std::move(inputs)), + default_output), H(get_inputs().detector_error_matrix()) { + // This decoder composes an error frame from its windows. Producing + // observables requires an observable mapping to project through; reject at + // construction rather than on the first decode. + if (default_output == decoder_output::observables && + !get_inputs().has_observable_model()) + throw std::invalid_argument( + "sliding_window was constructed for observable output but its model " + "supplies no observable mapping"); // Fetch parameters from the params map. window_size = params.get("window_size", window_size); step_size = params.get("step_size", step_size); @@ -144,8 +147,7 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, params.get("straddle_start_round", straddle_start_round); straddle_end_round = params.get("straddle_end_round", straddle_end_round); - error_rate_vec = params.get>( - "error_rate_vec", error_rate_vec); + error_rate_vec = get_inputs().error_rates(); inner_decoder_name = params.get("inner_decoder_name", inner_decoder_name); inner_decoder_params = params.get( @@ -181,12 +183,9 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, num_boundary_syndromes); first_columns.push_back(first_column); - // Slice the error vector to only include the current window. - auto inner_decoder_params_mod = inner_decoder_params; - std::vector error_vec_mod( - error_rate_vec.begin() + first_column, - error_rate_vec.begin() + last_column + 1); - inner_decoder_params_mod.insert("error_rate_vec", error_vec_mod); + // Slice model rates to the same error-column basis as the child H. + std::vector error_vec_mod(error_rate_vec.begin() + first_column, + error_rate_vec.begin() + last_column + 1); CUDA_QEC_INFO("Creating a decoder for rounds {}-{} (dims {} x {}) " "first_column = {}, last_column = {}", @@ -200,8 +199,19 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, last_column - first_column + 1, H_round.shape()[1])); } + auto child_O = sparse_binary_matrix::from_csr( + 0, H_round.shape()[1], std::vector{0}, {}); + std::optional> child_error_ids; + if (const auto &ids = get_inputs().error_ids()) + child_error_ids = std::vector( + ids->begin() + first_column, ids->begin() + last_column + 1); + auto child_inputs = get_inputs().derive_with_changed_basis( + sparse_binary_matrix(H_round), std::move(child_O), + std::move(error_vec_mod), std::move(child_error_ids), + "sliding-window child slices detector rows and error columns"); auto inner_decoder = - decoder::get(inner_decoder_name, H_round, inner_decoder_params_mod); + decoder::get(inner_decoder_name, std::move(child_inputs), + decoder_output::errors, inner_decoder_params); inner_decoders.push_back(std::move(inner_decoder)); } } @@ -274,6 +284,20 @@ std::vector sliding_window::decode_batch( window_rounds.clear(); rounds_since_last_reset = 0; num_windows_decoded = 0; + // The only site that produces composed error frames; the whole-block path + // returns results already converted by its recursive call, and every other + // return is the empty streaming sentinel. + // Composed frames are error frames; whether they are projected is fixed at + // construction. + if (get_default_output() == decoder_output::observables) + for (auto &r : results) { + if (r.result.empty()) + continue; // streaming sentinel + std::vector observables(get_num_observables(), 0.0); + project_errors_to_observables(r.result.data(), observables.data(), + observables.size()); + r.result = std::move(observables); + } return results; } @@ -441,7 +465,6 @@ struct sliding_window_schema_registrar { {"num_boundary_syndromes", k::uint64}, {"straddle_start_round", k::boolean}, {"straddle_end_round", k::boolean}, - {"error_rate_vec", k::f64_vec, /*required=*/true}, {"inner_decoder_name", k::string, /*required=*/true}, {"inner_decoder_params", k::discriminated, false, "", "inner_decoder_name", /*materialize_empty=*/false}, @@ -468,9 +491,6 @@ struct sliding_window_schema_registrar { "<= num_syndromes_per_round ({})", num_boundary_syndromes, num_syndromes_per_round)); } - if (args.get>("error_rate_vec").empty()) - throw std::runtime_error( - "sliding_window parameters: error_rate_vec must be non-empty"); }; decoding::config::register_decoder_schema(std::move(schema)); } diff --git a/libs/qec/lib/decoders/sliding_window.h b/libs/qec/lib/decoders/sliding_window.h index cfa073e48..0f7f74379 100644 --- a/libs/qec/lib/decoders/sliding_window.h +++ b/libs/qec/lib/decoders/sliding_window.h @@ -44,7 +44,7 @@ class sliding_window : public decoder { /// any subsequent rounds be included? bool straddle_end_round = true; /// The vector of error rates for the error mechanisms. - std::vector error_rate_vec; + std::vector error_rate_vec; /// The name of the inner decoder to use. std::string inner_decoder_name; /// The parameters to pass to the inner decoder. @@ -108,6 +108,7 @@ class sliding_window : public decoder { /// - inner_decoder_name: Name of the inner decoder to use /// - inner_decoder_params: Parameters for the inner decoder (optional) sliding_window(cudaq::qec::decoder_inputs inputs, + decoder_output default_output, const cudaqx::heterogeneous_map ¶ms); /// @brief Decode a syndrome vector @@ -145,9 +146,10 @@ class sliding_window : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( sliding_window, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(std::move(inputs), - params); + return std::make_unique( + std::move(inputs), output.value_or(decoder_output::errors), params); }) }; diff --git a/libs/qec/lib/realtime/config.cpp b/libs/qec/lib/realtime/config.cpp index 3cfaddf07..5fb6dece8 100644 --- a/libs/qec/lib/realtime/config.cpp +++ b/libs/qec/lib/realtime/config.cpp @@ -286,6 +286,7 @@ struct MappingTraits { io.mapRequired("H_sparse", config.H_sparse); io.mapRequired("O_sparse", config.O_sparse); io.mapRequired("D_sparse", config.D_sparse); + io.mapOptional("error_rate_vec", config.error_rate_vec); // Validate that the number of rows in the H_sparse vector is equal to // syndrome_size. @@ -314,6 +315,14 @@ struct MappingTraits { } } + if (!config.error_rate_vec.empty() && + config.error_rate_vec.size() != config.block_size) { + throw std::runtime_error( + "error_rate_vec size is not equal to block_size: " + + std::to_string(config.error_rate_vec.size()) + + " != " + std::to_string(config.block_size)); + } + // Validate that if the D_sparse is provided, it is a valid D matrix. That // means that the number of rows in the D_sparse matrix should be equal to // the number of rows in the H_sparse matrix, and no row should be empty. @@ -611,6 +620,9 @@ 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"}}}, + {"error_rate_vec", + llvm::json::Object{{"type", "array"}, + {"items", llvm::json::Object{{"type", "number"}}}}}, {"decoder_custom_args", llvm::json::Object{{"type", "object"}}}, }; diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index 63baa2774..7a983f3e3 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -159,61 +159,9 @@ namespace cudaq::qec::decoding::host { cudaqx::heterogeneous_map prepare_decoder_params( const cudaq::qec::decoding::config::decoder_config &decoder_config) { auto params = decoder_config.decoder_custom_args_to_heterogeneous_map(); - // Placement knob: surfaced for every decoder type (deliberately before the - // trt-only early return below); consumed by decoder::get() at construction. + // Placement is common factory policy and is consumed by decoder::get(). if (decoder_config.cuda_device_id.has_value()) params.insert("cuda_device_id", decoder_config.cuda_device_id.value()); - if (decoder_config.type != "trt_decoder") - return params; - - // batch_size > 1 has no effect on the realtime path: enqueue_syndrome decodes - // one syndrome per call, so the trt_decoder zero-pads the batch and discards - // all but slot 0. Warn rather than reject -- the result is correct, just - // wasteful. (Offline decode_batch users set batch_size via a raw params map, - // not this realtime config path.) - if (params.contains("batch_size") && - params.get("batch_size") > 1) - CUDA_QEC_WARN( - "trt_decoder batch_size > 1 has no effect on the realtime decode path " - "(one syndrome is decoded per call); the extra batch slots are " - "zero-padded and discarded. Use batch_size = 1 for realtime."); - - // The trt_decoder plugin attaches a global decoder only when both - // "global_decoder" and "global_decoder_params" are present. Most config - // paths materialize defaults for known global decoders, but callers can still - // provide a hand-built map with only "global_decoder"; synthesize params here - // before the O_sparse early return so that decoder still attaches. - const bool has_global_decoder = - params.contains("global_decoder") && - !params.get("global_decoder").empty(); - const bool has_pymatching_global = - has_global_decoder && - params.get("global_decoder") == "pymatching"; - if (has_global_decoder && !params.contains("global_decoder_params")) - params.insert("global_decoder_params", cudaqx::heterogeneous_map()); - - if (decoder_config.O_sparse.empty()) - return params; - - const auto num_observables = std::count(decoder_config.O_sparse.begin(), - decoder_config.O_sparse.end(), -1); - if (num_observables == 0) - return params; - - auto O = cudaq::qec::pcm_from_sparse_vec( - decoder_config.O_sparse, num_observables, decoder_config.block_size); - params.insert("O", O); - - // PyMatching consumes the observable matrix through its params; other global - // decoders receive only the top-level O until they define a matching - // contract. - if (has_pymatching_global) { - auto global_decoder_params = - params.get("global_decoder_params"); - global_decoder_params.insert("O", O); - params.insert("global_decoder_params", global_decoder_params); - } - return params; } @@ -236,13 +184,15 @@ std::unique_ptr create_realtime_decoder( decoder_config.block_size); const auto num_observables = std::count(decoder_config.O_sparse.begin(), 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. - (void)cudaq::qec::pcm_from_sparse_vec( + auto observable_matrix = 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)); + cudaq::qec::decoder_inputs inputs(std::move(pcm), + std::move(observable_matrix), + decoder_config.error_rate_vec); + auto decoder = + cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), + cudaq::qec::decoder_output::observables, + prepare_decoder_params(decoder_config)); 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/bindings/py_decoder.cpp b/libs/qec/python/bindings/py_decoder.cpp index 4bb42defd..fe63243fa 100644 --- a/libs/qec/python/bindings/py_decoder.cpp +++ b/libs/qec/python/bindings/py_decoder.cpp @@ -196,23 +196,26 @@ class PyDecoder : public decoder { /// dense numpy array of any numeric dtype. PyDecoder(nb::object mat) : decoder(decoder_inputs([&mat]() -> cudaq::qec::sparse_binary_matrix { - // Any scipy sparse format exposes tocsr(); detect via that rather - // than indptr/indices, which COO and some other formats lack. - if (nb::hasattr(mat, "tocsr")) - return sparse_binary_matrix_from_scipy(mat); - // Dense numpy array of any dtype: build sparse storage directly so - // qec.Decoder.__init__(self, H) has the same memory behavior as - // native get_decoder(..., H) (no intermediate dense tensor copy). - // copy=False makes astype a no-op when the input is already uint8; - // make_sparse_from_dense reads strides directly, so a non-contiguous - // uint8 input is also handled without a copy. - return make_sparse_from_dense( - nb::cast>( - mat.attr("astype")("uint8", nb::arg("copy") = false))); - }())) {} + // Any scipy sparse format exposes tocsr(); detect via that + // rather than indptr/indices, which COO and some other + // formats lack. + if (nb::hasattr(mat, "tocsr")) + return sparse_binary_matrix_from_scipy(mat); + // Dense numpy array of any dtype: build sparse storage + // directly so qec.Decoder.__init__(self, H) has the same + // memory behavior as native get_decoder(..., H) (no + // intermediate dense tensor copy). copy=False makes astype a + // no-op when the input is already uint8; + // make_sparse_from_dense reads strides directly, so a + // non-contiguous uint8 input is also handled without a copy. + return make_sparse_from_dense( + nb::cast>(mat.attr( + "astype")("uint8", nb::arg("copy") = false))); + }()), + decoder_output::errors) {} decoder_result decode(const std::vector &syndrome) override { - NB_OVERRIDE_PURE(decode, syndrome); + NB_OVERRIDE_PURE_NAME("decode", decode, syndrome); } }; @@ -469,6 +472,10 @@ void bindDecoder(nb::module_ &mod) { ? nb::cast(mod.attr("qecrt")) : mod.def_submodule("qecrt"); + nb::enum_(qecmod, "DecoderOutput") + .value("ERRORS", decoder_output::errors) + .value("OBSERVABLES", decoder_output::observables); + nb::class_(qecmod, "DecoderResult", R"pbdoc( Single-shot decoder result. @@ -896,8 +903,22 @@ void bindDecoder(nb::module_ &mod) { return PyDecoderRegistry::get_decoder(name, H_obj, options); } - return get_decoder(name, decoder_inputs::from_stim_dem(dem_text), - hetMapFromKwargs(options)); + std::optional output; + if (options.contains("output")) { + const auto value = nb::cast(options["output"]); + options.attr("pop")("output"); + if (value == "errors") + output = decoder_output::errors; + else if (value == "observables") + output = decoder_output::observables; + else + throw std::runtime_error("output must be 'errors' or 'observables'"); + } + auto inputs = decoder_inputs::from_stim_dem(dem_text); + return output ? get_decoder(name, std::move(inputs), *output, + hetMapFromKwargs(options)) + : get_decoder(name, std::move(inputs), + hetMapFromKwargs(options)); }; qecmod.def( @@ -924,6 +945,40 @@ void bindDecoder(nb::module_ &mod) { H_sparse = make_sparse_from_dense( nb::cast>(H)); + std::optional output; + if (options.contains("output")) { + const auto value = nb::cast(options["output"]); + options.attr("pop")("output"); + if (value == "errors") + output = decoder_output::errors; + else if (value == "observables") + output = decoder_output::observables; + else + throw std::runtime_error( + "output must be 'errors' or 'observables'"); + } + + // Absent O means no observable model, not a zero-row one. Fabricating + // an empty O here would make an H-only construction look like a + // supplied model and defeat the construction-time validation. + std::optional O_sparse; + if (options.contains("O")) { + nb::object O = nb::cast(options["O"]); + if (nb::hasattr(O, "tocsr")) + O_sparse = sparse_binary_matrix_from_scipy(O); + else + O_sparse = make_sparse_from_dense( + nb::cast>(O)); + options.attr("pop")("O"); + } + + std::vector error_rates; + if (options.contains("error_rate_vec")) { + error_rates = + nb::cast>(options["error_rate_vec"]); + options.attr("pop")("error_rate_vec"); + } + if (name == "tensor_network_decoder") { throw std::runtime_error( "Decoder 'tensor_network_decoder' is not available. " @@ -931,7 +986,12 @@ void bindDecoder(nb::module_ &mod) { " pip install cudaq-qec[tensor-network-decoder]\n"); } - return get_decoder(name, H_sparse, hetMapFromKwargs(options)); + decoder_inputs inputs(std::move(H_sparse), std::move(O_sparse), + std::move(error_rates)); + return output ? get_decoder(name, std::move(inputs), *output, + hetMapFromKwargs(options)) + : get_decoder(name, std::move(inputs), + hetMapFromKwargs(options)); }, R"pbdoc( Get a decoder by name. @@ -948,6 +1008,11 @@ void bindDecoder(nb::module_ &mod) { raw DEM text via ``decoder_inputs``; Python-registered decoders receive the DEM-derived PCM plus ``O`` and ``error_rate_vec`` defaults. + Native decoders may select their instance-default result with + ``output="errors"`` or ``output="observables"``. Matrix ``O`` and + ``error_rate_vec`` keyword adapters are normalized into decoder_inputs; + O never selects the output mode. + For Python-registered decoders (``cudaq.qec.decoder`` decorator), ``H`` is passed through to ``__init__`` unchanged (NumPy array or scipy sparse matrix). DEM string inputs are parsed first as described above. Call diff --git a/libs/qec/python/bindings/py_decoding_config.cpp b/libs/qec/python/bindings/py_decoding_config.cpp index fb66c0832..47f6df037 100644 --- a/libs/qec/python/bindings/py_decoding_config.cpp +++ b/libs/qec/python/bindings/py_decoding_config.cpp @@ -35,6 +35,21 @@ T cast_param(const nb::object &value, const std::string &key, } } +// The schema a nested parameter's dict should be converted with: named +// directly for a subschema, or named by a sibling key for a discriminated one. +const decoder_schema *resolve_nested_schema(const param_spec &spec, + const nb::dict &dict) { + if (spec.kind == param_kind::subschema) + return find_decoder_schema(spec.subschema); + if (spec.kind == param_kind::discriminated && + dict.contains(spec.discriminator.c_str())) { + nb::object discriminator = dict[spec.discriminator.c_str()]; + if (nb::isinstance(discriminator)) + return find_decoder_schema(nb::cast(discriminator)); + } + return nullptr; +} + // Convert a Python dict to the canonical storage types the decoder's // registered schema declares (int32 params admit negative ints, f64 params // admit Python ints, ...). The generic heterogeneous_map caster stores every @@ -86,23 +101,9 @@ schema_typed_map_from_dict(const decoder_schema &schema, nb::dict dict) { map.insert(key, cast_param>>( value, key, schema.name, "list-of-list-of-float")); break; - case param_kind::subschema: { - const auto *nested = find_decoder_schema(spec->subschema); - if (nested && nb::isinstance(value)) - map.insert(key, schema_typed_map_from_dict(*nested, - nb::cast(value))); - else - map.insert(key, cast_param( - value, key, schema.name, "dict")); - break; - } + case param_kind::subschema: case param_kind::discriminated: { - const decoder_schema *nested = nullptr; - if (dict.contains(spec->discriminator.c_str())) { - nb::object discriminator = dict[spec->discriminator.c_str()]; - if (nb::isinstance(discriminator)) - nested = find_decoder_schema(nb::cast(discriminator)); - } + const decoder_schema *nested = resolve_nested_schema(*spec, dict); if (nested && nb::isinstance(value)) map.insert(key, schema_typed_map_from_dict(*nested, nb::cast(value))); @@ -121,9 +122,57 @@ schema_typed_map_from_dict(const decoder_schema &schema, nb::dict dict) { return map; } -cudaqx::heterogeneous_map -custom_args_map_from_python(const std::string &decoder_type, nb::object value) { - std::string schema_name = decoder_type; +// Model error rates are top-level decoder_config state, but the deprecated +// typed configs exposed them as a per-decoder parameter, including on a nested +// child (e.g. a TRT config whose global decoder is PyMatching). Move them out +// of the shim's parameters at every level when the decoder's schema no longer +// declares the key, so a legacy config's rates reach the model instead of being +// dropped. A schema that still declares it (e.g. an out-of-tree decoder) keeps +// the old meaning. +void promote_legacy_error_rates(decoder_config &self, + const std::string &schema_name, nb::dict dict) { + const char *key = "error_rate_vec"; + const decoder_schema *schema = find_decoder_schema(schema_name); + bool schema_declares_key = false; + if (schema) + for (const auto ¶m : schema->params) + if (param.key == key) + schema_declares_key = true; + + if (dict.contains(key) && !schema_declares_key) { + auto rates = cast_param>(dict[key], key, schema_name, + "list of floats"); + // One model, one set of rates: a disagreement between levels expresses an + // intent the model cannot represent, so reject instead of picking one. + if (!self.error_rate_vec.empty() && self.error_rate_vec != rates) + throw std::runtime_error("Conflicting error_rate_vec values in " + "deprecated configuration for '" + + schema_name + + "'; set model error rates in one place."); + self.error_rate_vec = std::move(rates); + nb::del(dict[key]); + } + + if (!schema) + return; + for (const auto ¶m : schema->params) { + if (param.kind != param_kind::subschema && + param.kind != param_kind::discriminated) + continue; + if (!dict.contains(param.key.c_str())) + continue; + nb::object nested_value = dict[param.key.c_str()]; + if (!nb::isinstance(nested_value)) + continue; + if (const auto *nested = resolve_nested_schema(param, dict)) + promote_legacy_error_rates(self, nested->name, + nb::cast(nested_value)); + } +} + +cudaqx::heterogeneous_map custom_args_map_from_python(decoder_config &self, + nb::object value) { + std::string schema_name = self.type; if (!nb::isinstance(value) && nb::hasattr(value, "to_heterogeneous_map")) { // Deprecated typed-config path (the cudaq_qec._compat shims and the @@ -137,6 +186,8 @@ custom_args_map_from_python(const std::string &decoder_type, nb::object value) { schema_name = nb::cast(schema_attr); } value = value.attr("to_heterogeneous_map")(); + if (nb::isinstance(value)) + promote_legacy_error_rates(self, schema_name, nb::cast(value)); } if (nb::isinstance(value)) if (const auto *schema = find_decoder_schema(schema_name)) @@ -192,14 +243,14 @@ 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("error_rate_vec", &decoder_config::error_rate_vec) .def_prop_rw( "decoder_custom_args", [](const decoder_config &self) -> nb::object { return nb::cast(self.decoder_custom_args.map()); }, [](decoder_config &self, nb::object value) { - self.decoder_custom_args = - custom_args_map_from_python(self.type, value); + self.decoder_custom_args = custom_args_map_from_python(self, value); }, "The decoder's parameter dict. Keys are governed by the parameter " "schema the decoder registered (see decoder_param_schema()); set " @@ -210,7 +261,7 @@ void bindDecodingConfig(nb::module_ &mod) { "set_decoder_custom_args", [](config::decoder_config &self, nb::object custom_args) { self.decoder_custom_args = - custom_args_map_from_python(self.type, custom_args); + custom_args_map_from_python(self, custom_args); }, nb::arg("custom_args"), "Set the decoder parameter dict for this decoder (equivalent to " diff --git a/libs/qec/python/cudaq_qec/_compat.py b/libs/qec/python/cudaq_qec/_compat.py index 761f07796..b13c432d6 100644 --- a/libs/qec/python/cudaq_qec/_compat.py +++ b/libs/qec/python/cudaq_qec/_compat.py @@ -239,7 +239,8 @@ class trt_decoder_config(_deprecated_typed_config): _schema_name = "trt_decoder" _fields = ("onnx_load_path", "engine_load_path", "engine_save_path", "precision", "memory_workspace", "batch_size", "use_cuda_graph", - "global_decoder", "global_decoder_params") + "engine_output_format", "global_decoder", + "global_decoder_params") _global_decoder_classes = { "pymatching": pymatching_config, diff --git a/libs/qec/python/tests/test_decoders_yaml.py b/libs/qec/python/tests/test_decoders_yaml.py index 474c8483f..c07e289ba 100644 --- a/libs/qec/python/tests/test_decoders_yaml.py +++ b/libs/qec/python/tests/test_decoders_yaml.py @@ -90,6 +90,7 @@ def create_test_decoder_config_nv_qldpc(decoder_id): """ config = create_test_empty_decoder_config(decoder_id) config.type = "nv-qldpc-decoder" + config.error_rate_vec = [0.1] * config.block_size # Create NV-QLDPC decoder configuration (a parameter dict; keys are # governed by the decoder's registered schema) @@ -99,7 +100,6 @@ def create_test_decoder_config_nv_qldpc(decoder_id): "use_osd": True, "osd_order": 60, "osd_method": 3, - "error_rate_vec": [0.1] * config.block_size, "n_threads": 128, "bp_batch_size": 1, "osd_batch_size": 16, @@ -231,12 +231,12 @@ def test_sliding_window_decoder(): "num_syndromes_per_round": n_syndromes_per_round, "straddle_start_round": False, "straddle_end_round": True, - "error_rate_vec": [0.1] * config.block_size, "inner_decoder_name": "multi_error_lut", "inner_decoder_params": { "lut_error_depth": 2 }, } + config.error_rate_vec = [0.1] * config.block_size multi_config.decoders = [config] @@ -268,9 +268,9 @@ def test_sliding_window_boundary_syndromes_roundtrip(): "step_size": 1, "num_syndromes_per_round": 2, "num_boundary_syndromes": 1, - "error_rate_vec": [0.1] * config.block_size, "inner_decoder_name": "single_error_lut", } + config.error_rate_vec = [0.1] * config.block_size multi_config.decoders = [config] diff --git a/libs/qec/python/tests/test_decoding_config.py b/libs/qec/python/tests/test_decoding_config.py index a933702e3..9a9577896 100644 --- a/libs/qec/python/tests/test_decoding_config.py +++ b/libs/qec/python/tests/test_decoding_config.py @@ -53,7 +53,7 @@ def test_decoder_param_schema_introspection(): sw_schema = qec.decoder_param_schema("sliding_window") assert sw_schema is not None by_key = {entry["key"]: entry for entry in sw_schema} - assert by_key["error_rate_vec"]["required"] is True + assert "error_rate_vec" not in by_key assert by_key["inner_decoder_params"]["kind"] == "discriminated" assert by_key["inner_decoder_params"]["discriminator"] == \ "inner_decoder_name" @@ -89,11 +89,12 @@ def test_decoder_config_yaml_roundtrip_and_custom_args(): dc.block_size = 10 dc.syndrome_size = 3 dc.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] + dc.error_rate_vec = \ + [0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1] dc.decoder_custom_args = { "use_sparsity": True, "error_rate": 0.01, "max_iterations": 50, - "error_rate_vec": [0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1], "srelay_config": { "pre_iter": 5, "stopping_criterion": "NConv", @@ -129,8 +130,8 @@ def test_pymatching_config_yaml_roundtrip(): dc.H_sparse = [0, -1, 1, -1, 2, -1] dc.O_sparse = [0, -1, 1, -1, 2, -1] dc.D_sparse = [0, -1, 1, -1, 2, -1] + dc.error_rate_vec = [0.1, 0.2, 0.3] dc.decoder_custom_args = { - "error_rate_vec": [0.1, 0.2, 0.3], "merge_strategy": "smallest_weight", } @@ -142,7 +143,7 @@ def test_pymatching_config_yaml_roundtrip(): assert dc2.type == "pymatching" args = dc2.decoder_custom_args - assert list(args["error_rate_vec"]) == [0.1, 0.2, 0.3] + assert list(dc2.error_rate_vec) == [0.1, 0.2, 0.3] assert args["merge_strategy"] == "smallest_weight" @@ -228,10 +229,10 @@ def test_validate_custom_args_runs_schema_validate_hook(): # (step_size must be between 1 and window_size). dc = qec.decoder_config() dc.type = "sliding_window" + dc.error_rate_vec = [0.01, 0.01] dc.decoder_custom_args = { "window_size": 4, "step_size": 2, - "error_rate_vec": [0.01, 0.01], "inner_decoder_name": "single_error_lut", } dc.validate_custom_args() @@ -239,15 +240,14 @@ def test_validate_custom_args_runs_schema_validate_hook(): dc.decoder_custom_args = { "window_size": 2, "step_size": 4, - "error_rate_vec": [0.01, 0.01], "inner_decoder_name": "single_error_lut", } with pytest.raises(RuntimeError, match="step_size"): dc.validate_custom_args() - # Missing required key (error_rate_vec). - dc.decoder_custom_args = {"inner_decoder_name": "single_error_lut"} - with pytest.raises(RuntimeError, match="error_rate_vec"): + # Missing required key (inner_decoder_name). + dc.decoder_custom_args = {"window_size": 2} + with pytest.raises(RuntimeError, match="inner_decoder_name"): dc.validate_custom_args() @@ -275,8 +275,8 @@ def test_decoder_config_json_schema_validates_yaml_documents(): dc.H_sparse = [0, -1, 1, -1, 2, -1] dc.O_sparse = [0, -1, 1, -1, 2, -1] dc.D_sparse = [0, -1, 1, -1, 2, -1] + dc.error_rate_vec = [0.1, 0.1, 0.1] dc.decoder_custom_args = { - "error_rate_vec": [0.1, 0.1, 0.1], "merge_strategy": "smallest_weight", } document = yaml.safe_load(qec_yaml_for(dc)) @@ -290,7 +290,7 @@ def test_decoder_config_json_schema_validates_yaml_documents(): validator.validate(bad) # Missing required custom-arg keys fail validation (sliding_window - # requires error_rate_vec and inner_decoder_name). + # requires inner_decoder_name). missing = yaml.safe_load(qec_yaml_for(dc)) missing["decoders"][0]["type"] = "sliding_window" missing["decoders"][0]["decoder_custom_args"] = {"window_size": 2} @@ -339,8 +339,8 @@ def test_decoder_config_json_schema_covers_dispatch_and_transport(): dc.H_sparse = [0, -1, 1, -1, 2, -1] dc.O_sparse = [0, -1, 1, -1, 2, -1] dc.D_sparse = [0, -1, 1, -1, 2, -1] + dc.error_rate_vec = [0.1, 0.1, 0.1] dc.decoder_custom_args = { - "error_rate_vec": [0.1, 0.1, 0.1], "merge_strategy": "smallest_weight", } @@ -403,6 +403,7 @@ def test_trt_decoder_config_yaml_roundtrip(): dc.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] dc.decoder_custom_args = { "engine_load_path": "/path/to/engine.trt", + "engine_output_format": "errors", "precision": "fp16", "memory_workspace": 1073741824, # 1GB } @@ -436,6 +437,7 @@ def test_trt_decoder_chromobius_global_config_yaml_roundtrip(): dc.D_sparse = [0, -1, 1, -1, 2, -1] dc.decoder_custom_args = { "onnx_load_path": "/tmp/predecoder.onnx", + "engine_output_format": "observables_and_residual_detectors", "global_decoder": "chromobius", "global_decoder_params": { "ignore_decomposition_failures": True, @@ -471,7 +473,10 @@ def test_trt_decoder_default_global_params_materialized(): dc.H_sparse = [0, -1, 1, -1, 2, -1] dc.O_sparse = [0, -1, 1, -1, 2, -1] dc.D_sparse = [0, -1, 1, -1, 2, -1] - dc.decoder_custom_args = {"global_decoder": "pymatching"} + dc.decoder_custom_args = { + "engine_output_format": "residual_detectors", + "global_decoder": "pymatching", + } mdc2 = qec.multi_decoder_config.from_yaml_str(qec_yaml_for(dc)) args = mdc2.decoders[0].decoder_custom_args @@ -570,10 +575,11 @@ def test_multi_decoder_config_yaml_roundtrip(): d1.block_size = 10 d1.syndrome_size = 3 d1.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] + d1.error_rate_vec = \ + [0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1] d1.decoder_custom_args = { "use_sparsity": True, "error_rate": 0.01, - "error_rate_vec": [0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1], "max_iterations": 50, } @@ -636,9 +642,8 @@ def test_configure_decoders_from_str_smoke(): decoder_config.block_size = 10 decoder_config.syndrome_size = 3 decoder_config.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] - decoder_config.decoder_custom_args = { - "error_rate_vec": [0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1], - } + decoder_config.error_rate_vec = \ + [0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1] multi_decoder_config = qec.multi_decoder_config() multi_decoder_config.decoders = [decoder_config] yaml_str = multi_decoder_config.to_yaml_str() @@ -668,6 +673,8 @@ def make_pymatching_multi_decoder_config(pm_args, h_sparse=None): dc.H_sparse = h_sparse if h_sparse is not None else [0, -1, 1, -1, 2, -1] dc.O_sparse = [0, -1, 1, -1, 2, -1] dc.D_sparse = [0, -1, 1, -1, 2, -1] + pm_args = dict(pm_args) + dc.error_rate_vec = pm_args.pop("error_rate_vec", []) dc.decoder_custom_args = pm_args mdc = qec.multi_decoder_config() @@ -692,10 +699,15 @@ def test_configure_valid_pymatching_decoder(): assert ret == 0 -@pytest.mark.parametrize( - "error_rate_vec", - ([0.1, 0.1], [0.0, 0.1, 0.1], [0.1, 0.6, 0.1]), -) +def test_configure_rejects_pymatching_error_rate_vec_size_mismatch(): + with pytest.raises(RuntimeError, match="error_rate_vec size"): + configure_pymatching_status({ + "error_rate_vec": [0.1, 0.1], + "merge_strategy": "smallest_weight", + }) + + +@pytest.mark.parametrize("error_rate_vec", ([0.0, 0.1, 0.1], [0.1, 0.6, 0.1])) def test_configure_invalid_pymatching_error_rate_vec(error_rate_vec): ret = configure_pymatching_status({ "error_rate_vec": error_rate_vec, diff --git a/libs/qec/python/tests/test_decoding_config_deprecated.py b/libs/qec/python/tests/test_decoding_config_deprecated.py index e8d15dd5d..451e38fdd 100644 --- a/libs/qec/python/tests/test_decoding_config_deprecated.py +++ b/libs/qec/python/tests/test_decoding_config_deprecated.py @@ -31,6 +31,7 @@ trt_schema_missing = qec.decoder_param_schema("trt_decoder") is None chromobius_schema_missing = qec.decoder_param_schema("chromobius") is None +pymatching_schema_missing = qec.decoder_param_schema("pymatching") is None nv_qldpc_schema_missing = qec.decoder_param_schema("nv-qldpc-decoder") is None @@ -114,7 +115,10 @@ def test_deprecated_config_assignable_to_property(): dc.decoder_custom_args = cfg dc.type = "pymatching" args = dc.decoder_custom_args - assert list(args["error_rate_vec"]) == [0.1, 0.2, 0.3] + # Model rates are top-level decoder_config state, so the shim promotes + # them out of the per-decoder parameters instead of dropping them. + assert "error_rate_vec" not in args + assert list(dc.error_rate_vec) == [0.1, 0.2, 0.3] assert args["merge_strategy"] == "smallest_weight" dc.validate_custom_args() @@ -491,6 +495,7 @@ def test_trt_decoder_config_set_and_get_each_optional(name, meta): def test_trt_decoder_config_yaml_roundtrip(): trt = qec.trt_decoder_config() trt.engine_load_path = "/path/to/engine.trt" + trt.engine_output_format = "observables" trt.precision = "fp16" trt.memory_workspace = 1073741824 # 1GB @@ -621,7 +626,8 @@ def test_pymatching_config_yaml_roundtrip(): # reads back as a plain dict, never a typed config object. pm2 = dc2.decoder_custom_args assert pm2 is not None - assert list(pm2["error_rate_vec"]) == [0.1, 0.2, 0.3] + assert "error_rate_vec" not in pm2 + assert list(dc2.error_rate_vec) == [0.1, 0.2, 0.3] assert pm2["merge_strategy"] == "smallest_weight" @@ -634,6 +640,7 @@ def test_trt_decoder_chromobius_global_config_yaml_roundtrip(): chromobius.return_weight = False trt = qec.trt_decoder_config() + trt.engine_output_format = "residual_detectors" trt.global_decoder = "chromobius" trt.global_decoder_params = chromobius @@ -666,6 +673,61 @@ def test_trt_decoder_chromobius_global_config_yaml_roundtrip(): assert chromobius2["return_weight"] is False +@pytest.mark.skipif( + trt_schema_missing or pymatching_schema_missing, + reason="trt_decoder/pymatching plugins (and their schemas) not available") +def test_trt_decoder_nested_pymatching_rates_promote_and_roundtrip(): + # A nested composition carries model rates on the child, which belong to + # the top-level model rather than the child's parameters. + pm = qec.pymatching_config() + pm.error_rate_vec = [0.1, 0.2, 0.3] + pm.merge_strategy = "independent" + + trt = qec.trt_decoder_config() + trt.engine_output_format = "residual_detectors" + trt.global_decoder = "pymatching" + trt.global_decoder_params = pm + + dc = qec.decoder_config() + dc.id = 0 + dc.type = "trt_decoder" + dc.block_size = 3 + dc.syndrome_size = 3 + dc.H_sparse = [0, -1, 1, -1, 2, -1] + dc.set_decoder_custom_args(trt) + + assert list(dc.error_rate_vec) == [0.1, 0.2, 0.3] + nested = dc.decoder_custom_args["global_decoder_params"] + assert "error_rate_vec" not in nested + assert nested["merge_strategy"] == "independent" + dc.validate_custom_args() + + dc2 = qec.decoder_config.from_yaml_str(dc.to_yaml_str()) + assert list(dc2.error_rate_vec) == [0.1, 0.2, 0.3] + nested2 = dc2.decoder_custom_args["global_decoder_params"] + assert "error_rate_vec" not in nested2 + assert nested2["merge_strategy"] == "independent" + + +@pytest.mark.skipif( + trt_schema_missing or pymatching_schema_missing, + reason="trt_decoder/pymatching plugins (and their schemas) not available") +def test_trt_decoder_nested_pymatching_conflicting_rates_rejected(): + pm = qec.pymatching_config() + pm.error_rate_vec = [0.9, 0.9, 0.9] + + trt = qec.trt_decoder_config() + trt.engine_output_format = "residual_detectors" + trt.global_decoder = "pymatching" + trt.global_decoder_params = pm + + dc = qec.decoder_config() + dc.type = "trt_decoder" + dc.error_rate_vec = [0.1, 0.2, 0.3] + with pytest.raises(RuntimeError, match="Conflicting error_rate_vec"): + dc.set_decoder_custom_args(trt) + + # decoder_config tests @@ -848,7 +910,7 @@ def test_configure_valid_pymatching_decoder(): @pytest.mark.parametrize( "error_rate_vec", - ([0.1, 0.1], [0.0, 0.1, 0.1], [0.1, 0.6, 0.1]), + ([0.0, 0.1, 0.1], [0.1, 0.6, 0.1]), ) def test_configure_invalid_pymatching_error_rate_vec(error_rate_vec): pm = qec.pymatching_config() @@ -860,6 +922,17 @@ def test_configure_invalid_pymatching_error_rate_vec(error_rate_vec): assert ret != 0 +def test_configure_rejects_pymatching_error_rate_vec_size_mismatch(): + # Promoted rates are validated with the top-level field, which rejects a + # size mismatch by raising rather than through the status code. + pm = qec.pymatching_config() + pm.error_rate_vec = [0.1, 0.1] + pm.merge_strategy = "smallest_weight" + + with pytest.raises(RuntimeError, match="error_rate_vec size"): + configure_pymatching_status(pm) + + def test_configure_invalid_pymatching_merge_strategy(): pm = qec.pymatching_config() pm.error_rate_vec = [0.1, 0.1, 0.1] diff --git a/libs/qec/python/tests/test_dem.py b/libs/qec/python/tests/test_dem.py index e8decf19d..0fc4a1eb6 100644 --- a/libs/qec/python/tests/test_dem.py +++ b/libs/qec/python/tests/test_dem.py @@ -320,11 +320,12 @@ def test_pymatching_decode_to_observable_surface_code_dem(): 'pymatching', dem.detector_error_matrix, O=dem.observables_flips_matrix, + output='observables', error_rate_vec=np.array(dem.error_rates), ) dr = decoder.decode_batch(syndromes) - # With decode_to_observables=True, each row is observable flips + # Constructed for observable output, so each row is observable flips # (length num_observables), not error predictions. obs_per_shot = np.asarray(dr.result, dtype=np.float64) data_predictions = np.round(obs_per_shot).astype(np.uint8).T @@ -669,6 +670,7 @@ def test_pymatching_decodes_stim_surface_code_dem(): 'pymatching', H, O=O, + output='observables', error_rate_vec=rates, merge_strategy='independent', ) @@ -676,7 +678,7 @@ def test_pymatching_decodes_stim_surface_code_dem(): pytest.skip(f'pymatching decoder unavailable in this build: {e}') dr = decoder.decode_batch(syndromes) - # With O provided, the decoder returns predicted observable flips. + # Constructed for observable output, so these are observable flips. obs_per_shot = np.asarray(dr.result, dtype=np.float64) data_predictions = np.round(obs_per_shot).astype(np.uint8).flatten() diff --git a/libs/qec/python/tests/test_sliding_window.py b/libs/qec/python/tests/test_sliding_window.py index 321eeb106..d4da7d418 100644 --- a/libs/qec/python/tests/test_sliding_window.py +++ b/libs/qec/python/tests/test_sliding_window.py @@ -176,12 +176,13 @@ def test_pymatching_parallel_edges_use_observable_faults(): with pytest.raises(ValueError, match="Parallel edges not permitted"): qec.get_decoder("pymatching", H) - # ASSERT: providing O with merge_strategy='independent' combines the - # parallel edges and yields a converged observable-space decode. + # ASSERT: explicitly requesting observables permits independent parallel + # edge merging; O is model data and does not select the output mode. decoder = qec.get_decoder("pymatching", H, O=O, error_rate_vec=error_rates, + output="observables", merge_strategy="independent") result = decoder.decode_batch(np.array([[1]], dtype=np.uint8)) diff --git a/libs/qec/python/tests/test_trt_decoder.py b/libs/qec/python/tests/test_trt_decoder.py index 880b7eac2..bb0874927 100644 --- a/libs/qec/python/tests/test_trt_decoder.py +++ b/libs/qec/python/tests/test_trt_decoder.py @@ -376,7 +376,9 @@ def test_validate_parameters_no_paths_provided(self): # Initialization failures throw (converged never stands in for # decoder health), so construction without a path raises. try: - decoder = qec.get_decoder('trt_decoder', self.H) + decoder = qec.get_decoder('trt_decoder', + self.H, + engine_output_format='errors') # If decoder is None or doesn't initialize properly, skip these tests if decoder is None: pytest.skip( @@ -432,12 +434,17 @@ def setup_method(self): # Create a dummy H matrix (identity matrix for simplicity) self.H_inference = np.eye(num_detectors, dtype=np.uint8) + self.O_inference = np.zeros((NUM_OBSERVABLES, num_detectors), + dtype=np.uint8) # Create the TRT decoder self.onnx_path = ONNX_MODEL_PATH try: self.decoder = qec.get_decoder('trt_decoder', self.H_inference, + O=self.O_inference, + output='observables', + engine_output_format='observables', onnx_load_path=self.onnx_path) # If decoder is None or doesn't initialize properly, skip these tests if self.decoder is None: @@ -585,6 +592,11 @@ def test_decoder_with_zero_syndrome(self): try: decoder = qec.get_decoder('trt_decoder', H, + O=np.zeros( + (NUM_OBSERVABLES, num_detectors), + dtype=np.uint8), + output='observables', + engine_output_format='observables', onnx_load_path=ONNX_MODEL_PATH) except Exception: pytest.skip("Failed to create TRT decoder") @@ -607,6 +619,11 @@ def test_decoder_with_all_ones_syndrome(self): try: decoder = qec.get_decoder('trt_decoder', H, + O=np.zeros( + (NUM_OBSERVABLES, num_detectors), + dtype=np.uint8), + output='observables', + engine_output_format='observables', onnx_load_path=ONNX_MODEL_PATH) except Exception: pytest.skip("Failed to create TRT decoder") @@ -640,11 +657,15 @@ def test_performance_comparison_cuda_graph_vs_traditional(self): # Create decoder WITH CUDA graphs (default) # ===================================================================== try: - decoder_cuda_graph = qec.get_decoder('trt_decoder', - H, - onnx_load_path=ONNX_MODEL_PATH, - precision='fp16', - use_cuda_graph=True) + decoder_cuda_graph = qec.get_decoder( + 'trt_decoder', + H, + O=np.zeros((NUM_OBSERVABLES, num_detectors), dtype=np.uint8), + output='observables', + engine_output_format='observables', + onnx_load_path=ONNX_MODEL_PATH, + precision='fp16', + use_cuda_graph=True) except Exception as e: pytest.skip(f"Failed to create CUDA graph decoder: {e}") @@ -655,6 +676,9 @@ def test_performance_comparison_cuda_graph_vs_traditional(self): decoder_traditional = qec.get_decoder( 'trt_decoder', H, + O=np.zeros((NUM_OBSERVABLES, num_detectors), dtype=np.uint8), + output='observables', + engine_output_format='observables', onnx_load_path=ONNX_MODEL_PATH, precision='fp16', use_cuda_graph=False) @@ -774,6 +798,7 @@ def test_cuda_graph_vs_traditional_correctness(self): decoder_cuda_graph = qec.get_decoder( 'trt_decoder', H, + engine_output_format='errors', engine_load_path=engine_path, use_cuda_graph=True) except Exception as e: @@ -786,6 +811,7 @@ def test_cuda_graph_vs_traditional_correctness(self): decoder_traditional = qec.get_decoder( 'trt_decoder', H, + engine_output_format='errors', engine_load_path=engine_path, use_cuda_graph=False) except Exception as e: @@ -908,6 +934,7 @@ def decoder_with_batch_size(self, request, tmp_path): try: decoder = qec.get_decoder("trt_decoder", H, + engine_output_format="errors", engine_load_path=str(engine_path)) except Exception as e: pytest.skip(f"Failed to create decoder: {e}") diff --git a/libs/qec/tools/decoding-server/decoding_server_config.yaml b/libs/qec/tools/decoding-server/decoding_server_config.yaml index 21348c6f3..45102ca73 100644 --- a/libs/qec/tools/decoding-server/decoding_server_config.yaml +++ b/libs/qec/tools/decoding-server/decoding_server_config.yaml @@ -22,6 +22,6 @@ decoders: H_sparse: [0, -1, 1, -1, 2, -1] O_sparse: [0, -1, 1, -1, 2, -1] D_sparse: [0, -1, 1, -1, 2, -1] + error_rate_vec: [0.1, 0.1, 0.1] decoder_custom_args: merge_strategy: smallest_weight - error_rate_vec: [0.1, 0.1, 0.1] diff --git a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp index 8fc4c2647..fb312210d 100644 --- a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp +++ b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp @@ -448,9 +448,10 @@ TEST(QECCodeTester, checkNoisySampleMemoryCircuitAndDecodeStim) { } printf("syndrome:\n"); syndrome.dump(); - auto [converged, v_result, opt] = decoder->decode(syndrome); + auto decoded = decoder->decode(syndrome); cudaqx::tensor result_tensor; - cudaq::qec::convert_vec_soft_to_tensor_hard(v_result, result_tensor); + cudaq::qec::convert_vec_soft_to_tensor_hard(decoded.result, + result_tensor); printf("decode result:\n"); result_tensor.dump(); cudaqx::tensor decoded_observables = @@ -542,9 +543,10 @@ TEST(QECCodeTester, checkNoisySampleMemoryCircuitAndDecodeStim) { } printf("syndrome:\n"); syndrome.dump(); - auto [converged, v_result, opt] = decoder->decode(syndrome); + auto decoded = decoder->decode(syndrome); cudaqx::tensor result_tensor; - cudaq::qec::convert_vec_soft_to_tensor_hard(v_result, result_tensor); + cudaq::qec::convert_vec_soft_to_tensor_hard(decoded.result, + result_tensor); printf("decode result:\n"); result_tensor.dump(); @@ -950,10 +952,9 @@ shor9_dem(std::size_t num_rounds, } // Build sliding_window parameters for a boundary-layout DEM. -cudaqx::heterogeneous_map -shor9_sliding_params(std::size_t window_size, std::size_t interior, - std::size_t numBoundary, - const std::vector &error_rates) { +cudaqx::heterogeneous_map shor9_sliding_params(std::size_t window_size, + std::size_t interior, + std::size_t numBoundary) { cudaqx::heterogeneous_map inner_params; inner_params.insert("dummy_param", 1); cudaqx::heterogeneous_map params; @@ -963,7 +964,6 @@ shor9_sliding_params(std::size_t window_size, std::size_t interior, params.insert("num_boundary_syndromes", numBoundary); params.insert("straddle_start_round", false); params.insert("straddle_end_round", true); - params.insert("error_rate_vec", error_rates); params.insert("inner_decoder_name", std::string("single_error_lut")); params.insert("inner_decoder_params", inner_params); return params; @@ -1112,9 +1112,8 @@ TEST(QECCodeTester, checkSlidingWindowShor9Boundary) { cudaq::qec::decoder::get("single_error_lut", dem.detector_error_matrix); // A single window spanning all layers -- should match the full decoder. auto sw = cudaq::qec::decoder::get( - "sliding_window", dem.detector_error_matrix, - shor9_sliding_params(num_layers, interior, numBoundary, - dem.error_rates)); + "sliding_window", cudaq::qec::decoder_inputs{dem}, + shor9_sliding_params(num_layers, interior, numBoundary)); expectObservablesMatchFullDecoder( dem, *full, [&](const std::vector &syndrome) { @@ -1156,9 +1155,8 @@ TEST(QECCodeTester, checkSlidingWindowShor9Streaming) { cudaq::qec::decoder::get("single_error_lut", dem.detector_error_matrix); // A genuinely sliding configuration: window of 2 rounds, stepping by 1. auto sw = cudaq::qec::decoder::get( - "sliding_window", dem.detector_error_matrix, - shor9_sliding_params(/*window_size=*/2, interior, numBoundary, - dem.error_rates)); + "sliding_window", cudaq::qec::decoder_inputs{dem}, + shor9_sliding_params(/*window_size=*/2, interior, numBoundary)); expectObservablesMatchFullDecoder( dem, *full, [&](const std::vector &syndrome) { @@ -1253,11 +1251,10 @@ TEST(QECCodeTester, checkSlidingWindowRealtimeBoundaryStreaming) { params.insert("num_boundary_syndromes", B); params.insert("straddle_start_round", false); params.insert("straddle_end_round", true); - params.insert("error_rate_vec", dem.error_rates); params.insert("inner_decoder_name", std::string("single_error_lut")); params.insert("inner_decoder_params", cudaqx::heterogeneous_map{}); - return cudaq::qec::decoder::get("sliding_window", dem.detector_error_matrix, - params); + return cudaq::qec::decoder::get("sliding_window", + cudaq::qec::decoder_inputs{dem}, params); }; auto sw = make_sw(); // realtime streaming auto sw_ref = make_sw(); // whole-block reference diff --git a/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp index ee387d44b..9d582932b 100644 --- a/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp +++ b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp @@ -50,8 +50,16 @@ TEST(ChromobiusDecoder, checkAllZeroSyndrome) { EXPECT_TRUE(result.converged); ASSERT_EQ(result.result.size(), 1); EXPECT_EQ(result.result[0], 0.0); - EXPECT_EQ(decoder->get_block_size(), 1); + EXPECT_EQ(decoder->get_block_size(), 6); EXPECT_EQ(decoder->get_syndrome_size(), 4); + EXPECT_EQ(decoder->get_default_output(), + cudaq::qec::decoder_output::observables); + EXPECT_THROW((void)cudaq::qec::decoder::get( + "chromobius", + cudaq::qec::decoder_inputs::from_stim_dem( + std::string{chromobius_dem}), + cudaq::qec::decoder_output::errors, make_params()), + std::invalid_argument); } TEST(ChromobiusDecoder, checkKnownObservableFlip) { diff --git a/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp b/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp index 0cef4e657..4cbecd763 100644 --- a/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp +++ b/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp @@ -163,7 +163,17 @@ TEST(PyMatchingDecoder, AcceptsAllMergeStrategiesAndRejectsUnknown) { "replace"}) { cudaqx::heterogeneous_map params; params.insert("merge_strategy", strategy); - auto d = cudaq::qec::decoder::get("pymatching", H, params); + std::unique_ptr d; + if (strategy == "disallow") { + d = cudaq::qec::decoder::get("pymatching", H, params); + } else { + auto sparse_H = cudaq::qec::sparse_binary_matrix(H); + auto O = cudaq::qec::sparse_binary_matrix::from_nested_csr(1, 1, {{0}}); + d = cudaq::qec::decoder::get( + "pymatching", + cudaq::qec::decoder_inputs(std::move(sparse_H), std::move(O)), + cudaq::qec::decoder_output::observables, params); + } ASSERT_NE(d, nullptr) << strategy; auto result = d->decode(std::vector{1.0}); ASSERT_TRUE(result.converged) << strategy; @@ -176,6 +186,35 @@ TEST(PyMatchingDecoder, AcceptsAllMergeStrategiesAndRejectsUnknown) { std::runtime_error); } +TEST(PyMatchingDecoder, ErrorOutputTracksMergedParallelEdgeColumn) { + cudaqx::tensor H; + const std::vector H_vec = {1, 1}; + H.copy(H_vec.data(), {1, 2}); + + auto decode_with = [&](const std::string &strategy) { + cudaqx::heterogeneous_map params; + params.insert("merge_strategy", strategy); + auto O = cudaq::qec::sparse_binary_matrix::from_csr( + 0, 2, std::vector{0}, {}); + auto inputs = cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix(H), std::move(O), {0.1, 0.2}); + auto decoder = + cudaq::qec::decoder::get("pymatching", std::move(inputs), + cudaq::qec::decoder_output::errors, params); + return decoder->decode(std::vector{1.0}).result; + }; + + EXPECT_EQ(decode_with("keep_original"), + (std::vector{1.0, 0.0})); + EXPECT_EQ(decode_with("independent"), + (std::vector{1.0, 0.0})); + EXPECT_EQ(decode_with("replace"), + (std::vector{0.0, 1.0})); + EXPECT_EQ(decode_with("smallest_weight"), + (std::vector{0.0, 1.0})); + EXPECT_THROW((void)decode_with("disallow"), std::invalid_argument); +} + TEST(PyMatchingDecoder, RejectsObservableMatrixWithWrongBlockSize) { cudaqx::tensor H; std::vector H_vec = {1, 0, 0, 1}; @@ -183,11 +222,10 @@ TEST(PyMatchingDecoder, RejectsObservableMatrixWithWrongBlockSize) { cudaqx::tensor O({1, 3}); O.at({0, 0}) = 1; - cudaqx::heterogeneous_map params; - params.insert("O", O); - - EXPECT_THROW((void)cudaq::qec::decoder::get("pymatching", H, params), - std::runtime_error); + EXPECT_THROW( + (void)cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H), + cudaq::qec::sparse_binary_matrix(O)), + std::invalid_argument); } TEST(PyMatchingDecoder, DecodesHighObservableIndicesAcrossPaths) { @@ -203,9 +241,11 @@ TEST(PyMatchingDecoder, DecodesHighObservableIndicesAcrossPaths) { O.at({i, i}) = 1; } - cudaqx::heterogeneous_map params; - params.insert("O", O); - auto d = cudaq::qec::decoder::get("pymatching", H, params); + auto d = cudaq::qec::decoder::get( + "pymatching", + cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H), + cudaq::qec::sparse_binary_matrix(O)), + cudaq::qec::decoder_output::observables); // ASSERT: valid graph-like identity matrices must construct a decoder. ASSERT_NE(d, nullptr); diff --git a/libs/qec/unittests/decoders/pymatching/test_pymatching_device_call_realtime.cpp b/libs/qec/unittests/decoders/pymatching/test_pymatching_device_call_realtime.cpp index 43977d6ff..7b5c2cc11 100644 --- a/libs/qec/unittests/decoders/pymatching/test_pymatching_device_call_realtime.cpp +++ b/libs/qec/unittests/decoders/pymatching/test_pymatching_device_call_realtime.cpp @@ -60,10 +60,10 @@ config::multi_decoder_config make_config() { decoder_config.H_sparse = identity_sparse_matrix; decoder_config.O_sparse = identity_sparse_matrix; decoder_config.D_sparse = identity_sparse_matrix; + decoder_config.error_rate_vec = + std::vector(kBlockSize, kUniformErrorRate); cudaqx::heterogeneous_map pymatching_args; - pymatching_args.insert("error_rate_vec", - std::vector(kBlockSize, kUniformErrorRate)); pymatching_args.insert("merge_strategy", "smallest_weight"); decoder_config.decoder_custom_args = pymatching_args; diff --git a/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp b/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp index 150de67fb..671c141c5 100644 --- a/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp +++ b/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp @@ -173,9 +173,9 @@ TEST(PyMatchingRealtime, ConfiguresViaRealtimeDecoderConfig) { decoder_config.H_sparse = {0, -1, 1, -1, 2, -1}; decoder_config.O_sparse = {0, -1, 1, -1, 2, -1}; decoder_config.D_sparse = {0, -1, 1, -1, 2, -1}; + decoder_config.error_rate_vec = {0.1, 0.1, 0.1}; cudaqx::heterogeneous_map pymatching_args; - pymatching_args.insert("error_rate_vec", std::vector{0.1, 0.1, 0.1}); pymatching_args.insert("merge_strategy", "smallest_weight"); decoder_config.decoder_custom_args = pymatching_args; diff --git a/libs/qec/unittests/decoders/sample_decoder.cpp b/libs/qec/unittests/decoders/sample_decoder.cpp index d3513d2f5..54d6fb140 100644 --- a/libs/qec/unittests/decoders/sample_decoder.cpp +++ b/libs/qec/unittests/decoders/sample_decoder.cpp @@ -7,6 +7,7 @@ ******************************************************************************/ #include "cudaq/qec/decoder.h" +#include #include using namespace cudaqx; @@ -16,25 +17,34 @@ namespace cudaq::qec { /// @brief This is a sample (dummy) decoder that demonstrates how to build a /// bare bones custom decoder based on the `cudaq::qec::decoder` interface. class sample_decoder : public decoder { -private: - bool decode_to_obs = false; - public: sample_decoder(cudaq::qec::decoder_inputs inputs, + decoder_output default_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs)) { - // Decoder-specific constructor arguments can be placed in `params`. - decode_to_obs = params.get("decode_to_obs", decode_to_obs); - if (decode_to_obs) - set_result_type(decode_result_type::decode_to_obs); + : decoder(std::move(inputs), default_output) { + // This decoder computes an error frame. Producing observables requires an + // observable mapping to project through; reject at construction rather + // than on the first decode. + if (default_output == decoder_output::observables && + !get_inputs().has_observable_model()) + throw std::invalid_argument( + "sample_decoder was constructed for observable output but its model " + "supplies no observable mapping"); } - virtual decoder_result decode(const std::vector &syndrome) { - // This is a simple decoder that simply results + decoder_result decode(const std::vector &syndrome) override { decoder_result result; result.converged = true; - result.result = - decode_to_obs ? syndrome : std::vector(block_size, 0.0f); + result.result = std::vector(block_size, 0.0f); + + // Whether the frame is projected is fixed at construction, so the decision + // is read from immutable instance state rather than negotiated per call. + if (get_default_output() == decoder_output::observables) { + std::vector observables(get_num_observables(), 0.0); + project_errors_to_observables(result.result.data(), observables.data(), + observables.size()); + result.result = std::move(observables); + } return result; } @@ -43,9 +53,10 @@ class sample_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( sample_decoder, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(std::move(inputs), - params); + return std::make_unique( + std::move(inputs), output.value_or(decoder_output::errors), params); }) }; diff --git a/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp b/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp index 694e313cd..41170e327 100644 --- a/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp +++ b/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp @@ -40,6 +40,17 @@ cudaqx::tensor make_identity_h(std::size_t n) { return H; } +decoder_inputs +make_inputs_with_empty_observables(const cudaqx::tensor &H, + std::size_t num_observables) { + auto sparse_H = sparse_binary_matrix(H); + std::vector row_ptrs(num_observables + 1, 0); + auto O = sparse_binary_matrix::from_csr( + static_cast(num_observables), sparse_H.num_cols(), + std::move(row_ptrs), {}); + return decoder_inputs(std::move(sparse_H), std::move(O)); +} + std::filesystem::path make_temp_engine_path(const std::string &name) { return std::filesystem::temp_directory_path() / name; } @@ -112,6 +123,7 @@ class TRTDecoderTest : public ::testing::Test { TEST_F(TRTDecoderTest, ValidateParameters_ValidONNXPath) { cudaqx::heterogeneous_map params; params.insert("onnx_load_path", std::string("test_model.onnx")); + params.insert("engine_output_format", std::string("errors")); // Should not throw EXPECT_NO_THROW( @@ -122,6 +134,7 @@ TEST_F(TRTDecoderTest, ValidateParameters_ValidONNXPath) { TEST_F(TRTDecoderTest, ValidateParameters_ValidEnginePath) { cudaqx::heterogeneous_map params; params.insert("engine_load_path", std::string("test_engine.trt")); + params.insert("engine_output_format", std::string("errors")); // Should not throw EXPECT_NO_THROW( @@ -133,6 +146,7 @@ TEST_F(TRTDecoderTest, ValidateParameters_BothPathsProvided) { cudaqx::heterogeneous_map params; params.insert("onnx_load_path", std::string("test_model.onnx")); params.insert("engine_load_path", std::string("test_engine.trt")); + params.insert("engine_output_format", std::string("errors")); // Should throw runtime_error EXPECT_THROW( @@ -153,6 +167,7 @@ TEST_F(TRTDecoderTest, ValidateParameters_EmptyStringPaths) { cudaqx::heterogeneous_map params; params.insert("onnx_load_path", std::string("")); params.insert("engine_load_path", std::string("")); + params.insert("engine_output_format", std::string("errors")); // Should throw runtime_error (empty strings are still considered "provided") EXPECT_THROW( @@ -281,10 +296,13 @@ TEST_F(TRTDecoderTest, ValidateAgainstPyTorchModel) { // Create the TRT decoder cudaqx::heterogeneous_map params; params.insert("onnx_load_path", onnx_path); + params.insert("engine_output_format", std::string("observables")); std::unique_ptr trt_decoder; try { - trt_decoder = decoder::get("trt_decoder", H, params); + trt_decoder = decoder::get( + "trt_decoder", make_inputs_with_empty_observables(H, num_observables), + decoder_output::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create TRT decoder: " << e.what(); } @@ -374,10 +392,13 @@ TEST_F(TRTDecoderTest, ValidateSingleTestCase) { // Create the TRT decoder cudaqx::heterogeneous_map params; params.insert("onnx_load_path", onnx_path); + params.insert("engine_output_format", std::string("observables")); std::unique_ptr trt_decoder; try { - trt_decoder = decoder::get("trt_decoder", H, params); + trt_decoder = decoder::get( + "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), + decoder_output::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create TRT decoder: " << e.what(); } @@ -433,12 +454,15 @@ TEST_F(TRTDecoderTest, PerformanceComparisonCudaGraphVsTraditional) { // ========================================================================= cudaqx::heterogeneous_map params_cuda_graph; params_cuda_graph.insert("onnx_load_path", onnx_path); + params_cuda_graph.insert("engine_output_format", std::string("observables")); params_cuda_graph.insert("precision", "fp16"); params_cuda_graph.insert("use_cuda_graph", true); std::unique_ptr decoder_cuda_graph; try { - decoder_cuda_graph = decoder::get("trt_decoder", H, params_cuda_graph); + decoder_cuda_graph = decoder::get( + "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), + decoder_output::observables, params_cuda_graph); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create CUDA graph decoder: " << e.what(); } @@ -448,12 +472,15 @@ TEST_F(TRTDecoderTest, PerformanceComparisonCudaGraphVsTraditional) { // ========================================================================= cudaqx::heterogeneous_map params_traditional; params_traditional.insert("onnx_load_path", onnx_path); + params_traditional.insert("engine_output_format", std::string("observables")); params_traditional.insert("precision", "fp16"); params_traditional.insert("use_cuda_graph", false); std::unique_ptr decoder_traditional; try { - decoder_traditional = decoder::get("trt_decoder", H, params_traditional); + decoder_traditional = decoder::get( + "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), + decoder_output::observables, params_traditional); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create traditional decoder: " << e.what(); } @@ -554,6 +581,7 @@ TEST_F(TRTDecoderTest, ConstructionFailureThrows) { cudaqx::heterogeneous_map params; params.insert("engine_load_path", std::string("/no/such/cudaq-qec-test.engine")); + params.insert("engine_output_format", std::string("errors")); EXPECT_THROW(decoder::get("trt_decoder", make_identity_h(2), params), std::runtime_error); } @@ -574,6 +602,7 @@ TEST_F(TRTDecoderTest, EngineSavePathAndEngineLoadPathRoundTrip) { cudaqx::heterogeneous_map build_params; build_params.insert("onnx_load_path", onnx_path); + build_params.insert("engine_output_format", std::string("observables")); build_params.insert("engine_save_path", engine_path.string()); build_params.insert("memory_workspace", std::size_t{1 << 20}); build_params.insert("precision", std::string("fp16")); @@ -581,7 +610,9 @@ TEST_F(TRTDecoderTest, EngineSavePathAndEngineLoadPathRoundTrip) { std::unique_ptr built_decoder; try { - built_decoder = decoder::get("trt_decoder", H, build_params); + built_decoder = decoder::get( + "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), + decoder_output::observables, build_params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to build TRT decoder: " << e.what(); } @@ -589,10 +620,13 @@ TEST_F(TRTDecoderTest, EngineSavePathAndEngineLoadPathRoundTrip) { cudaqx::heterogeneous_map load_params; load_params.insert("engine_load_path", engine_path.string()); + load_params.insert("engine_output_format", std::string("observables")); load_params.insert("use_cuda_graph", false); std::unique_ptr loaded_decoder; try { - loaded_decoder = decoder::get("trt_decoder", H, load_params); + loaded_decoder = decoder::get( + "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), + decoder_output::observables, load_params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to load TRT decoder: " << e.what(); } @@ -619,6 +653,7 @@ TEST_F(TRTDecoderTest, DynamicBatchIdentityModelUsesOptimizationProfile) { cudaqx::heterogeneous_map params; params.insert("onnx_load_path", *onnx_path); + params.insert("engine_output_format", std::string("errors")); params.insert("batch_size", std::size_t{2}); params.insert("use_cuda_graph", true); params.insert("memory_workspace", std::size_t{1 << 20}); @@ -653,6 +688,7 @@ TEST_F(TRTDecoderTest, Uint8IdentityModelBinarizesInputAndOutput) { cudaqx::heterogeneous_map params; params.insert("onnx_load_path", *onnx_path); + params.insert("engine_output_format", std::string("errors")); params.insert("use_cuda_graph", false); std::unique_ptr trt_decoder; @@ -681,6 +717,7 @@ TEST_F(TRTDecoderTest, MixedDtypeCopiesOutput) { cudaqx::heterogeneous_map params; params.insert("onnx_load_path", *onnx_path); + params.insert("engine_output_format", std::string("errors")); params.insert("use_cuda_graph", false); std::unique_ptr trt_decoder; @@ -703,7 +740,7 @@ TEST_F(TRTDecoderTest, MixedDtypeCopiesOutput) { EXPECT_FLOAT_EQ(result.result[2], 1.0); } -TEST_F(TRTDecoderTest, BatchFailureThrows) { +TEST_F(TRTDecoderTest, RejectsGlobalDecoderSyndromeMismatchAtConstruction) { if (!gpu_available()) GTEST_SKIP() << "No CUDA GPU available"; auto onnx_path = get_dynamic_onnx_asset_path(); @@ -712,22 +749,15 @@ TEST_F(TRTDecoderTest, BatchFailureThrows) { cudaqx::heterogeneous_map params; params.insert("onnx_load_path", *onnx_path); + params.insert("engine_output_format", std::string("residual_detectors")); params.insert("batch_size", std::size_t{1}); params.insert("use_cuda_graph", false); params.insert("global_decoder", std::string("single_error_lut")); params.insert("global_decoder_params", cudaqx::heterogeneous_map{}); - std::unique_ptr trt_decoder; - try { - trt_decoder = decoder::get("trt_decoder", make_identity_h(2), params); - } catch (const std::exception &e) { - GTEST_SKIP() << "Failed to create mismatch TRT decoder: " << e.what(); - } - - // The mismatched global decoder forces an exception during inference. - // Inference failure is an error, not a non-converged decode: it must - // propagate rather than surface as fabricated converged=false results. - EXPECT_THROW(trt_decoder->decode_batch({{1.0, 0.0, 1.0}}), + // The engine emits three residual detectors, while make_identity_h(2) + // configures a child expecting two. Reject this before any decode call. + EXPECT_THROW((void)decoder::get("trt_decoder", make_identity_h(2), params), std::runtime_error); } @@ -747,15 +777,19 @@ TEST_F(TRTDecoderTest, CompositeGlobalDecoderCombinesLogicalFrame) { cudaqx::heterogeneous_map params; params.insert("onnx_load_path", *onnx_path); + params.insert("engine_output_format", + std::string("observables_and_residual_detectors")); params.insert("batch_size", std::size_t{2}); params.insert("use_cuda_graph", false); params.insert("global_decoder", std::string("single_error_lut")); params.insert("global_decoder_params", cudaqx::heterogeneous_map{}); - params.insert("O", O); std::unique_ptr trt_decoder; try { - trt_decoder = decoder::get("trt_decoder", H, params); + trt_decoder = decoder::get( + "trt_decoder", + decoder_inputs(sparse_binary_matrix(H), sparse_binary_matrix(O)), + decoder_output::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create composite TRT decoder: " << e.what(); } diff --git a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp index 09d8c44ee..2b41e34b9 100644 --- a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp +++ b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp @@ -86,16 +86,15 @@ reusable_decode_barrier &decode_barrier() { /// subsequent decode rendezvous with all configured instances before returning. class concurrency_test_decoder : public decoder { public: - concurrency_test_decoder(decoder_inputs inputs, + concurrency_test_decoder(decoder_inputs inputs, decoder_output default_output, const cudaqx::heterogeneous_map &) - : decoder(std::move(inputs)) { + : decoder(std::move(inputs), default_output) { std::cout << "QEC_CONCURRENCY_TEST_DECODER_CONSTRUCTED" << std::endl; - set_result_type(decode_result_type::decode_to_obs); } decoder_result decode(const std::vector &) override { - decoder_result result{true, - std::vector(get_num_observables(), 0.0)}; + decoder_result result{ + true, std::vector(get_num_observables(), 0.0), std::nullopt}; if (initialization_probe_) { initialization_probe_ = false; @@ -114,9 +113,11 @@ class concurrency_test_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( concurrency_test_decoder, static std::unique_ptr create( - decoder_inputs inputs, const cudaqx::heterogeneous_map ¶ms) { - return make_pcm_decoder(std::move(inputs), - params); + decoder_inputs inputs, std::optional output, + const cudaqx::heterogeneous_map ¶ms) { + return std::make_unique( + std::move(inputs), output.value_or(decoder_output::observables), + params); }) private: diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp index 71671c8f5..93f3aa9de 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp @@ -197,12 +197,11 @@ syndrome_capture_state g_capture; // with any --param key=value overrides applied on top. The registered decoder // schema drives type coercion for each override. static cudaqx::heterogeneous_map -decoder_args(const std::string &type, const std::vector &error_rates, +decoder_args(const std::string &type, const std::vector ¶ms = {}) { cudaqx::heterogeneous_map args; if (type == "nv-qldpc-decoder") { args.insert("use_sparsity", true); - args.insert("error_rate_vec", error_rates); args.insert("max_iterations", 50); args.insert("bp_method", 3); // min-sum + dmem (required for relay) args.insert("composition", 1); // sequential relay @@ -218,7 +217,6 @@ decoder_args(const std::string &type, const std::vector &error_rates, args.insert("gamma_dist", std::vector{0.1, 0.2}); } else if (type == "pymatching") { args.insert("merge_strategy", "smallest_weight"); - args.insert("error_rate_vec", error_rates); } else if (type == "multi_error_lut") { args.insert("lut_error_depth", 2); } else { @@ -326,6 +324,7 @@ build_multi_decoder_config(const cudaq::qec::decoder_inputs &inputs, dc.H_sparse = cudaq::qec::pcm_to_sparse_vec(dem.detector_error_matrix); dc.O_sparse = cudaq::qec::pcm_to_sparse_vec(dem.observables_flips_matrix); dc.D_sparse = d_sparse; + dc.error_rate_vec = dem.error_rates; if (opts.decoder_type == "sliding_window") { dc.type = "sliding_window"; @@ -337,15 +336,13 @@ build_multi_decoder_config(const cudaq::qec::decoder_inputs &inputs, sw_args.insert("straddle_start_round", false); sw_args.insert("straddle_end_round", true); sw_args.insert("inner_decoder_name", opts.sw_inner_decoder); - sw_args.insert("error_rate_vec", dem.error_rates); sw_args.insert("inner_decoder_params", - decoder_args(opts.sw_inner_decoder, dem.error_rates, - opts.decoder_params)); + decoder_args(opts.sw_inner_decoder, opts.decoder_params)); dc.decoder_custom_args = sw_args; } else { dc.type = opts.decoder_type; dc.decoder_custom_args = - decoder_args(opts.decoder_type, dem.error_rates, opts.decoder_params); + decoder_args(opts.decoder_type, opts.decoder_params); } multi_config.decoders.push_back(dc); diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml-mixed-dispatch-test.sh b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml-mixed-dispatch-test.sh index ce0410b23..beb33dd9f 100755 --- a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml-mixed-dispatch-test.sh +++ b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml-mixed-dispatch-test.sh @@ -99,9 +99,9 @@ decoders: H_sparse: $H_SPARSE O_sparse: $O_SPARSE D_sparse: $D_SPARSE + error_rate_vec: $ERR_VEC decoder_custom_args: use_sparsity: true - error_rate_vec: $ERR_VEC max_iterations: 50 clip_value: 200 bp_method: 3 diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp index d1ca7bfe8..4fd60c7c5 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp @@ -341,13 +341,13 @@ void save_dem_to_file( if (!D) throw std::runtime_error("decoder inputs are missing D"); config.D_sparse = cudaq::qec::d_sparse(*D); + config.error_rate_vec = edem.error_rates; if (decoder_type == "nv-qldpc-decoder") { cudaqx::heterogeneous_map nv_args; // Basic settings nv_args.insert("use_sparsity", true); - nv_args.insert("error_rate_vec", edem.error_rates); nv_args.insert("max_iterations", 50); if (use_relay_bp) { @@ -373,7 +373,6 @@ void save_dem_to_file( } else if (decoder_type == "pymatching") { cudaqx::heterogeneous_map pm_args; pm_args.insert("merge_strategy", "smallest_weight"); - pm_args.insert("error_rate_vec", edem.error_rates); config.decoder_custom_args = pm_args; } else if (decoder_type == "trt_decoder") { cudaqx::heterogeneous_map trt_args; @@ -383,6 +382,8 @@ void save_dem_to_file( trt_args.insert("batch_size", std::size_t{1}); trt_args.insert("use_cuda_graph", true); trt_args.insert("global_decoder", "pymatching"); + trt_args.insert("engine_output_format", + "observables_and_residual_detectors"); cudaqx::heterogeneous_map pm_args; pm_args.insert("merge_strategy", "smallest_weight"); @@ -417,13 +418,11 @@ void save_dem_to_file( config.syndrome_size = hRows; config.block_size = hCols; - pm_args.insert("error_rate_vec", priors); + config.error_rate_vec = priors; printf("trt+Ising: loaded Ising bundle '%s' (H %ux%u, O %u rows, " "priors %zu); D_sparse from D_sparse.txt (%zu detectors)\n", ising_artifacts_dir.c_str(), hRows, hCols, oRows, priors.size(), dRows); - } else { - pm_args.insert("error_rate_vec", edem.error_rates); } trt_args.insert("global_decoder_params", pm_args); diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-5-per-decoder-rings.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-5-per-decoder-rings.cpp index e0a4d2405..3e795d6ba 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-5-per-decoder-rings.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-5-per-decoder-rings.cpp @@ -104,9 +104,8 @@ config::multi_decoder_config make_config() { dc.H_sparse = identity; dc.O_sparse = identity; dc.D_sparse = identity; + dc.error_rate_vec = std::vector(kBlockSize, kUniformErrorRate); cudaqx::heterogeneous_map pm_args; - pm_args.insert("error_rate_vec", - std::vector(kBlockSize, kUniformErrorRate)); pm_args.insert("merge_strategy", "smallest_weight"); dc.decoder_custom_args = pm_args; multi.decoders.push_back(dc); diff --git a/libs/qec/unittests/realtime/qec_roce_decode_test/data/config_nv_qldpc_relay.yml b/libs/qec/unittests/realtime/qec_roce_decode_test/data/config_nv_qldpc_relay.yml index c9c489a4a..034766c4f 100644 --- a/libs/qec/unittests/realtime/qec_roce_decode_test/data/config_nv_qldpc_relay.yml +++ b/libs/qec/unittests/realtime/qec_roce_decode_test/data/config_nv_qldpc_relay.yml @@ -11,13 +11,13 @@ decoders: O_sparse: [ 0, 3, 6, 14, 15, 23, 26, 29, 37, 38, 46, 49, 52, 60, 61, -1 ] D_sparse: [ 0, 8, -1, 1, 9, -1, 2, 10, -1, 3, 11, -1, 4, 12, -1, 5, 13, -1, 6, 14, -1, 7, 15, -1, 8, 16, -1, 9, 17, -1, 10, 18, -1, 11, 19, -1, 12, 20, -1, 13, 21, -1, 14, 22, -1, 15, 23, -1, 16, 24, -1, 17, 25, -1, 18, 26, -1, 19, 27, -1, 20, 28, -1, 21, 29, -1, 22, 30, -1, 23, 31, -1 ] - decoder_custom_args: - use_sparsity: true - error_rate_vec: [ 0.00664444, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, + error_rate_vec: [ 0.00664444, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00333333, 0.00664444, 0.00333333, 0.00333333, 0.00664444, 0.00333333 ] + decoder_custom_args: + use_sparsity: true max_iterations: 50 bp_method: 3 gamma0: 0 diff --git a/libs/qec/unittests/realtime/test_decoding_server.cpp b/libs/qec/unittests/realtime/test_decoding_server.cpp index 2d21d1797..f158e33de 100644 --- a/libs/qec/unittests/realtime/test_decoding_server.cpp +++ b/libs/qec/unittests/realtime/test_decoding_server.cpp @@ -504,9 +504,9 @@ TEST(DecodingServerTwoProcess, TwoProcessHostDispatchDualDecoders) { << " H_sparse: [0, -1, 1, -1, 2, -1]\n" << " O_sparse: [0, -1, 1, -1, 2, -1]\n" << " D_sparse: [0, -1, 1, -1, 2, -1]\n" + << " error_rate_vec: [0.1, 0.1, 0.1]\n" << " decoder_custom_args:\n" - << " merge_strategy: smallest_weight\n" - << " error_rate_vec: [0.1, 0.1, 0.1]\n"; + << " merge_strategy: smallest_weight\n"; } } @@ -560,9 +560,9 @@ TEST(DecodingServerTwoProcess, TwoProcessHostDispatchYamlTransportSection) { << " H_sparse: [0, -1, 1, -1, 2, -1]\n" << " O_sparse: [0, -1, 1, -1, 2, -1]\n" << " D_sparse: [0, -1, 1, -1, 2, -1]\n" + << " error_rate_vec: [0.1, 0.1, 0.1]\n" << " decoder_custom_args:\n" - << " merge_strategy: smallest_weight\n" - << " error_rate_vec: [0.1, 0.1, 0.1]\n"; + << " merge_strategy: smallest_weight\n"; } } @@ -608,9 +608,9 @@ TEST(DecodingServerTwoProcess, TransportCliConflictsWithYamlSection) { << " H_sparse: [0, -1, 1, -1, 2, -1]\n" << " O_sparse: [0, -1, 1, -1, 2, -1]\n" << " D_sparse: [0, -1, 1, -1, 2, -1]\n" + << " error_rate_vec: [0.1, 0.1, 0.1]\n" << " decoder_custom_args:\n" - << " merge_strategy: smallest_weight\n" - << " error_rate_vec: [0.1, 0.1, 0.1]\n"; + << " merge_strategy: smallest_weight\n"; } ServerProcess server; @@ -651,9 +651,9 @@ TEST(DecodingServerTwoProcess, TwoProcessPerDecoderRings) { << " H_sparse: [0, -1, 1, -1, 2, -1]\n" << " O_sparse: [0, -1, 1, -1, 2, -1]\n" << " D_sparse: [0, -1, 1, -1, 2, -1]\n" + << " error_rate_vec: [0.1, 0.1, 0.1]\n" << " decoder_custom_args:\n" - << " merge_strategy: smallest_weight\n" - << " error_rate_vec: [0.1, 0.1, 0.1]\n"; + << " merge_strategy: smallest_weight\n"; } } diff --git a/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp b/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp index 679ec6d50..4a8f20a4c 100644 --- a/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp +++ b/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp @@ -284,19 +284,24 @@ int main(int argc, char *argv[]) { std::cout << "[Setup] H tensor: [" << H_full.shape()[0] << " x " << H_full.shape()[1] << "]\n"; - if (!stim.priors.empty() && stim.priors.size() == stim.H.ncols) - pm_params.insert("error_rate_vec", stim.priors); - + auto O = cudaq::qec::sparse_binary_matrix::from_csr( + 0, stim.H.ncols, std::vector{0}, {}); if (stim.O.loaded()) { obs_row = stim.O.row_dense(0); - pm_params.insert("O", stim.O.to_dense()); + O = cudaq::qec::sparse_binary_matrix(stim.O.to_dense()); } + auto rates = !stim.priors.empty() && stim.priors.size() == stim.H.ncols + ? stim.priors + : std::vector{}; + auto inputs = + cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H_full), + std::move(O), std::move(rates)); std::cout << "[Setup] Creating " << config.num_decode_workers << " PyMatching decoders (full H)...\n"; for (int i = 0; i < config.num_decode_workers; ++i) - decoder_ctx.decoders.push_back( - cudaq::qec::decoder::get("pymatching", H_full, pm_params)); + decoder_ctx.decoders.push_back(cudaq::qec::decoder::get( + "pymatching", inputs, cudaq::qec::decoder_output::errors, pm_params)); } else { // Fallback: per-slice decode with CUDA-Q surface code H_z std::cout << "[Setup] Creating PyMatching decoder (d=" << config.distance diff --git a/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp b/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp index 5a94035ab..8defa89a2 100644 --- a/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp +++ b/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp @@ -24,11 +24,10 @@ * test_trt_decoder_composite [d7|d13|d13_r104|d21|d21_r42|d31] * --data-dir DIR [--max-samples=N] [--onnx-path=FILE] * [--engine-save-path=FILE] [--batch-size=N] [--warmup=N] - * [--no-cuda-graph] [--no-raw-diagnostics] + * [--no-cuda-graph] * * test_trt_decoder_composite --data-dir DIR --config-yaml FILE * [--decoder-id=N] [--max-samples=N] [--warmup=N] - * [--no-raw-diagnostics] ******************************************************************************/ #include "predecoder_pipeline_common.h" @@ -65,7 +64,6 @@ struct DemoConfig { int warmup_count = 20; size_t batch_size = 1; bool use_cuda_graph = true; - bool raw_diagnostics = true; }; bool starts_with(const std::string &s, const std::string &prefix) { @@ -119,8 +117,6 @@ void print_usage(const char *argv0) { "1)\n" << " --use-cuda-graph=0|1 Enable CUDA graph executor (default 1)\n" << " --no-cuda-graph Shorthand for --use-cuda-graph=0\n" - << " --no-raw-diagnostics Skip extra TRT-only pass for predecoder " - "stats\n" << "\nPipelineConfig overrides are also accepted:\n" << " --distance=N --num-rounds=N --onnx-filename=FILE --label=NAME\n"; } @@ -167,8 +163,6 @@ DemoConfig parse_demo_config(int argc, char *argv[]) { parse_bool(value_after_equals(arg, "--use-cuda-graph=")); } else if (arg == "--no-cuda-graph") { cfg.use_cuda_graph = false; - } else if (arg == "--no-raw-diagnostics") { - cfg.raw_diagnostics = false; } } return cfg; @@ -192,41 +186,6 @@ size_t sparse_vec_rows(const std::vector &sparse) { return static_cast(std::count(sparse.begin(), sparse.end(), -1)); } -template -void copy_param_if_present(const cudaqx::heterogeneous_map &src, - cudaqx::heterogeneous_map &dst, - const std::string &key) { - if (src.contains(key)) - dst.insert(key, src.get(key)); -} - -bool build_raw_trt_params(const cudaqx::heterogeneous_map &trt_params, - cudaqx::heterogeneous_map &raw_params) { - bool has_model_source = false; - if (trt_params.contains("engine_load_path")) { - raw_params.insert("engine_load_path", - trt_params.get("engine_load_path")); - has_model_source = true; - } else if (trt_params.contains("engine_save_path") && - file_exists(trt_params.get("engine_save_path"))) { - raw_params.insert("engine_load_path", - trt_params.get("engine_save_path")); - has_model_source = true; - } else if (trt_params.contains("onnx_load_path")) { - raw_params.insert("onnx_load_path", - trt_params.get("onnx_load_path")); - copy_param_if_present(trt_params, raw_params, - "engine_save_path"); - has_model_source = true; - } - - copy_param_if_present(trt_params, raw_params, "batch_size"); - copy_param_if_present(trt_params, raw_params, "use_cuda_graph"); - copy_param_if_present(trt_params, raw_params, "memory_workspace"); - copy_param_if_present(trt_params, raw_params, "precision"); - return has_model_source; -} - std::vector sample_to_syndrome(const TestData &data, int sample_idx) { std::vector syndrome(data.num_detectors); @@ -236,14 +195,6 @@ std::vector sample_to_syndrome(const TestData &data, return syndrome; } -int count_input_nonzero(const TestData &data, int sample_idx) { - const int32_t *sample = data.sample(sample_idx); - int count = 0; - for (uint32_t i = 0; i < data.num_detectors; ++i) - count += (sample[i] != 0); - return count; -} - int bit_from_float(cudaq::qec::float_t v) { return v >= 0.5 ? 1 : 0; } double percentile(const std::vector &sorted, double p) { @@ -264,22 +215,10 @@ struct CompositeStats { int any_obs_mismatches = 0; int ground_truth_ones = 0; int result_ones = 0; - std::vector first_obs_pred; std::vector latencies_us; double wall_us = 0.0; }; -struct RawDiagnostics { - bool ran = false; - int decoded = 0; - int malformed = 0; - int predecoder_only_mismatches = 0; - int64_t total_input_nonzero = 0; - int64_t total_residual_nonzero = 0; - int64_t total_pre_l = 0; - int64_t total_pymatch_frame = 0; -}; - struct DecoderSetup { std::unique_ptr decoder; cudaqx::tensor H; @@ -301,7 +240,6 @@ CompositeStats run_composite_decoder(cudaq::qec::decoder &decoder, const TestData &test_data, int n_samples, size_t num_observables) { CompositeStats stats; - stats.first_obs_pred.assign(n_samples, -1); stats.latencies_us.reserve(n_samples); const size_t check_observables = @@ -334,7 +272,6 @@ CompositeStats run_composite_decoder(cudaq::qec::decoder &decoder, int pred = bit_from_float(result.result[obs]); int truth = test_data.observable(i, static_cast(obs)); if (obs == 0) { - stats.first_obs_pred[i] = pred; stats.ground_truth_ones += truth != 0; stats.result_ones += pred != 0; if (pred != truth) @@ -399,6 +336,8 @@ DecoderSetup create_decoder_from_yaml(const DemoConfig &demo_cfg) { setup.H = cudaq::qec::pcm_from_sparse_vec(decoder_config.H_sparse, decoder_config.syndrome_size, decoder_config.block_size); + auto O = cudaq::qec::pcm_from_sparse_vec( + decoder_config.O_sparse, setup.O_rows, decoder_config.block_size); setup.trt_params = cudaq::qec::decoding::host::prepare_decoder_params(decoder_config); if (setup.trt_params.contains("onnx_load_path")) @@ -411,8 +350,11 @@ DecoderSetup create_decoder_from_yaml(const DemoConfig &demo_cfg) { setup.engine_save_path = setup.trt_params.get("engine_load_path"); - setup.decoder = - cudaq::qec::decoder::get(decoder_config.type, setup.H, setup.trt_params); + setup.decoder = cudaq::qec::decoder::get( + decoder_config.type, + cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(setup.H), + cudaq::qec::sparse_binary_matrix(O)), + cudaq::qec::decoder_output::observables, setup.trt_params); return setup; } @@ -433,14 +375,12 @@ DecoderSetup create_decoder_from_cli(const PipelineConfig &config, cudaqx::heterogeneous_map pm_params; pm_params.insert("merge_strategy", std::string("smallest_weight")); - pm_params.insert("O", O); if (!stim.priors.empty()) { if (stim.priors.size() != stim.H.ncols) { throw std::runtime_error( "priors.bin has " + std::to_string(stim.priors.size()) + " entries, but H has " + std::to_string(stim.H.ncols) + " columns."); } - pm_params.insert("error_rate_vec", stim.priors); } DecoderSetup setup; @@ -460,51 +400,20 @@ DecoderSetup create_decoder_from_cli(const PipelineConfig &config, setup.trt_params.insert("engine_save_path", engine_save_path); setup.trt_params.insert("batch_size", demo_cfg.batch_size); setup.trt_params.insert("use_cuda_graph", demo_cfg.use_cuda_graph); + setup.trt_params.insert("engine_output_format", + "observables_and_residual_detectors"); setup.trt_params.insert("global_decoder", std::string("pymatching")); setup.trt_params.insert("global_decoder_params", pm_params); - setup.trt_params.insert("O", O); - setup.decoder = - cudaq::qec::decoder::get("trt_decoder", setup.H, setup.trt_params); + setup.decoder = cudaq::qec::decoder::get( + "trt_decoder", + cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H), + cudaq::qec::sparse_binary_matrix(O), + stim.priors), + cudaq::qec::decoder_output::observables, setup.trt_params); return setup; } -RawDiagnostics run_raw_diagnostics(cudaq::qec::decoder &raw_decoder, - const TestData &test_data, - const std::vector &final_pred, - int n_samples, size_t num_observables, - size_t residual_detectors) { - RawDiagnostics stats; - stats.ran = true; - - const size_t expected_output = num_observables + residual_detectors; - for (int i = 0; i < n_samples; ++i) { - auto syndrome = sample_to_syndrome(test_data, i); - auto raw = raw_decoder.decode(syndrome); - if (raw.result.size() < expected_output || num_observables == 0) { - stats.malformed++; - continue; - } - - stats.decoded++; - stats.total_input_nonzero += count_input_nonzero(test_data, i); - - int pre_l = bit_from_float(raw.result[0]); - int truth = test_data.observable(i, 0); - if (pre_l != truth) - stats.predecoder_only_mismatches++; - stats.total_pre_l += pre_l; - - if (i < static_cast(final_pred.size()) && final_pred[i] >= 0) - stats.total_pymatch_frame += (final_pred[i] ^ pre_l); - - for (size_t k = 0; k < residual_detectors; ++k) - stats.total_residual_nonzero += - bit_from_float(raw.result[num_observables + k]); - } - return stats; -} - } // namespace int main(int argc, char *argv[]) { @@ -600,10 +509,9 @@ int main(int argc, char *argv[]) { << " row(s).\n"; return 1; } - if (setup.decoder->get_result_type() != - cudaq::qec::decoder::decode_result_type::decode_to_obs) { - std::cerr << "ERROR: composite trt_decoder must report decode_to_obs " - "when constructed with O.\n"; + if (setup.decoder->get_default_output() != + cudaq::qec::decoder_output::observables) { + std::cerr << "ERROR: composite trt_decoder must use observable output.\n"; return 1; } @@ -645,31 +553,6 @@ int main(int argc, char *argv[]) { CompositeStats stats = run_composite_decoder(*setup.decoder, test_data, n_samples, setup.O_rows); - RawDiagnostics raw_stats; - if (demo_cfg.raw_diagnostics) { - cudaqx::heterogeneous_map raw_params; - if (!build_raw_trt_params(setup.trt_params, raw_params)) { - std::cerr << "[WARN] Raw TRT diagnostics skipped: no raw TRT model " - "source is available.\n"; - } else { - if (setup.trt_params.contains("engine_save_path") && - !file_exists(setup.trt_params.get("engine_save_path"))) { - std::cerr << "[WARN] Engine file was not found after composite init; " - "raw diagnostics will rebuild from ONNX.\n"; - } - - try { - auto raw_decoder = - cudaq::qec::decoder::get("trt_decoder", setup.H, raw_params); - raw_stats = - run_raw_diagnostics(*raw_decoder, test_data, stats.first_obs_pred, - n_samples, setup.O_rows, setup.H_rows); - } catch (const std::exception &e) { - std::cerr << "[WARN] Raw TRT diagnostics skipped: " << e.what() << "\n"; - } - } - } - int warmup = std::min(demo_cfg.warmup_count, static_cast(stats.latencies_us.size())); std::vector steady_latencies(stats.latencies_us.begin() + warmup, @@ -745,48 +628,6 @@ int main(int argc, char *argv[]) { std::cout << " Ground truth ones: " << stats.ground_truth_ones << "/" << stats.decoded << "\n"; - if (raw_stats.ran && raw_stats.decoded > 0) { - double pred_ler = - static_cast(raw_stats.predecoder_only_mismatches) / - static_cast(raw_stats.decoded); - double avg_input_nz = static_cast(raw_stats.total_input_nonzero) / - static_cast(raw_stats.decoded); - double avg_residual_nz = - static_cast(raw_stats.total_residual_nonzero) / - static_cast(raw_stats.decoded); - double input_density = avg_input_nz / test_data.num_detectors; - double residual_density = avg_residual_nz / setup.H_rows; - double reduction = - input_density > 0.0 ? (1.0 - residual_density / input_density) : 0.0; - - std::cout - << " " - "---------------------------------------------------------------\n"; - std::cout << " Raw TRT diagnostics (" << raw_stats.decoded << " samples, " - << raw_stats.malformed << " malformed):\n"; - std::cout << " Predecoder-only mismatches: " - << raw_stats.predecoder_only_mismatches << " LER: " << pred_ler - << "\n"; - std::cout << std::setprecision(3); - std::cout << " Avg logical_pred: " - << static_cast(raw_stats.total_pre_l) / raw_stats.decoded - << "\n"; - std::cout << " Avg PyMatching frame flip: " - << static_cast(raw_stats.total_pymatch_frame) / - raw_stats.decoded - << "\n"; - std::cout << std::setprecision(1); - std::cout << " Input density: " << avg_input_nz << " / " - << test_data.num_detectors << " (" << std::setprecision(4) - << input_density << ")\n"; - std::cout << std::setprecision(1); - std::cout << " Residual density: " << avg_residual_nz << " / " - << setup.H_rows << " (" << std::setprecision(4) - << residual_density << ")\n"; - std::cout << std::setprecision(1); - std::cout << " Reduction: " << reduction * 100.0 << "%\n"; - } - std::cout << "================================================================\n"; std::cout << "Done.\n"; diff --git a/libs/qec/unittests/test_decoders.cpp b/libs/qec/unittests/test_decoders.cpp index c840fc464..79a899e49 100644 --- a/libs/qec/unittests/test_decoders.cpp +++ b/libs/qec/unittests/test_decoders.cpp @@ -25,7 +25,7 @@ namespace { class decoder_inputs_probe final : public cudaq::qec::decoder { public: explicit decoder_inputs_probe(cudaq::qec::decoder_inputs inputs) - : decoder(std::move(inputs)) {} + : decoder(std::move(inputs), cudaq::qec::decoder_output::errors) {} cudaq::qec::decoder_result decode(const std::vector &) override { @@ -36,6 +36,32 @@ class decoder_inputs_probe final : public cudaq::qec::decoder { std::size_t configured_measurement_rows() const { return D_sparse.size(); } }; +class observable_output_probe final : public cudaq::qec::decoder { +public: + observable_output_probe(cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_output default_output, + const cudaqx::heterogeneous_map &) + : decoder(std::move(inputs), default_output) {} + + cudaq::qec::decoder_result + decode(const std::vector &syndrome) override { + return {true, syndrome, std::nullopt}; + } + + CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( + observable_output_probe, + static std::unique_ptr create( + cudaq::qec::decoder_inputs inputs, + std::optional output, + const cudaqx::heterogeneous_map ¶ms) { + return std::make_unique( + std::move(inputs), + output.value_or(cudaq::qec::decoder_output::observables), params); + }) +}; + +CUDAQ_EXT_PT_REGISTER_TYPE(observable_output_probe) + class ScopedEnv { public: ScopedEnv(const char *name, const char *value) : name(name) { @@ -155,6 +181,65 @@ TEST(DecoderInputs, RejectsInconsistentDimensions) { std::invalid_argument); } +TEST(DecoderInputs, ChildDerivationPreservesOrRecordsProvenance) { + auto inputs = cudaq::qec::decoder_inputs::from_stim_dem( + "error(0.1) D0 L0\n", + cudaq::qec::sparse_binary_matrix::from_nested_csr(1, 2, {{0, 1}})); + + auto basis_preserving = inputs.without_measurement_to_detectors(); + EXPECT_TRUE(basis_preserving.has_stim_dem()); + EXPECT_EQ(basis_preserving.stim_dem(), inputs.stim_dem()); + EXPECT_EQ(basis_preserving.measurement_to_detectors(), nullptr); + EXPECT_FALSE(basis_preserving.provenance_loss_reason().has_value()); + + auto child_H = cudaq::qec::sparse_binary_matrix::from_nested_csc(1, 1, {{0}}); + auto child_O = cudaq::qec::sparse_binary_matrix::from_nested_csr(0, 1, {}); + auto basis_changed = inputs.derive_with_changed_basis( + std::move(child_H), std::move(child_O), {0.1}, std::nullopt, + "test changes the detector and error basis"); + EXPECT_FALSE(basis_changed.has_stim_dem()); + ASSERT_TRUE(basis_changed.provenance_loss_reason().has_value()); + EXPECT_EQ(*basis_changed.provenance_loss_reason(), + "test changes the detector and error basis"); +} + +TEST(DecoderOutputContract, OutputFormIsImmutablePerInstance) { + auto H = cudaq::qec::sparse_binary_matrix::from_nested_csc( + 2, 2, std::vector>{{0}, {1}}); + auto O = cudaq::qec::sparse_binary_matrix::from_nested_csr( + 1, 2, std::vector>{{0}}); + auto decoder = cudaq::qec::get_decoder( + "single_error_lut", + cudaq::qec::decoder_inputs(std::move(H), std::move(O)), + cudaq::qec::decoder_output::observables); + + EXPECT_EQ(decoder->get_default_output(), + cudaq::qec::decoder_output::observables); + + const std::vector syndrome{1.0, 0.0}; + auto observables = decoder->decode(syndrome); + EXPECT_EQ(observables.result, std::vector({1.0})); + + auto error_decoder = cudaq::qec::get_decoder( + "single_error_lut", + cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix::from_nested_csc( + 2, 2, std::vector>{{0}, {1}}), + cudaq::qec::sparse_binary_matrix::from_nested_csr( + 1, 2, std::vector>{{0}})), + cudaq::qec::decoder_output::errors); + auto errors = error_decoder->decode(syndrome); + EXPECT_EQ(errors.result, std::vector({1.0, 0.0})); +} + +TEST(DecoderOutputContract, ModelDataIsRejectedInCustomParameters) { + auto H = cudaq::qec::sparse_binary_matrix::from_nested_csc(1, 1, {{0}}); + cudaqx::heterogeneous_map params; + params.insert("O", cudaqx::tensor({1, 1})); + EXPECT_THROW(cudaq::qec::get_decoder("single_error_lut", H, params), + std::runtime_error); +} + TEST(DecoderUtils, CovertHardToSoft) { std::vector in = {1, 0, 1, 1}; std::vector out; @@ -610,14 +695,19 @@ void SlidingWindowDecoderTest(bool run_batched, std::size_t n_rounds, sliding_window_params.insert("step_size", step_size); sliding_window_params.insert("num_syndromes_per_round", n_syndromes_per_round); - sliding_window_params.insert("error_rate_vec", simplified_weights); sliding_window_params.insert("inner_decoder_name", inner_decoder_name); cudaqx::heterogeneous_map inner_decoder_params; sliding_window_params.insert("inner_decoder_params", inner_decoder_params); + auto sliding_H = cudaq::qec::sparse_binary_matrix(simplified_pcm); + auto sliding_O = cudaq::qec::sparse_binary_matrix::from_csr( + 0, sliding_H.num_cols(), {0}, {}); auto sliding_window_decoder = cudaq::qec::decoder::get( - "sliding_window", simplified_pcm, sliding_window_params); + "sliding_window", + cudaq::qec::decoder_inputs(std::move(sliding_H), std::move(sliding_O), + simplified_weights), + sliding_window_params); // Create some random syndromes. const int num_syndromes = 1000; @@ -762,11 +852,16 @@ TEST(SlidingWindowDecoder, EmptyBatchReturnsNoResults) { params.insert("window_size", std::size_t{2}); params.insert("step_size", std::size_t{1}); params.insert("num_syndromes_per_round", n_syndromes_per_round); - params.insert("error_rate_vec", std::vector(pcm.shape()[1], 0.1)); params.insert("inner_decoder_name", std::string("single_error_lut")); params.insert("inner_decoder_params", cudaqx::heterogeneous_map{}); - auto decoder = cudaq::qec::decoder::get("sliding_window", pcm, params); + auto H = cudaq::qec::sparse_binary_matrix(pcm); + auto O = cudaq::qec::sparse_binary_matrix::from_csr(0, H.num_cols(), {0}, {}); + auto decoder = cudaq::qec::decoder::get( + "sliding_window", + cudaq::qec::decoder_inputs(std::move(H), std::move(O), + std::vector(pcm.shape()[1], 0.1)), + params); ASSERT_NE(decoder, nullptr); EXPECT_TRUE(decoder->decode_batch({}).empty()); } @@ -784,11 +879,16 @@ TEST(SlidingWindowDecoder, PerRoundStreamingUsesRollingWindowUnwrap) { params.insert("window_size", std::size_t{2}); params.insert("step_size", std::size_t{1}); params.insert("num_syndromes_per_round", n_syndromes_per_round); - params.insert("error_rate_vec", std::vector(pcm.shape()[1], 0.1)); params.insert("inner_decoder_name", std::string("single_error_lut")); params.insert("inner_decoder_params", cudaqx::heterogeneous_map{}); - auto decoder = cudaq::qec::decoder::get("sliding_window", pcm, params); + auto H = cudaq::qec::sparse_binary_matrix(pcm); + auto O = cudaq::qec::sparse_binary_matrix::from_csr(0, H.num_cols(), {0}, {}); + auto decoder = cudaq::qec::decoder::get( + "sliding_window", + cudaq::qec::decoder_inputs(std::move(H), std::move(O), + std::vector(pcm.shape()[1], 0.1)), + params); ASSERT_NE(decoder, nullptr); cudaq::qec::decoder_result last_result; @@ -1198,24 +1298,25 @@ TEST(StimDemGetDecoder, DecomposeErrorsXorCancelled) { } // --------------------------------------------------------------------------- -// Tests for enqueue_syndrome decode_result_type routing +// Tests for enqueue_syndrome tagged-result routing // --------------------------------------------------------------------------- -// Verify that enqueue_syndrome uses decode() output directly as corrections -// when get_result_type() == decode_to_obs, bypassing the O_sparse projection. +// Verify that an observable-output decoder session uses its result directly. TEST(EnqueueSyndrome, ObsFrameDecoderUsesResultDirectly) { // H: 2 syndrome measurements, 4 physical errors cudaqx::tensor H_tensor({2, 4}); H_tensor.at({0, 0}) = 1; H_tensor.at({1, 1}) = 1; - cudaqx::heterogeneous_map params; - params.insert("decode_to_obs", true); - auto dec = cudaq::qec::decoder::get("sample_decoder", H_tensor, params); + auto H = cudaq::qec::sparse_binary_matrix(H_tensor); + auto O = cudaq::qec::sparse_binary_matrix::from_nested_csr( + 2, 4, std::vector>{{0}, {1}}); + auto dec = cudaq::qec::decoder::get( + "observable_output_probe", + cudaq::qec::decoder_inputs(std::move(H), std::move(O)), + cudaq::qec::decoder_output::observables); // D_sparse maps the two enqueued syndrome bits directly to two detector bits. dec->set_D_sparse(std::vector>{{0}, {1}}); - // Two observables; cols 0/1 are within block_size=4 for validation only. - dec->set_O_sparse(std::vector>{{0}, {1}}); bool did_decode = dec->enqueue_syndrome(std::vector{1, 0}); EXPECT_TRUE(did_decode); @@ -1231,12 +1332,15 @@ TEST(EnqueueSyndrome, ObsFrameMultiShotAccumulation) { cudaqx::tensor H_tensor({2, 4}); H_tensor.at({0, 0}) = 1; H_tensor.at({1, 1}) = 1; - cudaqx::heterogeneous_map params; - params.insert("decode_to_obs", true); - auto dec = cudaq::qec::decoder::get("sample_decoder", H_tensor, params); + auto H = cudaq::qec::sparse_binary_matrix(H_tensor); + auto O = cudaq::qec::sparse_binary_matrix::from_nested_csr( + 2, 4, std::vector>{{0}, {1}}); + auto dec = cudaq::qec::decoder::get( + "observable_output_probe", + cudaq::qec::decoder_inputs(std::move(H), std::move(O)), + cudaq::qec::decoder_output::observables); dec->set_D_sparse(std::vector>{{0}, {1}}); - dec->set_O_sparse(std::vector>{{0}, {1}}); // Shot 1: obs[0]=1, obs[1]=0 -> corrections become [1, 0] EXPECT_TRUE(dec->enqueue_syndrome(std::vector{1, 0})); @@ -1257,22 +1361,22 @@ TEST(EnqueueSyndrome, ObsFrameMultiShotAccumulation) { EXPECT_EQ(corr[1], 0u); } -// Verify that a result size mismatch against num_observables throws for -// decode_to_obs decoders. +// Verify that a tagged observable result is checked against num_observables. TEST(EnqueueSyndrome, ObsFrameSizeMismatchThrows) { cudaqx::tensor H_tensor({3, 4}); H_tensor.at({0, 0}) = 1; H_tensor.at({1, 1}) = 1; H_tensor.at({2, 2}) = 1; - cudaqx::heterogeneous_map params; - params.insert("decode_to_obs", true); - auto dec = cudaq::qec::decoder::get("sample_decoder", H_tensor, params); + auto H = cudaq::qec::sparse_binary_matrix(H_tensor); + auto O = cudaq::qec::sparse_binary_matrix::from_nested_csr( + 2, 4, std::vector>{{0}, {1}}); + auto dec = cudaq::qec::decoder::get( + "observable_output_probe", + cudaq::qec::decoder_inputs(std::move(H), std::move(O)), + cudaq::qec::decoder_output::observables); dec->set_D_sparse(std::vector>{{0}, {1}, {2}}); - dec->set_O_sparse( - std::vector>{{0}, {1}}); // 2 observables - - // sample_decoder returns all three detector bits in decode_to_obs mode. + // sample_decoder returns all three detector bits as observables. EXPECT_THROW(dec->enqueue_syndrome(std::vector{1, 0, 1}), std::runtime_error); } @@ -1292,13 +1396,18 @@ TEST(SlidingWindowDecoder, BaseStreamingCopiesFirstRoundDetectors) { params.insert("window_size", std::size_t{2}); params.insert("step_size", std::size_t{1}); params.insert("num_syndromes_per_round", n_syndromes_per_round); - params.insert("error_rate_vec", std::vector(pcm.shape()[1], 0.1)); params.insert("inner_decoder_name", std::string("single_error_lut")); params.insert("inner_decoder_params", cudaqx::heterogeneous_map{}); - auto decoder = cudaq::qec::decoder::get("sliding_window", pcm, params); + auto H = cudaq::qec::sparse_binary_matrix(pcm); + auto O = + cudaq::qec::sparse_binary_matrix::from_csr(1, H.num_cols(), {0, 1}, {0}); + auto decoder = cudaq::qec::decoder::get( + "sliding_window", + cudaq::qec::decoder_inputs(std::move(H), std::move(O), + std::vector(pcm.shape()[1], 0.1)), + cudaq::qec::decoder_output::observables, params); ASSERT_NE(decoder, nullptr); - decoder->set_O_sparse(std::vector>{{0}}); decoder->set_D_sparse(std::vector>{{0}, {1}}); std::vector first_round = {1, 0}; @@ -1316,7 +1425,6 @@ TEST(SlidingWindowDecoder, PreparePcmRejectsBadBoundaryLayout) { p.insert("step_size", std::size_t{1}); p.insert("num_syndromes_per_round", S); p.insert("num_boundary_syndromes", B); - p.insert("error_rate_vec", std::vector{0.1, 0.1}); p.insert("inner_decoder_name", std::string("single_error_lut")); p.insert("inner_decoder_params", cudaqx::heterogeneous_map{}); return p; @@ -1328,7 +1436,14 @@ TEST(SlidingWindowDecoder, PreparePcmRejectsBadBoundaryLayout) { const cudaqx::heterogeneous_map &p, const std::string &needle) { try { - cudaq::qec::decoder::get("sliding_window", H, p); + auto sparse_H = cudaq::qec::sparse_binary_matrix(H); + auto O = cudaq::qec::sparse_binary_matrix::from_csr( + 0, sparse_H.num_cols(), {0}, {}); + cudaq::qec::decoder::get( + "sliding_window", + cudaq::qec::decoder_inputs(std::move(sparse_H), std::move(O), + std::vector(H.shape()[1], 0.1)), + p); FAIL() << "expected sliding_window construction to throw"; } catch (const std::invalid_argument &e) { EXPECT_NE(std::string(e.what()).find(needle), std::string::npos) @@ -1378,8 +1493,9 @@ class ScopedDeviceRestore { class strict_keys_decoder : public cudaq::qec::decoder { public: strict_keys_decoder(cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_output default_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs)) { + : decoder(std::move(inputs), default_output) { auto invalid = cudaq::qec::validate_config_parameters(params, {"decode_to_obs"}); if (!invalid.empty()) @@ -1396,9 +1512,11 @@ class strict_keys_decoder : public cudaq::qec::decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( strict_keys_decoder, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder( - std::move(inputs), params); + return std::make_unique( + std::move(inputs), + output.value_or(cudaq::qec::decoder_output::errors), params); }) }; CUDAQ_EXT_PT_REGISTER_TYPE(strict_keys_decoder) @@ -1414,8 +1532,9 @@ class device_recording_decoder : public cudaq::qec::decoder { public: std::atomic last_decode_device{-2}; device_recording_decoder(cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_output default_output, const cudaqx::heterogeneous_map &) - : decoder(std::move(inputs)) {} + : decoder(std::move(inputs), default_output) {} cudaq::qec::decoder_result decode(const std::vector &) override { int dev = -1; @@ -1431,9 +1550,11 @@ class device_recording_decoder : public cudaq::qec::decoder { device_recording_decoder, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder( - std::move(inputs), params); + return std::make_unique( + std::move(inputs), + output.value_or(cudaq::qec::decoder_output::errors), params); }) }; CUDAQ_EXT_PT_REGISTER_TYPE(device_recording_decoder) diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index cc099a859..829fddb75 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -162,6 +162,7 @@ create_test_decoder_config_nv_qldpc(int id) { cudaq::qec::decoding::config::decoder_config config = create_test_empty_decoder_config(id); config.type = "nv-qldpc-decoder"; + config.error_rate_vec = std::vector(config.block_size, 0.1); cudaqx::heterogeneous_map nv_args; nv_args.insert("use_sparsity", true); @@ -169,7 +170,6 @@ create_test_decoder_config_nv_qldpc(int id) { nv_args.insert("use_osd", true); nv_args.insert("osd_order", 60); nv_args.insert("osd_method", 3); - nv_args.insert("error_rate_vec", std::vector(config.block_size, 0.1)); nv_args.insert("n_threads", 128); nv_args.insert("bp_batch_size", 1); nv_args.insert("osd_batch_size", 16); @@ -355,6 +355,7 @@ create_test_decoder_config_trt(int id) { O.at({0, 1}) = 1; O.at({1, 3}) = 1; config.O_sparse = cudaq::qec::pcm_to_sparse_vec(O); + config.error_rate_vec = std::vector(config.block_size, 0.1); cudaqx::heterogeneous_map trt_args; trt_args.insert("onnx_load_path", "/tmp/predecoder.onnx"); @@ -363,11 +364,10 @@ create_test_decoder_config_trt(int id) { trt_args.insert("memory_workspace", std::size_t{1ULL << 20}); trt_args.insert("batch_size", std::size_t{4}); trt_args.insert("use_cuda_graph", false); + trt_args.insert("engine_output_format", "observables_and_residual_detectors"); trt_args.insert("global_decoder", "pymatching"); cudaqx::heterogeneous_map pymatching_params; pymatching_params.insert("merge_strategy", "smallest_weight"); - pymatching_params.insert("error_rate_vec", - std::vector(config.block_size, 0.1)); trt_args.insert("global_decoder_params", pymatching_params); config.decoder_custom_args = trt_args; @@ -399,31 +399,27 @@ TEST(DecoderYAMLTest, TrtDecoderConfigToHeterogeneousMap) { EXPECT_EQ(params.get("memory_workspace"), 1ULL << 20); EXPECT_EQ(params.get("batch_size"), 4u); EXPECT_FALSE(params.get("use_cuda_graph")); + EXPECT_EQ(params.get("engine_output_format"), + "observables_and_residual_detectors"); EXPECT_EQ(params.get("global_decoder"), "pymatching"); auto global_params = params.get("global_decoder_params"); EXPECT_EQ(global_params.get("merge_strategy"), "smallest_weight"); - EXPECT_EQ(global_params.get>("error_rate_vec").size(), - config.block_size); + EXPECT_FALSE(global_params.contains("error_rate_vec")); } -TEST(DecoderYAMLTest, TrtDecoderRealtimeParamsIncludeObservableMatrix) { +TEST(DecoderYAMLTest, RealtimeParamsDoNotInjectObservableMatrix) { auto config = create_test_decoder_config_trt(0); auto params = cudaq::qec::decoding::host::prepare_decoder_params(config); - auto O = params.get>("O"); - EXPECT_EQ(O.shape()[0], 2u); - EXPECT_EQ(O.shape()[1], config.block_size); - EXPECT_EQ(O.at({0, 1}), 1); - EXPECT_EQ(O.at({1, 3}), 1); + EXPECT_FALSE(params.contains("O")); + EXPECT_FALSE(params.contains("error_rate_vec")); auto global_params = params.get("global_decoder_params"); - auto global_O = global_params.get>("O"); - EXPECT_EQ(global_O.shape()[0], 2u); - EXPECT_EQ(global_O.shape()[1], config.block_size); + EXPECT_FALSE(global_params.contains("O")); } TEST(DecoderYAMLTest, TrtDecoderEmptyGlobalDecoderParams) { @@ -457,7 +453,7 @@ TEST(DecoderYAMLTest, TrtDecoderEmptyGlobalDecoderParams) { params = cudaq::qec::decoding::host::prepare_decoder_params(config); EXPECT_TRUE(params.contains("global_decoder_params")); - EXPECT_TRUE(params.contains("O")); + EXPECT_FALSE(params.contains("O")); config.O_sparse.clear(); params = cudaq::qec::decoding::host::prepare_decoder_params(config); @@ -480,6 +476,7 @@ TEST(DecoderYAMLTest, TrtDecoderDefaultGlobalDecoderParams) { O_sparse: [] D_sparse: [0, -1] decoder_custom_args: + engine_output_format: residual_detectors global_decoder: chromobius )"; auto parsed = @@ -513,6 +510,7 @@ TEST(DecoderYAMLTest, UnknownTrtGlobalDecoderParamsThrow) { O_sparse: [] D_sparse: [0, -1] decoder_custom_args: + engine_output_format: residual_detectors global_decoder: my_plugin global_decoder_params: {} )"; @@ -533,6 +531,7 @@ TEST(DecoderYAMLTest, UnknownTrtGlobalDecoderParamsThrow) { O_sparse: [] D_sparse: [0, -1] decoder_custom_args: + engine_output_format: residual_detectors global_decoder: my_plugin )"; auto parsed = @@ -556,6 +555,7 @@ TEST(DecoderYAMLTest, TrtDecoderParamsWithoutDecoderThrows) { O_sparse: [] D_sparse: [0, -1] decoder_custom_args: + engine_output_format: errors onnx_load_path: /tmp/predecoder.onnx global_decoder_params: merge_strategy: smallest_weight @@ -592,13 +592,13 @@ TEST(DecoderYAMLTest, SlidingWindowDecoder) { cudaq::qec::pcm_to_sparse_vec(cudaqx::tensor({2, n_cols})); config.D_sparse = cudaq::qec::generate_timelike_sparse_detector_matrix( config.syndrome_size, 2, /*include_first_round=*/false); + config.error_rate_vec = std::vector(config.block_size, 0.1); cudaqx::heterogeneous_map sw_args; sw_args.insert("window_size", std::size_t{1}); sw_args.insert("step_size", std::size_t{1}); sw_args.insert("num_syndromes_per_round", n_syndromes_per_round); sw_args.insert("straddle_start_round", false); sw_args.insert("straddle_end_round", true); - sw_args.insert("error_rate_vec", std::vector(config.block_size, 0.1)); // Inner decoder config sw_args.insert("inner_decoder_name", "multi_error_lut"); @@ -626,6 +626,7 @@ TEST(DecoderYAMLTest, TrtDecoderConfigRoundTripWithoutInstantiation) { trt_args.insert("engine_save_path", "/tmp/saved.engine"); trt_args.insert("precision", "best"); trt_args.insert("memory_workspace", std::size_t{1 << 20}); + trt_args.insert("engine_output_format", "errors"); config.decoder_custom_args = trt_args; multi_config.decoders.push_back(config); @@ -647,6 +648,7 @@ TEST(DecoderYAMLTest, SlidingWindowInnerDecoderVariantRoundTrips) { config.O_sparse = cudaq::qec::pcm_to_sparse_vec(O); config.D_sparse = cudaq::qec::generate_timelike_sparse_detector_matrix( config.syndrome_size, 2, /*include_first_round=*/false); + config.error_rate_vec = std::vector(config.block_size, 0.1); config.decoder_custom_args = sw_args; multi_config.decoders.push_back(config); test_decoder_yaml_roundtrip(multi_config); @@ -657,7 +659,6 @@ TEST(DecoderYAMLTest, SlidingWindowInnerDecoderVariantRoundTrips) { single_lut_sw.insert("step_size", std::size_t{1}); single_lut_sw.insert("num_syndromes_per_round", std::size_t{2}); single_lut_sw.insert("num_boundary_syndromes", std::size_t{1}); - single_lut_sw.insert("error_rate_vec", std::vector(6, 0.1)); single_lut_sw.insert("inner_decoder_name", "single_error_lut"); check_roundtrip(single_lut_sw); @@ -666,7 +667,6 @@ TEST(DecoderYAMLTest, SlidingWindowInnerDecoderVariantRoundTrips) { nv_sw.insert("inner_decoder_name", "nv-qldpc-decoder"); cudaqx::heterogeneous_map nv_inner; nv_inner.insert("max_iterations", 5); - nv_inner.insert("error_rate_vec", std::vector(6, 0.1)); nv_sw.insert("inner_decoder_params", nv_inner); check_roundtrip(nv_sw); } @@ -1090,10 +1090,10 @@ TEST(DecoderSchemaTest, SlidingWindowValidateHookRejectsBadWindowing) { H_sparse: [0, -1, 1, -1] O_sparse: [0, -1, 1, -1] D_sparse: [0, -1, 1, -1] + error_rate_vec: [0.01, 0.01] decoder_custom_args: window_size: WINDOW step_size: STEP - error_rate_vec: [0.01, 0.01] inner_decoder_name: single_error_lut )"; auto make_yaml = [&](const std::string &window, const std::string &step) { @@ -1113,10 +1113,10 @@ TEST(DecoderSchemaTest, SlidingWindowValidateHookRejectsBadWindowing) { decoder_config config; config.type = "sliding_window"; + config.error_rate_vec = {0.01, 0.01}; cudaqx::heterogeneous_map args; args.insert("window_size", std::size_t(2)); args.insert("step_size", std::size_t(4)); - args.insert("error_rate_vec", std::vector{0.01, 0.01}); args.insert("inner_decoder_name", std::string("single_error_lut")); config.decoder_custom_args = args; EXPECT_THROW(config.validate_custom_args(), std::runtime_error); @@ -1135,10 +1135,6 @@ TEST(DecoderSchemaTest, SlidingWindowValidateHookRejectsBadWindowing) { args.insert("num_boundary_syndromes", std::size_t(2)); config.decoder_custom_args = args; EXPECT_NO_THROW(config.validate_custom_args()); - - args.insert("error_rate_vec", std::vector{}); - config.decoder_custom_args = args; - EXPECT_THROW(config.validate_custom_args(), std::runtime_error); } TEST(DecoderSchemaTest, JsonSchemaExportReflectsRegistry) { @@ -1273,6 +1269,7 @@ TEST(DecoderYAMLTest, TrtFirstEmissionMaterializesGlobalDecoderParams) { auto config = create_test_empty_decoder_config(0); config.type = "trt_decoder"; cudaqx::heterogeneous_map args; + args.insert("engine_output_format", std::string("residual_detectors")); args.insert("global_decoder", std::string("pymatching")); config.decoder_custom_args = args; diff --git a/libs/qec/unittests/test_decoding_server_core.cpp b/libs/qec/unittests/test_decoding_server_core.cpp index 41b7acf0d..7ce8136a6 100644 --- a/libs/qec/unittests/test_decoding_server_core.cpp +++ b/libs/qec/unittests/test_decoding_server_core.cpp @@ -42,9 +42,10 @@ class ControlledDecoder final : public cudaq::qec::decoder { public: ControlledDecoder() : decoder(cudaq::qec::decoder_inputs( - cudaq::qec::sparse_binary_matrix::from_csr( - /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, - /*col_indices=*/{0}))) { + cudaq::qec::sparse_binary_matrix::from_csr( + /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, + /*col_indices=*/{0})), + cudaq::qec::decoder_output::errors) { set_O_sparse(std::vector>{{0}}); // One detector is the parity of two incoming measurement bits, so a decode // completes only after two one-bit enqueue calls. @@ -302,9 +303,10 @@ class MispinnedDecoder final : public cudaq::qec::decoder { public: MispinnedDecoder() : decoder(cudaq::qec::decoder_inputs( - cudaq::qec::sparse_binary_matrix::from_csr( - /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, - /*col_indices=*/{0}))) { + cudaq::qec::sparse_binary_matrix::from_csr( + /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, + /*col_indices=*/{0})), + cudaq::qec::decoder_output::errors) { set_O_sparse(std::vector>{{0}}); set_D_sparse(std::vector>{{0, 1}}); cuda_device_id_ = 1 << 20; From 45c26c132fc66c44151503b72c135a0b73810af6 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Mon, 3 Aug 2026 10:12:56 -0700 Subject: [PATCH 03/24] Supply the measurement-to-detector map at decoder construction The realtime path built decoder_inputs from H, O and error rates while leaving the measurement-to-detector map out, then delivered it separately through set_D_sparse. D was therefore stored twice: carried but unset on the construction inputs, and set on the decoder itself. Build D once, in GF(2)-canonical form, and hand the same matrix to both. A repeated index in a row cancels under the realtime detector XOR, so canonicalizing puts that rule in the model rather than leaving each consumer to interpret duplicates its own way. The measurement width is taken before cancellation, so a measurement referenced only by a cancelling pair still counts toward the per-decode width. set_D_sparse still performs the realtime buffer allocation, so this does not yet remove the second path; it makes the construction inputs complete. The divergence this closes is invisible end to end, since the realtime base drives decoding; it is observable only to a plugin reading its own construction inputs. The regression test therefore captures D through a decoder built by the realtime factory. Signed-off-by: Melody Ren --- libs/qec/lib/realtime/realtime_decoding.cpp | 38 ++++++++- libs/qec/unittests/test_decoders_yaml.cpp | 95 +++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index 7a983f3e3..cd076ca2e 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -186,16 +186,46 @@ std::unique_ptr create_realtime_decoder( decoder_config.O_sparse.end(), -1); auto observable_matrix = cudaq::qec::pcm_from_sparse_vec( decoder_config.O_sparse, num_observables, decoder_config.block_size); - cudaq::qec::decoder_inputs inputs(std::move(pcm), - std::move(observable_matrix), - decoder_config.error_rate_vec); + // D belongs to the construction inputs like H and O. Build it once and hand + // the same matrix to both the inputs and the realtime setter, so the two + // cannot describe different matrices. Canonicalize rather than rasterize: a + // repeated index in a row cancels under the realtime detector XOR, and + // GF(2)-collapse encodes that rule in the model instead of leaving each + // consumer to interpret duplicates its own way. + std::vector> detector_rows; + { + std::vector row; + for (std::int64_t entry : decoder_config.D_sparse) { + if (entry < 0) { + detector_rows.push_back(std::move(row)); + row.clear(); + } else { + row.push_back(static_cast(entry)); + } + } + if (!row.empty()) + detector_rows.push_back(std::move(row)); + } + std::uint32_t num_measurements = 0; + for (const auto &row : detector_rows) + for (auto column : row) + num_measurements = std::max(num_measurements, column + 1); + const auto measurement_to_detectors = + cudaq::qec::sparse_binary_matrix::from_nested_csr( + static_cast(detector_rows.size()), num_measurements, + detector_rows) + .canonicalize(); + + cudaq::qec::decoder_inputs inputs( + std::move(pcm), std::move(observable_matrix), + decoder_config.error_rate_vec, measurement_to_detectors); auto decoder = cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), cudaq::qec::decoder_output::observables, prepare_decoder_params(decoder_config)); decoder->set_decoder_id(decoder_config.id); decoder->set_O_sparse(decoder_config.O_sparse); - decoder->set_D_sparse(decoder_config.D_sparse); + decoder->set_D_sparse(measurement_to_detectors); // Force plugin initialization before the caller publishes the decoder for // realtime work. This preserves configure_decoders()'s existing behavior. diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index 829fddb75..a8332d3d1 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -22,6 +22,55 @@ #include #include +namespace cudaq::qec { + +/// Records the measurement-to-detector map exactly as a plugin sees it at +/// construction, so a test can pin what the construction inputs carry. +struct construction_d_probe { + static inline bool has_d = false; + static inline std::vector> rows; + static inline std::uint32_t num_cols = 0; + /// Detector syndrome handed to decode(), i.e. D as the realtime path applies + /// it, so a test can compare that against the construction copy above. + static inline std::vector last_decode_syndrome; +}; + +class d_capture_decoder : public decoder { +public: + d_capture_decoder(decoder_inputs inputs, decoder_output default_output, + const cudaqx::heterogeneous_map &) + : decoder(std::move(inputs), default_output) { + const auto *D = get_inputs().measurement_to_detectors(); + construction_d_probe::has_d = D != nullptr; + construction_d_probe::rows.clear(); + construction_d_probe::num_cols = 0; + if (D) { + construction_d_probe::rows = D->to_nested_csr(); + construction_d_probe::num_cols = D->num_cols(); + } + } + + decoder_result decode(const std::vector &syndrome) override { + construction_d_probe::last_decode_syndrome = syndrome; + return decoder_result{true, + std::vector(get_num_observables(), 0.0)}; + } + + CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( + d_capture_decoder, + static std::unique_ptr create( + decoder_inputs inputs, std::optional output, + const cudaqx::heterogeneous_map ¶ms) { + return std::make_unique( + std::move(inputs), output.value_or(decoder_output::observables), + params); + }) +}; + +CUDAQ_EXT_PT_REGISTER_TYPE(d_capture_decoder) + +} // namespace cudaq::qec + namespace { class ScopedEnv { public: @@ -697,6 +746,52 @@ TEST(DecoderConfigTest, CreateRealtimeDecoderConfiguresRuntimeState) { EXPECT_EQ(decoder->get_num_msyn_per_decode(), 20u); } +// A repeated index within a D row cancels under the realtime detector XOR. The +// construction inputs must encode that same rule, or a plugin reading its +// inputs sees a different D from the one the realtime path applies. +TEST(DecoderConfigTest, DuplicateDetectorIndicesCollapseInConstructionInputs) { + auto config = create_test_empty_decoder_config(0); + config.type = "d_capture_decoder"; + // Ten non-empty detector rows, as the configuration layer requires. Row 0 + // names measurement 9 twice, which cancels, plus measurement 2, which + // survives. No other row references measurement 9, so the inferred + // measurement width stays 10 only if width is taken before cancellation. + config.D_sparse = {9, 9, 2, -1, 0, -1, 1, -1, 2, -1, 3, + -1, 4, -1, 5, -1, 6, -1, 7, -1, 8, -1}; + + // Round-trip so the fixture is a configuration the server would accept. + auto parsed = cudaq::qec::decoding::config::decoder_config::from_yaml_str( + config.to_yaml_str(200)); + + auto decoder = cudaq::qec::decoding::host::create_realtime_decoder(parsed); + ASSERT_NE(decoder, nullptr); + + // Construction copy: the duplicate pair has cancelled, measurement 2 remains. + ASSERT_TRUE(cudaq::qec::construction_d_probe::has_d); + ASSERT_EQ(cudaq::qec::construction_d_probe::rows.size(), + parsed.syndrome_size); + EXPECT_EQ(cudaq::qec::construction_d_probe::rows[0], + std::vector{2}); + EXPECT_EQ(cudaq::qec::construction_d_probe::rows[1], + std::vector{0}); + // Width survives the cancellation of its only referencing entry. + EXPECT_EQ(cudaq::qec::construction_d_probe::num_cols, 10u); + EXPECT_EQ(decoder->get_num_msyn_per_decode(), 10u); + + // Realtime application: feed one shot and check the detector syndrome the + // decoder receives matches the same canonical D. + cudaq::qec::construction_d_probe::last_decode_syndrome.clear(); + std::vector measurements(10, 0); + measurements[2] = 1; // survives in row 0 and row 3 + measurements[9] = 1; // cancelled in row 0, referenced nowhere else + ASSERT_TRUE(decoder->enqueue_syndrome(measurements)); + + const std::vector expected_detectors = {1, 0, 0, 1, 0, + 0, 0, 0, 0, 0}; + EXPECT_EQ(cudaq::qec::construction_d_probe::last_decode_syndrome, + expected_detectors); +} + TEST(DecoderConfigTest, CreateRealtimeDecoderRequiresDetectorMatrix) { auto config = create_test_sample_realtime_decoder_config(0); config.D_sparse.clear(); From bbe9fe650a5dedd00fecb9fe231ef81c25e235b1 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Mon, 3 Aug 2026 10:13:22 -0700 Subject: [PATCH 04/24] Resolve decoder models before applying a configuration Model semantics were validated inside the YAML mapping traits, which LLVM invokes for output as well as input, so serializing a configuration also validated it. A raw Stim DEM source cannot live there: deriving its sizes needs file IO and a Stim parse, and to_yaml_str() runs on every configure_decoders() call to publish the payload. Introduce resolve_decoder_inputs(), which selects the one authoritative model source, reads and parses a DEM when stim_dem_path is set, builds the canonical measurement-to-detector map, and validates dimensions. It has no side effects, so a whole configuration can be resolved before any of it is applied. create_realtime_decoder() takes the resolved inputs rather than resolving them itself, so the model reaching a plugin is the artifact that was validated, not a second derivation of it. stim_dem_path is mutually exclusive with H_sparse, O_sparse and error_rate_vec, which are the competing representation of the same model. block_size and syndrome_size stay accepted as assertions and are verified against the values the DEM implies. The matrix branch still requires them, since the flat sparse encodings cannot be interpreted without them. Neither branch's keys can be mapRequired, so the exported JSON Schema describes the two sources as alternatives and the resolver, not the parser, decides which keys are needed. Relative model paths resolve against the directory of the configuration that named them, absolute rather than merely normalized so they keep resolving if the working directory moves. Paths are rewritten into the applied configuration only once it is in effect, so a failure cannot leave a caller's configuration partly rewritten. Applying a configuration is restaged: resolve every entry first, then construct, and stash and publish only once runtime initialization succeeds. Previously the configuration was cached and advertised before any decoder existed. Reconfiguring while a realtime session is active is rejected outright, because that session holds a reference to the decoder vector and inspects it at initialize(); callers must finalize first. A matrix configuration with no observable mapping is rejected. The decoding server constructs every decoder for observable output, and such a configuration previously produced a decoder that decoded to a zero-length observable frame. The flat detector map is validated rather than narrowed: an index that does not fit the sparse index type would otherwise alias onto a real measurement, and any value below -1 was read as a row terminator. A plugin constructor failure can still leave the decoder set empty. Avoiding that needs the old and new decoders alive simultaneously, which doubles peak decoder memory, and that cost was judged unacceptable. Signed-off-by: Melody Ren --- .../cudaq/qec/realtime/decoding_config.h | 20 + libs/qec/lib/realtime/config.cpp | 141 +++--- .../decoding-server-cqr/DecodingServer.cpp | 7 +- .../decoding-server-cqr/SessionRegistry.cpp | 13 +- .../decoding-server-cqr/SessionRegistry.h | 9 +- libs/qec/lib/realtime/realtime_decoding.cpp | 290 ++++++++++-- libs/qec/lib/realtime/realtime_decoding.h | 38 +- .../python/bindings/py_decoding_config.cpp | 10 +- libs/qec/python/tests/test_decoding_config.py | 61 +++ .../tests/test_decoding_config_deprecated.py | 10 + libs/qec/unittests/CMakeLists.txt | 5 + libs/qec/unittests/test_decoders_yaml.cpp | 444 +++++++++++++++++- 12 files changed, 911 insertions(+), 137 deletions(-) diff --git a/libs/qec/include/cudaq/qec/realtime/decoding_config.h b/libs/qec/include/cudaq/qec/realtime/decoding_config.h index 76df1c282..a311763ef 100644 --- a/libs/qec/include/cudaq/qec/realtime/decoding_config.h +++ b/libs/qec/include/cudaq/qec/realtime/decoding_config.h @@ -10,6 +10,7 @@ #include "cuda-qx/core/heterogeneous_map.h" #include +#include #include #include #include @@ -70,10 +71,21 @@ 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; + /// Path to a Stim detector error model, authoritative when set. Resolved + /// against the configuration file's directory, or the process working + /// directory for a programmatic or raw-string configuration. Mutually + /// exclusive with `H_sparse`, `O_sparse` and `error_rate_vec`, which are the + /// competing matrix representation of the same model; `block_size` and + /// `syndrome_size` remain accepted as checked assertions. + std::string stim_dem_path; + /// Required for a matrix model; derived from the DEM otherwise. Zero means + /// unset. uint64_t block_size = 0; uint64_t syndrome_size = 0; std::vector H_sparse; std::vector O_sparse; + /// Maps raw measurements to detectors. Orthogonal to the model source and + /// required by both. std::vector D_sparse; /// Error probability per H column. This is framework model data and is /// normalized into decoder_inputs rather than passed to plugin parameters. @@ -180,6 +192,14 @@ __attribute__((visibility("default"))) std::string decoder_config_json_schema(); __attribute__((visibility("default"))) int configure_decoders(multi_decoder_config &config); +/// @brief Configure the decoders, resolving relative model paths (such as +/// `stim_dem_path`) against @p base_dir. The overload above uses the process +/// working directory as it stands when resolution starts. +/// @return 0 on success, non-zero on failure. +__attribute__((visibility("default"))) int +configure_decoders(multi_decoder_config &config, + const std::filesystem::path &base_dir); + /// @brief Configure the decoders from a file. This function configures both /// local decoders, and if running on remote target hardware, will submit the /// configuration to the remote target for further processing. diff --git a/libs/qec/lib/realtime/config.cpp b/libs/qec/lib/realtime/config.cpp index 5fb6dece8..9e0a98c66 100644 --- a/libs/qec/lib/realtime/config.cpp +++ b/libs/qec/lib/realtime/config.cpp @@ -281,70 +281,17 @@ struct MappingTraits { 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); + // A decoder model comes from exactly one source: the matrix keys or + // stim_dem_path. Neither branch's keys can be mapRequired, so which are + // needed is decided by resolve_decoder_inputs(), not by the parser. + io.mapOptional("stim_dem_path", config.stim_dem_path, std::string{}); + 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); + io.mapOptional("O_sparse", config.O_sparse); io.mapRequired("D_sparse", config.D_sparse); io.mapOptional("error_rate_vec", config.error_rate_vec); - // 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. - for (auto value : config.H_sparse) { - if (value < -1 || (value >= 0 && value >= config.block_size)) { - throw std::runtime_error("Value in H_sparse vector is out of range: " + - std::to_string(value)); - } - } - - // Validate that no values in the O_sparse vector are out of range. - for (auto value : config.O_sparse) { - if (value < -1 || (value >= 0 && value >= config.block_size)) { - throw std::runtime_error("Value in O_sparse vector is out of range: " + - std::to_string(value)); - } - } - - if (!config.error_rate_vec.empty() && - config.error_rate_vec.size() != config.block_size) { - throw std::runtime_error( - "error_rate_vec size is not equal to block_size: " + - std::to_string(config.error_rate_vec.size()) + - " != " + std::to_string(config.block_size)); - } - - // Validate that if the D_sparse is provided, it is a valid D matrix. That - // means that the number of rows in the D_sparse matrix should be equal to - // the number of rows in the H_sparse matrix, and no row should be empty. - if (!config.D_sparse.empty()) { - auto num_D_rows = - std::count(config.D_sparse.begin(), config.D_sparse.end(), -1); - if (num_D_rows != config.syndrome_size) { - throw std::runtime_error("Number of rows in D_sparse vector is not " - "equal to syndrome_size: " + - std::to_string(num_D_rows) + - " != " + std::to_string(config.syndrome_size)); - } - // No row should be empty, which means that there should be no - // back-to-back -1 values. - for (std::size_t i = 0; i < config.D_sparse.size() - 1; ++i) { - if (config.D_sparse.at(i) == -1 && config.D_sparse.at(i + 1) == -1) { - throw std::runtime_error("D_sparse row is empty for decoder " + - std::to_string(config.id)); - } - } - } - // Convert decoder_custom_args through the schema registered for this // decoder type. When no schema is registered, the key is intentionally // left unconsumed on input so the YAML parser's strict unknown-key check @@ -614,6 +561,7 @@ std::string decoder_config_json_schema() { llvm::json::Object{{"enum", llvm::json::Array{"host", "device_graph"}}}}, {"cuda_device_id", llvm::json::Object{{"type", "integer"}, {"minimum", 0}}}, + {"stim_dem_path", llvm::json::Object{{"type", "string"}}}, {"block_size", llvm::json::Object{{"type", "integer"}, {"minimum", 0}}}, {"syndrome_size", llvm::json::Object{{"type", "integer"}, {"minimum", 0}}}, @@ -695,9 +643,42 @@ std::string decoder_config_json_schema() { 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", "D_sparse"}}, + // Exactly one model source. The matrix branch needs the dimensions + // that make its flat encodings interpretable; the DEM branch + // derives them and forbids the competing matrix keys. + {"oneOf", + llvm::json::Array{ + llvm::json::Object{ + {"required", + llvm::json::Array{"block_size", "syndrome_size", + "H_sparse", "O_sparse"}}, + // Runtime selects the DEM source on a NON-EMPTY path, so + // an explicitly empty one is still a matrix configuration. + {"properties", + llvm::json::Object{ + {"stim_dem_path", + llvm::json::Object{{"maxLength", 0}}}}}}, + llvm::json::Object{ + {"required", llvm::json::Array{"stim_dem_path"}}, + {"properties", llvm::json::Object{{"stim_dem_path", + llvm::json::Object{ + {"minLength", 1}}}}}, + {"allOf", + llvm::json::Array{ + llvm::json::Object{ + {"not", llvm::json::Object{{"required", + llvm::json::Array{ + "H_sparse"}}}}}, + llvm::json::Object{ + {"not", llvm::json::Object{{"required", + llvm::json::Array{ + "O_sparse"}}}}}, + llvm::json::Object{ + {"not", + llvm::json::Object{ + {"required", + llvm::json::Array{"error_rate_vec"}}}}}}}}}}, {"additionalProperties", false}, {"allOf", std::move(dispatch)}}}, {"decoder_params", std::move(decoder_params)}, @@ -745,20 +726,33 @@ std::string decoder_config_json_schema() { static std::mutex g_last_multi_decoder_config_mutex; static std::shared_ptr g_last_multi_decoder_config; -int configure_decoders(multi_decoder_config &config) { +int configure_decoders(multi_decoder_config &config, + const std::filesystem::path &base_dir) { CUDA_QEC_INFO("Initializing realtime decoding library with config object"); + const int status = + cudaq::qec::decoding::host::configure_decoders(config, base_dir); + if (status != 0) + return status; + + // Stash and publish only once the configuration is actually in effect, so a + // failed application cannot leave a configuration cached here or advertised + // to remote targets that nothing is honoring. { std::lock_guard lock(g_last_multi_decoder_config_mutex); g_last_multi_decoder_config = std::make_shared(config); } - // Publish the decoder configuration so CUDA-Q can inject it into - // remote-target job requests. The cudaq integration (ExtraPayloadProvider) is - // installed by cudaq-qec at load time; this call is a no-op when cudaq-qec is - // not loaded, keeping this library free of any direct cudaq-common - // dependency. + // The cudaq integration (ExtraPayloadProvider) is installed by cudaq-qec at + // load time; this call is a no-op when cudaq-qec is not loaded, keeping this + // library free of any direct cudaq-common dependency. cudaq::qec::publish_decoder_config_payload(config.to_yaml_str()); - return cudaq::qec::decoding::host::configure_decoders(config); + return status; +} + +int configure_decoders(multi_decoder_config &config) { + // No originating file: relative model paths resolve against the working + // directory as it stands when resolution starts. + return configure_decoders(config, std::filesystem::current_path()); } std::shared_ptr @@ -805,7 +799,10 @@ int configure_decoders_from_file(const char *config_file) { std::istreambuf_iterator()); log_config(config_str.c_str(), /*from_file=*/true); auto config = multi_decoder_config::from_yaml_str(config_str); - return configure_decoders(config); + // Relative model paths resolve against the configuration file's directory, + // absolute so the resolved paths stay valid if the working directory moves. + return configure_decoders( + config, std::filesystem::absolute(config_file_str).parent_path()); } int configure_decoders_from_str(const char *config_str) { diff --git a/libs/qec/lib/realtime/decoding-server-cqr/DecodingServer.cpp b/libs/qec/lib/realtime/decoding-server-cqr/DecodingServer.cpp index 6bef45d3b..89eeddbc0 100644 --- a/libs/qec/lib/realtime/decoding-server-cqr/DecodingServer.cpp +++ b/libs/qec/lib/realtime/decoding-server-cqr/DecodingServer.cpp @@ -14,6 +14,7 @@ #include "cudaq/qec/realtime/decoding_config.h" #include +#include #include #include #include @@ -100,7 +101,11 @@ DecodingServer::DecodingServer(const std::string &config_yaml) { yaml_str); if (config.decoders.empty()) throw std::runtime_error("No decoders in config: " + config_yaml); - registry_.load_from_config(config, config_yaml); + { + registry_.load_from_config( + config, config_yaml, + std::filesystem::absolute(config_yaml).parent_path()); + } register_handlers(); const auto dispatch = registry_.required_dispatch(); diff --git a/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp b/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp index e872059ea..5f1a47e37 100644 --- a/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp +++ b/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp @@ -10,6 +10,7 @@ #include "../realtime_decoding.h" #include "cudaq/qec/logger.h" #include "cudaq/qec/realtime/decoding_config.h" +#include #include #include @@ -44,11 +45,16 @@ void SessionRegistry::load_from_config(const std::string &yaml_path) { std::string yaml_str((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - load_from_config(multi_decoder_config::from_yaml_str(yaml_str), yaml_path); + // A model path in the document is relative to the document, not to wherever + // the server happened to be started from. + auto base_dir = std::filesystem::absolute(yaml_path).parent_path(); + load_from_config(multi_decoder_config::from_yaml_str(yaml_str), yaml_path, + base_dir); } void SessionRegistry::load_from_config(const multi_decoder_config &config, - const std::string &source_name) { + const std::string &source_name, + const std::filesystem::path &base_dir) { for (const auto &dc : config.decoders) { if (dc.id < 0) throw std::runtime_error("Negative decoder id " + std::to_string(dc.id) + @@ -71,7 +77,8 @@ void SessionRegistry::load_from_config(const multi_decoder_config &config, CUDA_QEC_INFO("SessionRegistry: creating decoder id={} type={}", dc.id, dc.type); - auto decoder = cudaq::qec::decoding::host::create_realtime_decoder(dc); + auto decoder = cudaq::qec::decoding::host::create_realtime_decoder( + dc, cudaq::qec::decoding::host::resolve_decoder_inputs(dc, base_dir)); auto session = DecodingSession::create(std::move(decoder), make_default_mapping_table()); diff --git a/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.h b/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.h index eab734f5b..bc204f38c 100644 --- a/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.h +++ b/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.h @@ -8,6 +8,8 @@ #pragma once +#include + #include "DecodingSession.h" #include "cudaq/qec/realtime/decoding_config.h" @@ -35,9 +37,14 @@ class SessionRegistry { /// Same, from an already-parsed config (the in-process application path, /// where the config was handed to configure_decoders rather than a file). /// \p source_name is used in error messages only. + /// \p base_dir is the directory a relative model path (`stim_dem_path`) + /// resolves against; the file overload above passes the config's own + /// directory. Defaults to the working directory for a config with no + /// originating file. void load_from_config( const cudaq::qec::decoding::config::multi_decoder_config &config, - const std::string &source_name); + const std::string &source_name, + const std::filesystem::path &base_dir = std::filesystem::current_path()); DecodingSession &get(uint64_t decoder_id); const DecodingSession &get(uint64_t decoder_id) const; diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index cd076ca2e..34286f5a9 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -17,7 +17,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -165,19 +167,163 @@ cudaqx::heterogeneous_map prepare_decoder_params( return 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()) - throw std::invalid_argument("Decoder ID is outside the uint32_t range: " + - std::to_string(decoder_config.id)); +namespace { + +/// Build D in GF(2)-canonical form from the flat -1-terminated encoding. A +/// repeated index in a row cancels under the realtime detector XOR, so +/// canonicalizing here puts that rule in the model rather than leaving each +/// consumer to interpret duplicates its own way. +cudaq::qec::sparse_binary_matrix +canonical_measurement_to_detectors(const std::vector &d_sparse) { + std::vector> detector_rows; + std::vector row; + // -1 terminates a row; every other value must be a measurement index that, + // with its exclusive upper bound, fits the sparse matrix index type. + // Narrowing an out-of-range value would silently alias it onto a real + // measurement. + constexpr std::int64_t max_measurement_index = + static_cast( + std::numeric_limits< + cudaq::qec::sparse_binary_matrix::index_type>::max()) - + 1; + for (std::int64_t entry : d_sparse) { + if (entry == -1) { + detector_rows.push_back(std::move(row)); + row.clear(); + continue; + } + if (entry < -1 || entry > max_measurement_index) + throw std::runtime_error(fmt::format( + "Value in D_sparse vector is out of range: {} (expected -1 as a row " + "terminator, or a measurement index in [0, {}])", + entry, max_measurement_index)); + row.push_back(static_cast(entry)); + } + if (!row.empty()) + detector_rows.push_back(std::move(row)); + + // Width is taken before cancellation, so a trailing measurement referenced + // only by a cancelling pair still counts toward the per-decode width. The + // bound above makes column + 1 safe. + std::uint32_t num_measurements = 0; + for (const auto &r : detector_rows) + for (auto column : r) + num_measurements = std::max(num_measurements, column + 1); + + return cudaq::qec::sparse_binary_matrix::from_nested_csr( + static_cast(detector_rows.size()), num_measurements, + detector_rows) + .canonicalize(); +} + +void validate_sparse_indices(const std::vector &sparse, + std::uint64_t num_columns, const char *name) { + for (auto value : sparse) + if (value < -1 || + (value >= 0 && static_cast(value) >= num_columns)) + throw std::runtime_error( + fmt::format("Value in {} vector is out of range: {}", name, value)); +} + +void validate_detector_rows(const std::vector &d_sparse, + std::int64_t id) { + for (std::size_t i = 0; i + 1 < d_sparse.size(); ++i) + if (d_sparse.at(i) == -1 && d_sparse.at(i + 1) == -1) + throw std::runtime_error( + fmt::format("D_sparse row is empty for decoder {}", id)); +} + +} // namespace + +cudaq::qec::decoder_inputs resolve_decoder_inputs( + const cudaq::qec::decoding::config::decoder_config &decoder_config, + const std::filesystem::path &base_dir) { if (decoder_config.D_sparse.empty()) throw std::runtime_error( "D_sparse must be provided in decoder configuration"); + validate_detector_rows(decoder_config.D_sparse, decoder_config.id); + auto D = canonical_measurement_to_detectors(decoder_config.D_sparse); + + const bool dem_source = !decoder_config.stim_dem_path.empty(); + if (dem_source) { + // The matrix keys are a competing representation of the same model, not + // assertions about it, so supplying both leaves no single authority. + if (!decoder_config.H_sparse.empty() || !decoder_config.O_sparse.empty() || + !decoder_config.error_rate_vec.empty()) + throw std::runtime_error( + "stim_dem_path is mutually exclusive with H_sparse, O_sparse and " + "error_rate_vec; supply exactly one model source"); + + // Absolute, not merely normalized: base_dir may itself be relative (a + // server started with `configs/decoders.yml`), and a stored relative path + // stops resolving once the working directory changes. + std::filesystem::path dem_path(decoder_config.stim_dem_path); + if (dem_path.is_relative()) + dem_path = + std::filesystem::absolute(base_dir / dem_path).lexically_normal(); + std::ifstream dem_file(dem_path); + if (!dem_file) + throw std::runtime_error(fmt::format( + "stim_dem_path could not be opened: {}", dem_path.string())); + std::string dem_text((std::istreambuf_iterator(dem_file)), + std::istreambuf_iterator()); + + auto inputs = cudaq::qec::decoder_inputs::from_stim_dem(std::move(dem_text), + std::move(D)); + + // The DEM defines the detector basis; a supplied syndrome_size is only an + // assertion about it. + if (decoder_config.syndrome_size != 0 && + decoder_config.syndrome_size != inputs.num_detectors()) + throw std::runtime_error(fmt::format( + "syndrome_size ({}) does not match the detector count of {} ({})", + decoder_config.syndrome_size, decoder_config.stim_dem_path, + inputs.num_detectors())); + if (decoder_config.block_size != 0 && + decoder_config.block_size != inputs.num_error_mechanisms()) + throw std::runtime_error(fmt::format( + "block_size ({}) does not match the error-mechanism count of {} " + "({}). The derived value is the column count of the flattened matrix " + "projection of the DEM, which need not equal a count reported using " + "a different decomposition.", + decoder_config.block_size, decoder_config.stim_dem_path, + inputs.num_error_mechanisms())); + return inputs; + } - auto t0 = std::chrono::high_resolution_clock::now(); - CUDA_QEC_INFO("Creating decoder {} of type {}", decoder_config.id, - decoder_config.type); + // Matrix source. These dimensions are needed to interpret the flat sparse + // encodings, so they stay required on this branch. + if (decoder_config.syndrome_size == 0 || decoder_config.block_size == 0) + throw std::runtime_error( + "block_size and syndrome_size are required for a matrix decoder model"); + const auto num_H_rows = std::count(decoder_config.H_sparse.begin(), + decoder_config.H_sparse.end(), -1); + if (static_cast(num_H_rows) != decoder_config.syndrome_size) + throw std::runtime_error(fmt::format( + "Number of rows in H_sparse vector is not equal to syndrome_size: {} " + "!= {}", + num_H_rows, decoder_config.syndrome_size)); + validate_sparse_indices(decoder_config.H_sparse, decoder_config.block_size, + "H_sparse"); + // The realtime path exists to return observable corrections, so a model that + // supplies no observable mapping cannot serve it. Without this the decoder + // constructs and silently decodes to a zero-length observable frame. + if (decoder_config.O_sparse.empty()) + throw std::runtime_error( + "O_sparse is required: the decoding server constructs every decoder " + "for observable output, which needs an observable mapping"); + validate_sparse_indices(decoder_config.O_sparse, decoder_config.block_size, + "O_sparse"); + if (!decoder_config.error_rate_vec.empty() && + decoder_config.error_rate_vec.size() != decoder_config.block_size) + throw std::runtime_error(fmt::format( + "error_rate_vec size is not equal to block_size: {} != {}", + decoder_config.error_rate_vec.size(), decoder_config.block_size)); + if (static_cast(D.num_rows()) != decoder_config.syndrome_size) + throw std::runtime_error( + fmt::format("Number of rows in D_sparse vector is not equal to " + "syndrome_size: {} != {}", + D.num_rows(), decoder_config.syndrome_size)); auto pcm = cudaq::qec::pcm_from_sparse_vec(decoder_config.H_sparse, decoder_config.syndrome_size, @@ -186,51 +332,48 @@ std::unique_ptr create_realtime_decoder( decoder_config.O_sparse.end(), -1); auto observable_matrix = cudaq::qec::pcm_from_sparse_vec( decoder_config.O_sparse, num_observables, decoder_config.block_size); - // D belongs to the construction inputs like H and O. Build it once and hand - // the same matrix to both the inputs and the realtime setter, so the two - // cannot describe different matrices. Canonicalize rather than rasterize: a - // repeated index in a row cancels under the realtime detector XOR, and - // GF(2)-collapse encodes that rule in the model instead of leaving each - // consumer to interpret duplicates its own way. - std::vector> detector_rows; - { - std::vector row; - for (std::int64_t entry : decoder_config.D_sparse) { - if (entry < 0) { - detector_rows.push_back(std::move(row)); - row.clear(); - } else { - row.push_back(static_cast(entry)); - } - } - if (!row.empty()) - detector_rows.push_back(std::move(row)); - } - std::uint32_t num_measurements = 0; - for (const auto &row : detector_rows) - for (auto column : row) - num_measurements = std::max(num_measurements, column + 1); - const auto measurement_to_detectors = - cudaq::qec::sparse_binary_matrix::from_nested_csr( - static_cast(detector_rows.size()), num_measurements, - detector_rows) - .canonicalize(); - - cudaq::qec::decoder_inputs inputs( + return cudaq::qec::decoder_inputs( std::move(pcm), std::move(observable_matrix), - decoder_config.error_rate_vec, measurement_to_detectors); + decoder_config.error_rate_vec, std::move(D)); +} + +std::unique_ptr create_realtime_decoder( + const cudaq::qec::decoding::config::decoder_config &decoder_config, + cudaq::qec::decoder_inputs inputs) { + if (decoder_config.id < 0 || static_cast(decoder_config.id) > + std::numeric_limits::max()) + throw std::invalid_argument("Decoder ID is outside the uint32_t range: " + + std::to_string(decoder_config.id)); + + auto t0 = std::chrono::high_resolution_clock::now(); + CUDA_QEC_INFO("Creating decoder {} of type {}", decoder_config.id, + decoder_config.type); + + // decoder_inputs is a shared-state handle, so this copy is O(1) and keeps D + // readable after the original is moved into the factory. Copying the matrix + // itself would deep-copy its index vectors for no reason. + const cudaq::qec::decoder_inputs inputs_handle = inputs; + if (!inputs_handle.measurement_to_detectors()) + throw std::runtime_error( + "resolved decoder inputs carry no measurement-to-detector map"); auto decoder = cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), cudaq::qec::decoder_output::observables, prepare_decoder_params(decoder_config)); decoder->set_decoder_id(decoder_config.id); - decoder->set_O_sparse(decoder_config.O_sparse); - decoder->set_D_sparse(measurement_to_detectors); + // O already reached the decoder through the construction inputs; the base + // sized its corrections buffer from them at construction. D still drives the + // realtime buffer allocation, so it is handed over again here, as the same + // matrix the inputs carry. + decoder->set_D_sparse(*inputs_handle.measurement_to_detectors()); // Force plugin initialization before the caller publishes the decoder for // realtime work. This preserves configure_decoders()'s existing behavior. auto t1 = std::chrono::high_resolution_clock::now(); - std::vector syndrome(decoder_config.syndrome_size, 0.0); + // Size the dry run from the constructed decoder, not the configuration: a + // DEM-sourced config leaves syndrome_size unset and derives it from the + // model. + std::vector syndrome(decoder->get_syndrome_size(), 0.0); decoder->decode(syndrome); auto t2 = std::chrono::high_resolution_clock::now(); std::chrono::duration creation_duration = t1 - t0; @@ -250,9 +393,20 @@ cudaq::qec::realtime::qec_realtime_session *get_realtime_session() { } int configure_decoders( - cudaq::qec::decoding::config::multi_decoder_config &config) { + cudaq::qec::decoding::config::multi_decoder_config &config, + const std::filesystem::path &base_dir) { CUDA_QEC_INFO("Initializing decoders..."); + // A live session holds a reference to g_decoders and inspects it at + // initialize(), so replacing decoders underneath it is unsafe. Reject before + // doing any expensive work; callers must finalize first. PR #695 replaces + // this guard with real quiescence and rollback. + if (g_realtime_session) { + CUDA_QEC_WARN("Cannot reconfigure decoders while a realtime session is " + "active; call finalize_decoders() first."); + return 5; + } + const auto &decoder_configs = config.decoders; // First validate that the there are no duplicate decoder IDs. @@ -320,12 +474,45 @@ int configure_decoders( } #endif - // Create the decoders based on the decoder configs. + // Resolve every model before touching any process state. Resolution reads + // and parses model files and performs all model validation, so a bad + // configuration fails here, with the previously active decoders intact. + // Resolution errors propagate as exceptions rather than becoming a status + // code, preserving the behavior callers already see for invalid models. + const auto absolute_base = + std::filesystem::absolute(base_dir).lexically_normal(); + + std::vector resolved; + resolved.reserve(config.decoders.size()); + // The absolute form of each model path, applied to the caller's + // configuration only once the whole configuration has been applied. Rewriting + // as we go would leave a caller's config partly rewritten when a later entry + // fails to resolve, so a retry against a different base directory would + // silently keep the first one. + std::vector absolute_model_paths(config.decoders.size()); + for (std::size_t i = 0; i < config.decoders.size(); ++i) { + const auto &decoder_config = config.decoders[i]; + resolved.push_back(resolve_decoder_inputs(decoder_config, absolute_base)); + if (!decoder_config.stim_dem_path.empty()) { + std::filesystem::path model(decoder_config.stim_dem_path); + absolute_model_paths[i] = + model.is_relative() ? std::filesystem::absolute(absolute_base / model) + .lexically_normal() + .string() + : model.lexically_normal().string(); + } + } + + // Construction allocates, so replacements are built in place rather than + // alongside the old set: a constructor failure can still leave the decoder + // set empty. Overlapping both sets would double peak decoder memory, which + // is not an acceptable cost here. try { g_decoders.clear(); g_decoders.resize(max_decoder_id + 1); - for (const auto &decoder_config : decoder_configs) { - g_decoders[decoder_config.id] = create_realtime_decoder(decoder_config); + for (std::size_t i = 0; i < decoder_configs.size(); ++i) { + g_decoders[decoder_configs[i].id] = + create_realtime_decoder(decoder_configs[i], std::move(resolved[i])); } } catch (const std::exception &e) { CUDA_QEC_WARN("Error initializing decoders: {}", e.what()); @@ -333,6 +520,13 @@ int configure_decoders( } maybe_init_realtime_session(); + + // The configuration is now in effect. Make its model paths absolute so the + // copy that gets cached, published and re-read by the session registry + // resolves without knowing the base directory used here. + for (std::size_t i = 0; i < config.decoders.size(); ++i) + if (!absolute_model_paths[i].empty()) + config.decoders[i].stim_dem_path = absolute_model_paths[i]; return 0; } diff --git a/libs/qec/lib/realtime/realtime_decoding.h b/libs/qec/lib/realtime/realtime_decoding.h index d4e6558f7..16776b9aa 100644 --- a/libs/qec/lib/realtime/realtime_decoding.h +++ b/libs/qec/lib/realtime/realtime_decoding.h @@ -11,6 +11,7 @@ #include "cudaq/qec/decoder.h" #include "cudaq/qec/realtime/decoding_config.h" #include +#include #include // Note: none of these are intended to be user-facing functions. @@ -36,16 +37,34 @@ __attribute__((visibility("default"))) cudaqx::heterogeneous_map prepare_decoder_params( const cudaq::qec::decoding::config::decoder_config &decoder_config); -/// Construct and initialize one decoder for realtime use. The returned decoder -/// is fully configured with its ID and O/D matrices, but is not installed in a -/// process-global registry or attached to a worker thread. +/// Resolve a decoder configuration's model into construction inputs. +/// +/// Selects the one authoritative model source, reads and parses a raw Stim DEM +/// when `stim_dem_path` is set, builds the canonical measurement-to-detector +/// map, and validates dimensions and any supplied assertions. Performs no +/// side effects: it allocates no decoder, touches no process state, and can be +/// called for every entry of a configuration before any of them is applied. +/// +/// @param base_dir Directory a relative `stim_dem_path` resolves against. The +/// configuration file's parent directory for a file-based configuration, or +/// the process working directory for a programmatic or raw-string one. +/// @throws std::runtime_error on any resolution or validation failure. +__attribute__((visibility("default"))) cudaq::qec::decoder_inputs +resolve_decoder_inputs( + const cudaq::qec::decoding::config::decoder_config &decoder_config, + const std::filesystem::path &base_dir); + +/// Construct and initialize one decoder for realtime use from already-resolved +/// inputs. The returned decoder is fully configured with its ID and D matrix, +/// but is not installed in a process-global registry or attached to a worker +/// thread. /// /// @throws std::invalid_argument if the decoder ID cannot be represented. -/// @throws std::runtime_error if required realtime configuration is missing or -/// decoder construction/initialization fails. +/// @throws std::runtime_error if decoder construction/initialization fails. __attribute__((visibility("default"))) std::unique_ptr create_realtime_decoder( - const cudaq::qec::decoding::config::decoder_config &decoder_config); + const cudaq::qec::decoding::config::decoder_config &decoder_config, + cudaq::qec::decoder_inputs inputs); __attribute__((visibility("default"))) void get_corrections(std::size_t decoder_id, uint8_t *corrections, @@ -54,8 +73,13 @@ get_corrections(std::size_t decoder_id, uint8_t *corrections, __attribute__((visibility("default"))) void reset_decoder(std::size_t decoder_id); +/// Apply a configuration: resolve every entry's model, then construct and +/// install the decoders. Rejects reconfiguration while a realtime session is +/// active, because that session holds a reference to the decoder vector. +/// @param base_dir Directory relative model paths resolve against. int configure_decoders( - cudaq::qec::decoding::config::multi_decoder_config &config); + cudaq::qec::decoding::config::multi_decoder_config &config, + const std::filesystem::path &base_dir); int configure_decoders_from_file(const char *config_file); int configure_decoders_from_str(const char *config_str); void finalize_decoders(); diff --git a/libs/qec/python/bindings/py_decoding_config.cpp b/libs/qec/python/bindings/py_decoding_config.cpp index 47f6df037..1926e80bd 100644 --- a/libs/qec/python/bindings/py_decoding_config.cpp +++ b/libs/qec/python/bindings/py_decoding_config.cpp @@ -238,6 +238,12 @@ void bindDecodingConfig(nb::module_ &mod) { .def_rw("type", &decoder_config::type) .def_rw("dispatch", &decoder_config::dispatch) .def_rw("cuda_device_id", &decoder_config::cuda_device_id) + .def_rw( + "stim_dem_path", &decoder_config::stim_dem_path, + "Path to a Stim detector error model. Authoritative when set, and " + "mutually exclusive with H_sparse, O_sparse and error_rate_vec. " + "Relative paths resolve against the configuration file's " + "directory, or the working directory for a programmatic config.") .def_rw("block_size", &decoder_config::block_size) .def_rw("syndrome_size", &decoder_config::syndrome_size) .def_rw("H_sparse", &decoder_config::H_sparse) @@ -298,7 +304,9 @@ void bindDecodingConfig(nb::module_ &mod) { // Library helpers mod_cfg.def( - "configure_decoders", &configure_decoders, nb::arg("config"), + "configure_decoders", + static_cast(&configure_decoders), + nb::arg("config"), "Configure decoders in a multi_decoder_config list; returns int status."); mod_cfg.def("configure_decoders_from_file", &configure_decoders_from_file, nb::arg("config_file"), diff --git a/libs/qec/python/tests/test_decoding_config.py b/libs/qec/python/tests/test_decoding_config.py index 9a9577896..f26284263 100644 --- a/libs/qec/python/tests/test_decoding_config.py +++ b/libs/qec/python/tests/test_decoding_config.py @@ -6,6 +6,7 @@ # the terms of the Apache License 2.0 which accompanies this distribution. # # ============================================================================ # +import json import math import numpy as np @@ -615,6 +616,9 @@ def test_configure_valid_multi_error_lut_decoders(): dc.block_size = 10 dc.syndrome_size = 3 dc.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] + # The decoding server constructs for observable output, so a server config + # must supply an observable mapping. + dc.O_sparse = [0, -1] dc.D_sparse = qec.generate_timelike_sparse_detector_matrix( dc.syndrome_size, 2, include_first_round=False) dc.decoder_custom_args = {"lut_error_depth": 2} @@ -744,6 +748,10 @@ def test_configure_invalid_decoders(): decoder_config.block_size = 10 decoder_config.syndrome_size = 3 decoder_config.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] + # A resolvable model, so the failure under test is the unregistered + # decoder type at construction rather than an unresolvable configuration. + decoder_config.O_sparse = [0, -1] + decoder_config.D_sparse = [0, -1, 1, -1, 2, -1] decoder_config.decoder_custom_args = {"max_iterations": 50} multi_decoder_config = qec.multi_decoder_config() @@ -755,3 +763,56 @@ def test_configure_invalid_decoders(): if __name__ == "__main__": pytest.main() + + +# --- exported JSON Schema: the two model sources ---------------------------- +# +# The schema must describe the language the runtime actually accepts. It keys +# the DEM source on a NON-EMPTY stim_dem_path, matching resolve_decoder_inputs. + +def _decoder_doc(**overrides): + doc = {"id": 0, "type": "pymatching", "D_sparse": [0, -1, 1, -1]} + doc.update(overrides) + return {"decoders": [doc]} + + +_MATRIX_KEYS = { + "block_size": 4, + "syndrome_size": 2, + "H_sparse": [0, -1, 1, -1], + "O_sparse": [0, -1], +} + + +def _schema_accepts(doc): + jsonschema = pytest.importorskip("jsonschema") + schema = json.loads(qec.qecrt.config.decoder_config_json_schema()) + try: + jsonschema.validate(doc, schema) + return True + except jsonschema.ValidationError: + return False + + +def test_json_schema_accepts_matrix_source(): + assert _schema_accepts(_decoder_doc(**_MATRIX_KEYS)) + + +def test_json_schema_accepts_dem_source(): + assert _schema_accepts(_decoder_doc(stim_dem_path="model.dem")) + + +def test_json_schema_rejects_both_sources(): + assert not _schema_accepts( + _decoder_doc(stim_dem_path="model.dem", **_MATRIX_KEYS)) + + +def test_json_schema_rejects_neither_source(): + assert not _schema_accepts(_decoder_doc()) + + +def test_json_schema_treats_empty_dem_path_as_matrix_source(): + # An explicitly empty path is not a DEM source at runtime, so the schema + # must accept it alongside matrices and reject it on its own. + assert _schema_accepts(_decoder_doc(stim_dem_path="", **_MATRIX_KEYS)) + assert not _schema_accepts(_decoder_doc(stim_dem_path="")) diff --git a/libs/qec/python/tests/test_decoding_config_deprecated.py b/libs/qec/python/tests/test_decoding_config_deprecated.py index 451e38fdd..d1a39cf6c 100644 --- a/libs/qec/python/tests/test_decoding_config_deprecated.py +++ b/libs/qec/python/tests/test_decoding_config_deprecated.py @@ -446,6 +446,9 @@ def test_configure_valid_multi_error_lut_decoders(): dc.block_size = 10 dc.syndrome_size = 3 dc.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] + # The decoding server constructs for observable output, so a server config + # must supply an observable mapping. + dc.O_sparse = [0, -1] dc.D_sparse = qec.generate_timelike_sparse_detector_matrix( dc.syndrome_size, 2, include_first_round=False) dc.set_decoder_custom_args(nv) @@ -860,6 +863,9 @@ def test_configure_valid_decoders(): dc.block_size = 10 dc.syndrome_size = 3 dc.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] + # The decoding server constructs for observable output, so a server config + # must supply an observable mapping. + dc.O_sparse = [0, -1] dc.D_sparse = qec.generate_timelike_sparse_detector_matrix( dc.syndrome_size, 2, include_first_round=False) lut_config = qec.multi_error_lut_config() @@ -966,6 +972,10 @@ def test_configure_invalid_decoders(): decoder_config.block_size = 10 decoder_config.syndrome_size = 3 decoder_config.H_sparse = [1, 2, 3, -1, 6, 7, 8, -1, -1] + # A resolvable model, so the failure under test is the unregistered + # decoder type at construction rather than an unresolvable configuration. + decoder_config.O_sparse = [0, -1] + decoder_config.D_sparse = [0, -1, 1, -1, 2, -1] decoder_config.set_decoder_custom_args(nv) multi_decoder_config = qec.multi_decoder_config() diff --git a/libs/qec/unittests/CMakeLists.txt b/libs/qec/unittests/CMakeLists.txt index 407996e35..186bee874 100644 --- a/libs/qec/unittests/CMakeLists.txt +++ b/libs/qec/unittests/CMakeLists.txt @@ -53,6 +53,11 @@ if(TARGET cudaq-qec-decoding-server) cudaq-qec-realtime-decoding cudaq-qec-realtime-decoding-simulation cudaq::cudaq) + # Chromobius is the DEM-native decoder the raw-DEM model source exists for; + # link it when this configuration built it so the acceptance test can run. + if(TARGET cudaq-qec-chromobius) + target_link_libraries(test_decoders_yaml PRIVATE cudaq-qec-chromobius) + endif() add_dependencies(CUDAQXQECUnitTests test_decoders_yaml) gtest_discover_tests(test_decoders_yaml) diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index a8332d3d1..5db8c178d 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -21,6 +21,7 @@ #include #include #include +#include namespace cudaq::qec { @@ -72,6 +73,46 @@ CUDAQ_EXT_PT_REGISTER_TYPE(d_capture_decoder) } // namespace cudaq::qec namespace { +// A Stim DEM on disk, removed when the test finishes. Two detectors, three +// error mechanisms, one observable. +constexpr const char *kTinyDem = "error(0.1) D0 L0\n" + "error(0.1) D0 D1\n" + "error(0.2) D1\n"; + +class ScopedDemFile { +public: + explicit ScopedDemFile(const char *contents = kTinyDem) { + // GoogleTest binaries run concurrently under ctest, and this file is + // discovered by more than one target, so a process-local counter alone + // collides. Qualify by pid. + static int counter = 0; + path_ = std::filesystem::temp_directory_path() / + ("cudaqx_resolver_" + std::to_string(::getpid()) + "_" + + std::to_string(counter++) + ".dem"); + std::ofstream(path_) << contents; + } + ~ScopedDemFile() { + std::error_code ec; + std::filesystem::remove(path_, ec); + } + const std::filesystem::path &path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +/// Config carrying only what both model branches need: an id, a type, and a +/// two-row measurement-to-detector map matching the tiny DEM's detectors. +cudaq::qec::decoding::config::decoder_config +make_dem_config(const std::filesystem::path &dem_path) { + cudaq::qec::decoding::config::decoder_config config; + config.id = 0; + config.type = "d_capture_decoder"; + config.stim_dem_path = dem_path.string(); + config.D_sparse = {0, -1, 1, -1}; + return config; +} + class ScopedEnv { public: ScopedEnv(const char *name, const char *value) : name(name) { @@ -738,7 +779,9 @@ TEST(DecoderConfigTest, ConfigureRejectsDuplicateAndNegativeIds) { TEST(DecoderConfigTest, CreateRealtimeDecoderConfiguresRuntimeState) { auto config = create_test_sample_realtime_decoder_config(7); - auto decoder = cudaq::qec::decoding::host::create_realtime_decoder(config); + auto decoder = cudaq::qec::decoding::host::create_realtime_decoder( + config, cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path())); ASSERT_NE(decoder, nullptr); EXPECT_EQ(decoder->get_decoder_id(), 7u); @@ -763,7 +806,9 @@ TEST(DecoderConfigTest, DuplicateDetectorIndicesCollapseInConstructionInputs) { auto parsed = cudaq::qec::decoding::config::decoder_config::from_yaml_str( config.to_yaml_str(200)); - auto decoder = cudaq::qec::decoding::host::create_realtime_decoder(parsed); + auto decoder = cudaq::qec::decoding::host::create_realtime_decoder( + parsed, cudaq::qec::decoding::host::resolve_decoder_inputs( + parsed, std::filesystem::current_path())); ASSERT_NE(decoder, nullptr); // Construction copy: the duplicate pair has cancelled, measurement 2 remains. @@ -792,11 +837,400 @@ TEST(DecoderConfigTest, DuplicateDetectorIndicesCollapseInConstructionInputs) { expected_detectors); } +// --- raw Stim DEM model source --------------------------------------------- + +TEST(ResolveDecoderInputs, DemSourceCarriesRawProvenanceAndDerivedSizes) { + ScopedDemFile dem; + auto config = make_dem_config(dem.path()); + + auto inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path()); + + // The DEM stays authoritative, so a DEM-native decoder can read it back. + ASSERT_TRUE(inputs.has_stim_dem()); + EXPECT_NE(inputs.stim_dem().find("error(0.1) D0 L0"), std::string::npos); + // Sizes come from the DEM rather than the configuration. + EXPECT_EQ(inputs.num_detectors(), 2u); + EXPECT_EQ(inputs.num_error_mechanisms(), 3u); + EXPECT_EQ(inputs.num_observables(), 1u); + // D is orthogonal to the model source and survives resolution. + ASSERT_NE(inputs.measurement_to_detectors(), nullptr); + EXPECT_EQ(inputs.measurement_to_detectors()->num_rows(), 2u); +} + +TEST(ResolveDecoderInputs, DemSourceRejectsCompetingMatrixKeys) { + ScopedDemFile dem; + const std::filesystem::path cwd = std::filesystem::current_path(); + + auto with_H = make_dem_config(dem.path()); + with_H.H_sparse = {0, -1, 1, -1}; + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs(with_H, cwd), + std::runtime_error); + + auto with_O = make_dem_config(dem.path()); + with_O.O_sparse = {0, -1}; + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs(with_O, cwd), + std::runtime_error); + + auto with_rates = make_dem_config(dem.path()); + with_rates.error_rate_vec = {0.1, 0.1, 0.1}; + EXPECT_THROW( + cudaq::qec::decoding::host::resolve_decoder_inputs(with_rates, cwd), + std::runtime_error); +} + +TEST(ResolveDecoderInputs, DemSourceRejectsUnreadableFile) { + auto config = make_dem_config("/nonexistent/definitely-not-here.dem"); + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path()), + std::runtime_error); +} + +TEST(ResolveDecoderInputs, DemSourceTreatsSuppliedSizesAsAssertions) { + ScopedDemFile dem; + const std::filesystem::path cwd = std::filesystem::current_path(); + + // Matching values are accepted. + auto matching = make_dem_config(dem.path()); + matching.syndrome_size = 2; + matching.block_size = 3; + EXPECT_NO_THROW( + cudaq::qec::decoding::host::resolve_decoder_inputs(matching, cwd)); + + auto wrong_detectors = make_dem_config(dem.path()); + wrong_detectors.syndrome_size = 99; + EXPECT_THROW( + cudaq::qec::decoding::host::resolve_decoder_inputs(wrong_detectors, cwd), + std::runtime_error); + + auto wrong_mechanisms = make_dem_config(dem.path()); + wrong_mechanisms.block_size = 99; + EXPECT_THROW( + cudaq::qec::decoding::host::resolve_decoder_inputs(wrong_mechanisms, cwd), + std::runtime_error); +} + +TEST(ResolveDecoderInputs, DemSourceResolvesRelativePathAgainstBaseDir) { + ScopedDemFile dem; + auto config = make_dem_config(dem.path().filename()); + ASSERT_TRUE(std::filesystem::path(config.stim_dem_path).is_relative()); + + // Against the containing directory it resolves; against an unrelated one it + // does not, which is what makes the base directory meaningful. + EXPECT_NO_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + config, dem.path().parent_path())); + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + config, "/definitely/not/the/right/place"), + std::runtime_error); +} + +TEST(ResolveDecoderInputs, MatrixSourceStillRequiresItsDimensions) { + auto config = create_test_empty_decoder_config(0); + config.block_size = 0; + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path()), + std::runtime_error); +} + +TEST(ResolveDecoderInputs, MatrixSourceRequiresAnObservableMapping) { + auto config = create_test_empty_decoder_config(0); + config.O_sparse.clear(); + // The realtime path returns observable corrections, so a model with no + // observable mapping cannot serve it. Without this it constructed happily + // and decoded to a zero-length observable frame. + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path()), + std::runtime_error); +} + +// Acceptance: a DEM-native decoder can be configured and constructed for the +// decoding server straight from a raw Stim DEM, with no decoder-specific +// branch anywhere in the configuration or construction path. Chromobius +// requires the DEM itself -- it throws when handed only matrices -- so this +// only passes if raw provenance survives resolution. +// Acceptance: a decoding-server configuration on disk names its model with a +// path relative to itself, and the server resolves it through its own session +// path. Deliberately goes through SessionRegistry rather than calling the +// resolver directly: an earlier version of this test bypassed the registry and +// therefore missed that the registry resolved every model against the process +// working directory. +// --- configuration lifecycle ----------------------------------------------- +// +// Applying a configuration must not damage a working one. Resolution happens +// before any process state is touched, and the configuration is cached and +// published only once it is actually in effect. + +TEST(ConfigureDecodersLifecycle, InvalidModelLeavesPriorConfigurationInPlace) { + using namespace cudaq::qec::decoding::config; + + multi_decoder_config good; + good.decoders.push_back(create_test_sample_realtime_decoder_config(0)); + ASSERT_EQ(configure_decoders(good), 0); + const auto cached_after_good = last_configured_multi_decoder_config(); + ASSERT_NE(cached_after_good, nullptr); + + // An unresolvable model: resolution failures propagate as exceptions rather + // than a status code, and must happen before anything is replaced. + multi_decoder_config bad; + auto broken = create_test_sample_realtime_decoder_config(0); + broken.O_sparse.clear(); // no observable mapping for an observable server + bad.decoders.push_back(broken); + EXPECT_THROW(configure_decoders(bad), std::runtime_error); + + // The previously applied configuration is still the cached one, and is not + // replaced by the configuration that failed to apply. + const auto cached_after_bad = last_configured_multi_decoder_config(); + ASSERT_NE(cached_after_bad, nullptr); + EXPECT_EQ(*cached_after_bad, *cached_after_good); + + finalize_decoders(); +} + +TEST(ConfigureDecodersLifecycle, ConstructionFailureIsNotAdvertised) { + using namespace cudaq::qec::decoding::config; + + multi_decoder_config good; + good.decoders.push_back(create_test_sample_realtime_decoder_config(0)); + ASSERT_EQ(configure_decoders(good), 0); + const auto cached_after_good = last_configured_multi_decoder_config(); + ASSERT_NE(cached_after_good, nullptr); + + // Resolves cleanly, then fails in the factory: an unregistered decoder type. + multi_decoder_config unbuildable; + auto unknown = create_test_sample_realtime_decoder_config(0); + unknown.type = "no-such-decoder-is-registered"; + unbuildable.decoders.push_back(unknown); + EXPECT_NE(configure_decoders(unbuildable), 0); + + // A configuration that never took effect must not be cached or published. + const auto cached_after_failure = last_configured_multi_decoder_config(); + ASSERT_NE(cached_after_failure, nullptr); + EXPECT_EQ(*cached_after_failure, *cached_after_good); + + finalize_decoders(); +} + +TEST(ConfigureDecodersLifecycle, + AppliedConfigurationStoresAnAbsoluteModelPath) { + using namespace cudaq::qec::decoding::config; + + const auto root = std::filesystem::temp_directory_path() / + ("cudaqx_abs_" + std::to_string(::getpid())); + std::filesystem::create_directories(root / "configs"); + struct Cleanup { + std::filesystem::path dir; + ~Cleanup() { + std::error_code ec; + std::filesystem::remove_all(dir, ec); + } + } cleanup{root}; + std::ofstream(root / "configs" / "model.dem") << kTinyDem; + + const auto previous_cwd = std::filesystem::current_path(); + std::filesystem::current_path(root); + struct RestoreCwd { + std::filesystem::path path; + ~RestoreCwd() { std::filesystem::current_path(path); } + } restore{previous_cwd}; + + decoder_config dc; + dc.id = 0; + dc.type = "d_capture_decoder"; + dc.stim_dem_path = "model.dem"; + dc.D_sparse = {0, -1, 1, -1}; + multi_decoder_config mc; + mc.decoders.push_back(dc); + + // A RELATIVE base directory: normalizing the join without absolutizing it + // would store "configs/model.dem", which stops resolving once the working + // directory moves. + ASSERT_EQ(configure_decoders(mc, "configs"), 0); + + EXPECT_TRUE( + std::filesystem::path(mc.decoders[0].stim_dem_path).is_absolute()); + const auto cached = last_configured_multi_decoder_config(); + ASSERT_NE(cached, nullptr); + EXPECT_TRUE( + std::filesystem::path(cached->decoders[0].stim_dem_path).is_absolute()); + EXPECT_TRUE(std::filesystem::exists(cached->decoders[0].stim_dem_path)); + + finalize_decoders(); +} + +TEST(ConfigureDecodersLifecycle, FailedResolutionLeavesCallerConfigUnmodified) { + using namespace cudaq::qec::decoding::config; + + ScopedDemFile dem; + const auto base = dem.path().parent_path(); + + decoder_config first; + first.id = 0; + first.type = "d_capture_decoder"; + first.stim_dem_path = dem.path().filename().string(); + first.D_sparse = {0, -1, 1, -1}; + + decoder_config second = first; + second.id = 1; + second.stim_dem_path = "definitely-not-present.dem"; + + multi_decoder_config mc; + mc.decoders.push_back(first); + mc.decoders.push_back(second); + const std::string original_path = mc.decoders[0].stim_dem_path; + + // The second entry cannot resolve, so nothing is applied -- including the + // path rewrite on the entry that did resolve. Otherwise a retry against a + // different base directory would silently keep the first one. + EXPECT_THROW(configure_decoders(mc, base), std::runtime_error); + EXPECT_EQ(mc.decoders[0].stim_dem_path, original_path); +} + +TEST(ConfigureDecodersLifecycle, RejectsReconfigurationWhileSessionActive) { + using namespace cudaq::qec::decoding::config; + // A CPU decoder gives a HOST-mode session, which needs no GPU. + ScopedEnv realtime_mode("CUDAQ_QEC_REALTIME_MODE", "inproc_rpc"); + + multi_decoder_config first; + first.decoders.push_back(create_test_sample_realtime_decoder_config(0)); + ASSERT_EQ(configure_decoders(first), 0); + ASSERT_NE(cudaq::qec::decoding::host::get_realtime_session(), nullptr); + + // The session holds a reference to the decoder vector and inspects it at + // initialize(), so replacing decoders underneath it is unsafe. Reject + // instead, without resolving, destroying, replacing or publishing anything. + multi_decoder_config second; + second.decoders.push_back(create_test_sample_realtime_decoder_config(0)); + EXPECT_EQ(configure_decoders(second), 5); + EXPECT_NE(cudaq::qec::decoding::host::get_realtime_session(), nullptr); + + // Finalizing first is the supported way to reconfigure. + finalize_decoders(); + EXPECT_EQ(configure_decoders(second), 0); + finalize_decoders(); +} + +TEST(ResolveDecoderInputs, DetectorMapIndicesMustBeRepresentable) { + auto config = create_test_empty_decoder_config(0); + // Only -1 terminates a row; anything else must be a measurement index that + // fits the sparse index type. Narrowing would alias onto a real measurement. + config.D_sparse = { + static_cast(std::numeric_limits::max()), -1}; + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path()), + std::runtime_error); + + auto negative = create_test_empty_decoder_config(0); + negative.D_sparse = {-2, -1}; + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + negative, std::filesystem::current_path()), + std::runtime_error); +} + +TEST(DecodingServerAcceptance, + ServerLoadsFileRelativeDemThroughSessionRegistry) { + if (cudaq::qec::decoding::config::find_decoder_schema("chromobius") == + nullptr) + GTEST_SKIP() << "chromobius plugin not built in this configuration"; + + // A config directory holding both the document and its model, so the model + // is findable only by resolving relative to the document. + const auto dir = std::filesystem::temp_directory_path() / + ("cudaqx_server_acc_" + std::to_string(::getpid())); + std::filesystem::create_directories(dir); + struct Cleanup { + std::filesystem::path dir; + ~Cleanup() { + std::error_code ec; + std::filesystem::remove_all(dir, ec); + } + } cleanup{dir}; + + std::ofstream(dir / "model.dem") << "error(0.1) D0 L0\n" + "error(0.1) D0 D1 L1\n" + "error(0.1) D1 L2\n" + "detector(0, 0, 0, 0) D0\n" + "detector(0, 0, 0, 1) D1\n"; + + cudaq::qec::decoding::config::decoder_config config; + config.id = 0; + config.type = "chromobius"; + config.stim_dem_path = "model.dem"; // relative to the document, not the CWD + config.D_sparse = {0, -1, 1, -1}; + cudaq::qec::decoding::config::multi_decoder_config multi; + multi.decoders.push_back(config); + const auto config_path = dir / "decoders.yml"; + std::ofstream(config_path) << multi.to_yaml_str(200); + + // Run from somewhere else entirely, so a CWD-relative resolution fails. + const auto previous_cwd = std::filesystem::current_path(); + std::filesystem::current_path(std::filesystem::temp_directory_path()); + struct RestoreCwd { + std::filesystem::path path; + ~RestoreCwd() { std::filesystem::current_path(path); } + } restore{previous_cwd}; + + // The model is genuinely unreachable from the working directory, so this + // fixture fails unless the registry resolves against the document. + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path()), + std::runtime_error); + + cudaq::qec::decoding_server::SessionRegistry registry; + ASSERT_NO_THROW(registry.load_from_config(config_path.string())); + EXPECT_NO_THROW((void)registry.get(0)); +} + +TEST(DecodingServerAcceptance, ChromobiusConstructsFromRawDemSource) { + if (cudaq::qec::decoding::config::find_decoder_schema("chromobius") == + nullptr) + GTEST_SKIP() << "chromobius plugin not built in this configuration"; + + // The reference case from quantumlib/chromobius: two detectors carrying + // colour coordinates, three error mechanisms, three observables. + ScopedDemFile dem("error(0.1) D0 L0\n" + "error(0.1) D0 D1 L1\n" + "error(0.1) D1 L2\n" + "detector(0, 0, 0, 0) D0\n" + "detector(0, 0, 0, 1) D1\n"); + + cudaq::qec::decoding::config::decoder_config config; + config.id = 0; + config.type = "chromobius"; + config.stim_dem_path = dem.path().string(); + config.D_sparse = {0, -1, 1, -1}; + + // Round-trip so this is provably a configuration the server would accept. + auto parsed = cudaq::qec::decoding::config::decoder_config::from_yaml_str( + config.to_yaml_str(200)); + EXPECT_EQ(parsed.stim_dem_path, config.stim_dem_path); + EXPECT_TRUE(parsed.H_sparse.empty()); + + auto inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( + parsed, std::filesystem::current_path()); + ASSERT_TRUE(inputs.has_stim_dem()); + + auto decoder = cudaq::qec::decoding::host::create_realtime_decoder( + parsed, std::move(inputs)); + ASSERT_NE(decoder, nullptr); + EXPECT_EQ(decoder->get_num_observables(), 3u); + EXPECT_EQ(decoder->get_default_output(), + cudaq::qec::decoder_output::observables); + + // Decoding works off the DEM-derived detector basis, and returns one entry + // per observable the DEM declares. + ASSERT_EQ(decoder->get_syndrome_size(), 2u); + auto result = decoder->decode( + std::vector(decoder->get_syndrome_size(), 0.0)); + EXPECT_EQ(result.result.size(), 3u); +} + TEST(DecoderConfigTest, CreateRealtimeDecoderRequiresDetectorMatrix) { auto config = create_test_sample_realtime_decoder_config(0); config.D_sparse.clear(); - EXPECT_THROW(cudaq::qec::decoding::host::create_realtime_decoder(config), + EXPECT_THROW(cudaq::qec::decoding::host::create_realtime_decoder( + config, cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path())), std::runtime_error); } @@ -805,7 +1239,9 @@ TEST(DecoderConfigTest, CreateRealtimeDecoderRejectsUnrepresentableId) { config.id = static_cast(std::numeric_limits::max()) + 1; - EXPECT_THROW(cudaq::qec::decoding::host::create_realtime_decoder(config), + EXPECT_THROW(cudaq::qec::decoding::host::create_realtime_decoder( + config, cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path())), std::invalid_argument); } From b6160cb79124c9464f7636e77c99587649c9200b Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Mon, 3 Aug 2026 10:13:22 -0700 Subject: [PATCH 05/24] Prove the decoding-server acceptance cases Four end-to-end cases now pin the contracts the resolution work exists to provide. A matrix-source plugin stays usable both through configure_decoders and offline, with no decoder-specific framework change between them. Construction inputs agree across the two paths. The models are built independently -- the server resolves a configuration, the offline side builds decoder_inputs from the same matrices -- because reusing the handle the server produced would only prove an object equals itself. The fixture carries a repeated detector index, so the paths must agree on GF(2) collapse too; the test fails when the server stops canonicalizing, which is the class of divergence nothing observable end to end reveals. Chromobius is configured and constructed for the decoding server from a raw DEM named relative to its configuration file, loaded through the session registry rather than by calling the resolver directly. An earlier version of this test bypassed the registry and so missed that the registry resolved every model against the working directory. TensorRT nests Chromobius while the authoritative DEM survives its input derivation. Chromobius refuses to build from matrices alone, so construction succeeding is the assertion; the converse case, with the same engine and child on a matrix-only model, must fail. Also document, where the model file is read, that identifying a model by path means an in-place edit leaves the configuration unchanged and a reload keeps serving the previous model. Closing that needs the reload path to compare model content, which belongs with the transactional reload work that owns configuration comparison. Signed-off-by: Melody Ren --- libs/qec/lib/realtime/realtime_decoding.cpp | 10 ++ libs/qec/unittests/CMakeLists.txt | 4 + .../decoders/trt_decoder/test_trt_decoder.cpp | 82 +++++++++++ libs/qec/unittests/test_decoders_yaml.cpp | 135 +++++++++++++++++- 4 files changed, 230 insertions(+), 1 deletion(-) diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index 34286f5a9..fce737d14 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -268,6 +268,16 @@ cudaq::qec::decoder_inputs resolve_decoder_inputs( std::string dem_text((std::istreambuf_iterator(dem_file)), std::istreambuf_iterator()); + // Known gap, deliberately not addressed here: the model is identified by + // path, and a reload compares configurations. An operator who edits a DEM + // in place leaves the configuration byte-identical, so the reload sees no + // change and keeps serving the previous model. Closing this needs the + // reload path to compare model content (a hash in the effective + // configuration) or to always reconstruct decoders that reference an + // external file. That belongs with the transactional reload work, which + // owns configuration comparison; until then, change the path to change the + // model. + auto inputs = cudaq::qec::decoder_inputs::from_stim_dem(std::move(dem_text), std::move(D)); diff --git a/libs/qec/unittests/CMakeLists.txt b/libs/qec/unittests/CMakeLists.txt index 186bee874..f6c376f46 100644 --- a/libs/qec/unittests/CMakeLists.txt +++ b/libs/qec/unittests/CMakeLists.txt @@ -133,6 +133,10 @@ if(CUDAQ_QEC_TRT_DECODER_ENABLED AND CMAKE_SYSTEM_PROCESSOR MATCHES "(x86_64)|(A ) target_link_libraries(test_trt_decoder PRIVATE GTest::gtest_main cudaq-qec-decoders cudaq-qec-trt-decoder CUDA::cudart ${TENSORRT_LIBRARY}) + # Chromobius is the DEM-native child the raw-DEM nesting case needs. + if(TARGET cudaq-qec-chromobius) + target_link_libraries(test_trt_decoder PRIVATE cudaq-qec-chromobius) + endif() find_package(Python COMPONENTS Interpreter QUIET) if(Python_Interpreter_FOUND) diff --git a/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp b/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp index 41170e327..1b57cd56b 100644 --- a/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp +++ b/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp @@ -8,6 +8,7 @@ #include "trt_test_data.h" #include "cudaq/qec/decoder.h" +#include "cudaq/qec/decoder_config_schema.h" #include "cudaq/qec/trt_decoder_internal.h" #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include @@ -761,6 +763,86 @@ TEST_F(TRTDecoderTest, RejectsGlobalDecoderSyndromeMismatchAtConstruction) { std::runtime_error); } +// Acceptance 4: TensorRT constructs Chromobius as its global child while the +// authoritative raw DEM survives the derivation. Chromobius refuses to build +// from matrices alone, so construction succeeding IS the assertion that raw +// provenance reached the child through TensorRT's input derivation. +TEST_F(TRTDecoderTest, NestsChromobiusPreservingRawDem) { + if (!gpu_available()) + GTEST_SKIP() << "No CUDA GPU available"; + if (cudaq::qec::decoding::config::find_decoder_schema("chromobius") == + nullptr) + GTEST_SKIP() << "chromobius plugin not built in this configuration"; + auto onnx_path = get_dynamic_onnx_asset_path(); + if (!onnx_path || !std::filesystem::exists(*onnx_path)) + GTEST_SKIP() << "Generated dynamic ONNX fixture is unavailable"; + + // Two coloured detectors and one observable, so the engine's + // [observable, residual detectors] output is three wide, matching the + // dynamic fixture the other composite tests use. + const auto dem_path = + std::filesystem::temp_directory_path() / + ("cudaqx_trt_chromobius_" + std::to_string(::getpid()) + ".dem"); + std::ofstream(dem_path) << "error(0.1) D0 L0\n" + "error(0.1) D0 D1\n" + "error(0.1) D1 L0\n" + "detector(0, 0, 0, 0) D0\n" + "detector(0, 0, 0, 1) D1\n"; + struct Cleanup { + std::filesystem::path path; + ~Cleanup() { + std::error_code ec; + std::filesystem::remove(path, ec); + } + } cleanup{dem_path}; + + std::ifstream dem_file(dem_path); + std::string dem_text((std::istreambuf_iterator(dem_file)), + std::istreambuf_iterator()); + auto inputs = decoder_inputs::from_stim_dem(dem_text); + ASSERT_TRUE(inputs.has_stim_dem()); + ASSERT_EQ(inputs.num_detectors(), 2u); + ASSERT_EQ(inputs.num_observables(), 1u); + + cudaqx::heterogeneous_map params; + params.insert("onnx_load_path", *onnx_path); + params.insert("engine_output_format", + std::string("observables_and_residual_detectors")); + params.insert("batch_size", std::size_t{1}); + params.insert("use_cuda_graph", false); + params.insert("global_decoder", std::string("chromobius")); + params.insert("global_decoder_params", cudaqx::heterogeneous_map{}); + + std::unique_ptr composite; + try { + composite = decoder::get("trt_decoder", inputs, decoder_output::observables, + params); + } catch (const std::exception &e) { + GTEST_SKIP() << "TensorRT engine build unavailable: " << e.what(); + } + ASSERT_NE(composite, nullptr); + EXPECT_EQ(composite->get_num_observables(), 1u); + + // The engine fixes the input width, as in the other composite tests; a + // decode exercises the child on the residual detectors it is handed. + auto result = composite->decode({0.0, 0.0, 0.0}); + EXPECT_EQ(result.result.size(), 1u); + + // The converse, so the case above cannot pass for an unrelated reason: with + // the same engine and child but a matrix-only model, there is no DEM to hand + // down and construction must fail. + cudaqx::tensor H({2, 3}); + H.at({0, 0}) = 1; + H.at({1, 1}) = 1; + cudaqx::tensor O({1, 3}); + O.at({0, 0}) = 1; + EXPECT_THROW((void)decoder::get("trt_decoder", + decoder_inputs(sparse_binary_matrix(H), + sparse_binary_matrix(O)), + decoder_output::observables, params), + std::runtime_error); +} + TEST_F(TRTDecoderTest, CompositeGlobalDecoderCombinesLogicalFrame) { // TRT emits [pre_L, residual syndrome]; the optional global decoder decodes // the residual part and XORs it with pre_L to form the final observable. diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index 5db8c178d..d8271ffd5 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -27,6 +27,23 @@ namespace cudaq::qec { /// Records the measurement-to-detector map exactly as a plugin sees it at /// construction, so a test can pin what the construction inputs carry. +/// The whole model exactly as the plugin received it, so two construction +/// paths can be compared field by field. +struct captured_model { + std::vector> H; + std::vector> O; + bool has_observable_model = false; + std::vector rates; + std::vector> D; + bool has_d = false; + bool has_stim_dem = false; + std::size_t num_detectors = 0; + std::size_t num_error_mechanisms = 0; + std::size_t num_observables = 0; + + bool operator==(const captured_model &) const = default; +}; + struct construction_d_probe { static inline bool has_d = false; static inline std::vector> rows; @@ -34,6 +51,8 @@ struct construction_d_probe { /// Detector syndrome handed to decode(), i.e. D as the realtime path applies /// it, so a test can compare that against the construction copy above. static inline std::vector last_decode_syndrome; + + static inline captured_model model; }; class d_capture_decoder : public decoder { @@ -41,7 +60,8 @@ class d_capture_decoder : public decoder { d_capture_decoder(decoder_inputs inputs, decoder_output default_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), default_output) { - const auto *D = get_inputs().measurement_to_detectors(); + const auto &in = get_inputs(); + const auto *D = in.measurement_to_detectors(); construction_d_probe::has_d = D != nullptr; construction_d_probe::rows.clear(); construction_d_probe::num_cols = 0; @@ -49,6 +69,21 @@ class d_capture_decoder : public decoder { construction_d_probe::rows = D->to_nested_csr(); construction_d_probe::num_cols = D->num_cols(); } + + captured_model captured; + captured.H = in.detector_error_matrix().canonicalize().to_nested_csr(); + captured.has_observable_model = in.has_observable_model(); + if (captured.has_observable_model) + captured.O = in.observable_flips_matrix().canonicalize().to_nested_csr(); + captured.rates = in.error_rates(); + captured.has_d = D != nullptr; + if (D) + captured.D = D->to_nested_csr(); + captured.has_stim_dem = in.has_stim_dem(); + captured.num_detectors = in.num_detectors(); + captured.num_error_mechanisms = in.num_error_mechanisms(); + captured.num_observables = in.num_observables(); + construction_d_probe::model = std::move(captured); } decoder_result decode(const std::vector &syndrome) override { @@ -1010,6 +1045,104 @@ TEST(ConfigureDecodersLifecycle, ConstructionFailureIsNotAdvertised) { finalize_decoders(); } +// Acceptance 2: a plugin must receive the same model at construction whether it +// is built offline or through the decoding server. This is the test that finds +// divergences between the two construction paths -- the class of defect where +// one path canonicalized a matrix and the other did not, which was invisible +// end to end because only the plugin could see both. +TEST(DecodingServerAcceptance, + ConstructionInputsAgreeAcrossOfflineAndServerPaths) { + using namespace cudaq::qec::decoding::config; + + auto config = create_test_empty_decoder_config(0); + config.type = "d_capture_decoder"; + config.error_rate_vec = std::vector(config.block_size, 0.01); + // A duplicate index, so the two paths must agree on GF(2) collapse too. + config.D_sparse = {9, 9, 2, -1, 0, -1, 1, -1, 2, -1, 3, + -1, 4, -1, 5, -1, 6, -1, 7, -1, 8, -1}; + + // Server path: resolve the configuration, then construct through the factory. + auto server_inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path()); + auto server_decoder = cudaq::qec::decoding::host::create_realtime_decoder( + config, server_inputs); + ASSERT_NE(server_decoder, nullptr); + const auto server_model = cudaq::qec::construction_d_probe::model; + + // Offline path: build the model the way an offline caller does, directly from + // the same matrices. Reusing the handle the server just produced would only + // prove that an object equals itself. + auto offline_H = cudaq::qec::pcm_from_sparse_vec( + config.H_sparse, config.syndrome_size, config.block_size); + const auto offline_num_obs = + std::count(config.O_sparse.begin(), config.O_sparse.end(), -1); + auto offline_O = cudaq::qec::pcm_from_sparse_vec( + config.O_sparse, offline_num_obs, config.block_size); + std::vector> offline_d_rows; + { + std::vector row; + for (std::int64_t entry : config.D_sparse) { + if (entry < 0) { + offline_d_rows.push_back(std::move(row)); + row.clear(); + } else { + row.push_back(static_cast(entry)); + } + } + } + std::uint32_t offline_measurements = 0; + for (const auto &r : offline_d_rows) + for (auto c : r) + offline_measurements = std::max(offline_measurements, c + 1); + auto offline_D = cudaq::qec::sparse_binary_matrix::from_nested_csr( + static_cast(offline_d_rows.size()), + offline_measurements, offline_d_rows) + .canonicalize(); + cudaq::qec::decoder_inputs offline_inputs( + std::move(offline_H), std::move(offline_O), config.error_rate_vec, + std::move(offline_D)); + + auto offline_decoder = + cudaq::qec::decoder::get("d_capture_decoder", offline_inputs, + cudaq::qec::decoder_output::observables); + ASSERT_NE(offline_decoder, nullptr); + const auto offline_model = cudaq::qec::construction_d_probe::model; + + EXPECT_EQ(server_model, offline_model); + // And the model is complete, not merely equal: all four fields present. + EXPECT_FALSE(server_model.H.empty()); + EXPECT_TRUE(server_model.has_observable_model); + EXPECT_EQ(server_model.rates.size(), config.block_size); + EXPECT_TRUE(server_model.has_d); + // The duplicate pair cancelled on both paths. + EXPECT_EQ(server_model.D[0], std::vector{2}); +} + +// Acceptance 1: an H-based plugin remains usable offline and through the server +// without any decoder-specific framework change. Uses the same registered +// plugin on both routes and asserts each produces a working decoder. +TEST(DecodingServerAcceptance, MatrixSourcePluginWorksOfflineAndOnServer) { + using namespace cudaq::qec::decoding::config; + + auto config = create_test_sample_realtime_decoder_config(0); + + // Server route. + multi_decoder_config multi; + multi.decoders.push_back(config); + ASSERT_EQ(configure_decoders(multi), 0); + finalize_decoders(); + + // Offline route, same plugin and the same resolved model. + auto inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( + config, std::filesystem::current_path()); + auto offline = cudaq::qec::decoder::get(config.type, inputs, + cudaq::qec::decoder_output::errors); + ASSERT_NE(offline, nullptr); + auto result = offline->decode( + std::vector(config.syndrome_size, 0.0)); + EXPECT_EQ(result.result.size(), config.block_size); +} + TEST(ConfigureDecodersLifecycle, AppliedConfigurationStoresAnAbsoluteModelPath) { using namespace cudaq::qec::decoding::config; From 86fc90224b829911d89046a4643284dc55e86fd5 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Mon, 3 Aug 2026 10:45:32 -0700 Subject: [PATCH 06/24] Document model data as distinct from decoder parameters The API docs still described the contract this work replaced. The PyMatching page listed O and error_rate_vec under the decoder's parameter map and said that supplying O makes the decoder return observable flips instead of an error vector. Result form is now fixed at construction and requested explicitly; supplying an observable model does not by itself change it. Both are model data, routed into the construction inputs rather than the parameter map, and PyMatching's registered schema declares only merge_strategy. The realtime configuration page showed error_rate_vec inside decoder_custom_args, which is now rejected as an unknown key: model data belongs to decoder_config alongside H_sparse, O_sparse and D_sparse. The sliding-window page had the same misplacement, and its C++ example passed error_rate_vec in the parameter map. That example no longer works: the decoder reads its priors from the model, with no parameter fallback, so it would throw on an empty rate vector. The example now builds decoder_inputs. The Python example is unaffected because the binding routes those keys into the inputs. Signed-off-by: Melody Ren --- docs/sphinx/api/qec/pymatching_api.rst | 28 ++++++++++++------- .../api/qec/python_realtime_decoding_api.rst | 19 +++++++++---- docs/sphinx/api/qec/sliding_window_api.rst | 21 ++++++++------ 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/docs/sphinx/api/qec/pymatching_api.rst b/docs/sphinx/api/qec/pymatching_api.rst index 224d567db..cf55cb478 100644 --- a/docs/sphinx/api/qec/pymatching_api.rst +++ b/docs/sphinx/api/qec/pymatching_api.rst @@ -45,18 +45,26 @@ :param H: Parity check matrix. Each column must have one or two set entries (matchable graph). In Python, a ``scipy.sparse`` matrix or a dense NumPy ``uint8`` array may be passed. - :param params: Heterogeneous map of parameters: + :param O: Observable-flips matrix, ``num_observables x block_size``. + Model data supplied alongside ``H``, not a decoder parameter. + Supplying it also defaults ``merge_strategy`` to + ``"independent"``, matching PyMatching's detector-error-model + construction. + :param error_rate_vec: Per-error prior probabilities, one per column of + ``H`` (length ``block_size``). Model data, like ``H`` and ``O``: + it describes the noise model rather than tuning the algorithm. + Each value must lie in ``(0, 0.5]`` and sets the matching edge + weight ``-log(p / (1 - p))``. When omitted, all edge weights + default to ``1.0``. + :param output: The result form this decoder instance produces, fixed at + construction: ``"errors"`` (default) for an error frame of length + ``block_size``, or ``"observables"`` for predicted observable + flips. Supplying ``O`` does not by itself change the result form; + ask for the form you want. A decoder constructed for observable + output without an observable model is rejected at construction. + :param params: Heterogeneous map of decoder parameters: - - `error_rate_vec` (vector): Per-error prior probabilities, one - per column of ``H`` (length ``block_size``). Each value must lie in - ``(0, 0.5]`` and sets the matching edge weight ``-log(p / (1 - p))``. - When omitted, all edge weights default to ``1.0``. - `merge_strategy` (string): How to combine parallel edges that map to the same pair of detectors. One of ``"disallow"`` (default for the ``H``-only path), ``"independent"``, ``"smallest_weight"``, ``"keep_original"``, or ``"replace"``. - - `O` (tensor, optional): A ``num_observables x block_size`` binary - matrix. When provided, the decoder returns predicted observable flips - (``decode_to_obs``) instead of a raw error vector, and - ``merge_strategy`` defaults to ``"independent"`` to match PyMatching's - detector-error-model construction. diff --git a/docs/sphinx/api/qec/python_realtime_decoding_api.rst b/docs/sphinx/api/qec/python_realtime_decoding_api.rst index fff1b44ec..b895295a9 100644 --- a/docs/sphinx/api/qec/python_realtime_decoding_api.rst +++ b/docs/sphinx/api/qec/python_realtime_decoding_api.rst @@ -82,17 +82,24 @@ out-of-tree decoder plugins. Use ``cudaq_qec.decoder_param_schema(name)`` to inspect a decoder's parameters and ``cudaq_qec.registered_decoder_schemas()`` to list all decoders with registered schemas. -For example, the ``pymatching`` decoder accepts ``error_rate_vec`` -(per-error prior probabilities in the range ``(0, 0.5]``, length matching -the decoder ``block_size``) and ``merge_strategy`` (one of ``"disallow"``, -``"independent"``, ``"smallest_weight"``, ``"keep_original"``, -``"replace"``): +Model data is not a decoder parameter. ``H_sparse``, ``O_sparse``, +``D_sparse`` and ``error_rate_vec`` describe the model every decoder decodes +against, so they are fields of ``decoder_config`` itself; a decoder's +parameters tune its algorithm. Supplying model data under +``decoder_custom_args`` is rejected as an unknown key. + +For example, the ``pymatching`` decoder's only parameter is +``merge_strategy`` (one of ``"disallow"``, ``"independent"``, +``"smallest_weight"``, ``"keep_original"``, ``"replace"``), while its prior +probabilities are model data: .. code-block:: python config.type = "pymatching" + # Model data: per-error priors in (0, 0.5], one per column of H. + config.error_rate_vec = [0.1, 0.1, 0.1] + # Decoder parameters: how the algorithm behaves. config.decoder_custom_args = { - "error_rate_vec": [0.1, 0.1, 0.1], "merge_strategy": "smallest_weight", } diff --git a/docs/sphinx/api/qec/sliding_window_api.rst b/docs/sphinx/api/qec/sliding_window_api.rst index 38ee15392..5ca103fbb 100644 --- a/docs/sphinx/api/qec/sliding_window_api.rst +++ b/docs/sphinx/api/qec/sliding_window_api.rst @@ -96,14 +96,18 @@ auto inner_decoder_params = cudaqx::heterogeneous_map{{"use_osd", true}, {"max_iterations", 50}}; auto opts = cudaqx::heterogeneous_map{ - {"error_rate_vec", dem.error_rates}, {"window_size", 1}, {"num_syndromes_per_round", code->get_num_z_stabilizers() + code->get_num_x_stabilizers()}, {"num_boundary_syndromes", code->get_num_z_stabilizers()}, {"inner_decoder_name", "single_error_lut"}, {"inner_decoder_params", inner_decoder_params}}; - auto swdec = cudaq::qec::get_decoder("sliding_window", - dem.detector_error_matrix, opts); + // Priors are model data, so they travel with H rather than in + // the parameter map. + auto inputs = cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix(dem.detector_error_matrix), + std::nullopt, dem.error_rates); + auto swdec = + cudaq::qec::get_decoder("sliding_window", inputs, opts); return 0; } @@ -114,12 +118,13 @@ for C++, so it supports all the methods in those respective classes. :param H: Parity check matrix (tensor format) - :param params: Heterogeneous map of parameters: + :param error_rate_vec: Per-error prior probabilities, one per column of + ``H`` (length ``block_size``), each in the 0-1 range. Model data + supplied alongside ``H``, not a decoder parameter. The decoder + slices it to each window's error columns and passes the slice to + that window's inner decoder as part of its model. + :param params: Heterogeneous map of decoder parameters: - - `error_rate_vec` (double): Vector of length "block size" containing - the probability of an error (in 0-1 range). This vector is used to - populate the `error_rate_vec` parameter for the inner decoder - (automatically sliced correctly according to each window). - `window_size` (int): The number of rounds of syndrome data in each window. (Defaults to 1.) - `step_size` (int): The number of rounds to advance the window by each time. (Defaults to 1.) - `num_syndromes_per_round` (int): The number of syndromes per round. (Must be provided.) From 95f18f092cf6cd9ba04c6fb263d5b3cf423e5d7a Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Mon, 3 Aug 2026 12:46:40 -0700 Subject: [PATCH 07/24] Project a Stim DEM straight to sparse, and drop the provenance string Resolving a DEM model went through the materialized detector_error_model, which allocates a dense detectors x mechanisms tensor that the sparse conversion then scans back out. The parser already collects per-error hit lists, and those lists are exactly H's compressed columns, so the dense form was a round trip. On a distance-13 model it cost a ~99 MiB transient to retain 4.5 MiB, and it was pure waste for a DEM-native decoder such as Chromobius, which reads the raw text and never looks at the matrices. Split the parse from the projection. dem_from_stim_text keeps its public dense contract; a library-private helper builds CSC and CSR directly into their compressed arrays. Nonzero totals are accumulated and range-checked in size_t before anything is sized or cast, so an oversized model is rejected rather than wrapping a pointer array and overrunning the index buffer. The declaration lives in a private header, not an installed one, and is explicitly hidden because this library does not set CXX_VISIBILITY_PRESET. It returns a named struct rather than a tuple: a return type is not part of a mangled symbol, so a per-translation-unit declaration could drift and still link, and H and O share a type, so positional results could be transposed while still type-checking. The equivalence test compares H and O through decoder_inputs::from_stim_dem rather than through the helper, so a projection wired incorrectly into the handle cannot pass. Separately, remove provenance_loss_reason. The invariant it decorated is worth keeping: a basis-changing derivation must drop the authoritative source, because it describes the parent's detector and error indices. derive_with_changed_basis already carries that meaning by being a distinct operation from the basis-preserving ones, so the mandatory free-form sentence added no correctness, had no production reader, and put public API around a hypothetical diagnostic consumer. A decoder that needs the raw source can say so precisely on its own behalf. The test now asserts the invariant in both directions instead of asserting the sentence. Verified with the environment documented in Building.md, after ninja install and a forced rebuild of the nvq++ sources: ctest 461/461; pytest 293 passed, 0 failed, 42 skipped. Transient allocation for a distance-13 model measured at 2.2 MiB against 4.5 MiB retained, from 99.3 MiB. Signed-off-by: Melody Ren --- libs/qec/include/cudaq/qec/decoder_inputs.h | 21 ++- libs/qec/lib/decoder_inputs.cpp | 43 ++---- libs/qec/lib/decoders/sliding_window.cpp | 10 +- libs/qec/lib/dem_sparse_projection.h | 51 +++++++ libs/qec/lib/detector_error_model.cpp | 153 +++++++++++++++++--- libs/qec/unittests/test_decoders.cpp | 57 +++++++- 6 files changed, 269 insertions(+), 66 deletions(-) create mode 100644 libs/qec/lib/dem_sparse_projection.h diff --git a/libs/qec/include/cudaq/qec/decoder_inputs.h b/libs/qec/include/cudaq/qec/decoder_inputs.h index 76af067f4..a97286f7e 100644 --- a/libs/qec/include/cudaq/qec/decoder_inputs.h +++ b/libs/qec/include/cudaq/qec/decoder_inputs.h @@ -116,26 +116,26 @@ class decoder_inputs { /// /// Basis-preserving: `canonicalize()` sorts indices within each compressed /// group and XOR-merges duplicates, leaving column identity, ordering and - /// dimensions unchanged. Authoritative source, raw DEM provenance and any - /// existing provenance-loss reason are therefore all retained, whatever the - /// source kind. Consumers that need a canonical H should ask for it here - /// rather than rebuilding a matrix-authoritative handle by hand. + /// dimensions unchanged. The authoritative source is therefore retained, + /// whatever its kind. Consumers that need a canonical H should ask for it + /// here rather than rebuilding a matrix-authoritative handle by hand. decoder_inputs canonicalized() const; /// @brief Make child inputs after a detector/error-basis transformation. - /// Compact provenance is intentionally dropped because it no longer - /// describes the supplied matrices; `provenance_loss_reason` records why. + /// + /// The authoritative source is dropped: a raw DEM describes the parent's + /// detector and error indices, so it no longer applies once those are + /// re-indexed, and handing it down would let a child decode against a model + /// that does not match its own matrices. Derivations that preserve the basis + /// (`canonicalized`, `without_measurement_to_detectors`) keep it. decoder_inputs derive_with_changed_basis( sparse_binary_matrix detector_error_matrix, std::optional observable_flips_matrix, std::vector error_rates, std::optional> error_ids, - std::string provenance_loss_reason, std::optional measurement_to_detectors = std::nullopt) const; - std::optional provenance_loss_reason() const noexcept; - bool has_stim_dem() const noexcept; /// @throws std::logic_error if the authoritative source is not a Stim DEM. @@ -159,8 +159,7 @@ class decoder_inputs { std::vector error_rates, std::optional> error_ids, std::optional measurement_to_detectors, - std::optional raw_stim_dem = std::nullopt, - std::optional provenance_loss_reason = std::nullopt); + std::optional raw_stim_dem = std::nullopt); explicit decoder_inputs(std::shared_ptr state); std::shared_ptr state_; }; diff --git a/libs/qec/lib/decoder_inputs.cpp b/libs/qec/lib/decoder_inputs.cpp index 8ef2b7532..55ce02e19 100644 --- a/libs/qec/lib/decoder_inputs.cpp +++ b/libs/qec/lib/decoder_inputs.cpp @@ -7,6 +7,7 @@ ******************************************************************************/ #include "cudaq/qec/decoder_inputs.h" +#include "dem_sparse_projection.h" #include #include @@ -25,7 +26,6 @@ struct decoder_inputs::impl { std::optional> ids; std::optional D; std::optional raw_stim_dem; - std::optional provenance_loss_reason; }; namespace { @@ -56,8 +56,7 @@ std::shared_ptr decoder_inputs::make_matrix_state( std::optional O, std::vector rates, std::optional> ids, std::optional D, - std::optional raw_stim_dem, - std::optional provenance_loss_reason) { + std::optional raw_stim_dem) { H = H.to_csc(); if (O) *O = O->to_csr(); @@ -76,7 +75,6 @@ std::shared_ptr decoder_inputs::make_matrix_state( state->ids = std::move(ids); state->D = std::move(D); state->raw_stim_dem = std::move(raw_stim_dem); - state->provenance_loss_reason = std::move(provenance_loss_reason); return state; } @@ -108,13 +106,15 @@ decoder_inputs::decoder_inputs( decoder_inputs decoder_inputs::from_stim_dem( std::string stim_dem_text, std::optional measurement_to_detectors) { - auto model = dem_from_stim_text(stim_dem_text); + // Project straight to sparse. Going through the materialized + // detector_error_model would allocate a dense detectors x mechanisms tensor + // only to scan it back out again: ~98 MiB for a distance-13 model whose + // sparse form is under 1 MiB, and wasted entirely for a DEM-native decoder. + auto [H, O, error_rates] = details::sparse_dem_from_stim_text(stim_dem_text); return decoder_inputs(make_matrix_state( - decoder_model_source::stim_dem, - sparse_binary_matrix(model.detector_error_matrix), - sparse_binary_matrix(model.observables_flips_matrix), - std::move(model.error_rates), std::move(model.error_ids), - std::move(measurement_to_detectors), std::move(stim_dem_text))); + decoder_model_source::stim_dem, std::move(H), std::move(O), + std::move(error_rates), std::nullopt, std::move(measurement_to_detectors), + std::move(stim_dem_text))); } decoder_inputs::decoder_inputs(std::shared_ptr state) @@ -162,9 +162,9 @@ decoder_inputs::measurement_to_detectors() const noexcept { decoder_inputs decoder_inputs::canonicalized() const { auto H = state_->H.canonicalize().to_csc(); - return decoder_inputs(make_matrix_state( - state_->source, std::move(H), state_->O, state_->rates, state_->ids, - state_->D, state_->raw_stim_dem, state_->provenance_loss_reason)); + return decoder_inputs(make_matrix_state(state_->source, std::move(H), + state_->O, state_->rates, state_->ids, + state_->D, state_->raw_stim_dem)); } decoder_inputs decoder_inputs::without_measurement_to_detectors() const { @@ -177,24 +177,13 @@ decoder_inputs decoder_inputs::derive_with_changed_basis( sparse_binary_matrix H, std::optional O, std::vector error_rates, std::optional> error_ids, - std::string provenance_loss_reason, std::optional measurement_to_detectors) const { - if (provenance_loss_reason.empty()) - throw std::invalid_argument( - "decoder_inputs: a basis-changing derivation requires a provenance " - "loss reason"); + // No raw source is carried through: it indexes the parent's detectors and + // error mechanisms, which these matrices have re-indexed. return decoder_inputs(make_matrix_state( decoder_model_source::matrices, std::move(H), std::move(O), std::move(error_rates), std::move(error_ids), - std::move(measurement_to_detectors), std::nullopt, - std::move(provenance_loss_reason))); -} - -std::optional -decoder_inputs::provenance_loss_reason() const noexcept { - if (!state_->provenance_loss_reason) - return std::nullopt; - return *state_->provenance_loss_reason; + std::move(measurement_to_detectors))); } bool decoder_inputs::has_stim_dem() const noexcept { diff --git a/libs/qec/lib/decoders/sliding_window.cpp b/libs/qec/lib/decoders/sliding_window.cpp index 3a4f8ac76..8ebc94482 100644 --- a/libs/qec/lib/decoders/sliding_window.cpp +++ b/libs/qec/lib/decoders/sliding_window.cpp @@ -21,8 +21,8 @@ namespace { decoder_inputs canonicalize_sliding_window_inputs(decoder_inputs inputs) { // Canonical CSC is the steady-state contract for decode_window's column // slices and validate_inputs's per-column reads. canonicalized() is - // basis-preserving and retains the authoritative source, raw DEM provenance - // and any provenance-loss reason, so no source needs special-casing here. + // basis-preserving and retains the authoritative source, so no source kind + // needs special-casing here. return inputs.canonicalized(); } @@ -205,10 +205,12 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, if (const auto &ids = get_inputs().error_ids()) child_error_ids = std::vector( ids->begin() + first_column, ids->begin() + last_column + 1); + // Slicing detector rows and error columns re-indexes both, so the child + // gets matrices only; any raw source the parent carried does not describe + // them. auto child_inputs = get_inputs().derive_with_changed_basis( sparse_binary_matrix(H_round), std::move(child_O), - std::move(error_vec_mod), std::move(child_error_ids), - "sliding-window child slices detector rows and error columns"); + std::move(error_vec_mod), std::move(child_error_ids)); auto inner_decoder = decoder::get(inner_decoder_name, std::move(child_inputs), decoder_output::errors, inner_decoder_params); diff --git a/libs/qec/lib/dem_sparse_projection.h b/libs/qec/lib/dem_sparse_projection.h new file mode 100644 index 000000000..7fcc0f49a --- /dev/null +++ b/libs/qec/lib/dem_sparse_projection.h @@ -0,0 +1,51 @@ +/****************************************************************-*- 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 "cudaq/qec/sparse_binary_matrix.h" +#include +#include + +// Library-private: shared by detector_error_model.cpp and decoder_inputs.cpp. +// Not installed and not exported, so it adds no plugin-visible API surface. +// Use dem_from_stim_text() for the public materialized model. +// +// One shared declaration rather than a hand-written one per translation unit: +// an ordinary function's return type is not part of its mangled name, so a +// return type that drifted between the definition and a local declaration +// would link cleanly and corrupt the returned object. + +namespace cudaq::qec::details { + +/// The sparse projection of a Stim DEM, in the layouts `decoder_inputs` stores. +/// Named fields rather than a tuple: H and O share a type, so positional +/// results would let them be swapped while still type-checking. +struct sparse_dem_projection { + /// H, detectors x error mechanisms, CSC (one compressed group per error). + sparse_binary_matrix detector_error_matrix; + /// O, observables x error mechanisms, CSR (one compressed group per + /// observable). + sparse_binary_matrix observables_flips_matrix; + std::vector error_rates; +}; + +/// Parse a Stim DEM straight into that sparse projection. +/// +/// The parser already collects per-error hit lists, which are exactly H's +/// compressed columns, so this skips the dense intermediate entirely. That +/// matters at realistic sizes: a distance-13 model's dense H alone is ~98 MiB +/// while its sparse form is under 1 MiB, and a DEM-native decoder such as +/// Chromobius never reads the matrices at all. +/// +/// Hidden explicitly: this library does not set CXX_VISIBILITY_PRESET, so a +/// non-inline symbol would otherwise reach the dynamic symbol table. +__attribute__((visibility("hidden"))) sparse_dem_projection +sparse_dem_from_stim_text(const std::string &dem_text); + +} // namespace cudaq::qec::details diff --git a/libs/qec/lib/detector_error_model.cpp b/libs/qec/lib/detector_error_model.cpp index 4300c2bd6..6337f888a 100644 --- a/libs/qec/lib/detector_error_model.cpp +++ b/libs/qec/lib/detector_error_model.cpp @@ -7,8 +7,10 @@ ******************************************************************************/ #include "cudaq/qec/detector_error_model.h" +#include "dem_sparse_projection.h" #include "cudaq/qec/logger.h" #include "cudaq/qec/pcm_utils.h" +#include "cudaq/qec/sparse_binary_matrix.h" #include "stim.h" @@ -21,8 +23,20 @@ namespace cudaq::qec { -detector_error_model dem_from_stim_text(const std::string &dem_text, - bool use_decomp_suggestions) { +namespace { + +/// What the Stim parse yields before any matrix layout is chosen: per-error +/// detector and observable hit lists, already GF(2)-reduced and sorted. +struct parsed_stim_dem { + std::size_t num_detectors = 0; + std::size_t num_observables = 0; + std::vector> detector_hits; + std::vector> observable_hits; + std::vector error_rates; +}; + +parsed_stim_dem parse_stim_dem(const std::string &dem_text, + bool use_decomp_suggestions) { auto dem = [&dem_text]() { try { return stim::DetectorErrorModel(dem_text); @@ -99,35 +113,140 @@ detector_error_model dem_from_stim_text(const std::string &dem_text, ++instruction_index; }); - const std::size_t num_cols = detector_hits.size(); - if (num_cols == 0) + if (detector_hits.empty()) throw std::runtime_error( "Stim DEM contains no error mechanisms after flattening"); + + parsed_stim_dem parsed; + parsed.num_detectors = num_detectors; + parsed.num_observables = num_observables; + parsed.detector_hits = std::move(detector_hits); + parsed.observable_hits = std::move(observable_hits); + parsed.error_rates = std::move(error_rates); + return parsed; +} + +/// Shared by both projections so an out-of-range id is reported identically +/// whichever one the caller asked for. +void validate_hit_ids(const parsed_stim_dem &parsed) { + for (const auto &hits : parsed.detector_hits) + for (auto det : hits) + if (det >= parsed.num_detectors) + throw std::runtime_error( + "Stim DEM detector id out of range while extracting H"); + for (const auto &hits : parsed.observable_hits) + for (auto ob : hits) + if (ob >= parsed.num_observables) + throw std::runtime_error( + "Stim DEM observable id out of range while extracting O"); +} + +} // namespace + +detector_error_model dem_from_stim_text(const std::string &dem_text, + bool use_decomp_suggestions) { + auto parsed = parse_stim_dem(dem_text, use_decomp_suggestions); + validate_hit_ids(parsed); + const std::size_t num_cols = parsed.detector_hits.size(); + detector_error_model result; result.detector_error_matrix = - cudaqx::tensor({num_detectors, num_cols}); + cudaqx::tensor({parsed.num_detectors, num_cols}); result.observables_flips_matrix = - cudaqx::tensor({num_observables, num_cols}); - result.error_rates = std::move(error_rates); + cudaqx::tensor({parsed.num_observables, num_cols}); + result.error_rates = std::move(parsed.error_rates); for (std::size_t err = 0; err < num_cols; ++err) { - for (auto det : detector_hits[err]) { - if (det >= num_detectors) - throw std::runtime_error( - "Stim DEM detector id out of range while extracting H"); + for (auto det : parsed.detector_hits[err]) result.detector_error_matrix.at({det, err}) ^= 1; - } - for (auto ob : observable_hits[err]) { - if (ob >= num_observables) - throw std::runtime_error( - "Stim DEM observable id out of range while extracting O"); + for (auto ob : parsed.observable_hits[err]) result.observables_flips_matrix.at({ob, err}) ^= 1; - } } return result; } +namespace details { + +sparse_dem_projection sparse_dem_from_stim_text(const std::string &dem_text) { + auto parsed = parse_stim_dem(dem_text, /*use_decomp_suggestions=*/false); + validate_hit_ids(parsed); + + using index_type = sparse_binary_matrix::index_type; + const std::size_t num_cols = parsed.detector_hits.size(); + const auto index_limit = + static_cast(std::numeric_limits::max()); + if (num_cols > index_limit || parsed.num_detectors > index_limit || + parsed.num_observables > index_limit) + throw std::runtime_error( + "Stim DEM dimensions exceed the sparse matrix index type"); + + // Total nonzeros are counted in size_t and checked before anything is sized + // or cast. Accumulating a prefix sum directly in index_type would wrap on an + // oversized model, under-allocate the index array, and then write past its + // end -- memory corruption instead of a clean rejection. + auto total_nnz = [](const std::vector> &groups) { + std::size_t total = 0; + for (const auto &group : groups) + total += group.size(); + return total; + }; + const std::size_t h_nnz = total_nnz(parsed.detector_hits); + const std::size_t o_nnz = total_nnz(parsed.observable_hits); + if (h_nnz > index_limit || o_nnz > index_limit) + throw std::runtime_error( + "Stim DEM nonzero count exceeds the sparse matrix index type"); + + // Fill the compressed arrays directly. Going through nested per-group vectors + // would allocate one small buffer per error mechanism and per observable, and + // then copy them all again while flattening. + + // H: a hit list is already the compressed column for its error mechanism. + std::vector col_ptrs(num_cols + 1, 0); + std::size_t h_running = 0; + for (std::size_t err = 0; err < num_cols; ++err) { + h_running += parsed.detector_hits[err].size(); + col_ptrs[err + 1] = static_cast(h_running); + } + std::vector row_indices(h_nnz); + std::size_t written = 0; + for (const auto &hits : parsed.detector_hits) + for (auto det : hits) + row_indices[written++] = static_cast(det); + + // O is stored by observable, so count each row first, then scatter. Walking + // error mechanisms in order leaves every row's indices ascending. + std::vector observable_counts(parsed.num_observables, 0); + for (const auto &hits : parsed.observable_hits) + for (auto ob : hits) + ++observable_counts[ob]; + std::vector row_ptrs(parsed.num_observables + 1, 0); + std::size_t o_running = 0; + for (std::size_t ob = 0; ob < parsed.num_observables; ++ob) { + o_running += observable_counts[ob]; + row_ptrs[ob + 1] = static_cast(o_running); + } + std::vector col_indices(o_nnz); + std::vector cursor(row_ptrs.begin(), row_ptrs.end() - 1); + for (std::size_t err = 0; err < num_cols; ++err) + for (auto ob : parsed.observable_hits[err]) + col_indices[cursor[ob]++] = static_cast(err); + + sparse_dem_projection projection; + projection.detector_error_matrix = sparse_binary_matrix::from_csc( + static_cast(parsed.num_detectors), + static_cast(num_cols), std::move(col_ptrs), + std::move(row_indices)); + projection.observables_flips_matrix = sparse_binary_matrix::from_csr( + static_cast(parsed.num_observables), + static_cast(num_cols), std::move(row_ptrs), + std::move(col_indices)); + projection.error_rates = std::move(parsed.error_rates); + return projection; +} + +} // namespace details + std::size_t detector_error_model::num_detectors() const { auto shape = detector_error_matrix.shape(); if (shape.size() == 2) diff --git a/libs/qec/unittests/test_decoders.cpp b/libs/qec/unittests/test_decoders.cpp index 79a899e49..d82bf4c5a 100644 --- a/libs/qec/unittests/test_decoders.cpp +++ b/libs/qec/unittests/test_decoders.cpp @@ -181,7 +181,7 @@ TEST(DecoderInputs, RejectsInconsistentDimensions) { std::invalid_argument); } -TEST(DecoderInputs, ChildDerivationPreservesOrRecordsProvenance) { +TEST(DecoderInputs, ChildDerivationKeepsRawSourceOnlyWhenBasisIsUnchanged) { auto inputs = cudaq::qec::decoder_inputs::from_stim_dem( "error(0.1) D0 L0\n", cudaq::qec::sparse_binary_matrix::from_nested_csr(1, 2, {{0, 1}})); @@ -190,17 +190,20 @@ TEST(DecoderInputs, ChildDerivationPreservesOrRecordsProvenance) { EXPECT_TRUE(basis_preserving.has_stim_dem()); EXPECT_EQ(basis_preserving.stim_dem(), inputs.stim_dem()); EXPECT_EQ(basis_preserving.measurement_to_detectors(), nullptr); - EXPECT_FALSE(basis_preserving.provenance_loss_reason().has_value()); auto child_H = cudaq::qec::sparse_binary_matrix::from_nested_csc(1, 1, {{0}}); auto child_O = cudaq::qec::sparse_binary_matrix::from_nested_csr(0, 1, {}); auto basis_changed = inputs.derive_with_changed_basis( - std::move(child_H), std::move(child_O), {0.1}, std::nullopt, - "test changes the detector and error basis"); + std::move(child_H), std::move(child_O), {0.1}, std::nullopt); + // Re-indexing detectors and errors invalidates the raw source, so it is not + // carried into the child. EXPECT_FALSE(basis_changed.has_stim_dem()); - ASSERT_TRUE(basis_changed.provenance_loss_reason().has_value()); - EXPECT_EQ(*basis_changed.provenance_loss_reason(), - "test changes the detector and error basis"); + EXPECT_THROW((void)basis_changed.stim_dem(), std::logic_error); + + // Canonicalization preserves column identity and ordering, so it keeps it. + auto canonical = inputs.canonicalized(); + EXPECT_TRUE(canonical.has_stim_dem()); + EXPECT_EQ(canonical.stim_dem(), inputs.stim_dem()); } TEST(DecoderOutputContract, OutputFormIsImmutablePerInstance) { @@ -1139,6 +1142,46 @@ TEST(StimDemGetDecoder, StillAcceptsParityCheckMatrix) { EXPECT_EQ(d->get_block_size(), 3u); } +// The handle a decoder actually receives must describe exactly the matrices the +// materialized model does. Compared through decoder_inputs rather than the +// internal projection helper, so a mistake wiring the projection into the +// handle cannot pass. The sparse path exists to skip a dense intermediate -- +// a distance-13 dense H is ~98 MiB against under 1 MiB sparse -- not to mean +// something different. +TEST(StimDemGetDecoder, DecoderInputsMatchMaterializedModel) { + const std::string dem_text = R"(error(0.1) D0 L0 +error(0.2) D0 D1 +error(0.05) D1 D2 L1 +error(0.3) D2 D0 L0 L1 +error(0.1) D1 D1 D2 +)"; + + const auto dense = cudaq::qec::dem_from_stim_text(dem_text); + auto inputs = cudaq::qec::decoder_inputs::from_stim_dem(dem_text); + + EXPECT_EQ(inputs.num_detectors(), dense.num_detectors()); + EXPECT_EQ(inputs.num_error_mechanisms(), dense.num_error_mechanisms()); + EXPECT_EQ(inputs.num_observables(), dense.num_observables()); + EXPECT_EQ(inputs.error_rates(), dense.error_rates); + + // Content, compared through a common dense view so a layout difference + // cannot hide a content difference. + const auto H = inputs.detector_error_matrix().to_dense(); + ASSERT_EQ(H.shape(), dense.detector_error_matrix.shape()); + for (std::size_t r = 0; r < H.shape()[0]; ++r) + for (std::size_t c = 0; c < H.shape()[1]; ++c) + EXPECT_EQ(H.at({r, c}), dense.detector_error_matrix.at({r, c})) + << "H differs at (" << r << ", " << c << ")"; + + ASSERT_TRUE(inputs.has_observable_model()); + const auto O = inputs.observable_flips_matrix().to_dense(); + ASSERT_EQ(O.shape(), dense.observables_flips_matrix.shape()); + for (std::size_t r = 0; r < O.shape()[0]; ++r) + for (std::size_t c = 0; c < O.shape()[1]; ++c) + EXPECT_EQ(O.at({r, c}), dense.observables_flips_matrix.at({r, c})) + << "O differs at (" << r << ", " << c << ")"; +} + TEST(StimDemGetDecoder, RepeatedDetectorOrObservableTargetsXorFold) { const std::string dem_text = R"(error(0.1) D0 D0 D1 error(0.1) L0 L0 D2 From cece030753840449d6b01b6b3f671b647e2462c5 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Mon, 3 Aug 2026 13:43:57 -0700 Subject: [PATCH 08/24] Own O- and D-derived allocation at construction decoder_inputs was already authoritative at construction, but the base still exposed set_O_sparse and set_D_sparse, so O and D could also arrive afterwards. That left two ways to supply the same model and made the contract unfalsifiable from the outside: a reader could not tell whether the new inputs eliminated late injection or merely sat beside it. Neither setter was a setter. set_O_sparse sized the corrections buffer; set_D_sparse sized the measurement buffer, the detector buffers and the streaming layer offsets, and reset the round counters. They were allocation and lifecycle disguised as assignment, re-enterable on a live decoder. The base constructor now derives all of it from the model: D and its measurement width, the measurement buffer, the detector buffers, and the corrections buffer. A decoder is usable as soon as it exists. Layer geometry is the one thing the model does not determine. It describes how a decoder consumes rounds, and the base cannot ask a subclass for it while that subclass is still being constructed, so the base previously recovered it with a dynamic_cast to sliding_window -- generic code naming a specific decoder. sliding_window now hands it over from its own constructor through a protected, construction-only initializer, guarded by a one-shot latch: without the latch, "call it only during construction" is a convention, and the mid-stream buffer reset this change removes would still be reachable. The error-frame correction path and get_num_observables read the model's O rather than a separately installed copy, so the two can no longer disagree. Every caller supplies a complete model to the factory; the setters, their hooks and the protected matrices are gone. Deleting set_O_sparse breaks the private nv-qldpc decoder, which calls it on itself. That is accepted here: this change exists to show the intended shape, and that decoder is ported separately. Verified with the environment documented in Building.md, with CUDAQ_REALTIME_ROOT set so the realtime-gated targets build, after ninja install and a forced rebuild of the nvq++ sources: ctest 496/496; pytest 293 passed, 0 failed, 42 skipped. The six PyMatchingRealtime tests that previously exercised the setters build and pass against construction- supplied inputs. Construction cost for a distance-13 model is unchanged at 4.6 MiB retained and 2.0 MiB transient. Signed-off-by: Melody Ren --- libs/qec/include/cudaq/qec/decoder.h | 66 +++--- libs/qec/lib/decoder.cpp | 203 +++++++----------- libs/qec/lib/decoders/sliding_window.cpp | 10 + libs/qec/lib/realtime/realtime_decoding.cpp | 11 +- .../backend-specific/stim/test_qec_stim.cpp | 26 ++- .../pymatching/test_pymatching_realtime.cpp | 21 +- .../qldpc_config_loader.cpp | 43 +++- libs/qec/unittests/test_decoders.cpp | 78 ++++--- .../unittests/test_decoding_server_core.cpp | 59 +++-- .../hololink_qldpc_graph_decoder_bridge.cpp | 43 +++- 10 files changed, 310 insertions(+), 250 deletions(-) diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index fce060d91..6792a8087 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -284,8 +284,10 @@ class decoder // Note: all of the current realtime decoding API is designed to be used with // hard syndromes. - /// @brief Get the number of measurement syndromes per decode call. This - /// depends on D_sparse, so you must have called set_D_sparse() first. + /// @brief Get the number of measurement syndromes per decode call, i.e. the + /// measurement count of the model's measurement-to-detector map. Zero when + /// the model supplies no such map, because its syndromes are already + /// detectors. uint32_t get_num_msyn_per_decode() const; /// @brief The CUDA device this decoder was pinned to at construction via @@ -294,28 +296,6 @@ class decoder /// creates a decoder is the thread expected to drive its decode calls). int get_cuda_device_id() const { return cuda_device_id_; } - /// @brief Set the observable matrix. - void set_O_sparse(const std::vector> &O_sparse); - - /// @brief Set the observable matrix, using a single long vector with -1 as - /// row terminators. - void set_O_sparse(const std::vector &O_sparse); - - /// @brief Set D from nested rows. The measurement count is inferred as the - /// largest referenced column plus one, so trailing unused columns cannot be - /// represented. - void set_D_sparse(const std::vector> &D_sparse); - - /// @brief Set the D_sparse matrix, using a single long vector with -1 as row - /// terminators. Vector encodings infer the measurement count as the largest - /// referenced column plus one and therefore cannot represent trailing unused - /// measurement columns. - void set_D_sparse(const std::vector &D_sparse); - - /// @brief Set D from a shaped sparse matrix, preserving its exact measurement - /// column count, including trailing unused columns. - void set_D_sparse(const sparse_binary_matrix &D_sparse); - /// @brief Set the decoder id. void set_decoder_id(uint32_t decoder_id); @@ -393,17 +373,27 @@ class decoder float_t *observables, std::size_t observables_size) const; - /// @brief Hook called by both set_D_sparse overloads after base-class buffer - /// setup is complete. Override to react to a new D_sparse without having to - /// call the base-class implementation explicitly. The protected D_sparse - /// member holds the newly set matrix when this is called. - virtual void on_d_sparse_configured() {} - - /// @brief Hook called by both set_O_sparse overloads after base-class buffer - /// setup is complete. Override to react to a new O_sparse without having to - /// call the base-class implementation explicitly. The protected O_sparse - /// member holds the newly set matrix when this is called. - virtual void on_o_sparse_configured() {} + /// @brief Declare that this decoder consumes its realtime input as a stream + /// of detector layers rather than one full syndrome per decode. + /// + /// Everything the realtime path can derive from the model -- D, the + /// measurement buffer, the detector buffers, the corrections buffer -- is + /// sized by the base constructor from `decoder_inputs`. Layer geometry is + /// the exception: it is a property of how the decoder consumes rounds, not + /// of the model, and the base cannot ask a subclass for it while the + /// subclass is still being constructed. A streaming decoder therefore hands + /// it over here, from its own constructor. + /// + /// @param num_syndromes_per_round Width of the widest detector layer, which + /// bounds the per-layer buffers. + /// @param detector_layer_offsets Offsets `[0, w0, w0+w1, ...]`; `back()` + /// must equal the model's detector count. + /// @throws std::logic_error if called more than once. This is construction + /// state, not a reconfiguration point: re-entering it on a live decoder is + /// the mid-stream buffer reset that fixing this lifecycle removed. + void + initialize_streaming_layout(std::size_t num_syndromes_per_round, + std::vector detector_layer_offsets); /// @brief For a classical `[n,k]` code, this is `n`. std::size_t block_size = 0; @@ -411,12 +401,6 @@ class decoder /// @brief For a classical `[n,k]` code, this is `n-k` std::size_t syndrome_size = 0; - /// @brief The decoder's observable matrix in sparse format - std::vector> O_sparse; - - /// @brief The decoder's D matrix in sparse format - std::vector> D_sparse; - /// @brief CUDA device id consumed from the construction parameters by /// decoder::get(); -1 = unpinned. See get_cuda_device_id(). int cuda_device_id_ = -1; diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index d1a4b5a4b..ae3a3f327 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -60,6 +60,14 @@ struct decoder::rt_impl { bool is_sliding_window = false; + /// Set once initialize_streaming_layout() runs, so a second call is rejected + /// rather than silently resetting buffers on a live decoder. + bool streaming_layout_initialized = false; + + /// The model's measurement-to-detector map, by detector row. Empty when the + /// model supplies none, i.e. the decoder is handed detectors directly. + std::vector> measurement_to_detectors; + /// The number of syndromes per round. Only used for sliding window decoder. size_t num_syndromes_per_round = 0; @@ -85,9 +93,22 @@ decoder::decoder(decoder_inputs inputs, decoder_output default_output) inputs_(std::move(inputs)), default_output_(default_output) { syndrome_size = inputs_.num_detectors(); block_size = inputs_.num_error_mechanisms(); - reset_decoder(); + + // Everything the realtime path needs that the model determines is sized + // here, from the model. Nothing arrives later: a decoder is usable as soon + // as it is constructed. + if (const auto *D = inputs_.measurement_to_detectors()) { + if (D->num_rows() != syndrome_size) + throw std::invalid_argument(fmt::format( + "measurement-to-detector map row count ({}) must match the model's " + "detector count ({})", + D->num_rows(), syndrome_size)); + pimpl->measurement_to_detectors = D->to_nested_csr(); + pimpl->num_msyn_per_decode = D->num_cols(); + } pimpl->persistent_detector_buffer.resize(this->syndrome_size); pimpl->persistent_soft_detector_buffer.resize(this->syndrome_size); + reset_decoder(); // We allow detailed logging of decoder stats via the CUDAQ_QEC_DEBUG_DECODER // environment variable or the CUDAQ_LOG_LEVEL=info environment variable. If @@ -102,30 +123,22 @@ void decoder::project_errors_to_observables( const float_t *errors, float_t *observables, std::size_t observables_size) const { // Hot path: one call per shot on the realtime path. Sizes and O-row counts - // are fixed by construction (and by set_O_sparse for the legacy late-bound - // path), so they are not re-checked here. + // are fixed by construction, so they are not re-checked here. There is one + // observable model -- the one this decoder was constructed with -- so there + // is no second source to fall back to. if (observables_size > 0) std::fill(observables, observables + observables_size, float_t{0}); - // Presence, not row count: a supplied zero-row O is a model that projects to - // no observables, which is different from having no observable model at all. - if (inputs_.has_observable_model()) { - const auto &O = inputs_.observable_flips_matrix(); - assert(O.layout() == sparse_binary_matrix_layout::csr); - const auto &ptr = O.ptr(); - const auto &indices = O.indices(); - for (std::size_t row = 0; row < O.num_rows(); ++row) { - bool parity = false; - for (auto pos = ptr[row]; pos < ptr[row + 1]; ++pos) - parity ^= convert_soft_to_hard(errors[indices[pos]]); - observables[row] = static_cast(parity); - } + if (!inputs_.has_observable_model()) return; - } - for (std::size_t row = 0; row < O_sparse.size(); ++row) { + const auto &O = inputs_.observable_flips_matrix(); + assert(O.layout() == sparse_binary_matrix_layout::csr); + const auto &ptr = O.ptr(); + const auto &indices = O.indices(); + for (std::size_t row = 0; row < O.num_rows(); ++row) { bool parity = false; - for (auto col : O_sparse[row]) - parity ^= convert_soft_to_hard(errors[col]); + for (auto pos = ptr[row]; pos < ptr[row + 1]; ++pos) + parity ^= convert_soft_to_hard(errors[indices[pos]]); observables[row] = static_cast(parity); } } @@ -344,38 +357,6 @@ set_sparse_from_vec(const std::vector &vec_in, sparse_out.push_back(std::move(row)); } -void decoder::set_O_sparse(const std::vector> &O_sparse) { - // Presence, not row count: an explicitly supplied zero-row O is a model, and - // the late setter must not be able to silently replace it with a different - // row count. - if (inputs_.has_observable_model() && - O_sparse.size() != inputs_.num_observables()) - throw std::invalid_argument( - "O_sparse row count must match decoder_inputs observable count"); - validate_sparse_column_indices(O_sparse, block_size, "O_sparse"); - this->O_sparse = O_sparse; - this->pimpl->corrections.clear(); - this->pimpl->corrections.resize(get_num_observables()); - on_o_sparse_configured(); -} - -void decoder::set_O_sparse(const std::vector &O_sparse_vec_in) { - std::vector> parsed; - set_sparse_from_vec(O_sparse_vec_in, parsed); - // Presence, not row count: an explicitly supplied zero-row O is a model, and - // the late setter must not be able to silently replace it with a different - // row count. - if (inputs_.has_observable_model() && - parsed.size() != inputs_.num_observables()) - throw std::invalid_argument( - "O_sparse row count must match decoder_inputs observable count"); - validate_sparse_column_indices(parsed, block_size, "O_sparse"); - this->O_sparse = std::move(parsed); - this->pimpl->corrections.clear(); - this->pimpl->corrections.resize(get_num_observables()); - on_o_sparse_configured(); -} - uint32_t decoder::get_num_msyn_per_decode() const { return pimpl->num_msyn_per_decode; } @@ -386,65 +367,35 @@ void decoder::set_decoder_id(uint32_t decoder_id) { uint32_t decoder::get_decoder_id() const { return pimpl->decoder_id; } -template -void set_D_sparse_common(decoder *decoder, - const std::vector> &D_sparse, - uint32_t num_measurements, PimplType *pimpl) { - auto *sw_decoder = dynamic_cast(decoder); - - if (sw_decoder != nullptr) { - pimpl->is_sliding_window = true; - pimpl->num_syndromes_per_round = sw_decoder->get_num_syndromes_per_round(); - // Check if first row is a first-round detector (single syndrome index) - pimpl->has_first_round_detectors = - (D_sparse.size() > 0 && D_sparse[0].size() == 1); - pimpl->current_round = 0; - // Detector-layer offsets for the [B | S...S | B] layout; each streamed - // layer's width comes from these. - const std::size_t num_layers = sw_decoder->get_num_detector_layers(); - pimpl->detector_layer_offsets.resize(num_layers + 1); - for (std::size_t r = 0; r <= num_layers; ++r) - pimpl->detector_layer_offsets[r] = sw_decoder->get_layer_offset(r); - pimpl->detector_layer_index = 0; - // The interior width is the widest layer, so it bounds the buffers. - pimpl->persistent_detector_buffer.resize(pimpl->num_syndromes_per_round); - pimpl->persistent_soft_detector_buffer.resize( - pimpl->num_syndromes_per_round); - - } else { - pimpl->is_sliding_window = false; - if (D_sparse.size() != decoder->get_syndrome_size()) { - throw std::invalid_argument( - fmt::format("D_sparse row count ({}) must match syndrome_size ({})", - D_sparse.size(), decoder->get_syndrome_size())); - } - } - - pimpl->num_msyn_per_decode = num_measurements; - pimpl->msyn_buffer.clear(); - pimpl->msyn_buffer.resize(pimpl->num_msyn_per_decode); - pimpl->msyn_buffer_index = 0; -} - -void decoder::set_D_sparse(const std::vector> &D_sparse) { - this->D_sparse = D_sparse; - set_D_sparse_common(this, D_sparse, calculate_num_msyn_per_decode(D_sparse), - pimpl.get()); - on_d_sparse_configured(); -} - -void decoder::set_D_sparse(const std::vector &D_sparse_vec_in) { - set_sparse_from_vec(D_sparse_vec_in, this->D_sparse); - set_D_sparse_common(this, this->D_sparse, - calculate_num_msyn_per_decode(this->D_sparse), - pimpl.get()); - on_d_sparse_configured(); -} - -void decoder::set_D_sparse(const sparse_binary_matrix &D_sparse) { - this->D_sparse = D_sparse.to_nested_csr(); - set_D_sparse_common(this, this->D_sparse, D_sparse.num_cols(), pimpl.get()); - on_d_sparse_configured(); +void decoder::initialize_streaming_layout( + std::size_t num_syndromes_per_round, + std::vector detector_layer_offsets) { + if (pimpl->streaming_layout_initialized) + throw std::logic_error( + "initialize_streaming_layout() is construction state and may be called " + "only once"); + if (detector_layer_offsets.empty()) + throw std::invalid_argument( + "initialize_streaming_layout() requires at least one detector layer"); + if (detector_layer_offsets.back() != syndrome_size) + throw std::invalid_argument(fmt::format( + "detector layer offsets end at {} but the model has {} detectors", + detector_layer_offsets.back(), syndrome_size)); + + pimpl->is_sliding_window = true; + pimpl->num_syndromes_per_round = num_syndromes_per_round; + // A first-round detector layer references a single measurement per detector. + pimpl->has_first_round_detectors = + !pimpl->measurement_to_detectors.empty() && + pimpl->measurement_to_detectors[0].size() == 1; + pimpl->detector_layer_offsets = std::move(detector_layer_offsets); + pimpl->detector_layer_index = 0; + pimpl->current_round = 0; + // Layers are emitted one at a time, so the widest layer bounds the buffers + // rather than the full detector count. + pimpl->persistent_detector_buffer.resize(num_syndromes_per_round); + pimpl->persistent_soft_detector_buffer.resize(num_syndromes_per_round); + pimpl->streaming_layout_initialized = true; } bool decoder::enqueue_syndrome(const uint8_t *syndrome, @@ -496,9 +447,9 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, // Decode now. if (!pimpl->is_sliding_window) { - for (std::size_t i = 0; i < this->D_sparse.size(); i++) { + for (std::size_t i = 0; i < pimpl->measurement_to_detectors.size(); i++) { pimpl->persistent_detector_buffer[i] = 0; - for (auto col : this->D_sparse[i]) + for (auto col : pimpl->measurement_to_detectors[i]) pimpl->persistent_detector_buffer[i] ^= pimpl->msyn_buffer[col]; } } else { @@ -508,7 +459,7 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, pimpl->persistent_detector_buffer.resize(width); for (std::size_t j = 0; j < width; j++) { uint8_t v = 0; - for (auto col : this->D_sparse[off + j]) + for (auto col : pimpl->measurement_to_detectors[off + j]) v ^= pimpl->msyn_buffer[col]; pimpl->persistent_detector_buffer[j] = v; } @@ -597,20 +548,25 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, case decoder_output::errors: // Error-frame path: decoder returns a block-sized error vector; project // to observables via O_sparse. - if (O_sparse.size() != num_observables) - throw std::runtime_error(fmt::format( - "Observable matrix is not configured: expected {} rows, got {}", - num_observables, O_sparse.size())); + if (!inputs_.has_observable_model()) + throw std::runtime_error( + "Error-frame decoders need an observable model to project through; " + "this one was constructed without one"); if (should_log) for (std::size_t e = 0, E = decoded_values.size(); e < E; e++) if (decoded_values[e]) log_errors.push_back(e); // For each observable, flip its correction once for each predicted error // that flips it (net parity over O_sparse[i]). - for (std::size_t i = 0; i < num_observables; i++) - for (auto col : O_sparse[i]) - if (decoded_values[col]) - flip_correction(i); + { + const auto &O = inputs_.observable_flips_matrix(); + const auto &ptr = O.ptr(); + const auto &indices = O.indices(); + for (std::size_t i = 0; i < num_observables; i++) + for (auto k = ptr[i]; k < ptr[i + 1]; ++k) + if (decoded_values[indices[k]]) + flip_correction(i); + } break; } if (should_log) { @@ -692,8 +648,7 @@ const uint8_t *decoder::get_obs_corrections() const { std::size_t decoder::get_num_observables() const { // The model owns the count whenever it supplies an observable mapping, even // a zero-row one. The late-setter fallback serves only H-only inputs. - return inputs_.has_observable_model() ? inputs_.num_observables() - : O_sparse.size(); + return inputs_.num_observables(); } void decoder::reset_decoder() { diff --git a/libs/qec/lib/decoders/sliding_window.cpp b/libs/qec/lib/decoders/sliding_window.cpp index 8ebc94482..6e2218495 100644 --- a/libs/qec/lib/decoders/sliding_window.cpp +++ b/libs/qec/lib/decoders/sliding_window.cpp @@ -170,6 +170,16 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, validate_inputs(); + // Hand the base its streaming geometry. Everything else the realtime path + // needs came from the model at base construction; layer widths and offsets + // are a property of how this decoder consumes rounds, not of the model, and + // the base cannot ask for them while this constructor is still running. + std::vector detector_layer_offsets(num_detector_layers + 1); + for (std::size_t r = 0; r <= num_detector_layers; ++r) + detector_layer_offsets[r] = get_layer_offset(r); + initialize_streaming_layout(num_syndromes_per_round, + std::move(detector_layer_offsets)); + // Build the per-window inner decoders from the real (unpadded) sub-PCMs. The // boundary-aware round layout is handled by get_pcm_for_rounds. // this->H is canonical CSC (ctor init list), so skip the per-call diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index fce737d14..3c88a36a4 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -359,11 +359,7 @@ std::unique_ptr create_realtime_decoder( CUDA_QEC_INFO("Creating decoder {} of type {}", decoder_config.id, decoder_config.type); - // decoder_inputs is a shared-state handle, so this copy is O(1) and keeps D - // readable after the original is moved into the factory. Copying the matrix - // itself would deep-copy its index vectors for no reason. - const cudaq::qec::decoder_inputs inputs_handle = inputs; - if (!inputs_handle.measurement_to_detectors()) + if (!inputs.measurement_to_detectors()) throw std::runtime_error( "resolved decoder inputs carry no measurement-to-detector map"); auto decoder = @@ -371,11 +367,6 @@ std::unique_ptr create_realtime_decoder( cudaq::qec::decoder_output::observables, prepare_decoder_params(decoder_config)); decoder->set_decoder_id(decoder_config.id); - // O already reached the decoder through the construction inputs; the base - // sized its corrections buffer from them at construction. D still drives the - // realtime buffer allocation, so it is handed over again here, as the same - // matrix the inputs carry. - decoder->set_D_sparse(*inputs_handle.measurement_to_detectors()); // Force plugin initialization before the caller publishes the decoder for // realtime work. This preserves configure_decoders()'s existing behavior. diff --git a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp index fb312210d..025468425 100644 --- a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp +++ b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp @@ -633,13 +633,10 @@ TEST(QECCodeTester, checkRealtimeDecodeFromMemoryCircuit) { } EXPECT_LT(minRow, maxRow); - // Configure the realtime decoder from the decoder_inputs returned by - // full_component(). The server adapter explicitly requests and installs O - // and D; decoder base construction consumes metadata only. + // The decoder_inputs returned by full_component() already carry O and D, so + // construction configures the realtime path completely. There is no second + // step, and nothing to re-supply. auto decoder = cudaq::qec::get_decoder("single_error_lut", inputs); - EXPECT_EQ(decoder->get_num_msyn_per_decode(), 0); - decoder->set_O_sparse(inputs.observable_flips_matrix().to_nested_csr()); - decoder->set_D_sparse(*D); ASSERT_EQ(decoder->get_num_msyn_per_decode(), D->num_cols()); // Stream numCols ancilla per round, then the final data readout. The window @@ -1253,13 +1250,22 @@ TEST(QECCodeTester, checkSlidingWindowRealtimeBoundaryStreaming) { params.insert("straddle_end_round", true); params.insert("inner_decoder_name", std::string("single_error_lut")); params.insert("inner_decoder_params", cudaqx::heterogeneous_map{}); - return cudaq::qec::decoder::get("sliding_window", - cudaq::qec::decoder_inputs{dem}, params); + // O comes from the DEM and D is handed in alongside it, so the model is + // complete before the decoder exists. + std::uint32_t num_measurements = 0; + for (const auto &row : D_sparse) + for (auto col : row) + num_measurements = std::max(num_measurements, col + 1); + return cudaq::qec::decoder::get( + "sliding_window", + cudaq::qec::decoder_inputs{ + dem, cudaq::qec::sparse_binary_matrix::from_nested_csr( + static_cast(D_sparse.size()), + num_measurements, D_sparse)}, + params); }; auto sw = make_sw(); // realtime streaming auto sw_ref = make_sw(); // whole-block reference - sw->set_D_sparse(D_sparse); - sw->set_O_sparse(O_sparse); // Raw measurements: numRounds*numCols ancilla, then numData data, per shot. const std::size_t nShots = 200; diff --git a/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp b/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp index 671c141c5..662dc592f 100644 --- a/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp +++ b/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp @@ -31,17 +31,28 @@ DecoderVec make_pymatching_decoders(const std::vector &h_vec, h.copy(h_vec.data(), {syndrome_size, block_size}); DecoderVec decoders; - auto decoder = - cudaq::qec::decoder::get("pymatching", h, cudaqx::heterogeneous_map{}); - decoder->set_decoder_id(0); + // The whole model up front: an identity measurement-to-detector map and an + // identity observable map, supplied with H rather than installed after. std::vector> d_sparse(syndrome_size); for (std::size_t row = 0; row < syndrome_size; ++row) d_sparse[row].push_back(static_cast(row)); - decoder->set_D_sparse(d_sparse); std::vector> o_sparse(block_size); for (std::size_t row = 0; row < block_size; ++row) o_sparse[row].push_back(static_cast(row)); - decoder->set_O_sparse(o_sparse); + + auto decoder = cudaq::qec::decoder::get( + "pymatching", + cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix(h), + cudaq::qec::sparse_binary_matrix::from_nested_csr( + static_cast(block_size), + static_cast(block_size), o_sparse), + /*error_rates=*/{}, + cudaq::qec::sparse_binary_matrix::from_nested_csr( + static_cast(syndrome_size), + static_cast(syndrome_size), d_sparse)), + cudaqx::heterogeneous_map{}); + decoder->set_decoder_id(0); decoders.push_back(std::move(decoder)); return decoders; } diff --git a/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp b/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp index 921f812ed..fca8fa8f1 100644 --- a/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp +++ b/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp @@ -20,6 +20,33 @@ namespace test_realtime_qldpc { +namespace { +/// Flat, -1-terminated sparse rows (the YAML/config encoding) to a shaped +/// sparse matrix, so O and D can be handed to the decoder as part of its model. +cudaq::qec::sparse_binary_matrix +sparse_matrix_from_flat_rows(const std::vector &flat, + std::uint32_t num_rows) { + std::vector> rows; + std::vector row; + std::uint32_t num_cols = 0; + for (std::int64_t entry : flat) { + if (entry < 0) { + rows.push_back(std::move(row)); + row.clear(); + continue; + } + const auto col = static_cast(entry); + num_cols = std::max(num_cols, col + 1); + row.push_back(col); + } + if (!row.empty()) + rows.push_back(std::move(row)); + rows.resize(num_rows); + return cudaq::qec::sparse_binary_matrix::from_nested_csr(num_rows, num_cols, + rows); +} +} // namespace + namespace { std::string read_file(const std::string &path) { @@ -65,13 +92,23 @@ LoadedDecoder load_decoder_from_yaml(const std::string &yaml_path) { H_tensor.at({r, static_cast(h_col_idx[j])}) = 1; auto params = dec.decoder_custom_args_to_heterogeneous_map(); - auto plugin = decoder::get("nv-qldpc-decoder", H_tensor, params); + // O and D belong to the model, so they are supplied at construction rather + // than installed afterwards. + const auto num_observables = static_cast( + std::count(dec.O_sparse.begin(), dec.O_sparse.end(), -1)); + auto plugin = decoder::get( + "nv-qldpc-decoder", + cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix(H_tensor), + sparse_matrix_from_flat_rows(dec.O_sparse, num_observables), + /*error_rates=*/{}, + sparse_matrix_from_flat_rows(dec.D_sparse, + static_cast(ss))), + params); if (!plugin) throw std::runtime_error( "test_realtime_qldpc_config_loader: decoder::get(\"nv-qldpc-decoder\"," " ...) returned nullptr; is the plugin built and discoverable?"); - plugin->set_D_sparse(dec.D_sparse); - plugin->set_O_sparse(dec.O_sparse); LoadedDecoder out{}; out.decoder = std::move(plugin); diff --git a/libs/qec/unittests/test_decoders.cpp b/libs/qec/unittests/test_decoders.cpp index d82bf4c5a..62cd39add 100644 --- a/libs/qec/unittests/test_decoders.cpp +++ b/libs/qec/unittests/test_decoders.cpp @@ -32,8 +32,8 @@ class decoder_inputs_probe final : public cudaq::qec::decoder { return {true, std::vector(block_size, 0.0)}; } - std::size_t configured_observable_rows() const { return O_sparse.size(); } - std::size_t configured_measurement_rows() const { return D_sparse.size(); } + using cudaq::qec::decoder::get_num_msyn_per_decode; + using cudaq::qec::decoder::get_num_observables; }; class observable_output_probe final : public cudaq::qec::decoder { @@ -111,15 +111,13 @@ TEST(DecoderInputs, PreservesMatrixShapesAndMeasurementMap) { EXPECT_EQ(materialized.observables_flips_matrix.at({1, 2}), 0); auto decoder = cudaq::qec::get_decoder("sample_decoder", inputs); + // Both come from the model handed to the factory; there is no second way to + // supply either, so they cannot disagree with it. EXPECT_EQ(decoder->get_num_observables(), 3); - decoder->set_D_sparse(*inputs.measurement_to_detectors()); EXPECT_EQ(decoder->get_num_msyn_per_decode(), 5); - EXPECT_THROW( - decoder->set_O_sparse(std::vector>{{0}, {2}}), - std::invalid_argument); } -TEST(DecoderInputs, BaseConstructionUsesMetadataWithoutMaterializingMatrices) { +TEST(DecoderInputs, BaseConstructionSizesRealtimeStateFromTheModel) { using matrix = cudaq::qec::sparse_binary_matrix; auto H = matrix::from_nested_csc(2, 3, {{0}, {0, 1}, {1}}); auto O = matrix::from_nested_csr(3, 3, {{0}, {}, {2}}); @@ -128,20 +126,19 @@ TEST(DecoderInputs, BaseConstructionUsesMetadataWithoutMaterializingMatrices) { decoder_inputs_probe decoder( cudaq::qec::decoder_inputs(std::move(H), std::move(O), {}, std::move(D))); - EXPECT_EQ(decoder.configured_observable_rows(), 0); - EXPECT_EQ(decoder.configured_measurement_rows(), 0); + // Construction derives every realtime size from the model. Nothing arrives + // later, so a decoder is usable the moment it exists. EXPECT_EQ(decoder.get_num_observables(), 3); - EXPECT_EQ(decoder.get_num_msyn_per_decode(), 0); - decoder.reset_decoder(); + EXPECT_EQ(decoder.get_num_msyn_per_decode(), 5); const auto *corrections = decoder.get_obs_corrections(); ASSERT_NE(corrections, nullptr); EXPECT_EQ(corrections[0], 0); EXPECT_EQ(corrections[1], 0); EXPECT_EQ(corrections[2], 0); - decoder.set_D_sparse(matrix::from_nested_csr(2, 5, {{0, 1}, {2, 3}})); - EXPECT_THROW(decoder.enqueue_syndrome(std::vector(5, 0)), - std::runtime_error); + // A full measurement volume decodes with no further setup: the model gave + // the base everything the realtime path needs. + EXPECT_TRUE(decoder.enqueue_syndrome(std::vector(5, 0))); } TEST(DecoderInputs, RawStimRemainsAuthoritative) { @@ -394,7 +391,16 @@ TEST(SampleDecoder, RealtimeApiAndDefaultGraphHooks) { constexpr std::size_t block_size = 4; constexpr std::size_t syndrome_size = 2; cudaqx::tensor H({syndrome_size, block_size}); - auto decoder = cudaq::qec::decoder::get("sample_decoder", H); + // The whole model up front: three observables, and a D mapping three + // measurement bits onto the two detectors. + auto decoder = cudaq::qec::decoder::get( + "sample_decoder", cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix(H), + cudaq::qec::sparse_binary_matrix::from_nested_csr( + 3, block_size, {{0}, {}, {2}}), + /*error_rates=*/{}, + cudaq::qec::sparse_binary_matrix::from_nested_csr( + syndrome_size, 3, {{0, 1}, {2}}))); ASSERT_NE(decoder, nullptr); // Plain decoders do not support graph dispatch, and their default graph @@ -406,13 +412,9 @@ TEST(SampleDecoder, RealtimeApiAndDefaultGraphHooks) { decoder->set_decoder_id(7); EXPECT_EQ(decoder->get_decoder_id(), 7u); - decoder->set_D_sparse(std::vector>{{0, 1}, {2}}); + // Both sizes come from the construction model; there is no reapplication + // path, so they cannot be changed under a live decoder. EXPECT_EQ(decoder->get_num_msyn_per_decode(), 3u); - - // Reapply D and O through the flattened YAML-style representation to exercise - // the -1 row separators used by realtime configs. - decoder->set_D_sparse(std::vector{0, 1, -1, 2, -1}); - decoder->set_O_sparse(std::vector{0, -1, -1, 2, -1}); EXPECT_EQ(decoder->get_num_observables(), 3u); // Three measurement bits fill the D buffer and trigger a decode. @@ -1355,12 +1357,12 @@ TEST(EnqueueSyndrome, ObsFrameDecoderUsesResultDirectly) { 2, 4, std::vector>{{0}, {1}}); auto dec = cudaq::qec::decoder::get( "observable_output_probe", - cudaq::qec::decoder_inputs(std::move(H), std::move(O)), + // D maps the two enqueued syndrome bits directly to two detector bits. + cudaq::qec::decoder_inputs( + std::move(H), std::move(O), /*error_rates=*/{}, + cudaq::qec::sparse_binary_matrix::from_nested_csr(2, 2, {{0}, {1}})), cudaq::qec::decoder_output::observables); - // D_sparse maps the two enqueued syndrome bits directly to two detector bits. - dec->set_D_sparse(std::vector>{{0}, {1}}); - bool did_decode = dec->enqueue_syndrome(std::vector{1, 0}); EXPECT_TRUE(did_decode); @@ -1380,11 +1382,11 @@ TEST(EnqueueSyndrome, ObsFrameMultiShotAccumulation) { 2, 4, std::vector>{{0}, {1}}); auto dec = cudaq::qec::decoder::get( "observable_output_probe", - cudaq::qec::decoder_inputs(std::move(H), std::move(O)), + cudaq::qec::decoder_inputs( + std::move(H), std::move(O), /*error_rates=*/{}, + cudaq::qec::sparse_binary_matrix::from_nested_csr(2, 2, {{0}, {1}})), cudaq::qec::decoder_output::observables); - dec->set_D_sparse(std::vector>{{0}, {1}}); - // Shot 1: obs[0]=1, obs[1]=0 -> corrections become [1, 0] EXPECT_TRUE(dec->enqueue_syndrome(std::vector{1, 0})); const uint8_t *corr = dec->get_obs_corrections(); @@ -1415,10 +1417,11 @@ TEST(EnqueueSyndrome, ObsFrameSizeMismatchThrows) { 2, 4, std::vector>{{0}, {1}}); auto dec = cudaq::qec::decoder::get( "observable_output_probe", - cudaq::qec::decoder_inputs(std::move(H), std::move(O)), + cudaq::qec::decoder_inputs( + std::move(H), std::move(O), /*error_rates=*/{}, + cudaq::qec::sparse_binary_matrix::from_nested_csr(3, 3, + {{0}, {1}, {2}})), cudaq::qec::decoder_output::observables); - - dec->set_D_sparse(std::vector>{{0}, {1}, {2}}); // sample_decoder returns all three detector bits as observables. EXPECT_THROW(dec->enqueue_syndrome(std::vector{1, 0, 1}), std::runtime_error); @@ -1445,13 +1448,20 @@ TEST(SlidingWindowDecoder, BaseStreamingCopiesFirstRoundDetectors) { auto H = cudaq::qec::sparse_binary_matrix(pcm); auto O = cudaq::qec::sparse_binary_matrix::from_csr(1, H.num_cols(), {0, 1}, {0}); + // D spans every detector the model declares, as the model requires. Only the + // first-round layer references measurements, so one round of two bits fills + // the measurement volume and drives the first streamed layer. + std::vector> m2d(H.num_rows()); + m2d[0] = {0}; + m2d[1] = {1}; auto decoder = cudaq::qec::decoder::get( "sliding_window", - cudaq::qec::decoder_inputs(std::move(H), std::move(O), - std::vector(pcm.shape()[1], 0.1)), + cudaq::qec::decoder_inputs( + std::move(H), std::move(O), std::vector(pcm.shape()[1], 0.1), + cudaq::qec::sparse_binary_matrix::from_nested_csr( + static_cast(m2d.size()), 2, m2d)), cudaq::qec::decoder_output::observables, params); ASSERT_NE(decoder, nullptr); - decoder->set_D_sparse(std::vector>{{0}, {1}}); std::vector first_round = {1, 0}; EXPECT_FALSE(decoder->enqueue_syndrome(first_round)) diff --git a/libs/qec/unittests/test_decoding_server_core.cpp b/libs/qec/unittests/test_decoding_server_core.cpp index 7ce8136a6..faa6300b8 100644 --- a/libs/qec/unittests/test_decoding_server_core.cpp +++ b/libs/qec/unittests/test_decoding_server_core.cpp @@ -41,16 +41,20 @@ using cudaq::realtime::RPCResponse; class ControlledDecoder final : public cudaq::qec::decoder { public: ControlledDecoder() - : decoder(cudaq::qec::decoder_inputs( - cudaq::qec::sparse_binary_matrix::from_csr( - /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, - /*col_indices=*/{0})), - cudaq::qec::decoder_output::errors) { - set_O_sparse(std::vector>{{0}}); - // One detector is the parity of two incoming measurement bits, so a decode - // completes only after two one-bit enqueue calls. - set_D_sparse(std::vector>{{0, 1}}); - } + : decoder( + cudaq::qec::decoder_inputs( + /*H=*/cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, + {0}), + /*O=*/ + cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), + /*error_rates=*/{}, + // One detector is the parity of two incoming measurement + // bits, so a decode completes only after two one-bit + // enqueue calls. + /*D=*/ + cudaq::qec::sparse_binary_matrix::from_csr(1, 2, {0, 2}, + {0, 1})), + cudaq::qec::decoder_output::errors) {} cudaq::qec::decoder_result decode(const std::vector &syndrome) override { @@ -302,13 +306,20 @@ TEST(SetCudaDeviceForDecode, ImpossibleDeviceThrows) { class MispinnedDecoder final : public cudaq::qec::decoder { public: MispinnedDecoder() - : decoder(cudaq::qec::decoder_inputs( - cudaq::qec::sparse_binary_matrix::from_csr( - /*num_rows=*/1, /*num_cols=*/1, /*row_ptrs=*/{0, 1}, - /*col_indices=*/{0})), - cudaq::qec::decoder_output::errors) { - set_O_sparse(std::vector>{{0}}); - set_D_sparse(std::vector>{{0, 1}}); + : decoder( + cudaq::qec::decoder_inputs( + /*H=*/cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, + {0}), + /*O=*/ + cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), + /*error_rates=*/{}, + // One detector is the parity of two incoming measurement + // bits, so a decode completes only after two one-bit + // enqueue calls. + /*D=*/ + cudaq::qec::sparse_binary_matrix::from_csr(1, 2, {0, 2}, + {0, 1})), + cudaq::qec::decoder_output::errors) { cuda_device_id_ = 1 << 20; } cudaq::qec::decoder_result @@ -342,9 +353,17 @@ TEST(DecodingSessionPinHandshake, PinnedWorkerStartsAndServes) { params.insert("cuda_device_id", 0); auto dec = cudaq::qec::decoder::get( "single_error_lut", - cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), params); - dec->set_O_sparse(std::vector>{{0}}); - dec->set_D_sparse(std::vector>{{0, 1}}); + cudaq::qec::decoder_inputs( + /*H=*/cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), + /*O=*/ + cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), + /*error_rates=*/{}, + // One detector is the parity of two incoming measurement + // bits, so a decode completes only after two one-bit + // enqueue calls. + /*D=*/ + cudaq::qec::sparse_binary_matrix::from_csr(1, 2, {0, 2}, {0, 1})), + params); SyndromeMappingTable table; table[0] = {{}}; diff --git a/libs/qec/unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp b/libs/qec/unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp index 05ed19657..3ccd77f77 100644 --- a/libs/qec/unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp +++ b/libs/qec/unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp @@ -63,6 +63,33 @@ #include "cudaq/qec/realtime/graph_resources.h" #include "cudaq/qec/realtime/sparse_to_csr.h" +namespace { +/// Flat, -1-terminated sparse rows (the YAML/config encoding) to a shaped +/// sparse matrix, so O and D can be handed to the decoder as part of its model. +cudaq::qec::sparse_binary_matrix +sparse_matrix_from_flat_rows(const std::vector &flat, + std::uint32_t num_rows) { + std::vector> rows; + std::vector row; + std::uint32_t num_cols = 0; + for (std::int64_t entry : flat) { + if (entry < 0) { + rows.push_back(std::move(row)); + row.clear(); + continue; + } + const auto col = static_cast(entry); + num_cols = std::max(num_cols, col + 1); + row.push_back(col); + } + if (!row.empty()) + rows.push_back(std::move(row)); + rows.resize(num_rows); + return cudaq::qec::sparse_binary_matrix::from_nested_csr(num_rows, num_cols, + rows); +} +} // namespace + namespace { std::atomic g_stop{false}; @@ -212,13 +239,23 @@ int main(int argc, char *argv[]) { H_tensor.at({r, static_cast(h_col_idx[j])}) = 1; auto params = dec.decoder_custom_args_to_heterogeneous_map(); - auto decoder = cudaq::qec::decoder::get("nv-qldpc-decoder", H_tensor, params); + // O and D belong to the model, so they are supplied at construction rather + // than installed afterwards. + const auto num_observables = static_cast( + std::count(dec.O_sparse.begin(), dec.O_sparse.end(), -1)); + auto decoder = cudaq::qec::decoder::get( + "nv-qldpc-decoder", + cudaq::qec::decoder_inputs( + cudaq::qec::sparse_binary_matrix(H_tensor), + sparse_matrix_from_flat_rows(dec.O_sparse, num_observables), + /*error_rates=*/{}, + sparse_matrix_from_flat_rows(dec.D_sparse, + static_cast(ss))), + params); if (!decoder) { std::cerr << "ERROR: Failed to create nv-qldpc-decoder" << std::endl; return 1; } - decoder->set_D_sparse(dec.D_sparse); - decoder->set_O_sparse(dec.O_sparse); std::vector d_rp, d_ci; cudaq::qec::realtime::sparse_vec_to_csr(dec.D_sparse, d_rp, d_ci); From 608dfd33c9753874089e50c8ef0c94ca8f9ab72b Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Tue, 4 Aug 2026 10:38:54 -0700 Subject: [PATCH 09/24] WIP design_walkthrough.md Signed-off-by: Melody Ren --- design_walkthrough.md | 696 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 696 insertions(+) create mode 100644 design_walkthrough.md diff --git a/design_walkthrough.md b/design_walkthrough.md new file mode 100644 index 000000000..76df76a83 --- /dev/null +++ b/design_walkthrough.md @@ -0,0 +1,696 @@ +# Decoder model inputs: a design walkthrough + +Note to reviewers: + +This draft is not in a mergeable state. The intent is to get the design discussion started and have some concrete examples to look at. The design was done with Tracy's dynamic DEM PR in mind and was meant to be extensible to support dem chunks, though there certainly will be rough edges still. + +## A quick note on H/O/D + +`H` has shape `detectors x error mechanisms`. Column `e` says which detectors fire when error +mechanism `e` occurs; this is the model a matrix-based decoder decodes against. + +`O` has shape `observables x error mechanisms`. The same column `e` says which logical observables +that error mechanism flips. If a decoder predicts an error frame `x`, the observable correction is +`O * x` over GF(2). A decoder such as Chromobius can instead predict those observable flips +directly, but the meaning of O does not change. + +`D` has shape `detectors x raw measurements`. Hardware sends measurement bits; the decoder consumes +detectors. `D * m` over GF(2) is the bridge between those two bases. + +So H and O describe the decoding model. D is dimensionally +bound to H's detector basis, and the base class owns the buffers and preprocessing derived from it. + + +## A quick note about decoders + +This design is based on the standing convention that a decoder is immutable once constructed. E.g., its H, error rate, return type +are set at construction time and are not meant to change during the lifetime of the decoder instantance. + +## The current problem + +All I wanted to do was to enable Chromobius on the decoding server path. + +But the road to Chromobius is fraught with false leads. On baseline `main` the server always builds a plugin from H, while +Chromobius can only be constructed from a raw Stim DEM. So Chromobius works perfectly well through +the offline DEM factory, and unreachable through the server's matrix-only construction +path. Effectively, it is blocked from the decoding server. + +Concretely, the server configuration carries H, O and D, but only H reaches the factory: + +```cpp +// realtime_decoding.cpp, baseline main +auto decoder = cudaq::qec::get_decoder( + decoder_config.type, pcm, prepare_decoder_params(decoder_config)); // pcm is H +decoder->set_decoder_id(decoder_config.id); +decoder->set_O_sparse(decoder_config.O_sparse); // O arrives later +decoder->set_D_sparse(decoder_config.D_sparse); // D arrives later +``` + +Chromobius, meanwhile, accepts the other arm of `decoder_init` and rejects the matrix arm: + +```cpp +// chromobius.cpp, baseline main +const auto *dem_text = std::get_if(&init); +if (!dem_text) + throw std::runtime_error(...); +``` + +The old construction pathway is: + +```cpp +using decoder_init = std::variant; +``` + +This string variant allows Chromobius to be constructable offline. However, the baseline +YAML schema requires the matrix branch unconditionally: + +```cpp +// config.cpp, baseline main +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); +``` +This means that simply teaching the server to choose the string arm would leave two authorities. +A DEM-backed configuration would therefore need to supply H and O even though the DEM already +defines them. H and O might contradict DEM, and checking one against the other when both are supplied +can be expensive depending on the size of H/DEM. + +And then, it gets worse: + +### O arrives by a different road for every decoder + +The realtime path needs O so that error-frame decoders can produce observable corrections. On +baseline main, when and how that O arrives depends on which decoder is being used and which path +the decode is on. In other words, we have an "all roads lead to Rome" situation, with some very +precise "turn left, then right, then left" call-order implications embedded: + +Drawn out, with the worst case at the bottom: + +``` + baseline main - how O reaches a decoder + + offline, PyMatching .... params["O"] ------------> ctor --> this->set_O_sparse() + offline, Chromobius .... (read out of the DEM text; no O argument at all) + realtime, top level .... get_decoder(H, params) --> ctor + `-- then: set_O_sparse() + + realtime, TensorRT with a PyMatching child - the same matrix, three times: + + server --(1)-- params["O"] ---------------------------> TensorRT ctor + server --(2)-- params["global_decoder_params"]["O"] ---> PyMatching child ctor + server --(3)-- set_O_sparse() -------------------------> after construction + ^ + `-- (1) and (2) are selected by hardcoded decoder names +``` + + +That trt+pymatching is the one that should set off an alarm. Common server code knows both a wrapper's +internal parameter convention and a particular child decoder's name, *hardcoded*. This opens the gate that a third party decoder +author will need to modify our source code in order to plug in a different global decoder. {claude, double check this. also, the word "child decoder" is incorrect. did you mean global decoder?}. In addition, O arrives three different times and stored three times. Both plugins convert `params["O"]` and call `set_O_sparse()` on +themselves, so the matrix ends up living in the server's `decoder_config`, in the TensorRT object's +base member, and in the child's base member — having passed through two parameter maps as a *dense +tensor* to get there. The third delivery then overwrites the first copy with the same content!!! + +Under the proposal this collapses to a single road. `decoder_inputs` carries O, TensorRT hands its +child the same inputs through an explicit derivation, and nothing needs to know a decoder's name to +route a matrix. The routing function is the clearest before-and-after in the change: + +```cpp +// prepare_decoder_params(), baseline main - roughly 60 lines, abridged +if (decoder_config.type != "trt_decoder") // decoder name #1 + return params; +... +const bool has_pymatching_global = + params.get("global_decoder") == "pymatching"; // decoder name #2 +params.insert("O", O); +if (has_pymatching_global) + global_decoder_params.insert("O", O); + +// prepare_decoder_params(), proposed - this is the entire function +auto params = decoder_config.decoder_custom_args_to_heterogeneous_map(); +if (decoder_config.cuda_device_id.has_value()) + params.insert("cuda_device_id", decoder_config.cuda_device_id.value()); +return params; +``` + +{claude, your code example above shows something about cuda_device_id. how is that relevant to O at all?} + +That's not all, because: + +### O carries two meanings at once + +In baseline `main`, supplying O means "this is the observable matrix" and "return observables +instead of errors." It can also select a matching strategy in the case of pymatching: + +```cpp +// pymatching.cpp, baseline main — inside `if (params.contains("O"))` +this->set_O_sparse(O_sparse); +this->set_result_type(decode_result_type::decode_to_obs); +decode_to_observables = true; +if (!merge_strategy_explicit) + merge_strategy_enum = pm::MERGE_STRATEGY::INDEPENDENT; // surprise bonus +``` + +A caller cannot supply O as model data while asking for an error +frame, even though that is useful for a server that wants to perform the projection itself. Nor can +the caller discuss output shape without also discussing whether O happened to be present in the config. + + + + + + + + +## The smallest rival design + +There is a much smaller change that unblocks Chromobius, and it deserves to be stated accurately +rather than dismissed. A reviewer will propose it, and they will be right that it is cheaper. + +It is *not* "add a DEM arm to `decoder_init`" — that arm already exists. The real minimal fix is: + +1. add `stim_dem_path` to the server configuration and make the matrix fields optional; +2. read the DEM and pass its text through the existing string arm; +3. derive O from the DEM anyway, and still call `set_O_sparse()`, because the base sizes its + corrections buffer from the O it was handed and would otherwise see zero observables; and +4. keep injecting D through `set_D_sparse()` afterwards, exactly as today. + +Step 3 is the part that is easy to miss, and it is worth being precise about why it is unavoidable +on baseline main: + +```cpp +// decoder.cpp, baseline main — the base's observable count is whatever the setter handed it +void decoder::set_O_sparse(const std::vector> &O_sparse) { + this->O_sparse = O_sparse; + ... + this->pimpl->corrections.resize(O_sparse.size()); +} +``` + +So "make O optional in the schema and select the string arm" does not by itself enable Chromobius on +the server. The decoder would be fine; the base around it would report zero observables. The minimal +fix has to derive O from the DEM and inject it back through the setter — into a decoder that already +read that same O out of the DEM text it was constructed from. + +Which is where the joke writes itself: + +> **The cheap fix has the server derive O from the DEM the decoder already read it from, and then +> inject it back through a setter.** + +One matrix, two authorities, and the one that wins is the one that arrives last. We would be down to +praying that O does not get lost on its way to the decoder. + +I am deliberately not claiming a road count here. Whether that is "a fifth road" or two existing +roads used at once is arguable, and arguing about it would be arguing about the wrong thing. The +uncontestable statement is the one that matters: **the minimal fix delivers O twice, through two +authorities, with no mechanism that checks they agree.** It removes no existing road. + +What it leaves untouched: + +- O still reaches a decoder by every convention it already did, two of them selected by comparing a + decoder's name against a string literal in framework code; +- the same matrix is still delivered three times to a TensorRT decoder with a PyMatching child; +- stable construction data still travels in the untyped parameter bag beside `merge_strategy`, so a + plugin author still has to work out which of their inputs are model and which are knobs; +- output form is still inferred from whether O happened to be present; +- D still arrives after construction, so the base still cannot size realtime state when the + constructor returns; and +- a plugin still cannot rely on O or D during construction, and there is still nothing in the + factory signature that says so. + +Note what the minimal fix is *not*: it is not a Chromobius special case. Selecting a model source by +shape rather than by decoder name is generic, and any DEM-native decoder would benefit. It is a +smaller, legitimately general patch — it simply stops at unblocking a source shape instead of +producing a construction contract anyone else can build against. + +That is the honest trade: a smaller source-shape fix against a larger lifecycle correction. The +smaller option is not absurd, and this proposal should not win on road-count rhetoric. It should win +because the dual authorities it preserves have already produced concrete divergence in this +codebase, twice — which is the next section. + +### Two authorities have already disagreed here, twice + +**First, D.** When the measurement-to-detector map was populated into `decoder_inputs` while +`set_D_sparse()` still fed the realtime path, the two representations disagreed on a repeated index: +one rasterized the row into a dense matrix and collapsed the duplicate, the other XORed it entry by +entry and cancelled it. Nothing observable end to end disagreed, because the realtime path drove +decoding and was self-consistent. Only a plugin reading its own construction inputs could see it. + +**Second, O.** Rather than argue that the same hazard applies to O, we ran it — against intermediate +commit `95f18f09`, where both sources still existed. A model whose O says *error 0 flips observable +0*, and a `set_O_sparse()` call installing a same-shaped O that says the opposite: + +``` +set_O_sparse with a different (same-shaped) O: accepted, no error +enqueue_syndrome decoded: yes + + error frame predicted : e0 = 1 (detector 0 fired) + model O says obs correction : (1, 0) <- error 0 flips observable 0 + realtime corrections produced: (0, 1) + + DIVERGED: the realtime path used the setter's O, not the model's. +``` + +The realtime path emitted the **inverted logical correction**, silently. Nothing rejected the +contradicting matrix, because the guard on that path compares O's *row count* against the model and +never looks at its contents: + +```cpp +// decoder.cpp at 95f18f09 — shape is checked, content is not +if (O_sparse.size() != num_observables) + throw std::runtime_error("Observable matrix is not configured: ..."); +... +for (auto col : O_sparse[i]) // the setter's O decides the correction +``` + +Same signature as the D bug: two representations agreeing on shape, disagreeing on meaning, with +only one of them consulted at the point that matters. For D the consequence was a detector value. +Here it is the logical correction an experiment applies, which is the last place we should be +relying on two copies happening to match. + +> **[remove for PR]** Reproduce at `95f18f09`. Build `cudaq-qec-decoders`, then compile and link: +> +> ```cpp +> auto H = sparse_binary_matrix::from_nested_csc(2, 4, {{0}, {1}, {}, {}}); +> auto O_model = sparse_binary_matrix::from_nested_csr(2, 4, {{0}, {1}}); // e0 -> obs0 +> auto D = sparse_binary_matrix::from_nested_csr(2, 2, {{0}, {1}}); +> cudaq::qec::decoder_inputs inputs(H, O_model, /*rates=*/{}, D); +> auto d = cudaq::qec::decoder::get("single_error_lut", inputs, +> cudaq::qec::decoder_output::errors); +> d->set_O_sparse(std::vector>{{1}, {0}}); // contradiction +> d->set_D_sparse(D); +> d->enqueue_syndrome(std::vector{1, 0}); +> // d->get_obs_corrections() reports (0, 1); the model implies (1, 0). +> ``` +> +> `g++ -std=c++20 probe.cpp -Ilibs/qec/include -Ilibs/core/include -Lbuild/lib -lcudaq-qec-decoders` + +This is why the setters should eventually go — or, at minimum, become strict equality assertions +against the construction input. Two supported ways to supply the same matrix means the matrix can be +two things at once, and shape checks do not catch it. + +That deletion is still the *last* step and a reversible one. It is not the argument. The argument is +the lift. + +## What we propose + +The draft turns stable construction data into a single resolved value that exists before the decoder +does, makes output form an independent construction-time choice, teaches the server to resolve a raw +DEM source generically, and removes the late O/D mutation path. + +In order, with the one contested decision marked: + +1. **Give stable construction input a typed home:** `decoder_inputs`. +2. **Every path resolves it before construction** — offline, top-level server, and nested children + alike. ← *this is the decision being asked for.* +3. **Output form becomes an explicit construction argument**, because O is now a field and can no + longer double as the request. +4. **The base sizes realtime state at construction**, since it finally knows the inputs then; the + sliding-window subclass hands over its own streaming geometry rather than being `dynamic_cast` + to. +5. **The setters are now unused: delete them**, or keep them as assertions. + +Steps 3 through 5 are the coherent consequences this design selects, not deductive necessities, and +each has its own local contract worth examining. Explicit output selection could be built without +`decoder_inputs` at all. The setters could survive step 5 as equality assertions. Someone could +accept unified construction inputs and still dislike the exact streaming-layout handoff in step 4. +Those are all legitimate arguments to have. + +What I am asking is that they be had *after* step 2, not instead of it. Reject step 2 and most of +the rest loses its motivation and the draft roughly halves; accept it and the remaining +disagreements are about API shape, which is a much better conversation than a file count. + +### One construction input, distinct from the knobs + +`decoder_inputs` is a small immutable handle to shared construction state. It owns: + +- H in sparse CSC form; +- optional O in sparse CSR form; +- error rates and optional error IDs, indexed by H column; +- optional D in sparse CSR form; +- the authoritative source kind and, for a Stim source, the raw DEM text; and +- dimensions as metadata, so asking for a size does not force a future compact source to + materialize a matrix. + +The boundary is: **stable construction input describes the decoding problem and this session's input +basis independently of one decoder's implementation; parameters choose how a particular decoder +solves it.** Not every decoder consumes every field. That is fine. `error_rate_vec` belongs here +because it has one entry per H column and comes from the same DEM as H and O. D belongs here because +it is fixed for the session and dimensionally bound to H's detector basis, even though it is not +part of the noise model. `max_iterations` and `merge_strategy` are parameters. + +`decoder_config` does not disappear and it does not magically become pure knobs. It remains the +server's serializable configuration form, including the selected model source. The server resolver +turns that configuration into `decoder_inputs`; the factory and plugin see the normalized runtime +inputs, not the YAML transport representation. + +The handle uses a PIMPL/shared-state representation. That makes copies cheap and leaves room to add +a typed compact source later without changing the handle's object layout. It is not, by itself, a +promise of a versioned cross-release `.so` ABI; we explicitly deferred that problem. + +### Output form is fixed at construction + +O becomes data only. `decoder_output::{errors, observables}` is a separate factory argument and is +fixed for the lifetime of the instance. No output-selection bit is added to each decode call or to +the realtime wire message. + +The plugin validates the combination during construction: + +- an error-producing decoder asked for observables requires O and may call the base projection + helper before returning; +- Chromobius accepts observables and rejects an error-frame request; +- TensorRT validates the request against its engine output format; and +- PyMatching constructs the graph corresponding to the requested form. + +There is one required single-shot virtual, `decode()`. We deliberately removed the experimental +`decode_native`, capability-query and alternate-dispatch machinery that this design effort had +itself introduced: "native" was circular for some wrappers, nobody outside the base queried the +capabilities, and a fixed instance does not need to renegotiate its contract on every shot. Batch +and async keep their previous shape and inherit the instance's fixed output form. + +This does have a cost: if a caller genuinely needs both errors and observables, this pass asks it to +construct two decoder instances. We found no current consumer requiring both, so we chose a simple +contract over a speculative multi-result API. + +### The server resolves one authoritative model source + +The server now accepts two source shapes: + +- **matrix source:** H, O and optional rates, with sizes required to interpret the flat sparse + encoding; or +- **Stim source:** `stim_dem_path`, mutually exclusive with H, O and rates. H, O, rates and sizes + are derived once from the DEM. + +D is orthogonal to that choice and remains required by the current realtime server because its +transport supplies raw measurements. For a DEM source, D's row count is checked against the +DEM-derived detector count. Optional `block_size` and `syndrome_size` values are assertions checked +against the DEM, not competing authorities. + +The raw DEM path is resolved relative to the configuration document (or the working directory for +programmatic/raw-string configuration), made absolute, read, parsed and normalized before decoder +construction. The plugin receives the validated artifact; construction does not re-parse a second +copy. + +This draft deliberately uses an operator-visible filesystem path rather than transporting an +839 KiB DEM in the published configuration payload. That is sufficient for the current +operator-hosted deployment. It does **not** provide remote configuration portability, and editing a +DEM in place without changing the path is invisible to the current reload comparison. Both are real +limitations, recorded here rather than solved. + +### Wrapper decoders and provenance + +Wrappers create child decoders, so they must answer a more interesting question: does the parent's +authoritative source still describe the child's detector and error bases? + +`decoder_inputs` provides distinct operations for the distinct answers: + +- `canonicalized()` preserves dimensions, row/column identity and source provenance; +- `without_measurement_to_detectors()` removes D when a child already receives detectors, while + preserving the model source; and +- `derive_with_changed_basis(...)` accepts new matrices and drops the raw source because the old + DEM indexes the parent's basis, not the child's. + +TensorRT hands a global child the same inputs without D. The reason a raw DEM survives that hop is a +**caller guarantee, not something the code proves**: declaring `engine_output_format` as one of the +residual forms is the caller asserting that the engine emits residual detectors in exactly the H-row +basis and order supplied at construction. The implementation validates width only. A reordered +engine would silently feed the child a permuted syndrome, and a raw-DEM child would then decode it +against the wrong detector identities. The source says so at the declaration site, and supporting +reordered residuals would need an explicit detector mapping this contract does not provide. So the +nesting acceptance test proves provenance reaches the child for a conforming fixture; it does not +verify arbitrary engine orderings. + +Sliding window slices detector rows and error columns for each child, so it uses +`derive_with_changed_basis()` and the raw DEM is dropped. Passing the parent's DEM through that +slice would be worse than losing provenance: it would be confidently wrong. + +We removed the compulsory free-form `provenance_loss_reason` string. The invariant is that a +basis-changing derivation drops the source; no production consumer read the prose explaining why. +If a real diagnostic consumer appears later, it should drive a structured representation, rather +than requiring every wrapper author to write a justification that nothing reads. + +### Who owns realtime allocation + +Once O and D are construction inputs, the base owns everything whose size they determine: + +- the measurement buffer from D's column count; +- D's measurement-to-detector mapping; +- detector and soft-detector buffers from H's row count; and +- observable corrections from O's row count. + +Baseline main already sizes the detector buffers in the base constructor from H's row count, so this +is not a wholesale relocation — the point is that the *remaining* pieces stop depending on setter +call order. + +Who owns what, before and after: + +| state | derived from | baseline main | proposed | +|---|---|---|---| +| H | model | factory argument | factory argument, inside `decoder_inputs` | +| O | model | `params["O"]`, or `set_O_sparse()` after construction | `decoder_inputs` | +| D | session input basis | `set_D_sparse()` after construction | `decoder_inputs` | +| error rates | model | `params["error_rate_vec"]`, beside the knobs | `decoder_inputs` | +| detector buffers | H row count | base constructor | base constructor | +| measurement buffer | D column count | sized by `set_D_sparse()` | base constructor | +| corrections buffer | O row count | sized by `set_O_sparse()` | base constructor | +| streaming layer geometry | the decoder's own choice | `set_D_sparse()` resizes detector buffers after the base `dynamic_cast`s to `sliding_window` | subclass hands it over once | +| output form | the caller's request | `result_type_`, set by decoder-specific constructor behaviour when O is present | explicit factory argument | + +And the lifecycle: + +``` + baseline main + + get_decoder ---> ctor: H only ---> set_O_sparse ---> set_D_sparse ---> realtime-usable + ^ ^ ^ + `----- offline decode() works here; the measurement-stream + path does not, and nothing says so + + proposed + + resolve ---> ctor: inputs + output form + allocation ---> usable + ^ ^ + | `-- the server may still assign an ID, dry-run a + | decode, or let the decoder initialize GPU + | resources lazily. None of that changes what + | the decoder means. + `-- every path produces the same decoder_inputs +``` + +Being precise about that interval, because I overstated it in an earlier draft: a baseline decoder +is perfectly usable for ordinary offline `decode()` the moment its constructor returns. What is +incomplete is the realtime measurement-stream path, which needs O for corrections and D for the +measurement-to-detector conversion. The defect is not that the object is broken — it is that its +readiness depends on a call sequence the type system never mentions. + +Sliding window has one extra construction step: its subclass constructor calls +`initialize_streaming_layout()` with detector-layer offsets and the maximum layer width. That +geometry is not a property of H/O/D; it is how this decoder chooses to consume rounds. The base +cannot obtain it through a virtual call while the subclass is still constructing, so the subclass +hands it over through a one-shot, construction-only latch. + +This deserves scrutiny, because a construction-only method can look a lot like a setter. The +distinction is ownership: it does not inject or replace construction input, it cannot be called +twice, and it exists only for subclass-specific streaming geometry. The payoff is removal of the +base's `dynamic_cast` and all decoder-name/concrete-type knowledge from common +decoder code. + +## What this buys a plugin author + +The `.so` discovery and schema-registration story already worked on main. We should not claim this +PR invented it. What changes is what the registered factory receives. + +Before, an ordinary H-based plugin creator is effectively written against this contract: + +```cpp +create(const decoder_init &init, const heterogeneous_map ¶ms) { + // Extract H or reject the other variant arm. + // O might be in params offline, or appear through a base setter online. + // D arrives only after construction on the server path. +} +``` + +After: + +```cpp +create(decoder_inputs inputs, + std::optional requested_output, + const heterogeneous_map ¶ms) { + // All construction input is present now. Validate the fixed output request and build. +} +``` + +The plugin still registers its factory and custom-argument schema in its own library. It still has +to understand H, or raw DEM, or whichever source it supports. We have not made decoder development +free; we have made the bill itemized. + +The concrete gains are: + +- offline, top-level server and nested construction share one input value; +- stable construction data no longer travels in an untyped decoder-parameter bag; +- a plugin can reject unsupported model/output combinations before becoming live; +- common server code no longer knows decoder names in order to forward O; and +- wrappers have an explicit rule for retaining or dropping authoritative source data. + +The costs are also concrete: + +- the factory signature changes, so every in-tree and private plugin must be ported; +- private nv-qldpc currently calls `set_O_sparse()` and will break until it adopts the constructor + input; +- each plugin owns construction-time validation of the output forms it promises; +- two instances are needed if a caller wants two output forms; and +- this is a large cross-cutting draft, not a merge-sized change. + +We accepted the source and ABI break for current plugins. We did **not** take on a versioned `.so` +ABI or compatibility layer in this round. + +## Performance and memory + +### Per shot: no measured regression result yet + +The code-path analysis is neutral-to-favorable, and it is worth stating what it does *not* claim. +Baseline main already reads its result form from an instance member (`result_type_`), so making it +an immutable construction-time field is a contract improvement rather than a hot-path saving. The +capability dispatch and general per-call validation we removed were introduced during this design +effort and never existed on main, so removing them is not subtractive relative to main either. The +accurate statement is that the final implementation leaves **no new** general validation or +capability dispatch on the hot path. + +The one genuine final-versus-main difference is that projection walks contiguous sparse CSR storage +instead of nested vectors. The accidental full-vector copy introduced in the LUT wrapper was found +and removed. + +But analysis is not a benchmark. A paired end-to-end latency comparison against main has **not** +been recorded for PyMatching, TensorRT and Chromobius. The earlier caller-buffer experiment measured +23,564.4 ns for allocating `decode()` versus 23,351.3 ns for the proposed buffered hook—a 213.2 ns, +0.90% difference—and we rejected the extra API machinery. That number answers the caller-buffer +question; it does not prove the whole branch is "within noise." + +> **[remove for PR]** Before making a per-shot claim, add one reproducible benchmark that runs the +> same model, decoder, output form, warm-up and shot count on baseline main and this branch. Until +> then the honest sentence is: "no regression is expected from code-path analysis; not yet measured." + +### Construction: one large transient was removed + +The DEM parser already collects detector hits per error mechanism. Those hit lists are H's sparse +columns. The first implementation materialized a dense `detectors x mechanisms` tensor and scanned +it back into sparse form; the final path builds CSC/CSR arrays directly. + +Measured on the distance-13 model (`H = 2184 x 47129`): + +| | before | after direct sparse projection | +|---|---:|---:| +| retained model memory | ~4.6 MiB | ~4.6 MiB | +| transient above retained | ~99.3 MiB | ~2.1–2.3 MiB | + +This is a real saving, but it belongs to DEM normalization, not to moving the setters. The lifecycle +redesign is what made the path visible; the sparse projection is what fixed it. Worth attributing +correctly. + +Resolution retains every normalized input before construction. On the measured eight-decoder, +distance-13 configuration, retaining normalized inputs used about 23.1 MiB and resolved in about +1.06 s. Retaining only raw snapshots used about 14.1 MiB but required a second parse at construction, +taking about 2.1 s and violating "validate the artifact you construct." We chose the first strategy: +roughly 9 MiB more for eight decoders to avoid a second derivation and about a second of reload +latency. + +We did **not** construct a complete replacement decoder set beside the live set. That would approach +2x peak decoder memory. Consequently, resolution failure preserves the active configuration, but a +plugin constructor failure can still leave the decoder set empty. The cached/published +configuration is not updated on that failure. This is an explicit memory-versus-transactionality +trade, not an accidental omission. + +## Compatibility with incoming work + +### Chunked or streaming DEM sources + +PR #759 introduces a compact repeated-round description and points toward decoders consuming +chunks without flattening. `decoder_inputs` therefore stores source metadata separately from its +common matrix view and hides its representation behind a PIMPL. The current enum exposes only the +two implemented sources—matrices and raw Stim DEM. A chunked source should be added only with a +typed constructor/accessor and a real consumer, not as a decorative enum value. + +The unresolved contract is what makes a transformation basis-preserving for a compact source, +including whether D still maps into the same detector basis. The current rule gives that future +work somewhere to attach: preserve the authoritative source when detector/error identity survives; +drop it when a transformation re-indexes either basis. + +### Reload work + +Model resolution is side-effect-free and happens before global decoder state is touched. Model +paths are written back as absolute only after construction and session initialization succeed. A +live realtime session rejects reconfiguration instead of destroying decoders that it still +references. + +The remaining reload limitations are deliberately visible: + +- constructor failure does not preserve the old live decoder set because we rejected overlapping + allocations; +- an in-place edit to a DEM at the same path is invisible to bytewise config comparison; and +- a path-based DEM source assumes the server can read the operator's filesystem. + +Those belong in the reload/transport design rather than in a decoder-specific parameter escape +hatch. + +## Acceptance evidence + +The useful acceptance tests are not "did this helper return the object it just built?" They cross +the boundaries where the old paths diverged: + +1. An H-based, out-of-tree-style plugin constructs offline and through the server with no + decoder-name framework branch. +2. A capture plugin observes the same canonical H, O, D and rates at construction offline and + through the server. +3. A server configuration resolves a raw DEM and constructs standalone Chromobius without a + Chromobius branch in server construction. +4. TensorRT nests Chromobius when its derivation preserves the detector/error basis; the converse + matrix-only case fails, proving the test did not pass for an unrelated reason. + +Step 4 adds the lifecycle evidence: the base sizes realtime state from construction inputs, +PyMatching's realtime path constructs through the new contract, and sliding-window streaming works +without a `dynamic_cast` in the base. + +The clean verification built all QEC and core realtime targets from scratch. The focused +realtime set passed 18/18; the full C++ scope had no failures. The qLDPC device-graph test compiled +and linked but skipped execution because the available GPUs are compute capability 8.6 and the test +requires 9.0+. The proprietary nv-qldpc plugin itself is not covered by this public-tree result. + +## Open questions for the draft discussion + +1. **Names.** `get_default_output()` really means the instance's fixed requested/resolved output, + not a default that can later be overridden. The behavior is settled; the name deserves reviewer + input. +2. **Compact-source equivalence.** What exact detector/error-basis guarantees let a wrapper retain + a future chunked source, and how is D's basis relationship represented? +3. **Remote model transport.** Is an operator-local path sufficient for the intended server + deployment, or must a later contract carry content/hash/URI semantics? +4. **Reload transactionality.** Is preserving the old decoder set across constructor failure worth + the 2x peak memory, or should a future reload mechanism construct and swap one decoder at a time? +5. **Private nv-qldpc migration.** Which constructor inputs and graph-dispatch ownership does the + private decoder need once its late O setter is removed? + +Settled questions should stay settled: no per-call output selection, no batch result redesign, no +capability apparatus, no caller-buffer hook, no versioned `.so` ABI in this proposal. There are +enough real questions here without inventing speculative ones. + +## What would falsify this design + +I would change or reject the proposal if review produces evidence that: + +- a supported construction path genuinely cannot know O or D until after the decoder must become + live; +- a current consumer needs one decoder instance to switch output basis per shot, and constructing + two instances is materially unacceptable; +- normalized `decoder_inputs` cannot represent a model needed by an existing target decoder without + eagerly materializing a compact source; +- a wrapper cannot state whether it preserves detector/error identity, making the provenance rule + unusable rather than merely unfinished; +- the paired latency benchmark shows a meaningful per-shot regression attributable to the new + contract; or +- the common factory contract forces decoder-specific knowledge back into the server or base. + +Conversely, "this touches many files" is evidence that the draft needs careful review and likely PR +splitting; it is not by itself evidence that the lifecycle boundary is wrong. The proposal should +survive on whether its invariants are useful, not on how much work went into it. From ed867b43828b331e81c68686461c5df8eafa9d81 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Tue, 4 Aug 2026 13:41:32 -0700 Subject: [PATCH 10/24] Name decoder_inputs operations for what they do decoder_inputs exposed three operations whose names described neither their effect nor their use: without_measurement_to_detectors() -> decoder_inputs_without_d() canonicalized() -> canonicalize_H() canonicalize_H() only canonicalizes H; O and D pass through untouched, which the old name did not suggest. derive_with_changed_basis() is removed. It read nothing from the object it was called on, so it was equivalent to constructing a fresh decoder_inputs from the caller's matrices. Sliding window now does exactly that. Dropping the raw source after a re-index is structural rather than a rule to remember: a matrix-constructed handle has no source to carry. get_default_output() -> get_output(), with default_output_ and the constructor parameters renamed to match. The output form is fixed for the lifetime of the instance; "default" implied it could be overridden later. Local names and comments for wrapped decoders now use each wrapper's own term: global decoder for trt_decoder, inner decoder for sliding_window. Signed-off-by: Melody Ren --- design_walkthrough.md | 630 +++++++----------- libs/qec/include/cudaq/qec/decoder.h | 6 +- libs/qec/include/cudaq/qec/decoder_inputs.h | 36 +- libs/qec/lib/decoder.cpp | 13 +- libs/qec/lib/decoder_inputs.cpp | 17 +- libs/qec/lib/decoders/lut.cpp | 12 +- .../plugins/chromobius/chromobius.cpp | 6 +- .../example/single_error_lut_example.cpp | 6 +- .../plugins/pymatching/pymatching.cpp | 6 +- .../plugins/trt_decoder/trt_decoder.cpp | 65 +- libs/qec/lib/decoders/sliding_window.cpp | 35 +- libs/qec/lib/decoders/sliding_window.h | 2 +- .../decoders/chromobius/test_chromobius.cpp | 3 +- .../qec/unittests/decoders/sample_decoder.cpp | 8 +- .../app_examples/concurrency_test_decoder.cpp | 5 +- .../realtime/test_trt_decoder_composite.cpp | 3 +- libs/qec/unittests/test_decoders.cpp | 44 +- libs/qec/unittests/test_decoders_yaml.cpp | 7 +- 18 files changed, 364 insertions(+), 540 deletions(-) diff --git a/design_walkthrough.md b/design_walkthrough.md index 76df76a83..763fb8443 100644 --- a/design_walkthrough.md +++ b/design_walkthrough.md @@ -2,7 +2,7 @@ Note to reviewers: -This draft is not in a mergeable state. The intent is to get the design discussion started and have some concrete examples to look at. The design was done with Tracy's dynamic DEM PR in mind and was meant to be extensible to support dem chunks, though there certainly will be rough edges still. +This draft is not in a mergeable state. The intent is to get the design discussion started and have some concrete examples to look at. The design was done with Tracy's dynamic DEM PR in mind and was meant to be extensible to support DEM chunks, though there are certainly still rough edges. ## A quick note on H/O/D @@ -17,14 +17,14 @@ directly, but the meaning of O does not change. `D` has shape `detectors x raw measurements`. Hardware sends measurement bits; the decoder consumes detectors. `D * m` over GF(2) is the bridge between those two bases. -So H and O describe the decoding model. D is dimensionally +So H and O describe the decoding model. D is not part of the noise model, but it is dimensionally bound to H's detector basis, and the base class owns the buffers and preprocessing derived from it. ## A quick note about decoders This design is based on the standing convention that a decoder is immutable once constructed. E.g., its H, error rate, return type -are set at construction time and are not meant to change during the lifetime of the decoder instantance. +are set at construction time and are not meant to change during the lifetime of the decoder instance. ## The current problem @@ -32,7 +32,7 @@ All I wanted to do was to enable Chromobius on the decoding server path. But the road to Chromobius is fraught with false leads. On baseline `main` the server always builds a plugin from H, while Chromobius can only be constructed from a raw Stim DEM. So Chromobius works perfectly well through -the offline DEM factory, and unreachable through the server's matrix-only construction +the offline DEM factory, and is unreachable through the server's matrix-only construction path. Effectively, it is blocked from the decoding server. Concretely, the server configuration carries H, O and D, but only H reaches the factory: @@ -61,7 +61,7 @@ The old construction pathway is: using decoder_init = std::variant; ``` -This string variant allows Chromobius to be constructable offline. However, the baseline +This string variant allows Chromobius to be constructible offline. However, the baseline YAML schema requires the matrix branch unconditionally: ```cpp @@ -74,8 +74,7 @@ io.mapRequired("D_sparse", config.D_sparse); ``` This means that simply teaching the server to choose the string arm would leave two authorities. A DEM-backed configuration would therefore need to supply H and O even though the DEM already -defines them. H and O might contradict DEM, and checking one against the other when both are supplied -can be expensive depending on the size of H/DEM. +defines them. H and O might contradict DEM. And then, it gets worse: @@ -84,7 +83,7 @@ And then, it gets worse: The realtime path needs O so that error-frame decoders can produce observable corrections. On baseline main, when and how that O arrives depends on which decoder is being used and which path the decode is on. In other words, we have an "all roads lead to Rome" situation, with some very -precise "turn left, then right, then left" call-order implications embedded: +precise "turn left, then right, then left" call-order implications embedded. Drawn out, with the worst case at the bottom: @@ -96,10 +95,10 @@ Drawn out, with the worst case at the bottom: realtime, top level .... get_decoder(H, params) --> ctor `-- then: set_O_sparse() - realtime, TensorRT with a PyMatching child - the same matrix, three times: + realtime, TensorRT with a PyMatching global decoder - the same matrix, three times: server --(1)-- params["O"] ---------------------------> TensorRT ctor - server --(2)-- params["global_decoder_params"]["O"] ---> PyMatching child ctor + server --(2)-- params["global_decoder_params"]["O"] ---> PyMatching global-decoder ctor server --(3)-- set_O_sparse() -------------------------> after construction ^ `-- (1) and (2) are selected by hardcoded decoder names @@ -107,37 +106,22 @@ Drawn out, with the worst case at the bottom: That trt+pymatching is the one that should set off an alarm. Common server code knows both a wrapper's -internal parameter convention and a particular child decoder's name, *hardcoded*. This opens the gate that a third party decoder -author will need to modify our source code in order to plug in a different global decoder. {claude, double check this. also, the word "child decoder" is incorrect. did you mean global decoder?}. In addition, O arrives three different times and stored three times. Both plugins convert `params["O"]` and call `set_O_sparse()` on -themselves, so the matrix ends up living in the server's `decoder_config`, in the TensorRT object's -base member, and in the child's base member — having passed through two parameter maps as a *dense -tensor* to get there. The third delivery then overwrites the first copy with the same content!!! - -Under the proposal this collapses to a single road. `decoder_inputs` carries O, TensorRT hands its -child the same inputs through an explicit derivation, and nothing needs to know a decoder's name to -route a matrix. The routing function is the clearest before-and-after in the change: +internal parameter convention and a particular global decoder's name, *hardcoded*. This opens the gate that a third party decoder +author will need to modify our source code in order to plug in a different global decoder. Baseline `main` says so itself: ```cpp -// prepare_decoder_params(), baseline main - roughly 60 lines, abridged -if (decoder_config.type != "trt_decoder") // decoder name #1 - return params; -... -const bool has_pymatching_global = - params.get("global_decoder") == "pymatching"; // decoder name #2 -params.insert("O", O); -if (has_pymatching_global) - global_decoder_params.insert("O", O); - -// prepare_decoder_params(), proposed - this is the entire function -auto params = decoder_config.decoder_custom_args_to_heterogeneous_map(); -if (decoder_config.cuda_device_id.has_value()) - params.insert("cuda_device_id", decoder_config.cuda_device_id.value()); -return params; +// realtime_decoding.cpp, baseline main +// PyMatching consumes the observable matrix through its params; other global +// decoders receive only the top-level O until they define a matching contract. +if (has_pymatching_global) { ... global_decoder_params.insert("O", O); } ``` -{claude, your code example above shows something about cuda_device_id. how is that relevant to O at all?} +In addition, O arrives three different times and is stored three times. Both plugins convert `params["O"]` and call `set_O_sparse()` on +themselves, so the matrix ends up living in the server's `decoder_config`, in the TensorRT object's +base member, and in the global decoder's base member — having passed through two parameter maps as a *dense +tensor* to get there. The third delivery then overwrites the first copy with the same content!!! -That's not all, because: +That's not all and I certainly contributed to this, because: ### O carries two meanings at once @@ -157,170 +141,27 @@ A caller cannot supply O as model data while asking for an error frame, even though that is useful for a server that wants to perform the projection itself. Nor can the caller discuss output shape without also discussing whether O happened to be present in the config. - - - - - - - -## The smallest rival design - -There is a much smaller change that unblocks Chromobius, and it deserves to be stated accurately -rather than dismissed. A reviewer will propose it, and they will be right that it is cheaper. - -It is *not* "add a DEM arm to `decoder_init`" — that arm already exists. The real minimal fix is: - -1. add `stim_dem_path` to the server configuration and make the matrix fields optional; -2. read the DEM and pass its text through the existing string arm; -3. derive O from the DEM anyway, and still call `set_O_sparse()`, because the base sizes its - corrections buffer from the O it was handed and would otherwise see zero observables; and -4. keep injecting D through `set_D_sparse()` afterwards, exactly as today. - -Step 3 is the part that is easy to miss, and it is worth being precise about why it is unavoidable -on baseline main: - -```cpp -// decoder.cpp, baseline main — the base's observable count is whatever the setter handed it -void decoder::set_O_sparse(const std::vector> &O_sparse) { - this->O_sparse = O_sparse; - ... - this->pimpl->corrections.resize(O_sparse.size()); -} -``` - -So "make O optional in the schema and select the string arm" does not by itself enable Chromobius on -the server. The decoder would be fine; the base around it would report zero observables. The minimal -fix has to derive O from the DEM and inject it back through the setter — into a decoder that already -read that same O out of the DEM text it was constructed from. - -Which is where the joke writes itself: - -> **The cheap fix has the server derive O from the DEM the decoder already read it from, and then -> inject it back through a setter.** - -One matrix, two authorities, and the one that wins is the one that arrives last. We would be down to -praying that O does not get lost on its way to the decoder. - -I am deliberately not claiming a road count here. Whether that is "a fifth road" or two existing -roads used at once is arguable, and arguing about it would be arguing about the wrong thing. The -uncontestable statement is the one that matters: **the minimal fix delivers O twice, through two -authorities, with no mechanism that checks they agree.** It removes no existing road. - -What it leaves untouched: - -- O still reaches a decoder by every convention it already did, two of them selected by comparing a - decoder's name against a string literal in framework code; -- the same matrix is still delivered three times to a TensorRT decoder with a PyMatching child; -- stable construction data still travels in the untyped parameter bag beside `merge_strategy`, so a - plugin author still has to work out which of their inputs are model and which are knobs; -- output form is still inferred from whether O happened to be present; -- D still arrives after construction, so the base still cannot size realtime state when the - constructor returns; and -- a plugin still cannot rely on O or D during construction, and there is still nothing in the - factory signature that says so. - -Note what the minimal fix is *not*: it is not a Chromobius special case. Selecting a model source by -shape rather than by decoder name is generic, and any DEM-native decoder would benefit. It is a -smaller, legitimately general patch — it simply stops at unblocking a source shape instead of -producing a construction contract anyone else can build against. - -That is the honest trade: a smaller source-shape fix against a larger lifecycle correction. The -smaller option is not absurd, and this proposal should not win on road-count rhetoric. It should win -because the dual authorities it preserves have already produced concrete divergence in this -codebase, twice — which is the next section. - -### Two authorities have already disagreed here, twice - -**First, D.** When the measurement-to-detector map was populated into `decoder_inputs` while -`set_D_sparse()` still fed the realtime path, the two representations disagreed on a repeated index: -one rasterized the row into a dense matrix and collapsed the duplicate, the other XORed it entry by -entry and cancelled it. Nothing observable end to end disagreed, because the realtime path drove -decoding and was self-consistent. Only a plugin reading its own construction inputs could see it. - -**Second, O.** Rather than argue that the same hazard applies to O, we ran it — against intermediate -commit `95f18f09`, where both sources still existed. A model whose O says *error 0 flips observable -0*, and a `set_O_sparse()` call installing a same-shaped O that says the opposite: - -``` -set_O_sparse with a different (same-shaped) O: accepted, no error -enqueue_syndrome decoded: yes - - error frame predicted : e0 = 1 (detector 0 fired) - model O says obs correction : (1, 0) <- error 0 flips observable 0 - realtime corrections produced: (0, 1) - - DIVERGED: the realtime path used the setter's O, not the model's. -``` - -The realtime path emitted the **inverted logical correction**, silently. Nothing rejected the -contradicting matrix, because the guard on that path compares O's *row count* against the model and -never looks at its contents: - -```cpp -// decoder.cpp at 95f18f09 — shape is checked, content is not -if (O_sparse.size() != num_observables) - throw std::runtime_error("Observable matrix is not configured: ..."); -... -for (auto col : O_sparse[i]) // the setter's O decides the correction -``` - -Same signature as the D bug: two representations agreeing on shape, disagreeing on meaning, with -only one of them consulted at the point that matters. For D the consequence was a detector value. -Here it is the logical correction an experiment applies, which is the last place we should be -relying on two copies happening to match. - -> **[remove for PR]** Reproduce at `95f18f09`. Build `cudaq-qec-decoders`, then compile and link: -> -> ```cpp -> auto H = sparse_binary_matrix::from_nested_csc(2, 4, {{0}, {1}, {}, {}}); -> auto O_model = sparse_binary_matrix::from_nested_csr(2, 4, {{0}, {1}}); // e0 -> obs0 -> auto D = sparse_binary_matrix::from_nested_csr(2, 2, {{0}, {1}}); -> cudaq::qec::decoder_inputs inputs(H, O_model, /*rates=*/{}, D); -> auto d = cudaq::qec::decoder::get("single_error_lut", inputs, -> cudaq::qec::decoder_output::errors); -> d->set_O_sparse(std::vector>{{1}, {0}}); // contradiction -> d->set_D_sparse(D); -> d->enqueue_syndrome(std::vector{1, 0}); -> // d->get_obs_corrections() reports (0, 1); the model implies (1, 0). -> ``` -> -> `g++ -std=c++20 probe.cpp -Ilibs/qec/include -Ilibs/core/include -Lbuild/lib -lcudaq-qec-decoders` - -This is why the setters should eventually go — or, at minimum, become strict equality assertions -against the construction input. Two supported ways to supply the same matrix means the matrix can be -two things at once, and shape checks do not catch it. - -That deletion is still the *last* step and a reversible one. It is not the argument. The argument is -the lift. - ## What we propose -The draft turns stable construction data into a single resolved value that exists before the decoder -does, makes output form an independent construction-time choice, teaches the server to resolve a raw -DEM source generically, and removes the late O/D mutation path. +This draft separates decoder model data (H/O/D/DEM/error rate) from decoder knobs (iterations to run, strategy to use etc), +makes O mean O and nothing else, teaches the decoding server to accept either a raw DEM or H/O/D but not both +at the same time, and removes the late O/D setters (this part is debatable but the intention of removal was to keep a single +source of truth). -In order, with the one contested decision marked: +In order: 1. **Give stable construction input a typed home:** `decoder_inputs`. -2. **Every path resolves it before construction** — offline, top-level server, and nested children - alike. ← *this is the decision being asked for.* +2. **Every path resolves it before construction** — an offline caller, a decoder the server builds + directly from a `decoder_config` entry, and a decoder that a wrapper builds internally (TensorRT's + global decoder, sliding window's inner decoders) all resolve the same value. 3. **Output form becomes an explicit construction argument**, because O is now a field and can no longer double as the request. 4. **The base sizes realtime state at construction**, since it finally knows the inputs then; the sliding-window subclass hands over its own streaming geometry rather than being `dynamic_cast` - to. + to. (This part is debatable. I ported sliding window for completeness's sake) 5. **The setters are now unused: delete them**, or keep them as assertions. -Steps 3 through 5 are the coherent consequences this design selects, not deductive necessities, and -each has its own local contract worth examining. Explicit output selection could be built without -`decoder_inputs` at all. The setters could survive step 5 as equality assertions. Someone could -accept unified construction inputs and still dislike the exact streaming-layout handoff in step 4. -Those are all legitimate arguments to have. - -What I am asking is that they be had *after* step 2, not instead of it. Reject step 2 and most of -the rest loses its motivation and the draft roughly halves; accept it and the remaining -disagreements are about API shape, which is a much better conversation than a file count. +We now go over the above statements in detail below: ### One construction input, distinct from the knobs @@ -334,27 +175,81 @@ disagreements are about API shape, which is a much better conversation than a fi - dimensions as metadata, so asking for a size does not force a future compact source to materialize a matrix. -The boundary is: **stable construction input describes the decoding problem and this session's input +The public surface, abridged: + +```cpp +class decoder_inputs { + // Build it from whichever source is authoritative. D is optional: it is only + // meaningful for a decoder fed directly by the measurement transport. + decoder_inputs(sparse_binary_matrix H, + std::optional O = std::nullopt, + std::vector error_rates = {}, + std::optional D = std::nullopt, + std::optional> error_ids = std::nullopt); + + static decoder_inputs from_stim_dem(std::string stim_dem_text, + std::optional D = std::nullopt); + + decoder_model_source source() const noexcept; // which one is authoritative + + // The common matrix view, available whatever the source was. + const sparse_binary_matrix &detector_error_matrix() const; // H + const sparse_binary_matrix &observable_flips_matrix() const; // O + const std::vector &error_rates() const; + const sparse_binary_matrix *measurement_to_detectors() const; // D, or nullptr + + // The raw view, for decoders that want the source itself. + bool has_stim_dem() const noexcept; + const std::string &stim_dem() const; +}; +``` + +A DEM-native decoder reads `stim_dem()`; a matrix decoder reads `detector_error_matrix()`. Both are +looking at one source, which is the point. + +The distinction is: **stable construction input describes the decoding problem and this session's input basis independently of one decoder's implementation; parameters choose how a particular decoder solves it.** Not every decoder consumes every field. That is fine. `error_rate_vec` belongs here because it has one entry per H column and comes from the same DEM as H and O. D belongs here because it is fixed for the session and dimensionally bound to H's detector basis, even though it is not -part of the noise model. `max_iterations` and `merge_strategy` are parameters. +part of the noise model. On the other hand, `max_iterations` and `merge_strategy` are *parameters*, specific to how one +particular decoder solves the problem. `decoder_config` does not disappear and it does not magically become pure knobs. It remains the -server's serializable configuration form, including the selected model source. The server resolver -turns that configuration into `decoder_inputs`; the factory and plugin see the normalized runtime -inputs, not the YAML transport representation. +server's serializable configuration form, including the selected model source. What changes is where +the YAML stops. Today the plugin sees fragments of the config: a flat `-1`-delimited sparse vector +here, a dense tensor in a parameter map there, a matrix arriving after construction. Under the +proposal the server converts the config once, into the same `decoder_inputs` an offline caller would +build by hand, and that is the only thing the factory ever sees. No plugin author needs to know that +`O_sparse` was a flat vector with sentinel values in a YAML file: + +```cpp +// realtime_decoding.cpp, proposed — resolve first, construct second +auto D = canonical_measurement_to_detectors(decoder_config.D_sparse); + +if (!decoder_config.stim_dem_path.empty()) { + // stim_dem_path is mutually exclusive with H_sparse/O_sparse/error_rate_vec: + // one authoritative source, not two representations of the same model. + auto dem_text = read_file(resolve_against(base_dir, decoder_config.stim_dem_path)); + return decoder_inputs::from_stim_dem(std::move(dem_text), std::move(D)); +} +return decoder_inputs::from_matrices(H, O, rates, std::move(D)); // matrix source + +// ...and later, after every decoder's inputs have resolved successfully: +auto decoder = cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), + requested_output, params); +``` + +The resolve step is side-effect-free, so a bad configuration fails before any live decoder is +touched. The handle uses a PIMPL/shared-state representation. That makes copies cheap and leaves room to add -a typed compact source later without changing the handle's object layout. It is not, by itself, a -promise of a versioned cross-release `.so` ABI; we explicitly deferred that problem. +a typed compact source later without changing the handle's object layout. ### Output form is fixed at construction O becomes data only. `decoder_output::{errors, observables}` is a separate factory argument and is -fixed for the lifetime of the instance. No output-selection bit is added to each decode call or to -the realtime wire message. +fixed for the lifetime of the instance. The plugin validates the combination during construction: @@ -364,17 +259,11 @@ The plugin validates the combination during construction: - TensorRT validates the request against its engine output format; and - PyMatching constructs the graph corresponding to the requested form. -There is one required single-shot virtual, `decode()`. We deliberately removed the experimental -`decode_native`, capability-query and alternate-dispatch machinery that this design effort had -itself introduced: "native" was circular for some wrappers, nobody outside the base queried the -capabilities, and a fixed instance does not need to renegotiate its contract on every shot. Batch -and async keep their previous shape and inherit the instance's fixed output form. +A forward-looking note: this design does require the user to construct two decoder instances if they +want both errors and observables as the return type, though that can be expanded later. -This does have a cost: if a caller genuinely needs both errors and observables, this pass asks it to -construct two decoder instances. We found no current consumer requiring both, so we chose a simple -contract over a speculative multi-result API. -### The server resolves one authoritative model source +### The decoding server resolves one authoritative model source The server now accepts two source shapes: @@ -389,47 +278,74 @@ DEM-derived detector count. Optional `block_size` and `syndrome_size` values are against the DEM, not competing authorities. The raw DEM path is resolved relative to the configuration document (or the working directory for -programmatic/raw-string configuration), made absolute, read, parsed and normalized before decoder +programmatic/raw-string configuration), made absolute, read, parsed and normalized *before* decoder construction. The plugin receives the validated artifact; construction does not re-parse a second copy. This draft deliberately uses an operator-visible filesystem path rather than transporting an -839 KiB DEM in the published configuration payload. That is sufficient for the current -operator-hosted deployment. It does **not** provide remote configuration portability, and editing a -DEM in place without changing the path is invisible to the current reload comparison. Both are real -limitations, recorded here rather than solved. - -### Wrapper decoders and provenance - -Wrappers create child decoders, so they must answer a more interesting question: does the parent's -authoritative source still describe the child's detector and error bases? - -`decoder_inputs` provides distinct operations for the distinct answers: - -- `canonicalized()` preserves dimensions, row/column identity and source provenance; -- `without_measurement_to_detectors()` removes D when a child already receives detectors, while - preserving the model source; and -- `derive_with_changed_basis(...)` accepts new matrices and drops the raw source because the old - DEM indexes the parent's basis, not the child's. - -TensorRT hands a global child the same inputs without D. The reason a raw DEM survives that hop is a -**caller guarantee, not something the code proves**: declaring `engine_output_format` as one of the -residual forms is the caller asserting that the engine emits residual detectors in exactly the H-row -basis and order supplied at construction. The implementation validates width only. A reordered -engine would silently feed the child a permuted syndrome, and a raw-DEM child would then decode it -against the wrong detector identities. The source says so at the declaration site, and supporting -reordered residuals would need an explicit detector mapping this contract does not provide. So the -nesting acceptance test proves provenance reaches the child for a conforming fixture; it does not -verify arbitrary engine orderings. - -Sliding window slices detector rows and error columns for each child, so it uses -`derive_with_changed_basis()` and the raw DEM is dropped. Passing the parent's DEM through that -slice would be worse than losing provenance: it would be confidently wrong. - -We removed the compulsory free-form `provenance_loss_reason` string. The invariant is that a -basis-changing derivation drops the source; no production consumer read the prose explaining why. -If a real diagnostic consumer appears later, it should drive a structured representation, rather -than requiring every wrapper author to write a justification that nothing reads. +839 KiB DEM in the published configuration payload. One caveat is that editing a +DEM in place without changing the path is invisible to the current reload comparison. + +### Wrapper decoders + +Two decoders wrap another decoder: TensorRT constructs a **global decoder** to run after +its engine, and sliding window constructs an **inner decoder** per window. Both deserve +a bit of special treatment: + +Concretely, a wrapper may never hand the decoder it constructs a *different problem*. It hands +it the same problem — the same code, the same noise — possibly a slice of it, possibly at a later +stage of the pipeline. That is what `decoder_inputs` is for: the wrapper derives the decoder it +constructs from its own inputs, so there is nothing else it *could* hand over. *This is also where a conflict +with the streaming DEM work is most likely to happen.* + +"Same problem" is not the same as "same bytes," and this is where the shapes differ. PyMatching as a +global decoder wants H and O; Chromobius as a global decoder wants the raw DEM text. They receive the +same `decoder_inputs`, which carries both views of one source, and each reads the representation it +needs. Nobody derives a second model. + +Only two things can legitimately differ between a wrapper's inputs and its constructed decoder's: + +1. **Who feeds it.** Only the decoder the realtime transport feeds directly needs D, because D is + what the base applies to turn an arriving measurement stream into detectors. Anything a wrapper + constructs is fed by that wrapper, never by the transport, so D never travels inward. This is not + a per-wrapper judgement call — it is true of every wrapped decoder. +2. **Indexing.** Did the wrapper renumber detectors or error mechanisms? This decides whether the raw + DEM *text* still reads correctly, because a DEM names its detectors by position. + +TensorRT changes neither. It hands its global decoder the same inputs minus D, since the global +decoder is fed the engine's residual detectors rather than a measurement stream. The detector basis +and ordering are untouched, so the raw DEM still describes them exactly and Chromobius can be the +global decoder. That preservation is a **caller guarantee, not something the code proves**: declaring +`engine_output_format` as one of the residual forms is the caller asserting that the engine emits +residual detectors in exactly the H-row basis and order supplied at construction. The implementation +validates width only. A reordered engine would silently feed the global decoder a permuted syndrome, +and a raw-DEM global decoder would then decode it against the wrong detector identities. The source +says so at the declaration site, and supporting reordered residuals would need an explicit detector +mapping this contract does not provide. + +Sliding window changes indexing. Each window is the same code and the same noise — just a subset of +detector rows and error columns, renumbered from zero. The matrices slice cleanly and carry the +problem faithfully. The raw DEM text does not: it names detectors by the outer numbering, so handing +it to a window would have the inner decoder reading `D17` as its own detector 17 rather than the +outer one. So sliding window slices its matrices and constructs each inner decoder's inputs from +them directly. Passing the outer DEM through that slice would be worse than losing provenance: it +would be confidently wrong. + +That gives two cases, and only one of them needs an operation: + +- **Same numbering, different feed.** `decoder_inputs_without_d()` returns the same inputs without D, + for a decoder that receives detectors rather than a measurement stream. Everything else, the raw + source included, is preserved. TensorRT uses this for its global decoder. +- **New numbering.** The wrapper builds fresh `decoder_inputs` from the matrices it computed. There + is no operation for this and no need for one: a matrix-constructed handle carries no raw source, so + the DEM text is dropped structurally rather than by a rule someone has to remember. + +(`canonicalize_H()` is neither of those. It returns the same inputs with H in GF(2)-canonical form, +and sliding window calls it on *itself* before slicing so that its row and column reads are +well-defined. It is a normalization, not a hand-off.) + +This also allows us to expand into more exotic wrapping schemes by utilizing `decoder_inputs`. + ### Who owns realtime allocation @@ -441,8 +357,8 @@ Once O and D are construction inputs, the base owns everything whose size they d - observable corrections from O's row count. Baseline main already sizes the detector buffers in the base constructor from H's row count, so this -is not a wholesale relocation — the point is that the *remaining* pieces stop depending on setter -call order. +is not a wholesale relocation — the point is that the *remaining* pieces stop depending on *setter +call order*. Who owns what, before and after: @@ -465,8 +381,11 @@ And the lifecycle: get_decoder ---> ctor: H only ---> set_O_sparse ---> set_D_sparse ---> realtime-usable ^ ^ ^ - `----- offline decode() works here; the measurement-stream - path does not, and nothing says so + `----- decode(syndrome) already works here: H is all it + needs. enqueue_syndrome() works too -- but only + once both setters have run. Nothing states that + requirement or enforces it; you are expected to + know the call order. proposed @@ -479,11 +398,7 @@ And the lifecycle: `-- every path produces the same decoder_inputs ``` -Being precise about that interval, because I overstated it in an earlier draft: a baseline decoder -is perfectly usable for ordinary offline `decode()` the moment its constructor returns. What is -incomplete is the realtime measurement-stream path, which needs O for corrections and D for the -measurement-to-detector conversion. The defect is not that the object is broken — it is that its -readiness depends on a call sequence the type system never mentions. +One of the things that the proposal aims to remove is that the decoder's readiness depends on a call sequence the type system only implies. Sliding window has one extra construction step: its subclass constructor calls `initialize_streaming_layout()` with detector-layer offsets and the maximum layer width. That @@ -491,16 +406,7 @@ geometry is not a property of H/O/D; it is how this decoder chooses to consume r cannot obtain it through a virtual call while the subclass is still constructing, so the subclass hands it over through a one-shot, construction-only latch. -This deserves scrutiny, because a construction-only method can look a lot like a setter. The -distinction is ownership: it does not inject or replace construction input, it cannot be called -twice, and it exists only for subclass-specific streaming geometry. The payoff is removal of the -base's `dynamic_cast` and all decoder-name/concrete-type knowledge from common -decoder code. - -## What this buys a plugin author - -The `.so` discovery and schema-registration story already worked on main. We should not claim this -PR invented it. What changes is what the registered factory receives. +## What this buys a plugin author and user of the decoding server Before, an ordinary H-based plugin creator is effectively written against this contract: @@ -522,10 +428,6 @@ create(decoder_inputs inputs, } ``` -The plugin still registers its factory and custom-argument schema in its own library. It still has -to understand H, or raw DEM, or whichever source it supports. We have not made decoder development -free; we have made the bill itemized. - The concrete gains are: - offline, top-level server and nested construction share one input value; @@ -536,71 +438,66 @@ The concrete gains are: The costs are also concrete: -- the factory signature changes, so every in-tree and private plugin must be ported; -- private nv-qldpc currently calls `set_O_sparse()` and will break until it adopts the constructor - input; +- Lots, I mean lots, of code change, not even counting the change needed for the private decoder; - each plugin owns construction-time validation of the output forms it promises; -- two instances are needed if a caller wants two output forms; and -- this is a large cross-cutting draft, not a merge-sized change. -We accepted the source and ABI break for current plugins. We did **not** take on a versioned `.so` -ABI or compatibility layer in this round. ## Performance and memory -### Per shot: no measured regression result yet - -The code-path analysis is neutral-to-favorable, and it is worth stating what it does *not* claim. -Baseline main already reads its result form from an instance member (`result_type_`), so making it -an immutable construction-time field is a contract improvement rather than a hot-path saving. The -capability dispatch and general per-call validation we removed were introduced during this design -effort and never existed on main, so removing them is not subtractive relative to main either. The -accurate statement is that the final implementation leaves **no new** general validation or -capability dispatch on the hot path. - -The one genuine final-versus-main difference is that projection walks contiguous sparse CSR storage -instead of nested vectors. The accidental full-vector copy introduced in the LUT wrapper was found -and removed. +Measured against the merge base, `upstream/main` at `674cb8f2` — using Pymatching -But analysis is not a benchmark. A paired end-to-end latency comparison against main has **not** -been recorded for PyMatching, TensorRT and Chromobius. The earlier caller-buffer experiment measured -23,564.4 ns for allocating `decode()` versus 23,351.3 ns for the proposed buffered hook—a 213.2 ns, -0.90% difference—and we rejected the extra API machinery. That number answers the caller-buffer -question; it does not prove the whole branch is "within noise." +### The realtime path, over UDP -> **[remove for PR]** Before making a per-shot claim, add one reproducible benchmark that runs the -> same model, decoder, output form, warm-up and shot count on baseline main and this branch. Until -> then the honest sentence is: "no regression is expected from code-path analysis; not yet measured." +Benchmarked using `surface_code-1-cqr`. The server is run +with `QEC_DECODING_SERVER_SPIN_US=0` so it blocks rather than busy-polls: the semantics are identical +either way, but its CPU time then measures decode and transport work instead of poll loops. -### Construction: one large transient was removed +| distance 5, 5 rounds, 1000 shots (8000 decodes) | main | proposed | +|---|---:|---:| +| server CPU per decode | 262.5 / 265.0 / 263.8 µs | 256.3 / 257.5 / 256.3 µs | +| server peak RSS | 418.1 / 418.5 / 418.5 MiB | 417.9 / 417.9 / 418.1 MiB | +| app wall clock | 1.71 / 1.72 / 1.71 s | 1.68 / 1.67 / 1.67 s | -The DEM parser already collects detector hits per error mechanism. Those hit lists are H's sparse -columns. The first implementation materialized a dense `detectors x mechanisms` tensor and scanned -it back into sparse form; the final path builds CSC/CSR arrays directly. +| distance 9, 9 rounds, 500 shots (6000 decodes) | main | proposed | +|---|---:|---:| +| server CPU per decode | 751.7 / 745.0 µs | 738.3 / 746.7 µs | +| server peak RSS | 421.7 / 421.5 MiB | 420.5 / 420.4 MiB | +| app wall clock | 4.03 / 3.98 s | 3.95 / 4.00 s | + +**No regression.** At distance 5 the proposed branch is about 2.7% cheaper per decode and the +repetition ranges do not overlap; at distance 9 the two are indistinguishable. Peak RSS is the same +to within 0.3%, which is expected: this configuration carries its model as matrices, so it never +exercises the DEM parsing path below. + +This is the right benchmark for the question "did lifting O and D out of the setters cost anything," +because it is the one path that uses both, per shot. The generated configuration carries `H_sparse`, +`O_sparse` and `D_sparse` and no `stim_dem_path`, so on main it takes exactly the setter route — +`get_decoder(H)`, then `set_O_sparse()`, then `set_D_sparse()` — while this branch resolves the same +three into `decoder_inputs` before construction. Neither matrix is decoration at run time: D converts +every arriving measurement stream into detectors inside `enqueue_syndrome()`, and O turns the decoded +frame into the corrections the app counts. Both branches found the same number of corrections (50 at +distance 5, 71 at distance 9). + +### Resolving a model from a DEM + +This is where the branch is meaningfully different, and it is decoder-independent — it is the step +that turns DEM text into whatever the framework holds as the model. On the distance-13 surface code +DEM (`H = 2184 x 47129`): + +| | main (`dem_from_stim_text`) | proposed (`decoder_inputs::from_stim_dem`) | +|---|---:|---:| +| parse | 75.5 / 97.0 / 75.2 ms | 11.7 / 11.2 / 11.3 ms | +| retained | 105.9 / 105.9 / 105.9 MiB | 6.0 / 5.8 / 5.8 MiB | +| peak | 106.7 / 106.7 / 106.9 MiB | 6.9 / 6.8 / 6.8 MiB | -Measured on the distance-13 model (`H = 2184 x 47129`): +About 7x faster and 17x smaller. The retained figures differ because the representations differ, +which is the point: main materializes dense `detectors x mechanisms` tensors for H and O, while this +branch builds the sparse arrays directly from the hit lists the parser already has. Nothing about +this required the lifecycle change — but the lifecycle change is what put a single, obvious +resolution step where the cost was visible. -| | before | after direct sparse projection | -|---|---:|---:| -| retained model memory | ~4.6 MiB | ~4.6 MiB | -| transient above retained | ~99.3 MiB | ~2.1–2.3 MiB | - -This is a real saving, but it belongs to DEM normalization, not to moving the setters. The lifecycle -redesign is what made the path visible; the sparse projection is what fixed it. Worth attributing -correctly. - -Resolution retains every normalized input before construction. On the measured eight-decoder, -distance-13 configuration, retaining normalized inputs used about 23.1 MiB and resolved in about -1.06 s. Retaining only raw snapshots used about 14.1 MiB but required a second parse at construction, -taking about 2.1 s and violating "validate the artifact you construct." We chose the first strategy: -roughly 9 MiB more for eight decoders to avoid a second derivation and about a second of reload -latency. - -We did **not** construct a complete replacement decoder set beside the live set. That would approach -2x peak decoder memory. Consequently, resolution failure preserves the active configuration, but a -plugin constructor failure can still leave the decoder set empty. The cached/published -configuration is not updated on that failure. This is an explicit memory-versus-transactionality -trade, not an accidental omission. +This matters most for exactly the case that started all of this - a Chromobius-on-the-server +configuration is DEM-sourced by definition. ## Compatibility with incoming work @@ -617,80 +514,35 @@ including whether D still maps into the same detector basis. The current rule gi work somewhere to attach: preserve the authoritative source when detector/error identity survives; drop it when a transformation re-indexes either basis. -### Reload work +## Why not do a smaller fix by simply expanding what the decoding server accepts and leave O/D where they are? -Model resolution is side-effect-free and happens before global decoder state is touched. Model -paths are written back as absolute only after construction and session initialization succeed. A -live realtime session rejects reconfiguration instead of destroying decoders that it still -references. +The minimal fix is: accept `stim_dem_path` in the server config, pass the text through the existing +string arm of `decoder_init`, and keep the setters. It is much smaller and it does unblock +Chromobius. It also does not work on its own, and leaves the rest in place: -The remaining reload limitations are deliberately visible: +- **It still has to derive O and inject it.** The base sizes its corrections buffer from whatever + `set_O_sparse()` hands it, so a DEM-only config reports zero observables. The server must derive O + from the DEM and set it on a decoder that already read that same O out of its own DEM text. In the case + of Chromobius, you need to pass an O in just so Chromobius can be constructed and then discard the O. +- **Two authorities for O, with no check.** The setter's O and the decoder's own O can disagree; the + base validates row count, never content. We reproduced a silent inverted correction this way. +- **D still arrives after construction**, so the base still cannot size realtime state when the + constructor returns, and readiness still depends on an unstated call order. +- **Output form stays coupled to O's presence**, so a caller cannot ask for an error frame while + supplying O as data. +- **Name-based routing survives.** Common server code still forwards O by comparing against + `"trt_decoder"` and `"pymatching"`, so a third-party global decoder still requires editing our + source. +- **Nested construction stays special.** TensorRT and sliding window still receive their model by a + different mechanism than a top-level decoder. -- constructor failure does not preserve the old live decoder set because we rejected overlapping - allocations; -- an in-place edit to a DEM at the same path is invisible to bytewise config comparison; and -- a path-based DEM source assumes the server can read the operator's filesystem. -Those belong in the reload/transport design rather than in a decoder-specific parameter escape -hatch. -## Acceptance evidence - -The useful acceptance tests are not "did this helper return the object it just built?" They cross -the boundaries where the old paths diverged: - -1. An H-based, out-of-tree-style plugin constructs offline and through the server with no - decoder-name framework branch. -2. A capture plugin observes the same canonical H, O, D and rates at construction offline and - through the server. -3. A server configuration resolves a raw DEM and constructs standalone Chromobius without a - Chromobius branch in server construction. -4. TensorRT nests Chromobius when its derivation preserves the detector/error basis; the converse - matrix-only case fails, proving the test did not pass for an unrelated reason. +## Open questions for the draft discussion -Step 4 adds the lifecycle evidence: the base sizes realtime state from construction inputs, -PyMatching's realtime path constructs through the new contract, and sliding-window streaming works -without a `dynamic_cast` in the base. +1. **Names.** Renamed in this branch after review: `get_output()` (was `get_default_output()`, + which implied it could be overridden later), `decoder_inputs_without_d()` and `canonicalize_H()`. + Still open to reviewer input. -The clean verification built all QEC and core realtime targets from scratch. The focused -realtime set passed 18/18; the full C++ scope had no failures. The qLDPC device-graph test compiled -and linked but skipped execution because the available GPUs are compute capability 8.6 and the test -requires 9.0+. The proprietary nv-qldpc plugin itself is not covered by this public-tree result. -## Open questions for the draft discussion -1. **Names.** `get_default_output()` really means the instance's fixed requested/resolved output, - not a default that can later be overridden. The behavior is settled; the name deserves reviewer - input. -2. **Compact-source equivalence.** What exact detector/error-basis guarantees let a wrapper retain - a future chunked source, and how is D's basis relationship represented? -3. **Remote model transport.** Is an operator-local path sufficient for the intended server - deployment, or must a later contract carry content/hash/URI semantics? -4. **Reload transactionality.** Is preserving the old decoder set across constructor failure worth - the 2x peak memory, or should a future reload mechanism construct and swap one decoder at a time? -5. **Private nv-qldpc migration.** Which constructor inputs and graph-dispatch ownership does the - private decoder need once its late O setter is removed? - -Settled questions should stay settled: no per-call output selection, no batch result redesign, no -capability apparatus, no caller-buffer hook, no versioned `.so` ABI in this proposal. There are -enough real questions here without inventing speculative ones. - -## What would falsify this design - -I would change or reject the proposal if review produces evidence that: - -- a supported construction path genuinely cannot know O or D until after the decoder must become - live; -- a current consumer needs one decoder instance to switch output basis per shot, and constructing - two instances is materially unacceptable; -- normalized `decoder_inputs` cannot represent a model needed by an existing target decoder without - eagerly materializing a compact source; -- a wrapper cannot state whether it preserves detector/error identity, making the provenance rule - unusable rather than merely unfinished; -- the paired latency benchmark shows a meaningful per-shot regression attributable to the new - contract; or -- the common factory contract forces decoder-specific knowledge back into the server or base. - -Conversely, "this touches many files" is evidence that the draft needs careful review and likely PR -splitting; it is not by itself evidence that the lifecycle boundary is wrong. The proposal should -survive on whether its invariants are useful, not on how much work went into it. diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index 6792a8087..6f7a02dd9 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -151,7 +151,7 @@ class decoder /// @brief Constructor /// @param inputs Stable model and measurement inputs. Taken by value so the /// factory can move its immutable handle into the decoder. - decoder(decoder_inputs inputs, decoder_output default_output); + decoder(decoder_inputs inputs, decoder_output requested_output); /// @brief Decode a single syndrome /// @param syndrome A vector of syndrome measurements where the floating point @@ -277,7 +277,7 @@ class decoder /// @brief The result form this instance was constructed to produce. Fixed at /// construction; every decode operation returns this form. - decoder_output get_default_output() const noexcept { return default_output_; } + decoder_output get_output() const noexcept { return output_; } // -- Begin realtime decoding API -- @@ -412,7 +412,7 @@ class decoder const cudaqx::heterogeneous_map ¶m_map); /// @brief The decoder's immutable construction inputs. const decoder_inputs inputs_; - const decoder_output default_output_; + const decoder_output output_; }; /// @brief Convert a single soft probability to a hard 0/1 decision. diff --git a/libs/qec/include/cudaq/qec/decoder_inputs.h b/libs/qec/include/cudaq/qec/decoder_inputs.h index a97286f7e..23c41e668 100644 --- a/libs/qec/include/cudaq/qec/decoder_inputs.h +++ b/libs/qec/include/cudaq/qec/decoder_inputs.h @@ -108,33 +108,19 @@ class decoder_inputs { /// @brief Return D, or nullptr when input syndromes are already detectors. const sparse_binary_matrix *measurement_to_detectors() const noexcept; - /// @brief Make a basis-preserving child input while independently removing - /// the raw-measurement map. Authoritative compact model provenance is kept. - decoder_inputs without_measurement_to_detectors() const; + /// @brief Return the same inputs without D, for a decoder that is fed + /// detectors rather than a raw measurement stream. Everything else, + /// including the authoritative source, is preserved. + decoder_inputs decoder_inputs_without_d() const; - /// @brief Return the same model with H in GF(2)-canonical CSC form. + /// @brief Return the same inputs with H in GF(2)-canonical CSC form. /// - /// Basis-preserving: `canonicalize()` sorts indices within each compressed - /// group and XOR-merges duplicates, leaving column identity, ordering and - /// dimensions unchanged. The authoritative source is therefore retained, - /// whatever its kind. Consumers that need a canonical H should ask for it - /// here rather than rebuilding a matrix-authoritative handle by hand. - decoder_inputs canonicalized() const; - - /// @brief Make child inputs after a detector/error-basis transformation. - /// - /// The authoritative source is dropped: a raw DEM describes the parent's - /// detector and error indices, so it no longer applies once those are - /// re-indexed, and handing it down would let a child decode against a model - /// that does not match its own matrices. Derivations that preserve the basis - /// (`canonicalized`, `without_measurement_to_detectors`) keep it. - decoder_inputs derive_with_changed_basis( - sparse_binary_matrix detector_error_matrix, - std::optional observable_flips_matrix, - std::vector error_rates, - std::optional> error_ids, - std::optional measurement_to_detectors = - std::nullopt) const; + /// Sorts indices within each compressed group and XOR-merges duplicates, + /// leaving column identity, ordering and dimensions unchanged. O and D are + /// passed through untouched, and the authoritative source is retained. + /// Consumers that need a canonical H should ask for it here rather than + /// rebuilding a matrix-authoritative handle by hand. + decoder_inputs canonicalize_H() const; bool has_stim_dem() const noexcept; diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index ae3a3f327..4e7b0addd 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -88,9 +88,9 @@ struct decoder::rt_impl { void decoder::rt_impl_deleter::operator()(rt_impl *p) const { delete p; } -decoder::decoder(decoder_inputs inputs, decoder_output default_output) +decoder::decoder(decoder_inputs inputs, decoder_output requested_output) : pimpl(std::unique_ptr(new rt_impl())), - inputs_(std::move(inputs)), default_output_(default_output) { + inputs_(std::move(inputs)), output_(requested_output) { syndrome_size = inputs_.num_detectors(); block_size = inputs_.num_error_mechanisms(); @@ -497,7 +497,7 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, const char *result_type_str = nullptr; const char *result_type_name = nullptr; std::size_t expected_result_size = 0; - switch (default_output_) { + switch (output_) { case decoder_output::errors: result_type_str = "errs"; result_type_name = "errors"; @@ -510,9 +510,8 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, break; } if (!result_type_name) - throw std::runtime_error( - fmt::format("Unsupported decoder result type ({})", - static_cast(default_output_))); + throw std::runtime_error(fmt::format( + "Unsupported decoder result type ({})", static_cast(output_))); if ((!pimpl->is_sliding_window && decoded_values.size() != expected_result_size) || (pimpl->is_sliding_window && !decoded_values.empty() && @@ -534,7 +533,7 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, if (should_log) log_t2 = std::chrono::high_resolution_clock::now(); - switch (default_output_) { + switch (output_) { case decoder_output::observables: // Observable-frame path: decoder already projected to observables via its // internal "O" matrix; use the result directly. diff --git a/libs/qec/lib/decoder_inputs.cpp b/libs/qec/lib/decoder_inputs.cpp index 55ce02e19..444b610b4 100644 --- a/libs/qec/lib/decoder_inputs.cpp +++ b/libs/qec/lib/decoder_inputs.cpp @@ -160,32 +160,19 @@ decoder_inputs::measurement_to_detectors() const noexcept { return state_->D ? &*state_->D : nullptr; } -decoder_inputs decoder_inputs::canonicalized() const { +decoder_inputs decoder_inputs::canonicalize_H() const { auto H = state_->H.canonicalize().to_csc(); return decoder_inputs(make_matrix_state(state_->source, std::move(H), state_->O, state_->rates, state_->ids, state_->D, state_->raw_stim_dem)); } -decoder_inputs decoder_inputs::without_measurement_to_detectors() const { +decoder_inputs decoder_inputs::decoder_inputs_without_d() const { auto state = std::make_shared(*state_); state->D.reset(); return decoder_inputs(std::move(state)); } -decoder_inputs decoder_inputs::derive_with_changed_basis( - sparse_binary_matrix H, std::optional O, - std::vector error_rates, - std::optional> error_ids, - std::optional measurement_to_detectors) const { - // No raw source is carried through: it indexes the parent's detectors and - // error mechanisms, which these matrices have re-indexed. - return decoder_inputs(make_matrix_state( - decoder_model_source::matrices, std::move(H), std::move(O), - std::move(error_rates), std::move(error_ids), - std::move(measurement_to_detectors))); -} - bool decoder_inputs::has_stim_dem() const noexcept { return state_->raw_stim_dem.has_value(); } diff --git a/libs/qec/lib/decoders/lut.cpp b/libs/qec/lib/decoders/lut.cpp index 5f35bfd51..d35cf14c3 100644 --- a/libs/qec/lib/decoders/lut.cpp +++ b/libs/qec/lib/decoders/lut.cpp @@ -50,13 +50,13 @@ class multi_error_lut : public decoder { public: multi_error_lut(cudaq::qec::decoder_inputs inputs, - decoder_output default_output, + decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs), default_output) { + : decoder(std::move(inputs), requested_output) { // This decoder computes an error frame. Producing observables requires an // observable mapping to project through; reject at construction rather // than on the first decode. - if (default_output == decoder_output::observables && + if (requested_output == decoder_output::observables && !get_inputs().has_observable_model()) throw std::invalid_argument( "lut decoder was constructed for observable output but its model " @@ -181,7 +181,7 @@ class multi_error_lut : public decoder { // This decoder computes an error frame. Whether that frame is projected is // fixed at construction, so the decision is read from immutable instance // state rather than negotiated per call. - const bool project = get_default_output() == decoder_output::observables; + const bool project = get_output() == decoder_output::observables; auto finish = [&](decoder_result &r) { if (project) { std::vector observables(get_num_observables(), 0.0); @@ -268,9 +268,9 @@ CUDAQ_EXT_PT_REGISTER_TYPE(multi_error_lut) class single_error_lut : public multi_error_lut { public: single_error_lut(cudaq::qec::decoder_inputs inputs, - decoder_output default_output, + decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms) - : multi_error_lut(std::move(inputs), default_output, params) {} + : multi_error_lut(std::move(inputs), requested_output, params) {} virtual ~single_error_lut() {} diff --git a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp index ef96812f8..648eaae9a 100644 --- a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp +++ b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp @@ -75,14 +75,14 @@ class chromobius : public decoder { public: chromobius(decoder_inputs inputs, chromobius_init_data init_data, - decoder_output default_output, + decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs), default_output), + : decoder(std::move(inputs), requested_output), dem(std::move(init_data.dem)) { // Chromobius predicts observable flips directly and cannot be inverted to // an error frame. Reject the request at construction rather than on the // first live shot. - if (default_output != decoder_output::observables) + if (requested_output != decoder_output::observables) throw std::invalid_argument( "Chromobius cannot return an error frame; construct it for " "observable output"); diff --git a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp index 90d81743f..82b0cf701 100644 --- a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp +++ b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp @@ -23,15 +23,15 @@ class single_error_lut_example : public decoder { public: single_error_lut_example(cudaq::qec::decoder_inputs inputs, - decoder_output default_output, + decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs), default_output) { + : decoder(std::move(inputs), requested_output) { // The requested result form is validated here, at construction, so an // unsupported request fails at setup rather than on the first decode. This // example produces an error frame only; a decoder that can also project to // observables would instead call project_errors_to_observables() before // returning. - if (default_output != decoder_output::errors) + if (requested_output != decoder_output::errors) throw std::invalid_argument( "single_error_lut_example produces an error frame only; construct it " "for error output"); diff --git a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp index a346895d1..24598db59 100644 --- a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp +++ b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp @@ -75,12 +75,12 @@ class pymatching : public decoder { #endif public: - pymatching(cudaq::qec::decoder_inputs inputs, decoder_output default_output, + pymatching(cudaq::qec::decoder_inputs inputs, decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs), default_output) { + : decoder(std::move(inputs), requested_output) { const auto &H = get_inputs().detector_error_matrix(); error_rate_vec = get_inputs().error_rates(); - decode_to_observables = default_output == decoder_output::observables; + decode_to_observables = requested_output == decoder_output::observables; if (!error_rate_vec.empty()) { if (error_rate_vec.size() != block_size) { diff --git a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp index 11198b6d8..8d78145da 100644 --- a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp +++ b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp @@ -130,20 +130,20 @@ static Logger gLogger; /// the engine emits residual detectors *in exactly the H-row basis and order /// supplied at construction*. The width check below establishes size only, /// not identity or ordering: a reordered engine would silently feed the -/// global decoder a permuted syndrome, and a raw-DEM child would decode it -/// against the wrong detector identities. Supporting reordered residuals -/// would require an explicit detector mapping, which this contract does not -/// provide. +/// global decoder a permuted syndrome, and a raw-DEM global decoder would +/// decode it against the wrong detector identities. Supporting reordered +/// residuals would require an explicit detector mapping, which this contract +/// does not provide. /// - "global_decoder": Optional name of a decoder to run after TRT when the /// declared engine output includes residual detectors. /// - "global_decoder_params": Optional parameters for the global decoder. The /// decoder receives the same model inputs passed to the trt_decoder /// constructor, including authoritative raw DEM provenance when present. -/// When the engine output includes an observable prefix, the child's result -/// is XOR-combined with that prefix and the child's opt_results are carried -/// through onto the combined result, so child options that surface only -/// through opt_results (for example Chromobius's return_weight) remain -/// externally visible. +/// When the engine output includes an observable prefix, the global decoder's +/// result is XOR-combined with that prefix and the global decoder's +/// opt_results are carried through onto the combined result, so +/// global-decoder options that surface only through opt_results (for example +/// Chromobius's return_weight) remain externally visible. /// O is read from decoder_inputs only for model dimensions and observable /// combination. Its presence never selects an engine-output interpretation. /// @@ -190,11 +190,11 @@ decoder_output natural_trt_output(trt_engine_output_format format) { } decoder_output trt_emitted_output(trt_engine_output_format format, - decoder_output default_output) { + decoder_output requested_output) { if (format == trt_engine_output_format::errors) return decoder_output::errors; if (format == trt_engine_output_format::residual_detectors) - return default_output; + return requested_output; return decoder_output::observables; } @@ -467,7 +467,8 @@ class trt_decoder : public decoder { size_t num_observables_ = 0; public: - trt_decoder(cudaq::qec::decoder_inputs inputs, decoder_output default_output, + trt_decoder(cudaq::qec::decoder_inputs inputs, + decoder_output requested_output, trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms); @@ -588,17 +589,17 @@ struct trt_decoder::Impl { // ============================================================================ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, - decoder_output default_output, + decoder_output requested_output, trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs), default_output), + : decoder(std::move(inputs), requested_output), engine_output_format_(engine_output_format), emitted_output_( - trt_emitted_output(engine_output_format, default_output)) { + trt_emitted_output(engine_output_format, requested_output)) { if ((engine_output_format_ == trt_engine_output_format::observables || engine_output_format_ == trt_engine_output_format::observables_and_residual_detectors) && - default_output != decoder_output::observables) + requested_output != decoder_output::observables) throw std::runtime_error( "This TensorRT engine_output_format only supports observable output"); @@ -607,7 +608,7 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, // observable mapping there is nothing to project through, so reject here // rather than returning an unprojected error frame at decode time. if (emitted_output_ == decoder_output::errors && - default_output == decoder_output::observables && + requested_output == decoder_output::observables && !get_inputs().has_observable_model()) throw std::runtime_error( "This TensorRT engine emits an error frame and was constructed for " @@ -842,15 +843,14 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, throw std::runtime_error( "global_decoder requires an engine_output_format containing " "residual detectors"); - const auto child_output = + const auto global_output = engine_output_format_ == trt_engine_output_format::observables_and_residual_detectors ? decoder_output::observables - : default_output; - global_decoder_ = - decoder::get(global_decoder_name, - get_inputs().without_measurement_to_detectors(), - child_output, global_decoder_params_); + : requested_output; + global_decoder_ = decoder::get(global_decoder_name, + get_inputs().decoder_inputs_without_d(), + global_output, global_decoder_params_); CUDA_QEC_INFO("TensorRT decoder: global_decoder '{}' attached", global_decoder_name); } @@ -1003,7 +1003,7 @@ trt_decoder::decode_batch(const std::vector> &syndromes) { std::to_string(syndromes.size()) + " syndromes"); // The engine's output form and the instance's form are both fixed at // construction; only errors -> observables is reachable here. - if (emitted_output_ != get_default_output()) + if (emitted_output_ != get_output()) for (auto &r : results) { if (r.result.empty()) continue; @@ -1111,9 +1111,10 @@ std::vector trt_decoder::decode_batch_impl( std::vector global_results = global_decoder_->decode_batch(residual_soft); - // The child is an arbitrary registered decoder: validate its output - // before indexing it. This is composition safety at a trust boundary, - // checked once per batch, not per-decode contract re-validation. + // The global decoder is an arbitrary registered decoder: validate its + // output before indexing it. This is composition safety at a trust + // boundary, checked once per batch, not per-decode contract + // re-validation. if (global_results.size() != residual_soft.size()) throw std::runtime_error( "TensorRT global decoder returned " + @@ -1134,16 +1135,18 @@ std::vector trt_decoder::decode_batch_impl( for (size_t batch_idx = 0; batch_idx < actual_batch; ++batch_idx) { decoder_result combined; combined.converged = global_results[batch_idx].converged; - // Carry the child's optional metadata through; the combination - // changes the result values, not the child's diagnostics. + // Carry the global decoder's optional metadata through; the + // combination changes the result values, not the global decoder's + // diagnostics. combined.opt_results = std::move(global_results[batch_idx].opt_results); combined.result.resize(num_observables_, 0.0f); const OutputType *pre_L_row = output_host.data() + batch_idx * output_size_per_sample_; const std::vector &g = global_results[batch_idx].result; - // Width was validated above, so no bounds guard here: a short child - // result must fail loudly rather than be silently zero-filled. + // Width was validated above, so no bounds guard here: a short + // global-decoder result must fail loudly rather than be silently + // zero-filled. for (size_t k = 0; k < num_observables_; ++k) { const uint8_t a = trt_io_nonzero(pre_L_row[k]) ? 1u : 0u; const uint8_t b = g[k] >= 0.5f ? 1u : 0u; diff --git a/libs/qec/lib/decoders/sliding_window.cpp b/libs/qec/lib/decoders/sliding_window.cpp index 6e2218495..d2a514573 100644 --- a/libs/qec/lib/decoders/sliding_window.cpp +++ b/libs/qec/lib/decoders/sliding_window.cpp @@ -20,10 +20,10 @@ namespace { decoder_inputs canonicalize_sliding_window_inputs(decoder_inputs inputs) { // Canonical CSC is the steady-state contract for decode_window's column - // slices and validate_inputs's per-column reads. canonicalized() is + // slices and validate_inputs's per-column reads. canonicalize_H() is // basis-preserving and retains the authoritative source, so no source kind // needs special-casing here. - return inputs.canonicalized(); + return inputs.canonicalize_H(); } } // namespace @@ -121,17 +121,17 @@ void sliding_window::initialize_window(std::size_t batch_size) { } sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, - decoder_output default_output, + decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms) // Canonical CSC is the steady-state contract for decode_window's column // slices and for validate_inputs's per-column .front()/.back() reads. : decoder(canonicalize_sliding_window_inputs(std::move(inputs)), - default_output), + requested_output), H(get_inputs().detector_error_matrix()) { // This decoder composes an error frame from its windows. Producing // observables requires an observable mapping to project through; reject at // construction rather than on the first decode. - if (default_output == decoder_output::observables && + if (requested_output == decoder_output::observables && !get_inputs().has_observable_model()) throw std::invalid_argument( "sliding_window was constructed for observable output but its model " @@ -193,7 +193,7 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, num_boundary_syndromes); first_columns.push_back(first_column); - // Slice model rates to the same error-column basis as the child H. + // Slice model rates to the same error-column basis as the window H. std::vector error_vec_mod(error_rate_vec.begin() + first_column, error_rate_vec.begin() + last_column + 1); @@ -209,20 +209,21 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, last_column - first_column + 1, H_round.shape()[1])); } - auto child_O = sparse_binary_matrix::from_csr( + auto inner_O = sparse_binary_matrix::from_csr( 0, H_round.shape()[1], std::vector{0}, {}); - std::optional> child_error_ids; + std::optional> inner_error_ids; if (const auto &ids = get_inputs().error_ids()) - child_error_ids = std::vector( + inner_error_ids = std::vector( ids->begin() + first_column, ids->begin() + last_column + 1); - // Slicing detector rows and error columns re-indexes both, so the child - // gets matrices only; any raw source the parent carried does not describe - // them. - auto child_inputs = get_inputs().derive_with_changed_basis( - sparse_binary_matrix(H_round), std::move(child_O), - std::move(error_vec_mod), std::move(child_error_ids)); + // Slicing detector rows and error columns re-indexes both, so this window + // gets its own matrices. Nothing is inherited: a raw DEM names the outer + // detectors and would not describe these. + decoder_inputs inner_inputs(sparse_binary_matrix(H_round), + std::move(inner_O), std::move(error_vec_mod), + /*measurement_to_detectors=*/std::nullopt, + std::move(inner_error_ids)); auto inner_decoder = - decoder::get(inner_decoder_name, std::move(child_inputs), + decoder::get(inner_decoder_name, std::move(inner_inputs), decoder_output::errors, inner_decoder_params); inner_decoders.push_back(std::move(inner_decoder)); } @@ -301,7 +302,7 @@ std::vector sliding_window::decode_batch( // return is the empty streaming sentinel. // Composed frames are error frames; whether they are projected is fixed at // construction. - if (get_default_output() == decoder_output::observables) + if (get_output() == decoder_output::observables) for (auto &r : results) { if (r.result.empty()) continue; // streaming sentinel diff --git a/libs/qec/lib/decoders/sliding_window.h b/libs/qec/lib/decoders/sliding_window.h index 0f7f74379..9b9771ae3 100644 --- a/libs/qec/lib/decoders/sliding_window.h +++ b/libs/qec/lib/decoders/sliding_window.h @@ -108,7 +108,7 @@ class sliding_window : public decoder { /// - inner_decoder_name: Name of the inner decoder to use /// - inner_decoder_params: Parameters for the inner decoder (optional) sliding_window(cudaq::qec::decoder_inputs inputs, - decoder_output default_output, + decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms); /// @brief Decode a syndrome vector diff --git a/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp index 9d582932b..1f4d34c1c 100644 --- a/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp +++ b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp @@ -52,8 +52,7 @@ TEST(ChromobiusDecoder, checkAllZeroSyndrome) { EXPECT_EQ(result.result[0], 0.0); EXPECT_EQ(decoder->get_block_size(), 6); EXPECT_EQ(decoder->get_syndrome_size(), 4); - EXPECT_EQ(decoder->get_default_output(), - cudaq::qec::decoder_output::observables); + EXPECT_EQ(decoder->get_output(), cudaq::qec::decoder_output::observables); EXPECT_THROW((void)cudaq::qec::decoder::get( "chromobius", cudaq::qec::decoder_inputs::from_stim_dem( diff --git a/libs/qec/unittests/decoders/sample_decoder.cpp b/libs/qec/unittests/decoders/sample_decoder.cpp index 54d6fb140..083588620 100644 --- a/libs/qec/unittests/decoders/sample_decoder.cpp +++ b/libs/qec/unittests/decoders/sample_decoder.cpp @@ -19,13 +19,13 @@ namespace cudaq::qec { class sample_decoder : public decoder { public: sample_decoder(cudaq::qec::decoder_inputs inputs, - decoder_output default_output, + decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs), default_output) { + : decoder(std::move(inputs), requested_output) { // This decoder computes an error frame. Producing observables requires an // observable mapping to project through; reject at construction rather // than on the first decode. - if (default_output == decoder_output::observables && + if (requested_output == decoder_output::observables && !get_inputs().has_observable_model()) throw std::invalid_argument( "sample_decoder was constructed for observable output but its model " @@ -39,7 +39,7 @@ class sample_decoder : public decoder { // Whether the frame is projected is fixed at construction, so the decision // is read from immutable instance state rather than negotiated per call. - if (get_default_output() == decoder_output::observables) { + if (get_output() == decoder_output::observables) { std::vector observables(get_num_observables(), 0.0); project_errors_to_observables(result.result.data(), observables.data(), observables.size()); diff --git a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp index 2b41e34b9..b35bfc7ca 100644 --- a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp +++ b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp @@ -86,9 +86,10 @@ reusable_decode_barrier &decode_barrier() { /// subsequent decode rendezvous with all configured instances before returning. class concurrency_test_decoder : public decoder { public: - concurrency_test_decoder(decoder_inputs inputs, decoder_output default_output, + concurrency_test_decoder(decoder_inputs inputs, + decoder_output requested_output, const cudaqx::heterogeneous_map &) - : decoder(std::move(inputs), default_output) { + : decoder(std::move(inputs), requested_output) { std::cout << "QEC_CONCURRENCY_TEST_DECODER_CONSTRUCTED" << std::endl; } diff --git a/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp b/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp index 8defa89a2..3e5f2019d 100644 --- a/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp +++ b/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp @@ -509,8 +509,7 @@ int main(int argc, char *argv[]) { << " row(s).\n"; return 1; } - if (setup.decoder->get_default_output() != - cudaq::qec::decoder_output::observables) { + if (setup.decoder->get_output() != cudaq::qec::decoder_output::observables) { std::cerr << "ERROR: composite trt_decoder must use observable output.\n"; return 1; } diff --git a/libs/qec/unittests/test_decoders.cpp b/libs/qec/unittests/test_decoders.cpp index 62cd39add..5a371300d 100644 --- a/libs/qec/unittests/test_decoders.cpp +++ b/libs/qec/unittests/test_decoders.cpp @@ -39,9 +39,9 @@ class decoder_inputs_probe final : public cudaq::qec::decoder { class observable_output_probe final : public cudaq::qec::decoder { public: observable_output_probe(cudaq::qec::decoder_inputs inputs, - cudaq::qec::decoder_output default_output, + cudaq::qec::decoder_output requested_output, const cudaqx::heterogeneous_map &) - : decoder(std::move(inputs), default_output) {} + : decoder(std::move(inputs), requested_output) {} cudaq::qec::decoder_result decode(const std::vector &syndrome) override { @@ -178,29 +178,28 @@ TEST(DecoderInputs, RejectsInconsistentDimensions) { std::invalid_argument); } -TEST(DecoderInputs, ChildDerivationKeepsRawSourceOnlyWhenBasisIsUnchanged) { +TEST(DecoderInputs, DerivationsKeepRawSourceAndFreshMatricesDoNot) { auto inputs = cudaq::qec::decoder_inputs::from_stim_dem( "error(0.1) D0 L0\n", cudaq::qec::sparse_binary_matrix::from_nested_csr(1, 2, {{0, 1}})); - auto basis_preserving = inputs.without_measurement_to_detectors(); - EXPECT_TRUE(basis_preserving.has_stim_dem()); - EXPECT_EQ(basis_preserving.stim_dem(), inputs.stim_dem()); - EXPECT_EQ(basis_preserving.measurement_to_detectors(), nullptr); - - auto child_H = cudaq::qec::sparse_binary_matrix::from_nested_csc(1, 1, {{0}}); - auto child_O = cudaq::qec::sparse_binary_matrix::from_nested_csr(0, 1, {}); - auto basis_changed = inputs.derive_with_changed_basis( - std::move(child_H), std::move(child_O), {0.1}, std::nullopt); - // Re-indexing detectors and errors invalidates the raw source, so it is not - // carried into the child. - EXPECT_FALSE(basis_changed.has_stim_dem()); - EXPECT_THROW((void)basis_changed.stim_dem(), std::logic_error); + auto without_d = inputs.decoder_inputs_without_d(); + EXPECT_TRUE(without_d.has_stim_dem()); + EXPECT_EQ(without_d.stim_dem(), inputs.stim_dem()); + EXPECT_EQ(without_d.measurement_to_detectors(), nullptr); // Canonicalization preserves column identity and ordering, so it keeps it. - auto canonical = inputs.canonicalized(); + auto canonical = inputs.canonicalize_H(); EXPECT_TRUE(canonical.has_stim_dem()); EXPECT_EQ(canonical.stim_dem(), inputs.stim_dem()); + + // A caller that re-indexes detectors or errors builds fresh inputs, which + // carry no raw source: the DEM text names the original detectors. + cudaq::qec::decoder_inputs reindexed( + cudaq::qec::sparse_binary_matrix::from_nested_csc(1, 1, {{0}}), + cudaq::qec::sparse_binary_matrix::from_nested_csr(0, 1, {}), {0.1}); + EXPECT_FALSE(reindexed.has_stim_dem()); + EXPECT_THROW((void)reindexed.stim_dem(), std::logic_error); } TEST(DecoderOutputContract, OutputFormIsImmutablePerInstance) { @@ -213,8 +212,7 @@ TEST(DecoderOutputContract, OutputFormIsImmutablePerInstance) { cudaq::qec::decoder_inputs(std::move(H), std::move(O)), cudaq::qec::decoder_output::observables); - EXPECT_EQ(decoder->get_default_output(), - cudaq::qec::decoder_output::observables); + EXPECT_EQ(decoder->get_output(), cudaq::qec::decoder_output::observables); const std::vector syndrome{1.0, 0.0}; auto observables = decoder->decode(syndrome); @@ -1546,9 +1544,9 @@ class ScopedDeviceRestore { class strict_keys_decoder : public cudaq::qec::decoder { public: strict_keys_decoder(cudaq::qec::decoder_inputs inputs, - cudaq::qec::decoder_output default_output, + cudaq::qec::decoder_output requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(std::move(inputs), default_output) { + : decoder(std::move(inputs), requested_output) { auto invalid = cudaq::qec::validate_config_parameters(params, {"decode_to_obs"}); if (!invalid.empty()) @@ -1585,9 +1583,9 @@ class device_recording_decoder : public cudaq::qec::decoder { public: std::atomic last_decode_device{-2}; device_recording_decoder(cudaq::qec::decoder_inputs inputs, - cudaq::qec::decoder_output default_output, + cudaq::qec::decoder_output requested_output, const cudaqx::heterogeneous_map &) - : decoder(std::move(inputs), default_output) {} + : decoder(std::move(inputs), requested_output) {} cudaq::qec::decoder_result decode(const std::vector &) override { int dev = -1; diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index d8271ffd5..d1aa17e5f 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -57,9 +57,9 @@ struct construction_d_probe { class d_capture_decoder : public decoder { public: - d_capture_decoder(decoder_inputs inputs, decoder_output default_output, + d_capture_decoder(decoder_inputs inputs, decoder_output requested_output, const cudaqx::heterogeneous_map &) - : decoder(std::move(inputs), default_output) { + : decoder(std::move(inputs), requested_output) { const auto &in = get_inputs(); const auto *D = in.measurement_to_detectors(); construction_d_probe::has_d = D != nullptr; @@ -1346,8 +1346,7 @@ TEST(DecodingServerAcceptance, ChromobiusConstructsFromRawDemSource) { parsed, std::move(inputs)); ASSERT_NE(decoder, nullptr); EXPECT_EQ(decoder->get_num_observables(), 3u); - EXPECT_EQ(decoder->get_default_output(), - cudaq::qec::decoder_output::observables); + EXPECT_EQ(decoder->get_output(), cudaq::qec::decoder_output::observables); // Decoding works off the DEM-derived detector basis, and returns one entry // per observable the DEM declares. From 40481dc496c28fb720681cbaa0a0849b6b3f0e3f Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Tue, 4 Aug 2026 14:07:04 -0700 Subject: [PATCH 11/24] remove stale info from walkthrough Signed-off-by: Melody Ren --- design_walkthrough.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/design_walkthrough.md b/design_walkthrough.md index 763fb8443..aaca291ae 100644 --- a/design_walkthrough.md +++ b/design_walkthrough.md @@ -538,11 +538,4 @@ Chromobius. It also does not work on its own, and leaves the rest in place: -## Open questions for the draft discussion - -1. **Names.** Renamed in this branch after review: `get_output()` (was `get_default_output()`, - which implied it could be overridden later), `decoder_inputs_without_d()` and `canonicalize_H()`. - Still open to reviewer input. - - From 751e6ed9cb8566175a9866e862e5e2b9c1c2ce2a Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Tue, 4 Aug 2026 14:18:08 -0700 Subject: [PATCH 12/24] clarify some points in walkthrough Signed-off-by: Melody Ren --- design_walkthrough.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/design_walkthrough.md b/design_walkthrough.md index aaca291ae..7cb736889 100644 --- a/design_walkthrough.md +++ b/design_walkthrough.md @@ -340,10 +340,6 @@ That gives two cases, and only one of them needs an operation: is no operation for this and no need for one: a matrix-constructed handle carries no raw source, so the DEM text is dropped structurally rather than by a rule someone has to remember. -(`canonicalize_H()` is neither of those. It returns the same inputs with H in GF(2)-canonical form, -and sliding window calls it on *itself* before slicing so that its row and column reads are -well-defined. It is a normalization, not a hand-off.) - This also allows us to expand into more exotic wrapping schemes by utilizing `decoder_inputs`. From 2b78e58c55266c5454504dffd61cb7903b939fc7 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Wed, 5 Aug 2026 15:30:47 -0700 Subject: [PATCH 13/24] Drive realtime streaming from the installed layer geometry The base decided between whole-block and per-round realtime behavior by reading is_sliding_window, a flag naming one concrete subclass. Replace it with round_streaming_initialized, which initialize_streaming_layout() writes after validating the layer offsets, installing the geometry and sizing the buffers. The five realtime branches now consult the same state that supplies the detector layer offsets they index, so per-round behavior cannot be selected without the geometry it requires. The flag doubles as the existing one-shot construction latch, and is written last so streaming never activates on incomplete geometry. No public API or class layout change; any decoder that installs a streaming layout gets per-round behavior, whatever its type. Signed-off-by: Melody Ren --- libs/qec/lib/decoder.cpp | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index 4e7b0addd..971fd9187 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -58,11 +58,10 @@ struct decoder::rt_impl { /// The id of the decoder (for instrumentation) uint32_t decoder_id = 0; - bool is_sliding_window = false; - - /// Set once initialize_streaming_layout() runs, so a second call is rejected - /// rather than silently resetting buffers on a live decoder. - bool streaming_layout_initialized = false; + /// Written last by initialize_streaming_layout(), so per-round streaming + /// never activates on incomplete geometry. Also the one-shot construction + /// latch: a second call is rejected rather than resetting a live decoder. + bool round_streaming_initialized = false; /// The model's measurement-to-detector map, by detector row. Empty when the /// model supplies none, i.e. the decoder is handed detectors directly. @@ -370,7 +369,7 @@ uint32_t decoder::get_decoder_id() const { return pimpl->decoder_id; } void decoder::initialize_streaming_layout( std::size_t num_syndromes_per_round, std::vector detector_layer_offsets) { - if (pimpl->streaming_layout_initialized) + if (pimpl->round_streaming_initialized) throw std::logic_error( "initialize_streaming_layout() is construction state and may be called " "only once"); @@ -382,7 +381,6 @@ void decoder::initialize_streaming_layout( "detector layer offsets end at {} but the model has {} detectors", detector_layer_offsets.back(), syndrome_size)); - pimpl->is_sliding_window = true; pimpl->num_syndromes_per_round = num_syndromes_per_round; // A first-round detector layer references a single measurement per detector. pimpl->has_first_round_detectors = @@ -395,7 +393,7 @@ void decoder::initialize_streaming_layout( // rather than the full detector count. pimpl->persistent_detector_buffer.resize(num_syndromes_per_round); pimpl->persistent_soft_detector_buffer.resize(num_syndromes_per_round); - pimpl->streaming_layout_initialized = true; + pimpl->round_streaming_initialized = true; } bool decoder::enqueue_syndrome(const uint8_t *syndrome, @@ -414,7 +412,7 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, } bool should_decode = false; - if (!pimpl->is_sliding_window) { + if (!pimpl->round_streaming_initialized) { should_decode = (pimpl->msyn_buffer_index == pimpl->msyn_buffer.size()); } else { should_decode = @@ -446,7 +444,7 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, } // Decode now. - if (!pimpl->is_sliding_window) { + if (!pimpl->round_streaming_initialized) { for (std::size_t i = 0; i < pimpl->measurement_to_detectors.size(); i++) { pimpl->persistent_detector_buffer[i] = 0; for (auto col : pimpl->measurement_to_detectors[i]) @@ -486,7 +484,7 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, std::span decoded_values = decoded_result.result; // If we didn't get a decoded result, just return - if (pimpl->is_sliding_window) { + if (pimpl->round_streaming_initialized) { if (decoded_values.empty()) { return false; } @@ -512,9 +510,9 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, if (!result_type_name) throw std::runtime_error(fmt::format( "Unsupported decoder result type ({})", static_cast(output_))); - if ((!pimpl->is_sliding_window && + if ((!pimpl->round_streaming_initialized && decoded_values.size() != expected_result_size) || - (pimpl->is_sliding_window && !decoded_values.empty() && + (pimpl->round_streaming_initialized && !decoded_values.empty() && decoded_values.size() != expected_result_size)) { throw std::runtime_error(fmt::format( "Decoder result size ({}) does not match expected size ({}) for " From 6477f270eb6f44239f6a2ae871dc59bc4e53cdf8 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Wed, 5 Aug 2026 20:08:29 -0700 Subject: [PATCH 14/24] Fix CI build of the hololink bridge and the decoder Doxygen comment Two failures, neither reproducible in a default local build. The hololink qLDPC bridge declared num_observables to size the observable matrix it now passes at construction, in a function that already had a num_observables further down. The target builds only when the DOCA and GPU-RoCE transceiver libraries are configured, so the conflict appeared first in CI. Rename the new one to num_observable_rows. Doxygen runs with WARN_AS_ERROR=FAIL_ON_WARNINGS, and the decoder constructor documented inputs but not requested_output. Verified by running Doxygen over the public headers with the project's own Doxyfile, and by syntax-checking every QEC source the local build skips. Signed-off-by: Melody Ren --- libs/qec/include/cudaq/qec/decoder.h | 2 ++ .../unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index 6f7a02dd9..d41d0abab 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -151,6 +151,8 @@ class decoder /// @brief Constructor /// @param inputs Stable model and measurement inputs. Taken by value so the /// factory can move its immutable handle into the decoder. + /// @param requested_output The result basis this instance produces, fixed + /// for its lifetime. decoder(decoder_inputs inputs, decoder_output requested_output); /// @brief Decode a single syndrome diff --git a/libs/qec/unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp b/libs/qec/unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp index 3ccd77f77..4a5814b50 100644 --- a/libs/qec/unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp +++ b/libs/qec/unittests/utils/hololink_qldpc_graph_decoder_bridge.cpp @@ -241,13 +241,13 @@ int main(int argc, char *argv[]) { auto params = dec.decoder_custom_args_to_heterogeneous_map(); // O and D belong to the model, so they are supplied at construction rather // than installed afterwards. - const auto num_observables = static_cast( + const auto num_observable_rows = static_cast( std::count(dec.O_sparse.begin(), dec.O_sparse.end(), -1)); auto decoder = cudaq::qec::decoder::get( "nv-qldpc-decoder", cudaq::qec::decoder_inputs( cudaq::qec::sparse_binary_matrix(H_tensor), - sparse_matrix_from_flat_rows(dec.O_sparse, num_observables), + sparse_matrix_from_flat_rows(dec.O_sparse, num_observable_rows), /*error_rates=*/{}, sparse_matrix_from_flat_rows(dec.D_sparse, static_cast(ss))), From 398733cff946a9ec9a5dffad8ffee0c27e72d00c Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Thu, 6 Aug 2026 07:40:14 -0700 Subject: [PATCH 15/24] Resolve the Sphinx directives for the API this branch changed Adding a second configure_decoders overload made the unqualified doxygenfunction directive ambiguous, so name both signatures. The d_sparse directive qualified its parameter as cudaq::qec::sparse_binary_matrix, but the declaration sits inside that namespace and Doxygen records the argument unqualified, so breathe could not match it. Checked by generating Doxygen XML over every public header and resolving all 16 API documents against it: no unresolved directives, and no Doxygen warnings. Signed-off-by: Melody Ren --- docs/sphinx/api/qec/cpp_api.rst | 2 +- docs/sphinx/api/qec/cpp_realtime_decoding_api.rst | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/sphinx/api/qec/cpp_api.rst b/docs/sphinx/api/qec/cpp_api.rst index bca8b9bf2..61447f6c5 100644 --- a/docs/sphinx/api/qec/cpp_api.rst +++ b/docs/sphinx/api/qec/cpp_api.rst @@ -71,7 +71,7 @@ Decoder Interfaces :members: .. doxygenfunction:: cudaq::qec::d_sparse(const cudaq::M2DSparseMatrix &) -.. doxygenfunction:: cudaq::qec::d_sparse(const cudaq::qec::sparse_binary_matrix &) +.. doxygenfunction:: cudaq::qec::d_sparse(const sparse_binary_matrix &) .. doxygenclass:: cudaq::qec::decoder :members: diff --git a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst index 14fb3b622..7e0556c24 100644 --- a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst +++ b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst @@ -39,7 +39,8 @@ The configuration API enables setting up decoders before circuit execution. Deco .. doxygenclass:: cudaq::qec::decoding::config::multi_decoder_config :members: -.. doxygenfunction:: cudaq::qec::decoding::config::configure_decoders +.. doxygenfunction:: cudaq::qec::decoding::config::configure_decoders(multi_decoder_config &) +.. doxygenfunction:: cudaq::qec::decoding::config::configure_decoders(multi_decoder_config &, const std::filesystem::path &) .. 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 From 9092aa6248a61cf2711b67b3f2347f8b5ff97af3 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Thu, 6 Aug 2026 07:56:40 -0700 Subject: [PATCH 16/24] Remove the sparse helpers left behind by the O/D setters calculate_num_msyn_per_decode(), validate_sparse_column_indices() and set_sparse_from_vec() existed only to service set_O_sparse() and set_D_sparse(). Nothing has called them since those were removed: the construction path validates and derives the same quantities from decoder_inputs. Reported by tlshannon on PR #765 for the first and third; the second sits between them and is dead for the same reason. Signed-off-by: Melody Ren --- libs/qec/lib/decoder.cpp | 43 ---------------------------------------- 1 file changed, 43 deletions(-) diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index 971fd9187..eec16c2e7 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -313,49 +313,6 @@ dem_default_values dem_defaults_for_missing_keys( } // namespace details -static uint32_t calculate_num_msyn_per_decode( - const std::vector> &D_sparse) { - uint32_t max_col = 0; - bool found_column = false; - for (const auto &row : D_sparse) - for (const auto col : row) { - max_col = std::max(max_col, col); - found_column = true; - } - return found_column ? max_col + 1 : 0; -} - -static void -validate_sparse_column_indices(const std::vector> &sparse, - std::size_t max_col, const char *name) { - for (std::size_t row = 0; row < sparse.size(); ++row) { - for (const auto col : sparse[row]) { - if (col >= max_col) { - throw std::invalid_argument( - fmt::format("{} column index {} out of range [0, {}) at row {}", - name, col, max_col, row)); - } - } - } -} - -static void -set_sparse_from_vec(const std::vector &vec_in, - std::vector> &sparse_out) { - sparse_out.clear(); - std::vector row; - for (auto elem : vec_in) { - if (elem < 0) { - sparse_out.push_back(std::move(row)); - row.clear(); - } else { - row.push_back(static_cast(elem)); - } - } - if (!row.empty()) - sparse_out.push_back(std::move(row)); -} - uint32_t decoder::get_num_msyn_per_decode() const { return pimpl->num_msyn_per_decode; } From 01a8ff2a6df201bb409436796b9cd275e72b760c Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Thu, 6 Aug 2026 08:40:07 -0700 Subject: [PATCH 17/24] Address review: reject empty leading detector rows, drop unused surface Correctness. validate_detector_rows() only rejected adjacent -1 pairs, so a leading -1 -- an empty first detector row -- reached construction and installed a decoder whose detector 0 was permanently zero. On main the same configuration failed at set_D_sparse(). Reject it, with a test that empties the first row rather than inserting one, so the row count still matches the detector count and the empty row is the only thing under test. project_errors_to_observables() documented a throw but zero-filled and returned. In-tree every caller validates at construction, but this is the extension hook for out-of-tree plugins, where silently all-zero observable corrections is the worst available failure. Make the code match the doc. Unused surface. Eight decoder::get / get_decoder overloads took a matrix or raw DEM text together with an explicit output; no caller passes anything but decoder_inputs when it names an output, and their existence is what forced the string_view disambiguation comment. Three defensive branches no in-tree caller can reach are also gone: a D row-count check decoder_inputs already enforces, a missing-D check the resolver already guarantees, and a default arm after an exhaustive switch over a two-value enum. Documentation. The plugin-authoring guide taught a one-argument base constructor and make_pcm_decoder, which this branch removed; it now matches single_error_lut_example. The walkthrough named a decoder_inputs factory that does not exist. Reported by the review pass on PR #765. Signed-off-by: Melody Ren --- design_walkthrough.md | 5 +- docs/sphinx/components/qec/introduction.rst | 21 ++++-- libs/qec/include/cudaq/qec/decoder.h | 73 +------------------ libs/qec/lib/decoder.cpp | 16 +--- .../plugins/trt_decoder/trt_decoder.cpp | 3 +- libs/qec/lib/realtime/realtime_decoding.cpp | 21 ++---- libs/qec/unittests/test_decoders_yaml.cpp | 21 ++++++ 7 files changed, 52 insertions(+), 108 deletions(-) diff --git a/design_walkthrough.md b/design_walkthrough.md index 7cb736889..e711d739e 100644 --- a/design_walkthrough.md +++ b/design_walkthrough.md @@ -182,10 +182,11 @@ class decoder_inputs { // Build it from whichever source is authoritative. D is optional: it is only // meaningful for a decoder fed directly by the measurement transport. decoder_inputs(sparse_binary_matrix H, - std::optional O = std::nullopt, + std::optional O, std::vector error_rates = {}, std::optional D = std::nullopt, std::optional> error_ids = std::nullopt); + explicit decoder_inputs(sparse_binary_matrix H); // H-only models static decoder_inputs from_stim_dem(std::string stim_dem_text, std::optional D = std::nullopt); @@ -233,7 +234,7 @@ if (!decoder_config.stim_dem_path.empty()) { auto dem_text = read_file(resolve_against(base_dir, decoder_config.stim_dem_path)); return decoder_inputs::from_stim_dem(std::move(dem_text), std::move(D)); } -return decoder_inputs::from_matrices(H, O, rates, std::move(D)); // matrix source +return decoder_inputs(H, O, rates, std::move(D)); // matrix source // ...and later, after every decoder's inputs have resolved successfully: auto decoder = cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), diff --git a/docs/sphinx/components/qec/introduction.rst b/docs/sphinx/components/qec/introduction.rst index 5b9a46e01..0e8c79c94 100644 --- a/docs/sphinx/components/qec/introduction.rst +++ b/docs/sphinx/components/qec/introduction.rst @@ -633,9 +633,12 @@ To implement a new decoder: public: my_decoder(qec::decoder_inputs inputs, + qec::decoder_output requested_output, const heterogeneous_map& params) - : decoder(std::move(inputs)) { - // Initialize decoder + : decoder(std::move(inputs), requested_output) { + // All model data is available here. Reject a result form this + // decoder cannot produce, so an unsupported request fails at + // construction rather than on the first decode. } decoder_result decode( @@ -652,18 +655,20 @@ To implement a new decoder: my_decoder, static std::unique_ptr create( qec::decoder_inputs inputs, + std::optional requested_output, const heterogeneous_map& params) { - return qec::make_pcm_decoder(std::move(inputs), params); + return std::make_unique( + std::move(inputs), + requested_output.value_or(qec::decoder_output::errors), + params); } ) CUDAQ_EXT_PT_REGISTER_TYPE(my_decoder) -The :code:`make_pcm_decoder` helper is a transitional adapter for matrix-family -decoders. The factory always receives :code:`decoder_inputs`; the helper passes -that stable input handle to the decoder and supplies legacy constructor -defaults while decoder implementations migrate to reading model data directly -from the owned inputs. +The factory receives the model as :code:`decoder_inputs` and the caller's +result form as an optional :code:`decoder_output`. A decoder that supports one +form only should default the request to that form and reject any other. Example: Lookup Table Decoder ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index d41d0abab..e9c37c01e 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -208,14 +208,6 @@ class decoder return get(name, decoder_inputs{H}, param_map); } - static std::unique_ptr - get(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, - decoder_output output, - const cudaqx::heterogeneous_map ¶m_map = - cudaqx::heterogeneous_map()) { - return get(name, decoder_inputs{H}, output, param_map); - } - static std::unique_ptr get(const std::string &name, const cudaqx::tensor &H, const cudaqx::heterogeneous_map ¶m_map = @@ -230,35 +222,6 @@ class decoder return get(name, decoder_inputs::from_stim_dem(stim_dem_text), param_map); } - /// Each raw-DEM spelling needs its own explicit-output overload: string_view - /// does not convert to const std::string&, and with both present a string - /// literal would otherwise be ambiguous between them. - static std::unique_ptr - get(const std::string &name, const std::string &stim_dem_text, - decoder_output output, - const cudaqx::heterogeneous_map ¶m_map = - cudaqx::heterogeneous_map()) { - return get(name, decoder_inputs::from_stim_dem(stim_dem_text), output, - param_map); - } - - static std::unique_ptr - get(const std::string &name, const char *stim_dem_text, decoder_output output, - const cudaqx::heterogeneous_map ¶m_map = - cudaqx::heterogeneous_map()) { - return get(name, decoder_inputs::from_stim_dem(stim_dem_text), output, - param_map); - } - - static std::unique_ptr - get(const std::string &name, std::string_view stim_dem_text, - decoder_output output, - const cudaqx::heterogeneous_map ¶m_map = - cudaqx::heterogeneous_map()) { - return get(name, decoder_inputs::from_stim_dem(std::string{stim_dem_text}), - output, param_map); - } - static std::unique_ptr get(const std::string &name, const char *stim_dem_text, const cudaqx::heterogeneous_map ¶m_map = @@ -391,8 +354,8 @@ class decoder /// @param detector_layer_offsets Offsets `[0, w0, w0+w1, ...]`; `back()` /// must equal the model's detector count. /// @throws std::logic_error if called more than once. This is construction - /// state, not a reconfiguration point: re-entering it on a live decoder is - /// the mid-stream buffer reset that fixing this lifecycle removed. + /// state, not a reconfiguration point: re-entering it on a live decoder + /// would reset its buffers mid-stream. void initialize_streaming_layout(std::size_t num_syndromes_per_round, std::vector detector_layer_offsets); @@ -584,13 +547,6 @@ get_decoder(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, return get_decoder(name, decoder_inputs{H}, options); } -inline std::unique_ptr -get_decoder(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, - decoder_output output, - const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_inputs{H}, output, options); -} - inline std::unique_ptr get_decoder(const std::string &name, const cudaqx::tensor &H, const cudaqx::heterogeneous_map options = {}) { @@ -607,31 +563,6 @@ get_decoder(const std::string &name, const std::string &stim_dem_text, /// Each raw-DEM spelling needs its own explicit-output overload: string_view /// does not convert to const std::string&, and with both present a string /// literal would otherwise be ambiguous between them. -inline std::unique_ptr -get_decoder(const std::string &name, const std::string &stim_dem_text, - decoder_output output, - const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_inputs::from_stim_dem(stim_dem_text), output, - options); -} - -inline std::unique_ptr -get_decoder(const std::string &name, const char *stim_dem_text, - decoder_output output, - const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_inputs::from_stim_dem(stim_dem_text), output, - options); -} - -inline std::unique_ptr -get_decoder(const std::string &name, std::string_view stim_dem_text, - decoder_output output, - const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, - decoder_inputs::from_stim_dem(std::string{stim_dem_text}), - output, options); -} - inline std::unique_ptr get_decoder(const std::string &name, const char *stim_dem_text, const cudaqx::heterogeneous_map options = {}) { diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index eec16c2e7..31b78bc3a 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -97,11 +97,6 @@ decoder::decoder(decoder_inputs inputs, decoder_output requested_output) // here, from the model. Nothing arrives later: a decoder is usable as soon // as it is constructed. if (const auto *D = inputs_.measurement_to_detectors()) { - if (D->num_rows() != syndrome_size) - throw std::invalid_argument(fmt::format( - "measurement-to-detector map row count ({}) must match the model's " - "detector count ({})", - D->num_rows(), syndrome_size)); pimpl->measurement_to_detectors = D->to_nested_csr(); pimpl->num_msyn_per_decode = D->num_cols(); } @@ -125,10 +120,12 @@ void decoder::project_errors_to_observables( // are fixed by construction, so they are not re-checked here. There is one // observable model -- the one this decoder was constructed with -- so there // is no second source to fall back to. + if (!inputs_.has_observable_model()) + throw std::runtime_error("decoder was asked to project an error frame onto " + "observables but its model supplies no observable " + "mapping"); if (observables_size > 0) std::fill(observables, observables + observables_size, float_t{0}); - if (!inputs_.has_observable_model()) - return; const auto &O = inputs_.observable_flips_matrix(); assert(O.layout() == sparse_binary_matrix_layout::csr); @@ -464,9 +461,6 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, expected_result_size = num_observables; break; } - if (!result_type_name) - throw std::runtime_error(fmt::format( - "Unsupported decoder result type ({})", static_cast(output_))); if ((!pimpl->round_streaming_initialized && decoded_values.size() != expected_result_size) || (pimpl->round_streaming_initialized && !decoded_values.empty() && @@ -600,8 +594,6 @@ const uint8_t *decoder::get_obs_corrections() const { } std::size_t decoder::get_num_observables() const { - // The model owns the count whenever it supplies an observable mapping, even - // a zero-row one. The late-setter fallback serves only H-only inputs. return inputs_.num_observables(); } diff --git a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp index 8d78145da..3a46bfd7b 100644 --- a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp +++ b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp @@ -601,7 +601,8 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, trt_engine_output_format::observables_and_residual_detectors) && requested_output != decoder_output::observables) throw std::runtime_error( - "This TensorRT engine_output_format only supports observable output"); + "engine_output_format declares observables, so this decoder cannot be " + "constructed for error-frame output"); // An engine that emits an error frame can still serve an observable-output // instance, but only by projecting through the model's O. Without an diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index 3c88a36a4..d554c4f53 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -227,6 +227,9 @@ void validate_sparse_indices(const std::vector &sparse, void validate_detector_rows(const std::vector &d_sparse, std::int64_t id) { + if (!d_sparse.empty() && d_sparse.front() == -1) + throw std::runtime_error( + fmt::format("D_sparse row is empty for decoder {}", id)); for (std::size_t i = 0; i + 1 < d_sparse.size(); ++i) if (d_sparse.at(i) == -1 && d_sparse.at(i + 1) == -1) throw std::runtime_error( @@ -268,15 +271,9 @@ cudaq::qec::decoder_inputs resolve_decoder_inputs( std::string dem_text((std::istreambuf_iterator(dem_file)), std::istreambuf_iterator()); - // Known gap, deliberately not addressed here: the model is identified by - // path, and a reload compares configurations. An operator who edits a DEM - // in place leaves the configuration byte-identical, so the reload sees no - // change and keeps serving the previous model. Closing this needs the - // reload path to compare model content (a hash in the effective - // configuration) or to always reconstruct decoders that reference an - // external file. That belongs with the transactional reload work, which - // owns configuration comparison; until then, change the path to change the - // model. + // The model is identified by path, so editing a DEM in place leaves the + // configuration byte-identical and a reload keeps serving the old model. + // Change the path to change the model. auto inputs = cudaq::qec::decoder_inputs::from_stim_dem(std::move(dem_text), std::move(D)); @@ -359,9 +356,6 @@ std::unique_ptr create_realtime_decoder( CUDA_QEC_INFO("Creating decoder {} of type {}", decoder_config.id, decoder_config.type); - if (!inputs.measurement_to_detectors()) - throw std::runtime_error( - "resolved decoder inputs carry no measurement-to-detector map"); auto decoder = cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), cudaq::qec::decoder_output::observables, @@ -400,8 +394,7 @@ int configure_decoders( // A live session holds a reference to g_decoders and inspects it at // initialize(), so replacing decoders underneath it is unsafe. Reject before - // doing any expensive work; callers must finalize first. PR #695 replaces - // this guard with real quiescence and rollback. + // doing any expensive work; callers must finalize first. if (g_realtime_session) { CUDA_QEC_WARN("Cannot reconfigure decoders while a realtime session is " "active; call finalize_decoders() first."); diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index d1aa17e5f..e099c8a5d 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -1021,6 +1021,27 @@ TEST(ConfigureDecodersLifecycle, InvalidModelLeavesPriorConfigurationInPlace) { finalize_decoders(); } +TEST(ConfigureDecodersLifecycle, EmptyLeadingDetectorRowIsRejected) { + using namespace cudaq::qec::decoding::config; + + // A -1 in first position is an empty detector row: that detector maps to no + // measurement and would decode as permanently zero. The row-emptiness check + // once looked only for adjacent -1 pairs, so a leading one reached + // construction and produced a silently wrong decoder. + multi_decoder_config config; + auto leading_empty = create_test_sample_realtime_decoder_config(0); + auto &d = leading_empty.D_sparse; + d.erase(d.begin(), std::find(d.begin(), d.end(), -1)); + ASSERT_EQ(d.front(), -1); + ASSERT_EQ(std::count(d.begin(), d.end(), -1), + std::count(leading_empty.D_sparse.begin(), + leading_empty.D_sparse.end(), -1)); + config.decoders.push_back(leading_empty); + EXPECT_THROW(configure_decoders(config), std::runtime_error); + + finalize_decoders(); +} + TEST(ConfigureDecodersLifecycle, ConstructionFailureIsNotAdvertised) { using namespace cudaq::qec::decoding::config; From 2a91fe50879da04fb557ac801295d25b3925533b Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Thu, 6 Aug 2026 09:17:13 -0700 Subject: [PATCH 18/24] Drop the design walkthrough and the unused Python output enum The walkthrough was a review artifact: it opened by saying it was not in a mergeable state, and its content belongs in the pull request rather than the repository. The DecoderOutput binding was added by this branch and never consumed; the Python path selects the result basis with an output="errors"/"observables" keyword. Remove it and fold the keyword parsing, which had been copied into both get_decoder lambdas, into one helper. Also record what decoder_model_source is for: it is the entry point for a compact chunked DEM source, which would arrive as a new enumerator with its own typed constructor and accessor rather than by flattening chunks into matrices. Signed-off-by: Melody Ren --- design_walkthrough.md | 538 -------------------- libs/qec/include/cudaq/qec/decoder_inputs.h | 13 +- libs/qec/python/bindings/py_decoder.cpp | 31 +- 3 files changed, 25 insertions(+), 557 deletions(-) delete mode 100644 design_walkthrough.md diff --git a/design_walkthrough.md b/design_walkthrough.md deleted file mode 100644 index e711d739e..000000000 --- a/design_walkthrough.md +++ /dev/null @@ -1,538 +0,0 @@ -# Decoder model inputs: a design walkthrough - -Note to reviewers: - -This draft is not in a mergeable state. The intent is to get the design discussion started and have some concrete examples to look at. The design was done with Tracy's dynamic DEM PR in mind and was meant to be extensible to support DEM chunks, though there are certainly still rough edges. - -## A quick note on H/O/D - -`H` has shape `detectors x error mechanisms`. Column `e` says which detectors fire when error -mechanism `e` occurs; this is the model a matrix-based decoder decodes against. - -`O` has shape `observables x error mechanisms`. The same column `e` says which logical observables -that error mechanism flips. If a decoder predicts an error frame `x`, the observable correction is -`O * x` over GF(2). A decoder such as Chromobius can instead predict those observable flips -directly, but the meaning of O does not change. - -`D` has shape `detectors x raw measurements`. Hardware sends measurement bits; the decoder consumes -detectors. `D * m` over GF(2) is the bridge between those two bases. - -So H and O describe the decoding model. D is not part of the noise model, but it is dimensionally -bound to H's detector basis, and the base class owns the buffers and preprocessing derived from it. - - -## A quick note about decoders - -This design is based on the standing convention that a decoder is immutable once constructed. E.g., its H, error rate, return type -are set at construction time and are not meant to change during the lifetime of the decoder instance. - -## The current problem - -All I wanted to do was to enable Chromobius on the decoding server path. - -But the road to Chromobius is fraught with false leads. On baseline `main` the server always builds a plugin from H, while -Chromobius can only be constructed from a raw Stim DEM. So Chromobius works perfectly well through -the offline DEM factory, and is unreachable through the server's matrix-only construction -path. Effectively, it is blocked from the decoding server. - -Concretely, the server configuration carries H, O and D, but only H reaches the factory: - -```cpp -// realtime_decoding.cpp, baseline main -auto decoder = cudaq::qec::get_decoder( - decoder_config.type, pcm, prepare_decoder_params(decoder_config)); // pcm is H -decoder->set_decoder_id(decoder_config.id); -decoder->set_O_sparse(decoder_config.O_sparse); // O arrives later -decoder->set_D_sparse(decoder_config.D_sparse); // D arrives later -``` - -Chromobius, meanwhile, accepts the other arm of `decoder_init` and rejects the matrix arm: - -```cpp -// chromobius.cpp, baseline main -const auto *dem_text = std::get_if(&init); -if (!dem_text) - throw std::runtime_error(...); -``` - -The old construction pathway is: - -```cpp -using decoder_init = std::variant; -``` - -This string variant allows Chromobius to be constructible offline. However, the baseline -YAML schema requires the matrix branch unconditionally: - -```cpp -// config.cpp, baseline main -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); -``` -This means that simply teaching the server to choose the string arm would leave two authorities. -A DEM-backed configuration would therefore need to supply H and O even though the DEM already -defines them. H and O might contradict DEM. - -And then, it gets worse: - -### O arrives by a different road for every decoder - -The realtime path needs O so that error-frame decoders can produce observable corrections. On -baseline main, when and how that O arrives depends on which decoder is being used and which path -the decode is on. In other words, we have an "all roads lead to Rome" situation, with some very -precise "turn left, then right, then left" call-order implications embedded. - -Drawn out, with the worst case at the bottom: - -``` - baseline main - how O reaches a decoder - - offline, PyMatching .... params["O"] ------------> ctor --> this->set_O_sparse() - offline, Chromobius .... (read out of the DEM text; no O argument at all) - realtime, top level .... get_decoder(H, params) --> ctor - `-- then: set_O_sparse() - - realtime, TensorRT with a PyMatching global decoder - the same matrix, three times: - - server --(1)-- params["O"] ---------------------------> TensorRT ctor - server --(2)-- params["global_decoder_params"]["O"] ---> PyMatching global-decoder ctor - server --(3)-- set_O_sparse() -------------------------> after construction - ^ - `-- (1) and (2) are selected by hardcoded decoder names -``` - - -That trt+pymatching is the one that should set off an alarm. Common server code knows both a wrapper's -internal parameter convention and a particular global decoder's name, *hardcoded*. This opens the gate that a third party decoder -author will need to modify our source code in order to plug in a different global decoder. Baseline `main` says so itself: - -```cpp -// realtime_decoding.cpp, baseline main -// PyMatching consumes the observable matrix through its params; other global -// decoders receive only the top-level O until they define a matching contract. -if (has_pymatching_global) { ... global_decoder_params.insert("O", O); } -``` - -In addition, O arrives three different times and is stored three times. Both plugins convert `params["O"]` and call `set_O_sparse()` on -themselves, so the matrix ends up living in the server's `decoder_config`, in the TensorRT object's -base member, and in the global decoder's base member — having passed through two parameter maps as a *dense -tensor* to get there. The third delivery then overwrites the first copy with the same content!!! - -That's not all and I certainly contributed to this, because: - -### O carries two meanings at once - -In baseline `main`, supplying O means "this is the observable matrix" and "return observables -instead of errors." It can also select a matching strategy in the case of pymatching: - -```cpp -// pymatching.cpp, baseline main — inside `if (params.contains("O"))` -this->set_O_sparse(O_sparse); -this->set_result_type(decode_result_type::decode_to_obs); -decode_to_observables = true; -if (!merge_strategy_explicit) - merge_strategy_enum = pm::MERGE_STRATEGY::INDEPENDENT; // surprise bonus -``` - -A caller cannot supply O as model data while asking for an error -frame, even though that is useful for a server that wants to perform the projection itself. Nor can -the caller discuss output shape without also discussing whether O happened to be present in the config. - -## What we propose - -This draft separates decoder model data (H/O/D/DEM/error rate) from decoder knobs (iterations to run, strategy to use etc), -makes O mean O and nothing else, teaches the decoding server to accept either a raw DEM or H/O/D but not both -at the same time, and removes the late O/D setters (this part is debatable but the intention of removal was to keep a single -source of truth). - -In order: - -1. **Give stable construction input a typed home:** `decoder_inputs`. -2. **Every path resolves it before construction** — an offline caller, a decoder the server builds - directly from a `decoder_config` entry, and a decoder that a wrapper builds internally (TensorRT's - global decoder, sliding window's inner decoders) all resolve the same value. -3. **Output form becomes an explicit construction argument**, because O is now a field and can no - longer double as the request. -4. **The base sizes realtime state at construction**, since it finally knows the inputs then; the - sliding-window subclass hands over its own streaming geometry rather than being `dynamic_cast` - to. (This part is debatable. I ported sliding window for completeness's sake) -5. **The setters are now unused: delete them**, or keep them as assertions. - -We now go over the above statements in detail below: - -### One construction input, distinct from the knobs - -`decoder_inputs` is a small immutable handle to shared construction state. It owns: - -- H in sparse CSC form; -- optional O in sparse CSR form; -- error rates and optional error IDs, indexed by H column; -- optional D in sparse CSR form; -- the authoritative source kind and, for a Stim source, the raw DEM text; and -- dimensions as metadata, so asking for a size does not force a future compact source to - materialize a matrix. - -The public surface, abridged: - -```cpp -class decoder_inputs { - // Build it from whichever source is authoritative. D is optional: it is only - // meaningful for a decoder fed directly by the measurement transport. - decoder_inputs(sparse_binary_matrix H, - std::optional O, - std::vector error_rates = {}, - std::optional D = std::nullopt, - std::optional> error_ids = std::nullopt); - explicit decoder_inputs(sparse_binary_matrix H); // H-only models - - static decoder_inputs from_stim_dem(std::string stim_dem_text, - std::optional D = std::nullopt); - - decoder_model_source source() const noexcept; // which one is authoritative - - // The common matrix view, available whatever the source was. - const sparse_binary_matrix &detector_error_matrix() const; // H - const sparse_binary_matrix &observable_flips_matrix() const; // O - const std::vector &error_rates() const; - const sparse_binary_matrix *measurement_to_detectors() const; // D, or nullptr - - // The raw view, for decoders that want the source itself. - bool has_stim_dem() const noexcept; - const std::string &stim_dem() const; -}; -``` - -A DEM-native decoder reads `stim_dem()`; a matrix decoder reads `detector_error_matrix()`. Both are -looking at one source, which is the point. - -The distinction is: **stable construction input describes the decoding problem and this session's input -basis independently of one decoder's implementation; parameters choose how a particular decoder -solves it.** Not every decoder consumes every field. That is fine. `error_rate_vec` belongs here -because it has one entry per H column and comes from the same DEM as H and O. D belongs here because -it is fixed for the session and dimensionally bound to H's detector basis, even though it is not -part of the noise model. On the other hand, `max_iterations` and `merge_strategy` are *parameters*, specific to how one -particular decoder solves the problem. - -`decoder_config` does not disappear and it does not magically become pure knobs. It remains the -server's serializable configuration form, including the selected model source. What changes is where -the YAML stops. Today the plugin sees fragments of the config: a flat `-1`-delimited sparse vector -here, a dense tensor in a parameter map there, a matrix arriving after construction. Under the -proposal the server converts the config once, into the same `decoder_inputs` an offline caller would -build by hand, and that is the only thing the factory ever sees. No plugin author needs to know that -`O_sparse` was a flat vector with sentinel values in a YAML file: - -```cpp -// realtime_decoding.cpp, proposed — resolve first, construct second -auto D = canonical_measurement_to_detectors(decoder_config.D_sparse); - -if (!decoder_config.stim_dem_path.empty()) { - // stim_dem_path is mutually exclusive with H_sparse/O_sparse/error_rate_vec: - // one authoritative source, not two representations of the same model. - auto dem_text = read_file(resolve_against(base_dir, decoder_config.stim_dem_path)); - return decoder_inputs::from_stim_dem(std::move(dem_text), std::move(D)); -} -return decoder_inputs(H, O, rates, std::move(D)); // matrix source - -// ...and later, after every decoder's inputs have resolved successfully: -auto decoder = cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), - requested_output, params); -``` - -The resolve step is side-effect-free, so a bad configuration fails before any live decoder is -touched. - -The handle uses a PIMPL/shared-state representation. That makes copies cheap and leaves room to add -a typed compact source later without changing the handle's object layout. - -### Output form is fixed at construction - -O becomes data only. `decoder_output::{errors, observables}` is a separate factory argument and is -fixed for the lifetime of the instance. - -The plugin validates the combination during construction: - -- an error-producing decoder asked for observables requires O and may call the base projection - helper before returning; -- Chromobius accepts observables and rejects an error-frame request; -- TensorRT validates the request against its engine output format; and -- PyMatching constructs the graph corresponding to the requested form. - -A forward-looking note: this design does require the user to construct two decoder instances if they -want both errors and observables as the return type, though that can be expanded later. - - -### The decoding server resolves one authoritative model source - -The server now accepts two source shapes: - -- **matrix source:** H, O and optional rates, with sizes required to interpret the flat sparse - encoding; or -- **Stim source:** `stim_dem_path`, mutually exclusive with H, O and rates. H, O, rates and sizes - are derived once from the DEM. - -D is orthogonal to that choice and remains required by the current realtime server because its -transport supplies raw measurements. For a DEM source, D's row count is checked against the -DEM-derived detector count. Optional `block_size` and `syndrome_size` values are assertions checked -against the DEM, not competing authorities. - -The raw DEM path is resolved relative to the configuration document (or the working directory for -programmatic/raw-string configuration), made absolute, read, parsed and normalized *before* decoder -construction. The plugin receives the validated artifact; construction does not re-parse a second -copy. - -This draft deliberately uses an operator-visible filesystem path rather than transporting an -839 KiB DEM in the published configuration payload. One caveat is that editing a -DEM in place without changing the path is invisible to the current reload comparison. - -### Wrapper decoders - -Two decoders wrap another decoder: TensorRT constructs a **global decoder** to run after -its engine, and sliding window constructs an **inner decoder** per window. Both deserve -a bit of special treatment: - -Concretely, a wrapper may never hand the decoder it constructs a *different problem*. It hands -it the same problem — the same code, the same noise — possibly a slice of it, possibly at a later -stage of the pipeline. That is what `decoder_inputs` is for: the wrapper derives the decoder it -constructs from its own inputs, so there is nothing else it *could* hand over. *This is also where a conflict -with the streaming DEM work is most likely to happen.* - -"Same problem" is not the same as "same bytes," and this is where the shapes differ. PyMatching as a -global decoder wants H and O; Chromobius as a global decoder wants the raw DEM text. They receive the -same `decoder_inputs`, which carries both views of one source, and each reads the representation it -needs. Nobody derives a second model. - -Only two things can legitimately differ between a wrapper's inputs and its constructed decoder's: - -1. **Who feeds it.** Only the decoder the realtime transport feeds directly needs D, because D is - what the base applies to turn an arriving measurement stream into detectors. Anything a wrapper - constructs is fed by that wrapper, never by the transport, so D never travels inward. This is not - a per-wrapper judgement call — it is true of every wrapped decoder. -2. **Indexing.** Did the wrapper renumber detectors or error mechanisms? This decides whether the raw - DEM *text* still reads correctly, because a DEM names its detectors by position. - -TensorRT changes neither. It hands its global decoder the same inputs minus D, since the global -decoder is fed the engine's residual detectors rather than a measurement stream. The detector basis -and ordering are untouched, so the raw DEM still describes them exactly and Chromobius can be the -global decoder. That preservation is a **caller guarantee, not something the code proves**: declaring -`engine_output_format` as one of the residual forms is the caller asserting that the engine emits -residual detectors in exactly the H-row basis and order supplied at construction. The implementation -validates width only. A reordered engine would silently feed the global decoder a permuted syndrome, -and a raw-DEM global decoder would then decode it against the wrong detector identities. The source -says so at the declaration site, and supporting reordered residuals would need an explicit detector -mapping this contract does not provide. - -Sliding window changes indexing. Each window is the same code and the same noise — just a subset of -detector rows and error columns, renumbered from zero. The matrices slice cleanly and carry the -problem faithfully. The raw DEM text does not: it names detectors by the outer numbering, so handing -it to a window would have the inner decoder reading `D17` as its own detector 17 rather than the -outer one. So sliding window slices its matrices and constructs each inner decoder's inputs from -them directly. Passing the outer DEM through that slice would be worse than losing provenance: it -would be confidently wrong. - -That gives two cases, and only one of them needs an operation: - -- **Same numbering, different feed.** `decoder_inputs_without_d()` returns the same inputs without D, - for a decoder that receives detectors rather than a measurement stream. Everything else, the raw - source included, is preserved. TensorRT uses this for its global decoder. -- **New numbering.** The wrapper builds fresh `decoder_inputs` from the matrices it computed. There - is no operation for this and no need for one: a matrix-constructed handle carries no raw source, so - the DEM text is dropped structurally rather than by a rule someone has to remember. - -This also allows us to expand into more exotic wrapping schemes by utilizing `decoder_inputs`. - - -### Who owns realtime allocation - -Once O and D are construction inputs, the base owns everything whose size they determine: - -- the measurement buffer from D's column count; -- D's measurement-to-detector mapping; -- detector and soft-detector buffers from H's row count; and -- observable corrections from O's row count. - -Baseline main already sizes the detector buffers in the base constructor from H's row count, so this -is not a wholesale relocation — the point is that the *remaining* pieces stop depending on *setter -call order*. - -Who owns what, before and after: - -| state | derived from | baseline main | proposed | -|---|---|---|---| -| H | model | factory argument | factory argument, inside `decoder_inputs` | -| O | model | `params["O"]`, or `set_O_sparse()` after construction | `decoder_inputs` | -| D | session input basis | `set_D_sparse()` after construction | `decoder_inputs` | -| error rates | model | `params["error_rate_vec"]`, beside the knobs | `decoder_inputs` | -| detector buffers | H row count | base constructor | base constructor | -| measurement buffer | D column count | sized by `set_D_sparse()` | base constructor | -| corrections buffer | O row count | sized by `set_O_sparse()` | base constructor | -| streaming layer geometry | the decoder's own choice | `set_D_sparse()` resizes detector buffers after the base `dynamic_cast`s to `sliding_window` | subclass hands it over once | -| output form | the caller's request | `result_type_`, set by decoder-specific constructor behaviour when O is present | explicit factory argument | - -And the lifecycle: - -``` - baseline main - - get_decoder ---> ctor: H only ---> set_O_sparse ---> set_D_sparse ---> realtime-usable - ^ ^ ^ - `----- decode(syndrome) already works here: H is all it - needs. enqueue_syndrome() works too -- but only - once both setters have run. Nothing states that - requirement or enforces it; you are expected to - know the call order. - - proposed - - resolve ---> ctor: inputs + output form + allocation ---> usable - ^ ^ - | `-- the server may still assign an ID, dry-run a - | decode, or let the decoder initialize GPU - | resources lazily. None of that changes what - | the decoder means. - `-- every path produces the same decoder_inputs -``` - -One of the things that the proposal aims to remove is that the decoder's readiness depends on a call sequence the type system only implies. - -Sliding window has one extra construction step: its subclass constructor calls -`initialize_streaming_layout()` with detector-layer offsets and the maximum layer width. That -geometry is not a property of H/O/D; it is how this decoder chooses to consume rounds. The base -cannot obtain it through a virtual call while the subclass is still constructing, so the subclass -hands it over through a one-shot, construction-only latch. - -## What this buys a plugin author and user of the decoding server - -Before, an ordinary H-based plugin creator is effectively written against this contract: - -```cpp -create(const decoder_init &init, const heterogeneous_map ¶ms) { - // Extract H or reject the other variant arm. - // O might be in params offline, or appear through a base setter online. - // D arrives only after construction on the server path. -} -``` - -After: - -```cpp -create(decoder_inputs inputs, - std::optional requested_output, - const heterogeneous_map ¶ms) { - // All construction input is present now. Validate the fixed output request and build. -} -``` - -The concrete gains are: - -- offline, top-level server and nested construction share one input value; -- stable construction data no longer travels in an untyped decoder-parameter bag; -- a plugin can reject unsupported model/output combinations before becoming live; -- common server code no longer knows decoder names in order to forward O; and -- wrappers have an explicit rule for retaining or dropping authoritative source data. - -The costs are also concrete: - -- Lots, I mean lots, of code change, not even counting the change needed for the private decoder; -- each plugin owns construction-time validation of the output forms it promises; - - -## Performance and memory - -Measured against the merge base, `upstream/main` at `674cb8f2` — using Pymatching - -### The realtime path, over UDP - -Benchmarked using `surface_code-1-cqr`. The server is run -with `QEC_DECODING_SERVER_SPIN_US=0` so it blocks rather than busy-polls: the semantics are identical -either way, but its CPU time then measures decode and transport work instead of poll loops. - -| distance 5, 5 rounds, 1000 shots (8000 decodes) | main | proposed | -|---|---:|---:| -| server CPU per decode | 262.5 / 265.0 / 263.8 µs | 256.3 / 257.5 / 256.3 µs | -| server peak RSS | 418.1 / 418.5 / 418.5 MiB | 417.9 / 417.9 / 418.1 MiB | -| app wall clock | 1.71 / 1.72 / 1.71 s | 1.68 / 1.67 / 1.67 s | - -| distance 9, 9 rounds, 500 shots (6000 decodes) | main | proposed | -|---|---:|---:| -| server CPU per decode | 751.7 / 745.0 µs | 738.3 / 746.7 µs | -| server peak RSS | 421.7 / 421.5 MiB | 420.5 / 420.4 MiB | -| app wall clock | 4.03 / 3.98 s | 3.95 / 4.00 s | - -**No regression.** At distance 5 the proposed branch is about 2.7% cheaper per decode and the -repetition ranges do not overlap; at distance 9 the two are indistinguishable. Peak RSS is the same -to within 0.3%, which is expected: this configuration carries its model as matrices, so it never -exercises the DEM parsing path below. - -This is the right benchmark for the question "did lifting O and D out of the setters cost anything," -because it is the one path that uses both, per shot. The generated configuration carries `H_sparse`, -`O_sparse` and `D_sparse` and no `stim_dem_path`, so on main it takes exactly the setter route — -`get_decoder(H)`, then `set_O_sparse()`, then `set_D_sparse()` — while this branch resolves the same -three into `decoder_inputs` before construction. Neither matrix is decoration at run time: D converts -every arriving measurement stream into detectors inside `enqueue_syndrome()`, and O turns the decoded -frame into the corrections the app counts. Both branches found the same number of corrections (50 at -distance 5, 71 at distance 9). - -### Resolving a model from a DEM - -This is where the branch is meaningfully different, and it is decoder-independent — it is the step -that turns DEM text into whatever the framework holds as the model. On the distance-13 surface code -DEM (`H = 2184 x 47129`): - -| | main (`dem_from_stim_text`) | proposed (`decoder_inputs::from_stim_dem`) | -|---|---:|---:| -| parse | 75.5 / 97.0 / 75.2 ms | 11.7 / 11.2 / 11.3 ms | -| retained | 105.9 / 105.9 / 105.9 MiB | 6.0 / 5.8 / 5.8 MiB | -| peak | 106.7 / 106.7 / 106.9 MiB | 6.9 / 6.8 / 6.8 MiB | - -About 7x faster and 17x smaller. The retained figures differ because the representations differ, -which is the point: main materializes dense `detectors x mechanisms` tensors for H and O, while this -branch builds the sparse arrays directly from the hit lists the parser already has. Nothing about -this required the lifecycle change — but the lifecycle change is what put a single, obvious -resolution step where the cost was visible. - -This matters most for exactly the case that started all of this - a Chromobius-on-the-server -configuration is DEM-sourced by definition. - -## Compatibility with incoming work - -### Chunked or streaming DEM sources - -PR #759 introduces a compact repeated-round description and points toward decoders consuming -chunks without flattening. `decoder_inputs` therefore stores source metadata separately from its -common matrix view and hides its representation behind a PIMPL. The current enum exposes only the -two implemented sources—matrices and raw Stim DEM. A chunked source should be added only with a -typed constructor/accessor and a real consumer, not as a decorative enum value. - -The unresolved contract is what makes a transformation basis-preserving for a compact source, -including whether D still maps into the same detector basis. The current rule gives that future -work somewhere to attach: preserve the authoritative source when detector/error identity survives; -drop it when a transformation re-indexes either basis. - -## Why not do a smaller fix by simply expanding what the decoding server accepts and leave O/D where they are? - -The minimal fix is: accept `stim_dem_path` in the server config, pass the text through the existing -string arm of `decoder_init`, and keep the setters. It is much smaller and it does unblock -Chromobius. It also does not work on its own, and leaves the rest in place: - -- **It still has to derive O and inject it.** The base sizes its corrections buffer from whatever - `set_O_sparse()` hands it, so a DEM-only config reports zero observables. The server must derive O - from the DEM and set it on a decoder that already read that same O out of its own DEM text. In the case - of Chromobius, you need to pass an O in just so Chromobius can be constructed and then discard the O. -- **Two authorities for O, with no check.** The setter's O and the decoder's own O can disagree; the - base validates row count, never content. We reproduced a silent inverted correction this way. -- **D still arrives after construction**, so the base still cannot size realtime state when the - constructor returns, and readiness still depends on an unstated call order. -- **Output form stays coupled to O's presence**, so a caller cannot ask for an error frame while - supplying O as data. -- **Name-based routing survives.** Common server code still forwards O by comparing against - `"trt_decoder"` and `"pymatching"`, so a third-party global decoder still requires editing our - source. -- **Nested construction stays special.** TensorRT and sliding window still receive their model by a - different mechanism than a top-level decoder. - - - - diff --git a/libs/qec/include/cudaq/qec/decoder_inputs.h b/libs/qec/include/cudaq/qec/decoder_inputs.h index 23c41e668..531d9ddf7 100644 --- a/libs/qec/include/cudaq/qec/decoder_inputs.h +++ b/libs/qec/include/cudaq/qec/decoder_inputs.h @@ -22,10 +22,12 @@ namespace cudaq::qec { /// @brief Authoritative representation from which a decoder model originates. /// -/// Matrix and Stim sources are supported. A compact repeated-round source is -/// expected once the dynamic DEM APIs settle; adding it here needs only a new -/// enumerator plus its typed constructor and accessor, and changes neither the -/// `decoder_inputs` object layout nor the decoder factory signature. +/// Matrix and Stim sources are implemented. This is the entry point for a +/// compact chunked DEM: that source would be added here with a new enumerator +/// plus its typed constructor and accessor, so a decoder that consumes chunks +/// reads them directly instead of the handle first flattening them into +/// matrices. Adding one changes neither the `decoder_inputs` object layout nor +/// the decoder factory signature. enum class decoder_model_source : std::uint8_t { matrices, stim_dem, @@ -86,6 +88,9 @@ class decoder_inputs { decoder_inputs &operator=(decoder_inputs &&) noexcept; ~decoder_inputs(); + /// @brief The authoritative representation. Consumers that only need to + /// know whether raw DEM text is available should ask has_stim_dem(); this + /// discriminator is what a future compact source would extend. decoder_model_source source() const noexcept; /// @brief Return the stored common H projection. diff --git a/libs/qec/python/bindings/py_decoder.cpp b/libs/qec/python/bindings/py_decoder.cpp index fe63243fa..eed072489 100644 --- a/libs/qec/python/bindings/py_decoder.cpp +++ b/libs/qec/python/bindings/py_decoder.cpp @@ -461,6 +461,21 @@ nb::object copyToPyArray(const std::vector &v) { } // namespace +namespace { +/// Read and remove the "output" keyword, which selects the result basis. +std::optional pop_requested_output(nb::kwargs &options) { + if (!options.contains("output")) + return std::nullopt; + const auto value = nb::cast(options["output"]); + options.attr("pop")("output"); + if (value == "errors") + return decoder_output::errors; + if (value == "observables") + return decoder_output::observables; + throw std::runtime_error("output must be 'errors' or 'observables'"); +} +} // namespace + void bindDecoder(nb::module_ &mod) { // Store a sentinel (non-null pointer required by PyCapsule_New) and invoke // plugin cleanup when the module is garbage-collected. @@ -472,10 +487,6 @@ void bindDecoder(nb::module_ &mod) { ? nb::cast(mod.attr("qecrt")) : mod.def_submodule("qecrt"); - nb::enum_(qecmod, "DecoderOutput") - .value("ERRORS", decoder_output::errors) - .value("OBSERVABLES", decoder_output::observables); - nb::class_(qecmod, "DecoderResult", R"pbdoc( Single-shot decoder result. @@ -903,17 +914,7 @@ void bindDecoder(nb::module_ &mod) { return PyDecoderRegistry::get_decoder(name, H_obj, options); } - std::optional output; - if (options.contains("output")) { - const auto value = nb::cast(options["output"]); - options.attr("pop")("output"); - if (value == "errors") - output = decoder_output::errors; - else if (value == "observables") - output = decoder_output::observables; - else - throw std::runtime_error("output must be 'errors' or 'observables'"); - } + const auto output = pop_requested_output(options); auto inputs = decoder_inputs::from_stim_dem(dem_text); return output ? get_decoder(name, std::move(inputs), *output, hetMapFromKwargs(options)) From bae441f287e530eebd61bf924defdb49ad2e8f31 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Thu, 6 Aug 2026 10:24:29 -0700 Subject: [PATCH 19/24] Document which column a merged parallel edge reports Parallel H columns share one matching edge, so an error frame must name one of them. The column named is the one whose parameters the graph holds after the merge: KEEP_ORIGINAL and INDEPENDENT retain the first column's observables, REPLACE adopts the last, SMALLEST_WEIGHT adopts the smaller weight. Baseline main named the last column for every strategy, which contradicts the retained edge for the first two. Comment only; the behavior and its test are unchanged. Signed-off-by: Melody Ren --- libs/qec/unittests/decoders/pymatching/test_pymatching.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp b/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp index 4cbecd763..2c7781d9a 100644 --- a/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp +++ b/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp @@ -186,6 +186,12 @@ TEST(PyMatchingDecoder, AcceptsAllMergeStrategiesAndRejectsUnknown) { std::runtime_error); } +// Parallel columns share one matching edge, so an error frame has to name one +// of them. The column named is the one whose parameters the graph actually +// holds after the merge: KEEP_ORIGINAL and INDEPENDENT retain the first +// column's observables, REPLACE adopts the last, SMALLEST_WEIGHT adopts +// whichever weight is smaller. Baseline main named the last column for every +// strategy, which contradicts the retained edge for the first two. TEST(PyMatchingDecoder, ErrorOutputTracksMergedParallelEdgeColumn) { cudaqx::tensor H; const std::vector H_vec = {1, 1}; From 1124859cd2ae4bbebab13bfec26519f4fd41b114 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Thu, 6 Aug 2026 12:50:55 -0700 Subject: [PATCH 20/24] Restore main's names where the concept did not change This branch renamed API that already existed upstream, without the meaning changing. Put those back: decoder_output -> decode_result_type get_output() -> get_result_type() output_ -> result_type_ matched_edges -> edges (pymatching) measurement -> meas (loop variable) The enumerators stay spelled errors/observables: upstream's decode_to_errs and decode_to_obs read as instructions to a setter that no longer exists, while the value is now a construction-time declaration. Two other names differ from upstream on purpose and are left alone. TensorRT's decode_to_observables_ member became a local has_observable_prefix derived from the engine format, which answers a different question. The base's is_sliding_window flag became round_streaming_initialized, which names the installed geometry rather than a concrete subclass. Signed-off-by: Melody Ren --- docs/sphinx/components/qec/introduction.rst | 8 ++-- libs/qec/include/cudaq/qec/decoder.h | 16 +++---- libs/qec/lib/decoder.cpp | 24 +++++------ libs/qec/lib/decoders/lut.cpp | 18 ++++---- .../plugins/chromobius/chromobius.cpp | 8 ++-- .../example/single_error_lut_example.cpp | 9 ++-- .../plugins/pymatching/pymatching.cpp | 26 +++++------ .../plugins/trt_decoder/trt_decoder.cpp | 32 +++++++------- libs/qec/lib/decoders/sliding_window.cpp | 8 ++-- libs/qec/lib/decoders/sliding_window.h | 7 +-- libs/qec/lib/realtime/realtime_decoding.cpp | 2 +- libs/qec/python/bindings/py_decoder.cpp | 14 +++--- .../decoders/chromobius/test_chromobius.cpp | 5 ++- .../decoders/pymatching/test_pymatching.cpp | 10 ++--- .../qec/unittests/decoders/sample_decoder.cpp | 11 ++--- .../decoders/trt_decoder/test_trt_decoder.cpp | 20 ++++----- .../app_examples/concurrency_test_decoder.cpp | 6 +-- .../test_realtime_predecoder_w_pymatching.cpp | 3 +- .../realtime/test_trt_decoder_composite.cpp | 7 +-- libs/qec/unittests/test_decoders.cpp | 43 ++++++++++--------- libs/qec/unittests/test_decoders_yaml.cpp | 15 ++++--- .../unittests/test_decoding_server_core.cpp | 4 +- 22 files changed, 154 insertions(+), 142 deletions(-) diff --git a/docs/sphinx/components/qec/introduction.rst b/docs/sphinx/components/qec/introduction.rst index 0e8c79c94..c307c1f8a 100644 --- a/docs/sphinx/components/qec/introduction.rst +++ b/docs/sphinx/components/qec/introduction.rst @@ -633,7 +633,7 @@ To implement a new decoder: public: my_decoder(qec::decoder_inputs inputs, - qec::decoder_output requested_output, + qec::decode_result_type requested_output, const heterogeneous_map& params) : decoder(std::move(inputs), requested_output) { // All model data is available here. Reject a result form this @@ -655,11 +655,11 @@ To implement a new decoder: my_decoder, static std::unique_ptr create( qec::decoder_inputs inputs, - std::optional requested_output, + std::optional requested_output, const heterogeneous_map& params) { return std::make_unique( std::move(inputs), - requested_output.value_or(qec::decoder_output::errors), + requested_output.value_or(qec::decode_result_type::errors), params); } ) @@ -667,7 +667,7 @@ To implement a new decoder: CUDAQ_EXT_PT_REGISTER_TYPE(my_decoder) The factory receives the model as :code:`decoder_inputs` and the caller's -result form as an optional :code:`decoder_output`. A decoder that supports one +result form as an optional :code:`decode_result_type`. A decoder that supports one form only should default the request to that form and reject any other. Example: Lookup Table Decoder diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index e9c37c01e..867ec438e 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -31,7 +31,7 @@ using float_t = double; #endif /// @brief The basis of a decoder result. -enum class decoder_output : std::uint8_t { +enum class decode_result_type : std::uint8_t { errors, observables, }; @@ -136,7 +136,7 @@ class async_decoder_result { /// decoder. class decoder : public cudaqx::extension_point, + std::optional, const cudaqx::heterogeneous_map &> { private: struct rt_impl; @@ -153,7 +153,7 @@ class decoder /// factory can move its immutable handle into the decoder. /// @param requested_output The result basis this instance produces, fixed /// for its lifetime. - decoder(decoder_inputs inputs, decoder_output requested_output); + decoder(decoder_inputs inputs, decode_result_type requested_output); /// @brief Decode a single syndrome /// @param syndrome A vector of syndrome measurements where the floating point @@ -198,7 +198,7 @@ class decoder /// @brief Construct a registered decoder with an explicit instance-default /// result form. static std::unique_ptr - get(const std::string &name, decoder_inputs inputs, decoder_output output, + get(const std::string &name, decoder_inputs inputs, decode_result_type output, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()); static std::unique_ptr @@ -242,7 +242,7 @@ class decoder /// @brief The result form this instance was constructed to produce. Fixed at /// construction; every decode operation returns this form. - decoder_output get_output() const noexcept { return output_; } + decode_result_type get_result_type() const noexcept { return result_type_; } // -- Begin realtime decoding API -- @@ -373,11 +373,11 @@ class decoder private: static std::unique_ptr get_impl(const std::string &name, decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶m_map); /// @brief The decoder's immutable construction inputs. const decoder_inputs inputs_; - const decoder_output output_; + const decode_result_type result_type_; }; /// @brief Convert a single soft probability to a hard 0/1 decision. @@ -538,7 +538,7 @@ get_decoder(const std::string &name, decoder_inputs inputs, std::unique_ptr get_decoder(const std::string &name, decoder_inputs inputs, - decoder_output output, + decode_result_type output, const cudaqx::heterogeneous_map options = {}); inline std::unique_ptr diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index 31b78bc3a..bf7c17e37 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -21,7 +21,7 @@ #include INSTANTIATE_REGISTRY(cudaq::qec::decoder, cudaq::qec::decoder_inputs, - std::optional, + std::optional, const cudaqx::heterogeneous_map &) // Include decoder implementations AFTER registry instantiation @@ -87,9 +87,9 @@ struct decoder::rt_impl { void decoder::rt_impl_deleter::operator()(rt_impl *p) const { delete p; } -decoder::decoder(decoder_inputs inputs, decoder_output requested_output) +decoder::decoder(decoder_inputs inputs, decode_result_type requested_output) : pimpl(std::unique_ptr(new rt_impl())), - inputs_(std::move(inputs)), output_(requested_output) { + inputs_(std::move(inputs)), result_type_(requested_output) { syndrome_size = inputs_.num_detectors(); block_size = inputs_.num_error_mechanisms(); @@ -253,14 +253,14 @@ decoder::get(const std::string &name, decoder_inputs inputs, std::unique_ptr decoder::get(const std::string &name, decoder_inputs inputs, - decoder_output output, + decode_result_type output, const cudaqx::heterogeneous_map ¶m_map) { return get_impl(name, std::move(inputs), output, param_map); } std::unique_ptr decoder::get_impl(const std::string &name, decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶m_map) { for (const char *reserved : {"H", "O", "D", "error_rate_vec"}) if (param_map.contains(reserved)) @@ -449,13 +449,13 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, const char *result_type_str = nullptr; const char *result_type_name = nullptr; std::size_t expected_result_size = 0; - switch (output_) { - case decoder_output::errors: + switch (result_type_) { + case decode_result_type::errors: result_type_str = "errs"; result_type_name = "errors"; expected_result_size = block_size; break; - case decoder_output::observables: + case decode_result_type::observables: result_type_str = "obs"; result_type_name = "observables"; expected_result_size = num_observables; @@ -482,8 +482,8 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, if (should_log) log_t2 = std::chrono::high_resolution_clock::now(); - switch (output_) { - case decoder_output::observables: + switch (result_type_) { + case decode_result_type::observables: // Observable-frame path: decoder already projected to observables via its // internal "O" matrix; use the result directly. for (std::size_t i = 0; i < num_observables; i++) @@ -493,7 +493,7 @@ bool decoder::enqueue_syndrome(const uint8_t *syndrome, flip_correction(i); } break; - case decoder_output::errors: + case decode_result_type::errors: // Error-frame path: decoder returns a block-sized error vector; project // to observables via O_sparse. if (!inputs_.has_observable_model()) @@ -629,7 +629,7 @@ std::unique_ptr get_decoder(const std::string &name, std::unique_ptr get_decoder(const std::string &name, decoder_inputs inputs, - decoder_output output, + decode_result_type output, const cudaqx::heterogeneous_map options) { return decoder::get(name, std::move(inputs), output, options); } diff --git a/libs/qec/lib/decoders/lut.cpp b/libs/qec/lib/decoders/lut.cpp index d35cf14c3..d4d655720 100644 --- a/libs/qec/lib/decoders/lut.cpp +++ b/libs/qec/lib/decoders/lut.cpp @@ -50,13 +50,13 @@ class multi_error_lut : public decoder { public: multi_error_lut(cudaq::qec::decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { // This decoder computes an error frame. Producing observables requires an // observable mapping to project through; reject at construction rather // than on the first decode. - if (requested_output == decoder_output::observables && + if (requested_output == decode_result_type::observables && !get_inputs().has_observable_model()) throw std::invalid_argument( "lut decoder was constructed for observable output but its model " @@ -181,7 +181,7 @@ class multi_error_lut : public decoder { // This decoder computes an error frame. Whether that frame is projected is // fixed at construction, so the decision is read from immutable instance // state rather than negotiated per call. - const bool project = get_output() == decoder_output::observables; + const bool project = get_result_type() == decode_result_type::observables; auto finish = [&](decoder_result &r) { if (project) { std::vector observables(get_num_observables(), 0.0); @@ -256,10 +256,11 @@ class multi_error_lut : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( multi_error_lut, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( - std::move(inputs), output.value_or(decoder_output::errors), params); + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; @@ -268,7 +269,7 @@ CUDAQ_EXT_PT_REGISTER_TYPE(multi_error_lut) class single_error_lut : public multi_error_lut { public: single_error_lut(cudaq::qec::decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : multi_error_lut(std::move(inputs), requested_output, params) {} @@ -277,10 +278,11 @@ class single_error_lut : public multi_error_lut { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( single_error_lut, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( - std::move(inputs), output.value_or(decoder_output::errors), params); + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; diff --git a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp index 648eaae9a..067de62a4 100644 --- a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp +++ b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp @@ -75,14 +75,14 @@ class chromobius : public decoder { public: chromobius(decoder_inputs inputs, chromobius_init_data init_data, - decoder_output requested_output, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output), dem(std::move(init_data.dem)) { // Chromobius predicts observable flips directly and cannot be inverted to // an error frame. Reject the request at construction rather than on the // first live shot. - if (requested_output != decoder_output::observables) + if (requested_output != decode_result_type::observables) throw std::invalid_argument( "Chromobius cannot return an error frame; construct it for " "observable output"); @@ -157,12 +157,12 @@ class chromobius : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( chromobius, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { auto init_data = make_chromobius_init_data(inputs); return std::make_unique( std::move(inputs), std::move(init_data), - output.value_or(decoder_output::observables), params); + output.value_or(decode_result_type::observables), params); }) }; diff --git a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp index 82b0cf701..7eb17290f 100644 --- a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp +++ b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp @@ -23,7 +23,7 @@ class single_error_lut_example : public decoder { public: single_error_lut_example(cudaq::qec::decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { // The requested result form is validated here, at construction, so an @@ -31,7 +31,7 @@ class single_error_lut_example : public decoder { // example produces an error frame only; a decoder that can also project to // observables would instead call project_errors_to_observables() before // returning. - if (requested_output != decoder_output::errors) + if (requested_output != decode_result_type::errors) throw std::invalid_argument( "single_error_lut_example produces an error frame only; construct it " "for error output"); @@ -89,10 +89,11 @@ class single_error_lut_example : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( single_error_lut_example, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( - std::move(inputs), output.value_or(decoder_output::errors), params); + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; diff --git a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp index 24598db59..519699dec 100644 --- a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp +++ b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp @@ -42,7 +42,7 @@ class pymatching : public decoder { bool decode_to_observables = false; std::vector detection_events; - std::vector matched_edges; + std::vector edges; std::vector observable_bits; // Helper function to make a canonical edge from two nodes. @@ -75,12 +75,13 @@ class pymatching : public decoder { #endif public: - pymatching(cudaq::qec::decoder_inputs inputs, decoder_output requested_output, + pymatching(cudaq::qec::decoder_inputs inputs, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { const auto &H = get_inputs().detector_error_matrix(); error_rate_vec = get_inputs().error_rates(); - decode_to_observables = requested_output == decoder_output::observables; + decode_to_observables = requested_output == decode_result_type::observables; if (!error_rate_vec.empty()) { if (error_rate_vec.size() != block_size) { @@ -177,7 +178,7 @@ class pymatching : public decoder { ? &user_graph.get_mwpm() : &user_graph.get_mwpm_with_search_graph(); detection_events.reserve(syndrome_size); - matched_edges.reserve(block_size * 2); + edges.reserve(block_size * 2); observable_bits.resize(get_inputs().num_observables()); #if PERFORM_TIMING std::fill(decode_times.begin(), decode_times.end(), 0.0); @@ -228,14 +229,12 @@ class pymatching : public decoder { } } } else { - matched_edges.clear(); - pm::decode_detection_events_to_edges(*mwpm, detection_events, - matched_edges); + edges.clear(); + pm::decode_detection_events_to_edges(*mwpm, detection_events, edges); // Loop over the edge pairs to reconstruct errors. - assert(matched_edges.size() % 2 == 0); - for (size_t i = 0; i < matched_edges.size(); i += 2) { - auto edge = - make_canonical_edge(matched_edges.at(i), matched_edges.at(i + 1)); + assert(edges.size() % 2 == 0); + for (size_t i = 0; i < edges.size(); i += 2) { + auto edge = make_canonical_edge(edges.at(i), edges.at(i + 1)); auto col_idx = edge2col_idx.at(edge); output[col_idx] = 1.0; } @@ -271,10 +270,11 @@ class pymatching : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( pymatching, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( - std::move(inputs), output.value_or(decoder_output::errors), params); + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; diff --git a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp index 3a46bfd7b..40c29216e 100644 --- a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp +++ b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp @@ -183,19 +183,19 @@ parse_engine_output_format(const cudaqx::heterogeneous_map ¶ms) { "observables, observables_and_residual_detectors"); } -decoder_output natural_trt_output(trt_engine_output_format format) { +decode_result_type natural_trt_output(trt_engine_output_format format) { return format == trt_engine_output_format::errors - ? decoder_output::errors - : decoder_output::observables; + ? decode_result_type::errors + : decode_result_type::observables; } -decoder_output trt_emitted_output(trt_engine_output_format format, - decoder_output requested_output) { +decode_result_type trt_emitted_output(trt_engine_output_format format, + decode_result_type requested_output) { if (format == trt_engine_output_format::errors) - return decoder_output::errors; + return decode_result_type::errors; if (format == trt_engine_output_format::residual_detectors) return requested_output; - return decoder_output::observables; + return decode_result_type::observables; } // Helpers for templated I/O: binarize TRT output (float or uint8) to 0/1 @@ -463,12 +463,12 @@ class trt_decoder : public decoder { cudaqx::heterogeneous_map global_decoder_params_; trt_engine_output_format engine_output_format_; - decoder_output emitted_output_; + decode_result_type emitted_output_; size_t num_observables_ = 0; public: trt_decoder(cudaq::qec::decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms); @@ -482,7 +482,7 @@ class trt_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( trt_decoder, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { const auto format = parse_engine_output_format(params); return std::make_unique( @@ -589,7 +589,7 @@ struct trt_decoder::Impl { // ============================================================================ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output), @@ -599,7 +599,7 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, if ((engine_output_format_ == trt_engine_output_format::observables || engine_output_format_ == trt_engine_output_format::observables_and_residual_detectors) && - requested_output != decoder_output::observables) + requested_output != decode_result_type::observables) throw std::runtime_error( "engine_output_format declares observables, so this decoder cannot be " "constructed for error-frame output"); @@ -608,8 +608,8 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, // instance, but only by projecting through the model's O. Without an // observable mapping there is nothing to project through, so reject here // rather than returning an unprojected error frame at decode time. - if (emitted_output_ == decoder_output::errors && - requested_output == decoder_output::observables && + if (emitted_output_ == decode_result_type::errors && + requested_output == decode_result_type::observables && !get_inputs().has_observable_model()) throw std::runtime_error( "This TensorRT engine emits an error frame and was constructed for " @@ -847,7 +847,7 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, const auto global_output = engine_output_format_ == trt_engine_output_format::observables_and_residual_detectors - ? decoder_output::observables + ? decode_result_type::observables : requested_output; global_decoder_ = decoder::get(global_decoder_name, get_inputs().decoder_inputs_without_d(), @@ -1004,7 +1004,7 @@ trt_decoder::decode_batch(const std::vector> &syndromes) { std::to_string(syndromes.size()) + " syndromes"); // The engine's output form and the instance's form are both fixed at // construction; only errors -> observables is reachable here. - if (emitted_output_ != get_output()) + if (emitted_output_ != get_result_type()) for (auto &r : results) { if (r.result.empty()) continue; diff --git a/libs/qec/lib/decoders/sliding_window.cpp b/libs/qec/lib/decoders/sliding_window.cpp index d2a514573..c0bb89436 100644 --- a/libs/qec/lib/decoders/sliding_window.cpp +++ b/libs/qec/lib/decoders/sliding_window.cpp @@ -121,7 +121,7 @@ void sliding_window::initialize_window(std::size_t batch_size) { } sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) // Canonical CSC is the steady-state contract for decode_window's column // slices and for validate_inputs's per-column .front()/.back() reads. @@ -131,7 +131,7 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, // This decoder composes an error frame from its windows. Producing // observables requires an observable mapping to project through; reject at // construction rather than on the first decode. - if (requested_output == decoder_output::observables && + if (requested_output == decode_result_type::observables && !get_inputs().has_observable_model()) throw std::invalid_argument( "sliding_window was constructed for observable output but its model " @@ -224,7 +224,7 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, std::move(inner_error_ids)); auto inner_decoder = decoder::get(inner_decoder_name, std::move(inner_inputs), - decoder_output::errors, inner_decoder_params); + decode_result_type::errors, inner_decoder_params); inner_decoders.push_back(std::move(inner_decoder)); } } @@ -302,7 +302,7 @@ std::vector sliding_window::decode_batch( // return is the empty streaming sentinel. // Composed frames are error frames; whether they are projected is fixed at // construction. - if (get_output() == decoder_output::observables) + if (get_result_type() == decode_result_type::observables) for (auto &r : results) { if (r.result.empty()) continue; // streaming sentinel diff --git a/libs/qec/lib/decoders/sliding_window.h b/libs/qec/lib/decoders/sliding_window.h index 9b9771ae3..75acfb096 100644 --- a/libs/qec/lib/decoders/sliding_window.h +++ b/libs/qec/lib/decoders/sliding_window.h @@ -108,7 +108,7 @@ class sliding_window : public decoder { /// - inner_decoder_name: Name of the inner decoder to use /// - inner_decoder_params: Parameters for the inner decoder (optional) sliding_window(cudaq::qec::decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms); /// @brief Decode a syndrome vector @@ -146,10 +146,11 @@ class sliding_window : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( sliding_window, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( - std::move(inputs), output.value_or(decoder_output::errors), params); + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index d554c4f53..b9f232f84 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -358,7 +358,7 @@ std::unique_ptr create_realtime_decoder( auto decoder = cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), - cudaq::qec::decoder_output::observables, + cudaq::qec::decode_result_type::observables, prepare_decoder_params(decoder_config)); decoder->set_decoder_id(decoder_config.id); diff --git a/libs/qec/python/bindings/py_decoder.cpp b/libs/qec/python/bindings/py_decoder.cpp index 3d0766116..97184002a 100644 --- a/libs/qec/python/bindings/py_decoder.cpp +++ b/libs/qec/python/bindings/py_decoder.cpp @@ -212,7 +212,7 @@ class PyDecoder : public decoder { nb::cast>(mat.attr( "astype")("uint8", nb::arg("copy") = false))); }()), - decoder_output::errors) {} + decode_result_type::errors) {} decoder_result decode(const std::vector &syndrome) override { NB_OVERRIDE_PURE_NAME("decode", decode, syndrome); @@ -463,15 +463,15 @@ nb::object copyToPyArray(const std::vector &v) { namespace { /// Read and remove the "output" keyword, which selects the result basis. -std::optional pop_requested_output(nb::kwargs &options) { +std::optional pop_requested_output(nb::kwargs &options) { if (!options.contains("output")) return std::nullopt; const auto value = nb::cast(options["output"]); options.attr("pop")("output"); if (value == "errors") - return decoder_output::errors; + return decode_result_type::errors; if (value == "observables") - return decoder_output::observables; + return decode_result_type::observables; throw std::runtime_error("output must be 'errors' or 'observables'"); } } // namespace @@ -949,14 +949,14 @@ void bindDecoder(nb::module_ &mod) { H_sparse = make_sparse_from_dense( nb::cast>(H)); - std::optional output; + std::optional output; if (options.contains("output")) { const auto value = nb::cast(options["output"]); options.attr("pop")("output"); if (value == "errors") - output = decoder_output::errors; + output = decode_result_type::errors; else if (value == "observables") - output = decoder_output::observables; + output = decode_result_type::observables; else throw std::runtime_error( "output must be 'errors' or 'observables'"); diff --git a/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp index 1f4d34c1c..a85ab66d4 100644 --- a/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp +++ b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp @@ -52,12 +52,13 @@ TEST(ChromobiusDecoder, checkAllZeroSyndrome) { EXPECT_EQ(result.result[0], 0.0); EXPECT_EQ(decoder->get_block_size(), 6); EXPECT_EQ(decoder->get_syndrome_size(), 4); - EXPECT_EQ(decoder->get_output(), cudaq::qec::decoder_output::observables); + EXPECT_EQ(decoder->get_result_type(), + cudaq::qec::decode_result_type::observables); EXPECT_THROW((void)cudaq::qec::decoder::get( "chromobius", cudaq::qec::decoder_inputs::from_stim_dem( std::string{chromobius_dem}), - cudaq::qec::decoder_output::errors, make_params()), + cudaq::qec::decode_result_type::errors, make_params()), std::invalid_argument); } diff --git a/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp b/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp index 2c7781d9a..c800ef4d0 100644 --- a/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp +++ b/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp @@ -172,7 +172,7 @@ TEST(PyMatchingDecoder, AcceptsAllMergeStrategiesAndRejectsUnknown) { d = cudaq::qec::decoder::get( "pymatching", cudaq::qec::decoder_inputs(std::move(sparse_H), std::move(O)), - cudaq::qec::decoder_output::observables, params); + cudaq::qec::decode_result_type::observables, params); } ASSERT_NE(d, nullptr) << strategy; auto result = d->decode(std::vector{1.0}); @@ -204,9 +204,9 @@ TEST(PyMatchingDecoder, ErrorOutputTracksMergedParallelEdgeColumn) { 0, 2, std::vector{0}, {}); auto inputs = cudaq::qec::decoder_inputs( cudaq::qec::sparse_binary_matrix(H), std::move(O), {0.1, 0.2}); - auto decoder = - cudaq::qec::decoder::get("pymatching", std::move(inputs), - cudaq::qec::decoder_output::errors, params); + auto decoder = cudaq::qec::decoder::get( + "pymatching", std::move(inputs), cudaq::qec::decode_result_type::errors, + params); return decoder->decode(std::vector{1.0}).result; }; @@ -251,7 +251,7 @@ TEST(PyMatchingDecoder, DecodesHighObservableIndicesAcrossPaths) { "pymatching", cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H), cudaq::qec::sparse_binary_matrix(O)), - cudaq::qec::decoder_output::observables); + cudaq::qec::decode_result_type::observables); // ASSERT: valid graph-like identity matrices must construct a decoder. ASSERT_NE(d, nullptr); diff --git a/libs/qec/unittests/decoders/sample_decoder.cpp b/libs/qec/unittests/decoders/sample_decoder.cpp index 083588620..1c090b246 100644 --- a/libs/qec/unittests/decoders/sample_decoder.cpp +++ b/libs/qec/unittests/decoders/sample_decoder.cpp @@ -19,13 +19,13 @@ namespace cudaq::qec { class sample_decoder : public decoder { public: sample_decoder(cudaq::qec::decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { // This decoder computes an error frame. Producing observables requires an // observable mapping to project through; reject at construction rather // than on the first decode. - if (requested_output == decoder_output::observables && + if (requested_output == decode_result_type::observables && !get_inputs().has_observable_model()) throw std::invalid_argument( "sample_decoder was constructed for observable output but its model " @@ -39,7 +39,7 @@ class sample_decoder : public decoder { // Whether the frame is projected is fixed at construction, so the decision // is read from immutable instance state rather than negotiated per call. - if (get_output() == decoder_output::observables) { + if (get_result_type() == decode_result_type::observables) { std::vector observables(get_num_observables(), 0.0); project_errors_to_observables(result.result.data(), observables.data(), observables.size()); @@ -53,10 +53,11 @@ class sample_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( sample_decoder, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( - std::move(inputs), output.value_or(decoder_output::errors), params); + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; diff --git a/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp b/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp index 1b57cd56b..cce7229b8 100644 --- a/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp +++ b/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp @@ -304,7 +304,7 @@ TEST_F(TRTDecoderTest, ValidateAgainstPyTorchModel) { try { trt_decoder = decoder::get( "trt_decoder", make_inputs_with_empty_observables(H, num_observables), - decoder_output::observables, params); + decode_result_type::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create TRT decoder: " << e.what(); } @@ -400,7 +400,7 @@ TEST_F(TRTDecoderTest, ValidateSingleTestCase) { try { trt_decoder = decoder::get( "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), - decoder_output::observables, params); + decode_result_type::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create TRT decoder: " << e.what(); } @@ -464,7 +464,7 @@ TEST_F(TRTDecoderTest, PerformanceComparisonCudaGraphVsTraditional) { try { decoder_cuda_graph = decoder::get( "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), - decoder_output::observables, params_cuda_graph); + decode_result_type::observables, params_cuda_graph); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create CUDA graph decoder: " << e.what(); } @@ -482,7 +482,7 @@ TEST_F(TRTDecoderTest, PerformanceComparisonCudaGraphVsTraditional) { try { decoder_traditional = decoder::get( "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), - decoder_output::observables, params_traditional); + decode_result_type::observables, params_traditional); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create traditional decoder: " << e.what(); } @@ -614,7 +614,7 @@ TEST_F(TRTDecoderTest, EngineSavePathAndEngineLoadPathRoundTrip) { try { built_decoder = decoder::get( "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), - decoder_output::observables, build_params); + decode_result_type::observables, build_params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to build TRT decoder: " << e.what(); } @@ -628,7 +628,7 @@ TEST_F(TRTDecoderTest, EngineSavePathAndEngineLoadPathRoundTrip) { try { loaded_decoder = decoder::get( "trt_decoder", make_inputs_with_empty_observables(H, NUM_OBSERVABLES), - decoder_output::observables, load_params); + decode_result_type::observables, load_params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to load TRT decoder: " << e.what(); } @@ -815,8 +815,8 @@ TEST_F(TRTDecoderTest, NestsChromobiusPreservingRawDem) { std::unique_ptr composite; try { - composite = decoder::get("trt_decoder", inputs, decoder_output::observables, - params); + composite = decoder::get("trt_decoder", inputs, + decode_result_type::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "TensorRT engine build unavailable: " << e.what(); } @@ -839,7 +839,7 @@ TEST_F(TRTDecoderTest, NestsChromobiusPreservingRawDem) { EXPECT_THROW((void)decoder::get("trt_decoder", decoder_inputs(sparse_binary_matrix(H), sparse_binary_matrix(O)), - decoder_output::observables, params), + decode_result_type::observables, params), std::runtime_error); } @@ -871,7 +871,7 @@ TEST_F(TRTDecoderTest, CompositeGlobalDecoderCombinesLogicalFrame) { trt_decoder = decoder::get( "trt_decoder", decoder_inputs(sparse_binary_matrix(H), sparse_binary_matrix(O)), - decoder_output::observables, params); + decode_result_type::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create composite TRT decoder: " << e.what(); } diff --git a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp index b35bfc7ca..26e41a57f 100644 --- a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp +++ b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp @@ -87,7 +87,7 @@ reusable_decode_barrier &decode_barrier() { class concurrency_test_decoder : public decoder { public: concurrency_test_decoder(decoder_inputs inputs, - decoder_output requested_output, + decode_result_type requested_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), requested_output) { std::cout << "QEC_CONCURRENCY_TEST_DECODER_CONSTRUCTED" << std::endl; @@ -114,10 +114,10 @@ class concurrency_test_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( concurrency_test_decoder, static std::unique_ptr create( - decoder_inputs inputs, std::optional output, + decoder_inputs inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( - std::move(inputs), output.value_or(decoder_output::observables), + std::move(inputs), output.value_or(decode_result_type::observables), params); }) diff --git a/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp b/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp index 4a8f20a4c..26e5225a2 100644 --- a/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp +++ b/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp @@ -301,7 +301,8 @@ int main(int argc, char *argv[]) { << " PyMatching decoders (full H)...\n"; for (int i = 0; i < config.num_decode_workers; ++i) decoder_ctx.decoders.push_back(cudaq::qec::decoder::get( - "pymatching", inputs, cudaq::qec::decoder_output::errors, pm_params)); + "pymatching", inputs, cudaq::qec::decode_result_type::errors, + pm_params)); } else { // Fallback: per-slice decode with CUDA-Q surface code H_z std::cout << "[Setup] Creating PyMatching decoder (d=" << config.distance diff --git a/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp b/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp index 3e5f2019d..6e34de0fa 100644 --- a/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp +++ b/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp @@ -354,7 +354,7 @@ DecoderSetup create_decoder_from_yaml(const DemoConfig &demo_cfg) { decoder_config.type, cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(setup.H), cudaq::qec::sparse_binary_matrix(O)), - cudaq::qec::decoder_output::observables, setup.trt_params); + cudaq::qec::decode_result_type::observables, setup.trt_params); return setup; } @@ -410,7 +410,7 @@ DecoderSetup create_decoder_from_cli(const PipelineConfig &config, cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H), cudaq::qec::sparse_binary_matrix(O), stim.priors), - cudaq::qec::decoder_output::observables, setup.trt_params); + cudaq::qec::decode_result_type::observables, setup.trt_params); return setup; } @@ -509,7 +509,8 @@ int main(int argc, char *argv[]) { << " row(s).\n"; return 1; } - if (setup.decoder->get_output() != cudaq::qec::decoder_output::observables) { + if (setup.decoder->get_result_type() != + cudaq::qec::decode_result_type::observables) { std::cerr << "ERROR: composite trt_decoder must use observable output.\n"; return 1; } diff --git a/libs/qec/unittests/test_decoders.cpp b/libs/qec/unittests/test_decoders.cpp index 5a371300d..a9f7f62f9 100644 --- a/libs/qec/unittests/test_decoders.cpp +++ b/libs/qec/unittests/test_decoders.cpp @@ -25,7 +25,7 @@ namespace { class decoder_inputs_probe final : public cudaq::qec::decoder { public: explicit decoder_inputs_probe(cudaq::qec::decoder_inputs inputs) - : decoder(std::move(inputs), cudaq::qec::decoder_output::errors) {} + : decoder(std::move(inputs), cudaq::qec::decode_result_type::errors) {} cudaq::qec::decoder_result decode(const std::vector &) override { @@ -39,7 +39,7 @@ class decoder_inputs_probe final : public cudaq::qec::decoder { class observable_output_probe final : public cudaq::qec::decoder { public: observable_output_probe(cudaq::qec::decoder_inputs inputs, - cudaq::qec::decoder_output requested_output, + cudaq::qec::decode_result_type requested_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), requested_output) {} @@ -52,11 +52,12 @@ class observable_output_probe final : public cudaq::qec::decoder { observable_output_probe, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( std::move(inputs), - output.value_or(cudaq::qec::decoder_output::observables), params); + output.value_or(cudaq::qec::decode_result_type::observables), + params); }) }; @@ -210,9 +211,10 @@ TEST(DecoderOutputContract, OutputFormIsImmutablePerInstance) { auto decoder = cudaq::qec::get_decoder( "single_error_lut", cudaq::qec::decoder_inputs(std::move(H), std::move(O)), - cudaq::qec::decoder_output::observables); + cudaq::qec::decode_result_type::observables); - EXPECT_EQ(decoder->get_output(), cudaq::qec::decoder_output::observables); + EXPECT_EQ(decoder->get_result_type(), + cudaq::qec::decode_result_type::observables); const std::vector syndrome{1.0, 0.0}; auto observables = decoder->decode(syndrome); @@ -225,7 +227,7 @@ TEST(DecoderOutputContract, OutputFormIsImmutablePerInstance) { 2, 2, std::vector>{{0}, {1}}), cudaq::qec::sparse_binary_matrix::from_nested_csr( 1, 2, std::vector>{{0}})), - cudaq::qec::decoder_output::errors); + cudaq::qec::decode_result_type::errors); auto errors = error_decoder->decode(syndrome); EXPECT_EQ(errors.result, std::vector({1.0, 0.0})); } @@ -1359,7 +1361,7 @@ TEST(EnqueueSyndrome, ObsFrameDecoderUsesResultDirectly) { cudaq::qec::decoder_inputs( std::move(H), std::move(O), /*error_rates=*/{}, cudaq::qec::sparse_binary_matrix::from_nested_csr(2, 2, {{0}, {1}})), - cudaq::qec::decoder_output::observables); + cudaq::qec::decode_result_type::observables); bool did_decode = dec->enqueue_syndrome(std::vector{1, 0}); EXPECT_TRUE(did_decode); @@ -1383,7 +1385,7 @@ TEST(EnqueueSyndrome, ObsFrameMultiShotAccumulation) { cudaq::qec::decoder_inputs( std::move(H), std::move(O), /*error_rates=*/{}, cudaq::qec::sparse_binary_matrix::from_nested_csr(2, 2, {{0}, {1}})), - cudaq::qec::decoder_output::observables); + cudaq::qec::decode_result_type::observables); // Shot 1: obs[0]=1, obs[1]=0 -> corrections become [1, 0] EXPECT_TRUE(dec->enqueue_syndrome(std::vector{1, 0})); @@ -1419,7 +1421,7 @@ TEST(EnqueueSyndrome, ObsFrameSizeMismatchThrows) { std::move(H), std::move(O), /*error_rates=*/{}, cudaq::qec::sparse_binary_matrix::from_nested_csr(3, 3, {{0}, {1}, {2}})), - cudaq::qec::decoder_output::observables); + cudaq::qec::decode_result_type::observables); // sample_decoder returns all three detector bits as observables. EXPECT_THROW(dec->enqueue_syndrome(std::vector{1, 0, 1}), std::runtime_error); @@ -1458,7 +1460,7 @@ TEST(SlidingWindowDecoder, BaseStreamingCopiesFirstRoundDetectors) { std::move(H), std::move(O), std::vector(pcm.shape()[1], 0.1), cudaq::qec::sparse_binary_matrix::from_nested_csr( static_cast(m2d.size()), 2, m2d)), - cudaq::qec::decoder_output::observables, params); + cudaq::qec::decode_result_type::observables, params); ASSERT_NE(decoder, nullptr); std::vector first_round = {1, 0}; @@ -1544,7 +1546,7 @@ class ScopedDeviceRestore { class strict_keys_decoder : public cudaq::qec::decoder { public: strict_keys_decoder(cudaq::qec::decoder_inputs inputs, - cudaq::qec::decoder_output requested_output, + cudaq::qec::decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { auto invalid = @@ -1561,13 +1563,14 @@ class strict_keys_decoder : public cudaq::qec::decoder { return r; } CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( - strict_keys_decoder, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, - std::optional output, - const cudaqx::heterogeneous_map ¶ms) { + strict_keys_decoder, + static std::unique_ptr create( + cudaq::qec::decoder_inputs inputs, + std::optional output, + const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( std::move(inputs), - output.value_or(cudaq::qec::decoder_output::errors), params); + output.value_or(cudaq::qec::decode_result_type::errors), params); }) }; CUDAQ_EXT_PT_REGISTER_TYPE(strict_keys_decoder) @@ -1583,7 +1586,7 @@ class device_recording_decoder : public cudaq::qec::decoder { public: std::atomic last_decode_device{-2}; device_recording_decoder(cudaq::qec::decoder_inputs inputs, - cudaq::qec::decoder_output requested_output, + cudaq::qec::decode_result_type requested_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), requested_output) {} cudaq::qec::decoder_result @@ -1601,11 +1604,11 @@ class device_recording_decoder : public cudaq::qec::decoder { device_recording_decoder, static std::unique_ptr create( cudaq::qec::decoder_inputs inputs, - std::optional output, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( std::move(inputs), - output.value_or(cudaq::qec::decoder_output::errors), params); + output.value_or(cudaq::qec::decode_result_type::errors), params); }) }; CUDAQ_EXT_PT_REGISTER_TYPE(device_recording_decoder) diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index 4314728e9..c43d16b13 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -57,7 +57,7 @@ struct construction_d_probe { class d_capture_decoder : public decoder { public: - d_capture_decoder(decoder_inputs inputs, decoder_output requested_output, + d_capture_decoder(decoder_inputs inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), requested_output) { const auto &in = get_inputs(); @@ -95,10 +95,10 @@ class d_capture_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( d_capture_decoder, static std::unique_ptr create( - decoder_inputs inputs, std::optional output, + decoder_inputs inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( - std::move(inputs), output.value_or(decoder_output::observables), + std::move(inputs), output.value_or(decode_result_type::observables), params); }) }; @@ -1125,7 +1125,7 @@ TEST(DecodingServerAcceptance, auto offline_decoder = cudaq::qec::decoder::get("d_capture_decoder", offline_inputs, - cudaq::qec::decoder_output::observables); + cudaq::qec::decode_result_type::observables); ASSERT_NE(offline_decoder, nullptr); const auto offline_model = cudaq::qec::construction_d_probe::model; @@ -1156,8 +1156,8 @@ TEST(DecodingServerAcceptance, MatrixSourcePluginWorksOfflineAndOnServer) { // Offline route, same plugin and the same resolved model. auto inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( config, std::filesystem::current_path()); - auto offline = cudaq::qec::decoder::get(config.type, inputs, - cudaq::qec::decoder_output::errors); + auto offline = cudaq::qec::decoder::get( + config.type, inputs, cudaq::qec::decode_result_type::errors); ASSERT_NE(offline, nullptr); auto result = offline->decode( std::vector(config.syndrome_size, 0.0)); @@ -1367,7 +1367,8 @@ TEST(DecodingServerAcceptance, ChromobiusConstructsFromRawDemSource) { parsed, std::move(inputs)); ASSERT_NE(decoder, nullptr); EXPECT_EQ(decoder->get_num_observables(), 3u); - EXPECT_EQ(decoder->get_output(), cudaq::qec::decoder_output::observables); + EXPECT_EQ(decoder->get_result_type(), + cudaq::qec::decode_result_type::observables); // Decoding works off the DEM-derived detector basis, and returns one entry // per observable the DEM declares. diff --git a/libs/qec/unittests/test_decoding_server_core.cpp b/libs/qec/unittests/test_decoding_server_core.cpp index faa6300b8..faee41772 100644 --- a/libs/qec/unittests/test_decoding_server_core.cpp +++ b/libs/qec/unittests/test_decoding_server_core.cpp @@ -54,7 +54,7 @@ class ControlledDecoder final : public cudaq::qec::decoder { /*D=*/ cudaq::qec::sparse_binary_matrix::from_csr(1, 2, {0, 2}, {0, 1})), - cudaq::qec::decoder_output::errors) {} + cudaq::qec::decode_result_type::errors) {} cudaq::qec::decoder_result decode(const std::vector &syndrome) override { @@ -319,7 +319,7 @@ class MispinnedDecoder final : public cudaq::qec::decoder { /*D=*/ cudaq::qec::sparse_binary_matrix::from_csr(1, 2, {0, 2}, {0, 1})), - cudaq::qec::decoder_output::errors) { + cudaq::qec::decode_result_type::errors) { cuda_device_id_ = 1 << 20; } cudaq::qec::decoder_result From 8bf9b11183f75bb38c4d8094e0d30b13bd670358 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Fri, 7 Aug 2026 12:27:06 -0700 Subject: [PATCH 21/24] Name our construction input decoder_init and restore decoder_inputs Upstream had two names for two different things: `decoder_init`, the value a decoder is constructed from, and `decoder_inputs`, the canonicalized output of circuit analysis (a DEM plus the measurement-to-detector and measurement-to-observable maps). This branch deleted both and reused `decoder_inputs` for the construction input, which collapsed the distinction and silently broke code written against the analysis output. Our construction input is now `decoder_init`, which is the role upstream already gave that name. The experiment-side `decoder_inputs` struct returns verbatim, along with the component methods that produce it, so `realtime_decoding_demo` and `real_time_complete` compile unchanged and the Python component tuples keep their shape. The two meet at one explicit conversion: `m2d_to_sparse()` bridges circuit analysis to the sparse form a decoder is built from, so a call site that crosses from experiment to plugin says so. `decoder_init(std::string)` is deleted rather than absent: upstream's `decoder_init{dem_text}` meant "this is a DEM", and a plugin author who writes it now gets an error on a line whose comment names `from_stim_dem()`. Signed-off-by: Melody Ren --- docs/sphinx/api/qec/cpp_api.rst | 2 +- .../api/qec/cpp_realtime_decoding_api.rst | 2 +- docs/sphinx/api/qec/sliding_window_api.rst | 2 +- docs/sphinx/components/qec/introduction.rst | 8 +- .../examples/qec/cpp/real_time_complete.cpp | 4 +- libs/qec/include/cudaq/qec/decoder.h | 40 ++++---- .../qec/{decoder_inputs.h => decoder_init.h} | 37 ++++---- libs/qec/include/cudaq/qec/experiments.h | 22 ++--- .../cudaq/qec/realtime/decoding_config.h | 2 +- libs/qec/lib/CMakeLists.txt | 2 +- libs/qec/lib/decoder.cpp | 16 ++-- .../{decoder_inputs.cpp => decoder_init.cpp} | 93 +++++++++---------- libs/qec/lib/decoders/lut.cpp | 8 +- .../plugins/chromobius/chromobius.cpp | 6 +- .../example/single_error_lut_example.cpp | 4 +- .../plugins/pymatching/pymatching.cpp | 4 +- .../plugins/trt_decoder/trt_decoder.cpp | 10 +- libs/qec/lib/decoders/sliding_window.cpp | 12 +-- libs/qec/lib/decoders/sliding_window.h | 4 +- libs/qec/lib/dem_sparse_projection.h | 4 +- libs/qec/lib/experiments.cpp | 53 +++++------ libs/qec/lib/realtime/config.cpp | 2 +- .../decoding-server-cqr/SessionRegistry.cpp | 2 +- libs/qec/lib/realtime/realtime_decoding.cpp | 17 ++-- libs/qec/lib/realtime/realtime_decoding.h | 6 +- libs/qec/python/bindings/py_code.cpp | 15 +-- libs/qec/python/bindings/py_decoder.cpp | 12 +-- libs/qec/python/tests/test_decoding_config.py | 2 +- .../backend-specific/stim/test_qec_stim.cpp | 35 ++++--- .../decoders/chromobius/test_chromobius.cpp | 12 +-- .../decoders/pymatching/test_pymatching.cpp | 14 +-- .../pymatching/test_pymatching_realtime.cpp | 2 +- .../qec/unittests/decoders/sample_decoder.cpp | 4 +- .../decoders/trt_decoder/test_trt_decoder.cpp | 12 +-- .../app_examples/concurrency_test_decoder.cpp | 4 +- .../realtime/app_examples/surface_code-1.cpp | 11 +-- .../app_examples/surface_code-4-yaml.cpp | 8 +- .../qldpc_config_loader.cpp | 2 +- .../test_realtime_predecoder_w_pymatching.cpp | 4 +- .../realtime/test_trt_decoder_composite.cpp | 10 +- libs/qec/unittests/test_decoders.cpp | 89 +++++++++--------- libs/qec/unittests/test_decoders_yaml.cpp | 50 +++++----- .../unittests/test_decoding_server_core.cpp | 6 +- .../gpu_roce_qldpc_graph_decoder_bridge.cpp | 2 +- 44 files changed, 318 insertions(+), 338 deletions(-) rename libs/qec/include/cudaq/qec/{decoder_inputs.h => decoder_init.h} (86%) rename libs/qec/lib/{decoder_inputs.cpp => decoder_init.cpp} (65%) diff --git a/docs/sphinx/api/qec/cpp_api.rst b/docs/sphinx/api/qec/cpp_api.rst index 61447f6c5..38b6b86a5 100644 --- a/docs/sphinx/api/qec/cpp_api.rst +++ b/docs/sphinx/api/qec/cpp_api.rst @@ -67,7 +67,7 @@ Legacy convenience wrappers (delegate to ``cpu::sample_dem``; prefer the Decoder Interfaces ================== -.. doxygenclass:: cudaq::qec::decoder_inputs +.. doxygenclass:: cudaq::qec::decoder_init :members: .. doxygenfunction:: cudaq::qec::d_sparse(const cudaq::M2DSparseMatrix &) diff --git a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst index 7e0556c24..88425ed31 100644 --- a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst +++ b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst @@ -53,7 +53,7 @@ Real-time decoding requires converting matrices to sparse format for efficient d - :cpp:func:`cudaq::qec::pcm_to_sparse_vec` for converting a dense PCM to a sparse PCM. - :cpp:func:`cudaq::qec::pcm_from_sparse_vec` for converting a sparse PCM to a dense PCM. - :cpp:func:`cudaq::qec::d_sparse` for converting an ``M2DSparseMatrix`` (obtained from - a :cpp:class:`cudaq::qec::decoder_inputs` component) into the ``-1``-terminated sparse + a :cpp:class:`cudaq::qec::decoder_init` component) into the ``-1``-terminated sparse vector a decoder config expects for ``D_sparse``. **Usage in real-time decoding:** diff --git a/docs/sphinx/api/qec/sliding_window_api.rst b/docs/sphinx/api/qec/sliding_window_api.rst index 5ca103fbb..e23b7d404 100644 --- a/docs/sphinx/api/qec/sliding_window_api.rst +++ b/docs/sphinx/api/qec/sliding_window_api.rst @@ -103,7 +103,7 @@ {"inner_decoder_params", inner_decoder_params}}; // Priors are model data, so they travel with H rather than in // the parameter map. - auto inputs = cudaq::qec::decoder_inputs( + auto inputs = cudaq::qec::decoder_init( cudaq::qec::sparse_binary_matrix(dem.detector_error_matrix), std::nullopt, dem.error_rates); auto swdec = diff --git a/docs/sphinx/components/qec/introduction.rst b/docs/sphinx/components/qec/introduction.rst index c307c1f8a..565e7a8a1 100644 --- a/docs/sphinx/components/qec/introduction.rst +++ b/docs/sphinx/components/qec/introduction.rst @@ -632,7 +632,7 @@ To implement a new decoder: // Decoder-specific members public: - my_decoder(qec::decoder_inputs inputs, + my_decoder(qec::decoder_init inputs, qec::decode_result_type requested_output, const heterogeneous_map& params) : decoder(std::move(inputs), requested_output) { @@ -654,7 +654,7 @@ To implement a new decoder: CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( my_decoder, static std::unique_ptr create( - qec::decoder_inputs inputs, + qec::decoder_init inputs, std::optional requested_output, const heterogeneous_map& params) { return std::make_unique( @@ -666,7 +666,7 @@ To implement a new decoder: CUDAQ_EXT_PT_REGISTER_TYPE(my_decoder) -The factory receives the model as :code:`decoder_inputs` and the caller's +The factory receives the model as :code:`decoder_init` and the caller's result form as an optional :code:`decode_result_type`. A decoder that supports one form only should default the request to that form and reject any other. @@ -682,7 +682,7 @@ Here's a simple lookup table decoder for the Steane code: std::map single_qubit_err_signatures; public: - single_error_lut(qec::decoder_inputs inputs, + single_error_lut(qec::decoder_init inputs, const heterogeneous_map& params) : decoder(std::move(inputs)) { // Canonicalize before using each sparse column as an error diff --git a/docs/sphinx/examples/qec/cpp/real_time_complete.cpp b/docs/sphinx/examples/qec/cpp/real_time_complete.cpp index 9c59a4418..595e1e21d 100644 --- a/docs/sphinx/examples/qec/cpp/real_time_complete.cpp +++ b/docs/sphinx/examples/qec/cpp/real_time_complete.cpp @@ -30,7 +30,7 @@ // Save decoder configuration to YAML file void save_dem(const cudaq::qec::decoder_inputs &inputs, const std::string &filename) { - const auto dem = inputs.materialize_detector_error_model(); + const auto &dem = inputs.dem; // Create decoder config cudaq::qec::decoding::config::decoder_config config; config.id = 0; @@ -39,7 +39,7 @@ void save_dem(const cudaq::qec::decoder_inputs &inputs, config.syndrome_size = dem.num_detectors(); config.H_sparse = cudaq::qec::pcm_to_sparse_vec(dem.detector_error_matrix); config.O_sparse = cudaq::qec::pcm_to_sparse_vec(dem.observables_flips_matrix); - config.D_sparse = cudaq::qec::d_sparse(*inputs.measurement_to_detectors()); + config.D_sparse = cudaq::qec::d_sparse(inputs.m2d); // Decoder parameters are a plain heterogeneous_map; keys are governed by // the parameter schema the decoder registered. diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index 867ec438e..579553518 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -11,7 +11,7 @@ #include "cuda-qx/core/extension_point.h" #include "cuda-qx/core/heterogeneous_map.h" #include "cuda-qx/core/tensor.h" -#include "cudaq/qec/decoder_inputs.h" +#include "cudaq/qec/decoder_init.h" #include #include #include @@ -135,7 +135,7 @@ class async_decoder_result { /// arbitrary constructor parameters that can be unique to each specific /// decoder. class decoder - : public cudaqx::extension_point, const cudaqx::heterogeneous_map &> { private: @@ -153,7 +153,7 @@ class decoder /// factory can move its immutable handle into the decoder. /// @param requested_output The result basis this instance produces, fixed /// for its lifetime. - decoder(decoder_inputs inputs, decode_result_type requested_output); + decoder(decoder_init inputs, decode_result_type requested_output); /// @brief Decode a single syndrome /// @param syndrome A vector of syndrome measurements where the floating point @@ -192,20 +192,20 @@ class decoder /// @param inputs Stable decoder inputs. /// @param param_map Optional decoder-specific parameters. static std::unique_ptr - get(const std::string &name, decoder_inputs inputs, + get(const std::string &name, decoder_init inputs, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()); /// @brief Construct a registered decoder with an explicit instance-default /// result form. static std::unique_ptr - get(const std::string &name, decoder_inputs inputs, decode_result_type output, + get(const std::string &name, decoder_init inputs, decode_result_type output, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()); static std::unique_ptr get(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()) { - return get(name, decoder_inputs{H}, param_map); + return get(name, decoder_init{H}, param_map); } static std::unique_ptr @@ -219,21 +219,21 @@ class decoder get(const std::string &name, const std::string &stim_dem_text, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()) { - return get(name, decoder_inputs::from_stim_dem(stim_dem_text), param_map); + return get(name, decoder_init::from_stim_dem(stim_dem_text), param_map); } static std::unique_ptr get(const std::string &name, const char *stim_dem_text, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()) { - return get(name, decoder_inputs::from_stim_dem(stim_dem_text), param_map); + return get(name, decoder_init::from_stim_dem(stim_dem_text), param_map); } static std::unique_ptr get(const std::string &name, std::string_view stim_dem_text, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()) { - return get(name, decoder_inputs::from_stim_dem(std::string{stim_dem_text}), + return get(name, decoder_init::from_stim_dem(std::string{stim_dem_text}), param_map); } @@ -326,7 +326,7 @@ class decoder protected: /// @brief The immutable construction inputs owned by this decoder. - const decoder_inputs &get_inputs() const noexcept { return inputs_; } + const decoder_init &get_inputs() const noexcept { return inputs_; } /// @brief Project an error frame onto observables through the model's O. /// @@ -343,7 +343,7 @@ class decoder /// /// Everything the realtime path can derive from the model -- D, the /// measurement buffer, the detector buffers, the corrections buffer -- is - /// sized by the base constructor from `decoder_inputs`. Layer geometry is + /// sized by the base constructor from `decoder_init`. Layer geometry is /// the exception: it is a property of how the decoder consumes rounds, not /// of the model, and the base cannot ask a subclass for it while the /// subclass is still being constructed. A streaming decoder therefore hands @@ -372,11 +372,11 @@ class decoder private: static std::unique_ptr - get_impl(const std::string &name, decoder_inputs inputs, + get_impl(const std::string &name, decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶m_map); /// @brief The decoder's immutable construction inputs. - const decoder_inputs inputs_; + const decoder_init inputs_; const decode_result_type result_type_; }; @@ -533,18 +533,18 @@ inline void convert_vec_hard_to_soft(const std::vector> &in, } std::unique_ptr -get_decoder(const std::string &name, decoder_inputs inputs, +get_decoder(const std::string &name, decoder_init inputs, const cudaqx::heterogeneous_map options = {}); std::unique_ptr -get_decoder(const std::string &name, decoder_inputs inputs, +get_decoder(const std::string &name, decoder_init inputs, decode_result_type output, const cudaqx::heterogeneous_map options = {}); inline std::unique_ptr get_decoder(const std::string &name, const cudaq::qec::sparse_binary_matrix &H, const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_inputs{H}, options); + return get_decoder(name, decoder_init{H}, options); } inline std::unique_ptr @@ -556,8 +556,7 @@ get_decoder(const std::string &name, const cudaqx::tensor &H, inline std::unique_ptr get_decoder(const std::string &name, const std::string &stim_dem_text, const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_inputs::from_stim_dem(stim_dem_text), - options); + return get_decoder(name, decoder_init::from_stim_dem(stim_dem_text), options); } /// Each raw-DEM spelling needs its own explicit-output overload: string_view @@ -566,15 +565,14 @@ get_decoder(const std::string &name, const std::string &stim_dem_text, inline std::unique_ptr get_decoder(const std::string &name, const char *stim_dem_text, const cudaqx::heterogeneous_map options = {}) { - return get_decoder(name, decoder_inputs::from_stim_dem(stim_dem_text), - options); + return get_decoder(name, decoder_init::from_stim_dem(stim_dem_text), options); } inline std::unique_ptr get_decoder(const std::string &name, std::string_view stim_dem_text, const cudaqx::heterogeneous_map options = {}) { return get_decoder( - name, decoder_inputs::from_stim_dem(std::string{stim_dem_text}), options); + name, decoder_init::from_stim_dem(std::string{stim_dem_text}), options); } namespace details { diff --git a/libs/qec/include/cudaq/qec/decoder_inputs.h b/libs/qec/include/cudaq/qec/decoder_init.h similarity index 86% rename from libs/qec/include/cudaq/qec/decoder_inputs.h rename to libs/qec/include/cudaq/qec/decoder_init.h index 531d9ddf7..5cd67df2a 100644 --- a/libs/qec/include/cudaq/qec/decoder_inputs.h +++ b/libs/qec/include/cudaq/qec/decoder_init.h @@ -26,7 +26,7 @@ namespace cudaq::qec { /// compact chunked DEM: that source would be added here with a new enumerator /// plus its typed constructor and accessor, so a decoder that consumes chunks /// reads them directly instead of the handle first flattening them into -/// matrices. Adding one changes neither the `decoder_inputs` object layout nor +/// matrices. Adding one changes neither the `decoder_init` object layout nor /// the decoder factory signature. enum class decoder_model_source : std::uint8_t { matrices, @@ -41,10 +41,15 @@ enum class decoder_model_source : std::uint8_t { /// expose the projection stored when the handle is constructed. Model matrices /// are stored sparsely instead of composing detector_error_model, whose matrix /// fields are dense tensors. -class decoder_inputs { +class decoder_init { public: /// @brief Construct an H-only matrix model. - explicit decoder_inputs(sparse_binary_matrix detector_error_matrix); + explicit decoder_init(sparse_binary_matrix detector_error_matrix); + + /// Raw Stim DEM text enters through from_stim_dem(), which parses and + /// projects it. Deleted so the older spelling fails here rather than + /// through overload resolution somewhere less obvious. + explicit decoder_init(std::string) = delete; /// @brief Construct a materialized matrix model. /// @param detector_error_matrix H, with shape detectors x error mechanisms. @@ -56,7 +61,7 @@ class decoder_inputs { /// @param measurement_to_detectors Optional D, with shape detectors x raw /// measurements. /// @param error_ids Optional correlation ID per error mechanism. - decoder_inputs( + decoder_init( sparse_binary_matrix detector_error_matrix, std::optional observable_flips_matrix, std::vector error_rates = {}, @@ -65,28 +70,28 @@ class decoder_inputs { std::optional> error_ids = std::nullopt); /// @brief Construct from the existing materialized detector-error model. - explicit decoder_inputs(detector_error_model model, - std::optional - measurement_to_detectors = std::nullopt); + explicit decoder_init(detector_error_model model, + std::optional + measurement_to_detectors = std::nullopt); /// @brief Construct from authoritative raw Stim DEM text. /// /// Matrix accessors expose the common lossy projection produced by /// `dem_from_stim_text`; DEM-native decoders should consume `stim_dem()`. - static decoder_inputs + static decoder_init from_stim_dem(std::string stim_dem_text, std::optional measurement_to_detectors = std::nullopt); - decoder_inputs(const decoder_inputs &) noexcept; + decoder_init(const decoder_init &) noexcept; /// @brief Move construction leaves the source valid only for destruction or /// assignment. - decoder_inputs(decoder_inputs &&) noexcept; - decoder_inputs &operator=(const decoder_inputs &) noexcept; + decoder_init(decoder_init &&) noexcept; + decoder_init &operator=(const decoder_init &) noexcept; /// @brief Move assignment leaves the source valid only for destruction or /// assignment. - decoder_inputs &operator=(decoder_inputs &&) noexcept; - ~decoder_inputs(); + decoder_init &operator=(decoder_init &&) noexcept; + ~decoder_init(); /// @brief The authoritative representation. Consumers that only need to /// know whether raw DEM text is available should ask has_stim_dem(); this @@ -116,7 +121,7 @@ class decoder_inputs { /// @brief Return the same inputs without D, for a decoder that is fed /// detectors rather than a raw measurement stream. Everything else, /// including the authoritative source, is preserved. - decoder_inputs decoder_inputs_without_d() const; + decoder_init decoder_init_without_d() const; /// @brief Return the same inputs with H in GF(2)-canonical CSC form. /// @@ -125,7 +130,7 @@ class decoder_inputs { /// passed through untouched, and the authoritative source is retained. /// Consumers that need a canonical H should ask for it here rather than /// rebuilding a matrix-authoritative handle by hand. - decoder_inputs canonicalize_H() const; + decoder_init canonicalize_H() const; bool has_stim_dem() const noexcept; @@ -151,7 +156,7 @@ class decoder_inputs { std::optional> error_ids, std::optional measurement_to_detectors, std::optional raw_stim_dem = std::nullopt); - explicit decoder_inputs(std::shared_ptr state); + explicit decoder_init(std::shared_ptr state); std::shared_ptr state_; }; diff --git a/libs/qec/include/cudaq/qec/experiments.h b/libs/qec/include/cudaq/qec/experiments.h index 5b25dfe1b..9085c8896 100644 --- a/libs/qec/include/cudaq/qec/experiments.h +++ b/libs/qec/include/cudaq/qec/experiments.h @@ -9,7 +9,7 @@ #include "cudaq/algorithms/dem.h" #include "cudaq/qec/code.h" -#include "cudaq/qec/decoder_inputs.h" +#include "cudaq/qec/decoder_init.h" #include "cudaq/qec/detector_error_model.h" #include #include @@ -161,12 +161,18 @@ std::tuple, cudaqx::tensor> sample_memory_circuit(const code &code, std::size_t numShots, std::size_t numRounds, cudaq::noise_model &noise); +/// @brief Finalized decoder inputs: a canonicalized DEM and measurement maps. +struct decoder_inputs { + detector_error_model dem; + cudaq::M2DSparseMatrix m2d; + cudaq::M2OSparseMatrix m2o; +}; + /// @brief Lazy handle returned by `decoder_context_from_memory_circuit`. /// /// Stores the raw (uncanonicalized) circuit analysis. Call a component method -/// to canonicalize exactly the stabilizer type needed and obtain stable -/// `decoder_inputs`. The circuit-only measurement-to-observable map is not -/// part of the decoder plugin input contract. +/// to canonicalize exactly the stabilizer type needed and obtain a +/// `decoder_inputs`: /// - `x_component()` — X-stabilizer detectors only /// - `z_component()` — Z-stabilizer detectors only /// - `full_component()` — both stabilizer types, boundary-aware @@ -174,12 +180,6 @@ struct decoder_context { /// @brief Total number of measurements per shot (column count of m2d/m2o). std::size_t num_measurements() const; - /// @brief Circuit-analysis measurement-to-observable metadata. - /// - /// This remains on the experiment handle and is not passed through the - /// decoder plugin factory in the first contract iteration. - const cudaq::M2OSparseMatrix &measurement_to_observables() const; - /// @brief Canonicalize X-stabilizer detectors; return decoder_inputs. decoder_inputs x_component() const; @@ -209,7 +209,7 @@ struct decoder_context { std::vector d_sparse(const cudaq::M2DSparseMatrix &m2d); /// @brief Convert CUDA-Q circuit-analysis M2D output to the QEC-owned sparse -/// matrix used by decoder_inputs. +/// matrix used by decoder_init. sparse_binary_matrix m2d_to_sparse(const cudaq::M2DSparseMatrix &m2d); /// @brief Flatten a QEC-owned detector-by-measurement matrix into the legacy diff --git a/libs/qec/include/cudaq/qec/realtime/decoding_config.h b/libs/qec/include/cudaq/qec/realtime/decoding_config.h index 99f99258c..70bcf484f 100644 --- a/libs/qec/include/cudaq/qec/realtime/decoding_config.h +++ b/libs/qec/include/cudaq/qec/realtime/decoding_config.h @@ -88,7 +88,7 @@ struct decoder_config { /// required by both. std::vector D_sparse; /// Error probability per H column. This is framework model data and is - /// normalized into decoder_inputs rather than passed to plugin parameters. + /// normalized into decoder_init rather than passed to plugin parameters. std::vector error_rate_vec; decoder_custom_args_t decoder_custom_args; diff --git a/libs/qec/lib/CMakeLists.txt b/libs/qec/lib/CMakeLists.txt index 02c993d65..7c4eb3d19 100644 --- a/libs/qec/lib/CMakeLists.txt +++ b/libs/qec/lib/CMakeLists.txt @@ -41,7 +41,7 @@ endif() set(DECODERS_SOURCES decoder.cpp - decoder_inputs.cpp + decoder_init.cpp decoder_config_payload.cpp decoder_config_schema.cpp detector_error_model.cpp diff --git a/libs/qec/lib/decoder.cpp b/libs/qec/lib/decoder.cpp index bf7c17e37..28194518c 100644 --- a/libs/qec/lib/decoder.cpp +++ b/libs/qec/lib/decoder.cpp @@ -20,7 +20,7 @@ #include #include -INSTANTIATE_REGISTRY(cudaq::qec::decoder, cudaq::qec::decoder_inputs, +INSTANTIATE_REGISTRY(cudaq::qec::decoder, cudaq::qec::decoder_init, std::optional, const cudaqx::heterogeneous_map &) @@ -87,7 +87,7 @@ struct decoder::rt_impl { void decoder::rt_impl_deleter::operator()(rt_impl *p) const { delete p; } -decoder::decoder(decoder_inputs inputs, decode_result_type requested_output) +decoder::decoder(decoder_init inputs, decode_result_type requested_output) : pimpl(std::unique_ptr(new rt_impl())), inputs_(std::move(inputs)), result_type_(requested_output) { syndrome_size = inputs_.num_detectors(); @@ -246,27 +246,27 @@ class ConstructionDevicePin { }; std::unique_ptr -decoder::get(const std::string &name, decoder_inputs inputs, +decoder::get(const std::string &name, decoder_init inputs, const cudaqx::heterogeneous_map ¶m_map) { return get_impl(name, std::move(inputs), std::nullopt, param_map); } std::unique_ptr -decoder::get(const std::string &name, decoder_inputs inputs, +decoder::get(const std::string &name, decoder_init inputs, decode_result_type output, const cudaqx::heterogeneous_map ¶m_map) { return get_impl(name, std::move(inputs), output, param_map); } std::unique_ptr -decoder::get_impl(const std::string &name, decoder_inputs inputs, +decoder::get_impl(const std::string &name, decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶m_map) { for (const char *reserved : {"H", "O", "D", "error_rate_vec"}) if (param_map.contains(reserved)) throw std::runtime_error( fmt::format("'{}' is framework model data; provide it through " - "decoder_inputs instead of decoder custom parameters", + "decoder_init instead of decoder custom parameters", reserved)); auto [mutex, registry] = get_registry(); std::lock_guard lock(mutex); @@ -622,13 +622,13 @@ void decoder::reset_decoder() { } std::unique_ptr get_decoder(const std::string &name, - decoder_inputs inputs, + decoder_init inputs, const cudaqx::heterogeneous_map options) { return decoder::get(name, std::move(inputs), options); } std::unique_ptr get_decoder(const std::string &name, - decoder_inputs inputs, + decoder_init inputs, decode_result_type output, const cudaqx::heterogeneous_map options) { return decoder::get(name, std::move(inputs), output, options); diff --git a/libs/qec/lib/decoder_inputs.cpp b/libs/qec/lib/decoder_init.cpp similarity index 65% rename from libs/qec/lib/decoder_inputs.cpp rename to libs/qec/lib/decoder_init.cpp index 444b610b4..103c886dd 100644 --- a/libs/qec/lib/decoder_inputs.cpp +++ b/libs/qec/lib/decoder_init.cpp @@ -6,14 +6,14 @@ * the terms of the Apache License 2.0 which accompanies this distribution. * ******************************************************************************/ -#include "cudaq/qec/decoder_inputs.h" +#include "cudaq/qec/decoder_init.h" #include "dem_sparse_projection.h" #include #include namespace cudaq::qec { -struct decoder_inputs::impl { +struct decoder_init::impl { decoder_model_source source = decoder_model_source::matrices; std::size_t num_detectors = 0; std::size_t num_error_mechanisms = 0; @@ -37,21 +37,21 @@ void validate_model(const sparse_binary_matrix &H, const std::optional &D) { if (O && O->num_cols() != H.num_cols()) throw std::invalid_argument( - "decoder_inputs: O column count must match H column count"); + "decoder_init: O column count must match H column count"); if (!rates.empty() && rates.size() != H.num_cols()) throw std::invalid_argument( - "decoder_inputs: error_rates size must match H column count"); + "decoder_init: error_rates size must match H column count"); if (ids && ids->size() != H.num_cols()) throw std::invalid_argument( - "decoder_inputs: error_ids size must match H column count"); + "decoder_init: error_ids size must match H column count"); if (D && D->num_rows() != H.num_rows()) throw std::invalid_argument( - "decoder_inputs: D row count must match H row count"); + "decoder_init: D row count must match H row count"); } } // namespace -std::shared_ptr decoder_inputs::make_matrix_state( +std::shared_ptr decoder_init::make_matrix_state( decoder_model_source source, sparse_binary_matrix H, std::optional O, std::vector rates, std::optional> ids, @@ -64,7 +64,7 @@ std::shared_ptr decoder_inputs::make_matrix_state( *D = D->to_csr(); validate_model(H, O, rates, ids, D); - auto state = std::make_shared(); + auto state = std::make_shared(); state->source = source; state->num_detectors = H.num_rows(); state->num_error_mechanisms = H.num_cols(); @@ -78,32 +78,32 @@ std::shared_ptr decoder_inputs::make_matrix_state( return state; } -decoder_inputs::decoder_inputs(sparse_binary_matrix H) - : decoder_inputs(make_matrix_state(decoder_model_source::matrices, - std::move(H), std::nullopt, {}, - std::nullopt, std::nullopt)) {} +decoder_init::decoder_init(sparse_binary_matrix H) + : decoder_init(make_matrix_state(decoder_model_source::matrices, + std::move(H), std::nullopt, {}, + std::nullopt, std::nullopt)) {} -decoder_inputs::decoder_inputs( +decoder_init::decoder_init( sparse_binary_matrix H, std::optional O, std::vector error_rates, std::optional measurement_to_detectors, std::optional> error_ids) - : decoder_inputs(make_matrix_state( + : decoder_init(make_matrix_state( decoder_model_source::matrices, std::move(H), std::move(O), std::move(error_rates), std::move(error_ids), std::move(measurement_to_detectors))) {} -decoder_inputs::decoder_inputs( +decoder_init::decoder_init( detector_error_model model, std::optional measurement_to_detectors) - : decoder_inputs(make_matrix_state( + : decoder_init(make_matrix_state( decoder_model_source::matrices, sparse_binary_matrix(model.detector_error_matrix), sparse_binary_matrix(model.observables_flips_matrix), std::move(model.error_rates), std::move(model.error_ids), std::move(measurement_to_detectors))) {} -decoder_inputs decoder_inputs::from_stim_dem( +decoder_init decoder_init::from_stim_dem( std::string stim_dem_text, std::optional measurement_to_detectors) { // Project straight to sparse. Going through the materialized @@ -111,80 +111,77 @@ decoder_inputs decoder_inputs::from_stim_dem( // only to scan it back out again: ~98 MiB for a distance-13 model whose // sparse form is under 1 MiB, and wasted entirely for a DEM-native decoder. auto [H, O, error_rates] = details::sparse_dem_from_stim_text(stim_dem_text); - return decoder_inputs(make_matrix_state( + return decoder_init(make_matrix_state( decoder_model_source::stim_dem, std::move(H), std::move(O), std::move(error_rates), std::nullopt, std::move(measurement_to_detectors), std::move(stim_dem_text))); } -decoder_inputs::decoder_inputs(std::shared_ptr state) +decoder_init::decoder_init(std::shared_ptr state) : state_(std::move(state)) {} -decoder_inputs::decoder_inputs(const decoder_inputs &) noexcept = default; -decoder_inputs::decoder_inputs(decoder_inputs &&) noexcept = default; -decoder_inputs & -decoder_inputs::operator=(const decoder_inputs &) noexcept = default; -decoder_inputs &decoder_inputs::operator=(decoder_inputs &&) noexcept = default; -decoder_inputs::~decoder_inputs() = default; +decoder_init::decoder_init(const decoder_init &) noexcept = default; +decoder_init::decoder_init(decoder_init &&) noexcept = default; +decoder_init &decoder_init::operator=(const decoder_init &) noexcept = default; +decoder_init &decoder_init::operator=(decoder_init &&) noexcept = default; +decoder_init::~decoder_init() = default; -decoder_model_source decoder_inputs::source() const noexcept { +decoder_model_source decoder_init::source() const noexcept { return state_->source; } -const sparse_binary_matrix &decoder_inputs::detector_error_matrix() const { +const sparse_binary_matrix &decoder_init::detector_error_matrix() const { return state_->H; } -bool decoder_inputs::has_observable_model() const noexcept { +bool decoder_init::has_observable_model() const noexcept { return state_->O.has_value(); } -const sparse_binary_matrix &decoder_inputs::observable_flips_matrix() const { +const sparse_binary_matrix &decoder_init::observable_flips_matrix() const { if (!state_->O) - throw std::logic_error( - "decoder_inputs: no observable mapping was supplied"); + throw std::logic_error("decoder_init: no observable mapping was supplied"); return *state_->O; } -const std::vector &decoder_inputs::error_rates() const { +const std::vector &decoder_init::error_rates() const { return state_->rates; } -const std::optional> & -decoder_inputs::error_ids() const { +const std::optional> &decoder_init::error_ids() const { return state_->ids; } const sparse_binary_matrix * -decoder_inputs::measurement_to_detectors() const noexcept { +decoder_init::measurement_to_detectors() const noexcept { return state_->D ? &*state_->D : nullptr; } -decoder_inputs decoder_inputs::canonicalize_H() const { +decoder_init decoder_init::canonicalize_H() const { auto H = state_->H.canonicalize().to_csc(); - return decoder_inputs(make_matrix_state(state_->source, std::move(H), - state_->O, state_->rates, state_->ids, - state_->D, state_->raw_stim_dem)); + return decoder_init(make_matrix_state(state_->source, std::move(H), state_->O, + state_->rates, state_->ids, state_->D, + state_->raw_stim_dem)); } -decoder_inputs decoder_inputs::decoder_inputs_without_d() const { +decoder_init decoder_init::decoder_init_without_d() const { auto state = std::make_shared(*state_); state->D.reset(); - return decoder_inputs(std::move(state)); + return decoder_init(std::move(state)); } -bool decoder_inputs::has_stim_dem() const noexcept { +bool decoder_init::has_stim_dem() const noexcept { return state_->raw_stim_dem.has_value(); } -const std::string &decoder_inputs::stim_dem() const { +const std::string &decoder_init::stim_dem() const { if (!state_->raw_stim_dem) throw std::logic_error( - "decoder_inputs: authoritative source is not a Stim DEM"); + "decoder_init: authoritative source is not a Stim DEM"); return *state_->raw_stim_dem; } -detector_error_model decoder_inputs::materialize_detector_error_model() const { +detector_error_model decoder_init::materialize_detector_error_model() const { detector_error_model model; model.detector_error_matrix = state_->H.to_dense(); // A model with no observable mapping materializes as zero observable rows, @@ -198,15 +195,15 @@ detector_error_model decoder_inputs::materialize_detector_error_model() const { return model; } -std::size_t decoder_inputs::num_detectors() const noexcept { +std::size_t decoder_init::num_detectors() const noexcept { return state_->num_detectors; } -std::size_t decoder_inputs::num_error_mechanisms() const noexcept { +std::size_t decoder_init::num_error_mechanisms() const noexcept { return state_->num_error_mechanisms; } -std::size_t decoder_inputs::num_observables() const noexcept { +std::size_t decoder_init::num_observables() const noexcept { return state_->num_observables; } diff --git a/libs/qec/lib/decoders/lut.cpp b/libs/qec/lib/decoders/lut.cpp index d4d655720..ce4fca814 100644 --- a/libs/qec/lib/decoders/lut.cpp +++ b/libs/qec/lib/decoders/lut.cpp @@ -49,7 +49,7 @@ class multi_error_lut : public decoder { bool decoding_time = false; public: - multi_error_lut(cudaq::qec::decoder_inputs inputs, + multi_error_lut(cudaq::qec::decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { @@ -255,7 +255,7 @@ class multi_error_lut : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( multi_error_lut, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( @@ -268,7 +268,7 @@ CUDAQ_EXT_PT_REGISTER_TYPE(multi_error_lut) class single_error_lut : public multi_error_lut { public: - single_error_lut(cudaq::qec::decoder_inputs inputs, + single_error_lut(cudaq::qec::decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : multi_error_lut(std::move(inputs), requested_output, params) {} @@ -277,7 +277,7 @@ class single_error_lut : public multi_error_lut { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( single_error_lut, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( diff --git a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp index 067de62a4..1cc0944f6 100644 --- a/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp +++ b/libs/qec/lib/decoders/plugins/chromobius/chromobius.cpp @@ -27,7 +27,7 @@ struct chromobius_init_data { stim::DetectorErrorModel dem; }; -chromobius_init_data make_chromobius_init_data(const decoder_inputs &inputs) { +chromobius_init_data make_chromobius_init_data(const decoder_init &inputs) { if (!inputs.has_stim_dem()) { throw std::runtime_error( "Chromobius decoder requires a Stim detector error model string as " @@ -74,7 +74,7 @@ class chromobius : public decoder { std::vector packed_detection_events; public: - chromobius(decoder_inputs inputs, chromobius_init_data init_data, + chromobius(decoder_init inputs, chromobius_init_data init_data, decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output), @@ -156,7 +156,7 @@ class chromobius : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( chromobius, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { auto init_data = make_chromobius_init_data(inputs); diff --git a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp index 7eb17290f..dea4f9566 100644 --- a/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp +++ b/libs/qec/lib/decoders/plugins/example/single_error_lut_example.cpp @@ -22,7 +22,7 @@ class single_error_lut_example : public decoder { std::map single_qubit_err_signatures; public: - single_error_lut_example(cudaq::qec::decoder_inputs inputs, + single_error_lut_example(cudaq::qec::decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { @@ -88,7 +88,7 @@ class single_error_lut_example : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( single_error_lut_example, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( diff --git a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp index 519699dec..33f321dd3 100644 --- a/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp +++ b/libs/qec/lib/decoders/plugins/pymatching/pymatching.cpp @@ -75,7 +75,7 @@ class pymatching : public decoder { #endif public: - pymatching(cudaq::qec::decoder_inputs inputs, + pymatching(cudaq::qec::decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { @@ -269,7 +269,7 @@ class pymatching : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( pymatching, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( diff --git a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp index 40c29216e..06550a947 100644 --- a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp +++ b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp @@ -144,7 +144,7 @@ static Logger gLogger; /// opt_results are carried through onto the combined result, so /// global-decoder options that surface only through opt_results (for example /// Chromobius's return_weight) remain externally visible. -/// O is read from decoder_inputs only for model dimensions and observable +/// O is read from decoder_init only for model dimensions and observable /// combination. Its presence never selects an engine-output interpretation. /// /// Note: Only one of onnx_load_path or engine_load_path should be specified, @@ -467,7 +467,7 @@ class trt_decoder : public decoder { size_t num_observables_ = 0; public: - trt_decoder(cudaq::qec::decoder_inputs inputs, + trt_decoder(cudaq::qec::decoder_init inputs, decode_result_type requested_output, trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms); @@ -481,7 +481,7 @@ class trt_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( trt_decoder, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { const auto format = parse_engine_output_format(params); @@ -588,7 +588,7 @@ struct trt_decoder::Impl { // trt_decoder method implementations // ============================================================================ -trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, +trt_decoder::trt_decoder(cudaq::qec::decoder_init inputs, decode_result_type requested_output, trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms) @@ -850,7 +850,7 @@ trt_decoder::trt_decoder(cudaq::qec::decoder_inputs inputs, ? decode_result_type::observables : requested_output; global_decoder_ = decoder::get(global_decoder_name, - get_inputs().decoder_inputs_without_d(), + get_inputs().decoder_init_without_d(), global_output, global_decoder_params_); CUDA_QEC_INFO("TensorRT decoder: global_decoder '{}' attached", global_decoder_name); diff --git a/libs/qec/lib/decoders/sliding_window.cpp b/libs/qec/lib/decoders/sliding_window.cpp index c0bb89436..78ad62571 100644 --- a/libs/qec/lib/decoders/sliding_window.cpp +++ b/libs/qec/lib/decoders/sliding_window.cpp @@ -18,7 +18,7 @@ namespace cudaq::qec { namespace { -decoder_inputs canonicalize_sliding_window_inputs(decoder_inputs inputs) { +decoder_init canonicalize_sliding_window_inputs(decoder_init inputs) { // Canonical CSC is the steady-state contract for decode_window's column // slices and validate_inputs's per-column reads. canonicalize_H() is // basis-preserving and retains the authoritative source, so no source kind @@ -120,7 +120,7 @@ void sliding_window::initialize_window(std::size_t batch_size) { std::chrono::duration(t1 - t0).count() * 1000; } -sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, +sliding_window::sliding_window(cudaq::qec::decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) // Canonical CSC is the steady-state contract for decode_window's column @@ -218,10 +218,10 @@ sliding_window::sliding_window(cudaq::qec::decoder_inputs inputs, // Slicing detector rows and error columns re-indexes both, so this window // gets its own matrices. Nothing is inherited: a raw DEM names the outer // detectors and would not describe these. - decoder_inputs inner_inputs(sparse_binary_matrix(H_round), - std::move(inner_O), std::move(error_vec_mod), - /*measurement_to_detectors=*/std::nullopt, - std::move(inner_error_ids)); + decoder_init inner_inputs(sparse_binary_matrix(H_round), std::move(inner_O), + std::move(error_vec_mod), + /*measurement_to_detectors=*/std::nullopt, + std::move(inner_error_ids)); auto inner_decoder = decoder::get(inner_decoder_name, std::move(inner_inputs), decode_result_type::errors, inner_decoder_params); diff --git a/libs/qec/lib/decoders/sliding_window.h b/libs/qec/lib/decoders/sliding_window.h index 75acfb096..826c63c93 100644 --- a/libs/qec/lib/decoders/sliding_window.h +++ b/libs/qec/lib/decoders/sliding_window.h @@ -107,7 +107,7 @@ class sliding_window : public decoder { /// - num_boundary_syndromes: Boundary-layer width (0 if uniform) /// - inner_decoder_name: Name of the inner decoder to use /// - inner_decoder_params: Parameters for the inner decoder (optional) - sliding_window(cudaq::qec::decoder_inputs inputs, + sliding_window(cudaq::qec::decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms); @@ -145,7 +145,7 @@ class sliding_window : public decoder { // Plugin registration macros CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( sliding_window, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( diff --git a/libs/qec/lib/dem_sparse_projection.h b/libs/qec/lib/dem_sparse_projection.h index 7fcc0f49a..ea464cb82 100644 --- a/libs/qec/lib/dem_sparse_projection.h +++ b/libs/qec/lib/dem_sparse_projection.h @@ -12,7 +12,7 @@ #include #include -// Library-private: shared by detector_error_model.cpp and decoder_inputs.cpp. +// Library-private: shared by detector_error_model.cpp and decoder_init.cpp. // Not installed and not exported, so it adds no plugin-visible API surface. // Use dem_from_stim_text() for the public materialized model. // @@ -23,7 +23,7 @@ namespace cudaq::qec::details { -/// The sparse projection of a Stim DEM, in the layouts `decoder_inputs` stores. +/// The sparse projection of a Stim DEM, in the layouts `decoder_init` stores. /// Named fields rather than a tuple: H and O share a type, so positional /// results would let them be swapped while still type-checking. struct sparse_dem_projection { diff --git a/libs/qec/lib/experiments.cpp b/libs/qec/lib/experiments.cpp index 29f841d69..0acd6f237 100644 --- a/libs/qec/lib/experiments.cpp +++ b/libs/qec/lib/experiments.cpp @@ -444,9 +444,9 @@ namespace details { /// so the simpler overload suffices. static decoder_inputs make_component(detector_error_model dem, cudaq::M2DSparseMatrix m2d, - std::size_t num_rounds, std::size_t num_x_stabilizers, - std::size_t num_z_stabilizers, bool fixed_basis_is_z, - bool keep_x, bool keep_z) { + cudaq::M2OSparseMatrix m2o, std::size_t num_rounds, + std::size_t num_x_stabilizers, std::size_t num_z_stabilizers, + bool fixed_basis_is_z, bool keep_x, bool keep_z) { if (keep_x && keep_z) { const uint32_t numBoundary = fixed_basis_is_z ? static_cast(num_z_stabilizers) @@ -454,7 +454,7 @@ make_component(detector_error_model dem, cudaq::M2DSparseMatrix m2d, dem.canonicalize_for_rounds_with_boundary( static_cast(num_x_stabilizers + num_z_stabilizers), numBoundary, /*remove_zero_syndrome_errors=*/true); - return decoder_inputs(std::move(dem), m2d_to_sparse(m2d)); + return {std::move(dem), std::move(m2d), std::move(m2o)}; } const std::size_t numDetectors = dem.detector_error_matrix.shape()[0]; @@ -473,7 +473,7 @@ make_component(detector_error_model dem, cudaq::M2DSparseMatrix m2d, detector_error_model empty_dem; empty_dem.detector_error_matrix = cudaqx::tensor({0, 0}); empty_dem.observables_flips_matrix = cudaqx::tensor({numObs, 0}); - return decoder_inputs(std::move(empty_dem), m2d_to_sparse(empty_m2d)); + return {std::move(empty_dem), std::move(empty_m2d), std::move(m2o)}; } // Select the detector rows. @@ -499,26 +499,24 @@ make_component(detector_error_model dem, cudaq::M2DSparseMatrix m2d, (keep_z ? num_z_stabilizers : 0) + (keep_x ? num_x_stabilizers : 0); dem.canonicalize_for_rounds(static_cast(numReturnSynPerRound), /*remove_zero_syndrome_errors=*/true); - return decoder_inputs(std::move(dem), m2d_to_sparse(out_m2d)); + return {std::move(dem), std::move(out_m2d), std::move(m2o)}; } } // namespace details std::vector d_sparse(const cudaq::M2DSparseMatrix &m2d) { std::vector out; + out.reserve(m2d.rows.size() * 2); // rough estimate for (const auto &row : m2d.rows) { - for (const auto measurement : row) { - if (measurement > - static_cast(std::numeric_limits::max())) - throw std::overflow_error( - "measurement-to-detector index exceeds int64_t range"); - out.push_back(static_cast(measurement)); - } + for (auto meas : row) + out.push_back(static_cast(meas)); out.push_back(-1); } return out; } +/// Bridge from CUDA-Q circuit analysis to the QEC-owned sparse matrix a +/// decoder is constructed from. sparse_binary_matrix m2d_to_sparse(const cudaq::M2DSparseMatrix &m2d) { using index_type = sparse_binary_matrix::index_type; if (m2d.rows.size() > std::numeric_limits::max() || @@ -615,29 +613,24 @@ std::size_t decoder_context::num_measurements() const { return m2d_.num_measurements; } -const cudaq::M2OSparseMatrix & -decoder_context::measurement_to_observables() const { - return m2o_; -} - decoder_inputs decoder_context::x_component() const { - return details::make_component(dem_, m2d_, num_rounds_, num_x_stabilizers_, - num_z_stabilizers_, fixed_basis_is_z_, - /*keep_x=*/true, + return details::make_component(dem_, m2d_, m2o_, num_rounds_, + num_x_stabilizers_, num_z_stabilizers_, + fixed_basis_is_z_, /*keep_x=*/true, /*keep_z=*/false); } decoder_inputs decoder_context::z_component() const { - return details::make_component(dem_, m2d_, num_rounds_, num_x_stabilizers_, - num_z_stabilizers_, fixed_basis_is_z_, - /*keep_x=*/false, + return details::make_component(dem_, m2d_, m2o_, num_rounds_, + num_x_stabilizers_, num_z_stabilizers_, + fixed_basis_is_z_, /*keep_x=*/false, /*keep_z=*/true); } decoder_inputs decoder_context::full_component() const { - return details::make_component(dem_, m2d_, num_rounds_, num_x_stabilizers_, - num_z_stabilizers_, fixed_basis_is_z_, - /*keep_x=*/true, + return details::make_component(dem_, m2d_, m2o_, num_rounds_, + num_x_stabilizers_, num_z_stabilizers_, + fixed_basis_is_z_, /*keep_x=*/true, /*keep_z=*/true); } @@ -650,7 +643,7 @@ detector_error_model dem_from_memory_circuit(const code &code, return decoder_context_from_memory_circuit(code, statePrep, numRounds, noise, decompose_errors) .full_component() - .materialize_detector_error_model(); + .dem; } // For CSS codes, may want to partition x vs z decoding @@ -662,7 +655,7 @@ detector_error_model x_dem_from_memory_circuit(const code &code, return decoder_context_from_memory_circuit(code, statePrep, numRounds, noise, decompose_errors) .x_component() - .materialize_detector_error_model(); + .dem; } detector_error_model z_dem_from_memory_circuit(const code &code, @@ -673,7 +666,7 @@ detector_error_model z_dem_from_memory_circuit(const code &code, return decoder_context_from_memory_circuit(code, statePrep, numRounds, noise, decompose_errors) .z_component() - .materialize_detector_error_model(); + .dem; } } // namespace cudaq::qec diff --git a/libs/qec/lib/realtime/config.cpp b/libs/qec/lib/realtime/config.cpp index 9e0a98c66..e006dc025 100644 --- a/libs/qec/lib/realtime/config.cpp +++ b/libs/qec/lib/realtime/config.cpp @@ -283,7 +283,7 @@ struct MappingTraits { io.mapOptional("cuda_device_id", config.cuda_device_id); // A decoder model comes from exactly one source: the matrix keys or // stim_dem_path. Neither branch's keys can be mapRequired, so which are - // needed is decided by resolve_decoder_inputs(), not by the parser. + // needed is decided by resolve_decoder_init(), not by the parser. io.mapOptional("stim_dem_path", config.stim_dem_path, std::string{}); io.mapOptional("block_size", config.block_size, std::uint64_t{0}); io.mapOptional("syndrome_size", config.syndrome_size, std::uint64_t{0}); diff --git a/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp b/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp index 5f1a47e37..3f8596b8f 100644 --- a/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp +++ b/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp @@ -78,7 +78,7 @@ void SessionRegistry::load_from_config(const multi_decoder_config &config, dc.type); auto decoder = cudaq::qec::decoding::host::create_realtime_decoder( - dc, cudaq::qec::decoding::host::resolve_decoder_inputs(dc, base_dir)); + dc, cudaq::qec::decoding::host::resolve_decoder_init(dc, base_dir)); auto session = DecodingSession::create(std::move(decoder), make_default_mapping_table()); diff --git a/libs/qec/lib/realtime/realtime_decoding.cpp b/libs/qec/lib/realtime/realtime_decoding.cpp index b9f232f84..4ca3c733a 100644 --- a/libs/qec/lib/realtime/realtime_decoding.cpp +++ b/libs/qec/lib/realtime/realtime_decoding.cpp @@ -238,7 +238,7 @@ void validate_detector_rows(const std::vector &d_sparse, } // namespace -cudaq::qec::decoder_inputs resolve_decoder_inputs( +cudaq::qec::decoder_init resolve_decoder_init( const cudaq::qec::decoding::config::decoder_config &decoder_config, const std::filesystem::path &base_dir) { if (decoder_config.D_sparse.empty()) @@ -275,8 +275,8 @@ cudaq::qec::decoder_inputs resolve_decoder_inputs( // configuration byte-identical and a reload keeps serving the old model. // Change the path to change the model. - auto inputs = cudaq::qec::decoder_inputs::from_stim_dem(std::move(dem_text), - std::move(D)); + auto inputs = cudaq::qec::decoder_init::from_stim_dem(std::move(dem_text), + std::move(D)); // The DEM defines the detector basis; a supplied syndrome_size is only an // assertion about it. @@ -339,14 +339,13 @@ cudaq::qec::decoder_inputs resolve_decoder_inputs( decoder_config.O_sparse.end(), -1); auto observable_matrix = cudaq::qec::pcm_from_sparse_vec( decoder_config.O_sparse, num_observables, decoder_config.block_size); - return cudaq::qec::decoder_inputs( - std::move(pcm), std::move(observable_matrix), - decoder_config.error_rate_vec, std::move(D)); + return cudaq::qec::decoder_init(std::move(pcm), std::move(observable_matrix), + decoder_config.error_rate_vec, std::move(D)); } std::unique_ptr create_realtime_decoder( const cudaq::qec::decoding::config::decoder_config &decoder_config, - cudaq::qec::decoder_inputs inputs) { + cudaq::qec::decoder_init inputs) { if (decoder_config.id < 0 || static_cast(decoder_config.id) > std::numeric_limits::max()) throw std::invalid_argument("Decoder ID is outside the uint32_t range: " + @@ -476,7 +475,7 @@ int configure_decoders( const auto absolute_base = std::filesystem::absolute(base_dir).lexically_normal(); - std::vector resolved; + std::vector resolved; resolved.reserve(config.decoders.size()); // The absolute form of each model path, applied to the caller's // configuration only once the whole configuration has been applied. Rewriting @@ -486,7 +485,7 @@ int configure_decoders( std::vector absolute_model_paths(config.decoders.size()); for (std::size_t i = 0; i < config.decoders.size(); ++i) { const auto &decoder_config = config.decoders[i]; - resolved.push_back(resolve_decoder_inputs(decoder_config, absolute_base)); + resolved.push_back(resolve_decoder_init(decoder_config, absolute_base)); if (!decoder_config.stim_dem_path.empty()) { std::filesystem::path model(decoder_config.stim_dem_path); absolute_model_paths[i] = diff --git a/libs/qec/lib/realtime/realtime_decoding.h b/libs/qec/lib/realtime/realtime_decoding.h index 16776b9aa..57f532869 100644 --- a/libs/qec/lib/realtime/realtime_decoding.h +++ b/libs/qec/lib/realtime/realtime_decoding.h @@ -49,8 +49,8 @@ prepare_decoder_params( /// configuration file's parent directory for a file-based configuration, or /// the process working directory for a programmatic or raw-string one. /// @throws std::runtime_error on any resolution or validation failure. -__attribute__((visibility("default"))) cudaq::qec::decoder_inputs -resolve_decoder_inputs( +__attribute__((visibility("default"))) cudaq::qec::decoder_init +resolve_decoder_init( const cudaq::qec::decoding::config::decoder_config &decoder_config, const std::filesystem::path &base_dir); @@ -64,7 +64,7 @@ resolve_decoder_inputs( __attribute__((visibility("default"))) std::unique_ptr create_realtime_decoder( const cudaq::qec::decoding::config::decoder_config &decoder_config, - cudaq::qec::decoder_inputs inputs); + cudaq::qec::decoder_init inputs); __attribute__((visibility("default"))) void get_corrections(std::size_t decoder_id, uint8_t *corrections, diff --git a/libs/qec/python/bindings/py_code.cpp b/libs/qec/python/bindings/py_code.cpp index bd3ada138..9eb8faf93 100644 --- a/libs/qec/python/bindings/py_code.cpp +++ b/libs/qec/python/bindings/py_code.cpp @@ -711,10 +711,7 @@ void bindCode(nb::module_ &mod) { "x_component", [](const decoder_context &h) { auto ctx = h.x_component(); - return nb::make_tuple( - ctx.materialize_detector_error_model(), - ctx.measurement_to_detectors()->to_nested_csr(), - h.measurement_to_observables().rows); + return nb::make_tuple(ctx.dem, ctx.m2d.rows, ctx.m2o.rows); }, R"pbdoc( Canonicalize X-stabilizer detectors; return (dem, m2d, m2o). @@ -726,10 +723,7 @@ void bindCode(nb::module_ &mod) { "z_component", [](const decoder_context &h) { auto ctx = h.z_component(); - return nb::make_tuple( - ctx.materialize_detector_error_model(), - ctx.measurement_to_detectors()->to_nested_csr(), - h.measurement_to_observables().rows); + return nb::make_tuple(ctx.dem, ctx.m2d.rows, ctx.m2o.rows); }, R"pbdoc( Canonicalize Z-stabilizer detectors; return (dem, m2d, m2o). @@ -741,10 +735,7 @@ void bindCode(nb::module_ &mod) { "full_component", [](const decoder_context &h) { auto ctx = h.full_component(); - return nb::make_tuple( - ctx.materialize_detector_error_model(), - ctx.measurement_to_detectors()->to_nested_csr(), - h.measurement_to_observables().rows); + return nb::make_tuple(ctx.dem, ctx.m2d.rows, ctx.m2o.rows); }, R"pbdoc( Canonicalize both stabilizer types with boundary awareness; diff --git a/libs/qec/python/bindings/py_decoder.cpp b/libs/qec/python/bindings/py_decoder.cpp index 97184002a..3338b485f 100644 --- a/libs/qec/python/bindings/py_decoder.cpp +++ b/libs/qec/python/bindings/py_decoder.cpp @@ -195,7 +195,7 @@ class PyDecoder : public decoder { /// @brief Construct from a scipy sparse matrix (CSR, CSC, COO, ...) or a /// dense numpy array of any numeric dtype. PyDecoder(nb::object mat) - : decoder(decoder_inputs([&mat]() -> cudaq::qec::sparse_binary_matrix { + : decoder(decoder_init([&mat]() -> cudaq::qec::sparse_binary_matrix { // Any scipy sparse format exposes tocsr(); detect via that // rather than indptr/indices, which COO and some other // formats lack. @@ -918,7 +918,7 @@ void bindDecoder(nb::module_ &mod) { } const auto output = pop_requested_output(options); - auto inputs = decoder_inputs::from_stim_dem(dem_text); + auto inputs = decoder_init::from_stim_dem(dem_text); return output ? get_decoder(name, std::move(inputs), *output, hetMapFromKwargs(options)) : get_decoder(name, std::move(inputs), @@ -990,8 +990,8 @@ void bindDecoder(nb::module_ &mod) { " pip install cudaq-qec[tensor-network-decoder]\n"); } - decoder_inputs inputs(std::move(H_sparse), std::move(O_sparse), - std::move(error_rates)); + decoder_init inputs(std::move(H_sparse), std::move(O_sparse), + std::move(error_rates)); return output ? get_decoder(name, std::move(inputs), *output, hetMapFromKwargs(options)) : get_decoder(name, std::move(inputs), @@ -1009,12 +1009,12 @@ void bindDecoder(nb::module_ &mod) { ``cudaqx::tensor`` is built first, then converted to CSC sparse storage. For large PCMs this can allocate as much memory as ``rows * cols``. - A Stim detector error model string: native C++ decoders receive the - raw DEM text via ``decoder_inputs``; Python-registered decoders receive + raw DEM text via ``decoder_init``; Python-registered decoders receive the DEM-derived PCM plus ``O`` and ``error_rate_vec`` defaults. Native decoders may select their instance-default result with ``output="errors"`` or ``output="observables"``. Matrix ``O`` and - ``error_rate_vec`` keyword adapters are normalized into decoder_inputs; + ``error_rate_vec`` keyword adapters are normalized into decoder_init; O never selects the output mode. For Python-registered decoders (``cudaq.qec.decoder`` decorator), ``H`` diff --git a/libs/qec/python/tests/test_decoding_config.py b/libs/qec/python/tests/test_decoding_config.py index 579a03a72..22df6754f 100644 --- a/libs/qec/python/tests/test_decoding_config.py +++ b/libs/qec/python/tests/test_decoding_config.py @@ -768,7 +768,7 @@ def test_configure_invalid_decoders(): # --- exported JSON Schema: the two model sources ---------------------------- # # The schema must describe the language the runtime actually accepts. It keys -# the DEM source on a NON-EMPTY stim_dem_path, matching resolve_decoder_inputs. +# the DEM source on a NON-EMPTY stim_dem_path, matching resolve_decoder_init. def _decoder_doc(**overrides): doc = {"id": 0, "type": "pymatching", "D_sparse": [0, -1, 1, -1]} diff --git a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp index 025468425..2a8ec0bd6 100644 --- a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp +++ b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp @@ -615,10 +615,9 @@ TEST(QECCodeTester, checkRealtimeDecodeFromMemoryCircuit) { auto ctx = cudaq::qec::decoder_context_from_memory_circuit( *steane, cudaq::qec::operation::prep0, nRounds, noise); auto inputs = ctx.full_component(); - auto dem = inputs.materialize_detector_error_model(); - const auto *D = inputs.measurement_to_detectors(); - ASSERT_NE(D, nullptr); - const auto m2d_rows = D->to_nested_csr(); + const auto &dem = inputs.dem; + const auto D = cudaq::qec::m2d_to_sparse(inputs.m2d); + const auto m2d_rows = D.to_nested_csr(); ASSERT_FALSE(m2d_rows.empty()); EXPECT_EQ(m2d_rows.size(), dem.num_detectors()); @@ -633,11 +632,13 @@ TEST(QECCodeTester, checkRealtimeDecodeFromMemoryCircuit) { } EXPECT_LT(minRow, maxRow); - // The decoder_inputs returned by full_component() already carry O and D, so - // construction configures the realtime path completely. There is no second - // step, and nothing to re-supply. - auto decoder = cudaq::qec::get_decoder("single_error_lut", inputs); - ASSERT_EQ(decoder->get_num_msyn_per_decode(), D->num_cols()); + // Circuit analysis produces the model; decoder_init is what a decoder is + // built from. Bridging the two here is one expression, and it carries O and + // D, so construction configures the realtime path completely. There is no + // second step, and nothing to re-supply. + auto decoder = cudaq::qec::get_decoder( + "single_error_lut", cudaq::qec::decoder_init(dem, D)); + ASSERT_EQ(decoder->get_num_msyn_per_decode(), D.num_cols()); // Stream numCols ancilla per round, then the final data readout. The window // must not decode until that last chunk completes it. @@ -684,7 +685,7 @@ TEST(QECCodeTester, checkDecoderContextAndComponents) { // full_component() matches the plain entry point. auto fc_inputs = ctx.full_component(); - auto fc_dem = fc_inputs.materialize_detector_error_model(); + const auto &fc_dem = fc_inputs.dem; EXPECT_TRUE( tensors_equal(fc_dem.detector_error_matrix, dem.detector_error_matrix)); @@ -692,17 +693,15 @@ TEST(QECCodeTester, checkDecoderContextAndComponents) { // the detectors without re-running dem_from_kernel. auto zc_inputs = ctx.z_component(); auto xc_inputs = ctx.x_component(); - auto zc_dem = zc_inputs.materialize_detector_error_model(); - auto xc_dem = xc_inputs.materialize_detector_error_model(); + const auto &zc_dem = zc_inputs.dem; + const auto &xc_dem = xc_inputs.dem; EXPECT_TRUE( tensors_equal(zc_dem.detector_error_matrix, z.detector_error_matrix)); EXPECT_TRUE( tensors_equal(xc_dem.detector_error_matrix, x.detector_error_matrix)); EXPECT_EQ(zc_dem.num_detectors() + xc_dem.num_detectors(), fc_dem.num_detectors()); - ASSERT_NE(zc_inputs.measurement_to_detectors(), nullptr); - EXPECT_EQ(zc_inputs.measurement_to_detectors()->num_rows(), - zc_dem.num_detectors()); + EXPECT_EQ(zc_inputs.m2d.rows.size(), zc_dem.num_detectors()); } TEST(QECCodeTester, checkDemFromMemoryCircuit) { @@ -1109,7 +1108,7 @@ TEST(QECCodeTester, checkSlidingWindowShor9Boundary) { cudaq::qec::decoder::get("single_error_lut", dem.detector_error_matrix); // A single window spanning all layers -- should match the full decoder. auto sw = cudaq::qec::decoder::get( - "sliding_window", cudaq::qec::decoder_inputs{dem}, + "sliding_window", cudaq::qec::decoder_init{dem}, shor9_sliding_params(num_layers, interior, numBoundary)); expectObservablesMatchFullDecoder( @@ -1152,7 +1151,7 @@ TEST(QECCodeTester, checkSlidingWindowShor9Streaming) { cudaq::qec::decoder::get("single_error_lut", dem.detector_error_matrix); // A genuinely sliding configuration: window of 2 rounds, stepping by 1. auto sw = cudaq::qec::decoder::get( - "sliding_window", cudaq::qec::decoder_inputs{dem}, + "sliding_window", cudaq::qec::decoder_init{dem}, shor9_sliding_params(/*window_size=*/2, interior, numBoundary)); expectObservablesMatchFullDecoder( @@ -1258,7 +1257,7 @@ TEST(QECCodeTester, checkSlidingWindowRealtimeBoundaryStreaming) { num_measurements = std::max(num_measurements, col + 1); return cudaq::qec::decoder::get( "sliding_window", - cudaq::qec::decoder_inputs{ + cudaq::qec::decoder_init{ dem, cudaq::qec::sparse_binary_matrix::from_nested_csr( static_cast(D_sparse.size()), num_measurements, D_sparse)}, diff --git a/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp index a85ab66d4..7d5b130cf 100644 --- a/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp +++ b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp @@ -54,12 +54,12 @@ TEST(ChromobiusDecoder, checkAllZeroSyndrome) { EXPECT_EQ(decoder->get_syndrome_size(), 4); EXPECT_EQ(decoder->get_result_type(), cudaq::qec::decode_result_type::observables); - EXPECT_THROW((void)cudaq::qec::decoder::get( - "chromobius", - cudaq::qec::decoder_inputs::from_stim_dem( - std::string{chromobius_dem}), - cudaq::qec::decode_result_type::errors, make_params()), - std::invalid_argument); + EXPECT_THROW( + (void)cudaq::qec::decoder::get( + "chromobius", + cudaq::qec::decoder_init::from_stim_dem(std::string{chromobius_dem}), + cudaq::qec::decode_result_type::errors, make_params()), + std::invalid_argument); } TEST(ChromobiusDecoder, checkKnownObservableFlip) { diff --git a/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp b/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp index c800ef4d0..ee0c5c716 100644 --- a/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp +++ b/libs/qec/unittests/decoders/pymatching/test_pymatching.cpp @@ -171,7 +171,7 @@ TEST(PyMatchingDecoder, AcceptsAllMergeStrategiesAndRejectsUnknown) { auto O = cudaq::qec::sparse_binary_matrix::from_nested_csr(1, 1, {{0}}); d = cudaq::qec::decoder::get( "pymatching", - cudaq::qec::decoder_inputs(std::move(sparse_H), std::move(O)), + cudaq::qec::decoder_init(std::move(sparse_H), std::move(O)), cudaq::qec::decode_result_type::observables, params); } ASSERT_NE(d, nullptr) << strategy; @@ -202,8 +202,8 @@ TEST(PyMatchingDecoder, ErrorOutputTracksMergedParallelEdgeColumn) { params.insert("merge_strategy", strategy); auto O = cudaq::qec::sparse_binary_matrix::from_csr( 0, 2, std::vector{0}, {}); - auto inputs = cudaq::qec::decoder_inputs( - cudaq::qec::sparse_binary_matrix(H), std::move(O), {0.1, 0.2}); + auto inputs = cudaq::qec::decoder_init(cudaq::qec::sparse_binary_matrix(H), + std::move(O), {0.1, 0.2}); auto decoder = cudaq::qec::decoder::get( "pymatching", std::move(inputs), cudaq::qec::decode_result_type::errors, params); @@ -229,8 +229,8 @@ TEST(PyMatchingDecoder, RejectsObservableMatrixWithWrongBlockSize) { cudaqx::tensor O({1, 3}); O.at({0, 0}) = 1; EXPECT_THROW( - (void)cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H), - cudaq::qec::sparse_binary_matrix(O)), + (void)cudaq::qec::decoder_init(cudaq::qec::sparse_binary_matrix(H), + cudaq::qec::sparse_binary_matrix(O)), std::invalid_argument); } @@ -249,8 +249,8 @@ TEST(PyMatchingDecoder, DecodesHighObservableIndicesAcrossPaths) { auto d = cudaq::qec::decoder::get( "pymatching", - cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H), - cudaq::qec::sparse_binary_matrix(O)), + cudaq::qec::decoder_init(cudaq::qec::sparse_binary_matrix(H), + cudaq::qec::sparse_binary_matrix(O)), cudaq::qec::decode_result_type::observables); // ASSERT: valid graph-like identity matrices must construct a decoder. ASSERT_NE(d, nullptr); diff --git a/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp b/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp index 662dc592f..d6f0b2ede 100644 --- a/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp +++ b/libs/qec/unittests/decoders/pymatching/test_pymatching_realtime.cpp @@ -42,7 +42,7 @@ DecoderVec make_pymatching_decoders(const std::vector &h_vec, auto decoder = cudaq::qec::decoder::get( "pymatching", - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( cudaq::qec::sparse_binary_matrix(h), cudaq::qec::sparse_binary_matrix::from_nested_csr( static_cast(block_size), diff --git a/libs/qec/unittests/decoders/sample_decoder.cpp b/libs/qec/unittests/decoders/sample_decoder.cpp index 1c090b246..a98c9015c 100644 --- a/libs/qec/unittests/decoders/sample_decoder.cpp +++ b/libs/qec/unittests/decoders/sample_decoder.cpp @@ -18,7 +18,7 @@ namespace cudaq::qec { /// bare bones custom decoder based on the `cudaq::qec::decoder` interface. class sample_decoder : public decoder { public: - sample_decoder(cudaq::qec::decoder_inputs inputs, + sample_decoder(cudaq::qec::decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { @@ -52,7 +52,7 @@ class sample_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( sample_decoder, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( diff --git a/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp b/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp index cce7229b8..f3128e16d 100644 --- a/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp +++ b/libs/qec/unittests/decoders/trt_decoder/test_trt_decoder.cpp @@ -42,7 +42,7 @@ cudaqx::tensor make_identity_h(std::size_t n) { return H; } -decoder_inputs +decoder_init make_inputs_with_empty_observables(const cudaqx::tensor &H, std::size_t num_observables) { auto sparse_H = sparse_binary_matrix(H); @@ -50,7 +50,7 @@ make_inputs_with_empty_observables(const cudaqx::tensor &H, auto O = sparse_binary_matrix::from_csr( static_cast(num_observables), sparse_H.num_cols(), std::move(row_ptrs), {}); - return decoder_inputs(std::move(sparse_H), std::move(O)); + return decoder_init(std::move(sparse_H), std::move(O)); } std::filesystem::path make_temp_engine_path(const std::string &name) { @@ -799,7 +799,7 @@ TEST_F(TRTDecoderTest, NestsChromobiusPreservingRawDem) { std::ifstream dem_file(dem_path); std::string dem_text((std::istreambuf_iterator(dem_file)), std::istreambuf_iterator()); - auto inputs = decoder_inputs::from_stim_dem(dem_text); + auto inputs = decoder_init::from_stim_dem(dem_text); ASSERT_TRUE(inputs.has_stim_dem()); ASSERT_EQ(inputs.num_detectors(), 2u); ASSERT_EQ(inputs.num_observables(), 1u); @@ -837,8 +837,8 @@ TEST_F(TRTDecoderTest, NestsChromobiusPreservingRawDem) { cudaqx::tensor O({1, 3}); O.at({0, 0}) = 1; EXPECT_THROW((void)decoder::get("trt_decoder", - decoder_inputs(sparse_binary_matrix(H), - sparse_binary_matrix(O)), + decoder_init(sparse_binary_matrix(H), + sparse_binary_matrix(O)), decode_result_type::observables, params), std::runtime_error); } @@ -870,7 +870,7 @@ TEST_F(TRTDecoderTest, CompositeGlobalDecoderCombinesLogicalFrame) { try { trt_decoder = decoder::get( "trt_decoder", - decoder_inputs(sparse_binary_matrix(H), sparse_binary_matrix(O)), + decoder_init(sparse_binary_matrix(H), sparse_binary_matrix(O)), decode_result_type::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create composite TRT decoder: " << e.what(); diff --git a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp index 26e41a57f..04daf1301 100644 --- a/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp +++ b/libs/qec/unittests/realtime/app_examples/concurrency_test_decoder.cpp @@ -86,7 +86,7 @@ reusable_decode_barrier &decode_barrier() { /// subsequent decode rendezvous with all configured instances before returning. class concurrency_test_decoder : public decoder { public: - concurrency_test_decoder(decoder_inputs inputs, + concurrency_test_decoder(decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), requested_output) { @@ -114,7 +114,7 @@ class concurrency_test_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( concurrency_test_decoder, static std::unique_ptr create( - decoder_inputs inputs, std::optional output, + decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( std::move(inputs), output.value_or(decode_result_type::observables), diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp index 93f3aa9de..c9cad601f 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp @@ -311,9 +311,8 @@ build_multi_decoder_config(const cudaq::qec::decoder_inputs &inputs, std::size_t num_boundary_syndromes, const run_options &opts) { namespace config = cudaq::qec::decoding::config; - const auto dem = inputs.materialize_detector_error_model(); - const auto d_sparse = - cudaq::qec::d_sparse(*inputs.measurement_to_detectors()); + const auto &dem = inputs.dem; + const auto d_sparse = cudaq::qec::d_sparse(inputs.m2d); config::multi_decoder_config multi_config; for (int i = 0; i < opts.num_logical; i++) { @@ -534,7 +533,7 @@ bool setup_decoders(const cudaq::qec::code &code, } // Characterize the DEM and build the decoder configuration. full_component() - // canonicalizes both stabilizer types (boundary-aware) into decoder_inputs. + // canonicalizes both stabilizer types (boundary-aware) into decoder_init. const std::string &leaf_decoder = opts.decoder_type == "sliding_window" ? opts.sw_inner_decoder : opts.decoder_type; @@ -542,8 +541,8 @@ bool setup_decoders(const cudaq::qec::code &code, auto ctx = cudaq::qec::decoder_context_from_memory_circuit( code, state_prep, opts.num_rounds, noise, decompose_errors); const auto inputs = ctx.full_component(); - printf("DEM: %ld detectors x %ld error mechanisms\n", inputs.num_detectors(), - inputs.num_error_mechanisms()); + printf("DEM: %ld detectors x %ld error mechanisms\n", + inputs.dem.num_detectors(), inputs.dem.num_error_mechanisms()); const bool is_z_prep = state_prep == cudaq::qec::operation::prep0 || state_prep == cudaq::qec::operation::prep1; diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp index 4fd60c7c5..b720dd674 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp @@ -316,8 +316,8 @@ static void enforce_ising_metadata(const std::string &bundle, int distance, // same measurement span. The error-column representations are intentionally // not probabilistically identical. void save_dem_to_file( - const std::vector &matching_inputs, - const std::vector &bp_inputs, + const std::vector &matching_inputs, + const std::vector &bp_inputs, std::string dem_filename, const std::vector &decoder_types, bool use_relay_bp, const std::string &onnx_path, bool use_ising, const std::string &ising_artifacts_dir) { @@ -975,8 +975,8 @@ void demo_circuit_host(const cudaq::qec::code &code, int distance, contains_type("pymatching") || contains_type("trt_decoder"); const bool haveBp = contains_type("nv-qldpc-decoder"); const bool decompose_errors = haveMatching; - std::vector matching_inputs; - std::vector bp_inputs; + std::vector matching_inputs; + std::vector bp_inputs; matching_inputs.reserve(numLogical); bp_inputs.reserve(numLogical); const bool dual_parse = haveMatching && haveBp; diff --git a/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp b/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp index fca8fa8f1..1baa01e62 100644 --- a/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp +++ b/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp @@ -98,7 +98,7 @@ LoadedDecoder load_decoder_from_yaml(const std::string &yaml_path) { std::count(dec.O_sparse.begin(), dec.O_sparse.end(), -1)); auto plugin = decoder::get( "nv-qldpc-decoder", - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( cudaq::qec::sparse_binary_matrix(H_tensor), sparse_matrix_from_flat_rows(dec.O_sparse, num_observables), /*error_rates=*/{}, diff --git a/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp b/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp index 26e5225a2..88d9cd947 100644 --- a/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp +++ b/libs/qec/unittests/realtime/test_realtime_predecoder_w_pymatching.cpp @@ -294,8 +294,8 @@ int main(int argc, char *argv[]) { ? stim.priors : std::vector{}; auto inputs = - cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H_full), - std::move(O), std::move(rates)); + cudaq::qec::decoder_init(cudaq::qec::sparse_binary_matrix(H_full), + std::move(O), std::move(rates)); std::cout << "[Setup] Creating " << config.num_decode_workers << " PyMatching decoders (full H)...\n"; diff --git a/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp b/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp index 6e34de0fa..ff14c1a80 100644 --- a/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp +++ b/libs/qec/unittests/realtime/test_trt_decoder_composite.cpp @@ -352,8 +352,8 @@ DecoderSetup create_decoder_from_yaml(const DemoConfig &demo_cfg) { setup.decoder = cudaq::qec::decoder::get( decoder_config.type, - cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(setup.H), - cudaq::qec::sparse_binary_matrix(O)), + cudaq::qec::decoder_init(cudaq::qec::sparse_binary_matrix(setup.H), + cudaq::qec::sparse_binary_matrix(O)), cudaq::qec::decode_result_type::observables, setup.trt_params); return setup; } @@ -407,9 +407,9 @@ DecoderSetup create_decoder_from_cli(const PipelineConfig &config, setup.decoder = cudaq::qec::decoder::get( "trt_decoder", - cudaq::qec::decoder_inputs(cudaq::qec::sparse_binary_matrix(H), - cudaq::qec::sparse_binary_matrix(O), - stim.priors), + cudaq::qec::decoder_init(cudaq::qec::sparse_binary_matrix(H), + cudaq::qec::sparse_binary_matrix(O), + stim.priors), cudaq::qec::decode_result_type::observables, setup.trt_params); return setup; } diff --git a/libs/qec/unittests/test_decoders.cpp b/libs/qec/unittests/test_decoders.cpp index a9f7f62f9..c4df74a5f 100644 --- a/libs/qec/unittests/test_decoders.cpp +++ b/libs/qec/unittests/test_decoders.cpp @@ -8,7 +8,7 @@ #include "stim.h" #include "cudaq/qec/decoder.h" -#include "cudaq/qec/decoder_inputs.h" +#include "cudaq/qec/decoder_init.h" #include "cudaq/qec/detector_error_model.h" #include "cudaq/qec/pcm_utils.h" #include @@ -22,9 +22,9 @@ #include namespace { -class decoder_inputs_probe final : public cudaq::qec::decoder { +class decoder_init_probe final : public cudaq::qec::decoder { public: - explicit decoder_inputs_probe(cudaq::qec::decoder_inputs inputs) + explicit decoder_init_probe(cudaq::qec::decoder_init inputs) : decoder(std::move(inputs), cudaq::qec::decode_result_type::errors) {} cudaq::qec::decoder_result @@ -38,7 +38,7 @@ class decoder_inputs_probe final : public cudaq::qec::decoder { class observable_output_probe final : public cudaq::qec::decoder { public: - observable_output_probe(cudaq::qec::decoder_inputs inputs, + observable_output_probe(cudaq::qec::decoder_init inputs, cudaq::qec::decode_result_type requested_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), requested_output) {} @@ -51,7 +51,7 @@ class observable_output_probe final : public cudaq::qec::decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( observable_output_probe, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( @@ -90,8 +90,8 @@ TEST(DecoderInputs, PreservesMatrixShapesAndMeasurementMap) { auto O = matrix::from_nested_csr(3, 3, {{0}, {}, {2}}); auto D = matrix::from_nested_csr(2, 5, {{0, 1}, {2, 3}}); - cudaq::qec::decoder_inputs inputs(std::move(H), std::move(O), {0.1, 0.2, 0.3}, - std::move(D)); + cudaq::qec::decoder_init inputs(std::move(H), std::move(O), {0.1, 0.2, 0.3}, + std::move(D)); EXPECT_EQ(inputs.source(), cudaq::qec::decoder_model_source::matrices); EXPECT_EQ(inputs.num_detectors(), 2); @@ -124,8 +124,8 @@ TEST(DecoderInputs, BaseConstructionSizesRealtimeStateFromTheModel) { auto O = matrix::from_nested_csr(3, 3, {{0}, {}, {2}}); auto D = matrix::from_nested_csr(2, 5, {{0, 1}, {2, 3}}); - decoder_inputs_probe decoder( - cudaq::qec::decoder_inputs(std::move(H), std::move(O), {}, std::move(D))); + decoder_init_probe decoder( + cudaq::qec::decoder_init(std::move(H), std::move(O), {}, std::move(D))); // Construction derives every realtime size from the model. Nothing arrives // later, so a decoder is usable the moment it exists. @@ -146,7 +146,7 @@ TEST(DecoderInputs, RawStimRemainsAuthoritative) { const std::string dem_text = "error(0.1) D0 L0\n" "error(0.2) D1\n"; - auto inputs = cudaq::qec::decoder_inputs::from_stim_dem(dem_text); + auto inputs = cudaq::qec::decoder_init::from_stim_dem(dem_text); EXPECT_EQ(inputs.source(), cudaq::qec::decoder_model_source::stim_dem); ASSERT_TRUE(inputs.has_stim_dem()); @@ -163,28 +163,28 @@ TEST(DecoderInputs, RejectsInconsistentDimensions) { using matrix = cudaq::qec::sparse_binary_matrix; auto H = matrix::from_nested_csc(2, 3, {{0}, {0, 1}, {1}}); - EXPECT_THROW(cudaq::qec::decoder_inputs( - H, matrix::from_nested_csr(1, 2, {{0}}), {0.1, 0.2, 0.3}), + EXPECT_THROW(cudaq::qec::decoder_init(H, matrix::from_nested_csr(1, 2, {{0}}), + {0.1, 0.2, 0.3}), std::invalid_argument); - EXPECT_THROW(cudaq::qec::decoder_inputs( - H, matrix::from_nested_csr(1, 3, {{0}}), {0.1, 0.2}), + EXPECT_THROW(cudaq::qec::decoder_init(H, matrix::from_nested_csr(1, 3, {{0}}), + {0.1, 0.2}), std::invalid_argument); - EXPECT_THROW(cudaq::qec::decoder_inputs( - H, matrix::from_nested_csr(1, 3, {{0}}), {0.1, 0.2, 0.3}, - matrix::from_nested_csr(1, 4, {{0}})), + EXPECT_THROW(cudaq::qec::decoder_init(H, matrix::from_nested_csr(1, 3, {{0}}), + {0.1, 0.2, 0.3}, + matrix::from_nested_csr(1, 4, {{0}})), std::invalid_argument); - EXPECT_THROW(cudaq::qec::decoder_inputs( - H, matrix::from_nested_csr(1, 3, {{0}}), {0.1, 0.2, 0.3}, - std::nullopt, std::vector{0, 1}), + EXPECT_THROW(cudaq::qec::decoder_init(H, matrix::from_nested_csr(1, 3, {{0}}), + {0.1, 0.2, 0.3}, std::nullopt, + std::vector{0, 1}), std::invalid_argument); } TEST(DecoderInputs, DerivationsKeepRawSourceAndFreshMatricesDoNot) { - auto inputs = cudaq::qec::decoder_inputs::from_stim_dem( + auto inputs = cudaq::qec::decoder_init::from_stim_dem( "error(0.1) D0 L0\n", cudaq::qec::sparse_binary_matrix::from_nested_csr(1, 2, {{0, 1}})); - auto without_d = inputs.decoder_inputs_without_d(); + auto without_d = inputs.decoder_init_without_d(); EXPECT_TRUE(without_d.has_stim_dem()); EXPECT_EQ(without_d.stim_dem(), inputs.stim_dem()); EXPECT_EQ(without_d.measurement_to_detectors(), nullptr); @@ -196,7 +196,7 @@ TEST(DecoderInputs, DerivationsKeepRawSourceAndFreshMatricesDoNot) { // A caller that re-indexes detectors or errors builds fresh inputs, which // carry no raw source: the DEM text names the original detectors. - cudaq::qec::decoder_inputs reindexed( + cudaq::qec::decoder_init reindexed( cudaq::qec::sparse_binary_matrix::from_nested_csc(1, 1, {{0}}), cudaq::qec::sparse_binary_matrix::from_nested_csr(0, 1, {}), {0.1}); EXPECT_FALSE(reindexed.has_stim_dem()); @@ -209,8 +209,7 @@ TEST(DecoderOutputContract, OutputFormIsImmutablePerInstance) { auto O = cudaq::qec::sparse_binary_matrix::from_nested_csr( 1, 2, std::vector>{{0}}); auto decoder = cudaq::qec::get_decoder( - "single_error_lut", - cudaq::qec::decoder_inputs(std::move(H), std::move(O)), + "single_error_lut", cudaq::qec::decoder_init(std::move(H), std::move(O)), cudaq::qec::decode_result_type::observables); EXPECT_EQ(decoder->get_result_type(), @@ -222,7 +221,7 @@ TEST(DecoderOutputContract, OutputFormIsImmutablePerInstance) { auto error_decoder = cudaq::qec::get_decoder( "single_error_lut", - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( cudaq::qec::sparse_binary_matrix::from_nested_csc( 2, 2, std::vector>{{0}, {1}}), cudaq::qec::sparse_binary_matrix::from_nested_csr( @@ -394,7 +393,7 @@ TEST(SampleDecoder, RealtimeApiAndDefaultGraphHooks) { // The whole model up front: three observables, and a D mapping three // measurement bits onto the two detectors. auto decoder = cudaq::qec::decoder::get( - "sample_decoder", cudaq::qec::decoder_inputs( + "sample_decoder", cudaq::qec::decoder_init( cudaq::qec::sparse_binary_matrix(H), cudaq::qec::sparse_binary_matrix::from_nested_csr( 3, block_size, {{0}, {}, {2}}), @@ -710,8 +709,8 @@ void SlidingWindowDecoderTest(bool run_batched, std::size_t n_rounds, 0, sliding_H.num_cols(), {0}, {}); auto sliding_window_decoder = cudaq::qec::decoder::get( "sliding_window", - cudaq::qec::decoder_inputs(std::move(sliding_H), std::move(sliding_O), - simplified_weights), + cudaq::qec::decoder_init(std::move(sliding_H), std::move(sliding_O), + simplified_weights), sliding_window_params); // Create some random syndromes. @@ -864,8 +863,8 @@ TEST(SlidingWindowDecoder, EmptyBatchReturnsNoResults) { auto O = cudaq::qec::sparse_binary_matrix::from_csr(0, H.num_cols(), {0}, {}); auto decoder = cudaq::qec::decoder::get( "sliding_window", - cudaq::qec::decoder_inputs(std::move(H), std::move(O), - std::vector(pcm.shape()[1], 0.1)), + cudaq::qec::decoder_init(std::move(H), std::move(O), + std::vector(pcm.shape()[1], 0.1)), params); ASSERT_NE(decoder, nullptr); EXPECT_TRUE(decoder->decode_batch({}).empty()); @@ -891,8 +890,8 @@ TEST(SlidingWindowDecoder, PerRoundStreamingUsesRollingWindowUnwrap) { auto O = cudaq::qec::sparse_binary_matrix::from_csr(0, H.num_cols(), {0}, {}); auto decoder = cudaq::qec::decoder::get( "sliding_window", - cudaq::qec::decoder_inputs(std::move(H), std::move(O), - std::vector(pcm.shape()[1], 0.1)), + cudaq::qec::decoder_init(std::move(H), std::move(O), + std::vector(pcm.shape()[1], 0.1)), params); ASSERT_NE(decoder, nullptr); @@ -1145,7 +1144,7 @@ TEST(StimDemGetDecoder, StillAcceptsParityCheckMatrix) { } // The handle a decoder actually receives must describe exactly the matrices the -// materialized model does. Compared through decoder_inputs rather than the +// materialized model does. Compared through decoder_init rather than the // internal projection helper, so a mistake wiring the projection into the // handle cannot pass. The sparse path exists to skip a dense intermediate -- // a distance-13 dense H is ~98 MiB against under 1 MiB sparse -- not to mean @@ -1159,7 +1158,7 @@ error(0.1) D1 D1 D2 )"; const auto dense = cudaq::qec::dem_from_stim_text(dem_text); - auto inputs = cudaq::qec::decoder_inputs::from_stim_dem(dem_text); + auto inputs = cudaq::qec::decoder_init::from_stim_dem(dem_text); EXPECT_EQ(inputs.num_detectors(), dense.num_detectors()); EXPECT_EQ(inputs.num_error_mechanisms(), dense.num_error_mechanisms()); @@ -1358,7 +1357,7 @@ TEST(EnqueueSyndrome, ObsFrameDecoderUsesResultDirectly) { auto dec = cudaq::qec::decoder::get( "observable_output_probe", // D maps the two enqueued syndrome bits directly to two detector bits. - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( std::move(H), std::move(O), /*error_rates=*/{}, cudaq::qec::sparse_binary_matrix::from_nested_csr(2, 2, {{0}, {1}})), cudaq::qec::decode_result_type::observables); @@ -1382,7 +1381,7 @@ TEST(EnqueueSyndrome, ObsFrameMultiShotAccumulation) { 2, 4, std::vector>{{0}, {1}}); auto dec = cudaq::qec::decoder::get( "observable_output_probe", - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( std::move(H), std::move(O), /*error_rates=*/{}, cudaq::qec::sparse_binary_matrix::from_nested_csr(2, 2, {{0}, {1}})), cudaq::qec::decode_result_type::observables); @@ -1417,7 +1416,7 @@ TEST(EnqueueSyndrome, ObsFrameSizeMismatchThrows) { 2, 4, std::vector>{{0}, {1}}); auto dec = cudaq::qec::decoder::get( "observable_output_probe", - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( std::move(H), std::move(O), /*error_rates=*/{}, cudaq::qec::sparse_binary_matrix::from_nested_csr(3, 3, {{0}, {1}, {2}})), @@ -1456,7 +1455,7 @@ TEST(SlidingWindowDecoder, BaseStreamingCopiesFirstRoundDetectors) { m2d[1] = {1}; auto decoder = cudaq::qec::decoder::get( "sliding_window", - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( std::move(H), std::move(O), std::vector(pcm.shape()[1], 0.1), cudaq::qec::sparse_binary_matrix::from_nested_csr( static_cast(m2d.size()), 2, m2d)), @@ -1494,8 +1493,8 @@ TEST(SlidingWindowDecoder, PreparePcmRejectsBadBoundaryLayout) { 0, sparse_H.num_cols(), {0}, {}); cudaq::qec::decoder::get( "sliding_window", - cudaq::qec::decoder_inputs(std::move(sparse_H), std::move(O), - std::vector(H.shape()[1], 0.1)), + cudaq::qec::decoder_init(std::move(sparse_H), std::move(O), + std::vector(H.shape()[1], 0.1)), p); FAIL() << "expected sliding_window construction to throw"; } catch (const std::invalid_argument &e) { @@ -1545,7 +1544,7 @@ class ScopedDeviceRestore { /// proving decoder::get() strips cuda_device_id before the plugin ctor. class strict_keys_decoder : public cudaq::qec::decoder { public: - strict_keys_decoder(cudaq::qec::decoder_inputs inputs, + strict_keys_decoder(cudaq::qec::decoder_init inputs, cudaq::qec::decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) : decoder(std::move(inputs), requested_output) { @@ -1565,7 +1564,7 @@ class strict_keys_decoder : public cudaq::qec::decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( strict_keys_decoder, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( @@ -1585,7 +1584,7 @@ cudaq::qec::sparse_binary_matrix make_test_H() { class device_recording_decoder : public cudaq::qec::decoder { public: std::atomic last_decode_device{-2}; - device_recording_decoder(cudaq::qec::decoder_inputs inputs, + device_recording_decoder(cudaq::qec::decoder_init inputs, cudaq::qec::decode_result_type requested_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), requested_output) {} @@ -1603,7 +1602,7 @@ class device_recording_decoder : public cudaq::qec::decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( device_recording_decoder, static std::unique_ptr create( - cudaq::qec::decoder_inputs inputs, + cudaq::qec::decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( diff --git a/libs/qec/unittests/test_decoders_yaml.cpp b/libs/qec/unittests/test_decoders_yaml.cpp index c43d16b13..a6f3e11fe 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -57,7 +57,7 @@ struct construction_d_probe { class d_capture_decoder : public decoder { public: - d_capture_decoder(decoder_inputs inputs, decode_result_type requested_output, + d_capture_decoder(decoder_init inputs, decode_result_type requested_output, const cudaqx::heterogeneous_map &) : decoder(std::move(inputs), requested_output) { const auto &in = get_inputs(); @@ -95,7 +95,7 @@ class d_capture_decoder : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( d_capture_decoder, static std::unique_ptr create( - decoder_inputs inputs, std::optional output, + decoder_init inputs, std::optional output, const cudaqx::heterogeneous_map ¶ms) { return std::make_unique( std::move(inputs), output.value_or(decode_result_type::observables), @@ -815,7 +815,7 @@ TEST(DecoderConfigTest, CreateRealtimeDecoderConfiguresRuntimeState) { auto config = create_test_sample_realtime_decoder_config(7); auto decoder = cudaq::qec::decoding::host::create_realtime_decoder( - config, cudaq::qec::decoding::host::resolve_decoder_inputs( + config, cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path())); ASSERT_NE(decoder, nullptr); @@ -842,7 +842,7 @@ TEST(DecoderConfigTest, DuplicateDetectorIndicesCollapseInConstructionInputs) { config.to_yaml_str(200)); auto decoder = cudaq::qec::decoding::host::create_realtime_decoder( - parsed, cudaq::qec::decoding::host::resolve_decoder_inputs( + parsed, cudaq::qec::decoding::host::resolve_decoder_init( parsed, std::filesystem::current_path())); ASSERT_NE(decoder, nullptr); @@ -878,7 +878,7 @@ TEST(ResolveDecoderInputs, DemSourceCarriesRawProvenanceAndDerivedSizes) { ScopedDemFile dem; auto config = make_dem_config(dem.path()); - auto inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( + auto inputs = cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path()); // The DEM stays authoritative, so a DEM-native decoder can read it back. @@ -899,24 +899,24 @@ TEST(ResolveDecoderInputs, DemSourceRejectsCompetingMatrixKeys) { auto with_H = make_dem_config(dem.path()); with_H.H_sparse = {0, -1, 1, -1}; - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs(with_H, cwd), + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init(with_H, cwd), std::runtime_error); auto with_O = make_dem_config(dem.path()); with_O.O_sparse = {0, -1}; - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs(with_O, cwd), + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init(with_O, cwd), std::runtime_error); auto with_rates = make_dem_config(dem.path()); with_rates.error_rate_vec = {0.1, 0.1, 0.1}; EXPECT_THROW( - cudaq::qec::decoding::host::resolve_decoder_inputs(with_rates, cwd), + cudaq::qec::decoding::host::resolve_decoder_init(with_rates, cwd), std::runtime_error); } TEST(ResolveDecoderInputs, DemSourceRejectsUnreadableFile) { auto config = make_dem_config("/nonexistent/definitely-not-here.dem"); - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path()), std::runtime_error); } @@ -930,18 +930,18 @@ TEST(ResolveDecoderInputs, DemSourceTreatsSuppliedSizesAsAssertions) { matching.syndrome_size = 2; matching.block_size = 3; EXPECT_NO_THROW( - cudaq::qec::decoding::host::resolve_decoder_inputs(matching, cwd)); + cudaq::qec::decoding::host::resolve_decoder_init(matching, cwd)); auto wrong_detectors = make_dem_config(dem.path()); wrong_detectors.syndrome_size = 99; EXPECT_THROW( - cudaq::qec::decoding::host::resolve_decoder_inputs(wrong_detectors, cwd), + cudaq::qec::decoding::host::resolve_decoder_init(wrong_detectors, cwd), std::runtime_error); auto wrong_mechanisms = make_dem_config(dem.path()); wrong_mechanisms.block_size = 99; EXPECT_THROW( - cudaq::qec::decoding::host::resolve_decoder_inputs(wrong_mechanisms, cwd), + cudaq::qec::decoding::host::resolve_decoder_init(wrong_mechanisms, cwd), std::runtime_error); } @@ -952,9 +952,9 @@ TEST(ResolveDecoderInputs, DemSourceResolvesRelativePathAgainstBaseDir) { // Against the containing directory it resolves; against an unrelated one it // does not, which is what makes the base directory meaningful. - EXPECT_NO_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + EXPECT_NO_THROW(cudaq::qec::decoding::host::resolve_decoder_init( config, dem.path().parent_path())); - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init( config, "/definitely/not/the/right/place"), std::runtime_error); } @@ -962,7 +962,7 @@ TEST(ResolveDecoderInputs, DemSourceResolvesRelativePathAgainstBaseDir) { TEST(ResolveDecoderInputs, MatrixSourceStillRequiresItsDimensions) { auto config = create_test_empty_decoder_config(0); config.block_size = 0; - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path()), std::runtime_error); } @@ -973,7 +973,7 @@ TEST(ResolveDecoderInputs, MatrixSourceRequiresAnObservableMapping) { // The realtime path returns observable corrections, so a model with no // observable mapping cannot serve it. Without this it constructed happily // and decoded to a zero-length observable frame. - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path()), std::runtime_error); } @@ -1083,7 +1083,7 @@ TEST(DecodingServerAcceptance, -1, 4, -1, 5, -1, 6, -1, 7, -1, 8, -1}; // Server path: resolve the configuration, then construct through the factory. - auto server_inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( + auto server_inputs = cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path()); auto server_decoder = cudaq::qec::decoding::host::create_realtime_decoder( config, server_inputs); @@ -1119,7 +1119,7 @@ TEST(DecodingServerAcceptance, static_cast(offline_d_rows.size()), offline_measurements, offline_d_rows) .canonicalize(); - cudaq::qec::decoder_inputs offline_inputs( + cudaq::qec::decoder_init offline_inputs( std::move(offline_H), std::move(offline_O), config.error_rate_vec, std::move(offline_D)); @@ -1154,7 +1154,7 @@ TEST(DecodingServerAcceptance, MatrixSourcePluginWorksOfflineAndOnServer) { finalize_decoders(); // Offline route, same plugin and the same resolved model. - auto inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( + auto inputs = cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path()); auto offline = cudaq::qec::decoder::get( config.type, inputs, cudaq::qec::decode_result_type::errors); @@ -1269,13 +1269,13 @@ TEST(ResolveDecoderInputs, DetectorMapIndicesMustBeRepresentable) { // fits the sparse index type. Narrowing would alias onto a real measurement. config.D_sparse = { static_cast(std::numeric_limits::max()), -1}; - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path()), std::runtime_error); auto negative = create_test_empty_decoder_config(0); negative.D_sparse = {-2, -1}; - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init( negative, std::filesystem::current_path()), std::runtime_error); } @@ -1325,7 +1325,7 @@ TEST(DecodingServerAcceptance, // The model is genuinely unreachable from the working directory, so this // fixture fails unless the registry resolves against the document. - EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_inputs( + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path()), std::runtime_error); @@ -1359,7 +1359,7 @@ TEST(DecodingServerAcceptance, ChromobiusConstructsFromRawDemSource) { EXPECT_EQ(parsed.stim_dem_path, config.stim_dem_path); EXPECT_TRUE(parsed.H_sparse.empty()); - auto inputs = cudaq::qec::decoding::host::resolve_decoder_inputs( + auto inputs = cudaq::qec::decoding::host::resolve_decoder_init( parsed, std::filesystem::current_path()); ASSERT_TRUE(inputs.has_stim_dem()); @@ -1383,7 +1383,7 @@ TEST(DecoderConfigTest, CreateRealtimeDecoderRequiresDetectorMatrix) { config.D_sparse.clear(); EXPECT_THROW(cudaq::qec::decoding::host::create_realtime_decoder( - config, cudaq::qec::decoding::host::resolve_decoder_inputs( + config, cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path())), std::runtime_error); } @@ -1394,7 +1394,7 @@ TEST(DecoderConfigTest, CreateRealtimeDecoderRejectsUnrepresentableId) { static_cast(std::numeric_limits::max()) + 1; EXPECT_THROW(cudaq::qec::decoding::host::create_realtime_decoder( - config, cudaq::qec::decoding::host::resolve_decoder_inputs( + config, cudaq::qec::decoding::host::resolve_decoder_init( config, std::filesystem::current_path())), std::invalid_argument); } diff --git a/libs/qec/unittests/test_decoding_server_core.cpp b/libs/qec/unittests/test_decoding_server_core.cpp index faee41772..e78e66295 100644 --- a/libs/qec/unittests/test_decoding_server_core.cpp +++ b/libs/qec/unittests/test_decoding_server_core.cpp @@ -42,7 +42,7 @@ class ControlledDecoder final : public cudaq::qec::decoder { public: ControlledDecoder() : decoder( - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( /*H=*/cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), /*O=*/ @@ -307,7 +307,7 @@ class MispinnedDecoder final : public cudaq::qec::decoder { public: MispinnedDecoder() : decoder( - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( /*H=*/cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), /*O=*/ @@ -353,7 +353,7 @@ TEST(DecodingSessionPinHandshake, PinnedWorkerStartsAndServes) { params.insert("cuda_device_id", 0); auto dec = cudaq::qec::decoder::get( "single_error_lut", - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( /*H=*/cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), /*O=*/ cudaq::qec::sparse_binary_matrix::from_csr(1, 1, {0, 1}, {0}), diff --git a/libs/qec/unittests/utils/gpu_roce_qldpc_graph_decoder_bridge.cpp b/libs/qec/unittests/utils/gpu_roce_qldpc_graph_decoder_bridge.cpp index 5b4e21624..9c7f1bddb 100644 --- a/libs/qec/unittests/utils/gpu_roce_qldpc_graph_decoder_bridge.cpp +++ b/libs/qec/unittests/utils/gpu_roce_qldpc_graph_decoder_bridge.cpp @@ -251,7 +251,7 @@ int main(int argc, char *argv[]) { std::count(dec.O_sparse.begin(), dec.O_sparse.end(), -1)); auto decoder = cudaq::qec::decoder::get( "nv-qldpc-decoder", - cudaq::qec::decoder_inputs( + cudaq::qec::decoder_init( cudaq::qec::sparse_binary_matrix(H_tensor), sparse_matrix_from_flat_rows(dec.O_sparse, num_observable_rows), /*error_rates=*/{}, From 4c367ac0a1d961661823687bc3b0b7b420d2ce00 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Fri, 7 Aug 2026 16:06:33 -0700 Subject: [PATCH 22/24] Address review feedback Documentation changes are cut back to what the docs build actually requires: the decoder_init directive becomes doxygenclass now that the typedef is a class, and configure_decoders names its arguments now that it has two overloads. Everything else returns to upstream and is left for a documentation pass, which also restores a sample that had gone stale. The requested result form defaults to errors, as upstream's result_type_ did, so a decoder can be constructed from its inputs alone. Two additions lose their only justification and go: the d_sparse overload taking a sparse matrix had one caller, which now flattens inline, and materialize_detector_error_model had none outside tests once the experiment struct came back -- its remaining call site reads the sparse model directly rather than densifying a model it immediately re-sparsifies. Signed-off-by: Melody Ren --- docs/sphinx/api/qec/cpp_api.rst | 4 ++- .../api/qec/cpp_realtime_decoding_api.rst | 9 +++--- docs/sphinx/api/qec/pymatching_api.rst | 28 ++++++---------- .../api/qec/python_realtime_decoding_api.rst | 19 ++++------- docs/sphinx/api/qec/sliding_window_api.rst | 21 +++++------- docs/sphinx/components/qec/introduction.rst | 32 ++++++++----------- libs/qec/include/cudaq/qec/decoder.h | 3 +- libs/qec/include/cudaq/qec/decoder_init.h | 3 -- libs/qec/include/cudaq/qec/experiments.h | 6 +--- .../cudaq/qec/realtime/decoding_config.h | 3 +- libs/qec/lib/decoder_init.cpp | 14 -------- libs/qec/lib/experiments.cpp | 10 ------ libs/qec/python/tests/test_decoding_config.py | 2 +- .../backend-specific/stim/test_qec_stim.cpp | 4 +-- .../realtime/app_examples/surface_code-1.cpp | 2 +- .../app_examples/surface_code-4-yaml.cpp | 30 ++++++++++------- libs/qec/unittests/test_decoders.cpp | 11 +++---- 17 files changed, 75 insertions(+), 126 deletions(-) diff --git a/docs/sphinx/api/qec/cpp_api.rst b/docs/sphinx/api/qec/cpp_api.rst index 38b6b86a5..f25d88250 100644 --- a/docs/sphinx/api/qec/cpp_api.rst +++ b/docs/sphinx/api/qec/cpp_api.rst @@ -67,11 +67,13 @@ Legacy convenience wrappers (delegate to ``cpu::sample_dem``; prefer the Decoder Interfaces ================== +.. doxygenstruct:: cudaq::qec::decoder_inputs + :members: + .. doxygenclass:: cudaq::qec::decoder_init :members: .. doxygenfunction:: cudaq::qec::d_sparse(const cudaq::M2DSparseMatrix &) -.. doxygenfunction:: cudaq::qec::d_sparse(const sparse_binary_matrix &) .. doxygenclass:: cudaq::qec::decoder :members: diff --git a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst index 88425ed31..d067f420f 100644 --- a/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst +++ b/docs/sphinx/api/qec/cpp_realtime_decoding_api.rst @@ -53,7 +53,7 @@ Real-time decoding requires converting matrices to sparse format for efficient d - :cpp:func:`cudaq::qec::pcm_to_sparse_vec` for converting a dense PCM to a sparse PCM. - :cpp:func:`cudaq::qec::pcm_from_sparse_vec` for converting a sparse PCM to a dense PCM. - :cpp:func:`cudaq::qec::d_sparse` for converting an ``M2DSparseMatrix`` (obtained from - a :cpp:class:`cudaq::qec::decoder_init` component) into the ``-1``-terminated sparse + a :cpp:struct:`cudaq::qec::decoder_inputs` component) into the ``-1``-terminated sparse vector a decoder config expects for ``D_sparse``. **Usage in real-time decoding:** @@ -63,9 +63,8 @@ Real-time decoding requires converting matrices to sparse format for efficient d auto ctx = cudaq::qec::decoder_context_from_memory_circuit( code, statePrep, numRounds, noise); auto inputs = ctx.z_component(); // or x_component() / full_component() - const auto dem = inputs.materialize_detector_error_model(); - config.H_sparse = cudaq::qec::pcm_to_sparse_vec(dem.detector_error_matrix); - config.O_sparse = cudaq::qec::pcm_to_sparse_vec(dem.observables_flips_matrix); - config.D_sparse = cudaq::qec::d_sparse(*inputs.measurement_to_detectors()); + config.H_sparse = cudaq::qec::pcm_to_sparse_vec(inputs.dem.detector_error_matrix); + config.O_sparse = cudaq::qec::pcm_to_sparse_vec(inputs.dem.observables_flips_matrix); + config.D_sparse = cudaq::qec::d_sparse(inputs.m2d); See also :ref:`parity_check_matrix_utilities` for additional PCM manipulation functions. diff --git a/docs/sphinx/api/qec/pymatching_api.rst b/docs/sphinx/api/qec/pymatching_api.rst index cf55cb478..224d567db 100644 --- a/docs/sphinx/api/qec/pymatching_api.rst +++ b/docs/sphinx/api/qec/pymatching_api.rst @@ -45,26 +45,18 @@ :param H: Parity check matrix. Each column must have one or two set entries (matchable graph). In Python, a ``scipy.sparse`` matrix or a dense NumPy ``uint8`` array may be passed. - :param O: Observable-flips matrix, ``num_observables x block_size``. - Model data supplied alongside ``H``, not a decoder parameter. - Supplying it also defaults ``merge_strategy`` to - ``"independent"``, matching PyMatching's detector-error-model - construction. - :param error_rate_vec: Per-error prior probabilities, one per column of - ``H`` (length ``block_size``). Model data, like ``H`` and ``O``: - it describes the noise model rather than tuning the algorithm. - Each value must lie in ``(0, 0.5]`` and sets the matching edge - weight ``-log(p / (1 - p))``. When omitted, all edge weights - default to ``1.0``. - :param output: The result form this decoder instance produces, fixed at - construction: ``"errors"`` (default) for an error frame of length - ``block_size``, or ``"observables"`` for predicted observable - flips. Supplying ``O`` does not by itself change the result form; - ask for the form you want. A decoder constructed for observable - output without an observable model is rejected at construction. - :param params: Heterogeneous map of decoder parameters: + :param params: Heterogeneous map of parameters: + - `error_rate_vec` (vector): Per-error prior probabilities, one + per column of ``H`` (length ``block_size``). Each value must lie in + ``(0, 0.5]`` and sets the matching edge weight ``-log(p / (1 - p))``. + When omitted, all edge weights default to ``1.0``. - `merge_strategy` (string): How to combine parallel edges that map to the same pair of detectors. One of ``"disallow"`` (default for the ``H``-only path), ``"independent"``, ``"smallest_weight"``, ``"keep_original"``, or ``"replace"``. + - `O` (tensor, optional): A ``num_observables x block_size`` binary + matrix. When provided, the decoder returns predicted observable flips + (``decode_to_obs``) instead of a raw error vector, and + ``merge_strategy`` defaults to ``"independent"`` to match PyMatching's + detector-error-model construction. diff --git a/docs/sphinx/api/qec/python_realtime_decoding_api.rst b/docs/sphinx/api/qec/python_realtime_decoding_api.rst index b895295a9..fff1b44ec 100644 --- a/docs/sphinx/api/qec/python_realtime_decoding_api.rst +++ b/docs/sphinx/api/qec/python_realtime_decoding_api.rst @@ -82,24 +82,17 @@ out-of-tree decoder plugins. Use ``cudaq_qec.decoder_param_schema(name)`` to inspect a decoder's parameters and ``cudaq_qec.registered_decoder_schemas()`` to list all decoders with registered schemas. -Model data is not a decoder parameter. ``H_sparse``, ``O_sparse``, -``D_sparse`` and ``error_rate_vec`` describe the model every decoder decodes -against, so they are fields of ``decoder_config`` itself; a decoder's -parameters tune its algorithm. Supplying model data under -``decoder_custom_args`` is rejected as an unknown key. - -For example, the ``pymatching`` decoder's only parameter is -``merge_strategy`` (one of ``"disallow"``, ``"independent"``, -``"smallest_weight"``, ``"keep_original"``, ``"replace"``), while its prior -probabilities are model data: +For example, the ``pymatching`` decoder accepts ``error_rate_vec`` +(per-error prior probabilities in the range ``(0, 0.5]``, length matching +the decoder ``block_size``) and ``merge_strategy`` (one of ``"disallow"``, +``"independent"``, ``"smallest_weight"``, ``"keep_original"``, +``"replace"``): .. code-block:: python config.type = "pymatching" - # Model data: per-error priors in (0, 0.5], one per column of H. - config.error_rate_vec = [0.1, 0.1, 0.1] - # Decoder parameters: how the algorithm behaves. config.decoder_custom_args = { + "error_rate_vec": [0.1, 0.1, 0.1], "merge_strategy": "smallest_weight", } diff --git a/docs/sphinx/api/qec/sliding_window_api.rst b/docs/sphinx/api/qec/sliding_window_api.rst index e23b7d404..38ee15392 100644 --- a/docs/sphinx/api/qec/sliding_window_api.rst +++ b/docs/sphinx/api/qec/sliding_window_api.rst @@ -96,18 +96,14 @@ auto inner_decoder_params = cudaqx::heterogeneous_map{{"use_osd", true}, {"max_iterations", 50}}; auto opts = cudaqx::heterogeneous_map{ + {"error_rate_vec", dem.error_rates}, {"window_size", 1}, {"num_syndromes_per_round", code->get_num_z_stabilizers() + code->get_num_x_stabilizers()}, {"num_boundary_syndromes", code->get_num_z_stabilizers()}, {"inner_decoder_name", "single_error_lut"}, {"inner_decoder_params", inner_decoder_params}}; - // Priors are model data, so they travel with H rather than in - // the parameter map. - auto inputs = cudaq::qec::decoder_init( - cudaq::qec::sparse_binary_matrix(dem.detector_error_matrix), - std::nullopt, dem.error_rates); - auto swdec = - cudaq::qec::get_decoder("sliding_window", inputs, opts); + auto swdec = cudaq::qec::get_decoder("sliding_window", + dem.detector_error_matrix, opts); return 0; } @@ -118,13 +114,12 @@ for C++, so it supports all the methods in those respective classes. :param H: Parity check matrix (tensor format) - :param error_rate_vec: Per-error prior probabilities, one per column of - ``H`` (length ``block_size``), each in the 0-1 range. Model data - supplied alongside ``H``, not a decoder parameter. The decoder - slices it to each window's error columns and passes the slice to - that window's inner decoder as part of its model. - :param params: Heterogeneous map of decoder parameters: + :param params: Heterogeneous map of parameters: + - `error_rate_vec` (double): Vector of length "block size" containing + the probability of an error (in 0-1 range). This vector is used to + populate the `error_rate_vec` parameter for the inner decoder + (automatically sliced correctly according to each window). - `window_size` (int): The number of rounds of syndrome data in each window. (Defaults to 1.) - `step_size` (int): The number of rounds to advance the window by each time. (Defaults to 1.) - `num_syndromes_per_round` (int): The number of syndromes per round. (Must be provided.) diff --git a/docs/sphinx/components/qec/introduction.rst b/docs/sphinx/components/qec/introduction.rst index 565e7a8a1..4c761f21f 100644 --- a/docs/sphinx/components/qec/introduction.rst +++ b/docs/sphinx/components/qec/introduction.rst @@ -632,13 +632,10 @@ To implement a new decoder: // Decoder-specific members public: - my_decoder(qec::decoder_init inputs, - qec::decode_result_type requested_output, - const heterogeneous_map& params) - : decoder(std::move(inputs), requested_output) { - // All model data is available here. Reject a result form this - // decoder cannot produce, so an unsupported request fails at - // construction rather than on the first decode. + my_decoder(const qec::sparse_binary_matrix& H, + const heterogeneous_map& params) + : decoder(H) { + // Initialize decoder } decoder_result decode( @@ -654,21 +651,18 @@ To implement a new decoder: CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( my_decoder, static std::unique_ptr create( - qec::decoder_init inputs, - std::optional requested_output, + const qec::decoder_init& init, const heterogeneous_map& params) { - return std::make_unique( - std::move(inputs), - requested_output.value_or(qec::decode_result_type::errors), - params); + return qec::make_pcm_decoder(init, params); } ) CUDAQ_EXT_PT_REGISTER_TYPE(my_decoder) -The factory receives the model as :code:`decoder_init` and the caller's -result form as an optional :code:`decode_result_type`. A decoder that supports one -form only should default the request to that form and reject any other. +The :code:`make_pcm_decoder` helper dispatches :code:`decoder_init`. It +passes a stored sparse PCM directly to the decoder constructor; when the +variant contains Stim DEM text, it parses the DEM and constructs the sparse +detector matrix before invoking the same constructor. Example: Lookup Table Decoder ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -682,12 +676,11 @@ Here's a simple lookup table decoder for the Steane code: std::map single_qubit_err_signatures; public: - single_error_lut(qec::decoder_init inputs, + single_error_lut(const qec::sparse_binary_matrix& H, const heterogeneous_map& params) - : decoder(std::move(inputs)) { + : decoder(H) { // Canonicalize before using each sparse column as an error // signature so duplicate row indices cancel over GF(2). - const auto& H = get_inputs().detector_error_matrix(); auto H_e2d = H.canonicalize().to_nested_csc(); for (std::size_t qErr = 0; qErr < block_size; qErr++) { @@ -1508,3 +1501,4 @@ Additional Noise Models noise.add_all_qubit_channel( "x", cudaq::depolarization2(/*probability*/ 0.01), /*numControls*/ 1); + diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index 579553518..78cedd782 100644 --- a/libs/qec/include/cudaq/qec/decoder.h +++ b/libs/qec/include/cudaq/qec/decoder.h @@ -153,7 +153,8 @@ class decoder /// factory can move its immutable handle into the decoder. /// @param requested_output The result basis this instance produces, fixed /// for its lifetime. - decoder(decoder_init inputs, decode_result_type requested_output); + decoder(decoder_init inputs, + decode_result_type requested_output = decode_result_type::errors); /// @brief Decode a single syndrome /// @param syndrome A vector of syndrome measurements where the floating point diff --git a/libs/qec/include/cudaq/qec/decoder_init.h b/libs/qec/include/cudaq/qec/decoder_init.h index 5cd67df2a..076c0cdac 100644 --- a/libs/qec/include/cudaq/qec/decoder_init.h +++ b/libs/qec/include/cudaq/qec/decoder_init.h @@ -137,9 +137,6 @@ class decoder_init { /// @throws std::logic_error if the authoritative source is not a Stim DEM. const std::string &stim_dem() const; - /// @brief Materialize the common detector-error-model view. - detector_error_model materialize_detector_error_model() const; - /// Dimensions are stored as source metadata so these accessors never need to /// request H or O. For matrix sources they intentionally duplicate the O(1) /// matrix shape values in preparation for compact source alternatives. diff --git a/libs/qec/include/cudaq/qec/experiments.h b/libs/qec/include/cudaq/qec/experiments.h index 9085c8896..8d9ed25b9 100644 --- a/libs/qec/include/cudaq/qec/experiments.h +++ b/libs/qec/include/cudaq/qec/experiments.h @@ -9,8 +9,8 @@ #include "cudaq/algorithms/dem.h" #include "cudaq/qec/code.h" -#include "cudaq/qec/decoder_init.h" #include "cudaq/qec/detector_error_model.h" +#include "cudaq/qec/sparse_binary_matrix.h" #include #include @@ -212,10 +212,6 @@ std::vector d_sparse(const cudaq::M2DSparseMatrix &m2d); /// matrix used by decoder_init. sparse_binary_matrix m2d_to_sparse(const cudaq::M2DSparseMatrix &m2d); -/// @brief Flatten a QEC-owned detector-by-measurement matrix into the legacy -/// `-1`-terminated server encoding. -std::vector d_sparse(const sparse_binary_matrix &m2d); - /// @brief Given a memory circuit setup, generate a DEM /// @param code QEC Code to sample /// @param statePrep Initial state preparation operation diff --git a/libs/qec/include/cudaq/qec/realtime/decoding_config.h b/libs/qec/include/cudaq/qec/realtime/decoding_config.h index 70bcf484f..b21257758 100644 --- a/libs/qec/include/cudaq/qec/realtime/decoding_config.h +++ b/libs/qec/include/cudaq/qec/realtime/decoding_config.h @@ -87,8 +87,7 @@ struct decoder_config { /// Maps raw measurements to detectors. Orthogonal to the model source and /// required by both. std::vector D_sparse; - /// Error probability per H column. This is framework model data and is - /// normalized into decoder_init rather than passed to plugin parameters. + /// Error probability per H column. std::vector error_rate_vec; decoder_custom_args_t decoder_custom_args; diff --git a/libs/qec/lib/decoder_init.cpp b/libs/qec/lib/decoder_init.cpp index 103c886dd..14584c4b9 100644 --- a/libs/qec/lib/decoder_init.cpp +++ b/libs/qec/lib/decoder_init.cpp @@ -181,20 +181,6 @@ const std::string &decoder_init::stim_dem() const { return *state_->raw_stim_dem; } -detector_error_model decoder_init::materialize_detector_error_model() const { - detector_error_model model; - model.detector_error_matrix = state_->H.to_dense(); - // A model with no observable mapping materializes as zero observable rows, - // matching a DEM that declares no observables. - model.observables_flips_matrix = - state_->O ? state_->O->to_dense() - : cudaqx::tensor( - {std::size_t{0}, state_->num_error_mechanisms}); - model.error_rates = state_->rates; - model.error_ids = state_->ids; - return model; -} - std::size_t decoder_init::num_detectors() const noexcept { return state_->num_detectors; } diff --git a/libs/qec/lib/experiments.cpp b/libs/qec/lib/experiments.cpp index 0acd6f237..499e86f7c 100644 --- a/libs/qec/lib/experiments.cpp +++ b/libs/qec/lib/experiments.cpp @@ -541,16 +541,6 @@ sparse_binary_matrix m2d_to_sparse(const cudaq::M2DSparseMatrix &m2d) { static_cast(m2d.num_measurements), rows); } -std::vector d_sparse(const sparse_binary_matrix &m2d) { - std::vector out; - for (const auto &row : m2d.to_nested_csr()) { - for (const auto measurement : row) - out.push_back(static_cast(measurement)); - out.push_back(-1); - } - return out; -} - decoder_context decoder_context_from_memory_circuit(const code &code, operation statePrep, std::size_t numRounds, diff --git a/libs/qec/python/tests/test_decoding_config.py b/libs/qec/python/tests/test_decoding_config.py index 22df6754f..34c238df6 100644 --- a/libs/qec/python/tests/test_decoding_config.py +++ b/libs/qec/python/tests/test_decoding_config.py @@ -764,12 +764,12 @@ def test_configure_invalid_decoders(): if __name__ == "__main__": pytest.main() - # --- exported JSON Schema: the two model sources ---------------------------- # # The schema must describe the language the runtime actually accepts. It keys # the DEM source on a NON-EMPTY stim_dem_path, matching resolve_decoder_init. + def _decoder_doc(**overrides): doc = {"id": 0, "type": "pymatching", "D_sparse": [0, -1, 1, -1]} doc.update(overrides) diff --git a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp index 2a8ec0bd6..868347d09 100644 --- a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp +++ b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp @@ -636,8 +636,8 @@ TEST(QECCodeTester, checkRealtimeDecodeFromMemoryCircuit) { // built from. Bridging the two here is one expression, and it carries O and // D, so construction configures the realtime path completely. There is no // second step, and nothing to re-supply. - auto decoder = cudaq::qec::get_decoder( - "single_error_lut", cudaq::qec::decoder_init(dem, D)); + auto decoder = cudaq::qec::get_decoder("single_error_lut", + cudaq::qec::decoder_init(dem, D)); ASSERT_EQ(decoder->get_num_msyn_per_decode(), D.num_cols()); // Stream numCols ancilla per round, then the final data readout. The window diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp index c9cad601f..bc1909227 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-1.cpp @@ -533,7 +533,7 @@ bool setup_decoders(const cudaq::qec::code &code, } // Characterize the DEM and build the decoder configuration. full_component() - // canonicalizes both stabilizer types (boundary-aware) into decoder_init. + // canonicalizes both stabilizer types (boundary-aware) into decoder_inputs. const std::string &leaf_decoder = opts.decoder_type == "sliding_window" ? opts.sw_inner_decoder : opts.decoder_type; diff --git a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp index b720dd674..670dc943a 100644 --- a/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp +++ b/libs/qec/unittests/realtime/app_examples/surface_code-4-yaml.cpp @@ -327,21 +327,26 @@ void save_dem_to_file( const auto &inputs = (decoder_type == "nv-qldpc-decoder") ? bp_inputs[i] : matching_inputs[i]; - const auto edem = inputs.materialize_detector_error_model(); cudaq::qec::decoding::config::decoder_config config; config.id = i; config.type = decoder_type; - config.block_size = edem.num_error_mechanisms(); - config.syndrome_size = edem.num_detectors(); - config.H_sparse = cudaq::qec::pcm_to_sparse_vec(edem.detector_error_matrix); + config.block_size = inputs.num_error_mechanisms(); + config.syndrome_size = inputs.num_detectors(); + config.H_sparse = + cudaq::qec::pcm_to_sparse_vec(inputs.detector_error_matrix()); config.O_sparse = - cudaq::qec::pcm_to_sparse_vec(edem.observables_flips_matrix); + cudaq::qec::pcm_to_sparse_vec(inputs.observable_flips_matrix()); // Ising replaces this native mapping with its detector ordering below. const auto *D = inputs.measurement_to_detectors(); if (!D) throw std::runtime_error("decoder inputs are missing D"); - config.D_sparse = cudaq::qec::d_sparse(*D); - config.error_rate_vec = edem.error_rates; + config.D_sparse.clear(); + for (const auto &row : D->to_nested_csr()) { + for (const auto measurement : row) + config.D_sparse.push_back(static_cast(measurement)); + config.D_sparse.push_back(-1); + } + config.error_rate_vec = inputs.error_rates(); if (decoder_type == "nv-qldpc-decoder") { cudaqx::heterogeneous_map nv_args; @@ -1006,15 +1011,16 @@ void demo_circuit_host(const cudaq::qec::code &code, int distance, matching_inputs.emplace_back(std::move(patch_dem), patch_D); bp_inputs.emplace_back(std::move(patch_dem_undecomposed), patch_D); } - dem = matching_inputs.front().materialize_detector_error_model(); + const auto &front = matching_inputs.front(); numSyndromesPerRound = numAncx + numAncz; printf("numSyndromesPerRound: %ld\n", numSyndromesPerRound); - printf("dem.detector_error_matrix:\n"); - dem.detector_error_matrix.dump_bits(); - printf("dem.observables_flips_matrix:\n"); - dem.observables_flips_matrix.dump_bits(); + printf("H: %u x %u (%u nonzeros)\n", + front.detector_error_matrix().num_rows(), + front.detector_error_matrix().num_cols(), + front.detector_error_matrix().num_nnz()); + printf("O: %zu observables\n", front.num_observables()); if (save_dem) { save_dem_to_file(matching_inputs, bp_inputs, dem_filename, decoder_types, diff --git a/libs/qec/unittests/test_decoders.cpp b/libs/qec/unittests/test_decoders.cpp index c4df74a5f..6b5b6aa5a 100644 --- a/libs/qec/unittests/test_decoders.cpp +++ b/libs/qec/unittests/test_decoders.cpp @@ -104,12 +104,11 @@ TEST(DecoderInputs, PreservesMatrixShapesAndMeasurementMap) { EXPECT_EQ(inputs.measurement_to_detectors()->num_cols(), 5); EXPECT_EQ(inputs.error_rates(), (std::vector{0.1, 0.2, 0.3})); - const auto materialized = inputs.materialize_detector_error_model(); - EXPECT_EQ(materialized.observables_flips_matrix.shape()[0], 3); - EXPECT_EQ(materialized.observables_flips_matrix.shape()[1], 3); - EXPECT_EQ(materialized.observables_flips_matrix.at({1, 0}), 0); - EXPECT_EQ(materialized.observables_flips_matrix.at({1, 1}), 0); - EXPECT_EQ(materialized.observables_flips_matrix.at({1, 2}), 0); + // A supplied O with an all-zero row is still an observable model: the row + // count is retained rather than collapsed. + EXPECT_EQ(inputs.observable_flips_matrix().num_rows(), 3); + EXPECT_EQ(inputs.observable_flips_matrix().num_cols(), 3); + EXPECT_TRUE(inputs.observable_flips_matrix().to_nested_csr()[1].empty()); auto decoder = cudaq::qec::get_decoder("sample_decoder", inputs); // Both come from the model handed to the factory; there is no second way to From 73bba87e8cbb7161f9c1ad1f015b447f40dc0db2 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Fri, 7 Aug 2026 15:02:57 -0700 Subject: [PATCH 23/24] Name the sparse DEM builder for what it does dem_sparse_projection.h / sparse_dem_projection reused "projection", which in this library already means the error frame to observables step that project_errors_to_observables performs. The header, the struct and the function now all say the same thing: this builds a sparse DEM from Stim DEM text. Signed-off-by: Melody Ren --- libs/qec/lib/decoder_init.cpp | 2 +- libs/qec/lib/detector_error_model.cpp | 6 +++--- ...{dem_sparse_projection.h => sparse_dem_from_stim_text.h} | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename libs/qec/lib/{dem_sparse_projection.h => sparse_dem_from_stim_text.h} (96%) diff --git a/libs/qec/lib/decoder_init.cpp b/libs/qec/lib/decoder_init.cpp index 14584c4b9..ada630b77 100644 --- a/libs/qec/lib/decoder_init.cpp +++ b/libs/qec/lib/decoder_init.cpp @@ -7,7 +7,7 @@ ******************************************************************************/ #include "cudaq/qec/decoder_init.h" -#include "dem_sparse_projection.h" +#include "sparse_dem_from_stim_text.h" #include #include diff --git a/libs/qec/lib/detector_error_model.cpp b/libs/qec/lib/detector_error_model.cpp index 6337f888a..03a019f3e 100644 --- a/libs/qec/lib/detector_error_model.cpp +++ b/libs/qec/lib/detector_error_model.cpp @@ -7,7 +7,7 @@ ******************************************************************************/ #include "cudaq/qec/detector_error_model.h" -#include "dem_sparse_projection.h" +#include "sparse_dem_from_stim_text.h" #include "cudaq/qec/logger.h" #include "cudaq/qec/pcm_utils.h" #include "cudaq/qec/sparse_binary_matrix.h" @@ -168,7 +168,7 @@ detector_error_model dem_from_stim_text(const std::string &dem_text, namespace details { -sparse_dem_projection sparse_dem_from_stim_text(const std::string &dem_text) { +sparse_dem sparse_dem_from_stim_text(const std::string &dem_text) { auto parsed = parse_stim_dem(dem_text, /*use_decomp_suggestions=*/false); validate_hit_ids(parsed); @@ -232,7 +232,7 @@ sparse_dem_projection sparse_dem_from_stim_text(const std::string &dem_text) { for (auto ob : parsed.observable_hits[err]) col_indices[cursor[ob]++] = static_cast(err); - sparse_dem_projection projection; + sparse_dem projection; projection.detector_error_matrix = sparse_binary_matrix::from_csc( static_cast(parsed.num_detectors), static_cast(num_cols), std::move(col_ptrs), diff --git a/libs/qec/lib/dem_sparse_projection.h b/libs/qec/lib/sparse_dem_from_stim_text.h similarity index 96% rename from libs/qec/lib/dem_sparse_projection.h rename to libs/qec/lib/sparse_dem_from_stim_text.h index ea464cb82..330168084 100644 --- a/libs/qec/lib/dem_sparse_projection.h +++ b/libs/qec/lib/sparse_dem_from_stim_text.h @@ -26,7 +26,7 @@ namespace cudaq::qec::details { /// The sparse projection of a Stim DEM, in the layouts `decoder_init` stores. /// Named fields rather than a tuple: H and O share a type, so positional /// results would let them be swapped while still type-checking. -struct sparse_dem_projection { +struct sparse_dem { /// H, detectors x error mechanisms, CSC (one compressed group per error). sparse_binary_matrix detector_error_matrix; /// O, observables x error mechanisms, CSR (one compressed group per @@ -45,7 +45,7 @@ struct sparse_dem_projection { /// /// Hidden explicitly: this library does not set CXX_VISIBILITY_PRESET, so a /// non-inline symbol would otherwise reach the dynamic symbol table. -__attribute__((visibility("hidden"))) sparse_dem_projection +__attribute__((visibility("hidden"))) sparse_dem sparse_dem_from_stim_text(const std::string &dem_text); } // namespace cudaq::qec::details From 4b8b98024e8861c9fb5e198bf99d2e2e69150c76 Mon Sep 17 00:00:00 2001 From: Melody Ren Date: Fri, 7 Aug 2026 19:37:27 -0700 Subject: [PATCH 24/24] Document the enums the decoder API now cross-references The docs build treats Sphinx warnings as errors, and moving decode_result_type out of the decoder class left it undocumented: it used to be covered by doxygenclass:: decoder, and at namespace scope it needs its own directive. decoder_model_source was never documented at all. Every reference to either from a class the docs do render was therefore unresolvable. Two comments referred to sibling methods as has_stim_dem() and num_observables(). Doxygen turns that spelling into a cross-reference, and breathe then emits a label it never generated, so the text now escapes the auto-link. Signed-off-by: Melody Ren --- docs/sphinx/api/qec/cpp_api.rst | 4 ++++ libs/qec/include/cudaq/qec/decoder_init.h | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/sphinx/api/qec/cpp_api.rst b/docs/sphinx/api/qec/cpp_api.rst index f25d88250..79f4e9966 100644 --- a/docs/sphinx/api/qec/cpp_api.rst +++ b/docs/sphinx/api/qec/cpp_api.rst @@ -67,6 +67,10 @@ Legacy convenience wrappers (delegate to ``cpu::sample_dem``; prefer the Decoder Interfaces ================== +.. doxygenenum:: cudaq::qec::decode_result_type + +.. doxygenenum:: cudaq::qec::decoder_model_source + .. doxygenstruct:: cudaq::qec::decoder_inputs :members: diff --git a/libs/qec/include/cudaq/qec/decoder_init.h b/libs/qec/include/cudaq/qec/decoder_init.h index 076c0cdac..1346cea15 100644 --- a/libs/qec/include/cudaq/qec/decoder_init.h +++ b/libs/qec/include/cudaq/qec/decoder_init.h @@ -94,7 +94,7 @@ class decoder_init { ~decoder_init(); /// @brief The authoritative representation. Consumers that only need to - /// know whether raw DEM text is available should ask has_stim_dem(); this + /// know whether raw DEM text is available should ask %has_stim_dem(); this /// discriminator is what a future compact source would extend. decoder_model_source source() const noexcept; @@ -103,7 +103,7 @@ class decoder_init { /// @brief Whether this model supplies an observable mapping at all. /// - /// Distinct from `num_observables() == 0`: a supplied O with zero rows is an + /// Distinct from `%num_observables() == 0`: a supplied O with zero rows is an /// observable model, an H-only input is not. Construction-time validation of /// an observable-output request depends on this distinction. bool has_observable_model() const noexcept;