From 5ef80a1b3e92809a13fdf0f08077463149a7efda Mon Sep 17 00:00:00 2001 From: Noah Shutty Date: Fri, 21 Aug 2026 20:13:49 -0700 Subject: [PATCH 1/3] Fix detector traversal order semantics --- README.md | 2 + src/common.cc | 25 ++++++- src/common.h | 4 +- src/common.test.cc | 25 +++++++ src/py/README.md | 4 +- src/py/tesseract_test.py | 25 +++++++ src/py/utils_test.py | 84 ++++++++++++++++++++++-- src/tesseract.cc | 81 +++++++++++++++-------- src/tesseract.h | 11 ++-- src/tesseract.pybind.h | 12 ++-- src/tesseract.test.cc | 128 ++++++++++++++++++++++++++++++++++++ src/utils.cc | 138 +++++++++++++++++++++++---------------- src/utils.h | 14 +++- src/utils.pybind.h | 23 ++++--- 14 files changed, 462 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 15331c43..ff8362cc 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,8 @@ tesseract_config = tesseract.TesseractConfig( ``` `DetIndex` is the default detector ordering. You can also pass `DetBFS` or `DetCoordinate` explicitly. +Detector orders are complete detector-ID permutations in traversal order: +`order[position] = detector_id`. These values balance decoding speed and accuracy across the benchmarks reported in the paper and can be adjusted for specific use cases. The Sinter decoder dictionary also provides sparsified variants: diff --git a/src/common.cc b/src/common.cc index 3a0cce01..67ced03f 100644 --- a/src/common.cc +++ b/src/common.cc @@ -36,6 +36,19 @@ std::string vector_to_string(const std::vector& vec) { return ss.str(); } +void preserve_dem_index_spaces(stim::DetectorErrorModel& dem, size_t num_detectors, + size_t num_observables) { + if (dem.count_detectors() < num_detectors) { + const std::vector no_coordinates; + dem.append_detector_instruction( + no_coordinates, stim::DemTarget::relative_detector_id(num_detectors - 1), /*tag=*/""); + } + if (dem.count_observables() < num_observables) { + dem.append_logical_observable_instruction(stim::DemTarget::observable_id(num_observables - 1), + /*tag=*/""); + } +} + } // namespace namespace tesseract_decoder { @@ -138,6 +151,9 @@ stim::DetectorErrorModel common::flatten(const stim::DetectorErrorModel& dem) { stim::DetectorErrorModel common::merge_indistinguishable_errors( const stim::DetectorErrorModel& dem, std::vector& error_index_map) { + const stim::DetectorErrorModel flat_dem = flatten(dem); + const size_t num_detectors = flat_dem.count_detectors(); + const size_t num_observables = flat_dem.count_observables(); stim::DetectorErrorModel out_dem; error_index_map.clear(); @@ -146,7 +162,7 @@ stim::DetectorErrorModel common::merge_indistinguishable_errors( std::unordered_map merged_index_by_symptom; std::vector merged_errors; - for (const stim::DemInstruction& instruction : flatten(dem).instructions) { + for (const stim::DemInstruction& instruction : flat_dem.instructions) { switch (instruction.type) { case stim::DemInstructionType::DEM_ERROR: { Error error(instruction); @@ -186,15 +202,19 @@ stim::DetectorErrorModel common::merge_indistinguishable_errors( error.symptom.as_dem_instruction_targets(), /*tag=*/""); } + preserve_dem_index_spaces(out_dem, num_detectors, num_observables); return out_dem; } stim::DetectorErrorModel common::remove_zero_probability_errors( const stim::DetectorErrorModel& dem, std::vector& error_index_map) { + const stim::DetectorErrorModel flat_dem = flatten(dem); + const size_t num_detectors = flat_dem.count_detectors(); + const size_t num_observables = flat_dem.count_observables(); stim::DetectorErrorModel out_dem; error_index_map.clear(); size_t output_error_index = 0; - for (const stim::DemInstruction& instruction : flatten(dem).instructions) { + for (const stim::DemInstruction& instruction : flat_dem.instructions) { switch (instruction.type) { case stim::DemInstructionType::DEM_ERROR: if (instruction.arg_data[0] > 0) { @@ -214,6 +234,7 @@ stim::DetectorErrorModel common::remove_zero_probability_errors( throw std::invalid_argument("Unrecognized instruction type: " + instruction.str()); } } + preserve_dem_index_spaces(out_dem, num_detectors, num_observables); return out_dem; } diff --git a/src/common.h b/src/common.h index 3524cb34..191eb685 100644 --- a/src/common.h +++ b/src/common.h @@ -79,14 +79,14 @@ bool is_flat(const stim::DetectorErrorModel& dem); stim::DetectorErrorModel flatten(const stim::DetectorErrorModel& dem); // Makes a new (flattened) dem where identical error mechanisms have been -// merged. +// merged, while preserving detector and observable counts. // `error_index_map[old_error_index]` gives the corresponding merged DEM error // index in the returned DEM. stim::DetectorErrorModel merge_indistinguishable_errors(const stim::DetectorErrorModel& dem, std::vector& error_index_map); // Returns a copy of the given error model with any zero-probability DEM_ERROR -// instructions removed. +// instructions removed, while preserving detector and observable counts. // `error_index_map[old_error_index]` gives the corresponding retained DEM error // index in the returned DEM, or `std::numeric_limits::max()` if the // error was removed. diff --git a/src/common.test.cc b/src/common.test.cc index 70eebd97..6d0fe06f 100644 --- a/src/common.test.cc +++ b/src/common.test.cc @@ -14,6 +14,8 @@ #include "common.h" +#include + #include "gtest/gtest.h" #include "stim.h" @@ -112,6 +114,29 @@ TEST(common, RemoveZeroProbabilityErrors) { EXPECT_NEAR(flat.instructions[1].arg_data[0], 0.2, 1e-9); } +TEST(common, RemoveZeroProbabilityErrorsPreservesIndexSpaces) { + stim::DetectorErrorModel dem("error(0) D2 L1"); + + std::vector error_index_map; + stim::DetectorErrorModel cleaned = common::remove_zero_probability_errors(dem, error_index_map); + + EXPECT_EQ(cleaned.count_errors(), 0); + EXPECT_EQ(cleaned.count_detectors(), 3); + EXPECT_EQ(cleaned.count_observables(), 2); + EXPECT_EQ(error_index_map, (std::vector{std::numeric_limits::max()})); +} + +TEST(common, MergeIndistinguishableErrorsPreservesIndexSpaces) { + stim::DetectorErrorModel dem("error(0.1) D2 D2 L1 L1"); + + std::vector error_index_map; + stim::DetectorErrorModel merged = common::merge_indistinguishable_errors(dem, error_index_map); + + EXPECT_EQ(merged.count_errors(), 1); + EXPECT_EQ(merged.count_detectors(), 3); + EXPECT_EQ(merged.count_observables(), 2); +} + // Helper function to compare the two methods. void assert_merged_probabilities_are_equal(double p1, double p2) { // Merge probabilities using the exclusive OR formula. diff --git a/src/py/README.md b/src/py/README.md index 658932a8..04fd13e1 100644 --- a/src/py/README.md +++ b/src/py/README.md @@ -17,7 +17,7 @@ Explanation of configuration arguments: * `verbose` - A boolean flag that, when `True`, enables verbose logging. This is useful for debugging and understanding the decoder's internal behavior, as it will print information about the search process. * `merge_errors` - A boolean flag that, when `True`, merges error channels with identical syndrome patterns before decoding. This is enabled by default. * `pqlimit` - An integer that sets a limit on the number of nodes in the priority queue. This can be used to constrain the memory usage of the decoder. The default value is `200000`. -* `det_orders` - A list of lists of integers, where each inner list represents an ordering of the detectors. This is used for "ensemble reordering," an optimization that tries different detector orderings to improve the search's convergence. The default is an empty list, meaning a single, fixed ordering is used. +* `det_orders` - A list of complete detector-ID permutations in traversal order: `order[position] = detector_id`. This is used for "ensemble reordering," an optimization that tries different detector orderings to improve the search's convergence. The default is an empty list, meaning a single, fixed ordering is used. * `det_penalty` - A floating-point value that adds a cost for each residual detection event. This encourages the decoder to prioritize paths that resolve more detection events, steering the search towards more complete solutions. The default value is `0.0`, meaning no penalty is applied. * `create_visualization` - A boolean flag that enables decoder visualization output when set to `True`. The default value is `False`. * `sparsify_errors` - Enables per-shot sparse error activation. When enabled, all errors up to `sparsify_base_degree` are always active, and selected higher-degree errors are reactivated per shot. @@ -286,7 +286,7 @@ The `tesseract_decoder.utils` module provides various helper functions used thro #### Functions * `utils.get_detector_coords(dem: stim.DetectorErrorModel) -> list[list[float]]` - * Extracts 3D coordinates for each detector from a `stim.DetectorErrorModel`. + * Extracts arbitrary-dimensional coordinates indexed by detector ID from a `stim.DetectorErrorModel`. Missing detector coordinates are returned as empty lists. **Example Usage**: diff --git a/src/py/tesseract_test.py b/src/py/tesseract_test.py index 70d52158..619c3bf0 100644 --- a/src/py/tesseract_test.py +++ b/src/py/tesseract_test.py @@ -35,6 +35,31 @@ """) +@pytest.mark.parametrize( + "detector_order, message", + [ + ([0], "has size"), + ([0, 0], "more than once"), + ([0, 2], "out-of-range detector ID"), + ], +) +def test_detector_orders_must_be_permutations(detector_order, message): + config = tesseract_decoder.tesseract.TesseractConfig( + _DETECTOR_ERROR_MODEL, det_orders=[detector_order] + ) + with pytest.raises(ValueError, match=message): + config.compile_decoder() + + +def test_selected_detector_order_index_must_be_in_range(): + config = tesseract_decoder.tesseract.TesseractConfig( + _DETECTOR_ERROR_MODEL, det_orders=[[1, 0]] + ) + decoder = config.compile_decoder() + with pytest.raises(IndexError, match="Detector order index 1"): + decoder.decode_to_errors(np.zeros(2, dtype=bool), 1, 0) + + def test_create_tesseract_config(): config = tesseract_decoder.tesseract.TesseractConfig(_DETECTOR_ERROR_MODEL) assert config.dem == _DETECTOR_ERROR_MODEL diff --git a/src/py/utils_test.py b/src/py/utils_test.py index 6e953dda..99c9e0b5 100644 --- a/src/py/utils_test.py +++ b/src/py/utils_test.py @@ -45,6 +45,20 @@ def test_build_detector_graph(): ] +def test_build_detector_graph_uses_positive_parity_reduced_symptoms(): + dem = stim.DetectorErrorModel(""" + error(0) D0 D1 + error(0.1) D0 D0 D1 + error(0.2) D1 D2 D3 + """) + assert tesseract_decoder.utils.build_detector_graph(dem) == [ + [], + [2, 3], + [1, 3], + [1, 2], + ] + + def test_build_det_orders_default_index(): res = tesseract_decoder.utils.build_det_orders( _DETECTOR_ERROR_MODEL_10, num_det_orders=1, seed=0 @@ -55,21 +69,79 @@ def test_build_det_orders_default_index(): def test_build_det_orders_bfs(): + path_dem = stim.DetectorErrorModel(""" + error(0.1) D0 D4 + error(0.1) D4 D1 + error(0.1) D1 D3 + error(0.1) D3 D2 + """) + graph = tesseract_decoder.utils.build_detector_graph(path_dem) + orders = tesseract_decoder.utils.build_det_orders( + path_dem, + num_det_orders=16, + method=tesseract_decoder.utils.DetOrder.DetBFS, + seed=0, + ) + for order in orders: + assert sorted(order) == list(range(5)) + distance = [None] * len(graph) + distance[order[0]] = 0 + frontier = [order[0]] + for detector in frontier: + for neighbor in graph[detector]: + if distance[neighbor] is None: + distance[neighbor] = distance[detector] + 1 + frontier.append(neighbor) + assert [distance[detector] for detector in order] == sorted( + distance[detector] for detector in order + ) + + +def test_build_det_orders_bfs_empty_dem(): assert tesseract_decoder.utils.build_det_orders( - _DETECTOR_ERROR_MODEL, - num_det_orders=1, + stim.DetectorErrorModel(), + num_det_orders=3, method=tesseract_decoder.utils.DetOrder.DetBFS, seed=0, - ) == [[0, 1]] + ) == [[], [], []] def test_build_det_orders_coordinate(): - assert tesseract_decoder.utils.build_det_orders( - _DETECTOR_ERROR_MODEL, + dem = stim.DetectorErrorModel(""" + detector(2) D3 + detector(0) D0 + detector(3) D1 + detector(1) D2 + """) + order = tesseract_decoder.utils.build_det_orders( + dem, + num_det_orders=1, + method=tesseract_decoder.utils.DetOrder.DetCoordinate, + seed=0, + )[0] + assert order in ([0, 2, 3, 1], [1, 3, 2, 0]) + + +def test_detector_coords_are_keyed_and_allow_missing_or_short_coordinates(): + dem = stim.DetectorErrorModel(""" + detector(2, 20) D2 + detector(0) D0 + detector(99) D2 + error(0.1) D3 + """) + assert tesseract_decoder.utils.get_detector_coords(dem) == [ + [0], + [], + [2, 20], + [], + ] + order = tesseract_decoder.utils.build_det_orders( + dem, num_det_orders=1, method=tesseract_decoder.utils.DetOrder.DetCoordinate, seed=0, - ) == [[0, 1]] + )[0] + assert order[2:] == [1, 3] def test_build_det_orders_index(): diff --git a/src/tesseract.cc b/src/tesseract.cc index 9ed11507..21e32c6b 100644 --- a/src/tesseract.cc +++ b/src/tesseract.cc @@ -68,6 +68,36 @@ int suggest_sparsify_reactivate_limit_capped(size_t num_detectors, int sparsify_ return static_cast(rounded); } +void validate_detector_orders(const std::vector>& detector_orders, + size_t num_detectors) { + for (size_t order_index = 0; order_index < detector_orders.size(); ++order_index) { + const std::vector& detector_at_position = detector_orders[order_index]; + if (detector_at_position.size() != num_detectors) { + throw std::invalid_argument("Detector order " + std::to_string(order_index) + " has size " + + std::to_string(detector_at_position.size()) + + ", but the detector error model has " + + std::to_string(num_detectors) + " detectors."); + } + + std::vector seen(num_detectors, false); + for (size_t position = 0; position < detector_at_position.size(); ++position) { + const size_t detector = detector_at_position[position]; + if (detector >= num_detectors) { + throw std::invalid_argument("Detector order " + std::to_string(order_index) + + " contains out-of-range detector ID " + + std::to_string(detector) + " at position " + + std::to_string(position) + "."); + } + if (seen[detector]) { + throw std::invalid_argument("Detector order " + std::to_string(order_index) + + " contains detector ID " + std::to_string(detector) + + " more than once."); + } + seen[detector] = true; + } + } +} + }; // namespace namespace std { @@ -175,17 +205,8 @@ TesseractDecoder::TesseractDecoder(TesseractConfig config_) : config(std::move(c if (config.det_orders.empty()) { config.det_orders.emplace_back(config.dem.count_detectors()); std::iota(config.det_orders[0].begin(), config.det_orders[0].end(), 0); - } else { - for (size_t i = 0; i < config.det_orders.size(); ++i) { - if (config.det_orders[i].size() != config.dem.count_detectors()) { - throw std::invalid_argument( - "Each detector order list must have a size equal to the number of detectors."); - } - } - } - if (config.det_orders.empty()) { - throw std::runtime_error("After initialization, detector orders list must not be empty."); } + validate_detector_orders(config.det_orders, config.dem.count_detectors()); errors = get_errors_from_dem(config.dem); if (config.verbose) { for (auto& error : errors) { @@ -306,36 +327,36 @@ void TesseractDecoder::decode_to_errors(const std::vector& detections) if (config.beam_climbing) { int beam = 0; - int detector_order = 0; + int order_index = 0; for (int trial = 0; trial < std::max(config.det_beam + 1, int(config.det_orders.size())); ++trial) { - decode_to_errors_with_graph(detections, detector_order, beam, active_d2e); + decode_to_errors_with_graph(detections, order_index, beam, active_d2e); double local_cost = cost_from_errors(predicted_errors_buffer); if (!low_confidence_flag && local_cost < best_cost) { best_errors = predicted_errors_buffer; best_cost = local_cost; } if (config.verbose) { - std::cout << "for detector_order " << detector_order << " beam " << beam + std::cout << "for detector_order " << order_index << " beam " << beam << " got low confidence " << low_confidence_flag << " and cost " << local_cost << " and obs_mask " << get_flipped_observables(predicted_errors_buffer) << ". Best cost so far: " << best_cost << std::endl; } beam += 1; - detector_order += 1; + order_index += 1; beam %= (config.det_beam + 1); - detector_order %= config.det_orders.size(); + order_index %= config.det_orders.size(); } } else { - for (size_t detector_order = 0; detector_order < config.det_orders.size(); ++detector_order) { - decode_to_errors_with_graph(detections, detector_order, config.det_beam, active_d2e); + for (size_t order_index = 0; order_index < config.det_orders.size(); ++order_index) { + decode_to_errors_with_graph(detections, order_index, config.det_beam, active_d2e); double local_cost = cost_from_errors(predicted_errors_buffer); if (!low_confidence_flag && local_cost < best_cost) { best_errors = predicted_errors_buffer; best_cost = local_cost; } if (config.verbose) { - std::cout << "for detector_order " << detector_order << " beam " << config.det_beam + std::cout << "for detector_order " << order_index << " beam " << config.det_beam << " got low confidence " << low_confidence_flag << " and cost " << local_cost << " and obs_mask " << get_flipped_observables(predicted_errors_buffer) << ". Best cost so far: " << best_cost << std::endl; @@ -347,7 +368,7 @@ void TesseractDecoder::decode_to_errors(const std::vector& detections) } void TesseractDecoder::flip_detectors_and_block_errors( - size_t detector_order, int64_t error_chain_idx, boost::dynamic_bitset<>& detectors, + size_t detector_order_index, int64_t error_chain_idx, boost::dynamic_bitset<>& detectors, std::vector& detector_cost_tuples, const std::vector>& active_d2e) const { int64_t walker_idx = error_chain_idx; @@ -369,17 +390,24 @@ void TesseractDecoder::flip_detectors_and_block_errors( } void TesseractDecoder::decode_to_errors(const std::vector& detections, - size_t detector_order, size_t detector_beam) { + size_t detector_order_index, size_t detector_beam) { if (config.sparsify_errors) { build_sparse_d2e(detections); } const auto& active_d2e = config.sparsify_errors ? sparse_d2e : d2e; - decode_to_errors_with_graph(detections, detector_order, detector_beam, active_d2e); + decode_to_errors_with_graph(detections, detector_order_index, detector_beam, active_d2e); } void TesseractDecoder::decode_to_errors_with_graph( - const std::vector& detections, size_t detector_order, size_t detector_beam, + const std::vector& detections, size_t detector_order_index, size_t detector_beam, const std::vector>& active_d2e) { + if (detector_order_index >= config.det_orders.size()) { + throw std::out_of_range("Detector order index " + std::to_string(detector_order_index) + + " is out of range for " + std::to_string(config.det_orders.size()) + + " detector orders."); + } + const std::vector& detector_at_position = config.det_orders[detector_order_index]; + predicted_errors_buffer.clear(); low_confidence_flag = false; error_chain_arena.clear(); @@ -435,7 +463,7 @@ void TesseractDecoder::decode_to_errors_with_graph( boost::dynamic_bitset<> detectors = initial_detectors; std::vector detector_cost_tuples(num_errors); - flip_detectors_and_block_errors(detector_order, node.error_chain_idx, detectors, + flip_detectors_and_block_errors(detector_order_index, node.error_chain_idx, detectors, detector_cost_tuples, active_d2e); if (node.num_dets == 0) { @@ -520,9 +548,10 @@ void TesseractDecoder::decode_to_errors_with_graph( next_detector_cost_tuples = detector_cost_tuples; size_t min_detector = std::numeric_limits::max(); - for (size_t d = 0; d < num_detectors; ++d) { - if (detectors[config.det_orders[detector_order][d]]) { - min_detector = config.det_orders[detector_order][d]; + for (size_t position = 0; position < num_detectors; ++position) { + const size_t detector = detector_at_position[position]; + if (detectors[detector]) { + min_detector = detector; break; } } diff --git a/src/tesseract.h b/src/tesseract.h index d06e47e1..b835daf5 100644 --- a/src/tesseract.h +++ b/src/tesseract.h @@ -45,6 +45,9 @@ struct TesseractConfig { bool verbose = false; bool merge_errors = true; size_t pqlimit = DEFAULT_PQLIMIT; + + // Detector traversal permutations. Each order uses the convention + // det_orders[order_index][position] = detector_id. std::vector> det_orders; double det_penalty = 0; bool create_visualization = false; @@ -91,7 +94,7 @@ struct TesseractDecoder { // Clears the predicted_errors_buffer and fills it with the decoded errors for // these detection events, using a specified detector ordering index. - void decode_to_errors(const std::vector& detections, size_t detector_order, + void decode_to_errors(const std::vector& detections, size_t detector_order_index, size_t detector_beam); // Returns the bitwise XOR of the observables flipped by the errors in the given array, indexed by @@ -134,15 +137,15 @@ struct TesseractDecoder { double get_detcost(size_t d, const std::vector& detector_cost_tuples) const; double get_detcost(size_t d, const std::vector& detector_cost_tuples, const std::vector>& active_d2e) const; - void flip_detectors_and_block_errors(size_t detector_order, int64_t error_chain_idx, + void flip_detectors_and_block_errors(size_t detector_order_index, int64_t error_chain_idx, boost::dynamic_bitset<>& detectors, std::vector& detector_cost_tuples, const std::vector>& active_d2e) const; private: void build_sparse_d2e(const std::vector& detections); - void decode_to_errors_with_graph(const std::vector& detections, size_t detector_order, - size_t detector_beam, + void decode_to_errors_with_graph(const std::vector& detections, + size_t detector_order_index, size_t detector_beam, const std::vector>& active_d2e); }; diff --git a/src/tesseract.pybind.h b/src/tesseract.pybind.h index 2ebf892a..2508a948 100644 --- a/src/tesseract.pybind.h +++ b/src/tesseract.pybind.h @@ -126,8 +126,9 @@ void add_tesseract_module(py::module& root) { pqlimit : int, default=max_size_t The maximum size of the priority queue. det_orders : list[list[int]], default=empty - A list of detector orderings to use for decoding. If empty, the decoder - will generate its own orderings. + Detector traversal permutations to use for decoding. Each inner list + gives detector IDs in traversal order and must contain every detector + exactly once. If empty, the decoder generates its own ordering. det_penalty : float, default=0.0 A penalty value added to the cost of each detector visited. create_visualization: bool, defualt=False @@ -169,8 +170,9 @@ void add_tesseract_module(py::module& root) { pqlimit : int, default=max_size_t The maximum size of the priority queue. det_orders : list[list[int]], default=empty - A list of detector orderings to use for decoding. If empty, the decoder - will generate its own orderings. + Detector traversal permutations to use for decoding. Each inner list + gives detector IDs in traversal order and must contain every detector + exactly once. If empty, the decoder generates its own ordering. det_penalty : float, default=0.0 A penalty value added to the cost of each detector visited. create_visualization: bool, defualt=False @@ -200,7 +202,7 @@ void add_tesseract_module(py::module& root) { .def_readwrite("pqlimit", &TesseractConfig::pqlimit, "The maximum size of the priority queue.") .def_readwrite("det_orders", &TesseractConfig::det_orders, - "A list of pre-specified detector orderings.") + "Detector-ID permutations in traversal order: order[position] = detector_id.") .def_readwrite("det_penalty", &TesseractConfig::det_penalty, "The penalty cost added for each detector.") .def_readwrite("create_visualization", &TesseractConfig::create_visualization, diff --git a/src/tesseract.test.cc b/src/tesseract.test.cc index 4bda92d9..4795789c 100644 --- a/src/tesseract.test.cc +++ b/src/tesseract.test.cc @@ -16,6 +16,7 @@ #include #include +#include #include #include "gtest/gtest.h" @@ -563,5 +564,132 @@ TEST(tesseract, MoreThan64Observables) { } } +TEST(utils, DetectorGraphUsesPositiveParityReducedSymptoms) { + stim::DetectorErrorModel dem(R"DEM( + error(0) D0 D1 + error(0.1) D0 D0 D1 + error(0.2) D1 D2 D3 + )DEM"); + + EXPECT_EQ(build_detector_graph(dem), + (std::vector>{{}, {2, 3}, {1, 3}, {1, 2}})); +} + +TEST(utils, EmptyDemHasEmptyBfsOrders) { + stim::DetectorErrorModel dem; + EXPECT_EQ(build_det_orders(dem, 3, DetOrder::DetBFS, 0), std::vector>(3)); +} + +TEST(utils, PreprocessingPreservesAllDetectorOrderEntries) { + stim::DetectorErrorModel dem("error(0) D2\nerror(0.1) D3 D3"); + TesseractConfig config{dem}; + config.det_orders = build_det_orders(dem, 1, DetOrder::DetBFS, 0); + + TesseractDecoder decoder(config); + EXPECT_EQ(decoder.num_detectors, 4); + EXPECT_EQ(decoder.config.det_orders[0].size(), 4); +} + +TEST(utils, BfsOrdersContainDetectorsInTraversalOrder) { + // A path with scrambled detector IDs: D0--D4--D1--D3--D2. The inverse of a + // traversal of this path is not itself a BFS traversal. + stim::DetectorErrorModel dem(R"DEM( + error(0.1) D0 D4 + error(0.1) D4 D1 + error(0.1) D1 D3 + error(0.1) D3 D2 + )DEM"); + const auto graph = build_detector_graph(dem); + const auto orders = build_det_orders(dem, 16, DetOrder::DetBFS, 0); + + for (const auto& detector_at_position : orders) { + ASSERT_EQ(detector_at_position.size(), graph.size()); + std::vector sorted_order = detector_at_position; + std::sort(sorted_order.begin(), sorted_order.end()); + EXPECT_EQ(sorted_order, (std::vector{0, 1, 2, 3, 4})); + + std::vector distance(graph.size(), std::numeric_limits::max()); + std::queue queue; + distance[detector_at_position[0]] = 0; + queue.push(detector_at_position[0]); + while (!queue.empty()) { + const size_t detector = queue.front(); + queue.pop(); + for (size_t neighbor : graph[detector]) { + if (distance[neighbor] == std::numeric_limits::max()) { + distance[neighbor] = distance[detector] + 1; + queue.push(neighbor); + } + } + } + for (size_t position = 1; position < detector_at_position.size(); ++position) { + EXPECT_LE(distance[detector_at_position[position - 1]], + distance[detector_at_position[position]]); + } + } +} + +TEST(utils, CoordinateOrdersContainDetectorsInProjectionOrder) { + // Coordinates are intentionally declared out of detector-ID order. A 1D + // projection can only produce the coordinate-sorted order or its reverse. + stim::DetectorErrorModel dem(R"DEM( + detector(2) D3 + detector(0) D0 + detector(3) D1 + detector(1) D2 + )DEM"); + + const auto order = build_det_orders(dem, 1, DetOrder::DetCoordinate, 0)[0]; + EXPECT_TRUE(order == (std::vector{0, 2, 3, 1}) || + order == (std::vector{1, 3, 2, 0})); +} + +TEST(utils, DetectorCoordinatesAreKeyedAndAllowMissingOrShortCoordinates) { + stim::DetectorErrorModel dem(R"DEM( + detector(2, 20) D2 + detector(0) D0 + detector(99) D2 + error(0.1) D3 + )DEM"); + + const auto coords = get_detector_coords(dem); + ASSERT_EQ(coords.size(), 4); + EXPECT_EQ(coords[0], (std::vector{0})); + EXPECT_TRUE(coords[1].empty()); + EXPECT_EQ(coords[2], (std::vector{2, 20})); + EXPECT_TRUE(coords[3].empty()); + + const auto order = build_det_orders(dem, 1, DetOrder::DetCoordinate, 0)[0]; + ASSERT_EQ(order.size(), 4); + EXPECT_EQ(order[2], 1); + EXPECT_EQ(order[3], 3); +} + +TEST(tesseract, DetectorOrdersMustBePermutations) { + stim::DetectorErrorModel dem("error(0.1) D0 D1 D2"); + + TesseractConfig valid_config{dem}; + valid_config.det_orders = {{2, 0, 1}}; + EXPECT_NO_THROW({ TesseractDecoder decoder(valid_config); }); + + TesseractConfig wrong_size_config{dem}; + wrong_size_config.det_orders = {{0, 1}}; + EXPECT_THROW({ TesseractDecoder decoder(wrong_size_config); }, std::invalid_argument); + + TesseractConfig duplicate_config{dem}; + duplicate_config.det_orders = {{0, 0, 2}}; + EXPECT_THROW({ TesseractDecoder decoder(duplicate_config); }, std::invalid_argument); + + TesseractConfig out_of_range_config{dem}; + out_of_range_config.det_orders = {{0, 1, 3}}; + EXPECT_THROW({ TesseractDecoder decoder(out_of_range_config); }, std::invalid_argument); +} + +TEST(tesseract, SelectedDetectorOrderIndexMustBeInRange) { + stim::DetectorErrorModel dem("error(0.1) D0"); + TesseractDecoder decoder(TesseractConfig{dem}); + EXPECT_THROW(decoder.decode_to_errors({}, 1, 0), std::out_of_range); +} + } // namespace } // namespace tesseract_decoder diff --git a/src/utils.cc b/src/utils.cc index 58659e02..6a48edb3 100644 --- a/src/utils.cc +++ b/src/utils.cc @@ -23,6 +23,7 @@ #include #include #include +#include #include "common.h" #include "stim.h" @@ -30,7 +31,10 @@ namespace tesseract_decoder { std::vector> get_detector_coords(const stim::DetectorErrorModel& dem) { - std::vector> detector_coords; + const size_t num_detectors = dem.count_detectors(); + std::vector> detector_coords(num_detectors); + std::vector detector_has_coordinate_instruction(num_detectors, false); + bool has_any_detector_coordinate_instruction = false; for (const stim::DemInstruction& instruction : common::flatten(dem).instructions) { switch (instruction.type) { case stim::DemInstructionType::DEM_SHIFT_DETECTORS: @@ -40,11 +44,18 @@ std::vector> get_detector_coords(const stim::DetectorErrorMo break; } case stim::DemInstructionType::DEM_DETECTOR: { - std::vector coord; - for (const double& t : instruction.arg_data) { - coord.push_back(t); + has_any_detector_coordinate_instruction = true; + const std::vector coord(instruction.arg_data.begin(), instruction.arg_data.end()); + for (const stim::DemTarget& target : instruction.target_data) { + if (!target.is_relative_detector_id()) { + continue; + } + const size_t detector = target.val(); + if (detector < num_detectors && !detector_has_coordinate_instruction[detector]) { + detector_coords[detector] = coord; + detector_has_coordinate_instruction[detector] = true; + } } - detector_coords.push_back(coord); break; } case stim::DemInstructionType::DEM_LOGICAL_OBSERVABLE: @@ -54,22 +65,19 @@ std::vector> get_detector_coords(const stim::DetectorErrorMo "Unexpected DemInstructionType found in the detector error model."); } } - return detector_coords; + return has_any_detector_coordinate_instruction ? detector_coords + : std::vector>{}; } std::vector> build_detector_graph(const stim::DetectorErrorModel& dem) { size_t num_detectors = dem.count_detectors(); std::vector> neighbors(num_detectors); for (const stim::DemInstruction& instruction : common::flatten(dem).instructions) { - if (instruction.type != stim::DemInstructionType::DEM_ERROR) { + if (instruction.type != stim::DemInstructionType::DEM_ERROR || instruction.arg_data[0] <= 0) { continue; } - std::vector dets; - for (const stim::DemTarget& target : instruction.target_data) { - if (target.is_relative_detector_id()) { - dets.push_back(target.val()); - } - } + const common::Error error(instruction); + const std::vector& dets = error.symptom.detectors; for (size_t i = 0; i < dets.size(); ++i) { for (size_t j = i + 1; j < dets.size(); ++j) { size_t a = dets[i]; @@ -91,19 +99,35 @@ static std::vector> build_det_orders_bfs(const stim::Detecto std::mt19937_64& rng) { std::vector> det_orders(num_det_orders); auto graph = build_detector_graph(dem); - std::uniform_int_distribution dist_det(0, graph.size() - 1); - for (size_t det_order = 0; det_order < num_det_orders; ++det_order) { - std::vector perm; - perm.reserve(graph.size()); + if (graph.empty()) { + return det_orders; + } + for (size_t order_index = 0; order_index < num_det_orders; ++order_index) { + std::vector detector_at_position; + detector_at_position.reserve(graph.size()); std::vector visited(graph.size(), false); + std::vector unvisited_detectors(graph.size()); + std::vector unvisited_position(graph.size()); + std::iota(unvisited_detectors.begin(), unvisited_detectors.end(), 0); + std::iota(unvisited_position.begin(), unvisited_position.end(), 0); + + auto mark_visited = [&](size_t detector) { + visited[detector] = true; + const size_t position = unvisited_position[detector]; + const size_t last_detector = unvisited_detectors.back(); + unvisited_detectors[position] = last_detector; + unvisited_position[last_detector] = position; + unvisited_detectors.pop_back(); + }; + std::queue q; - size_t start = dist_det(rng); - while (perm.size() < graph.size()) { - if (!visited[start]) { - visited[start] = true; - q.push(start); - perm.push_back(start); - } + while (!unvisited_detectors.empty()) { + std::uniform_int_distribution dist_root(0, unvisited_detectors.size() - 1); + const size_t start = unvisited_detectors[dist_root(rng)]; + mark_visited(start); + q.push(start); + detector_at_position.push_back(start); + while (!q.empty()) { size_t cur = q.front(); q.pop(); @@ -111,23 +135,14 @@ static std::vector> build_det_orders_bfs(const stim::Detecto std::shuffle(neigh.begin(), neigh.end(), rng); for (size_t n : neigh) { if (!visited[n]) { - visited[n] = true; + mark_visited(n); q.push(n); - perm.push_back(n); + detector_at_position.push_back(n); } } } - if (perm.size() < graph.size()) { - do { - start = dist_det(rng); - } while (visited[start]); - } - } - std::vector inv_perm(graph.size()); - for (size_t i = 0; i < perm.size(); ++i) { - inv_perm[perm[i]] = i; } - det_orders[det_order] = inv_perm; + det_orders[order_index] = std::move(detector_at_position); } return det_orders; } @@ -138,34 +153,45 @@ static std::vector> build_det_orders_coordinate( auto detector_coords = get_detector_coords(dem); std::vector inner_products(dem.count_detectors()); std::normal_distribution dist(0, 1); - if (detector_coords.empty() || detector_coords.at(0).empty()) { - for (size_t det_order = 0; det_order < num_det_orders; ++det_order) { - det_orders[det_order].resize(dem.count_detectors()); - std::iota(det_orders[det_order].begin(), det_orders[det_order].end(), 0); + size_t num_coordinate_dimensions = 0; + for (const auto& coord : detector_coords) { + num_coordinate_dimensions = std::max(num_coordinate_dimensions, coord.size()); + } + if (num_coordinate_dimensions == 0) { + for (size_t order_index = 0; order_index < num_det_orders; ++order_index) { + det_orders[order_index].resize(dem.count_detectors()); + std::iota(det_orders[order_index].begin(), det_orders[order_index].end(), 0); } return det_orders; } - for (size_t det_order = 0; det_order < num_det_orders; ++det_order) { + for (size_t order_index = 0; order_index < num_det_orders; ++order_index) { std::vector orientation_vector; - for (size_t i = 0; i < detector_coords.at(0).size(); ++i) { + orientation_vector.reserve(num_coordinate_dimensions); + for (size_t i = 0; i < num_coordinate_dimensions; ++i) { orientation_vector.push_back(dist(rng)); } for (size_t i = 0; i < detector_coords.size(); ++i) { inner_products[i] = 0; - for (size_t j = 0; j < orientation_vector.size(); ++j) { + for (size_t j = 0; j < detector_coords[i].size(); ++j) { inner_products[i] += detector_coords[i][j] * orientation_vector[j]; } } - std::vector perm(dem.count_detectors()); - std::iota(perm.begin(), perm.end(), 0); - std::sort(perm.begin(), perm.end(), [&](const size_t& i, const size_t& j) { - return inner_products[i] > inner_products[j]; - }); - std::vector inv_perm(dem.count_detectors()); - for (size_t i = 0; i < perm.size(); ++i) { - inv_perm[perm[i]] = i; + std::vector detector_at_position; + detector_at_position.reserve(dem.count_detectors()); + for (size_t detector = 0; detector < detector_coords.size(); ++detector) { + if (!detector_coords[detector].empty()) { + detector_at_position.push_back(detector); + } + } + std::stable_sort( + detector_at_position.begin(), detector_at_position.end(), + [&](const size_t& i, const size_t& j) { return inner_products[i] > inner_products[j]; }); + for (size_t detector = 0; detector < detector_coords.size(); ++detector) { + if (detector_coords[detector].empty()) { + detector_at_position.push_back(detector); + } } - det_orders[det_order] = inv_perm; + det_orders[order_index] = std::move(detector_at_position); } return det_orders; } @@ -176,14 +202,14 @@ static std::vector> build_det_orders_index(const stim::Detec std::vector> det_orders(num_det_orders); std::uniform_int_distribution dist_bool(0, 1); size_t n = dem.count_detectors(); - for (size_t det_order = 0; det_order < num_det_orders; ++det_order) { - det_orders[det_order].resize(n); + for (size_t order_index = 0; order_index < num_det_orders; ++order_index) { + det_orders[order_index].resize(n); if (dist_bool(rng)) { for (size_t i = 0; i < n; ++i) { - det_orders[det_order][i] = n - 1 - i; + det_orders[order_index][i] = n - 1 - i; } } else { - std::iota(det_orders[det_order].begin(), det_orders[det_order].end(), 0); + std::iota(det_orders[order_index].begin(), det_orders[order_index].end(), 0); } } return det_orders; diff --git a/src/utils.h b/src/utils.h index ad6932fb..fe4d219b 100644 --- a/src/utils.h +++ b/src/utils.h @@ -33,14 +33,24 @@ namespace tesseract_decoder { constexpr const double EPSILON = 1e-7; +// Returns detector coordinates keyed by detector ID. If the DEM contains any +// detector coordinate instructions, the returned vector has one entry per +// detector and an empty entry for each detector without declared coordinates. +// Returns an empty vector if the DEM has no detector coordinate instructions. std::vector> get_detector_coords(const stim::DetectorErrorModel& dem); -// Builds an adjacency list graph where two detectors share an edge iff an error -// in the model activates them both. +// Builds an adjacency list graph where each positive-probability error's +// parity-reduced detector symptom induces a clique. std::vector> build_detector_graph(const stim::DetectorErrorModel& dem); enum class DetOrder { DetBFS, DetIndex, DetCoordinate }; +// Builds detector traversal orders. Each inner vector uses the convention +// detector_at_position[position] = detector_id and is a permutation of all +// detector IDs in the DEM. Coordinate ordering projects all declared +// coordinate dimensions, treating missing trailing dimensions as zero and +// placing detectors without coordinates last. Seeded randomized orders are +// reproducible only within a fixed C++ standard-library implementation. std::vector> build_det_orders(const stim::DetectorErrorModel& dem, size_t num_det_orders, DetOrder method = DetOrder::DetIndex, diff --git a/src/utils.pybind.h b/src/utils.pybind.h index 8ea9564e..5167c5b2 100644 --- a/src/utils.pybind.h +++ b/src/utils.pybind.h @@ -57,8 +57,10 @@ void add_utils_module(py::module& root) { Returns ------- list[list[float]] - A list where each inner list contains the 3D coordinates - [x, y, z] of a detector. + If any detector coordinates are declared, returns one entry per + detector, indexed by detector ID. Missing coordinates are empty + lists and coordinate vectors may have any dimensionality. Returns + an empty list if the model declares no detector coordinates. )pbdoc"); m.def( "build_detector_graph", @@ -82,8 +84,8 @@ void add_utils_module(py::module& root) { An adjacency list representation of the detector graph. Each inner list contains the indices of detectors connected to the detector at the corresponding index. - Here we say that two detectors are connected if there exists at - least one error in the DEM which flips both detectors. + Each positive-probability error's parity-reduced detector symptom + induces a clique in the graph. )pbdoc"); m.def( "build_det_orders", @@ -104,16 +106,19 @@ void add_utils_module(py::module& root) { method : tesseract_decoder.utils.DetOrder, default=tesseract_decoder.utils.DetOrder.DetIndex Strategy for ordering detectors. ``DetIndex`` chooses either increasing or decreasing detector index order at random, ``DetBFS`` performs a - breadth-first traversal, and ``DetCoordinate`` uses randomized - geometric orientations. + breadth-first traversal, and ``DetCoordinate`` projects every declared + coordinate dimension onto randomized orientations and places detectors + without coordinates last. seed : int, default=0 - A seed for the random number generator. + A seed for the random number generator. Exact randomized orders + are reproducible only with a fixed C++ standard library and + toolchain. Returns ------- list[list[int]] - A list of detector orderings. Each inner list maps a detector index - to its position in the ordering. + A list of detector traversal permutations. Each inner list gives + detector IDs in traversal order: ``order[position] = detector_id``. )pbdoc"); m.def( "get_errors_from_dem", From 9d9415bbc8c09856b98d387df3fd316a4d60a38d Mon Sep 17 00:00:00 2001 From: Noah Shutty Date: Sat, 22 Aug 2026 10:23:07 -0700 Subject: [PATCH 2/3] Add behavioral detector-order regression test --- src/tesseract.test.cc | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/tesseract.test.cc b/src/tesseract.test.cc index 4795789c..c0296801 100644 --- a/src/tesseract.test.cc +++ b/src/tesseract.test.cc @@ -644,6 +644,57 @@ TEST(utils, CoordinateOrdersContainDetectorsInProjectionOrder) { order == (std::vector{1, 3, 2, 0})); } +TEST(tesseract, CoordinateOrderBuilderAndDecoderUseSameTraversalConvention) { + // Sorting by coordinate produces either [D0, D2, D3, D1] or its reverse. + // At beam 0, both traversals find the lower-cost correction {E1, E3}, which + // generated this symptom and flips L0. Before the representation fix, + // build_det_orders returned the inverse rank map instead; interpreted as a + // traversal, either inverse confidently chooses {E0, E2} and misses L0. + stim::DetectorErrorModel dem(R"DEM( + detector(0) D0 + detector(3) D1 + detector(1) D2 + detector(2) D3 + error(0.10) D1 + error(0.30) D0 D1 D2 L0 + error(0.12) D2 D3 + error(0.08) D0 D3 + )DEM"); + + const auto detector_at_position = + build_det_orders(dem, 1, DetOrder::DetCoordinate, 0)[0]; + std::vector legacy_position_of_detector(detector_at_position.size()); + for (size_t position = 0; position < detector_at_position.size(); ++position) { + legacy_position_of_detector[detector_at_position[position]] = position; + } + + TesseractConfig corrected_config{dem}; + corrected_config.det_beam = 0; + corrected_config.merge_errors = false; + corrected_config.det_orders = {detector_at_position}; + TesseractDecoder corrected_decoder(corrected_config); + corrected_decoder.decode_to_errors({1, 2, 3}); + EXPECT_FALSE(corrected_decoder.low_confidence_flag); + auto corrected_errors = corrected_decoder.predicted_errors_buffer; + std::sort(corrected_errors.begin(), corrected_errors.end()); + EXPECT_EQ(corrected_errors, (std::vector{1, 3})); + EXPECT_EQ(corrected_decoder.get_flipped_observables(corrected_decoder.predicted_errors_buffer), + (std::vector{0})); + + TesseractConfig legacy_config = corrected_config; + legacy_config.det_orders = {legacy_position_of_detector}; + TesseractDecoder legacy_decoder(legacy_config); + legacy_decoder.decode_to_errors({1, 2, 3}); + EXPECT_FALSE(legacy_decoder.low_confidence_flag); + auto legacy_errors = legacy_decoder.predicted_errors_buffer; + std::sort(legacy_errors.begin(), legacy_errors.end()); + EXPECT_EQ(legacy_errors, (std::vector{0, 2})); + EXPECT_TRUE( + legacy_decoder.get_flipped_observables(legacy_decoder.predicted_errors_buffer).empty()); + EXPECT_LT(corrected_decoder.cost_from_errors(corrected_decoder.predicted_errors_buffer), + legacy_decoder.cost_from_errors(legacy_decoder.predicted_errors_buffer)); +} + TEST(utils, DetectorCoordinatesAreKeyedAndAllowMissingOrShortCoordinates) { stim::DetectorErrorModel dem(R"DEM( detector(2, 20) D2 From 2a6b86a509ce3e6172625800da0724e6a0c09973 Mon Sep 17 00:00:00 2001 From: Noah Shutty Date: Sat, 22 Aug 2026 14:56:23 -0700 Subject: [PATCH 3/3] Apply clang-format to detector-order test --- src/tesseract.test.cc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tesseract.test.cc b/src/tesseract.test.cc index c0296801..ca5bbf1e 100644 --- a/src/tesseract.test.cc +++ b/src/tesseract.test.cc @@ -661,8 +661,7 @@ TEST(tesseract, CoordinateOrderBuilderAndDecoderUseSameTraversalConvention) { error(0.08) D0 D3 )DEM"); - const auto detector_at_position = - build_det_orders(dem, 1, DetOrder::DetCoordinate, 0)[0]; + const auto detector_at_position = build_det_orders(dem, 1, DetOrder::DetCoordinate, 0)[0]; std::vector legacy_position_of_detector(detector_at_position.size()); for (size_t position = 0; position < detector_at_position.size(); ++position) { legacy_position_of_detector[detector_at_position[position]] = position;