diff --git a/docs/sphinx/api/qec/cpp_api.rst b/docs/sphinx/api/qec/cpp_api.rst index a2f702c7b..79f4e9966 100644 --- a/docs/sphinx/api/qec/cpp_api.rst +++ b/docs/sphinx/api/qec/cpp_api.rst @@ -67,12 +67,17 @@ 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: -.. doxygenfunction:: cudaq::qec::d_sparse(const cudaq::M2DSparseMatrix &) +.. doxygenclass:: cudaq::qec::decoder_init + :members: -.. doxygentypedef:: cudaq::qec::decoder_init +.. doxygenfunction:: cudaq::qec::d_sparse(const cudaq::M2DSparseMatrix &) .. 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..d067f420f 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 diff --git a/libs/qec/include/cudaq/qec/decoder.h b/libs/qec/include/cudaq/qec/decoder.h index 226e22adf..78cedd782 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_init.h" #include #include #include @@ -21,7 +20,6 @@ #include #include #include -#include #include namespace cudaq::qec { @@ -32,9 +30,11 @@ 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 The basis of a decoder result. +enum class decode_result_type : std::uint8_t { + errors, + observables, +}; /// @brief Validates that all keys in a heterogeneous map are found in a list of /// acceptable types @@ -59,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 @@ -134,7 +135,8 @@ 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: struct rt_impl; @@ -144,49 +146,37 @@ 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: 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. - 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 - }; - 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. + /// @param requested_output The result basis this instance produces, fixed + /// for its lifetime. + 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 /// 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); @@ -194,17 +184,22 @@ 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); /// @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_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_init inputs, decode_result_type output, const cudaqx::heterogeneous_map ¶m_map = cudaqx::heterogeneous_map()); static std::unique_ptr @@ -225,39 +220,40 @@ 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_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_init{std::string{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_init{std::string{stim_dem_text}}, param_map); + return get(name, decoder_init::from_stim_dem(std::string{stim_dem_text}), + param_map); } 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. + decode_result_type get_result_type() const noexcept { return result_type_; } + // -- 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. + /// @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 @@ -266,20 +262,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 the D_sparse matrix. - 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. - void set_D_sparse(const std::vector &D_sparse); - /// @brief Set the decoder id. void set_decoder_id(uint32_t decoder_id); @@ -344,22 +326,40 @@ class decoder virtual std::string get_version() const; protected: - /// @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 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 The immutable construction inputs owned by this decoder. + const decoder_init &get_inputs() const noexcept { return inputs_; } + + /// @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 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_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 + /// 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 + /// would reset its buffers mid-stream. + 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; @@ -367,21 +367,18 @@ 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; - - /// @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; private: - decode_result_type result_type_ = decode_result_type::decode_to_errs; + static std::unique_ptr + 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_init inputs_; + const decode_result_type result_type_; }; /// @brief Convert a single soft probability to a hard 0/1 decision. @@ -537,7 +534,12 @@ 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_init inputs, + const cudaqx::heterogeneous_map options = {}); + +std::unique_ptr +get_decoder(const std::string &name, decoder_init inputs, + decode_result_type output, const cudaqx::heterogeneous_map options = {}); inline std::unique_ptr @@ -555,23 +557,26 @@ 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_init::from_stim_dem(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 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_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_init{std::string{stim_dem_text}}, options); + return get_decoder( + name, decoder_init::from_stim_dem(std::string{stim_dem_text}), options); } 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; @@ -584,25 +589,4 @@ 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. -template -std::unique_ptr -make_pcm_decoder(const decoder_init &init, - 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); -} - } // namespace cudaq::qec diff --git a/libs/qec/include/cudaq/qec/decoder_init.h b/libs/qec/include/cudaq/qec/decoder_init.h new file mode 100644 index 000000000..1346cea15 --- /dev/null +++ b/libs/qec/include/cudaq/qec/decoder_init.h @@ -0,0 +1,160 @@ +/****************************************************************-*- 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 +#include + +namespace cudaq::qec { + +/// @brief Authoritative representation from which a decoder model originates. +/// +/// 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_init` object layout nor +/// the decoder factory signature. +enum class decoder_model_source : std::uint8_t { + matrices, + stim_dem, +}; + +/// @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_init { +public: + /// @brief Construct an H-only matrix model. + 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. + /// @param observable_flips_matrix O, with shape observables x error + /// 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_init( + sparse_binary_matrix detector_error_matrix, + std::optional 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_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_init + from_stim_dem(std::string stim_dem_text, + std::optional measurement_to_detectors = + std::nullopt); + + decoder_init(const decoder_init &) noexcept; + /// @brief Move construction leaves the source valid only for destruction or + /// assignment. + 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_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 + /// discriminator is what a future compact source would extend. + decoder_model_source source() const noexcept; + + /// @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; + 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; + + /// @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_init decoder_init_without_d() const; + + /// @brief Return the same inputs with H in GF(2)-canonical CSC form. + /// + /// 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_init canonicalize_H() const; + + 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; + + /// 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, + 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); + explicit decoder_init(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..8d9ed25b9 100644 --- a/libs/qec/include/cudaq/qec/experiments.h +++ b/libs/qec/include/cudaq/qec/experiments.h @@ -10,6 +10,7 @@ #include "cudaq/algorithms/dem.h" #include "cudaq/qec/code.h" #include "cudaq/qec/detector_error_model.h" +#include "cudaq/qec/sparse_binary_matrix.h" #include #include @@ -207,6 +208,10 @@ 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_init. +sparse_binary_matrix m2d_to_sparse(const cudaq::M2DSparseMatrix &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 4fd6448ea..b21257758 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,11 +71,24 @@ 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. + std::vector error_rate_vec; decoder_custom_args_t decoder_custom_args; bool operator==(const decoder_config &) const = default; @@ -181,6 +195,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/CMakeLists.txt b/libs/qec/lib/CMakeLists.txt index 37842f4be..7c4eb3d19 100644 --- a/libs/qec/lib/CMakeLists.txt +++ b/libs/qec/lib/CMakeLists.txt @@ -41,6 +41,7 @@ endif() set(DECODERS_SOURCES decoder.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 6e9cf2388..28194518c 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, const cudaq::qec::decoder_init &, +INSTANTIATE_REGISTRY(cudaq::qec::decoder, cudaq::qec::decoder_init, + std::optional, const cudaqx::heterogeneous_map &) // Include decoder implementations AFTER registry instantiation @@ -56,7 +58,14 @@ struct decoder::rt_impl { /// The id of the decoder (for instrumentation) uint32_t decoder_id = 0; - bool is_sliding_window = 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. + std::vector> measurement_to_detectors; /// The number of syndromes per round. Only used for sliding window decoder. size_t num_syndromes_per_round = 0; @@ -78,14 +87,22 @@ 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(); - reset_decoder(); +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(); + block_size = inputs_.num_error_mechanisms(); + + // 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()) { + 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 @@ -96,6 +113,32 @@ decoder::decoder(cudaq::qec::sparse_binary_matrix H) 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, 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}); + + 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); + } +} + // 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) { @@ -203,8 +246,28 @@ class ConstructionDevicePin { }; std::unique_ptr -decoder::get(const std::string &name, const decoder_init &init, +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_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_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_init instead of decoder custom parameters", + reserved)); auto [mutex, registry] = get_registry(); std::lock_guard lock(mutex); auto iter = registry.find(name); @@ -214,8 +277,11 @@ decoder::get(const std::string &name, const decoder_init &init, ". 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(init, 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 +289,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), output, plugin_params); d->cuda_device_id_ = cuda_device_id; device_pin.commit(); return d; @@ -244,63 +310,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; - for (const auto &row : D_sparse) - for (const auto col : row) - max_col = std::max(max_col, col); - return max_col + 1; -} - -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(); - bool first_of_row = true; - for (auto elem : vec_in) { - if (elem < 0) { - first_of_row = true; - } else { - if (first_of_row) { - sparse_out.emplace_back(); - first_of_row = false; - } - sparse_out.back().push_back(static_cast(elem)); - } - } -} - -void decoder::set_O_sparse(const std::vector> &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()); - 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"); - this->pimpl->corrections.clear(); - this->pimpl->corrections.resize(O_sparse.size()); - on_o_sparse_configured(); -} - uint32_t decoder::get_num_msyn_per_decode() const { return pimpl->num_msyn_per_decode; } @@ -311,56 +320,34 @@ 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, - 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 = calculate_num_msyn_per_decode(D_sparse); - 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, 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()); - on_d_sparse_configured(); +void decoder::initialize_streaming_layout( + std::size_t num_syndromes_per_round, + std::vector detector_layer_offsets) { + if (pimpl->round_streaming_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->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->round_streaming_initialized = true; } bool decoder::enqueue_syndrome(const uint8_t *syndrome, @@ -379,7 +366,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 = @@ -406,15 +393,15 @@ 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. - if (!pimpl->is_sliding_window) { - for (std::size_t i = 0; i < this->D_sparse.size(); i++) { + 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 : this->D_sparse[i]) + for (auto col : pimpl->measurement_to_detectors[i]) pimpl->persistent_detector_buffer[i] ^= pimpl->msyn_buffer[col]; } } else { @@ -424,7 +411,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; } @@ -448,45 +435,40 @@ 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 (pimpl->round_streaming_initialized) { + 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 (result_type_) { + case decode_result_type::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 decode_result_type::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))); - 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)) { + if ((!pimpl->round_streaming_initialized && + decoded_values.size() != expected_result_size) || + (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 " "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 @@ -500,30 +482,39 @@ 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 (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++) - 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 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()) + 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_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]) - 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) { @@ -569,7 +560,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 +593,9 @@ 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(); +} void decoder::reset_decoder() { // Zero out all data that is considered "per-shot" memory. @@ -612,7 +605,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 +622,16 @@ void decoder::reset_decoder() { } std::unique_ptr get_decoder(const std::string &name, - const decoder_init &init, + 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_init inputs, + decode_result_type output, const cudaqx::heterogeneous_map options) { - return decoder::get(name, init, options); + return decoder::get(name, std::move(inputs), output, options); } // Constructor function for auto-loading plugins diff --git a/libs/qec/lib/decoder_init.cpp b/libs/qec/lib/decoder_init.cpp new file mode 100644 index 000000000..ada630b77 --- /dev/null +++ b/libs/qec/lib/decoder_init.cpp @@ -0,0 +1,196 @@ +/******************************************************************************* + * 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_init.h" +#include "sparse_dem_from_stim_text.h" +#include +#include + +namespace cudaq::qec { + +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; + std::size_t num_observables = 0; + sparse_binary_matrix H; + /// 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; +}; + +namespace { + +void validate_model(const sparse_binary_matrix &H, + const std::optional &O, + const std::vector &rates, + const std::optional> &ids, + const std::optional &D) { + if (O && O->num_cols() != H.num_cols()) + throw std::invalid_argument( + "decoder_init: O column count must match H column count"); + if (!rates.empty() && rates.size() != H.num_cols()) + throw std::invalid_argument( + "decoder_init: error_rates size must match H column count"); + if (ids && ids->size() != H.num_cols()) + throw std::invalid_argument( + "decoder_init: error_ids size must match H column count"); + if (D && D->num_rows() != H.num_rows()) + throw std::invalid_argument( + "decoder_init: D row count must match H row count"); +} + +} // namespace + +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, + std::optional D, + std::optional raw_stim_dem) { + H = H.to_csc(); + if (O) + *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 ? 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); + return state; +} + +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_init::decoder_init( + sparse_binary_matrix H, std::optional O, + std::vector error_rates, + std::optional measurement_to_detectors, + std::optional> error_ids) + : 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_init::decoder_init( + detector_error_model model, + std::optional measurement_to_detectors) + : 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_init decoder_init::from_stim_dem( + std::string stim_dem_text, + std::optional measurement_to_detectors) { + // 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_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_init::decoder_init(std::shared_ptr state) + : state_(std::move(state)) {} + +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_init::source() const noexcept { + return state_->source; +} + +const sparse_binary_matrix &decoder_init::detector_error_matrix() const { + return state_->H; +} + +bool decoder_init::has_observable_model() const noexcept { + return state_->O.has_value(); +} + +const sparse_binary_matrix &decoder_init::observable_flips_matrix() const { + if (!state_->O) + throw std::logic_error("decoder_init: no observable mapping was supplied"); + return *state_->O; +} + +const std::vector &decoder_init::error_rates() const { + return state_->rates; +} + +const std::optional> &decoder_init::error_ids() const { + return state_->ids; +} + +const sparse_binary_matrix * +decoder_init::measurement_to_detectors() const noexcept { + return state_->D ? &*state_->D : nullptr; +} + +decoder_init decoder_init::canonicalize_H() const { + auto H = state_->H.canonicalize().to_csc(); + return decoder_init(make_matrix_state(state_->source, std::move(H), state_->O, + state_->rates, state_->ids, state_->D, + state_->raw_stim_dem)); +} + +decoder_init decoder_init::decoder_init_without_d() const { + auto state = std::make_shared(*state_); + state->D.reset(); + return decoder_init(std::move(state)); +} + +bool decoder_init::has_stim_dem() const noexcept { + return state_->raw_stim_dem.has_value(); +} + +const std::string &decoder_init::stim_dem() const { + if (!state_->raw_stim_dem) + throw std::logic_error( + "decoder_init: authoritative source is not a Stim DEM"); + return *state_->raw_stim_dem; +} + +std::size_t decoder_init::num_detectors() const noexcept { + return state_->num_detectors; +} + +std::size_t decoder_init::num_error_mechanisms() const noexcept { + return state_->num_error_mechanisms; +} + +std::size_t decoder_init::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..ce4fca814 100644 --- a/libs/qec/lib/decoders/lut.cpp +++ b/libs/qec/lib/decoders/lut.cpp @@ -49,9 +49,20 @@ 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_init inputs, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : 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 == decode_result_type::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) { @@ -61,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"); } @@ -163,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_result_type() == decode_result_type::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'); @@ -183,6 +206,7 @@ class multi_error_lut : public decoder { if (!anyErrors) { result.converged = true; + finish(result); return result; } @@ -223,6 +247,7 @@ class multi_error_lut : public decoder { } } + finish(result); return result; } @@ -230,9 +255,12 @@ 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_init inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return std::make_unique( + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; @@ -240,17 +268,21 @@ 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_init inputs, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) - : multi_error_lut(H, params) {} + : multi_error_lut(std::move(inputs), requested_output, 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_init inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return std::make_unique( + 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 26877e1d6..1cc0944f6 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_init &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,15 +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)}; -} - -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; + return chromobius_init_data{std::move(dem)}; } bool get_bool_param(const cudaqx::heterogeneous_map ¶ms, @@ -103,9 +74,18 @@ class chromobius : public decoder { std::vector packed_detection_events; public: - chromobius(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(init_data.base_H)), dem(std::move(init_data.dem)) { + : 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 != decode_result_type::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", @@ -132,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 { @@ -163,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; } @@ -181,10 +156,13 @@ class chromobius : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( chromobius, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_init inputs, + std::optional output, 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), + 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 661306d80..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,9 +22,20 @@ 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_init inputs, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : 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 (requested_output != decode_result_type::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`. // The loop below sets err_sig[r] = '1' (not XOR-toggle), so canonicalize @@ -41,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)}; @@ -77,10 +88,12 @@ 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_init inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, - params); + return std::make_unique( + 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 f1e26d8e2..33f321dd3 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 edges; + std::vector observable_bits; // Helper function to make a canonical edge from two nodes. std::pair make_canonical_edge(int64_t node1, @@ -50,18 +51,39 @@ 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(const cudaq::qec::sparse_binary_matrix &H, + pymatching(cudaq::qec::decoder_init inputs, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : 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 == decode_result_type::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"); } @@ -97,39 +119,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"); @@ -148,12 +155,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); @@ -166,6 +177,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); + 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 @@ -176,17 +190,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); @@ -195,41 +212,33 @@ 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); + edges.clear(); pm::decode_detection_events_to_edges(*mwpm, detection_events, 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)); 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] += @@ -245,6 +254,7 @@ class pymatching : public decoder { std::chrono::duration_cast(t3 - t0).count() / 1e6; #endif + result.converged = true; return result; } @@ -259,9 +269,12 @@ class pymatching : public decoder { CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( pymatching, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_init inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return std::make_unique( + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; @@ -278,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 675b25fd9..06550a947 100644 --- a/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp +++ b/libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp @@ -123,20 +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 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 is created with the same H passed to the trt_decoder constructor. -/// - "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. +/// 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 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_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, /// not both. @@ -148,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"); +} + +decode_result_type natural_trt_output(trt_engine_output_format format) { + return format == trt_engine_output_format::errors + ? decode_result_type::errors + : decode_result_type::observables; +} + +decode_result_type trt_emitted_output(trt_engine_output_format format, + decode_result_type requested_output) { + if (format == trt_engine_output_format::errors) + return decode_result_type::errors; + if (format == trt_engine_output_format::residual_detectors) + return requested_output; + return decode_result_type::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; } @@ -412,29 +462,32 @@ 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_; + decode_result_type emitted_output_; size_t num_observables_ = 0; public: - trt_decoder(const cudaq::qec::sparse_binary_matrix &H, + trt_decoder(cudaq::qec::decoder_init inputs, + decode_result_type requested_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(); CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( trt_decoder, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, + cudaq::qec::decoder_init inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, 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: @@ -535,9 +588,32 @@ 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_init inputs, + decode_result_type requested_output, + trt_engine_output_format engine_output_format, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : decoder(std::move(inputs), requested_output), + engine_output_format_(engine_output_format), + emitted_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) && + 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"); + + // 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_ == 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 " + "observable output, but its model supplies no observable mapping"); impl_ = std::make_unique(); @@ -755,46 +831,38 @@ trt_decoder::trt_decoder(const cudaq::qec::sparse_binary_matrix &H, // 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()) { - global_decoder_ = - decoder::get(global_decoder_name, H, 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 global_output = + engine_output_format_ == + trt_engine_output_format::observables_and_residual_detectors + ? decode_result_type::observables + : requested_output; + global_decoder_ = decoder::get(global_decoder_name, + get_inputs().decoder_init_without_d(), + global_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::pcm_to_sparse_vec(O)); - 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 (" + @@ -816,11 +884,43 @@ trt_decoder::trt_decoder(const cudaq::qec::sparse_binary_matrix &H, ") 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 @@ -902,6 +1002,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_result_type()) + 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; } @@ -911,9 +1022,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 { @@ -999,19 +1112,45 @@ std::vector trt_decoder::decode_batch_impl( std::vector global_results = global_decoder_->decode_batch(residual_soft); - if (decode_to_observables_) { + // 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 " + + 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 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 + // 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 = (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)); @@ -1021,10 +1160,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; @@ -1109,6 +1248,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 8795c1bc4..78ad62571 100644 --- a/libs/qec/lib/decoders/sliding_window.cpp +++ b/libs/qec/lib/decoders/sliding_window.cpp @@ -16,6 +16,18 @@ namespace cudaq::qec { +namespace { + +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 + // needs special-casing here. + return inputs.canonicalize_H(); +} + +} // namespace + void sliding_window::validate_inputs() { uint32_t num_rows = H.num_rows(); if (num_boundary_syndromes > num_syndromes_per_round) @@ -108,11 +120,22 @@ 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_init inputs, + 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. - : decoder(H.canonicalize().to_csc()) { + : decoder(canonicalize_sliding_window_inputs(std::move(inputs)), + 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 (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 " + "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); @@ -124,8 +147,7 @@ sliding_window::sliding_window(const cudaq::qec::sparse_binary_matrix &H, 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( @@ -148,6 +170,16 @@ sliding_window::sliding_window(const cudaq::qec::sparse_binary_matrix &H, 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 @@ -161,12 +193,9 @@ sliding_window::sliding_window(const cudaq::qec::sparse_binary_matrix &H, 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 window 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 = {}", @@ -180,8 +209,22 @@ sliding_window::sliding_window(const cudaq::qec::sparse_binary_matrix &H, last_column - first_column + 1, H_round.shape()[1])); } + auto inner_O = sparse_binary_matrix::from_csr( + 0, H_round.shape()[1], std::vector{0}, {}); + std::optional> inner_error_ids; + if (const auto &ids = get_inputs().error_ids()) + inner_error_ids = std::vector( + ids->begin() + first_column, ids->begin() + last_column + 1); + // 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_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, H_round, inner_decoder_params_mod); + decoder::get(inner_decoder_name, std::move(inner_inputs), + decode_result_type::errors, inner_decoder_params); inner_decoders.push_back(std::move(inner_decoder)); } } @@ -254,6 +297,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_result_type() == decode_result_type::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; } @@ -421,7 +478,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}, @@ -448,9 +504,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 49c1af7e7..826c63c93 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. @@ -41,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. @@ -104,7 +107,8 @@ 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_init inputs, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms); /// @brief Decode a syndrome vector @@ -141,9 +145,12 @@ 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_init inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return std::make_unique( + std::move(inputs), output.value_or(decode_result_type::errors), + params); }) }; diff --git a/libs/qec/lib/detector_error_model.cpp b/libs/qec/lib/detector_error_model.cpp index 4300c2bd6..03a019f3e 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 "sparse_dem_from_stim_text.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 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.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/lib/experiments.cpp b/libs/qec/lib/experiments.cpp index 35a762d0a..499e86f7c 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; @@ -513,6 +515,32 @@ std::vector d_sparse(const cudaq::M2DSparseMatrix &m2d) { 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() || + 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); +} + decoder_context decoder_context_from_memory_circuit(const code &code, operation statePrep, std::size_t numRounds, diff --git a/libs/qec/lib/realtime/config.cpp b/libs/qec/lib/realtime/config.cpp index 3cfaddf07..e006dc025 100644 --- a/libs/qec/lib/realtime/config.cpp +++ b/libs/qec/lib/realtime/config.cpp @@ -281,60 +281,16 @@ 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_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}); + io.mapOptional("H_sparse", config.H_sparse); + io.mapOptional("O_sparse", config.O_sparse); io.mapRequired("D_sparse", config.D_sparse); - - // Validate that the number of rows in the H_sparse vector is equal to - // syndrome_size. - auto num_H_rows = - std::count(config.H_sparse.begin(), config.H_sparse.end(), -1); - if (num_H_rows != config.syndrome_size) { - 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)); - } - } - - // 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)); - } - } - } + io.mapOptional("error_rate_vec", config.error_rate_vec); // Convert decoder_custom_args through the schema registered for this // decoder type. When no schema is registered, the key is intentionally @@ -605,12 +561,16 @@ 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}}}, {"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"}}}, }; @@ -683,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)}, @@ -733,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 @@ -793,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 2b01fb997..ba69601ca 100644 --- a/libs/qec/lib/realtime/decoding-server-cqr/DecodingServer.cpp +++ b/libs/qec/lib/realtime/decoding-server-cqr/DecodingServer.cpp @@ -12,6 +12,8 @@ #include "cudaq/qec/logger.h" #include "cudaq/qec/realtime/decoding_config.h" +#include +#include #include #include #include @@ -84,7 +86,9 @@ 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()); const auto dispatch = registry_.required_dispatch(); // device_graph must run on the GPU the FPGA/NIC is affine to; when exactly diff --git a/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp b/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.cpp index d15130717..42f3475e7 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 @@ -30,11 +31,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) + @@ -57,7 +63,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_init(dc, base_dir)); // dc.dispatch (host / device_graph) is not consulted here: host sessions // are served inline by the CQR HOST_CALL plugin on the dispatcher // thread; the decoding_server process binds device_graph sessions to diff --git a/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.h b/libs/qec/lib/realtime/decoding-server-cqr/SessionRegistry.h index 15a3c00e8..d5d24db28 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 63baa2774..4ca3c733a 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 @@ -159,98 +161,213 @@ 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()); + return params; +} - if (decoder_config.O_sparse.empty()) - return params; +namespace { - const auto num_observables = std::count(decoder_config.O_sparse.begin(), - decoder_config.O_sparse.end(), -1); - if (num_observables == 0) - return params; +/// 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(); +} - auto O = cudaq::qec::pcm_from_sparse_vec( - decoder_config.O_sparse, num_observables, decoder_config.block_size); - params.insert("O", O); +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)); +} - // 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); +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( + fmt::format("D_sparse row is empty for decoder {}", id)); +} + +} // namespace + +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()) + 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()); + + // 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_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. + 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; } - return params; + // 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, + decoder_config.block_size); + const auto num_observables = std::count(decoder_config.O_sparse.begin(), + 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_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) { + const cudaq::qec::decoding::config::decoder_config &decoder_config, + 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: " + std::to_string(decoder_config.id)); - if (decoder_config.D_sparse.empty()) - throw std::runtime_error( - "D_sparse must be provided in decoder configuration"); auto t0 = std::chrono::high_resolution_clock::now(); CUDA_QEC_INFO("Creating decoder {} of type {}", decoder_config.id, decoder_config.type); - auto pcm = cudaq::qec::pcm_from_sparse_vec(decoder_config.H_sparse, - decoder_config.syndrome_size, - 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( - decoder_config.O_sparse, num_observables, decoder_config.block_size); - auto decoder = cudaq::qec::get_decoder( - decoder_config.type, pcm, prepare_decoder_params(decoder_config)); + auto decoder = + cudaq::qec::get_decoder(decoder_config.type, std::move(inputs), + cudaq::qec::decode_result_type::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); // 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; @@ -270,9 +387,19 @@ 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. + 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. @@ -340,12 +467,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_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] = + 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()); @@ -353,6 +513,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..57f532869 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_init +resolve_decoder_init( + 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_init 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/lib/sparse_dem_from_stim_text.h b/libs/qec/lib/sparse_dem_from_stim_text.h new file mode 100644 index 000000000..330168084 --- /dev/null +++ b/libs/qec/lib/sparse_dem_from_stim_text.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_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. +// +// 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_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 { + /// 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 +sparse_dem_from_stim_text(const std::string &dem_text); + +} // namespace cudaq::qec::details diff --git a/libs/qec/python/bindings/py_decoder.cpp b/libs/qec/python/bindings/py_decoder.cpp index 3426bc919..3338b485f 100644 --- a/libs/qec/python/bindings/py_decoder.cpp +++ b/libs/qec/python/bindings/py_decoder.cpp @@ -195,24 +195,27 @@ 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 { - // 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(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. + 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))); + }()), + decode_result_type::errors) {} decoder_result decode(const std::vector &syndrome) override { - NB_OVERRIDE_PURE(decode, syndrome); + NB_OVERRIDE_PURE_NAME("decode", decode, syndrome); } }; @@ -458,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 decode_result_type::errors; + if (value == "observables") + return decode_result_type::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. @@ -899,7 +917,12 @@ void bindDecoder(nb::module_ &mod) { return PyDecoderRegistry::get_decoder(name, H_obj, options); } - return get_decoder(name, decoder_init{dem_text}, hetMapFromKwargs(options)); + const auto output = pop_requested_output(options); + 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), + hetMapFromKwargs(options)); }; qecmod.def( @@ -926,6 +949,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 = decode_result_type::errors; + else if (value == "observables") + output = decode_result_type::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. " @@ -933,7 +990,12 @@ void bindDecoder(nb::module_ &mod) { " pip install cudaq-qec[tensor-network-decoder]\n"); } - return get_decoder(name, H_sparse, hetMapFromKwargs(options)); + 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), + hetMapFromKwargs(options)); }, R"pbdoc( Get a decoder by name. @@ -950,6 +1012,11 @@ void bindDecoder(nb::module_ &mod) { 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_init; + 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..1926e80bd 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)) @@ -187,19 +238,25 @@ 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) .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 +267,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 " @@ -247,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/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 1c3921173..34c238df6 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 @@ -53,7 +54,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 +90,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 +131,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 +144,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 +230,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 +241,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 +276,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 +291,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 +340,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 +404,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 +438,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 +474,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 +576,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, } @@ -609,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} @@ -636,9 +646,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 +677,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 +703,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, @@ -732,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() @@ -743,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_init. + + +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 e8d15dd5d..d1a39cf6c 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() @@ -442,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) @@ -491,6 +498,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 +629,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 +643,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 +676,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 @@ -798,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() @@ -848,7 +916,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 +928,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] @@ -893,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/python/tests/test_dem.py b/libs/qec/python/tests/test_dem.py index b158c1ecb..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() @@ -820,6 +822,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/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/CMakeLists.txt b/libs/qec/unittests/CMakeLists.txt index 886ede5e7..0a53731c4 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) @@ -128,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/backend-specific/stim/test_qec_stim.cpp b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp index e8235ce9b..a3e4f13c6 100644 --- a/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp +++ b/libs/qec/unittests/backend-specific/stim/test_qec_stim.cpp @@ -451,9 +451,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 = @@ -545,9 +546,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(); @@ -615,29 +617,31 @@ 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(); + auto inputs = ctx.full_component(); + 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()); + 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); + // 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. @@ -683,21 +687,24 @@ 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(); + const auto &fc_dem = fc_inputs.dem; 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(); + 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()); - EXPECT_EQ(zc_m2d.rows.size(), zc_dem.num_detectors()); + EXPECT_EQ(zc_inputs.m2d.rows.size(), zc_dem.num_detectors()); } TEST(QECCodeTester, checkDemFromMemoryCircuit) { @@ -944,10 +951,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; @@ -957,7 +963,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; @@ -1106,9 +1111,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_init{dem}, + shor9_sliding_params(num_layers, interior, numBoundary)); expectObservablesMatchFullDecoder( dem, *full, [&](const std::vector &syndrome) { @@ -1150,9 +1154,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_init{dem}, + shor9_sliding_params(/*window_size=*/2, interior, numBoundary)); expectObservablesMatchFullDecoder( dem, *full, [&](const std::vector &syndrome) { @@ -1247,16 +1250,24 @@ 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); + // 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_init{ + 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/chromobius/test_chromobius.cpp b/libs/qec/unittests/decoders/chromobius/test_chromobius.cpp index ee387d44b..7d5b130cf 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_result_type(), + cudaq::qec::decode_result_type::observables); + 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 0cef4e657..ee0c5c716 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_init(std::move(sparse_H), std::move(O)), + cudaq::qec::decode_result_type::observables, params); + } ASSERT_NE(d, nullptr) << strategy; auto result = d->decode(std::vector{1.0}); ASSERT_TRUE(result.converged) << strategy; @@ -176,6 +186,41 @@ 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}; + 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_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); + 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 +228,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_init(cudaq::qec::sparse_binary_matrix(H), + cudaq::qec::sparse_binary_matrix(O)), + std::invalid_argument); } TEST(PyMatchingDecoder, DecodesHighObservableIndicesAcrossPaths) { @@ -203,9 +247,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_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_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..d6f0b2ede 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_init( + 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; } @@ -173,9 +184,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 0d357b5d7..a98c9015c 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(const cudaq::qec::sparse_binary_matrix &H, + sample_decoder(cudaq::qec::decoder_init inputs, + decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { - // 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), 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 == decode_result_type::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_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()); + result.result = std::move(observables); + } return result; } @@ -42,9 +52,12 @@ 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_init inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + return std::make_unique( + 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 694e313cd..f3128e16d 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 @@ -40,6 +42,17 @@ cudaqx::tensor make_identity_h(std::size_t n) { return H; } +decoder_init +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_init(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 +125,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 +136,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 +148,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 +169,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 +298,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), + decode_result_type::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create TRT decoder: " << e.what(); } @@ -374,10 +394,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), + decode_result_type::observables, params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create TRT decoder: " << e.what(); } @@ -433,12 +456,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), + decode_result_type::observables, params_cuda_graph); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create CUDA graph decoder: " << e.what(); } @@ -448,12 +474,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), + decode_result_type::observables, params_traditional); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to create traditional decoder: " << e.what(); } @@ -554,6 +583,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 +604,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 +612,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), + decode_result_type::observables, build_params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to build TRT decoder: " << e.what(); } @@ -589,10 +622,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), + decode_result_type::observables, load_params); } catch (const std::exception &e) { GTEST_SKIP() << "Failed to load TRT decoder: " << e.what(); } @@ -619,6 +655,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 +690,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 +719,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 +742,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 +751,95 @@ 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; + // 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); +} + +// 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_init::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 { - trt_decoder = decoder::get("trt_decoder", make_identity_h(2), params); + composite = decoder::get("trt_decoder", inputs, + decode_result_type::observables, params); } catch (const std::exception &e) { - GTEST_SKIP() << "Failed to create mismatch TRT decoder: " << e.what(); + GTEST_SKIP() << "TensorRT engine build unavailable: " << 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}}), + 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_init(sparse_binary_matrix(H), + sparse_binary_matrix(O)), + decode_result_type::observables, params), std::runtime_error); } @@ -747,15 +859,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_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 d084a8e8a..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,16 +86,16 @@ 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_init inputs, + decode_result_type requested_output, const cudaqx::heterogeneous_map &) - : decoder(H) { + : decoder(std::move(inputs), requested_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,8 +114,11 @@ 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_init inputs, std::optional output, + const cudaqx::heterogeneous_map ¶ms) { + return std::make_unique( + std::move(inputs), output.value_or(decode_result_type::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 a5717f5c3..bc1909227 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 { @@ -325,6 +323,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"; @@ -336,15 +335,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 1b29e1025..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 @@ -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) { @@ -327,24 +327,32 @@ void save_dem_to_file( const auto &inputs = (decoder_type == "nv-qldpc-decoder") ? bp_inputs[i] : matching_inputs[i]; - const auto &edem = inputs.dem; 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. - 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.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; // 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) { @@ -370,7 +378,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; @@ -380,6 +387,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"); @@ -394,20 +403,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) @@ -416,13 +423,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); @@ -975,8 +980,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; @@ -989,7 +994,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,19 +1008,19 @@ 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; + 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/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_graph_decode_test/qldpc_config_loader.cpp b/libs/qec/unittests/realtime/qec_graph_decode_test/qldpc_config_loader.cpp index 921f812ed..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 @@ -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_init( + 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/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 4448329d1..81e0186c7 100644 --- a/libs/qec/unittests/realtime/test_decoding_server.cpp +++ b/libs/qec/unittests/realtime/test_decoding_server.cpp @@ -428,9 +428,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"; } } @@ -484,9 +484,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"; } } @@ -532,9 +532,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; @@ -575,9 +575,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..88d9cd947 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,25 @@ 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_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"; 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::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 5a94035ab..ff14c1a80 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_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; } @@ -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_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; } -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[]) { @@ -601,9 +510,8 @@ int main(int argc, char *argv[]) { 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"; + cudaq::qec::decode_result_type::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 7efd9e218..6b5b6aa5a 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_init.h" #include "cudaq/qec/detector_error_model.h" #include "cudaq/qec/pcm_utils.h" #include @@ -21,6 +22,47 @@ #include namespace { +class decoder_init_probe final : public cudaq::qec::decoder { +public: + explicit decoder_init_probe(cudaq::qec::decoder_init inputs) + : decoder(std::move(inputs), cudaq::qec::decode_result_type::errors) {} + + cudaq::qec::decoder_result + decode(const std::vector &) override { + return {true, std::vector(block_size, 0.0)}; + } + + 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 { +public: + 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) {} + + 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_init inputs, + std::optional output, + const cudaqx::heterogeneous_map ¶ms) { + return std::make_unique( + std::move(inputs), + output.value_or(cudaq::qec::decode_result_type::observables), + params); + }) +}; + +CUDAQ_EXT_PT_REGISTER_TYPE(observable_output_probe) + class ScopedEnv { public: ScopedEnv(const char *name, const char *value) : name(name) { @@ -42,6 +84,160 @@ 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_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); + 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})); + + // 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 + // supply either, so they cannot disagree with it. + EXPECT_EQ(decoder->get_num_observables(), 3); + EXPECT_EQ(decoder->get_num_msyn_per_decode(), 5); +} + +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}}); + auto D = matrix::from_nested_csr(2, 5, {{0, 1}, {2, 3}}); + + 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. + EXPECT_EQ(decoder.get_num_observables(), 3); + 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); + + // 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) { + const std::string dem_text = "error(0.1) D0 L0\n" + "error(0.2) D1\n"; + + 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()); + 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_init(H, matrix::from_nested_csr(1, 2, {{0}}), + {0.1, 0.2, 0.3}), + std::invalid_argument); + 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_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_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_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_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); + + // Canonicalization preserves column identity and ordering, so it keeps it. + 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_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()); + EXPECT_THROW((void)reindexed.stim_dem(), std::logic_error); +} + +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_init(std::move(H), std::move(O)), + cudaq::qec::decode_result_type::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); + EXPECT_EQ(observables.result, std::vector({1.0})); + + auto error_decoder = cudaq::qec::get_decoder( + "single_error_lut", + 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( + 1, 2, std::vector>{{0}})), + cudaq::qec::decode_result_type::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; @@ -193,7 +389,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_init( + 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 @@ -205,14 +410,10 @@ 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}); - EXPECT_EQ(decoder->get_num_observables(), 1u); + EXPECT_EQ(decoder->get_num_observables(), 3u); // Three measurement bits fill the D buffer and trigger a decode. std::vector msyn = {1, 0, 1}; @@ -497,14 +698,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_init(std::move(sliding_H), std::move(sliding_O), + simplified_weights), + sliding_window_params); // Create some random syndromes. const int num_syndromes = 1000; @@ -649,11 +855,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_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()); } @@ -671,11 +882,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_init(std::move(H), std::move(O), + std::vector(pcm.shape()[1], 0.1)), + params); ASSERT_NE(decoder, nullptr); cudaq::qec::decoder_result last_result; @@ -926,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_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 +// 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_init::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 @@ -1085,24 +1341,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); - - // 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}}); + 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", + // D maps the two enqueued syndrome bits directly to two detector bits. + 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); bool did_decode = dec->enqueue_syndrome(std::vector{1, 0}); EXPECT_TRUE(did_decode); @@ -1118,12 +1375,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); - - dec->set_D_sparse(std::vector>{{0}, {1}}); - dec->set_O_sparse(std::vector>{{0}, {1}}); + 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_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); // Shot 1: obs[0]=1, obs[1]=0 -> corrections become [1, 0] EXPECT_TRUE(dec->enqueue_syndrome(std::vector{1, 0})); @@ -1144,22 +1404,23 @@ 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); - - 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. + 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_init( + std::move(H), std::move(O), /*error_rates=*/{}, + cudaq::qec::sparse_binary_matrix::from_nested_csr(3, 3, + {{0}, {1}, {2}})), + 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); } @@ -1179,14 +1440,26 @@ 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}); + // 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_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)), + cudaq::qec::decode_result_type::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}; EXPECT_FALSE(decoder->enqueue_syndrome(first_round)) @@ -1203,7 +1476,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; @@ -1215,7 +1487,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_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) { EXPECT_NE(std::string(e.what()).find(needle), std::string::npos) @@ -1264,9 +1543,10 @@ 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_init inputs, + cudaq::qec::decode_result_type requested_output, const cudaqx::heterogeneous_map ¶ms) - : decoder(H) { + : decoder(std::move(inputs), requested_output) { auto invalid = cudaq::qec::validate_config_parameters(params, {"decode_to_obs"}); if (!invalid.empty()) @@ -1281,10 +1561,14 @@ class strict_keys_decoder : public cudaq::qec::decoder { return r; } CUDAQ_EXTENSION_CUSTOM_CREATOR_FUNCTION( - strict_keys_decoder, static std::unique_ptr create( - const cudaq::qec::decoder_init &init, - const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, params); + strict_keys_decoder, + static std::unique_ptr create( + cudaq::qec::decoder_init inputs, + std::optional output, + const cudaqx::heterogeneous_map ¶ms) { + return std::make_unique( + std::move(inputs), + output.value_or(cudaq::qec::decode_result_type::errors), params); }) }; CUDAQ_EXT_PT_REGISTER_TYPE(strict_keys_decoder) @@ -1299,9 +1583,10 @@ 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_init inputs, + cudaq::qec::decode_result_type requested_output, const cudaqx::heterogeneous_map &) - : decoder(H) {} + : decoder(std::move(inputs), requested_output) {} cudaq::qec::decoder_result decode(const std::vector &) override { int dev = -1; @@ -1316,10 +1601,12 @@ 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_init inputs, + std::optional output, const cudaqx::heterogeneous_map ¶ms) { - return cudaq::qec::make_pcm_decoder(init, - params); + return std::make_unique( + std::move(inputs), + 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 111f9e588..a6f3e11fe 100644 --- a/libs/qec/unittests/test_decoders_yaml.cpp +++ b/libs/qec/unittests/test_decoders_yaml.cpp @@ -21,8 +21,133 @@ #include #include #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. +/// 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; + 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; + + static inline captured_model model; +}; + +class d_capture_decoder : public decoder { +public: + 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(); + 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; + if (D) { + 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 { + 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_init inputs, std::optional output, + const cudaqx::heterogeneous_map ¶ms) { + return std::make_unique( + std::move(inputs), output.value_or(decode_result_type::observables), + params); + }) +}; + +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) { @@ -162,6 +287,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 +295,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 +480,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 +489,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 +524,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 +578,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 +601,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 +635,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 +656,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 +680,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 +717,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 +751,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 +773,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 +784,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 +792,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); } @@ -689,7 +814,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_init( + config, std::filesystem::current_path())); ASSERT_NE(decoder, nullptr); EXPECT_EQ(decoder->get_decoder_id(), 7u); @@ -697,11 +824,567 @@ 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, cudaq::qec::decoding::host::resolve_decoder_init( + parsed, std::filesystem::current_path())); + 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); +} + +// --- 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_init( + 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_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_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_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_init( + 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_init(matching, cwd)); + + auto wrong_detectors = make_dem_config(dem.path()); + wrong_detectors.syndrome_size = 99; + EXPECT_THROW( + 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_init(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_init( + config, dem.path().parent_path())); + EXPECT_THROW(cudaq::qec::decoding::host::resolve_decoder_init( + 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_init( + 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_init( + 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, 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; + + 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(); +} + +// 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_init( + 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_init 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::decode_result_type::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_init( + config, std::filesystem::current_path()); + 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)); + EXPECT_EQ(result.result.size(), config.block_size); +} + +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_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_init( + 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_init( + 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_init( + 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_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. + 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_init( + config, std::filesystem::current_path())), std::runtime_error); } @@ -710,7 +1393,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_init( + config, std::filesystem::current_path())), std::invalid_argument); } @@ -1090,10 +1775,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 +1798,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 +1820,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 +1954,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 4900c3b2d..2e8df382a 100644 --- a/libs/qec/unittests/test_decoding_server_core.cpp +++ b/libs/qec/unittests/test_decoding_server_core.cpp @@ -37,14 +37,20 @@ 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})) { - 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_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}), + /*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::decode_result_type::errors) {} cudaq::qec::decoder_result decode(const std::vector &syndrome) override { @@ -213,11 +219,20 @@ 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})) { - set_O_sparse(std::vector>{{0}}); - set_D_sparse(std::vector>{{0, 1}}); + : decoder( + 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}), + /*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::decode_result_type::errors) { cuda_device_id_ = 1 << 20; } cudaq::qec::decoder_result 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; 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 a56404b8d..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 @@ -66,6 +66,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}; @@ -218,13 +245,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_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_init( + cudaq::qec::sparse_binary_matrix(H_tensor), + sparse_matrix_from_flat_rows(dec.O_sparse, num_observable_rows), + /*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);